feat: allow admins to toggle user watermark downloads
This commit is contained in:
@@ -59,7 +59,7 @@ Use this table before searching.
|
||||
| Admin console | `src/app/console/page.tsx`, `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/*` | `src/components/admin/*`, `src/app/api/admin/*` |
|
||||
| Canvas (legacy, disabled in UI) | `src/app/canvas/page.tsx`, `src/components/canvas/infinite-canvas-workspace.tsx`, `src/components/canvas/react-flow-canvas.tsx` | `/canvas` intentionally returns 404 and navbar must not show `画布`; legacy source/API files remain only for future cleanup or explicit re-enable work. |
|
||||
| Gallery and creation history | `src/app/gallery/page.tsx`, `src/app/profile/page.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/components/image-metadata-badge.tsx` | `src/lib/creation-history-store.ts`, `src/lib/media-storage.ts`, `src/lib/gallery-publish-media.ts`, `src/app/api/gallery/*`, `src/app/api/creation-history/route.ts`. Gallery is server-authoritative: do not merge browser localStorage published/history records into the public gallery feed and do not auto-sync historical local published records on gallery page load. The gallery page must not request the full gallery at once; it uses small `/api/gallery` pages, browser-visible lazy image loading, and an IntersectionObserver sentinel to append more works as the user scrolls. It keeps a bounded browser localStorage list cache for instant first paint, then revalidates page 0 in the background so new/deleted works replace cached rows quickly. Gallery/detail/history image previews show actual ratio and natural resolution in the upper-right badge and should render `thumbnailUrl || url`; fullscreen, download, copy, edit, share, and reuse actions keep using original `url`. Current thumbnails use the `m1280q86` WEBP profile, balancing smaller gallery payloads with clear detail previews, and fullscreen components should show thumbnail fallback while original object-storage images load. `/api/gallery/publish` must reuse stable `/api/local-storage/...` generated image/video originals instead of synchronously copying object-backed media during share; external URLs still copy into gallery storage before insertion. History also refreshes on `miaojing_auth_updated` after login/account switch. |
|
||||
| Local/object files/downloads | `src/lib/local-storage.ts`, `src/lib/media-storage.ts`, `src/lib/media-watermark*.ts`, `src/app/api/local-storage/[...path]/route.ts` | `src/app/api/download/route.ts`, `src/proxy.ts`, `scripts/storage-sync-to-object.mjs`, `scripts/rainyun-ros-prepare.mjs`. Public URLs stay `/api/local-storage/<key>` while the backend can be `STORAGE_MODE=local`, `dual`, or `object`; new image originals can be written object-only, while compressed high-quality WEBP thumbnails are local-only under `thumbnails/...` and must be served from local disk directly. Generated work media is watermarked server-side before display/download for normal users, using `public/watermark/miaojing-watermark-logo.png` plus `MIAOJING AI`; display requests for generated image originals may redirect to an existing local thumbnail first so pages do not synchronously watermark multi-megabyte object-backed originals, but downloads still use original media. Do not reintroduce raw object-storage redirects for generated images/videos unless the download route has verified an entitled user with `profiles.watermark_disabled=true`. Thumbnail filenames include the resize/quality profile and can be served with long immutable browser cache headers; `src/proxy.ts` must not override thumbnail or gallery cache headers with global `/api` no-store. Object-backed non-generated files may redirect to short-lived signed object-storage URLs. When syncing production source, exclude only repo-root `/local-storage/`, not broad `local-storage/`, or this source route can be skipped. Rainyun ROS API is a control-plane helper for bucket creation/config generation; runtime file IO still uses S3-compatible `OBJECT_STORAGE_*`. |
|
||||
| Local/object files/downloads | `src/lib/local-storage.ts`, `src/lib/media-storage.ts`, `src/lib/media-watermark*.ts`, `src/app/api/local-storage/[...path]/route.ts` | `src/app/api/download/route.ts`, `src/proxy.ts`, `scripts/storage-sync-to-object.mjs`, `scripts/rainyun-ros-prepare.mjs`. Public URLs stay `/api/local-storage/<key>` while the backend can be `STORAGE_MODE=local`, `dual`, or `object`; new image originals can be written object-only, while compressed high-quality WEBP thumbnails are local-only under `thumbnails/...` and must be served from local disk directly. Generated work media is watermarked server-side before display/download for normal users, using `public/watermark/miaojing-watermark-logo.png` plus `MIAOJING AI`; display requests for generated image originals may redirect to an existing local thumbnail first so pages do not synchronously watermark multi-megabyte object-backed originals, but downloads still use original media. Do not reintroduce raw object-storage redirects for generated images/videos unless the download route has verified an admin role or a user with `profiles.watermark_disabled=true`; admins can toggle that flag per user from `src/components/admin/user-management-tab.tsx` through `/api/admin/users`. Thumbnail filenames include the resize/quality profile and can be served with long immutable browser cache headers; `src/proxy.ts` must not override thumbnail or gallery cache headers with global `/api` no-store. Object-backed non-generated files may redirect to short-lived signed object-storage URLs. When syncing production source, exclude only repo-root `/local-storage/`, not broad `local-storage/`, or this source route can be skipped. Rainyun ROS API is a control-plane helper for bucket creation/config generation; runtime file IO still uses S3-compatible `OBJECT_STORAGE_*`. |
|
||||
| Email and policy pages | `src/lib/email-service.ts`, `src/components/site-policy-page.tsx` | `src/app/api/email/*`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` |
|
||||
| Upgrade/deploy/backup | `scripts/*`, `ecosystem.config.cjs` | `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx` |
|
||||
| Data backup/import/export | `src/components/admin/data-management-tab.tsx` | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `src/lib/local-storage.ts`, `scripts/migration-integrity-check.mjs`. Export includes `_media` for storage assets; import restores media through the active storage adapter, remaps custom IDs, runs in a transaction, dedupes works by URL/source URL/media SHA only within the same `user_id`, and preserves password hashes, encrypted API keys, Manifest paths, and API pricing fields. |
|
||||
|
||||
@@ -37,7 +37,7 @@ All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`
|
||||
| GET | `/api/model-config` | Public, optional bearer token | `src/app/api/model-config/route.ts` | Read managed provider/model configuration for clients. System APIs are filtered to active platform-default models allowed for the current user's membership tier; anonymous users are treated as `free`. |
|
||||
| GET | `/api/style-presets` | Public | `src/app/api/style-presets/route.ts` | Returns active image style presets from `image_style_presets`, sorted by usage count. |
|
||||
| GET | `/api/local-storage/[...path]` | Public by URL | `src/app/api/local-storage/[...path]/route.ts` | Serve storage object by key. Generated work images/videos and their generated/gallery/work thumbnails are watermarked server-side before display, using `src/lib/media-watermark*.ts`; object-backed generated originals must not redirect raw to object storage. When a generated image original has an existing local `works.thumbnail_url`, display requests may 302 to that thumbnail first and then watermark the thumbnail, keeping page loads fast while preserving the stable original URL for fullscreen/download actions. Thumbnail keys under `thumbnails/...` are served from local disk with long immutable browser cache headers; non-generated object-backed originals can return a short-lived signed object-storage redirect when configured. Video frame thumbnails are WEBP files, while fallback SVG video thumbnails under `thumbnails/.../*.svg` may be rasterized when watermarked. The public URL shape remains stable across migration. |
|
||||
| GET | `/api/download?url=...&filename=...` | Public by URL, optional bearer/downloadToken | `src/app/api/download/route.ts` | Download proxy for remote, same-origin, and `/api/local-storage/*` URLs, including object-backed storage keys. Generated local-storage media returns watermarked bytes by default; raw generated media is allowed only after the route authenticates a member/admin with `profiles.watermark_disabled=true`. Add `disposition=inline` or `inline=1` when the proxy is used as an image/video preview source instead of a forced download. |
|
||||
| GET | `/api/download?url=...&filename=...` | Public by URL, optional bearer/downloadToken | `src/app/api/download/route.ts` | Download proxy for remote, same-origin, and `/api/local-storage/*` URLs, including object-backed storage keys. Generated local-storage media returns watermarked bytes by default; raw generated media is allowed only after the route authenticates an admin role or a user whose `profiles.watermark_disabled=true`. Add `disposition=inline` or `inline=1` when the proxy is used as an image/video preview source instead of a forced download. |
|
||||
|
||||
## Auth And Account Routes
|
||||
|
||||
@@ -49,7 +49,7 @@ All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`
|
||||
| POST | `/api/auth/test-api` | Public/auth context depends caller | `src/app/api/auth/test-api/route.ts` | Provider/API config | Tests upstream API. |
|
||||
| POST | `/api/auth/fetch-models` | Public/auth context depends caller | `src/app/api/auth/fetch-models/route.ts` | Endpoint/API key | Fetch model list from provider. |
|
||||
| GET | `/api/profile` | User | `src/app/api/profile/route.ts` | None | `{ profile }`, including `watermark_disabled` for the user's no-watermark download preference. |
|
||||
| PUT | `/api/profile` | User | `src/app/api/profile/route.ts` | `email`, `username`, `displayNickname`/`nickname`, `phone`, `avatarUrl`, optional `watermarkDisabled`, password fields | Updated profile. `username` remains usable for login; display nickname is returned as `nickname` for UI and gallery display. `watermarkDisabled=true` is accepted only for members/admins and controls download-original entitlement, not platform display. |
|
||||
| PUT | `/api/profile` | User | `src/app/api/profile/route.ts` | `email`, `username`, `displayNickname`/`nickname`, `phone`, `avatarUrl`, optional `watermarkDisabled`, password fields | Updated profile. `username` remains usable for login; display nickname is returned as `nickname` for UI and gallery display. `watermarkDisabled=true` is accepted only for members/admins as a self-service preference; free users cannot self-enable it, and an accidental/old-client `false` payload must not clear an admin-granted per-user authorization. The flag controls download-original entitlement, not platform display. |
|
||||
| PUT | `/api/profile/theme` | User | `src/app/api/profile/theme/route.ts` | `theme` | `{ success, preferred_theme }`. |
|
||||
| GET | `/api/credit-transactions` | User | `src/app/api/credit-transactions/route.ts` | Optional `limit` | Latest user credit transaction records as `{ records }`, used by the profile credits tab. |
|
||||
| GET | `/api/invitations/me` | User | `src/app/api/invitations/me/route.ts` | None | Returns the current user's stable `inviteCode` plus invitee records for the profile credits tab. Creates `profiles.invite_code` if missing. |
|
||||
@@ -123,7 +123,7 @@ All routes in this section require admin unless noted.
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/admin/dashboard` | `src/app/api/admin/dashboard/route.ts` | Console dashboard aggregate stats and recent activity. |
|
||||
| GET | `/api/admin/stats` | `src/app/api/admin/stats/route.ts` | Additional admin stats. |
|
||||
| GET/PUT/DELETE | `/api/admin/users` | `src/app/api/admin/users/route.ts` | List/update/delete users. |
|
||||
| GET/PUT/DELETE | `/api/admin/users` | `src/app/api/admin/users/route.ts` | List/update/delete users. GET returns `watermark_disabled`; PUT accepts `watermarkDisabled`/`watermark_disabled` so admins can grant or revoke no-watermark downloads for an individual user without changing that user's membership tier. |
|
||||
| POST | `/api/admin/clear-users` | `src/app/api/admin/clear-users/route.ts` | Dangerous user cleanup controlled by env switch. |
|
||||
| GET/POST/PUT | `/api/admin/orders` | `src/app/api/admin/orders/route.ts` | List/create/update orders. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/redeem-codes` | `src/app/api/admin/redeem-codes/route.ts` | Admin redeem-code management. GET lists codes by status/search, POST generates 1-500 unique single-use credit or membership codes, PUT enables/disables unused codes, and DELETE removes unused codes. Membership-code payloads include `membershipTier`, `membershipDurationValue`, and `membershipDurationUnit` (`day`, `month`, `year`). The redeem-code management UI also saves the shared external mall URL through `/api/site-config` as `redeemCodeMallUrl`. |
|
||||
|
||||
@@ -180,7 +180,7 @@ Admin console navigation state is intentionally short-lived. `src/modules/consol
|
||||
- Object storage config uses `OBJECT_STORAGE_BUCKET`, `OBJECT_STORAGE_REGION`, optional `OBJECT_STORAGE_ENDPOINT`, access keys, `OBJECT_STORAGE_FORCE_PATH_STYLE`, and optional `OBJECT_STORAGE_PREFIX`.
|
||||
- Rainyun ROS is handled as a control-plane preparation step, not as a runtime storage backend. `scripts/rainyun-ros-prepare.mjs` calls `POST https://api.v2.rainyun.com/product/ros/bucket` with `x-api-key` and `{ bucket_name, instance_id }`, then writes `.env.rainyun-object.generated` with standard `OBJECT_STORAGE_*` values derived from `access_key`, `secret_key`, and `public_api_url`. Copy those values into production `.env.local` only after review, keep `STORAGE_MODE=dual`, and keep `.env.rainyun-object.generated` private.
|
||||
- File serving route: `src/app/api/local-storage/[...path]/route.ts`. Generated images/videos under generated/gallery/imported work media paths, plus generated/gallery/work thumbnails, are served as watermarked bytes using `public/watermark/miaojing-watermark-logo.png` and `MIAOJING AI` at 50% opacity; this route should not expose raw object-storage redirects for generated media because browser extensions or scripts can call the same URL. For generated image originals that already have a local `works.thumbnail_url`, display requests can redirect to the thumbnail and watermark that smaller file first; download requests still go through `/api/download` for the original media.
|
||||
- Download route: `src/app/api/download/route.ts`. Downloads also return watermarked files by default; a raw generated file is allowed only when the request authenticates a member/admin and `profiles.watermark_disabled=true`. Frontend helpers pass the session via Authorization for fetch downloads and a same-origin `downloadToken` for anchor-triggered downloads.
|
||||
- Download route: `src/app/api/download/route.ts`. Downloads also return watermarked files by default; a raw generated file is allowed only when the request authenticates an admin role or a user whose `profiles.watermark_disabled=true`. Frontend helpers pass the session via Authorization for fetch downloads and a same-origin `downloadToken` for anchor-triggered downloads.
|
||||
- Storage key validation prevents traversal through `normalizeKey`, `path.resolve`, and `..` checks.
|
||||
|
||||
Generation routes persist generated media through the storage adapter. Image originals and video originals are object-first when object storage is configured: images go through `src/lib/media-storage.ts`, while videos from `src/app/api/generate/video/route.ts` are stored with `uploadFileObjectOnly(...)` under `generated/videos`. Gallery publish uses `src/lib/gallery-publish-media.ts`: stable `/api/local-storage/...` image and video originals are reused rather than synchronously copying object-backed generated media again, while external media URLs are copied into gallery storage before insertion. Admin data export/import reads and restores through the same adapter, and import whitelists `manifest_path` plus system API pricing fields so intelligent API configurations survive server migration. Import preserves `auth.users.password_hash` and existing encrypted secret fields as encrypted values; production migrations must carry the same `DATA_ENCRYPTION_KEY`/JWT secret family or encrypted API/payment secrets and existing sessions cannot be decoded correctly. Work dedupe is scoped by `user_id` plus URL/source URL/media SHA to protect private data ownership when different users have identical media.
|
||||
@@ -217,7 +217,7 @@ Schema sources:
|
||||
|
||||
Core data areas:
|
||||
|
||||
- Users: `auth.users`, `profiles`. `profiles.watermark_disabled` is the member/admin preference for downloading raw generated media; free users cannot enable it, and platform display still stays watermarked.
|
||||
- Users: `auth.users`, `profiles`. `profiles.watermark_disabled` is the per-user authorization for downloading raw generated media. Free users cannot enable it from their own profile, but admins can toggle it in user management without changing membership; platform display still stays watermarked.
|
||||
- Works: `works`.
|
||||
- Credits: `credit_transactions`, `redeem_codes`.
|
||||
- Orders: `orders`.
|
||||
|
||||
@@ -98,7 +98,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Feature | Files |
|
||||
| --- | --- |
|
||||
| Profile page | `src/app/profile/page.tsx` |
|
||||
| Profile API | `src/app/api/profile/route.ts`, `src/app/api/profile/theme/route.ts`, `src/lib/user-profile-defaults.ts`, `src/lib/profile-preferences.ts` | `profiles.watermark_disabled` stores the member/admin no-watermark download preference. Free users cannot enable it; platform display remains watermarked even when the preference is on. |
|
||||
| Profile API | `src/app/api/profile/route.ts`, `src/app/api/profile/theme/route.ts`, `src/lib/user-profile-defaults.ts`, `src/lib/profile-preferences.ts` | `profiles.watermark_disabled` stores no-watermark download authorization. Free users cannot enable it from their own profile, but admins can grant or revoke it per user from user management; platform display remains watermarked even when the flag is on. |
|
||||
| Creation history tab | `src/components/profile/creation-history-tab.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts` | User-private completed works. History storage and the API de-duplicate repeated rows by result URL so a single generated video does not appear twice after the local optimistic record is replaced by the server row. Video records without a thumbnail receive a local SVG thumbnail under `thumbnails/works/videos` for fast list/detail preview. |
|
||||
| Credits tab/store | `src/components/profile/credits-tab.tsx`, `src/lib/credit-records-store.ts`, `src/app/api/credit-transactions/route.ts`, `src/app/api/redeem-codes/redeem/route.ts`, `src/app/api/invitations/me/route.ts`, `src/lib/invitation-service.ts` | The credits tab includes redeem-code input, a `获取兑换码` button, and a per-user invite link. The get-code and recharge buttons open `site_config.redeem_code_mall_url` from `/api/site-config` when configured. Successful redemption calls the server transaction route, updates either `profiles.credits_balance` for credit codes or `profiles.membership_tier`/`membership_expires_at` for membership codes, refreshes the auth profile, and then reloads server credit records. Invite links use `profiles.invite_code`; registrations through `/auth/register?invite=...` create an `invitation_referrals` row and award 50 credits to both inviter and invitee. |
|
||||
| Orders tab/store | `src/components/profile/orders-tab.tsx`, `src/lib/order-store.ts`, `src/app/api/admin/orders/route.ts` |
|
||||
@@ -120,7 +120,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| --- | --- | --- |
|
||||
| Console login | `src/app/console/page.tsx`, `src/modules/console/pages/console-login-page.tsx` | `src/app/api/auth/login/route.ts` with `adminOnly` |
|
||||
| Console dashboard | `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/console-dashboard-page.tsx` | `src/app/api/admin/dashboard/route.ts`, `src/app/api/admin/stats/route.ts`. The dashboard page owns the mobile admin shell classes (`console-mobile-page`, `console-mobile-main`, `console-mobile-content`) used by `src/app/globals.css` to keep cards constrained and admin tables horizontally scrollable on phones. |
|
||||
| Users | `src/components/admin/user-management-tab.tsx` | `src/app/api/admin/users/route.ts`, `src/app/api/admin/clear-users/route.ts`, `src/app/api/admin/invitations/route.ts`. The user-management UI has separate subpages for `用户列表` and `邀请注册记录`; invitation records have independent search, pagination, total count, inviter/invitee details, invite code, reward amounts, and creation time. |
|
||||
| Users | `src/components/admin/user-management-tab.tsx` | `src/app/api/admin/users/route.ts`, `src/lib/admin-users-service.ts`, `src/app/api/admin/clear-users/route.ts`, `src/app/api/admin/invitations/route.ts`. The user-management UI has separate subpages for `用户列表` and `邀请注册记录`; invitation records have independent search, pagination, total count, inviter/invitee details, invite code, reward amounts, and creation time. Admins can edit an individual user's `下载无水印` switch, which writes `profiles.watermark_disabled` through `/api/admin/users` without changing the membership tier. |
|
||||
| API/model management | `src/components/admin/api-management-tab.tsx` | `src/app/api/admin/providers/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/model-recommendations/route.ts` |
|
||||
| Pricing | `src/components/admin/pricing-tab.tsx` | Admin store/site config related routes |
|
||||
| Redeem codes | `src/components/admin/redeem-code-management-tab.tsx` | `src/app/api/admin/redeem-codes/route.ts`, `src/lib/redeem-code-service.ts`, `src/app/api/site-config/route.ts`. Admins can generate one or many unique single-use redeem codes, choose credit-code or membership-code type, set credit amount or membership tier plus duration in days/months/years, copy generated codes, and manage unused code status. The same tab has a `商城链接配置` dialog that saves `site_config.redeem_code_mall_url`; frontend credit get-code buttons and membership upgrade buttons open that URL. |
|
||||
@@ -140,7 +140,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Storage adapter | `src/lib/local-storage.ts` | Uses stable `/api/local-storage/<key>` URLs while the backend can be `STORAGE_MODE=local`, `dual`, or `object`. Object mode uses S3-compatible `OBJECT_STORAGE_*` config; dual mode writes local disk first and mirrors to object storage for safe migration. |
|
||||
| Rainyun ROS object storage preparation | `scripts/rainyun-ros-prepare.mjs` | Uses the Rainyun control-plane API `POST /product/ros/bucket` to create a bucket from `RAINYUN_ROS_BUCKET_NAME` and `RAINYUN_ROS_INSTANCE_ID`, then writes a private `.env.rainyun-object.generated` file containing standard `OBJECT_STORAGE_*` variables. Do not use this control-plane API for runtime media reads/writes; runtime storage remains S3-compatible through `src/lib/local-storage.ts`. |
|
||||
| Local/object file API | `src/app/api/local-storage/[...path]/route.ts`, `src/lib/media-watermark-policy.ts`, `src/lib/media-watermark.ts`, `src/proxy.ts` | Serves storage objects by key without changing existing frontend URLs. Generated work media under `generated/`, `gallery/`, `imported/works`, and generated/gallery/work thumbnails is watermarked on the server before display, including object-backed originals, so page display and browser save-as cannot reach raw generated images/videos. Generated image original display requests should use an existing local `works.thumbnail_url` redirect before watermarking when available; downloads still use the original through `/api/download`. Thumbnail keys under `thumbnails/...` are read from local disk and use long immutable browser cache headers because the filename contains the thumbnail profile; `src/proxy.ts` must preserve those cache headers instead of applying global `/api` no-store. Non-generated originals can still redirect to short-lived object-storage signed URLs when configured. |
|
||||
| Download proxy | `src/app/api/download/route.ts` | Supports remote URL, same-origin URL, and `/api/local-storage/*`. Generated local-storage media returns watermarked bytes unless the request authenticates a member/admin whose `profiles.watermark_disabled` is true; normal users must not receive raw object-storage redirects for generated images/videos. The frontend passes the session through `downloadFile(...)` headers or `triggerDownloadFile(...)` download tokens. For non-generated object-backed local-storage files, it can redirect to a short-lived signed object URL with content-disposition instead of buffering large videos through Next.js. |
|
||||
| Download proxy | `src/app/api/download/route.ts` | Supports remote URL, same-origin URL, and `/api/local-storage/*`. Generated local-storage media returns watermarked bytes unless the request authenticates an admin role or a user whose `profiles.watermark_disabled` is true; normal users without that flag must not receive raw object-storage redirects for generated images/videos. The frontend passes the session through `downloadFile(...)` headers or `triggerDownloadFile(...)` download tokens. For non-generated object-backed local-storage files, it can redirect to a short-lived signed object URL with content-disposition instead of buffering large videos through Next.js. |
|
||||
| Remote fetch guard | `src/lib/remote-fetch.ts` | Use for server-side external fetches. It blocks private/local network targets, sends browser-like public-resource headers by default, and exposes `fetchPublicHttpUrlWithRetry` for generated image/result URL downloads that may transiently return 403, 429, 5xx, or timeout. |
|
||||
|
||||
## Database And Persistence
|
||||
|
||||
@@ -46,9 +46,10 @@ await runTest('watermark policy targets generated work media without touching si
|
||||
assert.equal(isWatermarkableStorageKey('reverse-prompt/reference-images/input.png'), false);
|
||||
});
|
||||
|
||||
await runTest('only privileged users who explicitly disable watermark can access original media', () => {
|
||||
await runTest('admin-authorized users can access original media while others receive watermarked downloads', () => {
|
||||
assert.equal(canAccessOriginalMedia(null), false);
|
||||
assert.equal(canAccessOriginalMedia({ role: 'user', membershipTier: 'free', watermarkDisabled: true }), false);
|
||||
assert.equal(canAccessOriginalMedia({ role: 'user', membershipTier: 'free', watermarkDisabled: true }), true);
|
||||
assert.equal(canAccessOriginalMedia({ role: 'user', membershipTier: 'free', watermarkDisabled: false }), false);
|
||||
assert.equal(canAccessOriginalMedia({ role: 'vip', membershipTier: 'pro', watermarkDisabled: false }), false);
|
||||
assert.equal(canAccessOriginalMedia({ role: 'vip', membershipTier: 'pro', watermarkDisabled: true }), true);
|
||||
assert.equal(canAccessOriginalMedia({ role: 'admin', membershipTier: 'free', watermarkDisabled: false }), true);
|
||||
@@ -166,9 +167,42 @@ await runTest('profile page exposes a VIP-only no-watermark download switch', ()
|
||||
assert.match(source, /watermarkDisabled/);
|
||||
assert.match(source, /checked=\{accountForm\.watermarkDisabled\}/);
|
||||
assert.match(source, /disabled=\{!canDisableWatermark/);
|
||||
assert.doesNotMatch(source, /watermarkDisabled:\s*canDisableWatermark && accountForm\.watermarkDisabled/);
|
||||
assert.match(source, /if \(canDisableWatermark\) \{/);
|
||||
assert.match(source, /payload\.watermarkDisabled = accountForm\.watermarkDisabled === true/);
|
||||
assert.match(source, /下载无水印/);
|
||||
});
|
||||
|
||||
await runTest('profile API preserves admin-granted no-watermark access for free users', () => {
|
||||
const source = read('src/app/api/profile/route.ts');
|
||||
|
||||
assert.match(source, /const canManageOwnWatermark = canDisableWatermarkForProfile/);
|
||||
assert.match(source, /if \(hasWatermarkDisabled && watermarkDisabled && !canManageOwnWatermark\) \{/);
|
||||
assert.match(source, /watermarkDisabled && !canManageOwnWatermark/);
|
||||
assert.match(source, /const shouldUpdateWatermark = hasWatermarkDisabled && canManageOwnWatermark/);
|
||||
assert.match(source, /shouldUpdateWatermark,\s*watermarkDisabled,\s*tokenUserId/s);
|
||||
assert.doesNotMatch(source, /if \(hasWatermarkDisabled && watermarkDisabled && !canDisableWatermarkForProfile/);
|
||||
});
|
||||
|
||||
await runTest('admin users API and UI can toggle no-watermark downloads per user', () => {
|
||||
const serviceSource = read('src/lib/admin-users-service.ts');
|
||||
const uiSource = read('src/components/admin/user-management-tab.tsx');
|
||||
const adminStoreSource = read('src/lib/admin-store.ts');
|
||||
|
||||
assert.match(serviceSource, /ensureProfilePreferenceSchema/);
|
||||
assert.match(serviceSource, /COALESCE\(p\.watermark_disabled,\s*false\) AS watermark_disabled/);
|
||||
assert.match(serviceSource, /updates\.watermarkDisabled/);
|
||||
assert.match(serviceSource, /watermark_disabled = \$\$\{paramIdx\+\+\}/);
|
||||
assert.match(adminStoreSource, /watermarkDisabled\??:\s*boolean/);
|
||||
assert.match(uiSource, /import \{ Switch \} from '@\/components\/ui\/switch'/);
|
||||
assert.match(uiSource, /watermark_disabled:\s*boolean/);
|
||||
assert.match(uiSource, /watermarkDisabled:\s*u\.watermark_disabled === true/);
|
||||
assert.match(uiSource, /setEditWatermarkDisabled\(user\.watermarkDisabled === true\)/);
|
||||
assert.match(uiSource, /watermarkDisabled:\s*editWatermarkDisabled/);
|
||||
assert.match(uiSource, /checked=\{editWatermarkDisabled\}/);
|
||||
assert.match(uiSource, /下载无水印/);
|
||||
});
|
||||
|
||||
await runTest('download helpers forward the current session to the download API', () => {
|
||||
const source = read('src/lib/utils.ts');
|
||||
|
||||
|
||||
@@ -187,10 +187,12 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Display nickname is too long or contains invalid characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (hasWatermarkDisabled && watermarkDisabled && !canDisableWatermarkForProfile(currentProfile.role, currentProfile.membership_tier)) {
|
||||
const canManageOwnWatermark = canDisableWatermarkForProfile(currentProfile.role, currentProfile.membership_tier);
|
||||
if (hasWatermarkDisabled && watermarkDisabled && !canManageOwnWatermark) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: '仅会员可关闭下载水印' }, { status: 403 });
|
||||
}
|
||||
const shouldUpdateWatermark = hasWatermarkDisabled && canManageOwnWatermark;
|
||||
|
||||
if (username !== undefined && username !== currentProfile.username) {
|
||||
const duplicateUsername = await client.query(
|
||||
@@ -288,7 +290,7 @@ export async function PUT(request: NextRequest) {
|
||||
avatarUrl !== undefined,
|
||||
avatarUrl || '',
|
||||
nextAvatarUrl,
|
||||
hasWatermarkDisabled,
|
||||
shouldUpdateWatermark,
|
||||
watermarkDisabled,
|
||||
tokenUserId,
|
||||
]
|
||||
|
||||
@@ -277,8 +277,10 @@ export default function ProfilePage() {
|
||||
email: accountForm.email,
|
||||
phone: accountForm.phone,
|
||||
avatarUrl: accountForm.avatarUrl,
|
||||
watermarkDisabled: canDisableWatermark && accountForm.watermarkDisabled,
|
||||
};
|
||||
if (canDisableWatermark) {
|
||||
payload.watermarkDisabled = accountForm.watermarkDisabled === true;
|
||||
}
|
||||
|
||||
if (passwordForm.newPassword) {
|
||||
payload.currentPassword = passwordForm.currentPassword;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Coins, Edit3, KeyRound, Loader2, Plus, Save, Search, Trash2, Users, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
@@ -28,6 +29,7 @@ export default function UserManagementTab() {
|
||||
membership_tier: string; credits_balance: number;
|
||||
daily_quota_limit: number; daily_quota_used: number;
|
||||
is_active: boolean;
|
||||
watermark_disabled: boolean;
|
||||
status: string; created_at: string; phone?: string | null;
|
||||
invite_code?: string | null; referred_by_user_id?: string | null;
|
||||
referred_by_email?: string | null; referred_by_nickname?: string | null;
|
||||
@@ -135,6 +137,7 @@ export default function UserManagementTab() {
|
||||
creditsBalance: u.credits_balance ?? 0,
|
||||
dailyQuotaLimit: u.daily_quota_limit ?? 5,
|
||||
dailyQuotaUsed: u.daily_quota_used ?? 0,
|
||||
watermarkDisabled: u.watermark_disabled === true,
|
||||
status: u.is_active === false ? 'suspended' as const : 'active' as const,
|
||||
createdAt: u.created_at ? new Date(u.created_at).toLocaleDateString('zh-CN') : '',
|
||||
}))
|
||||
@@ -164,6 +167,7 @@ export default function UserManagementTab() {
|
||||
credits_balance: number;
|
||||
daily_quota_limit: number;
|
||||
daily_quota_used: number;
|
||||
watermark_disabled: boolean;
|
||||
is_active: boolean;
|
||||
nickname: string;
|
||||
email: string;
|
||||
@@ -187,6 +191,7 @@ export default function UserManagementTab() {
|
||||
const [editCredits, setEditCredits] = useState('0');
|
||||
const [editQuota, setEditQuota] = useState('5');
|
||||
const [editStatus, setEditStatus] = useState<ManagedUser['status']>('active');
|
||||
const [editWatermarkDisabled, setEditWatermarkDisabled] = useState(false);
|
||||
|
||||
// Reset password
|
||||
const [resetPwUser, setResetPwUser] = useState<ManagedUser | null>(null);
|
||||
@@ -220,6 +225,7 @@ export default function UserManagementTab() {
|
||||
setEditEmail(user.email || '');
|
||||
setEditCredits(String(user.creditsBalance)); setEditQuota(String(user.dailyQuotaLimit));
|
||||
setEditStatus(user.status);
|
||||
setEditWatermarkDisabled(user.watermarkDisabled === true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
@@ -230,6 +236,7 @@ export default function UserManagementTab() {
|
||||
membershipTier: editTier,
|
||||
creditsBalance: Number(editCredits) || 0,
|
||||
dailyQuotaLimit: Number(editQuota) || 5,
|
||||
watermarkDisabled: editWatermarkDisabled,
|
||||
status: editStatus,
|
||||
});
|
||||
// Also save to Supabase if using real data
|
||||
@@ -248,6 +255,7 @@ export default function UserManagementTab() {
|
||||
membershipTier: editTier,
|
||||
creditsBalance: Number(editCredits) || 0,
|
||||
dailyQuotaLimit: Number(editQuota) || 5,
|
||||
watermarkDisabled: editWatermarkDisabled,
|
||||
status: editStatus,
|
||||
}),
|
||||
});
|
||||
@@ -674,6 +682,7 @@ export default function UserManagementTab() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{user.nickname}</span>
|
||||
<Badge variant={rl.variant}>{rl.label}</Badge>
|
||||
{user.watermarkDisabled && <Badge variant="secondary">无水印下载</Badge>}
|
||||
<span className={`text-xs ${sl.color}`}>{sl.label}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3 flex-wrap">
|
||||
@@ -949,6 +958,21 @@ export default function UserManagementTab() {
|
||||
<Input type="number" value={editQuota} onChange={e => setEditQuota(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-medium">下载无水印</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
开启后,该用户下载自己生成的图片和视频时返回原文件;站内展示仍保留水印。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={editWatermarkDisabled}
|
||||
onCheckedChange={setEditWatermarkDisabled}
|
||||
aria-label="下载无水印"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface ManagedUser {
|
||||
creditsBalance: number;
|
||||
dailyQuotaLimit: number;
|
||||
dailyQuotaUsed: number;
|
||||
watermarkDisabled?: boolean;
|
||||
status: 'active' | 'suspended' | 'banned';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
import { ensureInvitationSchema } from '@/lib/invitation-service';
|
||||
import { ensureProfilePreferenceSchema } from '@/lib/profile-preferences';
|
||||
|
||||
export type AdminUsersQuery = {
|
||||
search?: string;
|
||||
@@ -38,6 +39,7 @@ function toAdminUser(row: Record<string, unknown>) {
|
||||
daily_quota_limit: row.daily_quota_limit ?? 5,
|
||||
daily_quota_used: row.daily_quota_used ?? 0,
|
||||
is_active: row.is_active !== false,
|
||||
watermark_disabled: row.watermark_disabled === true,
|
||||
avatar_url: row.avatar_url || null,
|
||||
phone: row.phone || null,
|
||||
invite_code: row.invite_code || null,
|
||||
@@ -63,6 +65,7 @@ function clampPageSize(value: unknown): number {
|
||||
|
||||
export async function listAdminUsers(client: PoolClient, query: AdminUsersQuery = {}) {
|
||||
await ensureInvitationSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
const page = clampPage(query.page);
|
||||
const pageSize = clampPageSize(query.pageSize);
|
||||
const offset = (page - 1) * pageSize;
|
||||
@@ -91,7 +94,8 @@ export async function listAdminUsers(client: PoolClient, query: AdminUsersQuery
|
||||
const result = await client.query(
|
||||
`SELECT p.id, p.email, p.nickname, p.display_nickname, p.role, p.membership_tier,
|
||||
p.credits_balance, p.daily_quota_limit, p.daily_quota_used,
|
||||
p.is_active, p.avatar_url, p.phone, p.invite_code, p.referred_by_user_id,
|
||||
p.is_active, COALESCE(p.watermark_disabled, false) AS watermark_disabled,
|
||||
p.avatar_url, p.phone, p.invite_code, p.referred_by_user_id,
|
||||
ref.email AS referred_by_email,
|
||||
COALESCE(NULLIF(ref.display_nickname, ''), ref.nickname, ref.email) AS referred_by_nickname,
|
||||
COALESCE(invited.invited_count, 0) AS invited_count,
|
||||
@@ -122,6 +126,8 @@ export async function listAdminUsers(client: PoolClient, query: AdminUsersQuery
|
||||
}
|
||||
|
||||
export async function updateAdminUser(client: PoolClient, body: Record<string, unknown>) {
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
|
||||
const { userId, ...updates } = body;
|
||||
|
||||
if (!userId) {
|
||||
@@ -140,6 +146,7 @@ export async function updateAdminUser(client: PoolClient, body: Record<string, u
|
||||
const creditsBalance = updates.credits_balance ?? updates.creditsBalance;
|
||||
const dailyQuotaLimit = updates.daily_quota_limit ?? updates.dailyQuotaLimit;
|
||||
const dailyQuotaUsed = updates.daily_quota_used ?? updates.dailyQuotaUsed;
|
||||
const watermarkDisabled = updates.watermark_disabled ?? updates.watermarkDisabled;
|
||||
const isActive = updates.is_active ?? updates.isActive ?? (updates.status !== undefined ? updates.status === 'active' : undefined);
|
||||
const newPassword = typeof updates.newPassword === 'string' ? updates.newPassword.trim() : '';
|
||||
const nextTier = normalizeMembershipTier((membershipTier ?? currentResult.rows[0].membership_tier ?? 'free') as string);
|
||||
@@ -168,6 +175,7 @@ export async function updateAdminUser(client: PoolClient, body: Record<string, u
|
||||
if (creditsBalance !== undefined) { setClauses.push(`credits_balance = $${paramIdx++}`); params.push(creditsBalance); }
|
||||
if (dailyQuotaLimit !== undefined) { setClauses.push(`daily_quota_limit = $${paramIdx++}`); params.push(dailyQuotaLimit); }
|
||||
if (dailyQuotaUsed !== undefined) { setClauses.push(`daily_quota_used = $${paramIdx++}`); params.push(dailyQuotaUsed); }
|
||||
if (watermarkDisabled !== undefined) { setClauses.push(`watermark_disabled = $${paramIdx++}`); params.push(watermarkDisabled === true); }
|
||||
if (isActive !== undefined) { setClauses.push(`is_active = $${paramIdx++}`); params.push(isActive); }
|
||||
if (updates.username !== undefined) { setClauses.push(`nickname = $${paramIdx++}`); params.push(updates.username); }
|
||||
if (updates.nickname !== undefined) { setClauses.push(`display_nickname = $${paramIdx++}`); params.push(updates.nickname); }
|
||||
@@ -203,7 +211,8 @@ export async function updateAdminUser(client: PoolClient, body: Record<string, u
|
||||
const updated = await client.query(
|
||||
`SELECT p.id, p.email, p.nickname, p.display_nickname, p.role, p.membership_tier,
|
||||
p.credits_balance, p.daily_quota_limit, p.daily_quota_used,
|
||||
p.is_active, p.avatar_url, p.phone, p.invite_code, p.referred_by_user_id,
|
||||
p.is_active, COALESCE(p.watermark_disabled, false) AS watermark_disabled,
|
||||
p.avatar_url, p.phone, p.invite_code, p.referred_by_user_id,
|
||||
ref.email AS referred_by_email,
|
||||
COALESCE(NULLIF(ref.display_nickname, ''), ref.nickname, ref.email) AS referred_by_nickname,
|
||||
COALESCE(invited.invited_count, 0) AS invited_count,
|
||||
|
||||
@@ -34,7 +34,6 @@ const EXCLUDED_PREFIXES = [
|
||||
const WATERMARK_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp']);
|
||||
const WATERMARK_VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'webm']);
|
||||
const PRIVILEGED_ROLES = new Set(['admin', 'enterprise_admin']);
|
||||
const MEMBER_TIERS = new Set(['basic', 'pro', 'max', 'ultra', 'enterprise']);
|
||||
|
||||
export function normalizeStorageKeyForWatermark(key: string): string {
|
||||
return path.posix.normalize(key.replace(/\\/g, '/')).replace(/^\/+/, '');
|
||||
@@ -55,8 +54,7 @@ export function canAccessOriginalMedia(context: MediaWatermarkAccessContext): bo
|
||||
if (!context) return false;
|
||||
const role = context.role || 'user';
|
||||
if (PRIVILEGED_ROLES.has(role)) return true;
|
||||
const tier = context.membershipTier || 'free';
|
||||
return context.watermarkDisabled === true && (role === 'vip' || MEMBER_TIERS.has(tier));
|
||||
return context.watermarkDisabled === true;
|
||||
}
|
||||
|
||||
export function shouldWatermarkStorageResponse(
|
||||
|
||||
Reference in New Issue
Block a user