torrent-gateway/internal/api/auth_handlers.go
enki b3204ea07a
Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
first commit
2025-08-18 00:40:15 -07:00

444 lines
11 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"time"
"git.sovbit.dev/enki/torrentGateway/internal/auth"
"git.sovbit.dev/enki/torrentGateway/internal/middleware"
"github.com/gorilla/mux"
)
// AuthHandlers provides authentication-related HTTP handlers
type AuthHandlers struct {
nostrAuth *auth.NostrAuth
gateway *Gateway
}
// NewAuthHandlers creates new authentication handlers
func NewAuthHandlers(nostrAuth *auth.NostrAuth, gateway *Gateway) *AuthHandlers {
return &AuthHandlers{
nostrAuth: nostrAuth,
gateway: gateway,
}
}
// LoginRequest represents a login request
type LoginRequest struct {
AuthType string `json:"auth_type"` // "nip07" or "nip46"
AuthEvent string `json:"auth_event"` // For NIP-07: signed event JSON
BunkerURL string `json:"bunker_url"` // For NIP-46: bunker connection URL
}
// LoginResponse represents a login response
type LoginResponse struct {
Success bool `json:"success"`
SessionToken string `json:"session_token,omitempty"`
Pubkey string `json:"pubkey,omitempty"`
Message string `json:"message,omitempty"`
Challenge string `json:"challenge,omitempty"`
}
// UserStatsResponse represents user statistics
type UserStatsResponse struct {
Pubkey string `json:"pubkey"`
DisplayName string `json:"display_name,omitempty"`
FileCount int `json:"file_count"`
StorageUsed int64 `json:"storage_used"`
LastLogin string `json:"last_login"`
}
// UserFile represents a file in user's file list
type UserFile struct {
Hash string `json:"hash"`
Name string `json:"name"`
Size int64 `json:"size"`
StorageType string `json:"storage_type"`
AccessLevel string `json:"access_level"`
UploadedAt string `json:"uploaded_at"`
}
// LoginHandler handles user authentication
func (ah *AuthHandlers) LoginHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
var pubkey string
var err error
switch req.AuthType {
case "nip07":
pubkey, err = ah.nostrAuth.ValidateNIP07(req.AuthEvent)
if err != nil {
response := LoginResponse{
Success: false,
Message: fmt.Sprintf("NIP-07 validation failed: %v", err),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(response)
return
}
case "nip46":
pubkey, err = ah.nostrAuth.ValidateNIP46(req.BunkerURL)
if err != nil {
response := LoginResponse{
Success: false,
Message: fmt.Sprintf("NIP-46 validation failed: %v", err),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(response)
return
}
default:
response := LoginResponse{
Success: false,
Message: "Invalid auth_type: must be 'nip07' or 'nip46'",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(response)
return
}
// Create session
sessionToken, err := ah.nostrAuth.CreateSession(pubkey)
if err != nil {
response := LoginResponse{
Success: false,
Message: fmt.Sprintf("Failed to create session: %v", err),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(response)
return
}
// Set session cookie
cookie := &http.Cookie{
Name: "session_token",
Value: sessionToken,
Expires: time.Now().Add(24 * time.Hour),
HttpOnly: true,
Secure: false, // Set to true in production with HTTPS
SameSite: http.SameSiteStrictMode,
Path: "/",
}
http.SetCookie(w, cookie)
response := LoginResponse{
Success: true,
SessionToken: sessionToken,
Pubkey: pubkey,
Message: "Login successful",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// LogoutHandler handles user logout
func (ah *AuthHandlers) LogoutHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get session token from cookie or header
var token string
if cookie, err := r.Cookie("session_token"); err == nil {
token = cookie.Value
}
if token != "" {
// Revoke session
ah.nostrAuth.RevokeSession(token)
}
// Clear session cookie
cookie := &http.Cookie{
Name: "session_token",
Value: "",
Expires: time.Now().Add(-1 * time.Hour),
HttpOnly: true,
Path: "/",
}
http.SetCookie(w, cookie)
response := map[string]bool{"success": true}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// UserStatsHandler returns user statistics
func (ah *AuthHandlers) UserStatsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
pubkey := middleware.GetUserFromContext(r.Context())
if pubkey == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Get user info
user, err := ah.nostrAuth.GetUser(pubkey)
if err != nil {
http.Error(w, "Failed to get user info", http.StatusInternalServerError)
return
}
// Calculate current stats
storageUsed, fileCount, err := ah.gateway.storage.GetUserStats(pubkey)
if err != nil {
http.Error(w, "Failed to calculate stats", http.StatusInternalServerError)
return
}
// Update cached stats
ah.nostrAuth.UpdateUserStats(pubkey, storageUsed, fileCount)
response := UserStatsResponse{
Pubkey: pubkey,
FileCount: fileCount,
StorageUsed: storageUsed,
}
if user != nil {
response.DisplayName = user.DisplayName
response.LastLogin = user.LastLogin.Format(time.RFC3339)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// UserFilesHandler returns user's files
func (ah *AuthHandlers) UserFilesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
pubkey := middleware.GetUserFromContext(r.Context())
if pubkey == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Get user's files
files, err := ah.gateway.storage.GetUserFiles(pubkey)
if err != nil {
http.Error(w, "Failed to get user files", http.StatusInternalServerError)
return
}
// Convert to response format
var userFiles []UserFile
if files != nil {
for _, file := range files {
userFiles = append(userFiles, UserFile{
Hash: file.Hash,
Name: file.OriginalName,
Size: file.Size,
StorageType: file.StorageType,
AccessLevel: file.AccessLevel,
UploadedAt: file.CreatedAt.Format(time.RFC3339),
})
}
}
// Ensure we always return an array, never null
if userFiles == nil {
userFiles = []UserFile{}
}
response := struct {
Files []UserFile `json:"files"`
}{
Files: userFiles,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// DeleteFileHandler deletes a user's file
func (ah *AuthHandlers) DeleteFileHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
pubkey := middleware.GetUserFromContext(r.Context())
if pubkey == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
fileHash := vars["hash"]
if fileHash == "" {
http.Error(w, "Missing file hash", http.StatusBadRequest)
return
}
// Delete the file
err := ah.gateway.storage.DeleteUserFile(fileHash, pubkey)
if err != nil {
if err.Error() == "file not found" {
http.Error(w, "File not found", http.StatusNotFound)
return
}
if err.Error() == "permission denied: not file owner" {
http.Error(w, "Permission denied", http.StatusForbidden)
return
}
http.Error(w, "Failed to delete file", http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"success": true,
"message": "File deleted successfully",
"hash": fileHash,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// ChallengeHandler generates an authentication challenge
func (ah *AuthHandlers) ChallengeHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
challenge, err := auth.GenerateChallenge()
if err != nil {
http.Error(w, "Failed to generate challenge", http.StatusInternalServerError)
return
}
response := map[string]string{
"challenge": challenge,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// UpdateFileAccessRequest represents a file access update request
type UpdateFileAccessRequest struct {
AccessLevel string `json:"access_level"`
}
// UpdateFileAccessHandler updates a file's access level
func (ah *AuthHandlers) UpdateFileAccessHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
pubkey := middleware.GetUserFromContext(r.Context())
if pubkey == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
fileHash := vars["hash"]
if fileHash == "" {
http.Error(w, "Missing file hash", http.StatusBadRequest)
return
}
var req UpdateFileAccessRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate access level
if req.AccessLevel != "public" && req.AccessLevel != "private" {
http.Error(w, "Invalid access level: must be 'public' or 'private'", http.StatusBadRequest)
return
}
// Update the file access level
err := ah.gateway.storage.UpdateFileAccess(fileHash, pubkey, req.AccessLevel)
if err != nil {
if err.Error() == "file not found" {
http.Error(w, "File not found", http.StatusNotFound)
return
}
if err.Error() == "permission denied: not file owner" {
http.Error(w, "Permission denied", http.StatusForbidden)
return
}
http.Error(w, "Failed to update file access", http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"success": true,
"message": "File access level updated successfully",
"hash": fileHash,
"access_level": req.AccessLevel,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// AdminStatusHandler checks if the authenticated user is an admin
func (ah *AuthHandlers) AdminStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
pubkey := middleware.GetUserFromContext(r.Context())
if pubkey == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Check if user is admin - this would depend on your admin config
// For now, we'll check against the config admin pubkeys
isAdmin := false
if ah.gateway.config.Admin.Enabled {
for _, adminPubkey := range ah.gateway.config.Admin.Pubkeys {
if adminPubkey == pubkey {
isAdmin = true
break
}
}
}
response := map[string]interface{}{
"is_admin": isAdmin,
"pubkey": pubkey,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}