Compare commits

..

5 Commits

Author SHA1 Message Date
Grendgi
b0117c2c1a fix: keep trashed office docs out of my files
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 32s
CI / test (push) Successful in 21s
2026-06-24 12:15:42 +03:00
Grendgi
b75f274885 feat: search files across folders
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 29s
CI / test (push) Successful in 20s
2026-06-18 11:35:38 +03:00
Grendgi
1144b11ca3 feat: expose files storage health detail
All checks were successful
CI / hygiene (push) Successful in 1s
Build and Deploy / build-and-deploy (push) Successful in 34s
CI / test (push) Successful in 32s
2026-06-17 15:47:14 +03:00
Grendgi
aad9fa1b4a chore: use common header parsing 2026-06-17 13:54:04 +03:00
Grendgi
1a84c04314 chore: update portal-common to v0.3.0 2026-06-17 13:51:39 +03:00
8 changed files with 175 additions and 30 deletions

View File

@@ -55,7 +55,7 @@ func main() {
slog.Warn("ensure bucket failed", "error", err)
}
healthH := handler.NewHealthHandler(pool)
healthH := handler.NewHealthHandler(pool, store)
nodeRepo := repository.NewNodeRepository(pool)
nodeH := handler.NewNodeHandler(cfg, nodeRepo, store)
go runTrashPurger(ctx, nodeRepo, store)
@@ -67,6 +67,7 @@ func main() {
r.Get("/healthz", healthH.Healthz)
r.Get("/readyz", healthH.Readyz)
r.Get("/health/detail", healthH.Detail)
mountFilesAPI := func(r chi.Router) {
r.Get("/nodes", nodeH.List)

2
go.mod
View File

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

4
go.sum
View File

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

View File

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

View File

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

View File

@@ -36,7 +36,8 @@ func (h *NodeHandler) List(w http.ResponseWriter, r *http.Request) {
userID := commonmw.GetUserID(r.Context())
scope := r.URL.Query().Get("scope")
parentID := emptyToNil(r.URL.Query().Get("parent_id"))
nodes, err := h.repo.List(r.Context(), userID, subordinates(r), scope, parentID)
query := strings.TrimSpace(r.URL.Query().Get("q"))
nodes, err := h.repo.List(r.Context(), userID, subordinates(r), scope, parentID, query)
if err != nil {
writeInternalError(w, r, err, "failed to list files")
return
@@ -763,9 +764,9 @@ func allowedOfficeFormat(format string) bool {
}
func subordinates(r *http.Request) []string {
ids := csvHeader(r, "X-User-Subordinates")
ids := commonmw.HeaderCSV(r, "X-User-Subordinates")
if len(ids) == 0 {
ids = csvHeader(r, "X-User-Subordinate-Ids")
ids = commonmw.HeaderCSV(r, "X-User-Subordinate-Ids")
}
return ids
}

View File

@@ -39,7 +39,10 @@ func scanNode(scan func(dest ...any) error) (*model.Node, error) {
return &n, err
}
func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs []string, scope string, parentID *string) ([]model.Node, error) {
func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs []string, scope string, parentID *string, search string) ([]model.Node, error) {
if strings.TrimSpace(search) != "" {
return r.Search(ctx, userID, subordinateIDs, scope, search)
}
args := []any{userID, subordinateIDs}
where := []string{"n.deleted_at IS NULL"}
if scope == "trash" {
@@ -127,6 +130,71 @@ func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs
return out, rows.Err()
}
func (r *NodeRepository) Search(ctx context.Context, userID string, subordinateIDs []string, scope string, search string) ([]model.Node, error) {
pattern := "%" + strings.ToLower(strings.TrimSpace(search)) + "%"
args := []any{userID, subordinateIDs, pattern}
where := []string{`(
lower(n.title) LIKE $3
OR lower(COALESCE(n.original_filename, '')) LIKE $3
OR lower(COALESCE(n.extension, '')) LIKE $3
OR lower(COALESCE(n.mime_type, '')) LIKE $3
)`}
accessExpr := "effective_node_access(n.id, $1, $2::text[])"
if scope == "trash" {
where = append(where, "n.deleted_at IS NOT NULL")
where = append(where, `(
n.owner_user_id = $1
OR n.owner_user_id::text = ANY($2::text[])
OR EXISTS (
SELECT 1 FROM files_access a
WHERE a.node_id = n.id
AND a.user_id = $1
AND a.access_level = 'edit'
)
)`)
accessExpr = "'edit'"
} else {
where = append(where, "n.deleted_at IS NULL")
switch scope {
case "shared":
where = append(where, "n.owner_user_id <> $1")
where = append(where, `(
has_node_access(n.id, $1)
OR n.owner_user_id::text = ANY($2::text[])
)`)
default:
where = append(where, "n.owner_user_id = $1")
}
}
query := `
SELECT n.id, n.parent_id, n.node_type, n.title, n.owner_user_id, n.owner_department_id,
n.created_by, n.updated_by, n.storage_key, n.original_filename, n.mime_type,
n.extension, n.size_bytes, n.office_format, n.external_url, n.version,
` + accessExpr + `,
n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at
FROM files_nodes n
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY CASE WHEN n.node_type = 'folder' THEN 0 ELSE 1 END, lower(n.title), n.created_at DESC`
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]model.Node, 0)
for rows.Next() {
n, err := scanNode(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *n)
}
return out, rows.Err()
}
func (r *NodeRepository) ListChildrenForPublic(ctx context.Context, parentID string) ([]model.Node, error) {
rows, err := r.pool.Query(ctx, `
SELECT n.id, n.parent_id, n.node_type, n.title, n.owner_user_id, n.owner_department_id,
@@ -209,8 +277,7 @@ func (r *NodeRepository) ListOfficeExternalURLs(ctx context.Context, userID stri
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT n.external_url
FROM files_nodes n
WHERE n.deleted_at IS NULL
AND n.node_type = 'office_document'
WHERE n.node_type = 'office_document'
AND n.external_url IS NOT NULL
AND (
n.owner_user_id = $1

View File

@@ -66,6 +66,20 @@ func (s *Storage) EnsureBucket(ctx context.Context) error {
return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{})
}
func (s *Storage) Check(ctx context.Context) error {
if !s.Configured() {
return errors.New("storage not configured")
}
exists, err := s.client.BucketExists(ctx, s.cfg.Bucket)
if err != nil {
return fmt.Errorf("check bucket: %w", err)
}
if !exists {
return fmt.Errorf("bucket %q does not exist", s.cfg.Bucket)
}
return nil
}
func GenerateKey(ownerID, filename string) string {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
if !AllowedExtension(ext) {