Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# ====== Backend: FastAPI ======
FROM python:3.12-slim
# digest 固定:buildkit 每次都会联网解析 tag 元数据,国内网络下常被掐断导致
# 构建卡死;digest 引用可直接命中本地镜像,无需联网。升级基础镜像时更新 digest
# (docker images --digests python)。
FROM python:3.12-slim@sha256:09f7da3bc104798d0afb40bc08d23ab2da20a76130cec1f2ef170848f5d85217

LABEL app="mind-base-backend"

Expand Down Expand Up @@ -33,10 +36,11 @@ RUN curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 --connect-timeout 30
WORKDIR /app

# Install Python dependencies
# Tip: for faster downloads in China, uncomment the mirror line below
# --index-url https://pypi.tuna.tsinghua.edu.cn/simple
# --default-timeout/--retries:清华源偶尔读超时(大包下载中断导致整层失败),
# 放宽单请求超时到 120s 并自动重试,与 ffmpeg 下载的 --retry 策略保持一致。
COPY requirements.txt .
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
RUN pip install --no-cache-dir --timeout 120 --retries 5 \
-i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt

# Copy application code
COPY app/ ./app/
Expand Down
13 changes: 12 additions & 1 deletion app/infra/minio.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,13 +300,24 @@ async def abort_multipart_upload(self, object_key: str, upload_id: str) -> None:

# ── object access ─────────────────────────────────────────

async def presigned_get(self, object_key: str) -> str:
async def presigned_get(
self, object_key: str, response_headers: Optional[dict] = None
) -> str:
"""Presigned GET URL.

``response_headers`` (e.g. ``{"response-content-type": "application/pdf"}``)
is signed into the query string, so the object is served with the given
Content-Type regardless of how it was stored — needed when uploads
persisted a generic ``application/octet-stream`` and the browser must
render the file inline (PDF / video / image).
"""
self._ensure_client()
url = await _run_async(
self._client.presigned_get_object,
self.bucket,
object_key,
expires=timedelta(seconds=config.minio.presign_expire),
response_headers=response_headers,
)
return self._public_url(url)

Expand Down
10 changes: 9 additions & 1 deletion app/routers/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,15 @@ async def get_video_raw(
view_mode = _classify_view_mode(file.mime_type)

# Presigned GET URL - works for download and as <video>/<img>/<iframe> src.
url = await client.presigned_get(file.object_key)
# Binary view modes need the real MIME signed into the response: uploads
# may have persisted application/octet-stream, and with nosniff in the
# chain the browser refuses to sniff PDF/media, leaving the viewer blank.
response_headers = (
{"response-content-type": file.mime_type}
if view_mode in ("pdf", "video", "audio", "image")
else None
)
url = await client.presigned_get(file.object_key, response_headers)

# Inline text content for text-like files (<=5 MB) to avoid CORS fetch.
content: Optional[str] = None
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ services:
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
NEXT_PUBLIC_APISIX_HOST: nginx:80
# rewrites 构建时固化,MinIO 同源代理目标必须是 build arg(非运行时 env)
MINIO_PROXY_DEST: http://nginx:80
container_name: mind-base-frontend
# Port exposed for local access; in production nginx handles all traffic
ports:
Expand All @@ -137,6 +139,8 @@ services:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-}
- NEXT_PUBLIC_APISIX_HOST=nginx:80
# MinIO 同源代理目标(rewrites 在服务端运行时读取,非 NEXT_PUBLIC)
- MINIO_PROXY_DEST=http://nginx:80
depends_on:
backend:
condition: service_healthy
Expand Down
15 changes: 13 additions & 2 deletions frontendv2/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,20 @@ WORKDIR /app
# Override via: docker build --build-arg NEXT_PUBLIC_API_URL=...
ARG NEXT_PUBLIC_API_URL=""
ARG NEXT_PUBLIC_APISIX_HOST=""
# next build 会把 rewrites() 的 destination 固化进 routes-manifest.json(运行时
# env 不再参与求值),所以 MinIO 同源代理目标必须作为 build arg 注入。
# compose 传 http://nginx:80;宿主机本地构建缺省走 http://localhost。
ARG MINIO_PROXY_DEST=""
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_APISIX_HOST=$NEXT_PUBLIC_APISIX_HOST
NEXT_PUBLIC_APISIX_HOST=$NEXT_PUBLIC_APISIX_HOST \
MINIO_PROXY_DEST=$MINIO_PROXY_DEST

# Install dependencies first to leverage Docker's layer cache.
# registry 切国内镜像:package-lock 在 Windows 生成,Linux/musl 平台的
# @next/swc 二进制不在锁文件里,`next build` 会现场从 registry 下载约 60MB,
# 直连 npmjs 极易被掐断导致构建崩溃(download-swc 会读取 npm registry 配置,
# 所以这里一处设置同时管住 npm ci 和 swc 下载)。
RUN npm config set registry https://registry.npmmirror.com
COPY package.json package-lock.json ./
RUN npm ci

Expand Down Expand Up @@ -51,7 +61,8 @@ USER appuser

EXPOSE 3000

# busybox wget 会把 localhost 解析成 IPv6 ::1,而 Next 只绑 IPv4 —— 用 127.0.0.1
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3000/ || exit 1
CMD wget -qO- http://127.0.0.1:3000/ || exit 1

