feat: scaffold files service
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/files-service
|
||||||
|
/tmp
|
||||||
|
/.env
|
||||||
|
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
ENV GOPRIVATE=gitea.estateliga.work
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /files-service ./cmd/server
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
COPY --from=build /files-service /files-service
|
||||||
|
COPY migrations /migrations
|
||||||
|
EXPOSE 3001
|
||||||
|
ENTRYPOINT ["/files-service"]
|
||||||
|
|
||||||
34
README.md
Normal file
34
README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Files Service
|
||||||
|
|
||||||
|
Единый сервис файлов Portal: папки, документы Office, Google-ссылки, PDF/медиа, доступы и временные ссылки.
|
||||||
|
|
||||||
|
## Что уже заложено
|
||||||
|
|
||||||
|
- `files_nodes` — дерево папок и файлов.
|
||||||
|
- `files_access` — прямой доступ `view/edit`, наследуется от родительских папок.
|
||||||
|
- `files_public_links` — временные ссылки на просмотр/скачивание.
|
||||||
|
- MinIO-хранилище для бинарных файлов.
|
||||||
|
- InternalAuth через Portal (`X-Internal-Key`, `X-User-Id`).
|
||||||
|
|
||||||
|
## Основные API
|
||||||
|
|
||||||
|
- `GET /api/nodes?scope=my|shared&parent_id=<uuid>`
|
||||||
|
- `POST /api/folders`
|
||||||
|
- `POST /api/files` multipart: `file`, `parent_id`, `title`
|
||||||
|
- `GET /api/nodes/{id}`
|
||||||
|
- `PATCH /api/nodes/{id}`
|
||||||
|
- `DELETE /api/nodes/{id}`
|
||||||
|
- `GET /api/nodes/{id}/download`
|
||||||
|
- `GET /api/nodes/{id}/access`
|
||||||
|
- `PUT /api/nodes/{id}/access`
|
||||||
|
- `POST /api/nodes/{id}/public-links`
|
||||||
|
- `GET /public/{token}`
|
||||||
|
- `GET /public/{token}/download`
|
||||||
|
|
||||||
|
## Миграционный путь
|
||||||
|
|
||||||
|
1. Подключить proxy Portal `/api/files-service/*` к этому сервису.
|
||||||
|
2. Сделать новый UI `Файлы`: `Мои файлы` и `Доступные мне`.
|
||||||
|
3. Перенести старые `office_documents` и `google_sheets` в `files_nodes`.
|
||||||
|
4. После проверки выключить старые `/api/office` и `/api/sheets`.
|
||||||
|
|
||||||
110
cmd/server/main.go
Normal file
110
cmd/server/main.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
commondb "gitea.estateliga.work/admin/portal-common/db"
|
||||||
|
commonmw "gitea.estateliga.work/admin/portal-common/middleware"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"files-service/internal/config"
|
||||||
|
"files-service/internal/handler"
|
||||||
|
"files-service/internal/migrate"
|
||||||
|
"files-service/internal/repository"
|
||||||
|
"files-service/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
pool, err := commondb.ConnectWithRetry(ctx, cfg.DatabaseURL, 2*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("connect database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if err := migrate.Run(context.Background(), pool, cfg.MigrationsDir); err != nil {
|
||||||
|
slog.Error("run migrations", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := storage.New(storage.Config{
|
||||||
|
Endpoint: cfg.MinIOEndpoint,
|
||||||
|
AccessKey: cfg.MinIOAccessKey,
|
||||||
|
SecretKey: cfg.MinIOSecretKey,
|
||||||
|
Bucket: cfg.MinIOBucket,
|
||||||
|
UseSSL: cfg.MinIOUseSSL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("init storage", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := store.EnsureBucket(ctx); err != nil {
|
||||||
|
slog.Warn("ensure bucket failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
healthH := handler.NewHealthHandler(pool)
|
||||||
|
nodeH := handler.NewNodeHandler(cfg, repository.NewNodeRepository(pool), store)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(chimw.RequestID)
|
||||||
|
r.Use(chimw.RealIP)
|
||||||
|
r.Use(chimw.Recoverer)
|
||||||
|
|
||||||
|
r.Get("/healthz", healthH.Healthz)
|
||||||
|
r.Get("/readyz", healthH.Readyz)
|
||||||
|
|
||||||
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
r.Use(commonmw.InternalAuth(cfg.InternalAPIKey))
|
||||||
|
r.Get("/nodes", nodeH.List)
|
||||||
|
r.Post("/folders", nodeH.CreateFolder)
|
||||||
|
r.Post("/files", nodeH.UploadFile)
|
||||||
|
r.Route("/nodes/{id}", func(r chi.Router) {
|
||||||
|
r.Get("/", nodeH.Get)
|
||||||
|
r.Patch("/", nodeH.Update)
|
||||||
|
r.Delete("/", nodeH.Delete)
|
||||||
|
r.Get("/download", nodeH.Download)
|
||||||
|
r.Get("/access", nodeH.ListAccess)
|
||||||
|
r.Put("/access", nodeH.ReplaceAccess)
|
||||||
|
r.Post("/public-links", nodeH.CreatePublicLink)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/public/{token}", nodeH.PublicMeta)
|
||||||
|
r.Get("/public/{token}/download", nodeH.PublicDownload)
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + cfg.ServerPort,
|
||||||
|
Handler: r,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Minute,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("files server starting", "port", cfg.ServerPort, "pod", cfg.PodName)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("server error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
slog.Error("server shutdown error", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
go.mod
Normal file
37
go.mod
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
module files-service
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require (
|
||||||
|
gitea.estateliga.work/admin/portal-common v0.2.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
|
||||||
|
github.com/minio/minio-go/v7 v7.1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||||
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
)
|
||||||
78
go.sum
Normal file
78
go.sum
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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=
|
||||||
|
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=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||||
|
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
|
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
|
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
|
||||||
|
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||||
|
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
62
internal/config/config.go
Normal file
62
internal/config/config.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServerPort string
|
||||||
|
DatabaseURL string
|
||||||
|
MigrationsDir string
|
||||||
|
InternalAPIKey string
|
||||||
|
PublicBaseURL string
|
||||||
|
|
||||||
|
MinIOEndpoint string
|
||||||
|
MinIOAccessKey string
|
||||||
|
MinIOSecretKey string
|
||||||
|
MinIOBucket string
|
||||||
|
MinIOUseSSL bool
|
||||||
|
|
||||||
|
PodName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
return &Config{
|
||||||
|
ServerPort: envStr("SERVER_PORT", "3001"),
|
||||||
|
DatabaseURL: envStr("DATABASE_URL", "postgres://files:files@localhost:5432/files?sslmode=disable"),
|
||||||
|
MigrationsDir: envStr("MIGRATIONS_DIR", "/migrations"),
|
||||||
|
InternalAPIKey: envStr("INTERNAL_API_KEY", envStr("PORTAL_INTERNAL_API_KEY", "")),
|
||||||
|
PublicBaseURL: envStr("PUBLIC_BASE_URL", "https://portal.estateliga.work"),
|
||||||
|
MinIOEndpoint: envStr("MINIO_ENDPOINT", ""),
|
||||||
|
MinIOAccessKey: envStr("MINIO_ACCESS_KEY", ""),
|
||||||
|
MinIOSecretKey: envStr("MINIO_SECRET_KEY", ""),
|
||||||
|
MinIOBucket: envStr("MINIO_BUCKET", "portal-files"),
|
||||||
|
MinIOUseSSL: envBool("MINIO_USE_SSL", false),
|
||||||
|
PodName: envStr("POD_NAME", hostname()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envBool(key string, def bool) bool {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if b, err := strconv.ParseBool(v); err == nil {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func envStr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostname() string {
|
||||||
|
h, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
27
internal/handler/health.go
Normal file
27
internal/handler/health.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HealthHandler struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler {
|
||||||
|
return &HealthHandler{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.pool.Ping(r.Context()); err != nil {
|
||||||
|
writeInternalError(w, r, err, "database unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||||
|
}
|
||||||
44
internal/handler/helpers.go
Normal file
44
internal/handler/helpers.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInternalError(w http.ResponseWriter, r *http.Request, err error, msg string) {
|
||||||
|
slog.Error("http error", "method", r.Method, "path", r.URL.Path, "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
335
internal/handler/node.go
Normal file
335
internal/handler/node.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
commonmw "gitea.estateliga.work/admin/portal-common/middleware"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"files-service/internal/config"
|
||||||
|
"files-service/internal/model"
|
||||||
|
"files-service/internal/repository"
|
||||||
|
"files-service/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NodeHandler struct {
|
||||||
|
cfg *config.Config
|
||||||
|
repo *repository.NodeRepository
|
||||||
|
store *storage.Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNodeHandler(cfg *config.Config, repo *repository.NodeRepository, store *storage.Storage) *NodeHandler {
|
||||||
|
return &NodeHandler{cfg: cfg, repo: repo, store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := commonmw.GetUserID(r.Context())
|
||||||
|
scope := r.URL.Query().Get("scope")
|
||||||
|
parentID := emptyToNil(r.URL.Query().Get("parent_id"))
|
||||||
|
nodes, err := h.repo.List(r.Context(), userID, subordinates(r), scope, parentID)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to list files")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
node, ok := h.requireNode(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) CreateFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.CreateFolderRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Title = strings.TrimSpace(req.Title)
|
||||||
|
if req.Title == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "title is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := commonmw.GetUserID(r.Context())
|
||||||
|
node, err := h.repo.CreateFolder(r.Context(), req.Title, req.ParentID, userID)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to create folder")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.repo.Audit(r.Context(), userID, "files.folder_create", "files_node", node.ID, "{}")
|
||||||
|
writeJSON(w, http.StatusCreated, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.store.Configured() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid multipart")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
filename := filepath.Base(header.Filename)
|
||||||
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||||
|
if !storage.AllowedExtension(ext) {
|
||||||
|
writeError(w, http.StatusBadRequest, "file type is not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(r.FormValue("title"))
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
|
}
|
||||||
|
userID := commonmw.GetUserID(r.Context())
|
||||||
|
key := storage.GenerateKey(userID, filename)
|
||||||
|
contentType := storage.GuessContentType(filename, header.Header.Get("Content-Type"))
|
||||||
|
if err := h.store.PutObject(r.Context(), key, file, header.Size, contentType); err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to upload file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parentID := emptyToNil(r.FormValue("parent_id"))
|
||||||
|
node, err := h.repo.CreateFile(r.Context(), &model.Node{
|
||||||
|
ParentID: parentID,
|
||||||
|
Title: title,
|
||||||
|
OwnerUserID: userID,
|
||||||
|
StorageKey: &key,
|
||||||
|
OriginalFilename: &filename,
|
||||||
|
MimeType: &contentType,
|
||||||
|
Extension: &ext,
|
||||||
|
SizeBytes: header.Size,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to create file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.repo.Audit(r.Context(), userID, "files.file_upload", "files_node", node.ID, "{}")
|
||||||
|
writeJSON(w, http.StatusCreated, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.UpdateNodeRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Title != nil {
|
||||||
|
title := strings.TrimSpace(*req.Title)
|
||||||
|
if title == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "title is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Title = &title
|
||||||
|
}
|
||||||
|
userID := commonmw.GetUserID(r.Context())
|
||||||
|
node, err := h.repo.Update(r.Context(), chi.URLParam(r, "id"), userID, req)
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to update file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.repo.Audit(r.Context(), userID, "files.node_update", "files_node", node.ID, "{}")
|
||||||
|
writeJSON(w, http.StatusOK, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) 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) {
|
||||||
|
writeError(w, http.StatusNotFound, "file not found")
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to delete file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.repo.Audit(r.Context(), userID, "files.node_delete", "files_node", id, "{}")
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||||
|
node, ok := h.requireNode(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.streamNode(w, r, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) ListAccess(w http.ResponseWriter, r *http.Request) {
|
||||||
|
node, ok := h.requireNode(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if node.EffectiveAccess != model.AccessEdit {
|
||||||
|
writeError(w, http.StatusForbidden, "edit access required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
access, err := h.repo.ListAccess(r.Context(), node.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to list access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, access)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) ReplaceAccess(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.ReplaceAccessRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := commonmw.GetUserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.repo.ReplaceAccess(r.Context(), id, userID, req.Access); errors.Is(err, repository.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "file not found")
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to update access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.repo.Audit(r.Context(), userID, "files.access_update", "files_node", id, "{}")
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) CreatePublicLink(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.PublicLinkRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ExpiresAt.Before(time.Now().Add(time.Minute)) {
|
||||||
|
writeError(w, http.StatusBadRequest, "expires_at must be in future")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := newToken()
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to create public link")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := commonmw.GetUserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
linkID, err := h.repo.CreatePublicLink(r.Context(), id, userID, token, req.ExpiresAt)
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to create public link")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.repo.Audit(r.Context(), userID, "files.public_link_create", "files_node", id, "{}")
|
||||||
|
writeJSON(w, http.StatusCreated, model.PublicLinkResponse{
|
||||||
|
ID: linkID,
|
||||||
|
URL: strings.TrimRight(h.cfg.PublicBaseURL, "/") + "/public/files/" + token,
|
||||||
|
ExpiresAt: req.ExpiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) {
|
||||||
|
node, ok := h.publicNode(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) PublicDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
node, ok := h.publicNode(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.streamNode(w, r, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) requireNode(w http.ResponseWriter, r *http.Request) (*model.Node, bool) {
|
||||||
|
node, err := h.repo.GetForUser(r.Context(), chi.URLParam(r, "id"), commonmw.GetUserID(r.Context()), subordinates(r))
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "file not found")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to get file")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return node, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) publicNode(w http.ResponseWriter, r *http.Request) (*model.Node, bool) {
|
||||||
|
node, err := h.repo.GetByPublicToken(r.Context(), chi.URLParam(r, "token"))
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "public link not found")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to open public link")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return node, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) streamNode(w http.ResponseWriter, r *http.Request, node *model.Node) {
|
||||||
|
if node.NodeType == model.NodeTypeFolder || node.StorageKey == nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "node is not downloadable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := h.store.Stat(r.Context(), *node.StorageKey)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to open file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start, end, hasRange := storage.ParseRange(r.Header.Get("Range"), info.Size)
|
||||||
|
body, info, err := h.store.GetObject(r.Context(), *node.StorageKey, start, end)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, r, err, "failed to stream file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer body.Close()
|
||||||
|
filename := node.Title
|
||||||
|
if node.OriginalFilename != nil && *node.OriginalFilename != "" {
|
||||||
|
filename = *node.OriginalFilename
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Disposition", `inline; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`)
|
||||||
|
storage.WriteRangeResponse(w, info.ContentType, info.Size, start, end, hasRange)
|
||||||
|
_, _ = io.Copy(w, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyToNil(v string) *string {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func subordinates(r *http.Request) []string {
|
||||||
|
ids := csvHeader(r, "X-User-Subordinates")
|
||||||
|
if len(ids) == 0 {
|
||||||
|
ids = csvHeader(r, "X-User-Subordinate-Ids")
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
54
internal/migrate/migrate.go
Normal file
54
internal/migrate/migrate.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(ctx context.Context, pool *pgxpool.Pool, migrationsDir string) error {
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version VARCHAR(255) PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := filepath.Glob(filepath.Join(migrationsDir, "*.up.sql"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("glob migrations: %w", err)
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
version := strings.TrimSuffix(filepath.Base(f), ".up.sql")
|
||||||
|
var exists bool
|
||||||
|
if err := pool.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)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sql, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", version, err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||||
|
return fmt.Errorf("apply migration %s: %w", version, err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
|
||||||
|
return fmt.Errorf("record migration %s: %w", version, err)
|
||||||
|
}
|
||||||
|
slog.Info("applied migration", "version", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
67
internal/model/model.go
Normal file
67
internal/model/model.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
NodeTypeFolder = "folder"
|
||||||
|
NodeTypeFile = "file"
|
||||||
|
NodeTypeGoogleSheet = "google_sheet"
|
||||||
|
NodeTypeOfficeDocument = "office_document"
|
||||||
|
|
||||||
|
AccessView = "view"
|
||||||
|
AccessEdit = "edit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
|
NodeType string `json:"node_type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
OwnerUserID string `json:"owner_user_id"`
|
||||||
|
OwnerDepartmentID *string `json:"owner_department_id,omitempty"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
UpdatedBy *string `json:"updated_by,omitempty"`
|
||||||
|
StorageKey *string `json:"storage_key,omitempty"`
|
||||||
|
OriginalFilename *string `json:"original_filename,omitempty"`
|
||||||
|
MimeType *string `json:"mime_type,omitempty"`
|
||||||
|
Extension *string `json:"extension,omitempty"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
OfficeFormat *string `json:"office_format,omitempty"`
|
||||||
|
ExternalURL *string `json:"external_url,omitempty"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
EffectiveAccess string `json:"effective_access"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Access struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
AccessLevel string `json:"access_level"`
|
||||||
|
GrantedBy string `json:"granted_by,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateFolderRequest struct {
|
||||||
|
ParentID *string `json:"parent_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateNodeRequest struct {
|
||||||
|
ParentID *string `json:"parent_id"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplaceAccessRequest struct {
|
||||||
|
Access []Access `json:"access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicLinkRequest struct {
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicLinkResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
283
internal/repository/node.go
Normal file
283
internal/repository/node.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"files-service/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
type NodeRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNodeRepository(pool *pgxpool.Pool) *NodeRepository {
|
||||||
|
return &NodeRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanNode(scan func(dest ...any) error) (*model.Node, error) {
|
||||||
|
var n model.Node
|
||||||
|
err := scan(
|
||||||
|
&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,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return &n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs []string, scope string, parentID *string) ([]model.Node, error) {
|
||||||
|
args := []any{userID, subordinateIDs}
|
||||||
|
where := []string{"n.deleted_at IS NULL"}
|
||||||
|
if parentID == nil || *parentID == "" {
|
||||||
|
where = append(where, "n.parent_id IS NULL")
|
||||||
|
} else {
|
||||||
|
args = append(args, *parentID)
|
||||||
|
where = append(where, fmt.Sprintf("n.parent_id = $%d", len(args)))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch scope {
|
||||||
|
case "shared":
|
||||||
|
where = append(where, `n.owner_user_id <> $1 AND (
|
||||||
|
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,
|
||||||
|
effective_node_access(n.id, $1, $2::text[]),
|
||||||
|
n.created_at, n.updated_at, 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) 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
|
||||||
|
FROM files_nodes n
|
||||||
|
WHERE n.id = $1
|
||||||
|
AND n.deleted_at IS NULL
|
||||||
|
AND effective_node_access(n.id, $2, $3::text[]) <> ''
|
||||||
|
`, id, userID, subordinateIDs).Scan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepository) CreateFolder(ctx context.Context, title string, parentID *string, ownerID string) (*model.Node, error) {
|
||||||
|
return scanNode(r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO files_nodes (parent_id, node_type, title, owner_user_id, created_by)
|
||||||
|
VALUES ($1, 'folder', $2, $3, $3)
|
||||||
|
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
|
||||||
|
`, parentID, title, ownerID).Scan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepository) CreateFile(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, storage_key,
|
||||||
|
original_filename, mime_type, extension, size_bytes)
|
||||||
|
VALUES ($1, 'file', $2, $3, $3, $4, $5, $6, $7, $8)
|
||||||
|
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
|
||||||
|
`, n.ParentID, n.Title, n.OwnerUserID, n.StorageKey, n.OriginalFilename, n.MimeType, n.Extension, n.SizeBytes).Scan)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
SET title = COALESCE($3, title),
|
||||||
|
parent_id = COALESCE($4, parent_id),
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = now(),
|
||||||
|
version = version + 1
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND effective_node_access(id, $2, '{}'::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, deleted_at
|
||||||
|
`, id, actorID, req.Title, req.ParentID).Scan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepository) SoftDelete(ctx context.Context, id, actorID string) error {
|
||||||
|
tag, err := r.pool.Exec(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
|
||||||
|
)
|
||||||
|
UPDATE files_nodes
|
||||||
|
SET deleted_at = now(), updated_by = $2, updated_at = now()
|
||||||
|
WHERE id IN (SELECT id FROM subtree)
|
||||||
|
AND effective_node_access($1, $2, '{}'::text[]) = 'edit'
|
||||||
|
`, id, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return 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
|
||||||
|
FROM files_access
|
||||||
|
WHERE node_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]model.Access, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var a model.Access
|
||||||
|
if err := rows.Scan(&a.UserID, &a.AccessLevel, &a.GrantedBy, &a.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepository) ReplaceAccess(ctx context.Context, nodeID, actorID string, access []model.Access) error {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var canEdit bool
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT effective_node_access($1, $2, '{}'::text[]) = 'edit'`, nodeID, actorID).Scan(&canEdit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !canEdit {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM files_access WHERE node_id = $1`, nodeID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, a := range normalizeAccess(access) {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO files_access (node_id, user_id, access_level, granted_by)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (node_id, user_id)
|
||||||
|
DO UPDATE SET access_level = EXCLUDED.access_level, granted_by = EXCLUDED.granted_by
|
||||||
|
`, nodeID, a.UserID, a.AccessLevel, actorID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAccess(access []model.Access) []model.Access {
|
||||||
|
seen := map[string]model.Access{}
|
||||||
|
for _, a := range access {
|
||||||
|
if a.UserID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.AccessLevel != model.AccessEdit {
|
||||||
|
a.AccessLevel = model.AccessView
|
||||||
|
}
|
||||||
|
if prev, ok := seen[a.UserID]; ok && prev.AccessLevel == model.AccessEdit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[a.UserID] = a
|
||||||
|
}
|
||||||
|
out := make([]model.Access, 0, len(seen))
|
||||||
|
for _, a := range seen {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepository) CreatePublicLink(ctx context.Context, nodeID, actorID, token string, expiresAt time.Time) (string, error) {
|
||||||
|
hash := TokenHash(token)
|
||||||
|
var id string
|
||||||
|
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)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepository) GetByPublicToken(ctx context.Context, token 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,
|
||||||
|
'view' AS effective_access, n.created_at, n.updated_at, 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
|
||||||
|
`, TokenHash(token)).Scan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepository) Audit(ctx context.Context, actorID, action, entityType, entityID string, meta string) {
|
||||||
|
if meta == "" {
|
||||||
|
meta = "{}"
|
||||||
|
}
|
||||||
|
_, _ = r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO files_audit_events (actor_user_id, action, entity_type, entity_id, meta)
|
||||||
|
VALUES (NULLIF($1, '')::uuid, $2, $3, NULLIF($4, '')::uuid, $5::jsonb)
|
||||||
|
`, actorID, action, entityType, entityID, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TokenHash(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
183
internal/storage/minio.go
Normal file
183
internal/storage/minio.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Endpoint string
|
||||||
|
AccessKey string
|
||||||
|
SecretKey string
|
||||||
|
Bucket string
|
||||||
|
UseSSL bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Storage struct {
|
||||||
|
cfg Config
|
||||||
|
client *minio.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObjectInfo struct {
|
||||||
|
Size int64
|
||||||
|
ContentType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) (*Storage, error) {
|
||||||
|
if cfg.Endpoint == "" || cfg.AccessKey == "" || cfg.SecretKey == "" {
|
||||||
|
return &Storage{cfg: cfg}, nil
|
||||||
|
}
|
||||||
|
cli, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||||
|
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||||
|
Secure: cfg.UseSSL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init minio client: %w", err)
|
||||||
|
}
|
||||||
|
return &Storage{cfg: cfg, client: cli}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Configured() bool {
|
||||||
|
return s.client != nil && s.cfg.Bucket != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) EnsureBucket(ctx context.Context) error {
|
||||||
|
if !s.Configured() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
exists, err := s.client.BucketExists(ctx, s.cfg.Bucket)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check bucket: %w", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateKey(ownerID, filename string) string {
|
||||||
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||||
|
if !AllowedExtension(ext) {
|
||||||
|
ext = "bin"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/%s.%s", ownerID, uuid.NewString(), ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllowedExtension(ext string) bool {
|
||||||
|
switch strings.ToLower(ext) {
|
||||||
|
case "doc", "docx", "xls", "xlsx", "xlsm", "ppt", "pptx", "ods", "odt", "odp",
|
||||||
|
"pdf", "png", "jpg", "jpeg", "webp", "gif", "mp4", "webm", "mov", "m4v", "mp3", "wav", "ogg":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GuessContentType(filename, clientType string) string {
|
||||||
|
if clientType != "" && clientType != "application/octet-stream" {
|
||||||
|
return clientType
|
||||||
|
}
|
||||||
|
if ext := filepath.Ext(filename); ext != "" {
|
||||||
|
if v := mime.TypeByExtension(ext); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) PutObject(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||||
|
if !s.Configured() {
|
||||||
|
return errors.New("storage not configured")
|
||||||
|
}
|
||||||
|
_, err := s.client.PutObject(ctx, s.cfg.Bucket, key, body, size, minio.PutObjectOptions{
|
||||||
|
ContentType: contentType,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Stat(ctx context.Context, key string) (*ObjectInfo, error) {
|
||||||
|
if !s.Configured() {
|
||||||
|
return nil, errors.New("storage not configured")
|
||||||
|
}
|
||||||
|
info, err := s.client.StatObject(ctx, s.cfg.Bucket, key, minio.StatObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ObjectInfo{Size: info.Size, ContentType: info.ContentType}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) GetObject(ctx context.Context, key string, rangeStart, rangeEnd int64) (io.ReadCloser, *ObjectInfo, error) {
|
||||||
|
if !s.Configured() {
|
||||||
|
return nil, nil, errors.New("storage not configured")
|
||||||
|
}
|
||||||
|
opts := minio.GetObjectOptions{}
|
||||||
|
if rangeStart > 0 || rangeEnd > 0 {
|
||||||
|
_ = opts.SetRange(rangeStart, rangeEnd)
|
||||||
|
}
|
||||||
|
obj, err := s.client.GetObject(ctx, s.cfg.Bucket, key, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
info, err := obj.Stat()
|
||||||
|
if err != nil {
|
||||||
|
_ = obj.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return obj, &ObjectInfo{Size: info.Size, ContentType: info.ContentType}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseRange(header string, totalSize int64) (start, end int64, ok bool) {
|
||||||
|
if !strings.HasPrefix(header, "bytes=") {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(strings.TrimPrefix(header, "bytes="), "-", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
if parts[0] == "" {
|
||||||
|
n, err := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
if err != nil || n <= 0 || n > totalSize {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return totalSize - n, totalSize - 1, true
|
||||||
|
}
|
||||||
|
start, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil || start < 0 || start >= totalSize {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
if parts[1] == "" {
|
||||||
|
return start, totalSize - 1, true
|
||||||
|
}
|
||||||
|
end, err = strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
if err != nil || end < start {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
if end >= totalSize {
|
||||||
|
end = totalSize - 1
|
||||||
|
}
|
||||||
|
return start, end, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteRangeResponse(w http.ResponseWriter, contentType string, totalSize, start, end int64, hasRange bool) {
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
if hasRange {
|
||||||
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10))
|
||||||
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(totalSize, 10))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
12
k8s/configmap.yaml
Normal file
12
k8s/configmap.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: files-config
|
||||||
|
namespace: files
|
||||||
|
data:
|
||||||
|
SERVER_PORT: "3001"
|
||||||
|
PUBLIC_BASE_URL: "https://portal.estateliga.work"
|
||||||
|
MINIO_ENDPOINT: "minio.minio.svc.cluster.local:9000"
|
||||||
|
MINIO_BUCKET: "portal-files"
|
||||||
|
MINIO_USE_SSL: "false"
|
||||||
|
|
||||||
10
k8s/kustomization.yaml
Normal file
10
k8s/kustomization.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- configmap.yaml
|
||||||
|
- secrets.yaml
|
||||||
|
- postgres.yaml
|
||||||
|
- server-service.yaml
|
||||||
|
- server-deployment.yaml
|
||||||
|
|
||||||
5
k8s/namespace.yaml
Normal file
5
k8s/namespace.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: files
|
||||||
|
|
||||||
55
k8s/postgres.yaml
Normal file
55
k8s/postgres.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: files
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: files-postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: files
|
||||||
|
spec:
|
||||||
|
serviceName: postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: files-postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: files-postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:17-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: postgres-secret
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
|
|
||||||
24
k8s/secrets.yaml
Normal file
24
k8s/secrets.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: files-secrets
|
||||||
|
namespace: files
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
DATABASE_URL: "postgres://files:files@postgres.files.svc.cluster.local:5432/files?sslmode=disable"
|
||||||
|
PORTAL_INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
||||||
|
INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
||||||
|
MINIO_ACCESS_KEY: "files-svc"
|
||||||
|
MINIO_SECRET_KEY: "REPLACE_AFTER_FIRST_DEPLOY"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: postgres-secret
|
||||||
|
namespace: files
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
POSTGRES_USER: files
|
||||||
|
POSTGRES_PASSWORD: files
|
||||||
|
POSTGRES_DB: files
|
||||||
|
|
||||||
87
k8s/server-deployment.yaml
Normal file
87
k8s/server-deployment.yaml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: files-server
|
||||||
|
namespace: files
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: files-server
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: files-server
|
||||||
|
spec:
|
||||||
|
terminationGracePeriodSeconds: 15
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
runAsGroup: 65532
|
||||||
|
fsGroup: 65532
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: files-server
|
||||||
|
image: localhost:30300/admin/files-server:latest
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
ports:
|
||||||
|
- containerPort: 3001
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: files-config
|
||||||
|
- secretRef:
|
||||||
|
name: files-secrets
|
||||||
|
env:
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: 3001
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 30
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: 3001
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: 3001
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
---
|
||||||
|
apiVersion: autoscaling/v2
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: files-server
|
||||||
|
namespace: files
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: files-server
|
||||||
|
minReplicas: 2
|
||||||
|
maxReplicas: 5
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 70
|
||||||
|
|
||||||
12
k8s/server-service.yaml
Normal file
12
k8s/server-service.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: files-server
|
||||||
|
namespace: files
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: files-server
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 3001
|
||||||
|
|
||||||
5
migrations/001_initial.down.sql
Normal file
5
migrations/001_initial.down.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
DROP TABLE IF EXISTS files_audit_events;
|
||||||
|
DROP TABLE IF EXISTS files_public_links;
|
||||||
|
DROP TABLE IF EXISTS files_access;
|
||||||
|
DROP TABLE IF EXISTS files_nodes;
|
||||||
|
|
||||||
71
migrations/001_initial.up.sql
Normal file
71
migrations/001_initial.up.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE files_nodes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
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')),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
owner_user_id UUID NOT NULL,
|
||||||
|
owner_department_id UUID,
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
updated_by UUID,
|
||||||
|
storage_key TEXT,
|
||||||
|
original_filename TEXT,
|
||||||
|
mime_type TEXT,
|
||||||
|
extension TEXT,
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
office_format TEXT,
|
||||||
|
external_url TEXT,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT files_nodes_file_storage_check CHECK (
|
||||||
|
node_type IN ('folder', 'google_sheet') OR storage_key IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX 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 files_nodes_type_idx ON files_nodes(node_type) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE files_access (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
node_id UUID NOT NULL REFERENCES files_nodes(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
access_level TEXT NOT NULL CHECK (access_level IN ('view', 'edit')),
|
||||||
|
granted_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (node_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX files_access_node_idx ON files_access(node_id);
|
||||||
|
CREATE INDEX files_access_user_idx ON files_access(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE files_public_links (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
node_id UUID NOT NULL REFERENCES files_nodes(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
access_level TEXT NOT NULL DEFAULT 'view' CHECK (access_level = 'view'),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX 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 TABLE files_audit_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
actor_user_id UUID,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id UUID,
|
||||||
|
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
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 files_audit_events_entity_idx ON files_audit_events(entity_type, entity_id, created_at DESC);
|
||||||
|
|
||||||
2
migrations/002_access_functions.down.sql
Normal file
2
migrations/002_access_functions.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DROP FUNCTION IF EXISTS effective_node_access(UUID, UUID, TEXT[]);
|
||||||
|
DROP FUNCTION IF EXISTS has_node_access(UUID, UUID);
|
||||||
48
migrations/002_access_functions.up.sql
Normal file
48
migrations/002_access_functions.up.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
CREATE OR REPLACE FUNCTION has_node_access(p_node_id UUID, p_user_id UUID)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id, parent_id FROM files_nodes WHERE id = p_node_id AND deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id, p.parent_id
|
||||||
|
FROM files_nodes p
|
||||||
|
JOIN ancestors a ON a.parent_id = p.id
|
||||||
|
WHERE p.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM ancestors a
|
||||||
|
JOIN files_access fa ON fa.node_id = a.id
|
||||||
|
WHERE fa.user_id = p_user_id
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION effective_node_access(p_node_id UUID, p_user_id UUID, p_subordinate_ids TEXT[])
|
||||||
|
RETURNS TEXT
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id, parent_id, owner_user_id FROM files_nodes WHERE id = p_node_id AND deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id, p.parent_id, p.owner_user_id
|
||||||
|
FROM files_nodes p
|
||||||
|
JOIN ancestors a ON a.parent_id = p.id
|
||||||
|
WHERE p.deleted_at IS NULL
|
||||||
|
),
|
||||||
|
direct_access AS (
|
||||||
|
SELECT fa.access_level
|
||||||
|
FROM ancestors a
|
||||||
|
JOIN files_access fa ON fa.node_id = a.id
|
||||||
|
WHERE fa.user_id = p_user_id
|
||||||
|
)
|
||||||
|
SELECT CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM files_nodes n WHERE n.id = p_node_id AND n.owner_user_id = p_user_id AND n.deleted_at IS NULL) THEN 'edit'
|
||||||
|
WHEN EXISTS (SELECT 1 FROM files_nodes n WHERE n.id = p_node_id AND n.owner_user_id::text = ANY(p_subordinate_ids) AND n.deleted_at IS NULL) THEN 'view'
|
||||||
|
WHEN EXISTS (SELECT 1 FROM direct_access WHERE access_level = 'edit') THEN 'edit'
|
||||||
|
WHEN EXISTS (SELECT 1 FROM direct_access) THEN 'view'
|
||||||
|
ELSE ''
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user