diff --git a/messages/en.json b/messages/en.json index c50ab05..4cb4b7e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -335,7 +335,8 @@ "routes": "Internal Routes", "integrations": "Integrations", "projects": "Projects", - "nodeFonts": "Node Fonts" + "nodeFonts": "Node Fonts", + "exports": "Exports" }, "appAdminNodesPage": { "title": "Render Nodes", diff --git a/messages/fa.json b/messages/fa.json index f56a3e4..dd7476a 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -335,7 +335,8 @@ "routes": "مسیرهای داخلی", "integrations": "یکپارچه‌سازی‌ها", "projects": "پروژه‌ها", - "nodeFonts": "فونت نودها" + "nodeFonts": "فونت نودها", + "exports": "خروجی‌های رندر" }, "appAdminNodesPage": { "title": "نودهای رندر", diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go index b1454ba..6824558 100644 --- a/services/gateway/cmd/server/main.go +++ b/services/gateway/cmd/server/main.go @@ -136,6 +136,7 @@ func main() { v1.Any("/exports/*path", apiRL, auth, render.Handler()) v1.Any("/nodes/*path", apiRL, auth, render.Handler()) v1.Any("/node-fonts/*path", apiRL, auth, render.Handler()) + v1.Any("/admin-exports/*path", apiRL, auth, render.Handler()) v1.Any("/node-updates/*path", apiRL, auth, render.Handler()) // ── Notification Service ────────────────────────────────────────────────── diff --git a/services/render/cmd/server/main.go b/services/render/cmd/server/main.go index 1532d84..f0b7c86 100644 --- a/services/render/cmd/server/main.go +++ b/services/render/cmd/server/main.go @@ -145,6 +145,14 @@ func main() { nodeFonts.DELETE("/:id", fontH.Delete) } + // ── Exports management (admin: all users' rendered videos) ──────────────── + adminExports := v1.Group("/admin-exports", auth, admin) + { + adminExports.GET("", exportH.AdminList) + adminExports.DELETE("/:export_id", exportH.AdminDelete) + adminExports.GET("/:export_id/download-url", exportH.AdminDownloadURL) + } + // ── Internal (node agents only — HMAC auth) ─────────────────────────────── internal := v1.Group("/internal", nodeAuth) { diff --git a/services/render/internal/db/exports_admin.go b/services/render/internal/db/exports_admin.go new file mode 100644 index 0000000..0c8cb46 --- /dev/null +++ b/services/render/internal/db/exports_admin.go @@ -0,0 +1,63 @@ +package db + +import ( + "context" + "fmt" + + "github.com/flatrender/render-svc/internal/models" + "github.com/google/uuid" +) + +const exportCols = `id, tenant_id, user_id, saved_project_id, project_id, render_job_id, + image, path, file_extension, file_type::text, render_quality::text, + create_type::text, size_bytes, duration_sec, width, height, + produce_date, auto_delete_date, delete_notified, created_at, deleted_at` + +// ListAllExports returns every export across users (admin view), paginated. +func (s *Store) ListAllExports(ctx context.Context, page, pageSize int) ([]*models.Export, int64, error) { + var total int64 + _ = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM render.exports WHERE deleted_at IS NULL`).Scan(&total) + + rows, err := s.pool.Query(ctx, + `SELECT `+exportCols+` + FROM render.exports WHERE deleted_at IS NULL + ORDER BY produce_date DESC LIMIT $1 OFFSET $2`, + pageSize, (page-1)*pageSize) + if err != nil { + return nil, 0, err + } + defer rows.Close() + exports, err := scanExports(rows) + return exports, total, err +} + +// GetExportByIDAny fetches an export regardless of owner (admin). +func (s *Store) GetExportByIDAny(ctx context.Context, id uuid.UUID) (*models.Export, error) { + rows, err := s.pool.Query(ctx, + `SELECT `+exportCols+` FROM render.exports WHERE id = $1 AND deleted_at IS NULL`, id) + if err != nil { + return nil, err + } + defer rows.Close() + exports, err := scanExports(rows) + if err != nil { + return nil, err + } + if len(exports) == 0 { + return nil, fmt.Errorf("export not found") + } + return exports[0], nil +} + +// SoftDeleteExportAny deletes an export regardless of owner (admin). +func (s *Store) SoftDeleteExportAny(ctx context.Context, id uuid.UUID) error { + tag, err := s.pool.Exec(ctx, + `UPDATE render.exports SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("export not found") + } + return nil +} diff --git a/services/render/internal/handlers/exports_admin.go b/services/render/internal/handlers/exports_admin.go new file mode 100644 index 0000000..50fed9a --- /dev/null +++ b/services/render/internal/handlers/exports_admin.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/flatrender/render-svc/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// GET /v1/admin-exports — all exports across users (admin) +func (h *ExportHandler) AdminList(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "30")) + if pageSize < 1 || pageSize > 100 { + pageSize = 30 + } + exports, total, err := h.store.ListAllExports(c.Request.Context(), page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) + return + } + if exports == nil { + exports = []*models.Export{} + } + c.JSON(http.StatusOK, models.PagedResponse[*models.Export]{ + Data: exports, + Meta: models.PaginationMeta{Page: page, PageSize: pageSize, Total: total}, + }) +} + +// DELETE /v1/admin-exports/:export_id (admin) +func (h *ExportHandler) AdminDelete(c *gin.Context) { + exportID, err := uuid.Parse(c.Param("export_id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid export_id"}) + return + } + if err := h.store.SoftDeleteExportAny(c.Request.Context(), exportID); err != nil { + c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + +// GET /v1/admin-exports/:export_id/download-url (admin) +func (h *ExportHandler) AdminDownloadURL(c *gin.Context) { + exportID, err := uuid.Parse(c.Param("export_id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid export_id"}) + return + } + exp, err := h.store.GetExportByIDAny(c.Request.Context(), exportID) + if err != nil { + c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: err.Error()}) + return + } + expiry := 15 * time.Minute + url, err := h.minio.PresignedGetObject(context.Background(), h.bucket, exp.Path, expiry, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: "could not generate download URL"}) + return + } + c.JSON(http.StatusOK, gin.H{"url": url.String(), "expires_at": time.Now().Add(expiry)}) +} diff --git a/src/app/[locale]/admin/exports/page.tsx b/src/app/[locale]/admin/exports/page.tsx new file mode 100644 index 0000000..377a2a8 --- /dev/null +++ b/src/app/[locale]/admin/exports/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { ExportsAdmin } from "@/components/admin/ExportsAdmin"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index b07f67a..76eeccb 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -42,6 +42,7 @@ export default async function AdminLayout({ { href: "/admin/nodes", label: t("nodes") }, { href: "/admin/node-fonts", label: t("nodeFonts") }, { href: "/admin/renders", label: t("renderQueue") }, + { href: "/admin/exports", label: t("exports") }, ]; return (
diff --git a/src/components/admin/ExportsAdmin.tsx b/src/components/admin/ExportsAdmin.tsx new file mode 100644 index 0000000..13e09d2 --- /dev/null +++ b/src/components/admin/ExportsAdmin.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { AdminThumb } from "@/components/admin/AdminThumb"; +import { humanSize } from "@/lib/admin-files"; + +interface Export { + id: string; user_id: string; image?: string | null; path: string; + file_type: string; file_extension: string; render_quality: string; + size_bytes: number; duration_sec?: number | null; width?: number | null; height?: number | null; + produce_date: string; auto_delete_date: string; +} + +const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]"; +const ghost = "rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-50"; + +function faDate(iso: string) { + try { return new Date(iso).toLocaleDateString("fa-IR"); } catch { return iso?.slice(0, 10) ?? "—"; } +} +function expired(iso: string) { return new Date(iso).getTime() < Date.now(); } + +export function ExportsAdmin() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const pageSize = 30; + + const load = useCallback(async () => { + setLoading(true); + const r = await fetch(`/api/admin/resource/admin-exports?page=${page}&pageSize=${pageSize}`, { cache: "no-store" }) + .then((x) => x.json()).catch(() => null); + setRows(r?.data ?? []); + setTotal(r?.meta?.total ?? 0); + setLoading(false); + }, [page]); + useEffect(() => { load(); }, [load]); + + const download = async (e: Export) => { + const r = await fetch(`/api/admin/resource/admin-exports/${e.id}/download-url`, { cache: "no-store" }) + .then((x) => x.json()).catch(() => null); + if (r?.url) window.open(r.url, "_blank"); + }; + const remove = async (e: Export) => { + if (!confirm("این خروجی حذف شود؟")) return; + await fetch(`/api/admin/resource/admin-exports/${e.id}`, { method: "DELETE" }); + load(); + }; + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + return ( +
+
+

خروجی‌های رندر

+

همهٔ ویدیوها/تصاویر رندرشدهٔ کاربران. دانلود یا حذف کنید.

+
+ +
+ + + + + + + + {loading ? ( + + ) : rows.length === 0 ? ( + + ) : rows.map((e) => ( + + + + + + + + + + + + ))} + +
پیش‌نمایشنوعکیفیتحجممدتابعادتاریخ تولیدانقضاعملیات
در حال بارگذاری…
خروجی‌ای یافت نشد.
{e.file_type}{e.file_extension ? `.${e.file_extension}` : ""}{e.render_quality}{humanSize(e.size_bytes)}{e.duration_sec ? `${Math.round(e.duration_sec)}s` : "—"}{e.width && e.height ? `${e.width}×${e.height}` : "—"}{faDate(e.produce_date)} + {faDate(e.auto_delete_date)} + +
+ + +
+
+
+ +
+ {total.toLocaleString("fa-IR")} خروجی + {totalPages > 1 && ( +
+ + صفحهٔ {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} + +
+ )} +
+
+ ); +}