Compare commits

...

16 Commits

Author SHA1 Message Date
Grendgi
b0117c2c1a fix: keep trashed office docs out of my files
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 32s
CI / test (push) Successful in 21s
2026-06-24 12:15:42 +03:00
Grendgi
b75f274885 feat: search files across folders
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 29s
CI / test (push) Successful in 20s
2026-06-18 11:35:38 +03:00
Grendgi
1144b11ca3 feat: expose files storage health detail
All checks were successful
CI / hygiene (push) Successful in 1s
Build and Deploy / build-and-deploy (push) Successful in 34s
CI / test (push) Successful in 32s
2026-06-17 15:47:14 +03:00
Grendgi
aad9fa1b4a chore: use common header parsing 2026-06-17 13:54:04 +03:00
Grendgi
1a84c04314 chore: update portal-common to v0.3.0 2026-06-17 13:51:39 +03:00
Grendgi
dfbceb4bcd feat: use client-friendly public file links
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 31s
CI / test (push) Successful in 21s
2026-06-16 17:10:31 +03:00
Grendgi
47d4c2eb95 fix: open public folder files in new tab
All checks were successful
CI / hygiene (push) Successful in 3s
Build and Deploy / build-and-deploy (push) Successful in 28s
CI / test (push) Successful in 20s
2026-06-16 17:05:48 +03:00
Grendgi
51aba865eb fix: add back link to public folders
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 30s
CI / test (push) Successful in 20s
2026-06-16 17:02:29 +03:00
Grendgi
c397ff2c90 feat: link public folders to office viewer
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 32s
CI / test (push) Successful in 20s
2026-06-16 16:48:01 +03:00
Grendgi
6bd2251a98 fix: render public folder links
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 31s
CI / test (push) Successful in 19s
2026-06-16 16:39:04 +03:00
Grendgi
bfb1c2d0ab feat: list active public file links
All checks were successful
CI / hygiene (push) Successful in 1s
Build and Deploy / build-and-deploy (push) Successful in 30s
CI / test (push) Successful in 21s
2026-06-16 16:29:59 +03:00
Grendgi
44ea1fa36b fix: render public file previews
All checks were successful
CI / hygiene (push) Successful in 1s
Build and Deploy / build-and-deploy (push) Successful in 29s
CI / test (push) Successful in 20s
2026-06-16 16:12:03 +03:00
Grendgi
c831d2c7c6 feat: add visible trash restore for files
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 34s
CI / test (push) Successful in 19s
2026-06-16 15:48:09 +03:00
Grendgi
3dc5044c99 feat: support office document nodes
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 32s
CI / test (push) Successful in 20s
2026-06-16 15:25:33 +03:00
Grendgi
3de4e5dfe7 feat: add file move and trash retention
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 30s
CI / test (push) Successful in 19s
2026-06-16 14:40:28 +03:00
Grendgi
2723f20ab0 fix: support files proxy root paths
All checks were successful
CI / hygiene (push) Successful in 1s
Build and Deploy / build-and-deploy (push) Successful in 29s
CI / test (push) Successful in 19s
2026-06-16 13:57:55 +03:00
16 changed files with 1027 additions and 62 deletions

View File

@@ -24,8 +24,10 @@
- `POST /api/nodes/{id}/public-links`
- `GET /public/{token}` внутри сервиса
- `GET /public/{token}/download` внутри сервиса
- `GET /api/files/public/{token}` через Portal proxy
- `GET /api/files/public/{token}/download` через Portal proxy
- `GET /files/share/{token}` через Portal proxy — красивая публичная ссылка для клиентов
- `GET /files/share/{token}/download` через Portal proxy
- `GET /api/files/public/{token}` через Portal proxy — legacy URL для уже выданных ссылок
- `GET /api/files/public/{token}/download` через Portal proxy — legacy URL
## Миграционный путь

View File

