460 lines
14 KiB
Go
460 lines
14 KiB
Go
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) 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
|
|
}
|
|
writeJSON(w, http.StatusOK, 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) 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
|
|
}
|