Changeset 0.22.4 (#146)

This commit is contained in:
2026-03-02 22:16:08 +00:00
parent 9940fb5831
commit 3953dcf364
25 changed files with 2254 additions and 145 deletions

View File

@@ -457,6 +457,121 @@ func sanitizeFilename(name string) string {
return name
}
// ── Project Files (v0.22.4) ────────────────
// UploadToProject handles project-scoped file uploads.
// POST /api/v1/projects/:id/files
func (h *AttachmentHandler) 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)
var exists bool
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT EXISTS(
SELECT 1 FROM projects
WHERE id = $1 AND (owner_id = $2 OR team_id IN (
SELECT team_id FROM team_members WHERE user_id = $2 AND is_active = true
))
)
`), projectID, userID).Scan(&exists)
if err != nil || !exists {
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.Attachment{
ChannelID: "", // no channel association
UserID: userID,
ProjectID: &projectID,
Filename: header.Filename,
ContentType: contentType,
SizeBytes: header.Size,
StorageKey: storageKey,
}
if err := h.stores.Attachments.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 *AttachmentHandler) ListByProject(c *gin.Context) {
userID := getUserID(c)
projectID := c.Param("id")
// Verify project access
var exists bool
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT EXISTS(
SELECT 1 FROM projects
WHERE id = $1 AND (owner_id = $2 OR team_id IN (
SELECT team_id FROM team_members WHERE user_id = $2 AND is_active = true
))
)
`), projectID, userID).Scan(&exists)
if err != nil || !exists {
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
return
}
files, err := h.stores.Attachments.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
}
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{