Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/FileController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

203 lines
10 KiB
Java

package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.StoredFile;
import com.kaidi.oa.repository.StoredFileRepository;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
/**
* Real file storage + streaming for the OA document/archive centers.
*
* Files are stored as DB BLOBs (StoredFile). Upload attributes the uploader to
* the authenticated user (CurrentUserResolver). The stream endpoint returns the
* raw bytes with the right Content-Type and an inline Content-Disposition so the
* browser previews (PDF/image/video) or the frontend renderer (docx/xlsx) can
* consume it; the filename is RFC 5987 encoded so Chinese names survive.
*/
@RestController
@RequestMapping("/api/oa/files")
public class FileController {
private final StoredFileRepository repo;
private final CurrentUserResolver currentUser;
private final com.kaidi.oa.service.AuthorizationService authz;
public FileController(StoredFileRepository repo, CurrentUserResolver currentUser,
com.kaidi.oa.service.AuthorizationService authz) {
this.repo = repo;
this.currentUser = currentUser;
this.authz = authz;
}
/** Metadata view returned to the frontend (never includes the raw bytes). */
public record FileMetaResp(Long id, String name, String contentType, Long size,
String uploaderName, Instant createdAt) {
}
/** POST /api/oa/files (multipart field name: file) -> stored metadata. */
@PostMapping
public ApiResp<FileMetaResp> upload(@RequestParam("file") MultipartFile file,
HttpServletRequest request) {
if (file == null || file.isEmpty()) {
throw new ApiException(400, "file is required");
}
byte[] bytes;
try {
bytes = file.getBytes();
} catch (IOException e) {
throw new ApiException(500, "读取上传文件失败:" + e.getMessage());
}
StoredFile sf = new StoredFile();
String original = file.getOriginalFilename();
sf.setName(original == null || original.isBlank() ? "未命名文件" : original);
sf.setContentType(resolveContentType(file.getContentType(), sf.getName()));
sf.setSize((long) bytes.length);
sf.setData(bytes);
// 上传者身份:uploaderId=稳定 userId(授权依据,根治改名重用冒充)+ uploaderName=展示。
com.kaidi.oa.domain.SysUser uploader = currentUser.resolve(request);
sf.setUploaderName(currentUser.resolveLabel(request));
sf.setUploaderId(uploader != null ? uploader.getId() : null);
sf.setCreatedAt(Instant.now());
StoredFile saved = repo.save(sf);
return ApiResp.ok(toMeta(saved.getId(), saved.getName(), saved.getContentType(),
saved.getSize(), saved.getUploaderName(), saved.getCreatedAt()));
}
/** GET /api/oa/files -> metadata list (no bytes). */
@GetMapping
public ApiResp<List<FileMetaResp>> list(HttpServletRequest http) {
// 列表读授权与 stream/delete 同口径:ADMIN 看全量,其余仅看本人上传的元数据,
// 杜绝任意登录用户枚举出全站他人/审计/证据附件清单(读侧过度暴露)。
// FileMeta 投影不含 uploaderId,故按 uploaderName == 当前用户 displayName 过滤。
com.kaidi.oa.domain.SysUser su = currentUser.resolve(http);
boolean admin = authz.isAdmin(su);
String mine = su != null ? su.getDisplayName() : null;
List<FileMetaResp> out = repo.findAllMeta().stream()
.filter(m -> admin || (mine != null && mine.equals(m.uploaderName())))
.map(m -> toMeta(m.id(), m.name(), m.contentType(), m.size(),
m.uploaderName(), m.createdAt()))
.toList();
return ApiResp.ok(out);
}
/**
* GET /api/oa/files/{id}/stream -> raw bytes for preview/download.
* Content-Disposition is inline so browsers preview in place; the filename
* is provided in both plain (ASCII-stripped) and RFC 5987 (UTF-8) forms.
*/
@GetMapping("/{id}/stream")
public ResponseEntity<byte[]> stream(@PathVariable Long id, HttpServletRequest http) {
StoredFile sf = repo.findById(id)
.orElseThrow(() -> new NotFoundException("file not found: " + id));
// 对象级读授权:仅上传者本人或 ADMIN 可下载,杜绝任意登录用户按 id 枚举拖走他人/审计/证据附件(读 IDOR)。
// 身份按稳定 userId 比对(uploaderId 空时回退 uploaderName),根治改名重用冒充。
com.kaidi.oa.domain.SysUser su = currentUser.resolve(http);
if (!authz.isAdmin(su) && !authz.isOwner(su, sf.getUploaderId(), sf.getUploaderName())) {
throw new ApiException(403, "无权访问:仅上传者本人或管理员可下载该文件");
}
byte[] data = sf.getData() != null ? sf.getData() : new byte[0];
String name = sf.getName() != null ? sf.getName() : ("file-" + id);
// XSS 防护:HTML/SVG/XML 等可执行脚本的类型不以原类型同源 inline 回吐(否则可种存储型 XSS),
// 一律降级为 octet-stream + 强制下载(attachment);其余类型保持 inline 预览。统一加 nosniff 防嗅探。
String ct = sf.getContentType() == null ? "" : sf.getContentType().toLowerCase();
boolean active = ct.contains("html") || ct.contains("svg") || ct.contains("xml")
|| ct.contains("javascript") || ct.contains("xhtml");
MediaType mediaType;
if (active) {
mediaType = MediaType.APPLICATION_OCTET_STREAM;
} else {
try {
mediaType = MediaType.parseMediaType(ct.isBlank()
? MediaType.APPLICATION_OCTET_STREAM_VALUE : sf.getContentType());
} catch (Exception e) {
mediaType = MediaType.APPLICATION_OCTET_STREAM;
}
}
String disposition = active ? "attachment" : "inline";
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
String contentDisposition = disposition + "; filename=\"" + asciiFallback(name)
+ "\"; filename*=UTF-8''" + encoded;
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600")
.contentType(mediaType)
.contentLength(data.length)
.body(data);
}
/** DELETE /api/oa/files/{id} */
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id, HttpServletRequest http) {
StoredFile f = repo.findById(id)
.orElseThrow(() -> new NotFoundException("file not found: " + id));
// 属主校验:仅上传者本人或 ADMIN 可删,杜绝任意登录用户删他人/审计/证据附件(文件 IDOR)。
// 身份按稳定 userId 比对(uploaderId 空时回退 uploaderName),根治改名重用冒充。
com.kaidi.oa.domain.SysUser su = currentUser.resolve(http);
if (!authz.isAdmin(su) && !authz.isOwner(su, f.getUploaderId(), f.getUploaderName())) {
throw new ApiException(403, "无权删除:仅上传者本人或管理员可删除该文件");
}
repo.deleteById(id);
return ApiResp.ok();
}
private FileMetaResp toMeta(Long id, String name, String contentType, Long size,
String uploaderName, Instant createdAt) {
return new FileMetaResp(id, name, contentType, size, uploaderName, createdAt);
}
/** Best-effort content type: prefer the client value, else infer from extension. */
private String resolveContentType(String reported, String name) {
if (reported != null && !reported.isBlank()
&& !MediaType.APPLICATION_OCTET_STREAM_VALUE.equals(reported)) {
return reported;
}
String lower = name.toLowerCase();
if (lower.endsWith(".pdf")) return MediaType.APPLICATION_PDF_VALUE;
if (lower.endsWith(".png")) return MediaType.IMAGE_PNG_VALUE;
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return MediaType.IMAGE_JPEG_VALUE;
if (lower.endsWith(".gif")) return MediaType.IMAGE_GIF_VALUE;
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".bmp")) return "image/bmp";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".mp4")) return "video/mp4";
if (lower.endsWith(".webm")) return "video/webm";
if (lower.endsWith(".docx"))
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
if (lower.endsWith(".doc")) return "application/msword";
if (lower.endsWith(".xlsx"))
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
if (lower.endsWith(".xls")) return "application/vnd.ms-excel";
if (lower.endsWith(".csv")) return "text/csv";
if (lower.endsWith(".txt")) return "text/plain";
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
/** Strip non-ASCII so the legacy `filename="..."` token is always header-safe. */
private String asciiFallback(String name) {
String stripped = name.replaceAll("[^\\x20-\\x7E]", "_").replace("\"", "_");
return stripped.isBlank() ? "file" : stripped;
}
}