Compare commits
16 Commits
f12487d036
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0117c2c1a | ||
|
|
b75f274885 | ||
|
|
1144b11ca3 | ||
|
|
aad9fa1b4a | ||
|
|
1a84c04314 | ||
|
|
dfbceb4bcd | ||
|
|
47d4c2eb95 | ||
|
|
51aba865eb | ||
|
|
c397ff2c90 | ||
|
|
6bd2251a98 | ||
|
|
bfb1c2d0ab | ||
|
|
44ea1fa36b | ||
|
|
c831d2c7c6 | ||
|
|
3dc5044c99 | ||
|
|
3de4e5dfe7 | ||
|
|
2723f20ab0 |
@@ -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
|
||||
|
||||
## Миграционный путь
|
||||
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 = "{}"
|
||||
|
||||
@@ -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
|
||||
|
||||
5
migrations/003_trash_retention.down.sql
Normal file
5
migrations/003_trash_retention.down.sql
Normal 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;
|
||||
7
migrations/003_trash_retention.up.sql
Normal file
7
migrations/003_trash_retention.up.sql
Normal 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;
|
||||
7
migrations/004_office_document_nodes.down.sql
Normal file
7
migrations/004_office_document_nodes.down.sql
Normal 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
|
||||
);
|
||||
7
migrations/004_office_document_nodes.up.sql
Normal file
7
migrations/004_office_document_nodes.up.sql
Normal 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
|
||||
);
|
||||
3
migrations/005_public_link_tokens.down.sql
Normal file
3
migrations/005_public_link_tokens.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE files_public_links
|
||||
DROP COLUMN IF EXISTS public_token;
|
||||
|
||||
3
migrations/005_public_link_tokens.up.sql
Normal file
3
migrations/005_public_link_tokens.up.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE files_public_links
|
||||
ADD COLUMN IF NOT EXISTS public_token TEXT;
|
||||
|
||||
Reference in New Issue
Block a user