feat: watermark generated media downloads

This commit is contained in:
FengLee
2026-06-05 23:42:30 +08:00
parent 3ba90c0933
commit f3d5135e0b
20 changed files with 754 additions and 36 deletions

View File

@@ -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/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. 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 originals should 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`; 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_*`. |
| 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. |

View File

@@ -36,8 +36,8 @@ All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`
| DELETE | `/api/announcements?id=...` | Admin | `src/app/api/announcements/route.ts` | Delete announcement. |
| 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. Thumbnail keys under `thumbnails/...` are served from local disk with long immutable browser cache headers; object-backed originals return a short-lived signed object-storage redirect when configured. Video frame thumbnails are WEBP files, while fallback SVG video thumbnails under `thumbnails/.../*.svg` are served as `image/svg+xml`. The public URL shape remains stable across migration. |
| GET | `/api/download?url=...&filename=...` | Public by URL | `src/app/api/download/route.ts` | Download proxy for remote, same-origin, and `/api/local-storage/*` URLs, including object-backed storage keys. Object-backed local-storage keys redirect to short-lived signed object URLs with content-disposition so large videos are not buffered through Next.js. Add `disposition=inline` or `inline=1` when the proxy is used as an image/video preview source instead of a forced download. |
| 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. 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. |
## Auth And Account Routes
@@ -48,8 +48,8 @@ All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`
| GET | `/api/auth/admin-exists` | Public | `src/app/api/auth/admin-exists/route.ts` | None | Whether an admin profile exists. |
| 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 }`. |
| PUT | `/api/profile` | User | `src/app/api/profile/route.ts` | `email`, `username`, `displayNickname`/`nickname`, `phone`, `avatarUrl`, password fields | Updated profile. `username` remains usable for login; display nickname is returned as `nickname` for UI and gallery display. |
| 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/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. |

View File

@@ -171,7 +171,7 @@ Admin console navigation state is intentionally short-lived. `src/modules/consol
## Storage Architecture
`src/lib/local-storage.ts` is the storage abstraction. It keeps the public URL shape stable while allowing the backend to move from local disk to object storage.
`src/lib/local-storage.ts` is the storage abstraction. It keeps the public URL shape stable while allowing the backend to move from local disk to object storage. Generated media delivery is layered through `src/lib/media-watermark-policy.ts` and `src/lib/media-watermark.ts` so compliance-visible watermarks are applied at the server boundary rather than as a frontend overlay.
- Public URL shape remains `/api/local-storage/<key>` for local and object-backed files. Existing DB rows and frontend image URLs do not need rewriting during migration.
- `STORAGE_MODE=local`: read/write only `LOCAL_STORAGE_DIR`, or repo-local `local-storage` if unset.
@@ -179,15 +179,15 @@ Admin console navigation state is intentionally short-lived. `src/modules/consol
- `STORAGE_MODE=object`: read/write only the configured S3-compatible bucket. Use this only after `scripts/storage-sync-to-object.mjs --verify-only` passes and rollback expectations are clear.
- 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`.
- Download route: `src/app/api/download/route.ts`.
- 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.
- 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.
- 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.
Image originals and previews have separate storage rules. `src/lib/media-storage.ts` persists new generated image originals through `localStorage.uploadFileObjectOnly(...)` so production originals live in the object bucket even while the app remains in `STORAGE_MODE=dual`. The same helper writes high-quality compressed WEBP thumbnails through `uploadFileLocalOnly(...)` under `thumbnails/...`; the current thumbnail profile uses 1280px max edge, WEBP quality 86, Lanczos resize, and light sharpening, with an `m1280q86` filename suffix so older thumbnail profiles can be replaced in the background. Create results, creation history, gallery cards, and gallery detail previews should render `thumbnailUrl`, while fullscreen preview, download, copy, edit, and share actions must continue to use the original `url`. Sharing an already generated `/api/local-storage/...` image should reuse that original URL and existing thumbnail instead of copying the object into `gallery/images` or recompressing a gallery thumbnail synchronously; missing/stale thumbnails are backfilled later by `/api/gallery` reads. Legacy rows without current `works.thumbnail_url` are queued for background thumbnail backfill by `/api/creation-history` and `/api/gallery` when image works are read; list responses must not wait for thumbnail generation. Thumbnail backfill should read object-only originals through short-lived signed object URLs instead of slow SDK buffering. The `/api/local-storage/*` route reads `thumbnails/...` from local disk directly instead of probing object storage first and serves them with long immutable browser cache headers because thumbnail filenames include the profile/hash; `src/proxy.ts` must explicitly preserve cacheable thumbnail/gallery routes instead of applying the default `/api` no-store header. Object-backed originals should return a short-lived 302 signed object-storage URL instead of buffering the full original through Next.js.
Image originals and previews have separate storage rules. `src/lib/media-storage.ts` persists new generated image originals through `localStorage.uploadFileObjectOnly(...)` so production originals live in the object bucket even while the app remains in `STORAGE_MODE=dual`. The same helper writes high-quality compressed WEBP thumbnails through `uploadFileLocalOnly(...)` under `thumbnails/...`; the current thumbnail profile uses 1280px max edge, WEBP quality 86, Lanczos resize, and light sharpening, with an `m1280q86` filename suffix so older thumbnail profiles can be replaced in the background. Create results, creation history, gallery cards, and gallery detail previews should render `thumbnailUrl`, while fullscreen preview, download, copy, edit, and share actions must continue to use the original `url`; the storage/download routes decide whether that original URL returns watermarked bytes or raw bytes. Sharing an already generated `/api/local-storage/...` image should reuse that original URL and existing thumbnail instead of copying the object into `gallery/images` or recompressing a gallery thumbnail synchronously; missing/stale thumbnails are backfilled later by `/api/gallery` reads. Legacy rows without current `works.thumbnail_url` are queued for background thumbnail backfill by `/api/creation-history` and `/api/gallery` when image works are read; list responses must not wait for thumbnail generation. Thumbnail backfill should read object-only originals through short-lived signed object URLs instead of slow SDK buffering. The `/api/local-storage/*` route reads `thumbnails/...` from local disk directly instead of probing object storage first and serves them with long immutable browser cache headers because thumbnail filenames include the profile/hash; `src/proxy.ts` must explicitly preserve cacheable thumbnail/gallery routes instead of applying the default `/api` no-store header. Object-backed generated originals should not return raw signed URLs from public display paths; non-generated object-backed originals can still return a short-lived 302 signed object-storage URL instead of buffering through Next.js.
Video originals and previews also have separate storage rules. Generated videos are stored as object-backed `/api/local-storage/generated/videos/...` URLs. Video thumbnails are local files under `thumbnails/works/videos` or `thumbnails/gallery/videos`, generated by `ensureLocalVideoThumbnail(...)` when history/gallery rows are written or read. The current preferred profile is a real video frame extracted by `ffmpeg-static` and stored as `video-frame-m1280q86-v1.webp`; lightweight SVG profiles such as `video-svg-v1` and `video-fallback-svg-v2` are only fallbacks when frame extraction fails. SVG fallback rows are treated as stale so gallery/history reads can backfill real frame thumbnails in the background; publish also tries frame extraction before copying any client-provided thumbnail. Object-backed videos are streamed from the storage adapter into a bounded temporary local file before ffmpeg extraction, with retry around transient object-stream termination; this avoids passing signed object-storage URLs directly to the bundled `ffmpeg-static` binary, which can crash or return no stderr for some remote inputs. Gallery video cards and detail overlays render the thumbnail first; the original video element is mounted only after the user clicks play, so opening the gallery detail does not immediately download the object-storage video. `/api/download` redirects object-backed local-storage downloads to signed object URLs with content-disposition, and video buttons use a normal anchor-triggered download instead of fetching the full video into a browser blob.
Video originals and previews also have separate storage rules. Generated videos are stored as object-backed `/api/local-storage/generated/videos/...` URLs. Video thumbnails are local files under `thumbnails/works/videos` or `thumbnails/gallery/videos`, generated by `ensureLocalVideoThumbnail(...)` when history/gallery rows are written or read. The current preferred profile is a real video frame extracted by `ffmpeg-static` and stored as `video-frame-m1280q86-v1.webp`; lightweight SVG profiles such as `video-svg-v1` and `video-fallback-svg-v2` are only fallbacks when frame extraction fails. SVG fallback rows are treated as stale so gallery/history reads can backfill real frame thumbnails in the background; publish also tries frame extraction before copying any client-provided thumbnail. Object-backed videos are streamed from the storage adapter into a bounded temporary local file before ffmpeg extraction, with retry around transient object-stream termination; this avoids passing signed object-storage URLs directly to the bundled `ffmpeg-static` binary, which can crash or return no stderr for some remote inputs. Gallery video cards and detail overlays render the thumbnail first; the original video element is mounted only after the user clicks play, so opening the gallery detail does not immediately download the object-storage video. `/api/download` can redirect non-generated object-backed local-storage downloads to signed object URLs with content-disposition, but generated videos return watermarked bytes unless the authenticated member/admin no-watermark preference allows the raw file; video buttons use a normal anchor-triggered download and pass a same-origin download token.
Gallery detail metadata must not load original images just to compute size. `ImageMetadataBadge` accepts stored `width`/`height`; gallery detail passes those values with `loadMetadata={false}` so preview surfaces stay thumbnail-only and original requests are reserved for fullscreen, download, copy, edit, and share.
@@ -217,7 +217,7 @@ Schema sources:
Core data areas:
- Users: `auth.users`, `profiles`.
- 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.
- Works: `works`.
- Credits: `credit_transactions`, `redeem_codes`.
- Orders: `orders`.

View File

@@ -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` |
| 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. |
| 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` |
@@ -139,8 +139,8 @@ 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/proxy.ts` | Serves storage objects by key without changing existing frontend URLs. 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. Originals 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/*`. For object-backed local-storage files, it redirects to a short-lived signed object URL with content-disposition instead of buffering large videos through Next.js; frontend video buttons use `triggerDownloadFile(...)` so the browser starts the download immediately. |
| 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. 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. |
| 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

View File

@@ -22,6 +22,7 @@
"test:video-object-storage-actions": "tsx ./scripts/test-video-object-storage-actions.mjs",
"test:gallery-publish-fast-path": "tsx ./scripts/test-gallery-publish-fast-path.mjs",
"test:gallery-response": "node --no-warnings ./scripts/test-gallery-response.mjs",
"test:media-watermark-policy": "tsx ./scripts/test-media-watermark-policy.mjs",
"test:yuanjie-media-manifest-mapping": "tsx ./scripts/test-yuanjie-media-manifest-mapping.mjs",
"test:yuanjie-image2-persistence": "tsx ./scripts/test-yuanjie-image2-persistence.mjs",
"test:yuanjie-pricing-sync": "tsx ./scripts/test-yuanjie-pricing-sync.mjs",

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -790,7 +790,8 @@ ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_bound_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_sender_domain VARCHAR(255),
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark';
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark',
ADD COLUMN IF NOT EXISTS watermark_disabled BOOLEAN NOT NULL DEFAULT false;
UPDATE profiles
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), NULLIF(nickname, ''), split_part(email, '@', 1))

View File

@@ -65,6 +65,7 @@ CREATE TABLE IF NOT EXISTS profiles (
email_bound_at TIMESTAMPTZ,
email_sender_domain VARCHAR(255),
preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark',
watermark_disabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
@@ -446,7 +447,8 @@ ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_bound_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_sender_domain VARCHAR(255),
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark';
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark',
ADD COLUMN IF NOT EXISTS watermark_disabled BOOLEAN NOT NULL DEFAULT false;
UPDATE profiles
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), NULLIF(nickname, ''), split_part(email, '@', 1))

View File

@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
const repoRoot = path.resolve(import.meta.dirname, '..');
const policyModule = await import('../src/lib/media-watermark-policy.ts');
const watermarkModule = await import('../src/lib/media-watermark.ts');
const {
canAccessOriginalMedia,
getWatermarkedStorageKey,
isWatermarkableStorageKey,
shouldWatermarkStorageResponse,
shouldWatermarkDownloadResponse,
} = policyModule.default || policyModule;
const { applyImageWatermark } = watermarkModule.default || watermarkModule;
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('watermark policy targets generated work media without touching site assets or avatars', () => {
assert.equal(isWatermarkableStorageKey('generated/images/work.png'), true);
assert.equal(isWatermarkableStorageKey('generated/videos/work.mp4'), true);
assert.equal(isWatermarkableStorageKey('gallery/images/work.webp'), true);
assert.equal(isWatermarkableStorageKey('gallery/videos/work.mp4'), true);
assert.equal(isWatermarkableStorageKey('thumbnails/generated/images/work-m1280q86.webp'), true);
assert.equal(isWatermarkableStorageKey('thumbnails/works/videos/frame-video-frame-m1280q86-v1.webp'), true);
assert.equal(isWatermarkableStorageKey('imported/works/results/images/imported.jpg'), true);
assert.equal(isWatermarkableStorageKey('site-assets/logo.png'), false);
assert.equal(isWatermarkableStorageKey('avatars/user.webp'), false);
assert.equal(isWatermarkableStorageKey('user-api-manifests/user/key.json'), false);
assert.equal(isWatermarkableStorageKey('reverse-prompt/reference-images/input.png'), false);
});
await runTest('only privileged users who explicitly disable watermark can access original media', () => {
assert.equal(canAccessOriginalMedia(null), false);
assert.equal(canAccessOriginalMedia({ role: 'user', membershipTier: 'free', watermarkDisabled: true }), 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);
});
await runTest('storage responses default to watermarked generated media', () => {
assert.equal(shouldWatermarkStorageResponse('generated/images/work.png', 'image/png', null), true);
assert.equal(
shouldWatermarkStorageResponse('generated/images/work.png', 'image/png', {
role: 'vip',
membershipTier: 'pro',
watermarkDisabled: true,
}),
true,
);
assert.equal(shouldWatermarkStorageResponse('site-assets/logo.png', 'image/png', null), false);
});
await runTest('download responses only skip watermark for privileged users who disabled it', () => {
assert.equal(shouldWatermarkDownloadResponse('generated/images/work.png', 'image/png', null), true);
assert.equal(shouldWatermarkDownloadResponse('generated/images/work.png', 'image/png', {
role: 'vip',
membershipTier: 'pro',
watermarkDisabled: false,
}), true);
assert.equal(shouldWatermarkDownloadResponse('generated/images/work.png', 'image/png', {
role: 'vip',
membershipTier: 'pro',
watermarkDisabled: true,
}), false);
assert.equal(shouldWatermarkDownloadResponse('site-assets/logo.png', 'image/png', null), false);
});
await runTest('watermarked cache keys are deterministic and separated by media kind', () => {
assert.match(getWatermarkedStorageKey('generated/images/work.png', 'image/png'), /^watermarked\/images\/[a-f0-9]{64}\.png$/);
assert.match(getWatermarkedStorageKey('gallery/images/work.webp', 'image/webp'), /^watermarked\/images\/[a-f0-9]{64}\.webp$/);
assert.match(getWatermarkedStorageKey('generated/videos/work.mp4', 'video/mp4'), /^watermarked\/videos\/[a-f0-9]{64}\.mp4$/);
});
await runTest('image watermark renderer visibly changes raster media', async () => {
const input = await sharp({
create: {
width: 640,
height: 360,
channels: 4,
background: { r: 36, g: 50, b: 72, alpha: 1 },
},
})
.png()
.toBuffer();
const output = await applyImageWatermark(input, {
key: 'generated/images/work.png',
contentType: 'image/png',
});
assert.notDeepEqual(output, input);
const metadata = await sharp(output).metadata();
assert.equal(metadata.width, 640);
assert.equal(metadata.height, 360);
});
await runTest('local storage route uses watermark access instead of exposing raw object URLs by default', () => {
const source = read('src/app/api/local-storage/[...path]/route.ts');
assert.match(source, /shouldWatermarkStorageResponse\(/);
assert.match(source, /serveWatermarkedStorageFile\(/);
});
await runTest('download route applies watermark and checks authenticated no-watermark entitlement', () => {
const source = read('src/app/api/download/route.ts');
assert.match(source, /resolveMediaWatermarkAccess\(request\)/);
assert.match(source, /serveWatermarkedDownloadFile\(/);
assert.match(source, /canAccessOriginalMedia\(/);
});
await runTest('profile API and auth store carry the member no-watermark preference', () => {
const preferenceSource = read('src/lib/profile-preferences.ts');
const profileRouteSource = read('src/app/api/profile/route.ts');
const authStoreSource = read('src/lib/auth-store.ts');
assert.match(preferenceSource, /watermark_disabled BOOLEAN NOT NULL DEFAULT false/);
assert.match(profileRouteSource, /watermark_disabled/);
assert.match(profileRouteSource, /watermarkDisabled/);
assert.match(profileRouteSource, /COALESCE\(watermark_disabled,\s*false\) AS watermark_disabled/);
assert.match(authStoreSource, /watermarkDisabled:\s*boolean/);
assert.match(authStoreSource, /watermark_disabled === true/);
});
await runTest('profile page exposes a VIP-only no-watermark download switch', () => {
const source = read('src/app/profile/page.tsx');
assert.match(source, /import \{ Switch \} from '@\/components\/ui\/switch'/);
assert.match(source, /watermarkDisabled/);
assert.match(source, /checked=\{accountForm\.watermarkDisabled\}/);
assert.match(source, /disabled=\{!canDisableWatermark/);
assert.match(source, /下载无水印/);
});
await runTest('download helpers forward the current session to the download API', () => {
const source = read('src/lib/utils.ts');
assert.match(source, /function getStoredAccessTokenForDownload\(/);
assert.match(source, /Authorization: `Bearer \$\{token\}`/);
assert.match(source, /downloadToken/);
assert.match(source, /includeDownloadToken: false/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -55,7 +55,7 @@ const UUID_ID_TABLES = new Set([
]);
const TABLE_COLUMNS: Record<string, string[]> = {
profiles: ['id', 'email', 'nickname', 'display_nickname', 'avatar_url', 'phone', 'role', 'membership_tier', 'membership_expires_at', 'credits_balance', 'daily_quota_used', 'daily_quota_limit', 'is_active', 'preferred_theme', 'invite_code', 'referred_by_user_id', 'created_at', 'updated_at'],
profiles: ['id', 'email', 'nickname', 'display_nickname', 'avatar_url', 'phone', 'role', 'membership_tier', 'membership_expires_at', 'credits_balance', 'daily_quota_used', 'daily_quota_limit', 'is_active', 'preferred_theme', 'watermark_disabled', 'invite_code', 'referred_by_user_id', 'created_at', 'updated_at'],
site_config: ['id', 'site_name', 'site_tab_title', 'site_description', 'site_keywords', 'logo_url', 'favicon_url', 'announcement', 'membership_enabled', 'terms_of_service', 'privacy_policy', 'about_us', 'help_center', 'filing_info', 'filing_url', 'public_security_filing_info', 'public_security_filing_url', 'redeem_code_mall_url', 'log_retention_days', 'updated_at'],
site_stats: ['id', 'total_visits', 'total_users', 'total_generations', 'updated_at'],
announcements: ['id', 'title', 'content', 'type', 'is_active', 'starts_at', 'expires_at', 'created_at', 'updated_at'],
@@ -613,6 +613,7 @@ function getMergeAssignments(table: string, cols: string[]): string[] {
if (has('daily_quota_limit')) assignments.push(`daily_quota_limit = COALESCE(target.daily_quota_limit, EXCLUDED.daily_quota_limit)`);
if (has('is_active')) assignments.push(`is_active = COALESCE(target.is_active, EXCLUDED.is_active)`);
if (has('preferred_theme')) assignments.push(`preferred_theme = CASE WHEN EXCLUDED.preferred_theme IN ('dark', 'light') THEN EXCLUDED.preferred_theme ELSE target.preferred_theme END`);
if (has('watermark_disabled')) assignments.push(`watermark_disabled = COALESCE(EXCLUDED.watermark_disabled, target.watermark_disabled)`);
if (has('invite_code')) assignments.push(`invite_code = COALESCE(NULLIF(target.invite_code, ''), EXCLUDED.invite_code)`);
if (has('referred_by_user_id')) assignments.push(`referred_by_user_id = COALESCE(target.referred_by_user_id, EXCLUDED.referred_by_user_id)`);
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);

View File

@@ -1,6 +1,12 @@
import path from 'path';
import { NextRequest, NextResponse } from 'next/server';
import { localStorage } from '@/lib/local-storage';
import { serveWatermarkedDownloadFile } from '@/lib/media-watermark';
import {
canAccessOriginalMedia,
resolveMediaWatermarkAccess,
shouldWatermarkDownloadResponse,
} from '@/lib/media-watermark-policy';
import { fetchPublicHttpUrl } from '@/lib/remote-fetch';
/**
@@ -26,9 +32,10 @@ export async function GET(request: NextRequest) {
}
try {
const watermarkAccess = await resolveMediaWatermarkAccess(request);
const localKey = getLocalStorageKey(url);
if (localKey) {
return await downloadLocalStorageFile(localKey, filename, disposition);
return await downloadLocalStorageFile(localKey, filename, disposition, watermarkAccess);
}
const targetUrl = resolveDownloadUrl(url, request.nextUrl.origin);
@@ -104,10 +111,35 @@ function resolveDownloadUrl(url: string, origin: string): string | null {
return null;
}
async function downloadLocalStorageFile(key: string, filename: string, disposition: 'attachment' | 'inline') {
async function downloadLocalStorageFile(
key: string,
filename: string,
disposition: 'attachment' | 'inline',
watermarkAccess: Awaited<ReturnType<typeof resolveMediaWatermarkAccess>>,
) {
const contentType = getContentType(key);
const shouldWatermark = shouldWatermarkDownloadResponse(key, contentType, watermarkAccess);
const mayAccessOriginal = canAccessOriginalMedia(watermarkAccess);
if (shouldWatermark) {
if (!await localStorage.fileExistsAsync(key)) {
return NextResponse.json({ error: '文件不存在' }, { status: 404 });
}
const watermarked = await serveWatermarkedDownloadFile(key, contentType);
return buildDownloadResponse(
watermarked.buffer.buffer.slice(
watermarked.buffer.byteOffset,
watermarked.buffer.byteOffset + watermarked.buffer.byteLength,
) as ArrayBuffer,
watermarked.contentType,
filename,
watermarked.buffer.byteLength,
disposition,
);
}
const shouldTryObjectRedirect = contentType.startsWith('video/') || !localStorage.fileExists(key);
if (shouldTryObjectRedirect && await localStorage.objectFileExistsAsync(key)) {
if ((mayAccessOriginal || !shouldWatermark) && shouldTryObjectRedirect && await localStorage.objectFileExistsAsync(key)) {
const objectUrl = localStorage.generateObjectReadUrl(key, 300, {
contentDisposition: buildContentDisposition(disposition, filename),
contentType,

View File

@@ -1,9 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { localStorage } from '@/lib/local-storage';
import { serveWatermarkedStorageFile } from '@/lib/media-watermark';
import { shouldWatermarkStorageResponse } from '@/lib/media-watermark-policy';
import path from 'path';
const THUMBNAIL_CACHE_CONTROL = 'public, max-age=31536000, immutable';
const LOCAL_CACHE_CONTROL = 'private, max-age=300';
const WATERMARK_CACHE_CONTROL = 'public, max-age=86400, stale-while-revalidate=604800';
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
@@ -13,15 +16,24 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
}
const contentType = getContentType(filePath);
if (shouldWatermarkStorageResponse(filePath, contentType, null)) {
if (!await localStorage.fileExistsAsync(filePath)) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
const watermarked = await serveWatermarkedStorageFile(filePath, contentType);
return serveLocalBuffer(watermarked.key, watermarked.buffer, WATERMARK_CACHE_CONTROL, watermarked.contentType);
}
if (filePath.startsWith('thumbnails/')) {
if (!localStorage.fileExists(filePath)) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
return serveLocalBuffer(filePath, localStorage.readFile(filePath), THUMBNAIL_CACHE_CONTROL);
return serveLocalBuffer(filePath, localStorage.readFile(filePath), THUMBNAIL_CACHE_CONTROL, contentType);
}
if (localStorage.fileExists(filePath)) {
return serveLocalBuffer(filePath, localStorage.readFile(filePath), LOCAL_CACHE_CONTROL);
return serveLocalBuffer(filePath, localStorage.readFile(filePath), LOCAL_CACHE_CONTROL, contentType);
}
const objectUrl = localStorage.generateObjectReadUrl(filePath, 300);
@@ -38,10 +50,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
}
function serveLocalBuffer(filePath: string, fileBuffer: Buffer, cacheControl: string): NextResponse {
function serveLocalBuffer(filePath: string, fileBuffer: Buffer, cacheControl: string, contentType = getContentType(filePath)): NextResponse {
return new NextResponse(new Uint8Array(fileBuffer), {
headers: {
'Content-Type': getContentType(filePath),
'Content-Type': contentType,
'Content-Disposition': `inline; filename="${path.basename(filePath)}"`,
'Cache-Control': cacheControl,
},

View File

@@ -12,6 +12,11 @@ function normalizeRoleForTier(role: string | null | undefined, tier: string | nu
return tier && tier !== 'free' ? 'vip' : currentRole === 'vip' ? 'user' : currentRole;
}
function canDisableWatermarkForProfile(role: string | null | undefined, tier: string | null | undefined): boolean {
if (role === 'admin' || role === 'enterprise_admin') return true;
return Boolean(tier && tier !== 'free');
}
function isEmail(value: string): boolean {
return value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
@@ -56,7 +61,8 @@ export async function GET(request: NextRequest) {
`SELECT id, email, nickname AS username, COALESCE(NULLIF(display_nickname, ''), nickname) AS nickname,
COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname,
phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit,
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme,
COALESCE(watermark_disabled, false) AS watermark_disabled
FROM profiles WHERE id = $1`,
[tokenUserId],
);
@@ -95,6 +101,8 @@ export async function PUT(request: NextRequest) {
const hasNickname = Object.prototype.hasOwnProperty.call(body, 'nickname');
const hasPhone = Object.prototype.hasOwnProperty.call(body, 'phone');
const hasAvatarUrl = Object.prototype.hasOwnProperty.call(body, 'avatarUrl');
const hasWatermarkDisabled = Object.prototype.hasOwnProperty.call(body, 'watermarkDisabled');
const watermarkDisabled = body.watermarkDisabled === true;
const currentPassword = typeof body.currentPassword === 'string' ? body.currentPassword : '';
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : '';
const email = hasEmail && typeof body.email === 'string' ? body.email.trim() : undefined;
@@ -134,7 +142,8 @@ export async function PUT(request: NextRequest) {
`SELECT id, email, nickname AS username, COALESCE(NULLIF(display_nickname, ''), nickname) AS nickname,
COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname,
phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit,
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme,
COALESCE(watermark_disabled, false) AS watermark_disabled
FROM profiles WHERE id = $1 FOR UPDATE`,
[tokenUserId]
);
@@ -178,6 +187,11 @@ 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)) {
await client.query('ROLLBACK');
return NextResponse.json({ error: '仅会员可关闭下载水印' }, { status: 403 });
}
if (username !== undefined && username !== currentProfile.username) {
const duplicateUsername = await client.query(
'SELECT id FROM profiles WHERE LOWER(nickname) = LOWER($1) AND id <> $2 LIMIT 1',
@@ -254,12 +268,14 @@ export async function PUT(request: NextRequest) {
display_nickname = CASE WHEN $5::boolean THEN NULLIF($6, '') ELSE COALESCE(NULLIF(display_nickname, ''), nickname) END,
phone = CASE WHEN $7::boolean THEN NULLIF($8, '') ELSE phone END,
avatar_url = CASE WHEN $9::boolean THEN NULLIF($10, '') ELSE COALESCE(NULLIF(avatar_url, ''), $11) END,
watermark_disabled = CASE WHEN $12::boolean THEN $13 ELSE watermark_disabled END,
updated_at = NOW()
WHERE id = $12
WHERE id = $14
RETURNING id, email, nickname AS username, COALESCE(NULLIF(display_nickname, ''), nickname) AS nickname,
COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname,
phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit,
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme`,
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme,
COALESCE(watermark_disabled, false) AS watermark_disabled`,
[
email !== undefined,
email || null,
@@ -272,6 +288,8 @@ export async function PUT(request: NextRequest) {
avatarUrl !== undefined,
avatarUrl || '',
nextAvatarUrl,
hasWatermarkDisabled,
watermarkDisabled,
tokenUserId,
]
);

View File

@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { ManagedModelConfigResponse, ManagedModelRecommendation, ManagedModelType } from '@/lib/model-config-types';
@@ -95,7 +96,7 @@ export default function ProfilePage() {
const router = useRouter();
const [activeTab, setActiveTab] = useState('account');
const [mounted, setMounted] = useState(false);
const [accountForm, setAccountForm] = useState({ username: '', nickname: '', email: '', phone: '', avatarUrl: '' });
const [accountForm, setAccountForm] = useState({ username: '', nickname: '', email: '', phone: '', avatarUrl: '', watermarkDisabled: false });
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
const [savingAccount, setSavingAccount] = useState(false);
const [processingAvatar, setProcessingAvatar] = useState(false);
@@ -144,8 +145,9 @@ export default function ProfilePage() {
email: user.email || '',
phone: user.phone || '',
avatarUrl: user.avatarUrl || '',
watermarkDisabled: user.watermarkDisabled === true,
});
}, [user?.id, user?.username, user?.nickname, user?.email, user?.phone, user?.avatarUrl]);
}, [user?.id, user?.username, user?.nickname, user?.email, user?.phone, user?.avatarUrl, user?.watermarkDisabled]);
useEffect(() => {
if (emailVerifyCooldown <= 0) return;
@@ -168,11 +170,13 @@ export default function ProfilePage() {
created_at: user?.createdAt || '',
email_verified: user?.emailVerified === true,
email_verified_at: user?.emailVerifiedAt || '',
watermark_disabled: user?.watermarkDisabled === true,
};
const normalizedMembershipTier = normalizeMembershipTier(profile.membership_tier);
const currentMembershipRank = membershipRank[normalizedMembershipTier] ?? 0;
const tierInfo = membershipTiers.find(t => t.tier === normalizedMembershipTier) || membershipTiers[0];
const canDisableWatermark = isAdmin || isVip || currentMembershipRank > 0;
// Role display info
const roleInfo: Record<string, { label: string; color: string }> = {
@@ -267,12 +271,13 @@ export default function ProfilePage() {
setAccountMessage(null);
try {
const payload: Record<string, string> = {
const payload: Record<string, string | boolean> = {
username: accountForm.username,
displayNickname: accountForm.nickname,
email: accountForm.email,
phone: accountForm.phone,
avatarUrl: accountForm.avatarUrl,
watermarkDisabled: canDisableWatermark && accountForm.watermarkDisabled,
};
if (passwordForm.newPassword) {
@@ -308,6 +313,7 @@ export default function ProfilePage() {
createdAt: data.profile.created_at ?? authUser.createdAt,
emailVerified: data.profile.email_verified === true,
emailVerifiedAt: data.profile.email_verified_at ?? null,
watermarkDisabled: data.profile.watermark_disabled === true,
});
}
@@ -387,6 +393,7 @@ export default function ProfilePage() {
createdAt: data.profile.created_at ?? user?.createdAt ?? null,
emailVerified: data.profile.email_verified === true,
emailVerifiedAt: data.profile.email_verified_at ?? null,
watermarkDisabled: data.profile.watermark_disabled === true,
});
}
setShowEmailVerify(false);
@@ -652,6 +659,23 @@ export default function ProfilePage() {
<Separator />
<div className="rounded-xl border border-border bg-card/40 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-sm text-muted-foreground">
{canDisableWatermark ? '关闭后,下载导出的图片和视频将保留原文件。' : '升级会员后可关闭下载水印。'}
</p>
</div>
<Switch
checked={accountForm.watermarkDisabled}
disabled={!canDisableWatermark}
onCheckedChange={(checked) => setAccountForm(prev => ({ ...prev, watermarkDisabled: checked }))}
aria-label="下载无水印"
/>
</div>
</div>
<div>
<h3 className="font-medium mb-3 flex items-center gap-2"><Shield className="h-4 w-4" /></h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">

View File

@@ -16,6 +16,7 @@ export interface AuthUser {
emailVerified: boolean;
emailVerifiedAt: string | null;
preferredTheme: 'dark' | 'light';
watermarkDisabled: boolean;
}
export interface AuthState {
@@ -86,6 +87,7 @@ export function parseApiUser(apiUser: Record<string, unknown>): AuthUser {
emailVerified: apiUser.email_verified === true,
emailVerifiedAt: (apiUser.email_verified_at as string | null) ?? null,
preferredTheme: apiUser.preferred_theme === 'light' ? 'light' : 'dark',
watermarkDisabled: apiUser.watermark_disabled === true,
};
}

View File

@@ -0,0 +1,160 @@
import crypto from 'node:crypto';
import path from 'node:path';
import type { NextRequest } from 'next/server';
import { getAuthenticatedUser, verifySessionToken, type AuthenticatedUser } from '@/lib/session-auth';
import { getDbClient } from '@/storage/database/local-db';
export type MediaWatermarkAccessContext = {
role: string;
membershipTier: string;
watermarkDisabled: boolean;
} | null;
const WATERMARKABLE_PREFIXES = [
'generated/images/',
'generated/videos/',
'gallery/images/',
'gallery/videos/',
'imported/works/results/',
'imported/works/thumbnails/',
'thumbnails/generated/',
'thumbnails/gallery/',
'thumbnails/works/',
];
const EXCLUDED_PREFIXES = [
'watermarked/',
'site-assets/',
'avatars/',
'user-api-manifests/',
'system-api-manifests/',
'reverse-prompt/reference-images/',
];
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(/^\/+/, '');
}
export function isWatermarkableStorageKey(key: string): boolean {
const normalized = normalizeStorageKeyForWatermark(key);
if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) return false;
if (EXCLUDED_PREFIXES.some(prefix => normalized.startsWith(prefix))) return false;
return WATERMARKABLE_PREFIXES.some(prefix => normalized.startsWith(prefix));
}
export function isWatermarkableContentType(contentType: string): boolean {
return contentType.startsWith('image/') || contentType.startsWith('video/');
}
export function canAccessOriginalMedia(context: MediaWatermarkAccessContext): boolean {
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));
}
export function shouldWatermarkStorageResponse(
key: string,
contentType: string,
context: MediaWatermarkAccessContext,
): boolean {
void context;
return isWatermarkableStorageKey(key) && isWatermarkableContentType(contentType);
}
export function shouldWatermarkDownloadResponse(
key: string,
contentType: string,
context: MediaWatermarkAccessContext,
): boolean {
return isWatermarkableStorageKey(key) && isWatermarkableContentType(contentType) && !canAccessOriginalMedia(context);
}
export function getWatermarkedStorageKey(key: string, contentType: string): string {
const normalized = normalizeStorageKeyForWatermark(key);
const digest = crypto.createHash('sha256').update(`${normalized}:${contentType}:miaojing-watermark-v1`).digest('hex');
const isVideo = contentType.startsWith('video/') || isVideoStorageKey(normalized);
const ext = isVideo ? getVideoOutputExtension(normalized, contentType) : getImageOutputExtension(normalized, contentType);
return `watermarked/${isVideo ? 'videos' : 'images'}/${digest}.${ext}`;
}
export function getMediaKindFromContentType(key: string, contentType: string): 'image' | 'video' | null {
if (contentType.startsWith('image/')) return 'image';
if (contentType.startsWith('video/')) return 'video';
const ext = getExtension(key);
if (WATERMARK_IMAGE_EXTENSIONS.has(ext)) return 'image';
if (WATERMARK_VIDEO_EXTENSIONS.has(ext)) return 'video';
return null;
}
export async function resolveMediaWatermarkAccess(request: NextRequest): Promise<MediaWatermarkAccessContext> {
const tokenUser = await getRequestUser(request);
if (!tokenUser) return null;
const client = await getDbClient();
try {
const result = await client.query(
`SELECT role, membership_tier, COALESCE(watermark_disabled, false) AS watermark_disabled
FROM profiles
WHERE id = $1
AND is_active = true
LIMIT 1`,
[tokenUser.userId],
);
const row = result.rows[0];
if (!row) return null;
return {
role: row.role || tokenUser.role || 'user',
membershipTier: row.membership_tier || 'free',
watermarkDisabled: row.watermark_disabled === true,
};
} catch (error) {
if (error && typeof error === 'object' && (error as { code?: string }).code === '42703') {
return {
role: tokenUser.role || 'user',
membershipTier: 'free',
watermarkDisabled: false,
};
}
throw error;
} finally {
client.release();
}
}
async function getRequestUser(request: NextRequest): Promise<AuthenticatedUser | null> {
const headerUser = await getAuthenticatedUser(request);
if (headerUser) return headerUser;
const queryToken = request.nextUrl.searchParams.get('downloadToken') || '';
return queryToken ? verifySessionToken(queryToken) : null;
}
function isVideoStorageKey(key: string): boolean {
return WATERMARK_VIDEO_EXTENSIONS.has(getExtension(key));
}
function getImageOutputExtension(key: string, contentType: string): string {
if (contentType === 'image/jpeg') return 'jpg';
if (contentType === 'image/png') return 'png';
if (contentType === 'image/webp') return 'webp';
const ext = getExtension(key);
return WATERMARK_IMAGE_EXTENSIONS.has(ext) ? (ext === 'jpeg' ? 'jpg' : ext) : 'png';
}
function getVideoOutputExtension(key: string, contentType: string): string {
if (contentType === 'video/webm') return 'webm';
if (contentType === 'video/quicktime') return 'mov';
const ext = getExtension(key);
return WATERMARK_VIDEO_EXTENSIONS.has(ext) ? ext : 'mp4';
}
function getExtension(key: string): string {
return key.split('?')[0].split('#')[0].split('.').pop()?.toLowerCase() || '';
}

271
src/lib/media-watermark.ts Normal file
View File

@@ -0,0 +1,271 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { spawn } from 'node:child_process';
import sharp from 'sharp';
import { localStorage } from '@/lib/local-storage';
import {
getMediaKindFromContentType,
getWatermarkedStorageKey,
} from '@/lib/media-watermark-policy';
const WATERMARK_LOGO_PATH = path.join(process.cwd(), 'public', 'watermark', 'miaojing-watermark-logo.png');
const WATERMARK_TEXT = 'MIAOJING AI';
const WATERMARK_OPACITY = 0.5;
export async function serveWatermarkedStorageFile(key: string, contentType: string): Promise<{
key: string;
buffer: Buffer;
contentType: string;
}> {
return createWatermarkedFile(key, contentType);
}
export async function serveWatermarkedDownloadFile(key: string, contentType: string): Promise<{
key: string;
buffer: Buffer;
contentType: string;
}> {
return createWatermarkedFile(key, contentType);
}
export async function applyImageWatermark(input: Buffer, options: { key: string; contentType: string }): Promise<Buffer> {
const metadata = await sharp(input).metadata();
const width = metadata.width || 1024;
const height = metadata.height || 1024;
const overlay = await createImageWatermarkOverlay(width, height);
const composite = sharp(input, { animated: false }).composite([{ input: overlay, gravity: 'southeast' }]);
if (options.contentType === 'image/jpeg') {
return composite.jpeg({ quality: 92, mozjpeg: true }).toBuffer();
}
if (options.contentType === 'image/webp') {
return composite.webp({ quality: 92 }).toBuffer();
}
return composite.png().toBuffer();
}
async function createWatermarkedFile(key: string, contentType: string): Promise<{
key: string;
buffer: Buffer;
contentType: string;
}> {
const outputKey = getWatermarkedStorageKey(key, contentType);
if (await localStorage.fileExistsAsync(outputKey)) {
return {
key: outputKey,
buffer: await localStorage.readFileAsync(outputKey),
contentType,
};
}
const kind = getMediaKindFromContentType(key, contentType);
if (kind === 'image') {
const input = await localStorage.readFileAsync(key);
const output = await applyImageWatermark(input, { key, contentType });
const outputContentType = getOutputContentType(outputKey, contentType);
await localStorage.uploadFileLocalOnly({
fileContent: output,
fileName: outputKey,
contentType: outputContentType,
});
return { key: outputKey, buffer: output, contentType: outputContentType };
}
if (kind === 'video') {
const output = await applyVideoWatermark(key, outputKey, contentType);
return { key: outputKey, buffer: output, contentType: getOutputContentType(outputKey, contentType) };
}
throw new Error('Unsupported watermark media type');
}
async function createImageWatermarkOverlay(width: number, height: number): Promise<Buffer> {
const scale = Math.max(0.65, Math.min(1.45, Math.min(width, height) / 900));
const logoSize = Math.max(30, Math.round(Math.min(width, height) * 0.06));
const fontSize = Math.max(16, Math.round(logoSize * 0.48));
const gap = Math.max(8, Math.round(logoSize * 0.22));
const horizontalPadding = Math.max(14, Math.round(16 * scale));
const verticalPadding = Math.max(10, Math.round(10 * scale));
const estimatedTextWidth = Math.round(WATERMARK_TEXT.length * fontSize * 0.64);
const overlayWidth = logoSize + gap + estimatedTextWidth + horizontalPadding * 2;
const overlayHeight = Math.max(logoSize, fontSize * 1.35) + verticalPadding * 2;
const textY = Math.round((overlayHeight + fontSize * 0.72) / 2);
const logoTop = Math.round((overlayHeight - logoSize) / 2);
const logoLeft = horizontalPadding;
const textX = logoLeft + logoSize + gap;
const resizedLogo = await sharp(WATERMARK_LOGO_PATH)
.resize(logoSize, logoSize, { fit: 'inside' })
.png()
.toBuffer();
const textSvg = Buffer.from(`
<svg width="${overlayWidth}" height="${overlayHeight}" xmlns="http://www.w3.org/2000/svg">
<style>
text {
font-family: Inter, Arial, Helvetica, sans-serif;
font-size: ${fontSize}px;
font-weight: 700;
letter-spacing: 0;
}
</style>
<text x="${textX}" y="${textY}" fill="#fff">${WATERMARK_TEXT}</text>
<text x="${textX + 1}" y="${textY + 1}" fill="#000" opacity="0.26">${WATERMARK_TEXT}</text>
</svg>
`);
const overlay = await sharp({
create: {
width: overlayWidth,
height: overlayHeight,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([
{ input: resizedLogo, left: logoLeft, top: logoTop },
{ input: textSvg, left: 0, top: 0 },
])
.ensureAlpha()
.modulate({ brightness: 1 })
.png()
.toBuffer();
return applyPngOpacity(overlay, overlayWidth, overlayHeight, WATERMARK_OPACITY);
}
async function applyPngOpacity(buffer: Buffer, width: number, height: number, opacity: number): Promise<Buffer> {
const rgba = await sharp(buffer).ensureAlpha().raw().toBuffer();
for (let index = 3; index < rgba.length; index += 4) {
rgba[index] = Math.round(rgba[index] * opacity);
}
return sharp(rgba, { raw: { width, height, channels: 4 } }).png().toBuffer();
}
async function applyVideoWatermark(inputKey: string, outputKey: string, contentType: string): Promise<Buffer> {
const ffmpegPath = getFfmpegPath();
if (!ffmpegPath) throw new Error('ffmpeg-static is not available for video watermarking');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'miaojing-watermark-'));
const inputPath = path.join(tempDir, `input.${getVideoExtension(inputKey, contentType)}`);
const overlayPath = path.join(tempDir, 'watermark.png');
const outputPath = path.join(tempDir, `output.${getVideoExtension(outputKey, contentType)}`);
try {
await fs.promises.writeFile(inputPath, await localStorage.readFileAsync(inputKey));
await fs.promises.writeFile(overlayPath, await createVideoWatermarkOverlay());
await runFfmpeg(ffmpegPath, [
'-y',
'-i',
inputPath,
'-i',
overlayPath,
'-filter_complex',
'overlay=W-w-36:H-h-36',
'-c:v',
contentType === 'video/webm' ? 'libvpx-vp9' : 'libx264',
'-preset',
'veryfast',
'-crf',
'23',
'-c:a',
'copy',
'-movflags',
'+faststart',
outputPath,
]);
const output = await fs.promises.readFile(outputPath);
await localStorage.uploadFileLocalOnly({
fileContent: output,
fileName: outputKey,
contentType,
});
return output;
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}
}
async function createVideoWatermarkOverlay(): Promise<Buffer> {
const logoSize = 54;
const fontSize = 26;
const gap = 12;
const paddingX = 18;
const paddingY = 12;
const width = 244;
const height = 78;
const logo = await sharp(WATERMARK_LOGO_PATH).resize(logoSize, logoSize, { fit: 'inside' }).png().toBuffer();
const textSvg = Buffer.from(`
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<style>
text {
font-family: Inter, Arial, Helvetica, sans-serif;
font-size: ${fontSize}px;
font-weight: 700;
letter-spacing: 0;
}
</style>
<text x="${paddingX + logoSize + gap}" y="48" fill="#fff">${WATERMARK_TEXT}</text>
<text x="${paddingX + logoSize + gap + 1}" y="49" fill="#000" opacity="0.28">${WATERMARK_TEXT}</text>
</svg>
`);
const overlay = await sharp({
create: {
width,
height,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([
{ input: logo, left: paddingX, top: paddingY },
{ input: textSvg, left: 0, top: 0 },
])
.png()
.toBuffer();
return applyPngOpacity(overlay, width, height, WATERMARK_OPACITY);
}
function runFfmpeg(ffmpegPath: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] });
const stderr: Buffer[] = [];
child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)));
child.once('error', reject);
child.once('close', code => {
if (code === 0) {
resolve();
return;
}
reject(new Error(Buffer.concat(stderr).toString('utf8').trim() || `ffmpeg exited with code ${code}`));
});
});
}
function getFfmpegPath(): string | null {
try {
const requireFromCwd = createRequire(path.join(process.cwd(), 'package.json'));
const ffmpegPath = requireFromCwd('ffmpeg-static');
return typeof ffmpegPath === 'string' && fs.existsSync(ffmpegPath) ? ffmpegPath : null;
} catch {
return null;
}
}
function getVideoExtension(key: string, contentType: string): string {
if (contentType === 'video/webm') return 'webm';
if (contentType === 'video/quicktime') return 'mov';
const ext = key.split('?')[0].split('#')[0].split('.').pop()?.toLowerCase();
return ext === 'webm' || ext === 'mov' || ext === 'mp4' ? ext : 'mp4';
}
function getOutputContentType(key: string, fallback: string): string {
const ext = key.split('?')[0].split('#')[0].split('.').pop()?.toLowerCase();
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
if (ext === 'png') return 'image/png';
if (ext === 'webp') return 'image/webp';
if (ext === 'mp4') return 'video/mp4';
if (ext === 'webm') return 'video/webm';
if (ext === 'mov') return 'video/quicktime';
return fallback;
}

View File

@@ -14,7 +14,8 @@ export async function ensureProfilePreferenceSchema(client: PoolClient): Promise
try {
await client.query(`
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark'
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark',
ADD COLUMN IF NOT EXISTS watermark_disabled BOOLEAN NOT NULL DEFAULT false
`);
await client.query(`
UPDATE profiles

View File

@@ -160,8 +160,11 @@ export async function downloadFile(
filename: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const proxyUrl = getDownloadProxyUrl(url, filename);
const response = await fetch(proxyUrl);
const token = getStoredAccessTokenForDownload();
const proxyUrl = getDownloadProxyUrl(url, filename, { includeDownloadToken: false });
const response = await fetch(proxyUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!response.ok) {
const data = await response.json().catch(() => ({ error: '下载失败' }));
@@ -193,8 +196,20 @@ export function getImageDownloadExtension(url: string, preferredFormat?: string
return normalizedDataUrlExtension || 'png';
}
export function getDownloadProxyUrl(url: string, filename: string): string {
return `/api/download?url=${encodeURIComponent(url)}&filename=${encodeURIComponent(filename)}`;
export function getDownloadProxyUrl(
url: string,
filename: string,
options: { includeDownloadToken?: boolean } = {},
): string {
const params = new URLSearchParams({
url,
filename,
});
if (options.includeDownloadToken !== false) {
const token = getStoredAccessTokenForDownload();
if (token) params.set('downloadToken', token);
}
return `/api/download?${params.toString()}`;
}
export function triggerDownloadFile(url: string, filename: string): void {
@@ -208,6 +223,22 @@ export function triggerDownloadFile(url: string, filename: string): void {
link.remove();
}
function getStoredAccessTokenForDownload(): string | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem('miaojing_auth');
if (!raw) return null;
const parsed = JSON.parse(raw) as { accessToken?: unknown; session?: { access_token?: unknown } };
if (typeof parsed.accessToken === 'string' && parsed.accessToken) return parsed.accessToken;
if (typeof parsed.session?.access_token === 'string' && parsed.session.access_token) {
return parsed.session.access_token;
}
return null;
} catch {
return null;
}
}
function getImageExtensionFromUrl(url: string): string | null {
const trimmed = url.trim();
if (!trimmed || trimmed.startsWith('data:')) return null;

View File

@@ -27,6 +27,7 @@ export const profiles = pgTable(
daily_quota_limit: integer("daily_quota_limit").notNull().default(5),
is_active: boolean("is_active").default(true).notNull(),
preferred_theme: varchar("preferred_theme", { length: 16 }).notNull().default("dark"),
watermark_disabled: boolean("watermark_disabled").default(false).notNull(),
created_at: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updated_at: timestamp("updated_at", { withTimezone: true }),
},