feat: watermark generated media 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/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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
public/watermark/miaojing-watermark-logo.png
Normal file
BIN
public/watermark/miaojing-watermark-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
161
scripts/test-media-watermark-policy.mjs
Normal file
161
scripts/test-media-watermark-policy.mjs
Normal 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);
|
||||
@@ -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))`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
160
src/lib/media-watermark-policy.ts
Normal file
160
src/lib/media-watermark-policy.ts
Normal 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
271
src/lib/media-watermark.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user