Files

220 lines
8.3 KiB
Go
Raw Permalink Normal View History

package api
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"path/filepath"
"strings"
)
type sniffedUpload struct {
Ext string // including leading dot
Mime string
Kind string // image|video|audio|pdf|office|text
}
func readUploadHead(file multipart.File, n int) ([]byte, error) {
head := make([]byte, n)
got, err := io.ReadFull(file, head)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, fmt.Errorf("خواندن فایل ممکن نشد")
}
if got == 0 {
return nil, fmt.Errorf("فایل خالی است")
}
head = head[:got]
if seeker, ok := file.(io.Seeker); ok {
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("خواندن فایل ممکن نشد")
}
} else {
return nil, fmt.Errorf("خواندن فایل ممکن نشد")
}
return head, nil
}
func sniffImageKind(head []byte) (ext, mime string, ok bool) {
if len(head) >= 3 && head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF {
return ".jpg", "image/jpeg", true
}
if len(head) >= 8 && bytes.Equal(head[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
return ".png", "image/png", true
}
if len(head) >= 6 && (bytes.Equal(head[:6], []byte("GIF87a")) || bytes.Equal(head[:6], []byte("GIF89a"))) {
return ".gif", "image/gif", true
}
if len(head) >= 12 && bytes.Equal(head[:4], []byte("RIFF")) && bytes.Equal(head[8:12], []byte("WEBP")) {
return ".webp", "image/webp", true
}
return "", "", false
}
func sniffPDF(head []byte) bool {
return len(head) >= 5 && bytes.Equal(head[:5], []byte("%PDF-"))
}
func sniffZIP(head []byte) bool {
return len(head) >= 4 && head[0] == 0x50 && head[1] == 0x4B && (head[2] == 0x03 || head[2] == 0x05 || head[2] == 0x07) && (head[3] == 0x04 || head[3] == 0x06 || head[3] == 0x08)
}
func sniffOLE(head []byte) bool {
return len(head) >= 8 && bytes.Equal(head[:8], []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1})
}
func sniffAudioKind(head []byte, ext string) (mime string, ok bool) {
switch ext {
case ".mp3":
if len(head) >= 3 && (bytes.Equal(head[:3], []byte("ID3")) || (head[0] == 0xFF && (head[1]&0xE0) == 0xE0)) {
return "audio/mpeg", true
}
case ".wav":
if len(head) >= 12 && bytes.Equal(head[:4], []byte("RIFF")) && bytes.Equal(head[8:12], []byte("WAVE")) {
return "audio/wav", true
}
case ".ogg", ".opus":
if len(head) >= 4 && bytes.Equal(head[:4], []byte("OggS")) {
return "audio/ogg", true
}
case ".m4a", ".aac":
if len(head) >= 8 && bytes.Equal(head[4:8], []byte("ftyp")) {
return "audio/mp4", true
}
}
return "", false
}
// acceptAvatarImage requires JPEG/PNG/WebP by magic bytes (ignores client Content-Type).
func acceptAvatarImage(header *multipart.FileHeader, file multipart.File) (sniffedUpload, error) {
if header == nil || file == nil {
return sniffedUpload{}, fmt.Errorf("فایل ارسال نشده است")
}
head, err := readUploadHead(file, 16)
if err != nil {
return sniffedUpload{}, err
}
ext, mime, ok := sniffImageKind(head)
if !ok || (ext != ".jpg" && ext != ".png" && ext != ".webp") {
return sniffedUpload{}, fmt.Errorf("فقط JPEG، PNG یا WebP مجاز است")
}
nameExt := strings.ToLower(filepath.Ext(header.Filename))
if nameExt != "" && nameExt != ext && !(nameExt == ".jpeg" && ext == ".jpg") {
return sniffedUpload{}, fmt.Errorf("پسوند فایل با محتوای آن هم‌خوان نیست")
}
return sniffedUpload{Ext: ext, Mime: mime, Kind: "image"}, nil
}
// acceptChatUpload validates extension allow-list plus content sniffing.
func acceptChatUpload(header *multipart.FileHeader, file multipart.File) (sniffedUpload, error) {
if header == nil || file == nil {
return sniffedUpload{}, fmt.Errorf("فایلی ارسال نشده است")
}
name := filepath.Base(strings.TrimSpace(header.Filename))
ext := strings.ToLower(filepath.Ext(name))
if !allowedExtension(ext) {
return sniffedUpload{}, fmt.Errorf("این نوع فایل مجاز نیست")
}
head, err := readUploadHead(file, 16)
if err != nil {
return sniffedUpload{}, err
}
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
got, mime, ok := sniffImageKind(head)
if !ok {
return sniffedUpload{}, fmt.Errorf("محتوای فایل تصویر معتبر نیست")
}
if ext == ".jpeg" {
ext = ".jpg"
}
if got == ".jpg" && ext != ".jpg" || got != ".jpg" && got != ext {
return sniffedUpload{}, fmt.Errorf("پسوند فایل با محتوای آن هم‌خوان نیست")
}
return sniffedUpload{Ext: got, Mime: mime, Kind: "image"}, nil
case ".webm":
// MediaRecorder voice notes use audio/webm but share the same EBML magic as video/webm.
declared := strings.ToLower(header.Header.Get("Content-Type"))
base := strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name)))
asAudio := strings.HasPrefix(declared, "audio/") || strings.HasPrefix(base, "voice-")
kind, ok := sniffVideoKind(head)
if !ok || kind != "webm" {
return sniffedUpload{}, fmt.Errorf("محتوای فایل WebM معتبر نیست")
}
if asAudio {
return sniffedUpload{Ext: ".webm", Mime: "audio/webm", Kind: "audio"}, nil
}
return sniffedUpload{Ext: ".webm", Mime: "video/webm", Kind: "video"}, nil
case ".mp4", ".m4v":
kind, ok := sniffVideoKind(head)
if !ok || kind != "mp4" {
return sniffedUpload{}, fmt.Errorf("محتوای فایل ویدیویی معتبر نیست")
}
return sniffedUpload{Ext: ext, Mime: "video/mp4", Kind: "video"}, nil
case ".mov":
// QuickTime often looks like ISO BMFF
if kind, ok := sniffVideoKind(head); ok && kind == "mp4" {
return sniffedUpload{Ext: ext, Mime: "video/quicktime", Kind: "video"}, nil
}
return sniffedUpload{}, fmt.Errorf("محتوای فایل ویدیویی معتبر نیست")
case ".mp3", ".wav", ".m4a", ".aac", ".ogg", ".opus", ".weba":
if ext == ".weba" {
// WebM/Matroska container used for MediaRecorder voice notes.
kind, ok := sniffVideoKind(head)
if ok && kind == "webm" {
return sniffedUpload{Ext: ".webm", Mime: "audio/webm", Kind: "audio"}, nil
}
// Short/fragmented first chunk may lack a full EBML header; trust voice-* + audio/*.
declared := strings.ToLower(header.Header.Get("Content-Type"))
base := strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name)))
if strings.HasPrefix(declared, "audio/") || strings.HasPrefix(base, "voice-") {
return sniffedUpload{Ext: ".webm", Mime: "audio/webm", Kind: "audio"}, nil
}
return sniffedUpload{}, fmt.Errorf("محتوای فایل صوتی معتبر نیست")
}
mime, ok := sniffAudioKind(head, ext)
if !ok {
// Safari/Chrome sometimes emit ftyp later than 16 bytes for short m4a clips;
// accept voice notes when Content-Type is audio/* and name is voice-*.
declared := strings.ToLower(header.Header.Get("Content-Type"))
base := strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name)))
if strings.HasPrefix(base, "voice-") && strings.HasPrefix(declared, "audio/") {
fallback := map[string]string{
".m4a": "audio/mp4", ".aac": "audio/aac",
".ogg": "audio/ogg", ".opus": "audio/ogg",
".mp3": "audio/mpeg", ".wav": "audio/wav",
}
if m, ok := fallback[ext]; ok {
return sniffedUpload{Ext: ext, Mime: m, Kind: "audio"}, nil
}
}
return sniffedUpload{}, fmt.Errorf("محتوای فایل صوتی معتبر نیست")
}
return sniffedUpload{Ext: ext, Mime: mime, Kind: "audio"}, nil
case ".pdf":
if !sniffPDF(head) {
return sniffedUpload{}, fmt.Errorf("محتوای فایل PDF معتبر نیست")
}
return sniffedUpload{Ext: ext, Mime: "application/pdf", Kind: "pdf"}, nil
case ".doc", ".xls", ".ppt":
if !sniffOLE(head) {
return sniffedUpload{}, fmt.Errorf("محتوای فایل Office معتبر نیست")
}
return sniffedUpload{Ext: ext, Mime: "application/msword", Kind: "office"}, nil
case ".docx", ".xlsx", ".pptx":
if !sniffZIP(head) {
return sniffedUpload{}, fmt.Errorf("محتوای فایل Office معتبر نیست")
}
return sniffedUpload{Ext: ext, Mime: "application/zip", Kind: "office"}, nil
case ".txt", ".md", ".csv", ".json":
// Reject binary-looking heads (NUL in first bytes).
if bytes.IndexByte(head, 0) >= 0 {
return sniffedUpload{}, fmt.Errorf("محتوای فایل متنی معتبر نیست")
}
return sniffedUpload{Ext: ext, Mime: "text/plain", Kind: "text"}, nil
default:
return sniffedUpload{}, fmt.Errorf("این نوع فایل مجاز نیست")
}
}