package handler import ( "crypto/rand" "encoding/base64" "errors" "io" "net/http" "path/filepath" "strings" "time" commonmw "gitea.estateliga.work/admin/portal-common/middleware" "github.com/go-chi/chi/v5" "files-service/internal/config" "files-service/internal/model" "files-service/internal/repository" "files-service/internal/storage" ) type NodeHandler struct { cfg *config.Config repo *repository.NodeRepository store *storage.Storage } func NewNodeHandler(cfg *config.Config, repo *repository.NodeRepository, store *storage.Storage) *NodeHandler { return &NodeHandler{cfg: cfg, repo: repo, store: store} } func (h *NodeHandler) List(w http.ResponseWriter, r *http.Request) { userID := commonmw.GetUserID(r.Context()) scope := r.URL.Query().Get("scope") parentID := emptyToNil(r.URL.Query().Get("parent_id")) nodes, err := h.repo.List(r.Context(), userID, subordinates(r), scope, parentID) if err != nil { writeInternalError(w, r, err, "failed to list files") return } writeJSON(w, http.StatusOK, nodes) } func (h *NodeHandler) Get(w http.ResponseWriter, r *http.Request) { node, ok := h.requireNode(w, r) if !ok { return } writeJSON(w, http.StatusOK, node) } func (h *NodeHandler) CreateFolder(w http.ResponseWriter, r *http.Request) { var req model.CreateFolderRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } req.Title = strings.TrimSpace(req.Title) if req.Title == "" { writeError(w, http.StatusBadRequest, "title is required") return } userID := commonmw.GetUserID(r.Context()) if !h.requireWritableParent(w, r, userID, req.ParentID) { return } node, err := h.repo.CreateFolder(r.Context(), req.Title, req.ParentID, userID) if err != nil { writeInternalError(w, r, err, "failed to create folder") return } h.repo.Audit(r.Context(), userID, "files.folder_create", "files_node", node.ID, "{}") writeJSON(w, http.StatusCreated, node) } func (h *NodeHandler) ListOfficeLinks(w http.ResponseWriter, r *http.Request) { userID := commonmw.GetUserID(r.Context()) links, err := h.repo.ListOfficeExternalURLs(r.Context(), userID, subordinates(r)) if err != nil { writeInternalError(w, r, err, "failed to list office links") return } writeJSON(w, http.StatusOK, links) } func (h *NodeHandler) CreateOfficeDocument(w http.ResponseWriter, r *http.Request) { var req model.CreateOfficeDocumentRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } req.Title = strings.TrimSpace(req.Title) req.OfficeID = strings.TrimSpace(req.OfficeID) req.OfficeFormat = strings.Trim(strings.ToLower(req.OfficeFormat), ". ") if req.Title == "" { writeError(w, http.StatusBadRequest, "title is required") return } if req.OfficeID == "" { writeError(w, http.StatusBadRequest, "office_id is required") return } if !allowedOfficeFormat(req.OfficeFormat) { writeError(w, http.StatusBadRequest, "office_format is not allowed") return } userID := commonmw.GetUserID(r.Context()) if !h.requireWritableParent(w, r, userID, req.ParentID) { return } externalURL := "/office/" + req.OfficeID originalFilename := req.Title + "." + req.OfficeFormat mimeType := req.OfficeFormat node, err := h.repo.CreateOfficeDocument(r.Context(), &model.Node{ ParentID: req.ParentID, Title: req.Title, OwnerUserID: userID, OriginalFilename: &originalFilename, MimeType: &mimeType, Extension: &req.OfficeFormat, SizeBytes: req.SizeBytes, OfficeFormat: &req.OfficeFormat, ExternalURL: &externalURL, }) if err != nil { writeInternalError(w, r, err, "failed to create office document") return } h.repo.Audit(r.Context(), userID, "files.office_document_create", "files_node", node.ID, "{}") writeJSON(w, http.StatusCreated, node) } func (h *NodeHandler) UploadFile(w http.ResponseWriter, r *http.Request) { if !h.store.Configured() { writeError(w, http.StatusServiceUnavailable, "storage not configured") return } if err := r.ParseMultipartForm(64 << 20); err != nil { writeError(w, http.StatusBadRequest, "invalid multipart") return } file, header, err := r.FormFile("file") if err != nil { writeError(w, http.StatusBadRequest, "file is required") return } defer file.Close() filename := filepath.Base(header.Filename) ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) if !storage.AllowedExtension(ext) { writeError(w, http.StatusBadRequest, "file type is not allowed") return } title := strings.TrimSpace(r.FormValue("title")) if title == "" { title = strings.TrimSuffix(filename, filepath.Ext(filename)) } userID := commonmw.GetUserID(r.Context()) parentID := emptyToNil(r.FormValue("parent_id")) if !h.requireWritableParent(w, r, userID, parentID) { return } key := storage.GenerateKey(userID, filename) contentType := storage.GuessContentType(filename, header.Header.Get("Content-Type")) if err := h.store.PutObject(r.Context(), key, file, header.Size, contentType); err != nil { writeInternalError(w, r, err, "failed to upload file") return } node, err := h.repo.CreateFile(r.Context(), &model.Node{ ParentID: parentID, Title: title, OwnerUserID: userID, StorageKey: &key, OriginalFilename: &filename, MimeType: &contentType, Extension: &ext, SizeBytes: header.Size, }) if err != nil { writeInternalError(w, r, err, "failed to create file") return } h.repo.Audit(r.Context(), userID, "files.file_upload", "files_node", node.ID, "{}") writeJSON(w, http.StatusCreated, node) } func (h *NodeHandler) Update(w http.ResponseWriter, r *http.Request) { var req model.UpdateNodeRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } if req.Title != nil { title := strings.TrimSpace(*req.Title) if title == "" { writeError(w, http.StatusBadRequest, "title is required") return } req.Title = &title } userID := commonmw.GetUserID(r.Context()) node, err := h.repo.Update(r.Context(), chi.URLParam(r, "id"), userID, req) if errors.Is(err, repository.ErrNotFound) { writeError(w, http.StatusNotFound, "file not found") return } if err != nil { writeInternalError(w, r, err, "failed to update file") return } h.repo.Audit(r.Context(), userID, "files.node_update", "files_node", node.ID, "{}") writeJSON(w, http.StatusOK, node) } func (h *NodeHandler) Move(w http.ResponseWriter, r *http.Request) { var req model.MoveNodeRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } userID := commonmw.GetUserID(r.Context()) if !h.requireWritableParent(w, r, userID, req.ParentID) { return } node, err := h.repo.Move(r.Context(), chi.URLParam(r, "id"), userID, subordinates(r), req.ParentID) if errors.Is(err, repository.ErrNotFound) { writeError(w, http.StatusNotFound, "file not found") return } if errors.Is(err, model.ErrInvalidMove) { writeError(w, http.StatusBadRequest, "folder cannot be moved inside itself") return } if err != nil { writeInternalError(w, r, err, "failed to move file") return } h.repo.Audit(r.Context(), userID, "files.node_move", "files_node", node.ID, "{}") writeJSON(w, http.StatusOK, node) } func (h *NodeHandler) Restore(w http.ResponseWriter, r *http.Request) { userID := commonmw.GetUserID(r.Context()) node, err := h.repo.Restore(r.Context(), chi.URLParam(r, "id"), userID, subordinates(r)) if errors.Is(err, repository.ErrNotFound) { writeError(w, http.StatusNotFound, "file not found") return } if err != nil { writeInternalError(w, r, err, "failed to restore file") return } h.repo.Audit(r.Context(), userID, "files.node_restore", "files_node", node.ID, "{}") writeJSON(w, http.StatusOK, node) } func (h *NodeHandler) Delete(w http.ResponseWriter, r *http.Request) { userID := commonmw.GetUserID(r.Context()) id := chi.URLParam(r, "id") purgeAfter := time.Now().Add(30 * 24 * time.Hour) if err := h.repo.SoftDelete(r.Context(), id, userID, purgeAfter); errors.Is(err, repository.ErrNotFound) { writeError(w, http.StatusNotFound, "file not found") return } else if err != nil { writeInternalError(w, r, err, "failed to delete file") return } h.repo.Audit(r.Context(), userID, "files.node_delete", "files_node", id, "{}") w.WriteHeader(http.StatusNoContent) } func (h *NodeHandler) Download(w http.ResponseWriter, r *http.Request) { node, ok := h.requireNode(w, r) if !ok { return } h.streamNode(w, r, node) } func (h *NodeHandler) ListAccess(w http.ResponseWriter, r *http.Request) { node, ok := h.requireNode(w, r) if !ok { return } if node.EffectiveAccess != model.AccessEdit { writeError(w, http.StatusForbidden, "edit access required") return } access, err := h.repo.ListAccess(r.Context(), node.ID) if err != nil { writeInternalError(w, r, err, "failed to list access") return } writeJSON(w, http.StatusOK, access) } func (h *NodeHandler) ReplaceAccess(w http.ResponseWriter, r *http.Request) { var req model.ReplaceAccessRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } userID := commonmw.GetUserID(r.Context()) id := chi.URLParam(r, "id") if err := h.repo.ReplaceAccess(r.Context(), id, userID, req.Access); errors.Is(err, repository.ErrNotFound) { writeError(w, http.StatusNotFound, "file not found") return } else if err != nil { writeInternalError(w, r, err, "failed to update access") return } h.repo.Audit(r.Context(), userID, "files.access_update", "files_node", id, "{}") w.WriteHeader(http.StatusNoContent) } func (h *NodeHandler) CreatePublicLink(w http.ResponseWriter, r *http.Request) { var req model.PublicLinkRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } if req.ExpiresAt.Before(time.Now().Add(time.Minute)) { writeError(w, http.StatusBadRequest, "expires_at must be in future") return } token, err := newToken() if err != nil { writeInternalError(w, r, err, "failed to create public link") return } userID := commonmw.GetUserID(r.Context()) id := chi.URLParam(r, "id") linkID, err := h.repo.CreatePublicLink(r.Context(), id, userID, token, req.ExpiresAt) if errors.Is(err, repository.ErrNotFound) { writeError(w, http.StatusNotFound, "file not found") return } if err != nil { writeInternalError(w, r, err, "failed to create public link") return } h.repo.Audit(r.Context(), userID, "files.public_link_create", "files_node", id, "{}") writeJSON(w, http.StatusCreated, model.PublicLinkResponse{ ID: linkID, URL: strings.TrimRight(h.cfg.PublicBaseURL, "/") + "/api/files/public/" + token, ExpiresAt: req.ExpiresAt, }) } func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) { node, ok := h.publicNode(w, r) if !ok { return } response := model.PublicNodeResponse{Node: node} if node.NodeType == model.NodeTypeFolder { children, err := h.repo.ListChildrenForPublic(r.Context(), node.ID) if err != nil { writeInternalError(w, r, err, "failed to list folder") return } response.Children = children } writeJSON(w, http.StatusOK, response) } func (h *NodeHandler) PublicDownload(w http.ResponseWriter, r *http.Request) { node, ok := h.publicNode(w, r) if !ok { return } h.streamNode(w, r, node) } func (h *NodeHandler) requireNode(w http.ResponseWriter, r *http.Request) (*model.Node, bool) { node, err := h.repo.GetForUser(r.Context(), chi.URLParam(r, "id"), commonmw.GetUserID(r.Context()), subordinates(r)) if errors.Is(err, repository.ErrNotFound) { writeError(w, http.StatusNotFound, "file not found") return nil, false } if err != nil { writeInternalError(w, r, err, "failed to get file") return nil, false } return node, true } func (h *NodeHandler) requireWritableParent(w http.ResponseWriter, r *http.Request, userID string, parentID *string) bool { if parentID == nil || strings.TrimSpace(*parentID) == "" { return true } parent, err := h.repo.GetForUser(r.Context(), *parentID, userID, subordinates(r)) if errors.Is(err, repository.ErrNotFound) { writeError(w, http.StatusNotFound, "parent folder not found") return false } if err != nil { writeInternalError(w, r, err, "failed to check parent folder") return false } if parent.NodeType != model.NodeTypeFolder { writeError(w, http.StatusBadRequest, "parent_id must point to folder") return false } if parent.EffectiveAccess != model.AccessEdit { writeError(w, http.StatusForbidden, "edit access to parent folder required") return false } return true } func (h *NodeHandler) publicNode(w http.ResponseWriter, r *http.Request) (*model.Node, bool) { node, err := h.repo.GetByPublicToken(r.Context(), chi.URLParam(r, "token")) if errors.Is(err, repository.ErrNotFound) { writeError(w, http.StatusNotFound, "public link not found") return nil, false } if err != nil { writeInternalError(w, r, err, "failed to open public link") return nil, false } return node, true } func (h *NodeHandler) streamNode(w http.ResponseWriter, r *http.Request, node *model.Node) { if node.NodeType == model.NodeTypeFolder || node.StorageKey == nil { writeError(w, http.StatusBadRequest, "node is not downloadable") return } info, err := h.store.Stat(r.Context(), *node.StorageKey) if err != nil { writeInternalError(w, r, err, "failed to open file") return } start, end, hasRange := storage.ParseRange(r.Header.Get("Range"), info.Size) body, info, err := h.store.GetObject(r.Context(), *node.StorageKey, start, end) if err != nil { writeInternalError(w, r, err, "failed to stream file") return } defer body.Close() filename := node.Title if node.OriginalFilename != nil && *node.OriginalFilename != "" { filename = *node.OriginalFilename } w.Header().Set("Content-Disposition", `inline; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`) storage.WriteRangeResponse(w, info.ContentType, info.Size, start, end, hasRange) _, _ = io.Copy(w, body) } func emptyToNil(v string) *string { v = strings.TrimSpace(v) if v == "" { return nil } return &v } func allowedOfficeFormat(format string) bool { switch format { case "doc", "docx", "odt", "xls", "xlsx", "xlsm", "ods", "ppt", "pptx", "odp": return true default: return false } } func subordinates(r *http.Request) []string { ids := csvHeader(r, "X-User-Subordinates") if len(ids) == 0 { ids = csvHeader(r, "X-User-Subordinate-Ids") } return ids } func newToken() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buf), nil }