package handler import ( "crypto/rand" "encoding/base64" "errors" "html" "io" "net/http" "net/url" "path/filepath" "strconv" "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) ListPublicLinks(w http.ResponseWriter, r *http.Request) { userID := commonmw.GetUserID(r.Context()) id := chi.URLParam(r, "id") links, err := h.repo.ListPublicLinks(r.Context(), id, userID) if err != nil { writeInternalError(w, r, err, "failed to list public links") return } for i := range links { if links[i].URL != "" { links[i].URL = h.publicURL(links[i].URL) } } writeJSON(w, http.StatusOK, links) } 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") link, 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, "{}") link.URL = h.publicURL(token) writeJSON(w, http.StatusCreated, link) } func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) { node, ok := h.publicNode(w, r) if !ok { return } if node.NodeType == model.NodeTypeFile { h.renderPublicPreview(w, node, h.publicURL(chi.URLParam(r, "token"))+"/download") return } 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 } h.renderPublicFolder(w, r, node, children) return } h.renderPublicUnavailable(w, node) } 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) PublicChildMeta(w http.ResponseWriter, r *http.Request) { node, ok := h.publicNodeByID(w, r) if !ok { return } token := chi.URLParam(r, "token") if node.NodeType == model.NodeTypeOfficeDocument { if officeID := officeIDFromNode(node); officeID != "" { http.Redirect(w, r, h.publicOfficeURL(token, node.ID, officeID), http.StatusFound) return } } if node.NodeType == model.NodeTypeFile { h.renderPublicPreview(w, node, h.publicNodeURL(token, node.ID)+"/download") return } 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 } h.renderPublicFolder(w, r, node, children) return } h.renderPublicUnavailable(w, node) } func (h *NodeHandler) PublicOfficeInfo(w http.ResponseWriter, r *http.Request) { node, ok := h.publicNodeByID(w, r) if !ok { return } officeID := officeIDFromNode(node) if node.NodeType != model.NodeTypeOfficeDocument || officeID == "" { writeError(w, http.StatusBadRequest, "node is not an office document") return } writeJSON(w, http.StatusOK, map[string]string{ "node_id": node.ID, "office_id": officeID, "title": node.Title, }) } func (h *NodeHandler) PublicChildDownload(w http.ResponseWriter, r *http.Request) { node, ok := h.publicNodeByID(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) publicNodeByID(w http.ResponseWriter, r *http.Request) (*model.Node, bool) { node, err := h.repo.GetPublicDescendant(r.Context(), chi.URLParam(r, "token"), chi.URLParam(r, "id")) if errors.Is(err, repository.ErrNotFound) { writeError(w, http.StatusNotFound, "public file not found") return nil, false } if err != nil { writeInternalError(w, r, err, "failed to open public file") return nil, false } return node, true } func (h *NodeHandler) renderPublicPreview(w http.ResponseWriter, node *model.Node, downloadURL string) { title := node.Title if node.OriginalFilename != nil && *node.OriginalFilename != "" { title = *node.OriginalFilename } mimeType := "" if node.MimeType != nil { mimeType = strings.ToLower(*node.MimeType) } preview := `
Публичный предпросмотр для этого типа документа пока недоступен.