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 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(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 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 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 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; } }