commit cf92fda20e5151844c6e10678a10a915a998bced Author: Grendgi Date: Tue Jun 16 12:41:36 2026 +0300 feat: scaffold files service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88fe37b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/files-service +/tmp +/.env + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..df790f5 --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..55ce868 --- /dev/null +++ b/README.md @@ -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=` +- `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`. + diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..cde072c --- /dev/null +++ b/cmd/server/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7ac2544 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1eb994d --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..cc63002 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..bc3a00b --- /dev/null +++ b/internal/handler/health.go @@ -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"}) +} diff --git a/internal/handler/helpers.go b/internal/handler/helpers.go new file mode 100644 index 0000000..53cc66d --- /dev/null +++ b/internal/handler/helpers.go @@ -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 +} diff --git a/internal/handler/node.go b/internal/handler/node.go new file mode 100644 index 0000000..f7a148c --- /dev/null +++ b/internal/handler/node.go @@ -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 +} diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go new file mode 100644 index 0000000..5678d24 --- /dev/null +++ b/internal/migrate/migrate.go @@ -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 +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..bfd55d2 --- /dev/null +++ b/internal/model/model.go @@ -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"` +} diff --git a/internal/repository/node.go b/internal/repository/node.go new file mode 100644 index 0000000..c2cc950 --- /dev/null +++ b/internal/repository/node.go @@ -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[:]) +} diff --git a/internal/storage/minio.go b/internal/storage/minio.go new file mode 100644 index 0000000..cc155fd --- /dev/null +++ b/internal/storage/minio.go @@ -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) +} diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..4370e69 --- /dev/null +++ b/k8s/configmap.yaml @@ -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" + diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..045bf71 --- /dev/null +++ b/k8s/kustomization.yaml @@ -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 + diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..f63ce7d --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: files + diff --git a/k8s/postgres.yaml b/k8s/postgres.yaml new file mode 100644 index 0000000..2fc3a8e --- /dev/null +++ b/k8s/postgres.yaml @@ -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 + diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml new file mode 100644 index 0000000..0c87dd0 --- /dev/null +++ b/k8s/secrets.yaml @@ -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 + diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml new file mode 100644 index 0000000..f26f93c --- /dev/null +++ b/k8s/server-deployment.yaml @@ -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 + diff --git a/k8s/server-service.yaml b/k8s/server-service.yaml new file mode 100644 index 0000000..b39418c --- /dev/null +++ b/k8s/server-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: files-server + namespace: files +spec: + selector: + app: files-server + ports: + - port: 80 + targetPort: 3001 + diff --git a/migrations/001_initial.down.sql b/migrations/001_initial.down.sql new file mode 100644 index 0000000..0af28d2 --- /dev/null +++ b/migrations/001_initial.down.sql @@ -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; + diff --git a/migrations/001_initial.up.sql b/migrations/001_initial.up.sql new file mode 100644 index 0000000..fe12fda --- /dev/null +++ b/migrations/001_initial.up.sql @@ -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); + diff --git a/migrations/002_access_functions.down.sql b/migrations/002_access_functions.down.sql new file mode 100644 index 0000000..f495612 --- /dev/null +++ b/migrations/002_access_functions.down.sql @@ -0,0 +1,2 @@ +DROP FUNCTION IF EXISTS effective_node_access(UUID, UUID, TEXT[]); +DROP FUNCTION IF EXISTS has_node_access(UUID, UUID); diff --git a/migrations/002_access_functions.up.sql b/migrations/002_access_functions.up.sql new file mode 100644 index 0000000..58d84b2 --- /dev/null +++ b/migrations/002_access_functions.up.sql @@ -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; +$$;