package web import ( "mime" "net/http" "net/url" "path" "strconv" "strings" "unicode" ) // Uploads: what a file is and how it may be served. // // The root domain carries the session cookie and serves every blog's files // (/b/{sub}/media previews, the root blog's own /media), so an uploaded HTML, // SVG or XML page must never render there. The rules below make that a // property of the stored content type: the sniffer is trusted first, a // filename extension may only refine a generic sniff to a type on an // allowlist, and anything not on the inline list is a download. // maxUploadFiles is how many files one Files-page request may carry; the body // cap of that route is this many upload limits. const maxUploadFiles = 10 // maxUploadMB caps the superadmin's per-blog override: substring() takes int4 // offsets, and the whole upload sits in memory while it is stored. const maxUploadMB = 1024 var fileKinds = []string{"image", "document", "audio", "video", "archive", "other"} // fileKindNames are the tab labels (translated where used, like moduleNames). var fileKindNames = map[string]string{"image": "Images", "document": "Documents", "audio": "Audio", "video": "Video", "archive": "Archives", "other": "Other"} var imageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true, "image/x-icon": true, "image/avif": true, "image/bmp": true} var documentTypes = map[string]bool{ "application/pdf": true, "text/plain": true, "text/csv": true, "application/rtf": true, "application/epub+zip": true, "application/msword": true, "application/vnd.ms-excel": true, "application/vnd.ms-powerpoint": true, "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, "application/vnd.oasis.opendocument.text": true, "application/vnd.oasis.opendocument.spreadsheet": true, "application/vnd.oasis.opendocument.presentation": true, } var archiveTypes = map[string]bool{ "application/zip": true, "application/gzip": true, "application/x-gzip": true, "application/x-7z-compressed": true, "application/x-tar": true, "application/x-bzip2": true, "application/x-xz": true, "application/x-rar-compressed": true, "application/vnd.rar": true, } var mediaTypes = map[string]bool{ "audio/mpeg": true, "audio/mp4": true, "audio/ogg": true, "audio/flac": true, "audio/wav": true, "audio/wave": true, "audio/x-wav": true, "audio/webm": true, "audio/aac": true, "video/mp4": true, "video/ogg": true, "video/webm": true, "video/x-matroska": true, "video/quicktime": true, } // extAllowed is what an extension may turn a generic sniff into: nothing a // browser would run. Images are deliberately absent — a real image sniffs. func extAllowed(ct string) bool { return documentTypes[ct] || archiveTypes[ct] || mediaTypes[ct] } // fileType decides what an upload is from its first bytes and its name. The // client's declared type is never consulted, and the result is always one of // the known types above or application/octet-stream, so a stored content // type is safe to serve by construction. func fileType(head []byte, filename string) (contentType, kind string) { ct := mediaType(http.DetectContentType(head)) ext := strings.ToLower(path.Ext(filename)) // The sniffer only knows containers for these. switch { case ct == "application/ogg": ct = "audio/ogg" if ext == ".ogv" { ct = "video/ogg" } case ct == "video/mp4" && ext == ".m4a": ct = "audio/mp4" case ct == "image/vnd.microsoft.icon": ct = "image/x-icon" } if ct == "text/plain" || ct == "application/octet-stream" { switch e := mediaType(mime.TypeByExtension(ext)); { case e == "": // unknown extension: the bytes are all we have case extAllowed(e): ct = e case ct == "text/plain" && strings.HasPrefix(e, "text/") && !scriptTypes[e]: // .md, .log, .ini…: text is text (the tables differ between machines, so the name only confirms) default: // a name we would not serve inline (.html, .svg, .js, .exe…), whatever the bytes look like ct = "application/octet-stream" } } if !imageTypes[ct] && !extAllowed(ct) && !strings.HasPrefix(ct, "audio/") && !strings.HasPrefix(ct, "video/") { ct = "application/octet-stream" // sniffed HTML/XML, fonts, and everything else we do not name } return ct, kindOf(ct) } // scriptTypes are text types a browser would execute or interpret as markup. var scriptTypes = map[string]bool{"text/html": true, "text/javascript": true, "text/xml": true, "text/css": true} // mediaType drops the parameters ("; charset=utf-8") from a content type. func mediaType(ct string) string { if ct == "" { return "" } mt, _, err := mime.ParseMediaType(ct) if err != nil { return "" } return mt } // kindOf buckets a content type for the Files page tabs. func kindOf(ct string) string { switch { case imageTypes[ct]: return "image" case documentTypes[ct]: return "document" case strings.HasPrefix(ct, "audio/"): return "audio" case strings.HasPrefix(ct, "video/"): return "video" case archiveTypes[ct]: return "archive" } return "other" } // inlineOK says whether a browser may render the type in place. func inlineOK(ct string) bool { return imageTypes[ct] || ct == "application/pdf" || ct == "text/plain" || strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "video/") } // servedAs is the Content-Type and disposition /media answers with. func servedAs(ct string, download bool) (ctype, disposition string) { if !inlineOK(ct) { return "application/octet-stream", "attachment" } if ct == "text/plain" { ct = "text/plain; charset=utf-8" } if download { return ct, "attachment" } return ct, "inline" } // contentDisposition carries the filename in both the plain form (ASCII only, // for old browsers) and the RFC 5987 one (Greek names survive). func contentDisposition(disposition, name string) string { ascii := strings.Map(func(r rune) rune { if r < 0x20 || r > 0x7e || r == '"' || r == '\\' { return '_' } return r }, name) return disposition + `; filename="` + ascii + `"; filename*=utf-8''` + url.PathEscape(name) } // cleanFilename keeps only the base name a browser sent (Windows paths // included), without control characters or quotes, at most 120 runes. func cleanFilename(name string) string { name = path.Base(strings.ReplaceAll(name, `\`, "/")) name = strings.Map(func(r rune) rune { if unicode.IsControl(r) || r == '"' || r == '\'' { return -1 } return r }, name) name = strings.TrimSpace(name) if r := []rune(name); len(r) > 120 { name = string(r[:120]) } if name == "" || name == "." || name == "/" || name == ".." { return "file" } return name } // humanSize prints a byte count the way the dashboard shows it. func humanSize(n int64) string { if n < 1<<20 { return strconv.FormatInt(max(1, n>>10), 10) + " KB" } return strings.TrimSuffix(strconv.FormatFloat(float64(n)/(1<<20), 'f', 1, 64), ".0") + " MB" } // pageBounds clamps a 1-based page number to the list and gives the SQL offset. func pageBounds(total, per, n int) (offset, page, last int) { last = max(1, (total+per-1)/per) page = min(max(1, n), last) return (page - 1) * per, page, last }