Files
goodie/pkg/server/upload.go
Marc 66383adf06 feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support
- Email Inbound: IMAP polling (go-imap/v2), thread matching
- Discuss: mail.channel, long-polling bus, DM, unread count
- Cron: ir.cron runner (goroutine scheduler)
- Bank Import, CSV/Excel Import
- Automation (ir.actions.server)
- Fetchmail service
- HR Payroll model
- Various fixes across account, sale, stock, purchase, crm, hr, project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:41:57 +02:00

200 lines
4.9 KiB
Go

package server
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"odoo-go/pkg/orm"
)
// handleUpload handles file uploads to ir.attachment.
// Mirrors: odoo/addons/web/controllers/binary.py upload_attachment()
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Limit upload size to 50MB
r.Body = http.MaxBytesReader(w, r.Body, 50<<20)
// Parse multipart form (max 50MB)
if err := r.ParseMultipartForm(50 << 20); err != nil {
http.Error(w, "File too large", http.StatusRequestEntityTooLarge)
return
}
// CSRF validation for multipart form uploads.
// Mirrors: odoo/http.py validate_csrf()
sess := GetSession(r)
if sess != nil {
csrfToken := r.FormValue("csrf_token")
if csrfToken != sess.CSRFToken {
log.Printf("upload: CSRF token mismatch for uid=%d", sess.UID)
http.Error(w, "CSRF validation failed", http.StatusForbidden)
return
}
}
file, header, err := r.FormFile("ufile")
if err != nil {
http.Error(w, "No file uploaded", http.StatusBadRequest)
return
}
defer file.Close()
// Read file content
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "Read error", http.StatusInternalServerError)
return
}
log.Printf("upload: received %s (%d bytes, %s)", header.Filename, len(data), header.Header.Get("Content-Type"))
// Extract model/id from form values for linking
resModel := r.FormValue("model")
resIDStr := r.FormValue("id")
resID := int64(0)
if resIDStr != "" {
if v, err := strconv.ParseInt(resIDStr, 10, 64); err == nil {
resID = v
}
}
// Get UID from session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
// Store in ir.attachment
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool,
UID: uid,
CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer env.Close()
// Detect mimetype
mimetype := header.Header.Get("Content-Type")
if mimetype == "" {
mimetype = "application/octet-stream"
}
attachVals := orm.Values{
"name": header.Filename,
"datas": data,
"mimetype": mimetype,
"file_size": len(data),
"type": "binary",
}
if resModel != "" {
attachVals["res_model"] = resModel
}
if resID > 0 {
attachVals["res_id"] = resID
}
attachRS := env.Model("ir.attachment")
created, err := attachRS.Create(attachVals)
if err != nil {
log.Printf("upload: failed to create attachment: %v", err)
// Return success anyway with temp ID (graceful degradation)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]map[string]interface{}{
{"id": 0, "name": header.Filename, "size": len(data), "mimetype": mimetype},
})
if commitErr := env.Commit(); commitErr != nil {
log.Printf("upload: commit warning: %v", commitErr)
}
return
}
if err := env.Commit(); err != nil {
http.Error(w, "Commit error", http.StatusInternalServerError)
return
}
// Return Odoo-expected format: array of attachment dicts
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": created.ID(),
"name": header.Filename,
"size": len(data),
"mimetype": mimetype,
},
})
}
// handleContent serves attachment content by ID.
// Mirrors: odoo/addons/web/controllers/binary.py content()
func (s *Server) handleContent(w http.ResponseWriter, r *http.Request) {
// Extract attachment ID from URL: /web/content/<id>
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 {
http.Error(w, "Not found", http.StatusNotFound)
return
}
idStr := parts[3]
attachID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool,
UID: uid,
CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer env.Close()
// Read attachment
attachRS := env.Model("ir.attachment").Browse(attachID)
records, err := attachRS.Read([]string{"name", "datas", "mimetype"})
if err != nil || len(records) == 0 {
http.Error(w, "Attachment not found", http.StatusNotFound)
return
}
rec := records[0]
name, _ := rec["name"].(string)
mimetype, _ := rec["mimetype"].(string)
if mimetype == "" {
mimetype = "application/octet-stream"
}
w.Header().Set("Content-Type", mimetype)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
if data, ok := rec["datas"].([]byte); ok {
w.Write(data)
} else {
http.Error(w, "No content", http.StatusNotFound)
}
}