fix: render public folder links
This commit is contained in:
@@ -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 := `<div class="empty">Предпросмотр для этого типа файла недоступен.</div>`
|
||||
switch {
|
||||
case strings.HasPrefix(mimeType, "image/"):
|
||||
@@ -496,10 +539,129 @@ func (h *NodeHandler) renderPublicPreview(w http.ResponseWriter, r *http.Request
|
||||
</html>`)
|
||||
}
|
||||
|
||||
func (h *NodeHandler) renderPublicFolder(w http.ResponseWriter, r *http.Request, node *model.Node, children []model.Node) {
|
||||
token := chi.URLParam(r, "token")
|
||||
items := `<div class="empty">В папке пока нет файлов.</div>`
|
||||
if len(children) > 0 {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<div class="list">`)
|
||||
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(`<a class="item" href="` + html.EscapeString(href) + `">`)
|
||||
b.WriteString(`<span class="badge">` + html.EscapeString(kind) + `</span>`)
|
||||
b.WriteString(`<span class="title">` + html.EscapeString(title) + `</span>`)
|
||||
if child.NodeType != model.NodeTypeFolder {
|
||||
b.WriteString(`<span class="size">` + html.EscapeString(formatBytes(child.SizeBytes)) + `</span>`)
|
||||
}
|
||||
b.WriteString(`</a>`)
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
items = b.String()
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>`+html.EscapeString(node.Title)+`</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0d111b; color: #edf2ff; }
|
||||
body { margin: 0; min-height: 100vh; background: #0d111b; }
|
||||
.shell { min-height: 100vh; display: grid; grid-template-rows: auto 1fr; }
|
||||
header { padding: 18px 24px; border-bottom: 1px solid #263044; background: #151a2a; }
|
||||
h1 { margin: 0; font-size: 22px; line-height: 1.35; font-weight: 800; }
|
||||
.meta { margin-top: 6px; color: #9aa7bd; font-size: 14px; }
|
||||
main { padding: 24px; }
|
||||
.list { display: grid; gap: 10px; max-width: 980px; margin: 0 auto; }
|
||||
.item { display: grid; grid-template-columns: 72px minmax(0, 1fr) auto; gap: 14px; align-items: center; min-height: 58px; padding: 12px 14px; border: 1px solid #263044; border-radius: 10px; background: #151a2a; color: inherit; text-decoration: none; }
|
||||
.item:hover { border-color: #6366f1; background: #191f34; }
|
||||
.badge { display: grid; place-items: center; min-height: 34px; border-radius: 8px; background: #232844; color: #c7d2fe; font-size: 12px; font-weight: 800; }
|
||||
.title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
|
||||
.size { color: #9aa7bd; font-size: 14px; }
|
||||
.empty { max-width: 640px; margin: 0 auto; padding: 28px; border: 1px dashed #263044; border-radius: 10px; background: #151a2a; color: #c6d0e1; text-align: center; }
|
||||
@media (max-width: 680px) { .item { grid-template-columns: 58px minmax(0, 1fr); } .size { grid-column: 2; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header>
|
||||
<h1>`+html.EscapeString(node.Title)+`</h1>
|
||||
<div class="meta">Публичный просмотр папки</div>
|
||||
</header>
|
||||
<main>`+items+`</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>`)
|
||||
}
|
||||
|
||||
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, `<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>`+html.EscapeString(node.Title)+`</title>
|
||||
<style>:root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#0d111b;color:#edf2ff}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(640px,100%);padding:28px;border:1px solid #263044;border-radius:10px;background:#151a2a}.muted{color:#9aa7bd}</style></head>
|
||||
<body><main class="card"><h1>`+html.EscapeString(node.Title)+`</h1><p class="muted">Публичный предпросмотр для этого типа документа пока недоступен.</p></main></body></html>`)
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -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 = "{}"
|
||||
|
||||
Reference in New Issue
Block a user