Compare commits
22 Commits
f591446422
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0117c2c1a | ||
|
|
b75f274885 | ||
|
|
1144b11ca3 | ||
|
|
aad9fa1b4a | ||
|
|
1a84c04314 | ||
|
|
dfbceb4bcd | ||
|
|
47d4c2eb95 | ||
|
|
51aba865eb | ||
|
|
c397ff2c90 | ||
|
|
6bd2251a98 | ||
|
|
bfb1c2d0ab | ||
|
|
44ea1fa36b | ||
|
|
c831d2c7c6 | ||
|
|
3dc5044c99 | ||
|
|
3de4e5dfe7 | ||
|
|
2723f20ab0 | ||
|
|
f12487d036 | ||
|
|
facdba7f0b | ||
|
|
1d2e2a2330 | ||
|
|
dff97c55dc | ||
|
|
79eac4d251 | ||
|
|
5d721186cd |
@@ -51,9 +51,10 @@ jobs:
|
|||||||
kubectl apply -f k8s/secrets.yaml
|
kubectl apply -f k8s/secrets.yaml
|
||||||
kubectl apply -f k8s/configmap.yaml
|
kubectl apply -f k8s/configmap.yaml
|
||||||
kubectl apply -f k8s/postgres.yaml
|
kubectl apply -f k8s/postgres.yaml
|
||||||
|
kubectl -n files rollout status statefulset/postgres --timeout=240s
|
||||||
|
kubectl -n files wait --for=condition=ready pod -l app=files-postgres --timeout=240s
|
||||||
kubectl apply -f k8s/server-deployment.yaml
|
kubectl apply -f k8s/server-deployment.yaml
|
||||||
kubectl apply -f k8s/server-service.yaml
|
kubectl apply -f k8s/server-service.yaml
|
||||||
kubectl -n files set image deployment/files-server \
|
kubectl -n files set image deployment/files-server \
|
||||||
files-server=${{ env.NODE_REGISTRY }}/admin/files-server:${{ github.sha }}
|
files-server=${{ env.NODE_REGISTRY }}/admin/files-server:${{ github.sha }}
|
||||||
kubectl -n files rollout status deployment/files-server --timeout=120s
|
kubectl -n files rollout status deployment/files-server --timeout=240s
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,10 @@
|
|||||||
- `POST /api/nodes/{id}/public-links`
|
- `POST /api/nodes/{id}/public-links`
|
||||||
- `GET /public/{token}` внутри сервиса
|
- `GET /public/{token}` внутри сервиса
|
||||||
- `GET /public/{token}/download` внутри сервиса
|
- `GET /public/{token}/download` внутри сервиса
|
||||||
- `GET /api/files/public/{token}` через Portal proxy
|
- `GET /files/share/{token}` через Portal proxy — красивая публичная ссылка для клиентов
|
||||||
- `GET /api/files/public/{token}/download` через 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
|
||||||
|
|
||||||
## Миграционный путь
|
## Миграционный путь
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
pool, err := commondb.ConnectWithRetry(ctx, cfg.DatabaseURL, 2*time.Minute)
|
pool, err := commondb.ConnectWithRetry(ctx, cfg.DatabaseURL, 10*time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("connect database", "error", err)
|
slog.Error("connect database", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -55,8 +55,10 @@ func main() {
|
|||||||
slog.Warn("ensure bucket failed", "error", err)
|
slog.Warn("ensure bucket failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
healthH := handler.NewHealthHandler(pool)
|
healthH := handler.NewHealthHandler(pool, store)
|
||||||
nodeH := handler.NewNodeHandler(cfg, repository.NewNodeRepository(pool), store)
|
nodeRepo := repository.NewNodeRepository(pool)
|
||||||
|
nodeH := handler.NewNodeHandler(cfg, nodeRepo, store)
|
||||||
|
go runTrashPurger(ctx, nodeRepo, store)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(chimw.RequestID)
|
r.Use(chimw.RequestID)
|
||||||
@@ -65,25 +67,42 @@ func main() {
|
|||||||
|
|
||||||
r.Get("/healthz", healthH.Healthz)
|
r.Get("/healthz", healthH.Healthz)
|
||||||
r.Get("/readyz", healthH.Readyz)
|
r.Get("/readyz", healthH.Readyz)
|
||||||
|
r.Get("/health/detail", healthH.Detail)
|
||||||
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
mountFilesAPI := func(r chi.Router) {
|
||||||
r.Use(commonmw.InternalAuth(cfg.InternalAPIKey))
|
|
||||||
r.Get("/nodes", nodeH.List)
|
r.Get("/nodes", nodeH.List)
|
||||||
r.Post("/folders", nodeH.CreateFolder)
|
r.Post("/folders", nodeH.CreateFolder)
|
||||||
r.Post("/files", nodeH.UploadFile)
|
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.Route("/nodes/{id}", func(r chi.Router) {
|
||||||
r.Get("/", nodeH.Get)
|
r.Get("/", nodeH.Get)
|
||||||
r.Patch("/", nodeH.Update)
|
r.Patch("/", nodeH.Update)
|
||||||
|
r.Post("/move", nodeH.Move)
|
||||||
|
r.Post("/restore", nodeH.Restore)
|
||||||
r.Delete("/", nodeH.Delete)
|
r.Delete("/", nodeH.Delete)
|
||||||
r.Get("/download", nodeH.Download)
|
r.Get("/download", nodeH.Download)
|
||||||
r.Get("/access", nodeH.ListAccess)
|
r.Get("/access", nodeH.ListAccess)
|
||||||
r.Put("/access", nodeH.ReplaceAccess)
|
r.Put("/access", nodeH.ReplaceAccess)
|
||||||
|
r.Get("/public-links", nodeH.ListPublicLinks)
|
||||||
r.Post("/public-links", nodeH.CreatePublicLink)
|
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}", nodeH.PublicMeta)
|
||||||
r.Get("/public/{token}/download", nodeH.PublicDownload)
|
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{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.ServerPort,
|
Addr: ":" + cfg.ServerPort,
|
||||||
@@ -108,3 +127,37 @@ func main() {
|
|||||||
slog.Error("server shutdown error", "error", err)
|
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
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
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/go-chi/chi/v5 v5.2.5
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.1
|
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.3.0 h1:xpr9UeLXk5pCcNXcTVGZzJZr0Ni7An7DV0OkuYv9qVM=
|
||||||
gitea.estateliga.work/admin/portal-common v0.2.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk=
|
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"files-service/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HealthHandler struct {
|
type HealthHandler struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
|
store *storage.Storage
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler {
|
func NewHealthHandler(pool *pgxpool.Pool, store *storage.Storage) *HealthHandler {
|
||||||
return &HealthHandler{pool: pool}
|
return &HealthHandler{pool: pool, store: store}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) {
|
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"})
|
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"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
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()
|
defer r.Body.Close()
|
||||||
return json.NewDecoder(r.Body).Decode(v)
|
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"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,7 +36,8 @@ func (h *NodeHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
userID := commonmw.GetUserID(r.Context())
|
userID := commonmw.GetUserID(r.Context())
|
||||||
scope := r.URL.Query().Get("scope")
|
scope := r.URL.Query().Get("scope")
|
||||||
parentID := emptyToNil(r.URL.Query().Get("parent_id"))
|
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 {
|
if err != nil {
|
||||||
writeInternalError(w, r, err, "failed to list files")
|
writeInternalError(w, r, err, "failed to list files")
|
||||||
return
|
return
|
||||||
@@ -61,6 +65,9 @@ func (h *NodeHandler) CreateFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := commonmw.GetUserID(r.Context())
|
userID := commonmw.GetUserID(r.Context())
|
||||||
|
if !h.requireWritableParent(w, r, userID, req.ParentID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
node, err := h.repo.CreateFolder(r.Context(), req.Title, req.ParentID, userID)
|
node, err := h.repo.CreateFolder(r.Context(), req.Title, req.ParentID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, r, err, "failed to create folder")
|
writeInternalError(w, r, err, "failed to create folder")
|
||||||
@@ -70,6 +77,63 @@ func (h *NodeHandler) CreateFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, node)
|
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) {
|
func (h *NodeHandler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
if !h.store.Configured() {
|
if !h.store.Configured() {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not configured")
|
writeError(w, http.StatusServiceUnavailable, "storage not configured")
|
||||||
@@ -97,13 +161,16 @@ func (h *NodeHandler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
title = strings.TrimSuffix(filename, filepath.Ext(filename))
|
title = strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
}
|
}
|
||||||
userID := commonmw.GetUserID(r.Context())
|
userID := commonmw.GetUserID(r.Context())
|
||||||
|
parentID := emptyToNil(r.FormValue("parent_id"))
|
||||||
|
if !h.requireWritableParent(w, r, userID, parentID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
key := storage.GenerateKey(userID, filename)
|
key := storage.GenerateKey(userID, filename)
|
||||||
contentType := storage.GuessContentType(filename, header.Header.Get("Content-Type"))
|
contentType := storage.GuessContentType(filename, header.Header.Get("Content-Type"))
|
||||||
if err := h.store.PutObject(r.Context(), key, file, header.Size, contentType); err != nil {
|
if err := h.store.PutObject(r.Context(), key, file, header.Size, contentType); err != nil {
|
||||||
writeInternalError(w, r, err, "failed to upload file")
|
writeInternalError(w, r, err, "failed to upload file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
parentID := emptyToNil(r.FormValue("parent_id"))
|
|
||||||
node, err := h.repo.CreateFile(r.Context(), &model.Node{
|
node, err := h.repo.CreateFile(r.Context(), &model.Node{
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
Title: title,
|
Title: title,
|
||||||
@@ -150,10 +217,53 @@ func (h *NodeHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, node)
|
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) {
|
func (h *NodeHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := commonmw.GetUserID(r.Context())
|
userID := commonmw.GetUserID(r.Context())
|
||||||
id := chi.URLParam(r, "id")
|
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")
|
writeError(w, http.StatusNotFound, "file not found")
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
@@ -208,6 +318,22 @@ func (h *NodeHandler) ReplaceAccess(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
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) {
|
func (h *NodeHandler) CreatePublicLink(w http.ResponseWriter, r *http.Request) {
|
||||||
var req model.PublicLinkRequest
|
var req model.PublicLinkRequest
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
@@ -225,7 +351,7 @@ func (h *NodeHandler) CreatePublicLink(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
userID := commonmw.GetUserID(r.Context())
|
userID := commonmw.GetUserID(r.Context())
|
||||||
id := chi.URLParam(r, "id")
|
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) {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
writeError(w, http.StatusNotFound, "file not found")
|
writeError(w, http.StatusNotFound, "file not found")
|
||||||
return
|
return
|
||||||
@@ -235,11 +361,8 @@ func (h *NodeHandler) CreatePublicLink(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.repo.Audit(r.Context(), userID, "files.public_link_create", "files_node", id, "{}")
|
h.repo.Audit(r.Context(), userID, "files.public_link_create", "files_node", id, "{}")
|
||||||
writeJSON(w, http.StatusCreated, model.PublicLinkResponse{
|
link.URL = h.publicURL(token)
|
||||||
ID: linkID,
|
writeJSON(w, http.StatusCreated, link)
|
||||||
URL: strings.TrimRight(h.cfg.PublicBaseURL, "/") + "/api/files/public/" + token,
|
|
||||||
ExpiresAt: req.ExpiresAt,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) {
|
func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -247,7 +370,20 @@ func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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) {
|
func (h *NodeHandler) PublicDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -258,6 +394,59 @@ func (h *NodeHandler) PublicDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.streamNode(w, r, node)
|
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) {
|
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))
|
node, err := h.repo.GetForUser(r.Context(), chi.URLParam(r, "id"), commonmw.GetUserID(r.Context()), subordinates(r))
|
||||||
if errors.Is(err, repository.ErrNotFound) {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
@@ -271,6 +460,30 @@ func (h *NodeHandler) requireNode(w http.ResponseWriter, r *http.Request) (*mode
|
|||||||
return node, true
|
return node, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) requireWritableParent(w http.ResponseWriter, r *http.Request, userID string, parentID *string) bool {
|
||||||
|
if parentID == nil || strings.TrimSpace(*parentID) == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
parent, err := h.repo.GetForUser(r.Context(), *parentID, userID, subordinates(r))
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "parent folder not found")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to check parent folder")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if parent.NodeType != model.NodeTypeFolder {
|
||||||
|
writeError(w, http.StatusBadRequest, "parent_id must point to folder")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if parent.EffectiveAccess != model.AccessEdit {
|
||||||
|
writeError(w, http.StatusForbidden, "edit access to parent folder required")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *NodeHandler) publicNode(w http.ResponseWriter, r *http.Request) (*model.Node, bool) {
|
func (h *NodeHandler) publicNode(w http.ResponseWriter, r *http.Request) (*model.Node, bool) {
|
||||||
node, err := h.repo.GetByPublicToken(r.Context(), chi.URLParam(r, "token"))
|
node, err := h.repo.GetByPublicToken(r.Context(), chi.URLParam(r, "token"))
|
||||||
if errors.Is(err, repository.ErrNotFound) {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
@@ -284,6 +497,229 @@ func (h *NodeHandler) publicNode(w http.ResponseWriter, r *http.Request) (*model
|
|||||||
return node, true
|
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) {
|
func (h *NodeHandler) streamNode(w http.ResponseWriter, r *http.Request, node *model.Node) {
|
||||||
if node.NodeType == model.NodeTypeFolder || node.StorageKey == nil {
|
if node.NodeType == model.NodeTypeFolder || node.StorageKey == nil {
|
||||||
writeError(w, http.StatusBadRequest, "node is not downloadable")
|
writeError(w, http.StatusBadRequest, "node is not downloadable")
|
||||||
@@ -318,10 +754,19 @@ func emptyToNil(v string) *string {
|
|||||||
return &v
|
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 {
|
func subordinates(r *http.Request) []string {
|
||||||
ids := csvHeader(r, "X-User-Subordinates")
|
ids := commonmw.HeaderCSV(r, "X-User-Subordinates")
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
ids = csvHeader(r, "X-User-Subordinate-Ids")
|
ids = commonmw.HeaderCSV(r, "X-User-Subordinate-Ids")
|
||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Run(ctx context.Context, pool *pgxpool.Pool, migrationsDir string) error {
|
func Run(ctx context.Context, pool *pgxpool.Pool, migrationsDir string) error {
|
||||||
_, err := pool.Exec(ctx, `
|
tx, err := pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin migration tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(8507432101)`); err != nil {
|
||||||
|
return fmt.Errorf("acquire migration lock: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
version VARCHAR(255) PRIMARY KEY,
|
version VARCHAR(255) PRIMARY KEY,
|
||||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
@@ -32,7 +42,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool, migrationsDir string) error {
|
|||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
version := strings.TrimSuffix(filepath.Base(f), ".up.sql")
|
version := strings.TrimSuffix(filepath.Base(f), ".up.sql")
|
||||||
var exists bool
|
var exists bool
|
||||||
if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&exists); err != nil {
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&exists); err != nil {
|
||||||
return fmt.Errorf("check migration %s: %w", version, err)
|
return fmt.Errorf("check migration %s: %w", version, err)
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
@@ -42,13 +52,13 @@ func Run(ctx context.Context, pool *pgxpool.Pool, migrationsDir string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read migration %s: %w", version, err)
|
return fmt.Errorf("read migration %s: %w", version, err)
|
||||||
}
|
}
|
||||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
if _, err := tx.Exec(ctx, string(sql)); err != nil {
|
||||||
return fmt.Errorf("apply migration %s: %w", version, err)
|
return fmt.Errorf("apply migration %s: %w", version, err)
|
||||||
}
|
}
|
||||||
if _, err := pool.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
|
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
|
||||||
return fmt.Errorf("record migration %s: %w", version, err)
|
return fmt.Errorf("record migration %s: %w", version, err)
|
||||||
}
|
}
|
||||||
slog.Info("applied migration", "version", version)
|
slog.Info("applied migration", "version", version)
|
||||||
}
|
}
|
||||||
return nil
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NodeTypeFolder = "folder"
|
NodeTypeFolder = "folder"
|
||||||
@@ -12,6 +15,8 @@ const (
|
|||||||
AccessEdit = "edit"
|
AccessEdit = "edit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrInvalidMove = errors.New("invalid move")
|
||||||
|
|
||||||
type Node struct {
|
type Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ParentID *string `json:"parent_id,omitempty"`
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
@@ -32,6 +37,8 @@ type Node struct {
|
|||||||
EffectiveAccess string `json:"effective_access"`
|
EffectiveAccess string `json:"effective_access"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +54,28 @@ type CreateFolderRequest struct {
|
|||||||
Title string `json:"title"`
|
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 {
|
type UpdateNodeRequest struct {
|
||||||
ParentID *string `json:"parent_id"`
|
ParentID *string `json:"parent_id"`
|
||||||
Title *string `json:"title"`
|
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 {
|
type ReplaceAccessRequest struct {
|
||||||
Access []Access `json:"access"`
|
Access []Access `json:"access"`
|
||||||
}
|
}
|
||||||
@@ -62,6 +86,7 @@ type PublicLinkRequest struct {
|
|||||||
|
|
||||||
type PublicLinkResponse struct {
|
type PublicLinkResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url,omitempty"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
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.ID, &n.ParentID, &n.NodeType, &n.Title, &n.OwnerUserID, &n.OwnerDepartmentID,
|
||||||
&n.CreatedBy, &n.UpdatedBy, &n.StorageKey, &n.OriginalFilename, &n.MimeType,
|
&n.CreatedBy, &n.UpdatedBy, &n.StorageKey, &n.OriginalFilename, &n.MimeType,
|
||||||
&n.Extension, &n.SizeBytes, &n.OfficeFormat, &n.ExternalURL, &n.Version,
|
&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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -39,9 +39,50 @@ func scanNode(scan func(dest ...any) error) (*model.Node, error) {
|
|||||||
return &n, err
|
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}
|
args := []any{userID, subordinateIDs}
|
||||||
where := []string{"n.deleted_at IS NULL"}
|
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 == "" {
|
if parentID == nil || *parentID == "" {
|
||||||
where = append(where, "n.parent_id IS NULL")
|
where = append(where, "n.parent_id IS NULL")
|
||||||
} else {
|
} else {
|
||||||
@@ -51,7 +92,10 @@ func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs
|
|||||||
|
|
||||||
switch scope {
|
switch scope {
|
||||||
case "shared":
|
case "shared":
|
||||||
where = append(where, `n.owner_user_id <> $1 AND (
|
if parentID == nil || *parentID == "" {
|
||||||
|
where = append(where, "n.owner_user_id <> $1")
|
||||||
|
}
|
||||||
|
where = append(where, `(
|
||||||
has_node_access(n.id, $1)
|
has_node_access(n.id, $1)
|
||||||
OR n.owner_user_id::text = ANY($2::text[])
|
OR n.owner_user_id::text = ANY($2::text[])
|
||||||
)`)
|
)`)
|
||||||
@@ -64,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.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.extension, n.size_bytes, n.office_format, n.external_url, n.version,
|
||||||
effective_node_access(n.id, $1, $2::text[]),
|
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
|
FROM files_nodes n
|
||||||
WHERE ` + strings.Join(where, " AND ") + `
|
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`
|
ORDER BY CASE WHEN n.node_type = 'folder' THEN 0 ELSE 1 END, lower(n.title), n.created_at DESC`
|
||||||
@@ -86,13 +130,105 @@ func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs
|
|||||||
return out, rows.Err()
|
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) {
|
func (r *NodeRepository) GetForUser(ctx context.Context, id, userID string, subordinateIDs []string) (*model.Node, error) {
|
||||||
return scanNode(r.pool.QueryRow(ctx, `
|
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,
|
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.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.extension, n.size_bytes, n.office_format, n.external_url, n.version,
|
||||||
effective_node_access(n.id, $2, $3::text[]),
|
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
|
FROM files_nodes n
|
||||||
WHERE n.id = $1
|
WHERE n.id = $1
|
||||||
AND n.deleted_at IS NULL
|
AND n.deleted_at IS NULL
|
||||||
@@ -107,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,
|
RETURNING id, parent_id, node_type, title, owner_user_id, owner_department_id,
|
||||||
created_by, updated_by, storage_key, original_filename, mime_type,
|
created_by, updated_by, storage_key, original_filename, mime_type,
|
||||||
extension, size_bytes, office_format, external_url, version,
|
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)
|
`, parentID, title, ownerID).Scan)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,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,
|
RETURNING id, parent_id, node_type, title, owner_user_id, owner_department_id,
|
||||||
created_by, updated_by, storage_key, original_filename, mime_type,
|
created_by, updated_by, storage_key, original_filename, mime_type,
|
||||||
extension, size_bytes, office_format, external_url, version,
|
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)
|
`, 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) {
|
func (r *NodeRepository) Update(ctx context.Context, id, actorID string, req model.UpdateNodeRequest) (*model.Node, error) {
|
||||||
return scanNode(r.pool.QueryRow(ctx, `
|
return scanNode(r.pool.QueryRow(ctx, `
|
||||||
UPDATE files_nodes
|
UPDATE files_nodes
|
||||||
@@ -138,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,
|
RETURNING id, parent_id, node_type, title, owner_user_id, owner_department_id,
|
||||||
created_by, updated_by, storage_key, original_filename, mime_type,
|
created_by, updated_by, storage_key, original_filename, mime_type,
|
||||||
extension, size_bytes, office_format, external_url, version,
|
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)
|
`, 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, `
|
tag, err := r.pool.Exec(ctx, `
|
||||||
WITH RECURSIVE subtree AS (
|
WITH RECURSIVE subtree AS (
|
||||||
SELECT id FROM files_nodes WHERE id = $1 AND deleted_at IS NULL
|
SELECT id FROM files_nodes WHERE id = $1 AND deleted_at IS NULL
|
||||||
@@ -150,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
|
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
|
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)
|
WHERE id IN (SELECT id FROM subtree)
|
||||||
AND effective_node_access($1, $2, '{}'::text[]) = 'edit'
|
AND effective_node_access($1, $2, '{}'::text[]) = 'edit'
|
||||||
`, id, actorID)
|
`, id, actorID, purgeAfter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -163,6 +374,90 @@ func (r *NodeRepository) SoftDelete(ctx context.Context, id, actorID string) err
|
|||||||
return nil
|
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) {
|
func (r *NodeRepository) ListAccess(ctx context.Context, nodeID string) ([]model.Access, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT user_id, access_level, granted_by, created_at
|
SELECT user_id, access_level, granted_by, created_at
|
||||||
@@ -237,19 +532,49 @@ func normalizeAccess(access []model.Access) []model.Access {
|
|||||||
return out
|
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)
|
hash := TokenHash(token)
|
||||||
var id string
|
var link model.PublicLinkResponse
|
||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
INSERT INTO files_public_links (node_id, token_hash, expires_at, created_by)
|
INSERT INTO files_public_links (node_id, token_hash, public_token, expires_at, created_by)
|
||||||
SELECT $1, $2, $3, $4
|
SELECT $1, $2, $3, $4, $5
|
||||||
WHERE effective_node_access($1, $4, '{}'::text[]) = 'edit'
|
WHERE effective_node_access($1, $5, '{}'::text[]) = 'edit'
|
||||||
RETURNING id
|
RETURNING id, expires_at, created_at
|
||||||
`, nodeID, hash, expiresAt, actorID).Scan(&id)
|
`, nodeID, hash, token, expiresAt, actorID).Scan(&link.ID, &link.ExpiresAt, &link.CreatedAt)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
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) {
|
func (r *NodeRepository) GetByPublicToken(ctx context.Context, token string) (*model.Node, error) {
|
||||||
@@ -257,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,
|
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.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.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
|
FROM files_public_links l
|
||||||
JOIN files_nodes n ON n.id = l.node_id
|
JOIN files_nodes n ON n.id = l.node_id
|
||||||
WHERE l.token_hash = $1
|
WHERE l.token_hash = $1
|
||||||
@@ -267,6 +592,39 @@ func (r *NodeRepository) GetByPublicToken(ctx context.Context, token string) (*m
|
|||||||
`, TokenHash(token)).Scan)
|
`, 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) {
|
func (r *NodeRepository) Audit(ctx context.Context, actorID, action, entityType, entityID string, meta string) {
|
||||||
if meta == "" {
|
if meta == "" {
|
||||||
meta = "{}"
|
meta = "{}"
|
||||||
|
|||||||
@@ -66,6 +66,20 @@ func (s *Storage) EnsureBucket(ctx context.Context) error {
|
|||||||
return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{})
|
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 {
|
func GenerateKey(ownerID, filename string) string {
|
||||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||||
if !AllowedExtension(ext) {
|
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
|
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) {
|
func ParseRange(header string, totalSize int64) (start, end int64, ok bool) {
|
||||||
if !strings.HasPrefix(header, "bytes=") {
|
if !strings.HasPrefix(header, "bytes=") {
|
||||||
return 0, 0, false
|
return 0, 0, false
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
SERVER_PORT: "3001"
|
SERVER_PORT: "3001"
|
||||||
PUBLIC_BASE_URL: "https://portal.estateliga.work"
|
PUBLIC_BASE_URL: "https://portal.estateliga.work"
|
||||||
MINIO_ENDPOINT: "minio.minio.svc.cluster.local:9000"
|
# MinIO живёт на отдельном сервере. Как в telephony/meet/portal,
|
||||||
|
# пинним DNS через hostAliases в deployment, чтобы не попасть во
|
||||||
|
# внутренний Traefik/default-cert.
|
||||||
|
MINIO_ENDPOINT: "s3-minio.estateliga.work"
|
||||||
MINIO_BUCKET: "portal-files"
|
MINIO_BUCKET: "portal-files"
|
||||||
MINIO_USE_SSL: "false"
|
MINIO_USE_SSL: "true"
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ spec:
|
|||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: postgres-secret
|
name: postgres-secret
|
||||||
|
env:
|
||||||
|
- name: PGDATA
|
||||||
|
value: /var/lib/postgresql/data/pgdata
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /var/lib/postgresql/data
|
mountPath: /var/lib/postgresql/data
|
||||||
@@ -52,4 +55,3 @@ spec:
|
|||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 10Gi
|
storage: 10Gi
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ stringData:
|
|||||||
DATABASE_URL: "postgres://files:files@postgres.files.svc.cluster.local:5432/files?sslmode=disable"
|
DATABASE_URL: "postgres://files:files@postgres.files.svc.cluster.local:5432/files?sslmode=disable"
|
||||||
PORTAL_INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
PORTAL_INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
||||||
INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
||||||
MINIO_ACCESS_KEY: "files-svc"
|
MINIO_ACCESS_KEY: "admjn"
|
||||||
MINIO_SECRET_KEY: "REPLACE_AFTER_FIRST_DEPLOY"
|
MINIO_SECRET_KEY: "TropicalMacaw9Fantasize"
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
@@ -21,4 +21,3 @@ stringData:
|
|||||||
POSTGRES_USER: files
|
POSTGRES_USER: files
|
||||||
POSTGRES_PASSWORD: files
|
POSTGRES_PASSWORD: files
|
||||||
POSTGRES_DB: files
|
POSTGRES_DB: files
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: files-server
|
app: files-server
|
||||||
spec:
|
spec:
|
||||||
|
hostAliases:
|
||||||
|
- ip: "77.105.173.42"
|
||||||
|
hostnames:
|
||||||
|
- "s3-minio.estateliga.work"
|
||||||
terminationGracePeriodSeconds: 15
|
terminationGracePeriodSeconds: 15
|
||||||
securityContext:
|
securityContext:
|
||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
@@ -46,7 +50,7 @@ spec:
|
|||||||
path: /healthz
|
path: /healthz
|
||||||
port: 3001
|
port: 3001
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
failureThreshold: 30
|
failureThreshold: 60
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
@@ -84,4 +88,3 @@ spec:
|
|||||||
target:
|
target:
|
||||||
type: Utilization
|
type: Utilization
|
||||||
averageUtilization: 70
|
averageUtilization: 70
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
CREATE TABLE files_nodes (
|
CREATE TABLE IF NOT EXISTS files_nodes (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
parent_id UUID REFERENCES files_nodes(id) ON DELETE CASCADE,
|
parent_id UUID REFERENCES files_nodes(id) ON DELETE CASCADE,
|
||||||
node_type TEXT NOT NULL CHECK (node_type IN ('folder', 'file', 'google_sheet', 'office_document')),
|
node_type TEXT NOT NULL CHECK (node_type IN ('folder', 'file', 'google_sheet', 'office_document')),
|
||||||
@@ -25,11 +25,11 @@ CREATE TABLE files_nodes (
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX files_nodes_parent_idx ON files_nodes(parent_id) WHERE deleted_at IS NULL;
|
CREATE INDEX IF NOT EXISTS files_nodes_parent_idx ON files_nodes(parent_id) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX files_nodes_owner_idx ON files_nodes(owner_user_id) WHERE deleted_at IS NULL;
|
CREATE INDEX IF NOT EXISTS files_nodes_owner_idx ON files_nodes(owner_user_id) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX files_nodes_type_idx ON files_nodes(node_type) WHERE deleted_at IS NULL;
|
CREATE INDEX IF NOT EXISTS files_nodes_type_idx ON files_nodes(node_type) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE files_access (
|
CREATE TABLE IF NOT EXISTS files_access (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
node_id UUID NOT NULL REFERENCES files_nodes(id) ON DELETE CASCADE,
|
node_id UUID NOT NULL REFERENCES files_nodes(id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL,
|
user_id UUID NOT NULL,
|
||||||
@@ -39,10 +39,10 @@ CREATE TABLE files_access (
|
|||||||
UNIQUE (node_id, user_id)
|
UNIQUE (node_id, user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX files_access_node_idx ON files_access(node_id);
|
CREATE INDEX IF NOT EXISTS files_access_node_idx ON files_access(node_id);
|
||||||
CREATE INDEX files_access_user_idx ON files_access(user_id);
|
CREATE INDEX IF NOT EXISTS files_access_user_idx ON files_access(user_id);
|
||||||
|
|
||||||
CREATE TABLE files_public_links (
|
CREATE TABLE IF NOT EXISTS files_public_links (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
node_id UUID NOT NULL REFERENCES files_nodes(id) ON DELETE CASCADE,
|
node_id UUID NOT NULL REFERENCES files_nodes(id) ON DELETE CASCADE,
|
||||||
token_hash TEXT NOT NULL UNIQUE,
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
@@ -53,10 +53,10 @@ CREATE TABLE files_public_links (
|
|||||||
revoked_at TIMESTAMPTZ
|
revoked_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX files_public_links_node_idx ON files_public_links(node_id);
|
CREATE INDEX IF NOT EXISTS files_public_links_node_idx ON files_public_links(node_id);
|
||||||
CREATE INDEX files_public_links_active_idx ON files_public_links(token_hash, expires_at) WHERE revoked_at IS NULL;
|
CREATE INDEX IF NOT EXISTS files_public_links_active_idx ON files_public_links(token_hash, expires_at) WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE files_audit_events (
|
CREATE TABLE IF NOT EXISTS files_audit_events (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
actor_user_id UUID,
|
actor_user_id UUID,
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
@@ -66,6 +66,5 @@ CREATE TABLE files_audit_events (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX files_audit_events_actor_idx ON files_audit_events(actor_user_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS files_audit_events_actor_idx ON files_audit_events(actor_user_id, created_at DESC);
|
||||||
CREATE INDEX files_audit_events_entity_idx ON files_audit_events(entity_type, entity_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS files_audit_events_entity_idx ON files_audit_events(entity_type, entity_id, created_at DESC);
|
||||||
|
|
||||||
|
|||||||
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