CMD ["node", "server.js"]
15 changes: 12 additions & 3 deletions frontendv2/components/cloud-drive/cloud-drive-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,19 @@ export function CloudDriveView() {
);

const handleCreateFolder = useCallback(
async (name: string) => {
await cloudApi.createFolder({ parentId: selectedFolderId, name });
async (name: string, parentId: number | null) => {
await cloudApi.createFolder({ parentId, name });
await refreshFolders();
},
[selectedFolderId, refreshFolders]
[refreshFolders]
);

const handleRenameFolder = useCallback(
async (id: number, name: string) => {
await cloudApi.updateFolder(id, { name });
await refreshFolders();
},
[refreshFolders]
);

const handleDeleteFolder = useCallback(async () => {
Expand Down Expand Up @@ -348,6 +356,7 @@ export function CloudDriveView() {
totalCount={videosLoading ? 0 : totalFiles}
onSelect={handleSelectFolder}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={setDeleteFolderTarget}
/>
</aside>
Expand Down
72 changes: 62 additions & 10 deletions frontendv2/components/cloud-drive/file-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,42 @@
* Next 16 async-params dance, same convention as the shared-note page).
*
* Fetches a presigned MinIO GET URL (plus inline text for text-like types)
* from /cloud/video/:uuid/raw and renders the file natively:
* - video / audio / image / pdf -> browser element with the presigned URL
* - html / markdown / text -> inline content (no CORS fetch)
* from /cloud/video/:uuid/raw and renders the file:
* - pdf -> auto-open in a NEW browser tab; the
* browser's native PDF viewer takes over
* - video / audio / image -> browser element with the presigned URL
* - html / markdown / text -> inline content (no CORS fetch)
* - anything else -> download link
*
* Layout: sticky frosted header (back + icon + name + size + download) + a
* centered reading column.
*/
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { Markdown } from "@/components/markdown";
import { ArrowLeft, Loader2, AlertCircle, Download, FileText } from "lucide-react";
import { cloudApi, formatBytes, type CloudRawFileResponse } from "@/lib/api/cloud";
import { FileIconTile } from "./file-icon";

/**
* Normalize the presigned MinIO URL to a same-origin path when it goes
* through the nginx /minio-proxy prefix. Embedding the absolute proxy URL
* (e.g. http://localhost/minio-proxy/...) in an <iframe> trips nginx's
* X-Frame-Options: SAMEORIGIN whenever the page origin differs (page on
* :3000, proxy on :80). next.config.ts proxies the identical path through
* the Next server, so the relative form stays same-origin for every media
* element below. Non-proxy URLs (custom MINIO__PUBLIC_ENDPOINT) are kept.
*/
function toSameOriginUrl(url: string): string {
try {
const u = new URL(url, window.location.origin);
if (u.pathname.startsWith("/minio-proxy/")) return u.pathname + u.search;
return url;
} catch {
return url;
}
}

export function FileViewer() {
const pathname = usePathname();
const router = useRouter();
Expand All @@ -31,14 +52,26 @@ export function FileViewer() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

// PDF: open the file in a NEW tab and let the browser's native viewer
// take over; this page stays as a status card with manual links in case
// the popup is blocked. The ref guard keeps React StrictMode's dev
// double-invoke from opening two tabs.
const pdfAutoOpened = useRef(false);
useEffect(() => {
if (raw?.viewMode === "pdf" && raw.url && !pdfAutoOpened.current) {
pdfAutoOpened.current = true;
window.open(raw.url, "_blank");
}
}, [raw?.viewMode, raw?.url]);

useEffect(() => {
if (!uuid) return;
let cancelled = false;
void (async () => {
try {
const r = await cloudApi.getRawFile(uuid);
if (!cancelled) {
setRaw(r);
setRaw({ ...r, url: toSameOriginUrl(r.url) });
setLoading(false);
}
} catch (e) {
Expand Down Expand Up @@ -159,12 +192,31 @@ function RawContent({ raw }: { raw: CloudRawFileResponse }) {
/>
);
case "pdf":
// The auto-open effect opened a new tab (native PDF viewer); this
// card covers the popup-blocked case with a real anchor link.
return (
<iframe
src={raw.url}
title={raw.fileName}
className="h-[80vh] w-full rounded-2xl border border-border-subtle"
/>
<div className="flex flex-col items-center justify-center py-24 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-border-subtle text-secondary">
<FileText className="h-6 w-6" />
</span>
<p className="mt-4 text-[15px] font-medium text-foreground">PDF 已在新标签页打开</p>
<p className="mt-1.5 max-w-xs text-[13px] text-secondary">
由浏览器内置查看器展示;若新标签页未出现,点击下方链接手动打开。
</p>
<a
href={raw.url}
target="_blank"
rel="noopener"
className="btn-pill btn-primary mt-5 h-9 px-5 text-[13px]"
>
<FileText className="h-4 w-4" />
在新标签页打开 PDF
</a>
<a href={raw.url} download={raw.fileName} className="btn-pill btn-ghost mt-2 h-9 px-5 text-[13px]">
<Download className="h-4 w-4" />
下载原文件
</a>
</div>
);
case "html":
if (raw.content == null) return <TooLargeDownload raw={raw} />;
Expand Down
Loading
Loading