Files

537 lines
14 KiB
Go

package handlers
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// DeviceCodeRequest is the request to start device authorization
type DeviceCodeRequest struct {
DeviceName string `json:"device_name"`
}
// DeviceCodeResponse is the response with user and device codes
type DeviceCodeResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// DeviceCodeHandler handles POST /auth/device/code
type DeviceCodeHandler struct {
BaseUIHandler
AppViewBaseURL string // e.g., "http://localhost:5000"
}
func (h *DeviceCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req DeviceCodeRequest
if err := render.Decode(r, &req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
// Default device name if not provided
if req.DeviceName == "" {
req.DeviceName = "Unknown Device"
}
// Get client IP
ip := getClientIP(r)
// Get user agent
userAgent := r.UserAgent()
// Create pending authorization
pending, err := h.DeviceStore.CreatePendingAuth(req.DeviceName, ip, userAgent)
if err != nil {
http.Error(w, "failed to create authorization", http.StatusInternalServerError)
return
}
// Return device code info
resp := DeviceCodeResponse{
DeviceCode: pending.DeviceCode,
UserCode: pending.UserCode,
VerificationURI: h.AppViewBaseURL + "/device",
ExpiresIn: 600, // 10 minutes
Interval: 5, // Poll every 5 seconds
}
render.JSON(w, r, resp)
}
// DeviceTokenRequest is the request to poll for device authorization
type DeviceTokenRequest struct {
DeviceCode string `json:"device_code"`
}
// DeviceTokenResponse is the response with device secret or error
type DeviceTokenResponse struct {
DeviceSecret string `json:"device_secret,omitempty"`
Handle string `json:"handle,omitempty"`
DID string `json:"did,omitempty"`
Error string `json:"error,omitempty"`
}
// DeviceTokenHandler handles POST /auth/device/token
type DeviceTokenHandler struct {
BaseUIHandler
}
func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req DeviceTokenRequest
if err := render.Decode(r, &req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
// Get pending authorization
pending, ok := h.DeviceStore.GetPendingByDeviceCode(req.DeviceCode)
if !ok {
resp := DeviceTokenResponse{
Error: "expired_token",
}
render.JSON(w, r, resp)
return
}
// Check if approved
if pending.ApprovedDID == nil || *pending.ApprovedDID == "" {
// Still pending
resp := DeviceTokenResponse{
Error: "authorization_pending",
}
render.JSON(w, r, resp)
return
}
// Approved! Get device from store to find handle
devices := h.DeviceStore.ListDevices(*pending.ApprovedDID)
var handle string
for _, d := range devices {
if d.DID == *pending.ApprovedDID {
handle = d.Handle
break
}
}
// Return device secret
resp := DeviceTokenResponse{
DeviceSecret: *pending.DeviceSecret,
Handle: handle,
DID: *pending.ApprovedDID,
}
render.JSON(w, r, resp)
}
// DeviceApprovalPageHandler handles GET /device
type DeviceApprovalPageHandler struct {
BaseUIHandler
}
func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Check if user is logged in
sessionID, ok := db.GetSessionID(r)
if !ok {
// Not logged in - redirect to login with return URL
// Explicitly build the return URL with query parameters
returnTo := r.URL.Path
if r.URL.RawQuery != "" {
returnTo = r.URL.Path + "?" + r.URL.RawQuery
}
http.SetCookie(w, &http.Cookie{
Name: "oauth_return_to",
Value: returnTo,
Path: "/",
MaxAge: 600, // 10 minutes
HttpOnly: true,
})
http.Redirect(w, r, "/auth/oauth/login?return_to="+url.QueryEscape(returnTo), http.StatusFound)
return
}
sess, ok := h.SessionStore.Get(sessionID)
if !ok {
// Invalid session - explicitly build return URL with query parameters
returnTo := r.URL.Path
if r.URL.RawQuery != "" {
returnTo = r.URL.Path + "?" + r.URL.RawQuery
}
http.SetCookie(w, &http.Cookie{
Name: "oauth_return_to",
Value: returnTo,
Path: "/",
MaxAge: 600,
HttpOnly: true,
})
http.Redirect(w, r, "/auth/oauth/login?return_to="+url.QueryEscape(returnTo), http.StatusFound)
return
}
// Get user code from query
userCode := r.URL.Query().Get("user_code")
if userCode == "" {
http.Error(w, "user_code required", http.StatusBadRequest)
return
}
// Get pending authorization
pending, ok := h.DeviceStore.GetPendingByUserCode(userCode)
if !ok {
h.renderError(w, r, "That authorization code has expired or doesn't exist. Start a fresh `docker login` from your terminal to get a new one.")
return
}
// Check if already approved
if pending.ApprovedDID != nil && *pending.ApprovedDID != "" {
h.renderSuccess(w, r, pending.DeviceName)
return
}
// Render approval page
h.renderApprovalPage(w, r, sess, pending)
}
// DeviceApproveRequest is the request to approve a device
type DeviceApproveRequest struct {
UserCode string `json:"user_code"`
Approve bool `json:"approve"`
}
// DeviceApproveHandler handles POST /device/approve
type DeviceApproveHandler struct {
BaseUIHandler
}
func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Check session
sessionID, ok := db.GetSessionID(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sess, ok := h.SessionStore.Get(sessionID)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req DeviceApproveRequest
if err := render.Decode(r, &req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if !req.Approve {
// User denied
render.JSON(w, r, map[string]string{"status": "denied"})
return
}
// Approve the device
_, err := h.DeviceStore.ApprovePending(req.UserCode, sess.DID, sess.Handle)
if err != nil {
slog.Error("Failed to approve device", "component", "device/approve", "error", err)
http.Error(w, fmt.Sprintf("failed to approve: %v", err), http.StatusInternalServerError)
return
}
render.JSON(w, r, map[string]string{"status": "approved"})
}
// ListDevicesHandler handles GET /api/devices
// Returns HTML partial for HTMX requests, JSON for API requests
type ListDevicesHandler struct {
BaseUIHandler
}
func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Check session
sessionID, ok := db.GetSessionID(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sess, ok := h.SessionStore.Get(sessionID)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Get devices for this user
devices := h.DeviceStore.ListDevices(sess.DID)
// Check if this is an HTMX request
if r.Header.Get("HX-Request") == "true" {
// Return HTML partial
data := struct {
Devices []*db.Device
}{
Devices: devices,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.Templates.ExecuteTemplate(w, "devices-table", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
// Return JSON for API requests
render.JSON(w, r, devices)
}
// RevokeDeviceHandler handles DELETE /api/devices/{id}
type RevokeDeviceHandler struct {
BaseUIHandler
}
func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Check session
sessionID, ok := db.GetSessionID(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sess, ok := h.SessionStore.Get(sessionID)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Get device ID from URL
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
http.Error(w, "device ID required", http.StatusBadRequest)
return
}
// Revoke device
if err := h.DeviceStore.RevokeDevice(sess.DID, deviceID); err != nil {
http.Error(w, fmt.Sprintf("failed to revoke: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("HX-Trigger", "devicesChanged")
w.WriteHeader(http.StatusOK)
}
// renderApprovalPage renders the device authorization confirmation page.
// The browser-side identity (avatar + handle + display name) and the
// terminal-side device facts are paired side-by-side so a wrong-account
// approval is visually obvious before the Approve button is clicked.
func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, r *http.Request, sess *db.Session, pending *db.PendingAuthorization) {
// Hydrate the signed-in sailor: cached avatar/handle from our local
// users table; live displayName from their PDS (best-effort with a tight
// timeout — the page must still render quickly if Bluesky is slow).
user := &db.User{
DID: sess.DID,
Handle: sess.Handle,
PDSEndpoint: sess.PDSEndpoint,
}
if h.ReadOnlyDB != nil {
if u, err := db.GetUserByDID(h.ReadOnlyDB, sess.DID); err == nil && u != nil {
user = u
}
}
displayName := fetchDisplayName(r.Context(), sess)
meta := NewPageMeta(
"Authorize device - "+h.ClientShortName,
"Confirm device authorization for "+h.ClientShortName,
).WithRobots("noindex").
WithSiteName(h.ClientShortName)
pd := NewPageData(r, &h.BaseUIHandler)
pd.User = user
data := struct {
PageData
Meta *PageMeta
Pending *db.PendingAuthorization
ProfileDisplayName string
UserDIDShort string
UserAgentShort string
}{
PageData: pd,
Meta: meta,
Pending: pending,
ProfileDisplayName: displayName,
UserDIDShort: shortenDID(sess.DID),
UserAgentShort: shortenUserAgent(pending.UserAgent),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.Templates.ExecuteTemplate(w, "device-approve", data); err != nil {
slog.Error("Failed to render device approval page", "component", "device/approve", "error", err)
http.Error(w, "failed to render template", http.StatusInternalServerError)
}
}
func (h *DeviceApprovalPageHandler) renderSuccess(w http.ResponseWriter, r *http.Request, deviceName string) {
meta := NewPageMeta(
"Device authorized - "+h.ClientShortName,
"Device authorization complete",
).WithRobots("noindex").
WithSiteName(h.ClientShortName)
data := struct {
PageData
Meta *PageMeta
DeviceName string
}{
PageData: NewPageData(r, &h.BaseUIHandler),
Meta: meta,
DeviceName: deviceName,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.Templates.ExecuteTemplate(w, "device-approved", data); err != nil {
slog.Error("Failed to render device success page", "component", "device/approve", "error", err)
http.Error(w, "failed to render template", http.StatusInternalServerError)
}
}
func (h *DeviceApprovalPageHandler) renderError(w http.ResponseWriter, r *http.Request, message string) {
meta := NewPageMeta(
"Authorization error - "+h.ClientShortName,
"Device authorization could not be completed",
).WithRobots("noindex").
WithSiteName(h.ClientShortName)
data := struct {
PageData
Meta *PageMeta
Message string
}{
PageData: NewPageData(r, &h.BaseUIHandler),
Meta: meta,
Message: message,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
if err := h.Templates.ExecuteTemplate(w, "device-error", data); err != nil {
slog.Error("Failed to render device error page", "component", "device/approve", "error", err)
}
}
// fetchDisplayName best-effort fetches the sailor's display name from
// their PDS. Returns "" on any failure — the template falls back to the
// handle so the page never blocks on a slow upstream.
func fetchDisplayName(ctx context.Context, sess *db.Session) string {
if sess == nil || sess.PDSEndpoint == "" {
return ""
}
timeoutCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
defer cancel()
client := atproto.NewClient(sess.PDSEndpoint, sess.DID, "")
profile, err := client.GetActorProfile(timeoutCtx, sess.DID)
if err != nil || profile == nil {
return ""
}
return strings.TrimSpace(profile.DisplayName)
}
// shortenDID returns a compact DID for display (e.g.
// "did:plc:abc…xyz") without obscuring its kind.
func shortenDID(did string) string {
if len(did) <= 24 {
return did
}
// Keep the prefix (did:plc: / did:web:) and the last 6 chars.
prefixEnd := strings.Index(did[4:], ":")
if prefixEnd < 0 {
return did[:14] + "…" + did[len(did)-6:]
}
prefixEnd += 5 // include "did:" and the trailing ":"
if len(did)-prefixEnd <= 14 {
return did
}
return did[:prefixEnd+6] + "…" + did[len(did)-6:]
}
// shortenUserAgent picks a readable summary of the device's UA string —
// almost always something like "docker-credential-atcr/0.x" — and caps
// the length so the device card doesn't blow up on long UA strings.
func shortenUserAgent(ua string) string {
ua = strings.TrimSpace(ua)
if ua == "" {
return ""
}
if len(ua) > 80 {
return ua[:80] + "…"
}
return ua
}
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
// Check X-Real-IP header
xri := r.Header.Get("X-Real-IP")
if xri != "" {
return xri
}
// Fall back to RemoteAddr
parts := strings.Split(r.RemoteAddr, ":")
if len(parts) > 0 {
return parts[0]
}
return r.RemoteAddr
}