646 lines
20 KiB
Go
646 lines
20 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"chat-switchboard/extraction"
|
|
"chat-switchboard/models"
|
|
"chat-switchboard/storage"
|
|
"chat-switchboard/store"
|
|
)
|
|
|
|
// ── Default Limits ─────────────────────────
|
|
// Overridable via global_settings keys.
|
|
const (
|
|
defaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
|
defaultMaxUploadSize = 50 * 1024 * 1024 // 50 MB total per request (future: multi-file)
|
|
defaultMaxFilesPerMsg = 5 // future: multi-file per message
|
|
defaultOrphanMaxAge = 24 * time.Hour
|
|
)
|
|
|
|
// allowedMIMETypes is the default allowlist. Admin can override via global_settings.
|
|
var allowedMIMETypes = map[string]bool{
|
|
// Images
|
|
"image/jpeg": true, "image/png": true, "image/gif": true,
|
|
"image/webp": true, "image/svg+xml": true,
|
|
// Documents
|
|
"application/pdf": true,
|
|
"text/plain": true, "text/markdown": true, "text/csv": true,
|
|
// Microsoft Office
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
|
|
"application/msword": true, "application/vnd.ms-excel": true,
|
|
// OpenDocument
|
|
"application/vnd.oasis.opendocument.text": true,
|
|
"application/vnd.oasis.opendocument.spreadsheet": true,
|
|
"application/vnd.oasis.opendocument.presentation": true,
|
|
// Other
|
|
"application/rtf": true,
|
|
}
|
|
|
|
// ── Handler ────────────────────────────────
|
|
|
|
type FileHandler struct {
|
|
stores store.Stores
|
|
objStore storage.ObjectStore
|
|
extQueue *extraction.Queue // nil if extraction disabled
|
|
}
|
|
|
|
func NewFileHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *FileHandler {
|
|
return &FileHandler{stores: stores, objStore: objStore, extQueue: extQueue}
|
|
}
|
|
|
|
// ── Upload ─────────────────────────────────
|
|
// POST /api/v1/channels/:id/files
|
|
// Multipart form: file field "file", returns file metadata.
|
|
func (h *FileHandler) Upload(c *gin.Context) {
|
|
if h.objStore == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
|
return
|
|
}
|
|
|
|
userID := getUserID(c)
|
|
channelID := c.Param("id")
|
|
|
|
// Verify channel ownership
|
|
if !h.verifyChannelAccess(c, channelID, userID) {
|
|
return
|
|
}
|
|
|
|
// Parse multipart
|
|
file, header, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Size check
|
|
if header.Size > defaultMaxFileSize {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": fmt.Sprintf("file too large (max %d MB)", defaultMaxFileSize/(1024*1024)),
|
|
})
|
|
return
|
|
}
|
|
|
|
// MIME detection: read first 512 bytes for sniffing, then reset
|
|
buf := make([]byte, 512)
|
|
n, _ := file.Read(buf)
|
|
detectedType := http.DetectContentType(buf[:n])
|
|
|
|
// Reset reader to start
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process file"})
|
|
return
|
|
}
|
|
|
|
// Normalize MIME type (strip params like charset)
|
|
contentType := detectedType
|
|
if idx := strings.Index(contentType, ";"); idx > 0 {
|
|
contentType = strings.TrimSpace(contentType[:idx])
|
|
}
|
|
|
|
// For types that DetectContentType can't distinguish (returns application/octet-stream),
|
|
// fall back to extension-based detection
|
|
if contentType == "application/octet-stream" {
|
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
|
if mapped, ok := extToMIME[ext]; ok {
|
|
contentType = mapped
|
|
}
|
|
}
|
|
|
|
// Allowlist check
|
|
if !allowedMIMETypes[contentType] {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": fmt.Sprintf("file type %q not allowed", contentType),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Build storage key: files are stored under files/{channel_id}/{file_id}_{filename}
|
|
// We generate the ID first via a temp UUID, then use it in the key.
|
|
att := &models.File{
|
|
ChannelID: channelID,
|
|
UserID: userID,
|
|
Origin: models.FileOriginUserUpload,
|
|
Filename: sanitizeFilename(header.Filename),
|
|
ContentType: contentType,
|
|
SizeBytes: header.Size,
|
|
DisplayHint: displayHintFor(contentType),
|
|
Metadata: models.JSONMap{
|
|
"extraction_status": "pending",
|
|
},
|
|
}
|
|
|
|
// Create PG row first to get the UUID
|
|
// storage_key is set after we have the ID
|
|
att.StorageKey = "placeholder"
|
|
if err := h.stores.Files.Create(c.Request.Context(), att); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create file record"})
|
|
return
|
|
}
|
|
|
|
// Now build the real storage key and update
|
|
att.StorageKey = fmt.Sprintf("files/%s/%s_%s", channelID, att.ID, att.Filename)
|
|
|
|
// Write to object store
|
|
if err := h.objStore.Put(c.Request.Context(), att.StorageKey, file, header.Size, contentType); err != nil {
|
|
// Rollback PG row on storage failure
|
|
h.stores.Files.Delete(c.Request.Context(), att.ID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
|
|
return
|
|
}
|
|
|
|
// Update storage_key
|
|
h.stores.Files.UpdateStorageKey(c.Request.Context(), att.ID, att.StorageKey)
|
|
|
|
// For images, mark extraction as not needed (complete immediately)
|
|
if strings.HasPrefix(contentType, "image/") {
|
|
h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
|
|
"extraction_status": "complete",
|
|
})
|
|
att.Metadata["extraction_status"] = "complete"
|
|
}
|
|
|
|
// For text/plain, extract inline (trivial — just read the file)
|
|
if contentType == "text/plain" || contentType == "text/markdown" || contentType == "text/csv" {
|
|
if _, err := file.Seek(0, io.SeekStart); err == nil {
|
|
if textBytes, err := io.ReadAll(file); err == nil {
|
|
text := string(textBytes)
|
|
h.stores.Files.SetExtractedText(c.Request.Context(), att.ID, text)
|
|
h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
|
|
"extraction_status": "complete",
|
|
})
|
|
att.Metadata["extraction_status"] = "complete"
|
|
}
|
|
}
|
|
}
|
|
|
|
// For documents requiring extraction (PDF, DOCX, etc.), enqueue for sidecar
|
|
if extraction.IsExtractable(contentType) && h.extQueue != nil {
|
|
if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil {
|
|
log.Printf("extraction enqueue failed for %s: %v", att.ID, err)
|
|
// Non-fatal: file is uploaded, just won't have extracted text
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, att)
|
|
}
|
|
|
|
// ── Download ───────────────────────────────
|
|
// GET /api/v1/files/:id/download
|
|
// Streams file content with auth check via channel membership.
|
|
func (h *FileHandler) Download(c *gin.Context) {
|
|
if h.objStore == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
|
return
|
|
}
|
|
|
|
userID := getUserID(c)
|
|
attID := c.Param("id")
|
|
|
|
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
|
return
|
|
}
|
|
|
|
// Channel-scoped access check
|
|
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
|
|
return
|
|
}
|
|
|
|
reader, size, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
|
|
c.Header("Content-Type", att.ContentType)
|
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename))
|
|
c.Header("Content-Length", fmt.Sprintf("%d", size))
|
|
c.Status(http.StatusOK)
|
|
io.Copy(c.Writer, reader)
|
|
}
|
|
|
|
// ── Get Metadata ───────────────────────────
|
|
// GET /api/v1/files/:id
|
|
func (h *FileHandler) GetMetadata(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
attID := c.Param("id")
|
|
|
|
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
|
return
|
|
}
|
|
|
|
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, att)
|
|
}
|
|
|
|
// ── List Channel Files ───────────────
|
|
// GET /api/v1/channels/:id/files
|
|
func (h *FileHandler) ListByChannel(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
channelID := c.Param("id")
|
|
|
|
if !h.verifyChannelAccess(c, channelID, userID) {
|
|
return
|
|
}
|
|
|
|
files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, "")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
|
return
|
|
}
|
|
if files == nil {
|
|
files = []models.File{}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"files": files})
|
|
}
|
|
|
|
// ── List by Message ───────────────────────
|
|
// GET /api/v1/messages/:id/files
|
|
// Returns files attached to a specific message (inline artifacts, tool output).
|
|
func (h *FileHandler) ListByMessage(c *gin.Context) {
|
|
messageID := c.Param("id")
|
|
|
|
files, err := h.stores.Files.GetByMessage(c.Request.Context(), messageID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
|
return
|
|
}
|
|
if files == nil {
|
|
files = []models.File{}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"files": files})
|
|
}
|
|
|
|
// ── List by User (File Manager) ───────────
|
|
// GET /api/v1/files?page=1&per_page=50
|
|
// Paginated list of all files owned by the authenticated user.
|
|
func (h *FileHandler) ListByUser(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
page, perPage, _ := parsePagination(c)
|
|
|
|
files, total, err := h.stores.Files.GetByUser(c.Request.Context(), userID, page, perPage)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
|
return
|
|
}
|
|
if files == nil {
|
|
files = []models.File{}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"files": files,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": perPage,
|
|
})
|
|
}
|
|
|
|
// ── Delete ─────────────────────────────────
|
|
// DELETE /api/v1/files/:id
|
|
func (h *FileHandler) Delete(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
attID := c.Param("id")
|
|
|
|
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
|
return
|
|
}
|
|
|
|
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
|
|
return
|
|
}
|
|
|
|
// Delete from PG (returns the row for storage cleanup)
|
|
deleted, err := h.stores.Files.Delete(c.Request.Context(), attID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
|
|
return
|
|
}
|
|
|
|
// Clean up storage (async-safe: fire and forget with background context)
|
|
if h.objStore != nil && deleted != nil {
|
|
storageKey := deleted.StorageKey
|
|
go func() {
|
|
ctx := context.Background()
|
|
if err := h.objStore.Delete(ctx, storageKey); err != nil {
|
|
log.Printf("storage cleanup failed for %s: %v", storageKey, err)
|
|
}
|
|
// Also clean up thumbnail if it exists
|
|
h.objStore.Delete(ctx, storageKey+"_thumb.jpg")
|
|
}()
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "file deleted"})
|
|
}
|
|
|
|
// ── Admin: Orphan Cleanup ──────────────────
|
|
// POST /admin/storage/cleanup
|
|
func (h *FileHandler) CleanupOrphans(c *gin.Context) {
|
|
orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orphans"})
|
|
return
|
|
}
|
|
|
|
deleted := 0
|
|
var freedBytes int64
|
|
|
|
for _, att := range orphans {
|
|
if _, err := h.stores.Files.Delete(c.Request.Context(), att.ID); err != nil {
|
|
log.Printf("orphan cleanup: failed to delete %s from PG: %v", att.ID, err)
|
|
continue
|
|
}
|
|
if h.objStore != nil {
|
|
h.objStore.Delete(c.Request.Context(), att.StorageKey)
|
|
h.objStore.Delete(c.Request.Context(), att.StorageKey+"_thumb.jpg")
|
|
}
|
|
deleted++
|
|
freedBytes += att.SizeBytes
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"deleted": deleted,
|
|
"freed_bytes": freedBytes,
|
|
"scanned": len(orphans),
|
|
})
|
|
}
|
|
|
|
// ── Admin: Orphan Count ────────────────────
|
|
// GET /admin/storage/orphans (for the admin panel card)
|
|
func (h *FileHandler) OrphanCount(c *gin.Context) {
|
|
orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count orphans"})
|
|
return
|
|
}
|
|
|
|
var totalBytes int64
|
|
for _, att := range orphans {
|
|
totalBytes += att.SizeBytes
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"count": len(orphans),
|
|
"reclaimable_bytes": totalBytes,
|
|
})
|
|
}
|
|
|
|
// ── Admin: Extraction Queue Status ─────────
|
|
// GET /admin/storage/extraction
|
|
func (h *FileHandler) ExtractionStatus(c *gin.Context) {
|
|
if h.extQueue == nil {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"enabled": false,
|
|
"items": []interface{}{},
|
|
})
|
|
return
|
|
}
|
|
|
|
items, err := h.extQueue.ListAll()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list extraction queue"})
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []extraction.QueueItem{}
|
|
}
|
|
|
|
// Count by status
|
|
counts := map[string]int{}
|
|
for _, item := range items {
|
|
counts[item.Status]++
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"enabled": true,
|
|
"total": len(items),
|
|
"counts": counts,
|
|
"items": items,
|
|
})
|
|
}
|
|
|
|
// ── Channel Delete Hook ────────────────────
|
|
// Called by ChannelHandler.DeleteChannel to clean up storage.
|
|
func (h *FileHandler) CleanupChannelStorage(channelID string) {
|
|
if h.objStore == nil {
|
|
return
|
|
}
|
|
// CASCADE already deleted PG rows. Clean up filesystem.
|
|
prefix := fmt.Sprintf("files/%s", channelID)
|
|
if err := h.objStore.DeletePrefix(context.Background(), prefix); err != nil {
|
|
log.Printf("storage cleanup for channel %s failed: %v", channelID, err)
|
|
}
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────
|
|
|
|
// verifyChannelAccess checks that the requesting user owns the channel.
|
|
// Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission).
|
|
func (h *FileHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
|
|
owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
|
return false
|
|
}
|
|
if !owns {
|
|
// Check if user is admin (admins can access any channel)
|
|
role, _ := c.Get("role")
|
|
if role != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// sanitizeFilename cleans a filename for safe storage.
|
|
func sanitizeFilename(name string) string {
|
|
// Take only the base name (strip path separators)
|
|
name = filepath.Base(name)
|
|
// Replace problematic characters
|
|
replacer := strings.NewReplacer(
|
|
"/", "_", "\\", "_", "..", "_", "\x00", "",
|
|
)
|
|
name = replacer.Replace(name)
|
|
if name == "" || name == "." {
|
|
name = "unnamed"
|
|
}
|
|
// Truncate to 200 chars (leave room for UUID prefix in storage key)
|
|
if len(name) > 200 {
|
|
ext := filepath.Ext(name)
|
|
name = name[:200-len(ext)] + ext
|
|
}
|
|
return name
|
|
}
|
|
|
|
// ── Project Files (v0.22.4) ────────────────
|
|
|
|
// UploadToProject handles project-scoped file uploads.
|
|
// POST /api/v1/projects/:id/files
|
|
func (h *FileHandler) UploadToProject(c *gin.Context) {
|
|
if h.objStore == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
|
return
|
|
}
|
|
|
|
userID := getUserID(c)
|
|
projectID := c.Param("id")
|
|
|
|
// Verify project access (user owns or is team member; admins bypass)
|
|
if c.GetString("role") != "admin" {
|
|
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
|
|
ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs)
|
|
if err != nil || !ok {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
|
|
return
|
|
}
|
|
}
|
|
|
|
file, header, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
if header.Size > int64(defaultMaxFileSize) {
|
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds size limit"})
|
|
return
|
|
}
|
|
|
|
// Detect content type
|
|
buf := make([]byte, 512)
|
|
n, _ := file.Read(buf)
|
|
contentType := http.DetectContentType(buf[:n])
|
|
file.Seek(0, io.SeekStart)
|
|
|
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
|
if better, ok := extToMIME[ext]; ok && contentType == "application/octet-stream" {
|
|
contentType = better
|
|
}
|
|
|
|
// Store file
|
|
storageKey := fmt.Sprintf("projects/%s/%s/%s", projectID, store.NewID()[:8], sanitizeFilename(header.Filename))
|
|
if err := h.objStore.Put(c.Request.Context(), storageKey, file, header.Size, contentType); err != nil {
|
|
log.Printf("error: project file upload: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
|
|
return
|
|
}
|
|
|
|
att := models.File{
|
|
ChannelID: "", // no channel association
|
|
UserID: userID,
|
|
ProjectID: &projectID,
|
|
Origin: models.FileOriginUserUpload,
|
|
Filename: header.Filename,
|
|
ContentType: contentType,
|
|
SizeBytes: header.Size,
|
|
StorageKey: storageKey,
|
|
DisplayHint: displayHintFor(contentType),
|
|
}
|
|
|
|
if err := h.stores.Files.Create(c.Request.Context(), &att); err != nil {
|
|
log.Printf("error: project file create: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file metadata"})
|
|
return
|
|
}
|
|
|
|
if h.extQueue != nil {
|
|
if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil {
|
|
log.Printf("warning: project file extraction enqueue failed: %v", err)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, att)
|
|
}
|
|
|
|
// ListByProject returns all files uploaded to a project.
|
|
// GET /api/v1/projects/:id/files
|
|
func (h *FileHandler) ListByProject(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
projectID := c.Param("id")
|
|
|
|
// Admins bypass project ownership/membership checks
|
|
if c.GetString("role") != "admin" {
|
|
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
|
|
ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs)
|
|
if err != nil || !ok {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
|
|
return
|
|
}
|
|
}
|
|
|
|
files, err := h.stores.Files.GetByProject(c.Request.Context(), projectID)
|
|
if err != nil {
|
|
log.Printf("error: list project files: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
|
return
|
|
}
|
|
if files == nil {
|
|
files = []models.File{}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)})
|
|
}
|
|
|
|
// extToMIME maps file extensions to MIME types for cases where
|
|
// http.DetectContentType returns application/octet-stream.
|
|
var extToMIME = map[string]string{
|
|
".pdf": "application/pdf",
|
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
".doc": "application/msword",
|
|
".xls": "application/vnd.ms-excel",
|
|
".odt": "application/vnd.oasis.opendocument.text",
|
|
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
|
".odp": "application/vnd.oasis.opendocument.presentation",
|
|
".rtf": "application/rtf",
|
|
".md": "text/markdown",
|
|
".csv": "text/csv",
|
|
".svg": "image/svg+xml",
|
|
}
|
|
|
|
// displayHintFor returns the appropriate display hint for a content type.
|
|
func displayHintFor(contentType string) string {
|
|
switch {
|
|
case strings.HasPrefix(contentType, "image/"):
|
|
return models.FileHintInline
|
|
case strings.HasPrefix(contentType, "video/"):
|
|
return models.FileHintInline
|
|
case strings.HasPrefix(contentType, "audio/"):
|
|
return models.FileHintInline
|
|
case contentType == "application/pdf":
|
|
return models.FileHintThumbnail
|
|
case strings.HasPrefix(contentType, "text/"):
|
|
return models.FileHintInline
|
|
case contentType == "application/json":
|
|
return models.FileHintInline
|
|
default:
|
|
return models.FileHintDownload
|
|
}
|
|
}
|