@@ -55,8 +55,10 @@ func main() {
slog.Warn("ensure bucket failed", "error", err)
}
healthH := handler.NewHealthHandler(pool)
nodeH := handler.NewNodeHandler(cfg, repository.NewNodeRepository(pool), store)
healthH := handler.NewHealthHandler(pool, store)
nodeRepo := repository.NewNodeRepository(pool)
nodeH := handler.NewNodeHandler(cfg, nodeRepo, store)
go runTrashPurger(ctx, nodeRepo, store)
r := chi.NewRouter()
r.Use(chimw.RequestID)
@@ -65,25 +67,42 @@ func main() {
r.Get("/healthz", healthH.Healthz)
r.Get("/readyz", healthH.Readyz)
r.Get("/health/detail", healthH.Detail)
r.Route("/api", func(r chi.Router) {
r.Use(commonmw.InternalAuth(cfg.InternalAPIKey))
mountFilesAPI := func(r chi.Router) {
r.Get("/nodes", nodeH.List)
r.Post("/folders", nodeH.CreateFolder)
r.Post("/files", nodeH.UploadFile)
r.Get("/office-documents/links", nodeH.ListOfficeLinks)
r.Post("/office-documents", nodeH.CreateOfficeDocument)
r.Route("/nodes/{id}", func(r chi.Router) {
r.Get("/", nodeH.Get)
r.Patch("/", nodeH.Update)
r.Post("/move", nodeH.Move)
r.Post("/restore", nodeH.Restore)
r.Delete("/", nodeH.Delete)
r.Get("/download", nodeH.Download)
r.Get("/access", nodeH.ListAccess)
r.Put("/access", nodeH.ReplaceAccess)
r.Get("/public-links", nodeH.ListPublicLinks)
r.Post("/public-links", nodeH.CreatePublicLink)
})
}
r.Route("/api", func(r chi.Router) {
r.Use(commonmw.InternalAuth(cfg.InternalAPIKey))
mountFilesAPI(r)
})
r.Group(func(r chi.Router) {
r.Use(commonmw.InternalAuth(cfg.InternalAPIKey))
mountFilesAPI(r)
})
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)
r.Get("/public/{token}/nodes/{id}/office", nodeH.PublicOfficeInfo)
srv := &http.Server{
Addr: ":" + cfg.ServerPort,
@@ -108,3 +127,37 @@ func main() {
slog.Error("server shutdown error", "error", err)
}
}
func runTrashPurger(ctx context.Context, repo *repository.NodeRepository, store *storage.Storage) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
purgeExpiredTrash(ctx, repo, store)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func purgeExpiredTrash(ctx context.Context, repo *repository.NodeRepository, store *storage.Storage) {
keys, err := repo.ListPurgeableStorageKeys(ctx)
if err != nil {
slog.Warn("list purgeable files failed", "error", err)
return
}
for _, key := range keys {
if err := store.RemoveObject(ctx, key); err != nil {
slog.Warn("remove purged file object failed", "key", key, "error", err)
}
}
count, err := repo.PurgeExpired(ctx)
if err != nil {
slog.Warn("purge expired trash failed", "error", err)
return
}
if count > 0 {
slog.Info("purged expired trash", "nodes", count, "objects", len(keys))
}
}

2
go.mod
View File

@@ -3,7 +3,7 @@ module files-service
go 1.25.7
require (
gitea.estateliga.work/admin/portal-common v0.2.0
gitea.estateliga.work/admin/portal-common v0.3.0
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1

4
go.sum
View File

@@ -1,5 +1,5 @@
gitea.estateliga.work/admin/portal-common v0.2.0 h1:TwSxTDwSWnPJUGuCfjSy1f++MxvDIZ+HCUNMC3EFNcE=
gitea.estateliga.work/admin/portal-common v0.2.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk=
gitea.estateliga.work/admin/portal-common v0.3.0 h1:xpr9UeLXk5pCcNXcTVGZzJZr0Ni7An7DV0OkuYv9qVM=
gitea.estateliga.work/admin/portal-common v0.3.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=

View File

@@ -1,17 +1,24 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"files-service/internal/storage"
)
type HealthHandler struct {
pool *pgxpool.Pool
pool *pgxpool.Pool
store *storage.Storage
}
func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler {
return &HealthHandler{pool: pool}
func NewHealthHandler(pool *pgxpool.Pool, store *storage.Storage) *HealthHandler {
return &HealthHandler{pool: pool, store: store}
}
func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) {
@@ -25,3 +32,75 @@ func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
func (h *HealthHandler) Detail(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
components := []componentProbe{
h.probePostgres(ctx),
h.probeStorage(ctx),
h.probeTrash(ctx),
h.probeStorageMetadata(ctx),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"components": components})
}
type componentProbe struct {
Name string `json:"name"`
Status string `json:"status"`
LatencyMs int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
}
func (h *HealthHandler) probePostgres(ctx context.Context) componentProbe {
start := time.Now()
if err := h.pool.Ping(ctx); err != nil {
return componentProbe{Name: "postgres", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "postgres", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (h *HealthHandler) probeStorage(ctx context.Context) componentProbe {
start := time.Now()
if err := h.store.Check(ctx); err != nil {
return componentProbe{Name: "minio_storage", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "minio_storage", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (h *HealthHandler) probeTrash(ctx context.Context) componentProbe {
start := time.Now()
var due int
if err := h.pool.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM files_nodes
WHERE purge_after IS NOT NULL
AND purge_after <= now()
`).Scan(&due); err != nil {
return componentProbe{Name: "trash_purger", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
if due > 0 {
return componentProbe{Name: "trash_purger", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: "purge_due=" + strconv.Itoa(due)}
}
return componentProbe{Name: "trash_purger", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (h *HealthHandler) probeStorageMetadata(ctx context.Context) componentProbe {
start := time.Now()
var activeObjects int
if err := h.pool.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM files_nodes
WHERE deleted_at IS NULL
AND storage_key IS NOT NULL
`).Scan(&activeObjects); err != nil {
return componentProbe{Name: "storage_metadata", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{
Name: "storage_metadata",
Status: "ok",
LatencyMs: time.Since(start).Milliseconds(),
}
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"log/slog"
"net/http"
"strings"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -26,19 +25,3 @@ func decodeJSON(r *http.Request, v any) error {
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(v)
}
func csvHeader(r *http.Request, key string) []string {
raw := r.Header.Get(key)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}

View File

@@ -4,9 +4,12 @@ import (
"crypto/rand"
"encoding/base64"
"errors"
"html"
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
@@ -33,7 +36,8 @@ 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)
query := strings.TrimSpace(r.URL.Query().Get("q"))
nodes, err := h.repo.List(r.Context(), userID, subordinates(r), scope, parentID, query)
if err != nil {
writeInternalError(w, r, err, "failed to list files")
return
@@ -73,6 +77,63 @@ func (h *NodeHandler) CreateFolder(w http.ResponseWriter, r *http.Request) {
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")
@@ -156,10 +217,53 @@ func (h *NodeHandler) Update(w http.ResponseWriter, r *http.Request) {
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")
if err := h.repo.SoftDelete(r.Context(), id, userID); errors.Is(err, repository.ErrNotFound) {
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 {
@@ -214,6 +318,22 @@ func (h *NodeHandler) ReplaceAccess(w http.ResponseWriter, r *http.Request) {
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 {
@@ -231,7 +351,7 @@ func (h *NodeHandler) CreatePublicLink(w http.ResponseWriter, r *http.Request) {
}
userID := commonmw.GetUserID(r.Context())
id := chi.URLParam(r, "id")
linkID, err := h.repo.CreatePublicLink(r.Context(), id, userID, token, req.ExpiresAt)
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
@@ -241,11 +361,8 @@ func (h *NodeHandler) CreatePublicLink(w http.ResponseWriter, r *http.Request) {
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,
})
link.URL = h.publicURL(token)
writeJSON(w, http.StatusCreated, link)
}
func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) {
@@ -253,7 +370,20 @@ func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
writeJSON(w, http.StatusOK, node)
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) {
@@ -264,6 +394,59 @@ 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.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) {
@@ -314,6 +497,229 @@ func (h *NodeHandler) publicNode(w http.ResponseWriter, r *http.Request) (*model
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)
}
}
target := ""
if child.NodeType != model.NodeTypeFolder {
target = ` target="_blank" rel="noopener noreferrer"`
}
b.WriteString(`<a class="item" href="` + html.EscapeString(href) + `"` + target + `>`)
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, "/") + "/files/share/" + 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")
@@ -348,10 +754,19 @@ func emptyToNil(v string) *string {
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")
ids := commonmw.HeaderCSV(r, "X-User-Subordinates")
if len(ids) == 0 {
ids = csvHeader(r, "X-User-Subordinate-Ids")
ids = commonmw.HeaderCSV(r, "X-User-Subordinate-Ids")
}
return ids
}

View File

@@ -1,6 +1,9 @@
package model
import "time"
import (
"errors"
"time"
)
const (
NodeTypeFolder = "folder"
@@ -12,6 +15,8 @@ const (
AccessEdit = "edit"
)
var ErrInvalidMove = errors.New("invalid move")
type Node struct {
ID string `json:"id"`
ParentID *string `json:"parent_id,omitempty"`
@@ -32,6 +37,8 @@ type Node struct {
EffectiveAccess string `json:"effective_access"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TrashedAt *time.Time `json:"trashed_at,omitempty"`
PurgeAfter *time.Time `json:"purge_after,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
@@ -47,11 +54,28 @@ type CreateFolderRequest struct {
Title string `json:"title"`
}
type CreateOfficeDocumentRequest struct {
ParentID *string `json:"parent_id"`
Title string `json:"title"`
OfficeID string `json:"office_id"`
OfficeFormat string `json:"office_format"`
SizeBytes int64 `json:"size_bytes"`
}
type UpdateNodeRequest struct {
ParentID *string `json:"parent_id"`
Title *string `json:"title"`
}
type MoveNodeRequest struct {
ParentID *string `json:"parent_id"`
}
type PublicNodeResponse struct {
Node *Node `json:"node"`
Children []Node `json:"children,omitempty"`
}
type ReplaceAccessRequest struct {
Access []Access `json:"access"`
}
@@ -62,6 +86,7 @@ type PublicLinkRequest struct {
type PublicLinkResponse struct {
ID string `json:"id"`
URL string `json:"url"`
URL string `json:"url,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at,omitempty"`
}

View File

@@ -31,7 +31,7 @@ func scanNode(scan func(dest ...any) error) (*model.Node, error) {
&n.ID, &n.ParentID, &n.NodeType, &n.Title, &n.OwnerUserID, &n.OwnerDepartmentID,
&n.CreatedBy, &n.UpdatedBy, &n.StorageKey, &n.OriginalFilename, &n.MimeType,
&n.Extension, &n.SizeBytes, &n.OfficeFormat, &n.ExternalURL, &n.Version,
&n.EffectiveAccess, &n.CreatedAt, &n.UpdatedAt, &n.DeletedAt,
&n.EffectiveAccess, &n.CreatedAt, &n.UpdatedAt, &n.TrashedAt, &n.PurgeAfter, &n.DeletedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -39,9 +39,50 @@ func scanNode(scan func(dest ...any) error) (*model.Node, error) {
return &n, err
}
func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs []string, scope string, parentID *string) ([]model.Node, error) {
func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs []string, scope string, parentID *string, search string) ([]model.Node, error) {
if strings.TrimSpace(search) != "" {
return r.Search(ctx, userID, subordinateIDs, scope, search)
}
args := []any{userID, subordinateIDs}
where := []string{"n.deleted_at IS NULL"}
if scope == "trash" {
query := `
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,
'edit' AS effective_access,
n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at
FROM files_nodes n
LEFT JOIN files_nodes p ON p.id = n.parent_id
WHERE n.deleted_at IS NOT NULL
AND (n.parent_id IS NULL OR p.deleted_at IS NULL)
AND (
n.owner_user_id = $1
OR n.owner_user_id::text = ANY($2::text[])
OR EXISTS (
SELECT 1 FROM files_access a
WHERE a.node_id = n.id
AND a.user_id = $1
AND a.access_level = 'edit'
)
)
ORDER BY n.trashed_at DESC NULLS LAST, lower(n.title)`
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]model.Node, 0)
for rows.Next() {
n, err := scanNode(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *n)
}
return out, rows.Err()
}
if parentID == nil || *parentID == "" {
where = append(where, "n.parent_id IS NULL")
} else {
@@ -67,7 +108,7 @@ func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs
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,
effective_node_access(n.id, $1, $2::text[]),
n.created_at, n.updated_at, n.deleted_at
n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at
FROM files_nodes n
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY CASE WHEN n.node_type = 'folder' THEN 0 ELSE 1 END, lower(n.title), n.created_at DESC`
@@ -89,13 +130,105 @@ func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs
return out, rows.Err()
}
func (r *NodeRepository) Search(ctx context.Context, userID string, subordinateIDs []string, scope string, search string) ([]model.Node, error) {
pattern := "%" + strings.ToLower(strings.TrimSpace(search)) + "%"
args := []any{userID, subordinateIDs, pattern}
where := []string{`(
lower(n.title) LIKE $3
OR lower(COALESCE(n.original_filename, '')) LIKE $3
OR lower(COALESCE(n.extension, '')) LIKE $3
OR lower(COALESCE(n.mime_type, '')) LIKE $3
)`}
accessExpr := "effective_node_access(n.id, $1, $2::text[])"
if scope == "trash" {
where = append(where, "n.deleted_at IS NOT NULL")
where = append(where, `(
n.owner_user_id = $1
OR n.owner_user_id::text = ANY($2::text[])
OR EXISTS (
SELECT 1 FROM files_access a
WHERE a.node_id = n.id
AND a.user_id = $1
AND a.access_level = 'edit'
)
)`)
accessExpr = "'edit'"
} else {
where = append(where, "n.deleted_at IS NULL")
switch scope {
case "shared":
where = append(where, "n.owner_user_id <> $1")
where = append(where, `(
has_node_access(n.id, $1)
OR n.owner_user_id::text = ANY($2::text[])
)`)
default:
where = append(where, "n.owner_user_id = $1")
}
}
query := `
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,
` + accessExpr + `,
n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at
FROM files_nodes n
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY CASE WHEN n.node_type = 'folder' THEN 0 ELSE 1 END, lower(n.title), n.created_at DESC`
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]model.Node, 0)
for rows.Next() {
n, err := scanNode(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *n)
}
return out, rows.Err()
}
func (r *NodeRepository) ListChildrenForPublic(ctx context.Context, parentID string) ([]model.Node, error) {
rows, err := r.pool.Query(ctx, `
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,
'view' AS effective_access,
n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at
FROM files_nodes n
WHERE n.deleted_at IS NULL
AND n.parent_id = $1
ORDER BY CASE WHEN n.node_type = 'folder' THEN 0 ELSE 1 END, lower(n.title), n.created_at DESC
`, parentID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]model.Node, 0)
for rows.Next() {
n, err := scanNode(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *n)
}
return out, rows.Err()
}
func (r *NodeRepository) GetForUser(ctx context.Context, id, userID string, subordinateIDs []string) (*model.Node, error) {
return scanNode(r.pool.QueryRow(ctx, `
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,
effective_node_access(n.id, $2, $3::text[]),
n.created_at, n.updated_at, n.deleted_at
n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at
FROM files_nodes n
WHERE n.id = $1
AND n.deleted_at IS NULL
@@ -110,7 +243,7 @@ func (r *NodeRepository) CreateFolder(ctx context.Context, title string, parentI
RETURNING 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,
'edit' AS effective_access, created_at, updated_at, deleted_at
'edit' AS effective_access, created_at, updated_at, trashed_at, purge_after, deleted_at
`, parentID, title, ownerID).Scan)
}
@@ -123,10 +256,52 @@ func (r *NodeRepository) CreateFile(ctx context.Context, n *model.Node) (*model.
RETURNING 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,
'edit' AS effective_access, created_at, updated_at, deleted_at
'edit' AS effective_access, created_at, updated_at, trashed_at, purge_after, deleted_at
`, n.ParentID, n.Title, n.OwnerUserID, n.StorageKey, n.OriginalFilename, n.MimeType, n.Extension, n.SizeBytes).Scan)
}
func (r *NodeRepository) CreateOfficeDocument(ctx context.Context, n *model.Node) (*model.Node, error) {
return scanNode(r.pool.QueryRow(ctx, `
INSERT INTO files_nodes
(parent_id, node_type, title, owner_user_id, created_by, original_filename,
mime_type, extension, size_bytes, office_format, external_url)
VALUES ($1, 'office_document', $2, $3, $3, $4, $5, $6, $7, $8, $9)
RETURNING 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,
'edit' AS effective_access, created_at, updated_at, trashed_at, purge_after, deleted_at
`, n.ParentID, n.Title, n.OwnerUserID, n.OriginalFilename, n.MimeType, n.Extension, n.SizeBytes, n.OfficeFormat, n.ExternalURL).Scan)
}
func (r *NodeRepository) ListOfficeExternalURLs(ctx context.Context, userID string, subordinateIDs []string) ([]string, error) {
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT n.external_url
FROM files_nodes n
WHERE n.node_type = 'office_document'
AND n.external_url IS NOT NULL
AND (
n.owner_user_id = $1
OR has_node_access(n.id, $1)
OR n.owner_user_id::text = ANY($2::text[])
)
ORDER BY n.external_url
`, userID, subordinateIDs)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0)
for rows.Next() {
var url string
if err := rows.Scan(&url); err != nil {
return nil, err
}
out = append(out, url)
}
return out, rows.Err()
}
func (r *NodeRepository) Update(ctx context.Context, id, actorID string, req model.UpdateNodeRequest) (*model.Node, error) {
return scanNode(r.pool.QueryRow(ctx, `
UPDATE files_nodes
@@ -141,11 +316,44 @@ func (r *NodeRepository) Update(ctx context.Context, id, actorID string, req mod
RETURNING 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,
'edit' AS effective_access, created_at, updated_at, deleted_at
'edit' AS effective_access, created_at, updated_at, trashed_at, purge_after, deleted_at
`, id, actorID, req.Title, req.ParentID).Scan)
}
func (r *NodeRepository) SoftDelete(ctx context.Context, id, actorID string) error {
func (r *NodeRepository) Move(ctx context.Context, id, actorID string, subordinateIDs []string, parentID *string) (*model.Node, error) {
if parentID != nil && *parentID != "" {
var wouldCycle bool
if err := r.pool.QueryRow(ctx, `
WITH RECURSIVE subtree AS (
SELECT id FROM files_nodes WHERE id = $1 AND deleted_at IS NULL
UNION ALL
SELECT c.id FROM files_nodes c JOIN subtree s ON c.parent_id = s.id WHERE c.deleted_at IS NULL
)
SELECT EXISTS (SELECT 1 FROM subtree WHERE id = $2)
`, id, *parentID).Scan(&wouldCycle); err != nil {
return nil, err
}
if wouldCycle {
return nil, model.ErrInvalidMove
}
}
return scanNode(r.pool.QueryRow(ctx, `
UPDATE files_nodes
SET parent_id = $4,
updated_by = $2,
updated_at = now(),
version = version + 1
WHERE id = $1
AND deleted_at IS NULL
AND effective_node_access(id, $2, $3::text[]) = 'edit'
RETURNING 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,
'edit' AS effective_access, created_at, updated_at, trashed_at, purge_after, deleted_at
`, id, actorID, subordinateIDs, parentID).Scan)
}
func (r *NodeRepository) SoftDelete(ctx context.Context, id, actorID string, purgeAfter time.Time) error {
tag, err := r.pool.Exec(ctx, `
WITH RECURSIVE subtree AS (
SELECT id FROM files_nodes WHERE id = $1 AND deleted_at IS NULL
@@ -153,10 +361,10 @@ func (r *NodeRepository) SoftDelete(ctx context.Context, id, actorID string) err
SELECT c.id FROM files_nodes c JOIN subtree s ON c.parent_id = s.id WHERE c.deleted_at IS NULL
)
UPDATE files_nodes
SET deleted_at = now(), updated_by = $2, updated_at = now()
SET deleted_at = now(), trashed_at = now(), purge_after = $3, updated_by = $2, updated_at = now()
WHERE id IN (SELECT id FROM subtree)
AND effective_node_access($1, $2, '{}'::text[]) = 'edit'
`, id, actorID)
`, id, actorID, purgeAfter)
if err != nil {
return err
}
@@ -166,6 +374,90 @@ func (r *NodeRepository) SoftDelete(ctx context.Context, id, actorID string) err
return nil
}
func (r *NodeRepository) Restore(ctx context.Context, id, actorID string, subordinateIDs []string) (*model.Node, error) {
return scanNode(r.pool.QueryRow(ctx, `
WITH RECURSIVE subtree AS (
SELECT id FROM files_nodes WHERE id = $1 AND deleted_at IS NOT NULL
UNION ALL
SELECT c.id FROM files_nodes c JOIN subtree s ON c.parent_id = s.id WHERE c.deleted_at IS NOT NULL
),
restored AS (
UPDATE files_nodes n
SET deleted_at = NULL,
trashed_at = NULL,
purge_after = NULL,
parent_id = CASE
WHEN n.id = $1 AND EXISTS (
SELECT 1 FROM files_nodes p WHERE p.id = n.parent_id AND p.deleted_at IS NOT NULL
) THEN NULL
ELSE n.parent_id
END,
updated_by = $2,
updated_at = now(),
version = version + 1
WHERE n.id IN (SELECT id FROM subtree)
AND EXISTS (
SELECT 1 FROM files_nodes root
WHERE root.id = $1
AND root.deleted_at IS NOT NULL
AND (
root.owner_user_id = $2
OR root.owner_user_id::text = ANY($3::text[])
OR EXISTS (
SELECT 1 FROM files_access a
WHERE a.node_id = root.id
AND a.user_id = $2
AND a.access_level = 'edit'
)
)
)
)
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,
'edit' AS effective_access,
n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at
FROM files_nodes n
WHERE n.id = $1
AND n.deleted_at IS NULL
`, id, actorID, subordinateIDs).Scan)
}
func (r *NodeRepository) ListPurgeableStorageKeys(ctx context.Context) ([]string, error) {
rows, err := r.pool.Query(ctx, `
SELECT storage_key
FROM files_nodes
WHERE purge_after IS NOT NULL
AND purge_after <= now()
AND storage_key IS NOT NULL
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, err
}
out = append(out, key)
}
return out, rows.Err()
}
func (r *NodeRepository) PurgeExpired(ctx context.Context) (int64, error) {
tag, err := r.pool.Exec(ctx, `
DELETE FROM files_nodes
WHERE purge_after IS NOT NULL
AND purge_after <= now()
`)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
func (r *NodeRepository) ListAccess(ctx context.Context, nodeID string) ([]model.Access, error) {
rows, err := r.pool.Query(ctx, `
SELECT user_id, access_level, granted_by, created_at
@@ -240,19 +532,49 @@ func normalizeAccess(access []model.Access) []model.Access {
return out
}
func (r *NodeRepository) CreatePublicLink(ctx context.Context, nodeID, actorID, token string, expiresAt time.Time) (string, error) {
func (r *NodeRepository) CreatePublicLink(ctx context.Context, nodeID, actorID, token string, expiresAt time.Time) (*model.PublicLinkResponse, error) {
hash := TokenHash(token)
var id string
var link model.PublicLinkResponse
err := r.pool.QueryRow(ctx, `
INSERT INTO files_public_links (node_id, token_hash, expires_at, created_by)
SELECT $1, $2, $3, $4
WHERE effective_node_access($1, $4, '{}'::text[]) = 'edit'
RETURNING id
`, nodeID, hash, expiresAt, actorID).Scan(&id)
INSERT INTO files_public_links (node_id, token_hash, public_token, expires_at, created_by)
SELECT $1, $2, $3, $4, $5
WHERE effective_node_access($1, $5, '{}'::text[]) = 'edit'
RETURNING id, expires_at, created_at
`, nodeID, hash, token, expiresAt, actorID).Scan(&link.ID, &link.ExpiresAt, &link.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
return nil, ErrNotFound
}
return id, err
return &link, err
}
func (r *NodeRepository) ListPublicLinks(ctx context.Context, nodeID, actorID string) ([]model.PublicLinkResponse, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, COALESCE(public_token, ''), expires_at, created_at
FROM files_public_links
WHERE node_id = $1
AND revoked_at IS NULL
AND expires_at > now()
AND effective_node_access($1, $2, '{}'::text[]) = 'edit'
ORDER BY expires_at DESC, created_at DESC
`, nodeID, actorID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]model.PublicLinkResponse, 0)
for rows.Next() {
var link model.PublicLinkResponse
var token string
if err := rows.Scan(&link.ID, &token, &link.ExpiresAt, &link.CreatedAt); err != nil {
return nil, err
}
if token != "" {
link.URL = token
}
out = append(out, link)
}
return out, rows.Err()
}
func (r *NodeRepository) GetByPublicToken(ctx context.Context, token string) (*model.Node, error) {
@@ -260,7 +582,7 @@ func (r *NodeRepository) GetByPublicToken(ctx context.Context, token string) (*m
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,
'view' AS effective_access, n.created_at, n.updated_at, n.deleted_at
'view' AS effective_access, 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
@@ -270,6 +592,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 = "{}"

View File

@@ -66,6 +66,20 @@ func (s *Storage) EnsureBucket(ctx context.Context) error {
return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{})
}
func (s *Storage) Check(ctx context.Context) error {
if !s.Configured() {
return errors.New("storage not configured")
}
exists, err := s.client.BucketExists(ctx, s.cfg.Bucket)
if err != nil {
return fmt.Errorf("check bucket: %w", err)
}
if !exists {
return fmt.Errorf("bucket %q does not exist", s.cfg.Bucket)
}
return nil
}
func GenerateKey(ownerID, filename string) string {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
if !AllowedExtension(ext) {
@@ -137,6 +151,13 @@ func (s *Storage) GetObject(ctx context.Context, key string, rangeStart, rangeEn
return obj, &ObjectInfo{Size: info.Size, ContentType: info.ContentType}, nil
}
func (s *Storage) RemoveObject(ctx context.Context, key string) error {
if !s.Configured() {
return errors.New("storage not configured")
}
return s.client.RemoveObject(ctx, s.cfg.Bucket, key, minio.RemoveObjectOptions{})
}
func ParseRange(header string, totalSize int64) (start, end int64, ok bool) {
if !strings.HasPrefix(header, "bytes=") {
return 0, 0, false

View File

@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS files_nodes_purge_after_idx;
ALTER TABLE files_nodes
DROP COLUMN IF EXISTS purge_after,
DROP COLUMN IF EXISTS trashed_at;

View File

@@ -0,0 +1,7 @@
ALTER TABLE files_nodes
ADD COLUMN IF NOT EXISTS trashed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS purge_after TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS files_nodes_purge_after_idx
ON files_nodes(purge_after)
WHERE purge_after IS NOT NULL;

View File

@@ -0,0 +1,7 @@
ALTER TABLE files_nodes
DROP CONSTRAINT IF EXISTS files_nodes_file_storage_check;
ALTER TABLE files_nodes
ADD CONSTRAINT files_nodes_file_storage_check CHECK (
node_type IN ('folder', 'google_sheet') OR storage_key IS NOT NULL
);

View File

@@ -0,0 +1,7 @@
ALTER TABLE files_nodes
DROP CONSTRAINT IF EXISTS files_nodes_file_storage_check;
ALTER TABLE files_nodes
ADD CONSTRAINT files_nodes_file_storage_check CHECK (
node_type IN ('folder', 'google_sheet', 'office_document') OR storage_key IS NOT NULL
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE files_public_links
DROP COLUMN IF EXISTS public_token;

View File

@@ -0,0 +1,3 @@
ALTER TABLE files_public_links
ADD COLUMN IF NOT EXISTS public_token TEXT;