776 lines
26 KiB
Go
776 lines
26 KiB
Go
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, false)
|
||
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, true)
|
||
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 := `<div class="empty">Предпросмотр для этого типа файла недоступен.</div>`
|
||
switch {
|
||
case strings.HasPrefix(mimeType, "image/"):
|
||
preview = `<img class="preview-media" src="` + html.EscapeString(downloadURL) + `" alt="">`
|
||
case mimeType == "application/pdf":
|
||
preview = `<iframe class="preview-frame" src="` + html.EscapeString(downloadURL) + `"></iframe>`
|
||
case strings.HasPrefix(mimeType, "video/"):
|
||
preview = `<video class="preview-media" src="` + html.EscapeString(downloadURL) + `" controls></video>`
|
||
case strings.HasPrefix(mimeType, "audio/"):
|
||
preview = `<audio class="preview-audio" src="` + html.EscapeString(downloadURL) + `" controls></audio>`
|
||
}
|
||
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(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: 20px; line-height: 1.35; font-weight: 700; }
|
||
.meta { margin-top: 6px; color: #9aa7bd; font-size: 14px; }
|
||
main { padding: 24px; display: grid; place-items: center; overflow: auto; }
|
||
.preview-media { max-width: 100%; max-height: calc(100vh - 132px); border-radius: 10px; object-fit: contain; background: #080b12; }
|
||
.preview-frame { width: min(1200px, 100%); height: calc(100vh - 132px); border: 1px solid #263044; border-radius: 10px; background: #080b12; }
|
||
.preview-audio { width: min(720px, 100%); }
|
||
.empty { width: min(640px, 100%); padding: 28px; border: 1px solid #263044; border-radius: 10px; background: #151a2a; color: #c6d0e1; text-align: center; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="shell">
|
||
<header>
|
||
<h1>`+html.EscapeString(title)+`</h1>
|
||
<div class="meta">Публичный просмотр файла</div>
|
||
</header>
|
||
<main>`+preview+`</main>
|
||
</div>
|
||
</body>
|
||
</html>`)
|
||
}
|
||
|
||
func (h *NodeHandler) renderPublicFolder(w http.ResponseWriter, r *http.Request, node *model.Node, children []model.Node, showBack bool) {
|
||
token := chi.URLParam(r, "token")
|
||
backLink := ""
|
||
if showBack && node.ParentID != nil && *node.ParentID != "" {
|
||
backLink = `<a class="back-link" href="` + html.EscapeString(h.publicNodeURL(token, *node.ParentID)) + `">← Назад к папке выше</a>`
|
||
}
|
||
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)
|
||
if child.NodeType == model.NodeTypeOfficeDocument {
|
||
if officeID := officeIDFromNode(&child); officeID != "" {
|
||
href = h.publicOfficeURL(token, child.ID, officeID)
|
||
}
|
||
}
|
||
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; }
|
||
.nav { max-width: 980px; margin: 0 auto 14px; }
|
||
.back-link { display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; border: 1px solid #263044; border-radius: 8px; background: #151a2a; color: #c7d2fe; text-decoration: none; font-weight: 700; }
|
||
.back-link:hover { border-color: #6366f1; background: #191f34; }
|
||
.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><div class="nav">`+backLink+`</div>`+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 (h *NodeHandler) publicOfficeURL(token, nodeID, officeID string) string {
|
||
values := url.Values{}
|
||
values.Set("files_token", token)
|
||
values.Set("node_id", nodeID)
|
||
return strings.TrimRight(h.cfg.PublicBaseURL, "/") + "/office/public/" + url.PathEscape(officeID) + "?" + values.Encode()
|
||
}
|
||
|
||
func officeIDFromNode(node *model.Node) string {
|
||
if node == nil || node.ExternalURL == nil {
|
||
return ""
|
||
}
|
||
if match := strings.TrimPrefix(*node.ExternalURL, "/office/"); match != *node.ExternalURL {
|
||
return strings.TrimSpace(match)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
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")
|
||
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
|
||
}
|