package server import ( "archive/zip" "bytes" "compress/bzip2" "fmt" "io" "path" "strings" "unicode/utf8" "github.com/klauspost/compress/zstd" "golang.org/x/text/encoding/simplifiedchinese" ) // Go 의 archive/zip 은 Store(0) 와 Deflate(8) 두 가지 해제기만 내장하고 있어서, 다른 // 방식을 만나면 "zip: unsupported compression algorithm" 을 반환한다. 압축 프로그램은 // 기본이 아닌 설정에서 다른 방식을 자주 쓰므로(7-Zip 의 bzip2, WinZip 의 zstd), 여기서 // 순수 Go 로 풀 수 있는 두 가지를 보충한다. 정말로 풀 수 없는 경우(Deflate64 / LZMA / // XZ / PPMd / 암호화 파일)는 압축을 풀기 전에 한국어 안내를 내보내고, 하부 오류를 그대로 // 사용자에게 넘기지 않는다. const ( zipMethodStore = 0 zipMethodDeflate = 8 zipMethodDeflate64 = 9 zipMethodBzip2 = 12 zipMethodLZMA = 14 zipMethodZstdPKW = 20 // PKWARE 가 초기에 zstd 에 할당한 번호 zipMethodZstd = 93 zipMethodXZ = 95 zipMethodJPEG = 96 zipMethodWavPack = 97 zipMethodPPMd = 98 zipMethodAES = 99 ) var zipMethodNames = map[uint16]string{ zipMethodStore: "Store", zipMethodDeflate: "Deflate", zipMethodDeflate64: "Deflate64", zipMethodBzip2: "bzip2", zipMethodLZMA: "LZMA", zipMethodZstdPKW: "Zstandard", zipMethodZstd: "Zstandard", zipMethodXZ: "XZ", zipMethodJPEG: "JPEG", zipMethodWavPack: "WavPack", zipMethodPPMd: "PPMd", zipMethodAES: "AES 加密", } // 사용자에게 노출되는 스킬 업로드 오류 응답 문구. fsUploadSkill 이 // writeErr(400, err.Error()) 로 그대로 내보낸다(server_mgmt.go). const ( errSkillZipParse = "无法解析压缩文件(必须为 zip 格式): %w" errSkillZipEncrypted = "压缩文件已加密(%s)。请上传未加密的 zip 文件。" errSkillZipUnsupported = "不支持的压缩方式: %s(method %d),文件 %s。 " + "请改用「存储(Store)」或「Deflate」方式重新压缩" + "(在 7-Zip·WinRAR 中选择 Deflate 压缩方式,或使用操作系统自带压缩功能或命令行 zip -r)。" ) func zipMethodName(m uint16) string { if n, ok := zipMethodNames[m]; ok { return n } return "未知" } // newSkillZipReader parses an uploaded archive and registers the extra decompressors // we can support beyond the stdlib's Store/Deflate. func newSkillZipReader(buf []byte) (*zip.Reader, error) { zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf))) if err != nil { return nil, fmt.Errorf(errSkillZipParse, err) } zr.RegisterDecompressor(zipMethodBzip2, func(r io.Reader) io.ReadCloser { return io.NopCloser(bzip2.NewReader(r)) }) zdec := zstd.ZipDecompressor(zstd.WithDecoderConcurrency(1)) zr.RegisterDecompressor(zipMethodZstd, zdec) zr.RegisterDecompressor(zipMethodZstdPKW, zdec) return zr, nil } // skillZipEntry pairs a zip entry with its decoded (UTF-8) name — f.Name may hold // raw GBK bytes, see zipEntryName. type skillZipEntry struct { f *zip.File name string } // skillZipEntries lists the archive's real files (no directory entries, no archiver // junk) with their names decoded to UTF-8. func skillZipEntries(zr *zip.Reader) []skillZipEntry { out := make([]skillZipEntry, 0, len(zr.File)) for _, f := range zr.File { if f.FileInfo().IsDir() { continue } name := zipEntryName(f) if strings.HasPrefix(name, "__MACOSX/") || strings.Contains(name, "/__MACOSX/") || path.Base(name) == ".DS_Store" { continue // macOS 가 압축할 때 남긴 잔여 항목 } out = append(out, skillZipEntry{f: f, name: name}) } return out } // zipEntryName returns the entry path as UTF-8. Windows 의 7-Zip / WinRAR / 파일 탐색기는 // UTF-8 플래그 비트를 세우지 않으면 한글·중국어 파일명을 GBK 로 zip 에 기록하고, Go 는 그 // 바이트를 그대로 보존한다. 그러면 이름이 올바른 UTF-8 도 아니고 경로 검증도 통과하지 // 못하므로, 여기서 GBK 로 대체 디코딩한다. func zipEntryName(f *zip.File) string { if utf8.ValidString(f.Name) { return f.Name } if dec, err := simplifiedchinese.GBK.NewDecoder().String(f.Name); err == nil && utf8.ValidString(dec) { return dec } return f.Name } // checkSkillZipMethods rejects archives we cannot extract, naming the offending // entry and method instead of letting f.Open() fail with an opaque English error. func checkSkillZipMethods(entries []skillZipEntry) error { for _, e := range entries { if e.f.Flags&0x1 != 0 || e.f.Method == zipMethodAES { return fmt.Errorf(errSkillZipEncrypted, e.name) } switch e.f.Method { case zipMethodStore, zipMethodDeflate, zipMethodBzip2, zipMethodZstd, zipMethodZstdPKW: default: return fmt.Errorf(errSkillZipUnsupported, zipMethodName(e.f.Method), e.f.Method, e.name) } } return nil }