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>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user