From 6bd2251a989fcbb6e9ab3260a52cf6d7645255b8 Mon Sep 17 00:00:00 2001 From: Grendgi Date: Tue, 16 Jun 2026 16:39:04 +0300 Subject: [PATCH] fix: render public folder links --- cmd/server/main.go | 2 + internal/handler/node.go | 174 ++++++++++++++++++++++++++++++++++-- internal/repository/node.go | 33 +++++++ 3 files changed, 203 insertions(+), 6 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 51b0f7e..cb9fc7e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -99,6 +99,8 @@ func main() { r.Get("/public/{token}", nodeH.PublicMeta) r.Get("/public/{token}/download", nodeH.PublicDownload) + r.Get("/public/{token}/nodes/{id}", nodeH.PublicChildMeta) + r.Get("/public/{token}/nodes/{id}/download", nodeH.PublicChildDownload) srv := &http.Server{ Addr: ":" + cfg.ServerPort, diff --git a/internal/handler/node.go b/internal/handler/node.go index e0c1d1b..2921fe8 100644 --- a/internal/handler/node.go +++ b/internal/handler/node.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "path/filepath" + "strconv" "strings" "time" @@ -368,19 +369,19 @@ func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) { return } if node.NodeType == model.NodeTypeFile { - h.renderPublicPreview(w, r, node) + h.renderPublicPreview(w, node, h.publicURL(chi.URLParam(r, "token"))+"/download") 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 + h.renderPublicFolder(w, r, node, children) + return } - writeJSON(w, http.StatusOK, response) + h.renderPublicUnavailable(w, node) } func (h *NodeHandler) PublicDownload(w http.ResponseWriter, r *http.Request) { @@ -391,6 +392,36 @@ func (h *NodeHandler) PublicDownload(w http.ResponseWriter, r *http.Request) { 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.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) 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) { @@ -441,7 +472,20 @@ func (h *NodeHandler) publicNode(w http.ResponseWriter, r *http.Request) (*model return node, true } -func (h *NodeHandler) renderPublicPreview(w http.ResponseWriter, r *http.Request, node *model.Node) { +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 @@ -450,7 +494,6 @@ func (h *NodeHandler) renderPublicPreview(w http.ResponseWriter, r *http.Request if node.MimeType != nil { mimeType = strings.ToLower(*node.MimeType) } - downloadURL := h.publicURL(chi.URLParam(r, "token")) + "/download" preview := `
Предпросмотр для этого типа файла недоступен.
` switch { case strings.HasPrefix(mimeType, "image/"): @@ -496,10 +539,129 @@ func (h *NodeHandler) renderPublicPreview(w http.ResponseWriter, r *http.Request `) } +func (h *NodeHandler) renderPublicFolder(w http.ResponseWriter, r *http.Request, node *model.Node, children []model.Node) { + token := chi.URLParam(r, "token") + items := `
В папке пока нет файлов.
` + if len(children) > 0 { + var b strings.Builder + b.WriteString(`
`) + for _, child := range children { + title := child.Title + if child.OriginalFilename != nil && *child.OriginalFilename != "" { + title = *child.OriginalFilename + } + kind := publicKind(child) + href := h.publicNodeURL(token, child.ID) + b.WriteString(``) + b.WriteString(`` + html.EscapeString(kind) + ``) + b.WriteString(`` + html.EscapeString(title) + ``) + if child.NodeType != model.NodeTypeFolder { + b.WriteString(`` + html.EscapeString(formatBytes(child.SizeBytes)) + ``) + } + b.WriteString(``) + } + b.WriteString(`
`) + items = b.String() + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, ` + + + + + `+html.EscapeString(node.Title)+` + + + +
+
+

`+html.EscapeString(node.Title)+`

+
Публичный просмотр папки
+
+
`+items+`
+
+ +`) +} + +func (h *NodeHandler) renderPublicUnavailable(w http.ResponseWriter, node *model.Node) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, ` + +`+html.EscapeString(node.Title)+` + +

`+html.EscapeString(node.Title)+`

Публичный предпросмотр для этого типа документа пока недоступен.

`) +} + func (h *NodeHandler) publicURL(token string) string { return strings.TrimRight(h.cfg.PublicBaseURL, "/") + "/api/files/public/" + token } +func (h *NodeHandler) publicNodeURL(token, nodeID string) string { + return h.publicURL(token) + "/nodes/" + nodeID +} + +func publicKind(node model.Node) string { + switch node.NodeType { + case model.NodeTypeFolder: + return "DIR" + case model.NodeTypeOfficeDocument: + if node.OfficeFormat != nil && *node.OfficeFormat != "" { + return strings.ToUpper(*node.OfficeFormat) + } + return "DOC" + case model.NodeTypeFile: + if node.Extension != nil && *node.Extension != "" { + return strings.ToUpper(*node.Extension) + } + return "FILE" + default: + return "FILE" + } +} + +func formatBytes(bytes int64) string { + if bytes <= 0 { + return "0 Б" + } + units := []string{"Б", "КБ", "МБ", "ГБ"} + size := float64(bytes) + idx := 0 + for size >= 1024 && idx < len(units)-1 { + size /= 1024 + idx++ + } + if size >= 10 || idx == 0 { + return strings.TrimSuffix(strings.TrimSuffix(formatFloat(size, 0), ".0"), ".") + " " + units[idx] + } + return strings.TrimSuffix(strings.TrimSuffix(formatFloat(size, 1), ".0"), ".") + " " + units[idx] +} + +func formatFloat(v float64, precision int) string { + if precision == 0 { + return strconv.FormatInt(int64(v+0.5), 10) + } + return strconv.FormatFloat(v, 'f', precision, 64) +} + 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") diff --git a/internal/repository/node.go b/internal/repository/node.go index 1def47c..f157db9 100644 --- a/internal/repository/node.go +++ b/internal/repository/node.go @@ -525,6 +525,39 @@ func (r *NodeRepository) GetByPublicToken(ctx context.Context, token string) (*m `, TokenHash(token)).Scan) } +func (r *NodeRepository) GetPublicDescendant(ctx context.Context, token, nodeID string) (*model.Node, error) { + return scanNode(r.pool.QueryRow(ctx, ` + WITH RECURSIVE root AS ( + SELECT n.id, n.parent_id, n.node_type, n.title, n.owner_user_id, n.owner_department_id, + n.created_by, n.updated_by, n.storage_key, n.original_filename, n.mime_type, + n.extension, n.size_bytes, n.office_format, n.external_url, n.version, + n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at + FROM files_public_links l + JOIN files_nodes n ON n.id = l.node_id + WHERE l.token_hash = $1 + AND l.revoked_at IS NULL + AND l.expires_at > now() + AND n.deleted_at IS NULL + ), descendants AS ( + SELECT * FROM root + UNION ALL + SELECT c.id, c.parent_id, c.node_type, c.title, c.owner_user_id, c.owner_department_id, + c.created_by, c.updated_by, c.storage_key, c.original_filename, c.mime_type, + c.extension, c.size_bytes, c.office_format, c.external_url, c.version, + c.created_at, c.updated_at, c.trashed_at, c.purge_after, c.deleted_at + FROM files_nodes c + JOIN descendants d ON c.parent_id = d.id + WHERE c.deleted_at IS NULL + ) + SELECT id, parent_id, node_type, title, owner_user_id, owner_department_id, + created_by, updated_by, storage_key, original_filename, mime_type, + extension, size_bytes, office_format, external_url, version, + 'view' AS effective_access, created_at, updated_at, trashed_at, purge_after, deleted_at + FROM descendants + WHERE id = $2 + `, TokenHash(token), nodeID).Scan) +} + func (r *NodeRepository) Audit(ctx context.Context, actorID, action, entityType, entityID string, meta string) { if meta == "" { meta = "{}"