ultisuite-backend/internal/api/drive/handlers.go
2026-06-04 00:12:11 +02:00

703 lines
20 KiB
Go

package drive
import (
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/auth"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/permission"
"github.com/ultisuite/ulti-backend/internal/realtime"
)
type Handler struct {
svc *Service
logger *slog.Logger
}
func NewHandler(nc *nextcloud.Client, hub *realtime.Hub) *Handler {
return &Handler{
svc: NewService(nc, hub),
logger: slog.Default().With("component", "drive-api"),
}
}
func (h *Handler) nextcloudUser(w http.ResponseWriter, r *http.Request, claims *auth.Claims) (string, bool) {
userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
if err != nil {
h.logger.Error("ensure nextcloud user", "error", err, "sub", claims.Sub, "email", claims.Email)
apivalidate.WriteInternal(w, r)
return "", false
}
return userID, true
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead)
write := middleware.RequirePermission(permission.ResourceDrive, permission.LevelWrite)
admin := middleware.RequirePermission(permission.ResourceDrive, permission.LevelAdmin)
r.With(read).Get("/quota", h.GetQuota)
r.With(read).Get("/trash", h.ListTrash)
r.With(read).Get("/recent", h.ListRecent)
r.With(read).Get("/starred", h.ListStarred)
r.With(read).Get("/starred/*", h.ListStarred)
r.With(read).Get("/shared", h.ListSharedWithMe)
r.With(read).Get("/search", h.Search)
r.With(read).Get("/shares", h.ListShares)
r.With(read).Get("/shares/recipients/lookup", h.LookupShareRecipient)
r.With(read).Get("/download/*", h.Download)
r.With(read).Get("/preview/*", h.Preview)
r.With(read).Get("/files/*", h.ListFiles)
r.With(write).Post("/files/*", h.Upload)
r.With(write).Post("/files/new", h.CreateNewFile)
r.With(write).Delete("/files/*", h.DeleteFile)
r.With(write).Post("/folders/*", h.CreateFolder)
r.With(write).Post("/move", h.Move)
r.With(write).Post("/copy", h.Copy)
r.With(write).Post("/rename", h.Rename)
r.With(write).Post("/trash/restore", h.RestoreTrash)
r.With(write).Post("/favorite", h.SetFavorite)
r.With(admin).Post("/shares", h.CreateShare)
r.With(admin).Post("/shares/{shareID}/send-email", h.SendShareEmail)
r.With(admin).Put("/shares/{shareID}", h.UpdateShare)
r.With(admin).Delete("/shares/{shareID}", h.DeleteShare)
return r
}
func (h *Handler) ListFiles(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
path := chi.URLParam(r, "*")
result, err := h.svc.ListFiles(r.Context(), ncUser, path, params)
if err != nil {
h.logger.Error("list files", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
path := chi.URLParam(r, "*")
if verr := validatePath(path); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if chunk, ok, verr := chunkUploadFromHeaders(r); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
} else if ok {
if err := h.svc.UploadChunk(r.Context(), ncUser, chunk.UploadID, path, chunk.ChunkUpload, r.Body, r.Header.Get("Content-Type")); err != nil {
h.logger.Error("upload chunk", "error", err)
writeDriveError(w, r, err)
return
}
status := http.StatusAccepted
message := "chunk_uploaded"
if chunk.Complete {
status = http.StatusCreated
message = "uploaded"
}
apiresponse.WriteJSON(w, status, map[string]any{
"status": message,
"path": path,
"upload_id": chunk.UploadID,
"index": chunk.Index,
"total": chunk.Total,
"complete": chunk.Complete,
})
return
}
if err := h.svc.Upload(r.Context(), ncUser, path, r.Body, r.Header.Get("Content-Type"), r.ContentLength); err != nil {
h.logger.Error("upload", "error", err)
writeDriveError(w, r, err)
return
}
h.svc.notifyFileChanged(claims.Sub, path)
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"status": "uploaded", "path": path})
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
path := chi.URLParam(r, "*")
if verr := validatePath(path); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
body, contentType, err := h.svc.Download(r.Context(), ncUser, path)
if err != nil {
writeDriveError(w, r, err)
return
}
defer body.Close()
w.Header().Set("Content-Type", contentType)
io.Copy(w, body)
}
func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
path := chi.URLParam(r, "*")
if verr := validatePath(path); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
width, _ := strconv.Atoi(r.URL.Query().Get("w"))
height, _ := strconv.Atoi(r.URL.Query().Get("h"))
body, contentType, err := h.svc.Preview(r.Context(), ncUser, path, width, height)
if err != nil {
writeDriveError(w, r, err)
return
}
defer body.Close()
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "private, max-age=300")
io.Copy(w, body)
}
func (h *Handler) DeleteFile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
path := chi.URLParam(r, "*")
if verr := validatePath(path); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.Delete(r.Context(), ncUser, path); err != nil {
h.logger.Error("delete file", "error", err)
writeDriveError(w, r, err)
return
}
h.svc.notifyFileChanged(claims.Sub, path)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) CreateFolder(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
path := chi.URLParam(r, "*")
if verr := validatePath(path); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.CreateFolder(r.Context(), ncUser, path); err != nil {
h.logger.Error("create folder", "error", err)
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusCreated)
}
func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req moveRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateMoveRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.Move(r.Context(), ncUser, req.Source, req.Destination); err != nil {
h.logger.Error("move", "error", err)
writeDriveError(w, r, err)
return
}
h.svc.notifyFileChanged(claims.Sub, req.Destination)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Copy(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req copyRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateCopyRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.Copy(r.Context(), ncUser, req.Source, req.Destination); err != nil {
h.logger.Error("copy", "error", err)
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req renameRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateRenameRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.Rename(r.Context(), ncUser, req.Path, req.NewName); err != nil {
h.logger.Error("rename", "error", err)
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) ListTrash(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
result, err := h.svc.ListTrash(r.Context(), ncUser, params)
if err != nil {
h.logger.Error("list trash", "error", err)
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) ListRecent(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
result, err := h.svc.ListRecent(r.Context(), ncUser, params)
if err != nil {
h.logger.Error("list recent", "error", err)
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) ListStarred(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
basePath := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
result, err := h.svc.ListStarred(r.Context(), ncUser, basePath, params)
if err != nil {
h.logger.Error("list starred", "error", err)
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) ListSharedWithMe(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
result, err := h.svc.ListSharedWithMe(r.Context(), ncUser, params)
if err != nil {
h.logger.Error("list shared with me", "error", err)
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req createShareRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateCreateShareRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
permissions := req.Permissions
if permissions == 0 && strings.TrimSpace(req.Role) != "" {
if mapped, ok := sharePermissionsForRole(req.Role); ok {
permissions = mapped
}
}
share, err := h.svc.CreateShare(r.Context(), ncUser, req.Path, req, permissions)
if err != nil {
h.logger.Error("create share", "error", err)
writeDriveError(w, r, err)
return
}
h.svc.notifyShareUpdated(claims.Sub, req.Path)
apiresponse.WriteJSON(w, http.StatusCreated, share)
}
func (h *Handler) GetQuota(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
quota, err := h.svc.GetQuota(r.Context(), ncUser)
if err != nil {
h.logger.Error("get quota", "error", err, "nc_user", ncUser)
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, quota)
}
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
basePath := r.URL.Query().Get("path")
result, err := h.svc.Search(r.Context(), ncUser, basePath, params)
if err != nil {
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) ListShares(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
filePath := r.URL.Query().Get("path")
if filePath == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "path", Message: "required"},
))
return
}
shares, err := h.svc.ListShares(r.Context(), ncUser, filePath)
if err != nil {
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"shares": shares})
}
func (h *Handler) LookupShareRecipient(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
_ = ncUser
email := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("email")))
if email == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "email", Message: "required"},
))
return
}
registered, err := h.svc.UserExists(r.Context(), email)
if err != nil {
h.logger.Error("lookup share recipient", "error", err, "email", email)
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
"email": email,
"registered": registered,
})
}
func (h *Handler) SendShareEmail(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
shareID := chi.URLParam(r, "shareID")
var req struct {
Password string `json:"password"`
}
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if err := h.svc.SendShareEmail(r.Context(), ncUser, shareID, req.Password); err != nil {
h.logger.Error("send share email", "error", err, "share_id", shareID)
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) UpdateShare(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
shareID := chi.URLParam(r, "shareID")
var req updateShareRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
permissions := req.Permissions
if permissions == 0 && strings.TrimSpace(req.Role) != "" {
if mapped, ok := sharePermissionsForRole(req.Role); ok {
permissions = mapped
}
}
share, err := h.svc.UpdateShare(r.Context(), ncUser, shareID, permissions, req.ExpireDate, req.Password)
if err != nil {
writeDriveError(w, r, err)
return
}
h.svc.notifyShareUpdated(claims.Sub, share.Path)
apiresponse.WriteJSON(w, http.StatusOK, share)
}
func (h *Handler) DeleteShare(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
shareID := chi.URLParam(r, "shareID")
if err := h.svc.DeleteShare(r.Context(), ncUser, shareID); err != nil {
writeDriveError(w, r, err)
return
}
h.svc.notifyShareUpdated(claims.Sub, "")
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) RestoreTrash(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req restoreTrashRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateRestoreTrashRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.RestoreTrash(r.Context(), ncUser, req.Name); err != nil {
writeDriveError(w, r, err)
return
}
h.svc.notifyFileChanged(claims.Sub, req.Name)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) SetFavorite(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req favoriteRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateFavoriteRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.SetFavorite(r.Context(), ncUser, req.Path, req.Favorite); err != nil {
writeDriveError(w, r, err)
return
}
h.svc.notifyFileChanged(claims.Sub, req.Path)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) CreateNewFile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req newFileRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateNewFileRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
kind := NewFileKind(strings.TrimSpace(strings.ToLower(req.Kind)))
target, err := h.svc.CreateNewFile(r.Context(), ncUser, req.ParentPath, req.Name, kind)
if err != nil {
writeDriveError(w, r, err)
return
}
h.svc.notifyFileChanged(claims.Sub, target)
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"path": target})
}
func writeDriveError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, ErrNotFound):
apivalidate.WriteNotFound(w, r, "not found")
case errors.Is(err, ErrConflict):
apiresponse.WriteError(w, r, http.StatusConflict, "drive.conflict", "resource conflict", nil)
case errors.Is(err, ErrForbidden):
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "forbidden", nil)
case errors.Is(err, ErrQuotaExceeded):
apiresponse.WriteError(w, r, http.StatusInsufficientStorage, "drive.quota_exceeded", "quota exceeded", nil)
case errors.Is(err, ErrInvalid):
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil)
default:
apivalidate.WriteInternal(w, r)
}
}
type chunkRequest struct {
UploadID string
ChunkUpload
}
func chunkUploadFromHeaders(r *http.Request) (chunkRequest, bool, *apivalidate.ValidationError) {
uploadID := strings.TrimSpace(r.Header.Get("X-Upload-ID"))
if uploadID == "" {
return chunkRequest{}, false, nil
}
var details []apivalidate.FieldDetail
index, err := strconv.Atoi(strings.TrimSpace(r.Header.Get("X-Chunk-Index")))
if err != nil || index < 0 {
details = append(details, apivalidate.FieldDetail{Field: "X-Chunk-Index", Message: "required positive integer"})
}
total, err := strconv.Atoi(strings.TrimSpace(r.Header.Get("X-Chunk-Total")))
if err != nil || total <= 0 {
details = append(details, apivalidate.FieldDetail{Field: "X-Chunk-Total", Message: "required positive integer"})
}
totalSize := int64(-1)
if raw := strings.TrimSpace(r.Header.Get("X-Upload-Total-Size")); raw != "" {
parsed, parseErr := strconv.ParseInt(raw, 10, 64)
if parseErr != nil || parsed < 0 {
details = append(details, apivalidate.FieldDetail{Field: "X-Upload-Total-Size", Message: "invalid"})
} else {
totalSize = parsed
}
}
complete := strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Upload-Complete")), "true")
if total > 0 && index == total-1 {
complete = true
}
if len(details) > 0 {
return chunkRequest{}, true, apivalidate.NewValidationError(details...)
}
return chunkRequest{
UploadID: uploadID,
ChunkUpload: ChunkUpload{
Index: index,
Total: total,
TotalSize: totalSize,
Complete: complete,
},
}, true, nil
}