mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
537 lines
14 KiB
Go
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
|
|
}
|