# File Storage

> Files attached to tasks.

Goravel series, part 14 of 20. By Krishan Kumar. Published 2026-10-26.
Source: https://devchalk.com/goravel/14-file-storage-attachments
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-14

So far, a task in TaskFlow is only text. A bug report often needs more, like a screenshot of the error or a PDF of the spec. In this part, tasks get attachments: Ben uploads a screenshot to his task, downloads it and deletes it. Each file is saved on a disk, and Postgres keeps a row saying which task it belongs to and who uploaded it.

Start from the Feature Testing code:

```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-13
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"
go install github.com/air-verse/air@latest
air
```

## Where the files go

Goravel saves files through [`facades.Storage()`](https://www.goravel.dev/digging-deeper/filesystem). It saves them to a disk. A disk is a named place for files, set up in `config/filesystems.go`. A new app comes with this config:

```go title="config/filesystems.go" collapse={1-10} collapse={13-16} collapse={20-25}
package config

import (
	"github.com/goravel/framework/support/path"

	"taskflow/app/facades"
)

func init() {
	config := facades.Config()
	config.Add("filesystems", map[string]any{
		// Default Filesystem Disk
		//
		// Here you may specify the default filesystem disk that should be used
		// by the framework. The "local" disk, as well as a variety of cloud
		// based disks are available to your application. Just store away!
		"default": "local",

		// Filesystem Disks
		//
		// Here you may configure as many filesystem "disks" as you wish, and you
		// may even configure multiple disks of the same driver. Defaults have
		// been set up for each driver as an example of the required values.
		//
		// Supported Drivers: "local", "custom"
		"disks": map[string]any{
			"local": map[string]any{
				"driver": "local",
				"root":   path.Storage("app"),
			},
			"public": map[string]any{
				"driver": "local",
				"root":   path.Storage("app/public"),
				"url":    config.Env("APP_URL", "").(string) + "/storage",
			},
		},
	})
}
```

- `default` is the disk `facades.Storage()` uses when the code doesn't name one. Here it is `local`. To use another disk, name it: `facades.Storage().Disk("public")`.
- Each disk has a driver. The driver decides where the files really go. The `local` driver keeps them in a folder on the server, its `root`. Both disks here use it: `local` saves to `storage/app`, and `public` to `storage/app/public`. Other drivers save files somewhere else, like Amazon S3 (see [S3 and other cloud disks](#more-s3-and-other-cloud-disks) below).
- `public` also has a `url`, for links to its files. It is meant for files anyone can open, like avatars. But no route serves that folder yet: `routes/web.go` only serves `./public`.

Attachments should only reach the people working on the task. So TaskFlow keeps the `local` disk. No route serves `storage/app`, so a file only leaves the server when a handler sends it. And the handler sends it only when the policy allows it.

Make `default` come from `.env`, so the tests can use their own disk. Then add that `testing` disk:

```diff lang="go" title="config/filesystems.go" collapse={1-14} collapse={21-26}
 package config
 
 import (
 	"github.com/goravel/framework/support/path"
 
 	"taskflow/app/facades"
 )
 
 func init() {
 	config := facades.Config()
 	config.Add("filesystems", map[string]any{
 		// Default Filesystem Disk
 		//
 		// Here you may specify the default filesystem disk that should be used
 		// by the framework. The "local" disk, as well as a variety of cloud
 		// based disks are available to your application. Just store away!
-		"default": "local",
+		"default": config.Env("FILESYSTEM_DISK", "local"),
 
 		// Filesystem Disks
 		//
 		// Here you may configure as many filesystem "disks" as you wish, and you
 		// may even configure multiple disks of the same driver. Defaults have
 		// been set up for each driver as an example of the required values.
 		//
 		// Supported Drivers: "local", "custom"
 		"disks": map[string]any{
 			"local": map[string]any{
 				"driver": "local",
 				"root":   path.Storage("app"),
 			},
+			"testing": map[string]any{
+				"driver": "local",
+				"root":   path.Storage("framework/testing/disks/local"),
+			},
 			"public": map[string]any{
 				"driver": "local",
 				"root":   path.Storage("app/public"),
 				"url":    config.Env("APP_URL", "").(string) + "/storage",
 			},
 		},
 	})
 }
```

Add the setting to `.env` and `.env.example`:

```ini title=".env"
FILESYSTEM_DISK=local
```

S3 and other cloud disks:

TaskFlow stays on the local disk in this series. In production, files usually go to cloud storage, so they are not lost when the server is replaced. Goravel has driver packages for [Amazon S3](https://github.com/goravel/s3), [MinIO](https://github.com/goravel/minio), [Alibaba Cloud OSS](https://github.com/goravel/oss) and [Tencent COS](https://github.com/goravel/cos). For S3:

```bash
go run . artisan package:install github.com/goravel/s3
```

This adds an `s3` disk that reads `AWS_ACCESS_KEY_ID`, `AWS_ACCESS_KEY_SECRET`, `AWS_REGION`, `AWS_BUCKET` and `AWS_URL` from `.env`. It also sets `default` to `"s3"`, replacing the `FILESYSTEM_DISK` line.

The handlers in this part only use `facades.Storage()`, so they work on S3 unchanged. On S3, [`TemporaryUrl`](https://www.goravel.dev/digging-deeper/filesystem#temporary-urls) gives the client a link that expires. On the local disk, it just returns `Url`.

## The attachments table

Each file gets a row in Postgres. The row says which task the file belongs to and who uploaded it. It also keeps the file's original name, its place on the disk, its size and its type:

```go title="app/models/attachment.go"
package models

import (
	"github.com/goravel/framework/database/orm"
)

type Attachment struct {
	orm.Model
	TaskID     uint   `gorm:"not null" json:"task_id"`
	UploadedBy uint   `gorm:"not null" json:"uploaded_by"`
	Name       string `gorm:"size:255;not null" json:"name"`
	Path       string `gorm:"size:255;not null" json:"-"`
	Size       int64  `gorm:"not null" json:"size"`
	MimeType   string `gorm:"size:255;not null" json:"mime_type"`
}
```

`json:"-"` keeps `Path` out of every response, like the password in [Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt). Clients don't need to know where a file sits on the disk.

As in [Relationships: Belongs To](https://devchalk.com/goravel/05-relationships-belongs-to), `make:migration -m` only reads registered models, so register `Attachment` next to the others:

```diff lang="go" title="bootstrap/app.go" collapse={1-34} collapse={41-69}
 package bootstrap
 
 import (
 	"context"
 	"strings"
 
 	"github.com/goravel/framework/auth/access"
 	contractsaccess "github.com/goravel/framework/contracts/auth/access"
 	"github.com/goravel/framework/contracts/database/schema"
 	contractsfoundation "github.com/goravel/framework/contracts/foundation"
 	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/foundation"
 	"github.com/goravel/framework/http/limit"
 
 	"taskflow/app/enums"
 	"taskflow/app/facades"
 	"taskflow/app/http/authuser"
 	"taskflow/app/models"
 	"taskflow/app/policies"
 	"taskflow/config"
 	"taskflow/routes"
 )
 
 func Boot() contractsfoundation.Application {
 	return foundation.Setup().
 		WithRules(Rules).
 		WithSeeders(Seeders).
 		WithMigrations(Migrations).
 		WithRouting(func() {
 			routes.Api()
 			routes.Web()
 		}).
 		WithProviders(Providers).
 		WithConfig(config.Boot).
 		WithCallback(func() {
 			facades.Schema().Extend(schema.Extension{
-				Models: []any{models.User{}, models.Task{}},
+				Models: []any{models.User{}, models.Task{}, models.Attachment{}},
 			})
 
 			facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
 				email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
 
 				return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
 					ctx.Response().Json(429, map[string]any{
 						"error": "Too many login attempts. Try again in a minute.",
 					}).Abort()
 				})
 			})
 
 			facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
 				if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
 					return access.NewAllowResponse()
 				}
 
 				return nil
 			})
 
 			facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
 				return access.NewDenyResponse("Only managers can see the users.")
 			})
 
 			taskPolicy := policies.NewTaskPolicy()
 			facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
 			facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
 			facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
 		}).
 		Create()
 }
```

Then generate the migration from the model:

```bash
$ go run . artisan make:migration create_attachments_table -m Attachment
```

The columns come from the model, but the foreign keys don't, so add them. `CascadeOnDelete` on `task_id` tells Postgres to delete a task's attachment rows when the task is deleted. `uploaded_by` gets a plain foreign key, like `created_by` on tasks:

```diff lang="go" title="database/migrations/20261026070447_create_attachments_table.go" collapse={1-16} collapse={35-41}
 package migrations
 
 import (
 	"github.com/goravel/framework/contracts/database/schema"
 
 	"taskflow/app/facades"
 )
 
 type M20261026070447CreateAttachmentsTable struct{}
 
 // Signature The unique signature for the migration.
 func (r *M20261026070447CreateAttachmentsTable) Signature() string {
 	return "20261026070447_create_attachments_table"
 }
 
 // Up Run the migrations.
 func (r *M20261026070447CreateAttachmentsTable) Up() error {
 	if !facades.Schema().HasTable("attachments") {
 		return facades.Schema().Create("attachments", func(table schema.Blueprint) {
 			table.TimestampTz("created_at").Nullable()
 			table.TimestampTz("updated_at").Nullable()
 			table.BigIncrements("id")
 			table.UnsignedBigInteger("task_id")
 			table.UnsignedBigInteger("uploaded_by")
 			table.String("name", 255)
 			table.String("path", 255)
 			table.BigInteger("size")
 			table.String("mime_type", 255)
+
+			table.Foreign("task_id").References("id").On("tasks").CascadeOnDelete()
+			table.Foreign("uploaded_by").References("id").On("users")
 		})
 	}
 
 	return nil
 }
 
 // Down Reverse the migrations.
 func (r *M20261026070447CreateAttachmentsTable) Down() error {
 	return facades.Schema().DropIfExists("attachments")
 }
```

Run it. `db:table` shows the two foreign keys. `cascade` on `task_id` means deleting a task deletes its rows:

```bash
$ go run . artisan migrate
$ go run . artisan db:table attachments
  …
  Foreign Key .......................................... On Update / On Delete
  attachments_task_id_foreign task_id references id on tasks  no action / cascade
  attachments_uploaded_by_foreign uploaded_by references id on users  no action / no action
```

A task has many attachments, so `Task` gets the other side, as in [Relationships: Has Many](https://devchalk.com/goravel/06-relationships-has-many):

```diff lang="go" title="app/models/task.go" collapse={1-19}
 package models
 
 import (
 	"github.com/goravel/framework/contracts/database/factory"
 	"github.com/goravel/framework/database/orm"
 	"github.com/goravel/framework/support/carbon"
 
 	"taskflow/app/enums"
 	"taskflow/database/factories"
 )
 
 type Task struct {
 	orm.Model
 	CreatedBy   uint             `gorm:"not null" json:"created_by"`
 	Creator     *User            `gorm:"foreignKey:CreatedBy" json:"creator"`
 	AssignedTo  *uint            `json:"assigned_to"`
 	Assignee    *User            `gorm:"foreignKey:AssignedTo" json:"assignee"`
 	Title       string           `gorm:"size:255;not null" json:"title"`
 	Description string           `gorm:"type:text;not null" json:"description"`
 	Status      enums.Status     `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"`
 	DueAt       *carbon.DateTime `json:"due_at"`
+	Attachments []Attachment     `json:"attachments,omitempty"`
 }
 
 func (t *Task) Factory() factory.Factory {
 	return &factories.TaskFactory{}
 }
```

`omitempty` leaves `attachments` out of the JSON when it is empty. Only the single-task response loads the files, so the task list doesn't send `"attachments":null`.

## Who can attach files

The rules follow the task rules from [Gates & Policies](https://devchalk.com/goravel/12-gates-policies). Whoever works on a task can add and download its files. A member can only delete files they uploaded:

| | manager | member |
|---|---|---|
| attach a file to a task | yes | own tasks only |
| download a file | yes | own tasks only |
| delete a file | yes | files they uploaded |

Members still see which files a task has, because they can read every task. Only the task's assignee and managers can open them.

Ana is a manager, and `Before` already lets managers through. So the policy only decides for members:

```go title="app/policies/attachment_policy.go"
package policies

import (
	"context"

	"taskflow/app/http/authuser"
	"taskflow/app/models"

	"github.com/goravel/framework/auth/access"
	contractsaccess "github.com/goravel/framework/contracts/auth/access"
)

type AttachmentPolicy struct {
}

func NewAttachmentPolicy() *AttachmentPolicy {
	return &AttachmentPolicy{}
}

func (r *AttachmentPolicy) Create(ctx context.Context, arguments map[string]any) contractsaccess.Response {
	user := authuser.From(ctx)
	task := arguments["task"].(*models.Task)

	if user == nil || task.AssignedTo == nil || *task.AssignedTo != user.ID {
		return access.NewDenyResponse("You can only attach files to tasks assigned to you.")
	}

	return access.NewAllowResponse()
}

func (r *AttachmentPolicy) View(ctx context.Context, arguments map[string]any) contractsaccess.Response {
	user := authuser.From(ctx)
	task := arguments["task"].(*models.Task)

	if user == nil || task.AssignedTo == nil || *task.AssignedTo != user.ID {
		return access.NewDenyResponse("You can only open files on tasks assigned to you.")
	}

	return access.NewAllowResponse()
}

func (r *AttachmentPolicy) Delete(ctx context.Context, arguments map[string]any) contractsaccess.Response {
	user := authuser.From(ctx)
	attachment := arguments["attachment"].(*models.Attachment)

	if user == nil || attachment.UploadedBy != user.ID {
		return access.NewDenyResponse("You can only delete files you uploaded.")
	}

	return access.NewAllowResponse()
}
```

Name the three abilities:

```diff lang="go" title="app/policies/abilities.go"
 package policies
 
 const (
 	ViewUsers  = "view-users"
 	CreateTask = "create-task"
 	UpdateTask = "update-task"
 	DeleteTask = "delete-task"
+
+	CreateAttachment = "create-attachment"
+	ViewAttachment   = "view-attachment"
+	DeleteAttachment = "delete-attachment"
 )
```

And register them in `bootstrap/app.go`:

```diff lang="go" title="bootstrap/app.go" collapse={1-63}
 package bootstrap
 
 import (
 	"context"
 	"strings"
 
 	"github.com/goravel/framework/auth/access"
 	contractsaccess "github.com/goravel/framework/contracts/auth/access"
 	"github.com/goravel/framework/contracts/database/schema"
 	contractsfoundation "github.com/goravel/framework/contracts/foundation"
 	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/foundation"
 	"github.com/goravel/framework/http/limit"
 
 	"taskflow/app/enums"
 	"taskflow/app/facades"
 	"taskflow/app/http/authuser"
 	"taskflow/app/models"
 	"taskflow/app/policies"
 	"taskflow/config"
 	"taskflow/routes"
 )
 
 func Boot() contractsfoundation.Application {
 	return foundation.Setup().
 		WithRules(Rules).
 		WithSeeders(Seeders).
 		WithMigrations(Migrations).
 		WithRouting(func() {
 			routes.Api()
 			routes.Web()
 		}).
 		WithProviders(Providers).
 		WithConfig(config.Boot).
 		WithCallback(func() {
 			facades.Schema().Extend(schema.Extension{
 				Models: []any{models.User{}, models.Task{}, models.Attachment{}},
 			})
 
 			facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
 				email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
 
 				return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
 					ctx.Response().Json(429, map[string]any{
 						"error": "Too many login attempts. Try again in a minute.",
 					}).Abort()
 				})
 			})
 
 			facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
 				if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
 					return access.NewAllowResponse()
 				}
 
 				return nil
 			})
 
 			facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
 				return access.NewDenyResponse("Only managers can see the users.")
 			})
 
 			taskPolicy := policies.NewTaskPolicy()
 			facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
 			facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
 			facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
+
+			attachmentPolicy := policies.NewAttachmentPolicy()
+			facades.Gate().Define(policies.CreateAttachment, attachmentPolicy.Create)
+			facades.Gate().Define(policies.ViewAttachment, attachmentPolicy.View)
+			facades.Gate().Define(policies.DeleteAttachment, attachmentPolicy.Delete)
 		}).
 		Create()
 }
```

## Uploading a file

A file comes in a `multipart/form-data` request. It is what a browser form with a file input sends. Make a form request and a controller for it:

```bash
$ go run . artisan make:request StoreAttachmentRequest
$ go run . artisan make:controller AttachmentController
```

Then three routes under each task:

```diff lang="go" title="routes/api.go" collapse={1-13} collapse={20-30}
 package routes
 
 import (
 	"taskflow/app/facades"
 	"taskflow/app/http/controllers"
 	"taskflow/app/http/middleware"
 
 	"github.com/goravel/framework/contracts/route"
 	httpmiddleware "github.com/goravel/framework/http/middleware"
 )
 
 func Api() {
 	authController := controllers.NewAuthController()
 	taskController := controllers.NewTaskController()
 	userController := controllers.NewUserController()
+	attachmentController := controllers.NewAttachmentController()
 
 	facades.Route().Prefix("api/v1").Group(func(router route.Router) {
 		router.Post("register", authController.Register)
 		router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login)
 		router.Post("refresh", authController.Refresh)
 
 		router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
 			router.Get("me", authController.Me)
 			router.Post("logout", authController.Logout)
 
 			router.Post("tasks", taskController.Store).Name("tasks.create")
 			router.Get("tasks/{id}", taskController.Show)
 			router.Get("tasks", taskController.Index)
 			router.Patch("tasks/{id}", taskController.Update)
 			router.Delete("tasks/{id}", taskController.Destroy)
 
+			router.Post("tasks/{id}/attachments", attachmentController.Store)
+			router.Get("tasks/{id}/attachments/{attachment}", attachmentController.Show)
+			router.Delete("tasks/{id}/attachments/{attachment}", attachmentController.Destroy)
+
 			router.Get("users", userController.Index)
 			router.Get("users/{id}", userController.Show)
 		})
 	})
 }
```

### The rules for a file

The form request checks the file before the handler saves it:

```go title="app/http/requests/store_attachment_request.go" t1:field=/"(file)":/
package requests

import (
	"mime/multipart"

	"github.com/goravel/framework/contracts/http"
)

type StoreAttachmentRequest struct {
	File *multipart.FileHeader `form:"file" json:"file"`
}

func (r *StoreAttachmentRequest) Authorize(ctx http.Context) error {
	return nil
}

func (r *StoreAttachmentRequest) Rules(ctx http.Context) map[string]any {
	return map[string]any{
		"file": "required|file|max:5120|mimes:png,jpg,pdf,txt",
	}
}
```

- `File` holds the upload. Its type is Go's `*multipart.FileHeader`. It has the file's name and size, and can open the file.
- `file` passes only when a file came in the `file` field.
- `max:5120` is in kilobytes when the value is a file, so the limit is 5 MB. For a string, the same rule counts characters.
- `mimes:png,jpg,pdf,txt` looks at the first bytes of the file to find its real type. A shell script renamed to `photo.png` still fails, because its first line shows it is a script. A plain text file passes with any name, because `txt` is on the list. `Store` then saves it as `.txt`. The [`extensions`](https://www.goravel.dev/the-basics/validation#available-validation-rules) rule only checks the end of the name, so it would let the script through.

### The handler

The handler loads the task, asks the policy, runs the rules, and only then saves the file:

```go title="app/http/controllers/attachment_controller.go" collapse={121-214} t1:field=/request[.](File)/ t2:path=/"(attachments\/%d)"/ t3:name=/(GetClientOriginalName)/
package controllers

import (
	"errors"
	"fmt"
	"mime"
	"os"

	"taskflow/app/facades"
	"taskflow/app/http/authuser"
	"taskflow/app/http/requests"
	"taskflow/app/models"
	"taskflow/app/policies"

	"github.com/goravel/framework/contracts/http"
	frameworkerrors "github.com/goravel/framework/errors"
	"github.com/goravel/framework/filesystem"
)

type AttachmentController struct {
	// Dependent services
}

func NewAttachmentController() *AttachmentController {
	return &AttachmentController{
		// Inject services
	}
}

func (r *AttachmentController) Store(ctx http.Context) http.Response {
	var task models.Task
	err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The task does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the task.",
		})
	}

	response := facades.Gate().WithContext(ctx).Inspect(policies.CreateAttachment, map[string]any{
		"task": &task,
	})
	if !response.Allowed() {
		return ctx.Response().Json(403, map[string]any{
			"error": response.Message(),
		})
	}

	var request requests.StoreAttachmentRequest
	validationErrors, err := ctx.Request().ValidateRequest(&request)
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}
	if validationErrors != nil {
		return ctx.Response().Json(422, map[string]any{
			"errors": validationErrors.All(),
		})
	}

	file, err := filesystem.NewFileFromRequest(request.File)
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}
	defer os.Remove(file.File())

	size, err := file.Size()
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}
	mimeType, err := file.MimeType()
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}

	path, err := file.Store(fmt.Sprintf("attachments/%d", task.ID))
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}

	attachment := models.Attachment{
		TaskID:     task.ID,
		UploadedBy: authuser.From(ctx).ID,
		Name:       file.GetClientOriginalName(),
		Path:       path,
		Size:       size,
		MimeType:   mimeType,
	}
	if err := facades.Orm().WithContext(ctx).Query().Create(&attachment); err != nil {
		facades.Log().Error(err)
		if err := facades.Storage().Delete(path); err != nil {
			facades.Log().Error(err)
		}
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}

	return ctx.Response().Json(201, attachment)
}

func (r *AttachmentController) Show(ctx http.Context) http.Response {
	var task models.Task
	err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The task does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the task.",
		})
	}

	response := facades.Gate().WithContext(ctx).Inspect(policies.ViewAttachment, map[string]any{
		"task": &task,
	})
	if !response.Allowed() {
		return ctx.Response().Json(403, map[string]any{
			"error": response.Message(),
		})
	}

	var attachment models.Attachment
	err = facades.Orm().WithContext(ctx).Query().
		Where("task_id", task.ID).
		FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The file does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the file.",
		})
	}

	content, err := facades.Storage().GetBytes(attachment.Path)
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the file.",
		})
	}

	disposition := mime.FormatMediaType("attachment", map[string]string{"filename": attachment.Name})

	return ctx.Response().
		Header("Content-Disposition", disposition).
		Data(200, attachment.MimeType, content)
}

func (r *AttachmentController) Destroy(ctx http.Context) http.Response {
	var attachment models.Attachment
	err := facades.Orm().WithContext(ctx).Query().
		Where("task_id", ctx.Request().RouteInt("id")).
		FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The file does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the file.",
		})
	}

	response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteAttachment, map[string]any{
		"attachment": &attachment,
	})
	if !response.Allowed() {
		return ctx.Response().Json(403, map[string]any{
			"error": response.Message(),
		})
	}

	if _, err := facades.Orm().WithContext(ctx).Query().Delete(&attachment); err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not delete the file.",
		})
	}

	if err := facades.Storage().Delete(attachment.Path); err != nil {
		facades.Log().Error(err)
	}

	return ctx.Response().NoContent()
}
```

- `request.File` is the upload the rules just checked. `filesystem.NewFileFromRequest` turns it into a Goravel file, which has `Size`, `MimeType` and `Store`.
- `Store` saves the file under `attachments/<task id>`, with a random 40-character name. The extension comes from the file's content. If the type is unknown, it comes from the client's name. The client's name is never used in the path. So two files called `login-error.png` don't overwrite each other, and a name like `../../.env` can't put a file outside the folder.
- The row saves the client's file name from `GetClientOriginalName`. The download uses it later.
- If the row can't be saved, the handler deletes the file, so a failed upload doesn't leave a file behind.

A file belongs to a task. So first, Ana creates a task for Ben. Then Ben uploads files to it. The setup is the same as in [Gates & Policies](https://devchalk.com/goravel/12-gates-policies#the-rules):

1. Reset the database with `migrate:fresh --seed`. The seeder adds Ana and three members.
2. Register Ben. New accounts are members, and Ben gets the next id, 5.
3. Log in as Ana and as Ben, and save each token in a variable, `ANA` and `BEN`.
4. As Ana, create a task for Ben.

```bash
$ go run . artisan migrate:fresh --seed
$ curl -X POST http://localhost:3000/api/v1/register \
  -H "Content-Type: application/json" \
  -d '{"name":"Ben","email":"ben@example.com","password":"secret123"}'
{…,"id":5,"name":"Ben",…,"role":"member",…}

$ curl -X POST http://localhost:3000/api/v1/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ana@example.com","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ ANA=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…

$ curl -X POST http://localhost:3000/api/v1/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ben@example.com","password":"secret123"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ BEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…

$ curl -X POST http://localhost:3000/api/v1/tasks \
  -H "Authorization: Bearer $ANA" \
  -H "Content-Type: application/json" \
  -d '{"title":"Fix the login page","description":"The error message overlaps the button.","assigned_to":5}'
{…,"id":8,"created_by":1,…,"assigned_to":5,…,"title":"Fix the login page","description":"The error message overlaps the button.","status":"pending","due_at":null}
```

Ben uploads a screenshot to his task, task 8. Any PNG works; here it's `login-error.png`:

```bash t1:field=/-F (file)=/ t3:name=/"name":"(login-error[.]png)"/
$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments \
  -H "Authorization: Bearer $BEN" \
  -F file=@login-error.png
{…,"id":1,"task_id":8,"uploaded_by":5,"name":"login-error.png","size":93,"mime_type":"image/png"}
```

`curl -F` sends a `multipart/form-data` request. `@` tells curl to read the file from disk. The file is now in the task's folder, under a random name:

```bash t2:path=/(attachments\/8)\//
$ find storage/app -type f
storage/app/attachments/8/L1D5yFohfKVrSjYqRYGfCMGvrVz3dBvOMnj9XswQ.png
```

A shell script renamed to `fake.png`, any PDF over 5 MB and a file for someone else's task are all refused:

```bash
$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments -H "Authorization: Bearer $BEN" -F file=@fake.png
{"errors":{"file":{"mimes":"The file field must be a file of type: png, jpg, pdf, txt."}}}

$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments -H "Authorization: Bearer $BEN" -F file=@big.pdf
{"errors":{"file":{"max":"The file field must not be greater than 5120 kilobytes."}}}

$ curl -X POST http://localhost:3000/api/v1/tasks/1/attachments -H "Authorization: Bearer $BEN" -F file=@login-error.png
{"error":"You can only attach files to tasks assigned to you."}
```

> **Gotcha** `NewFileFromRequest` copies the upload into Go's temp folder. That is `$TMPDIR` if it is set, and `/tmp` if not. `ctx.Request().File` does the same copy. Goravel never deletes it, so the handler deletes it with `defer os.Remove(file.File())`. Without that line, each upload leaves one file in the temp folder. Its name starts with `APP_NAME`. After three uploads:
>
> ```bash
> $ ls $TMPDIR | grep Goravel_
> Goravel_2906077324.png
> Goravel_3355795916.png
> Goravel_438708143.png
> ```
>
> These files stay after the request ends, and even after the server stops. On a busy server, they fill up the disk. This happens with both HTTP drivers, Gin and Fiber.

body_limit doesn't limit the upload:

`config/http.go` has `body_limit: 4096`. It looks like a 4 MB limit, but it isn't one. Goravel reads the whole form the first time anything reads the request. In TaskFlow, that is the auth middleware, when it reads the `Authorization` header. It keeps up to 32 MB in memory and writes the rest to temp files. Go deletes those after the request. Gin only uses `body_limit` to decide how much of a form stays in memory, and by then Goravel has already read the form. So it never limits anything. So a 50 MB upload is read in full, even with no token:

```bash
$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments -H "Authorization: Bearer $BEN" -F file=@huge.pdf \
  -w ' [%{http_code}] sent %{size_upload} bytes\n'
{"errors":{"file":{"max":"The file field must not be greater than 5120 kilobytes."}}} [422] sent 52429012 bytes

$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments -F file=@huge.pdf \
  -w ' [%{http_code}] sent %{size_upload} bytes\n'
{"error":"authentication token is invalid"} [401] sent 52429012 bytes
```

To stop a large upload before the app reads it, limit the request size in front of the app. In nginx, that is `client_max_body_size`.

## Downloading a file

A file on the local disk has no public URL. So the client asks the API for it. The handler checks the policy before it sends the file:

```go title="app/http/controllers/attachment_controller.go" collapse={1-120} collapse={176-214} t3:name=/"filename": attachment[.](Name)/
package controllers

import (
	"errors"
	"fmt"
	"mime"
	"os"

	"taskflow/app/facades"
	"taskflow/app/http/authuser"
	"taskflow/app/http/requests"
	"taskflow/app/models"
	"taskflow/app/policies"

	"github.com/goravel/framework/contracts/http"
	frameworkerrors "github.com/goravel/framework/errors"
	"github.com/goravel/framework/filesystem"
)

type AttachmentController struct {
	// Dependent services
}

func NewAttachmentController() *AttachmentController {
	return &AttachmentController{
		// Inject services
	}
}

func (r *AttachmentController) Store(ctx http.Context) http.Response {
	var task models.Task
	err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The task does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the task.",
		})
	}

	response := facades.Gate().WithContext(ctx).Inspect(policies.CreateAttachment, map[string]any{
		"task": &task,
	})
	if !response.Allowed() {
		return ctx.Response().Json(403, map[string]any{
			"error": response.Message(),
		})
	}

	var request requests.StoreAttachmentRequest
	validationErrors, err := ctx.Request().ValidateRequest(&request)
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}
	if validationErrors != nil {
		return ctx.Response().Json(422, map[string]any{
			"errors": validationErrors.All(),
		})
	}

	file, err := filesystem.NewFileFromRequest(request.File)
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}
	defer os.Remove(file.File())

	size, err := file.Size()
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}
	mimeType, err := file.MimeType()
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}

	path, err := file.Store(fmt.Sprintf("attachments/%d", task.ID))
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}

	attachment := models.Attachment{
		TaskID:     task.ID,
		UploadedBy: authuser.From(ctx).ID,
		Name:       file.GetClientOriginalName(),
		Path:       path,
		Size:       size,
		MimeType:   mimeType,
	}
	if err := facades.Orm().WithContext(ctx).Query().Create(&attachment); err != nil {
		facades.Log().Error(err)
		if err := facades.Storage().Delete(path); err != nil {
			facades.Log().Error(err)
		}
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}

	return ctx.Response().Json(201, attachment)
}

func (r *AttachmentController) Show(ctx http.Context) http.Response {
	var task models.Task
	err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The task does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the task.",
		})
	}

	response := facades.Gate().WithContext(ctx).Inspect(policies.ViewAttachment, map[string]any{
		"task": &task,
	})
	if !response.Allowed() {
		return ctx.Response().Json(403, map[string]any{
			"error": response.Message(),
		})
	}

	var attachment models.Attachment
	err = facades.Orm().WithContext(ctx).Query().
		Where("task_id", task.ID).
		FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The file does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the file.",
		})
	}

	content, err := facades.Storage().GetBytes(attachment.Path)
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the file.",
		})
	}

	disposition := mime.FormatMediaType("attachment", map[string]string{"filename": attachment.Name})

	return ctx.Response().
		Header("Content-Disposition", disposition).
		Data(200, attachment.MimeType, content)
}

func (r *AttachmentController) Destroy(ctx http.Context) http.Response {
	var attachment models.Attachment
	err := facades.Orm().WithContext(ctx).Query().
		Where("task_id", ctx.Request().RouteInt("id")).
		FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The file does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the file.",
		})
	}

	response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteAttachment, map[string]any{
		"attachment": &attachment,
	})
	if !response.Allowed() {
		return ctx.Response().Json(403, map[string]any{
			"error": response.Message(),
		})
	}

	if _, err := facades.Orm().WithContext(ctx).Query().Delete(&attachment); err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not delete the file.",
		})
	}

	if err := facades.Storage().Delete(attachment.Path); err != nil {
		facades.Log().Error(err)
	}

	return ctx.Response().NoContent()
}
```

- `Where("task_id", task.ID)` only finds the file if it belongs to the task in the URL. A file from another task gives a `404`, even for Ana.
- `GetBytes` reads the file through the disk, so it works on the local disk and on S3. `ctx.Response().Download` can also send a file, but it only opens files on the server's own hard drive. With S3, the files aren't on the server, so `Download` would stop working.
- `Content-Disposition: attachment` makes the browser save the file under its original name, instead of opening it. `mime.FormatMediaType` builds the header, so names with spaces or accents still work.

```bash t3:name=/filename=(login-error[.]png)/
$ curl -D - -o /dev/null http://localhost:3000/api/v1/tasks/8/attachments/1 -H "Authorization: Bearer $BEN"
HTTP/1.1 200 OK
Content-Disposition: attachment; filename=login-error.png
Content-Length: 93
Content-Type: image/png
```

File 1 is not under task 1, so even Ana gets a 404:

```bash
$ curl http://localhost:3000/api/v1/tasks/1/attachments/1 -H "Authorization: Bearer $ANA"
{"error":"The file does not exist."}
```

A single task now lists its files. The client gets the file ids from there:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-78} collapse={86-245}
 package controllers
 
 import (
 	"errors"
 
 	"taskflow/app/enums"
 	"taskflow/app/facades"
 	"taskflow/app/http/authuser"
 	"taskflow/app/http/requests"
 	"taskflow/app/models"
 	"taskflow/app/policies"
 
 	"github.com/goravel/framework/contracts/http"
 	frameworkerrors "github.com/goravel/framework/errors"
 	"github.com/goravel/framework/validation"
 )
 
 type TaskController struct {
 	// Dependent services
 }
 
 func NewTaskController() *TaskController {
 	return &TaskController{
 		// Inject services
 	}
 }
 
 func (r *TaskController) Store(ctx http.Context) http.Response {
 	if response := facades.Gate().WithContext(ctx).Inspect(policies.CreateTask, nil); !response.Allowed() {
 		return ctx.Response().Json(403, map[string]any{
 			"error": response.Message(),
 		})
 	}
 
 	var request requests.StoreTaskRequest
 	validationErrors, err := ctx.Request().ValidateRequest(&request)
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not save the task.",
 		})
 	}
 	if validationErrors != nil {
 		return ctx.Response().Json(422, map[string]any{
 			"errors": validationErrors.All(),
 		})
 	}
 
 	task := models.Task{
 		CreatedBy:   authuser.From(ctx).ID,
 		AssignedTo:  request.AssignedTo,
 		Title:       request.Title,
 		Description: request.Description,
 		Status:      enums.StatusPending,
 		DueAt:       request.DueAt,
 	}
 	if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not save the task.",
 		})
 	}
 
 	var saved models.Task
 	err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not load the task.",
 		})
 	}
 
 	return ctx.Response().Json(201, saved)
 }
 
 func (r *TaskController) Show(ctx http.Context) http.Response {
 	var task models.Task
 	err := facades.Orm().WithContext(ctx).Query().
 		With("Creator").
 		With("Assignee").
+		With("Attachments").
 		FindOrFail(&task, ctx.Request().RouteInt("id"))
 	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
 		return ctx.Response().Json(404, map[string]any{
 			"error": "The task does not exist.",
 		})
 	}
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not load the task.",
 		})
 	}
 
 	return ctx.Response().Json(200, task)
 }
 
 func (r *TaskController) Index(ctx http.Context) http.Response {
 	validator, err := ctx.Request().Validate(map[string]any{
 		"status":   "in:pending,in-progress,done",
 		"page":     "bail|filled|integer|min:1",
 		"per_page": "bail|filled|integer|min:1|max:100",
 	})
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not list the tasks.",
 		})
 	}
 	if validator.Fails() {
 		return ctx.Response().Json(422, map[string]any{
 			"errors": validator.Errors().All(),
 		})
 	}
 
 	page := ctx.Request().QueryInt("page", 1)
 	perPage := ctx.Request().QueryInt("per_page", 15)
 
 	query := facades.Orm().WithContext(ctx).Query()
 	if status := ctx.Request().Query("status"); status != "" {
 		query = query.Where("status", status)
 	}
 
 	var tasks []models.Task
 	var total int64
 	err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not list the tasks.",
 		})
 	}
 
 	return ctx.Response().Json(200, map[string]any{
 		"data": tasks,
 		"meta": map[string]any{
 			"page":     page,
 			"per_page": perPage,
 			"total":    total,
 		},
 	})
 }
 
 func (r *TaskController) Update(ctx http.Context) http.Response {
 	var task models.Task
 	err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
 	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
 		return ctx.Response().Json(404, map[string]any{
 			"error": "The task does not exist.",
 		})
 	}
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not load the task.",
 		})
 	}
 
 	var request requests.UpdateTaskRequest
 	if err := request.Authorize(ctx); err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not save the task.",
 		})
 	}
 
 	validator, err := ctx.Request().Validate(
 		request.Rules(ctx),
 		validation.Filters(request.Filters(ctx)),
 		validation.Attributes(request.Attributes(ctx)),
 	)
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not save the task.",
 		})
 	}
 	if validator.Fails() {
 		return ctx.Response().Json(422, map[string]any{
 			"errors": validator.Errors().All(),
 		})
 	}
 
 	changes := validator.Validated()
 	response := facades.Gate().WithContext(ctx).Inspect(policies.UpdateTask, map[string]any{
 		"task":    &task,
 		"changes": changes,
 	})
 	if !response.Allowed() {
 		return ctx.Response().Json(403, map[string]any{
 			"error": response.Message(),
 		})
 	}
 
 	if len(changes) > 0 {
 		if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
 			facades.Log().Error(err)
 			return ctx.Response().Json(500, map[string]any{
 				"error": "Could not save the task.",
 			})
 		}
 	}
 
 	var saved models.Task
 	err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not load the task.",
 		})
 	}
 
 	return ctx.Response().Json(200, saved)
 }
 
 func (r *TaskController) Destroy(ctx http.Context) http.Response {
 	var task models.Task
 	err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
 	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
 		return ctx.Response().Json(404, map[string]any{
 			"error": "The task does not exist.",
 		})
 	}
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not load the task.",
 		})
 	}
 
 	if response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteTask, nil); !response.Allowed() {
 		return ctx.Response().Json(403, map[string]any{
 			"error": response.Message(),
 		})
 	}
 
 	if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not delete the task.",
 		})
 	}
 
 	return ctx.Response().NoContent()
 }
```

```bash
$ curl http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $BEN"
{…,"title":"Fix the login page",…,"attachments":[{…,"id":1,"task_id":8,"uploaded_by":5,"name":"login-error.png","size":93,"mime_type":"image/png"}]}
```

## Deleting files

A file has two parts: the row in Postgres and the file on the disk. Deleting it removes both:

```go title="app/http/controllers/attachment_controller.go" collapse={1-175}
package controllers

import (
	"errors"
	"fmt"
	"mime"
	"os"

	"taskflow/app/facades"
	"taskflow/app/http/authuser"
	"taskflow/app/http/requests"
	"taskflow/app/models"
	"taskflow/app/policies"

	"github.com/goravel/framework/contracts/http"
	frameworkerrors "github.com/goravel/framework/errors"
	"github.com/goravel/framework/filesystem"
)

type AttachmentController struct {
	// Dependent services
}

func NewAttachmentController() *AttachmentController {
	return &AttachmentController{
		// Inject services
	}
}

func (r *AttachmentController) Store(ctx http.Context) http.Response {
	var task models.Task
	err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The task does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the task.",
		})
	}

	response := facades.Gate().WithContext(ctx).Inspect(policies.CreateAttachment, map[string]any{
		"task": &task,
	})
	if !response.Allowed() {
		return ctx.Response().Json(403, map[string]any{
			"error": response.Message(),
		})
	}

	var request requests.StoreAttachmentRequest
	validationErrors, err := ctx.Request().ValidateRequest(&request)
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}
	if validationErrors != nil {
		return ctx.Response().Json(422, map[string]any{
			"errors": validationErrors.All(),
		})
	}

	file, err := filesystem.NewFileFromRequest(request.File)
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}
	defer os.Remove(file.File())

	size, err := file.Size()
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}
	mimeType, err := file.MimeType()
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}

	path, err := file.Store(fmt.Sprintf("attachments/%d", task.ID))
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}

	attachment := models.Attachment{
		TaskID:     task.ID,
		UploadedBy: authuser.From(ctx).ID,
		Name:       file.GetClientOriginalName(),
		Path:       path,
		Size:       size,
		MimeType:   mimeType,
	}
	if err := facades.Orm().WithContext(ctx).Query().Create(&attachment); err != nil {
		facades.Log().Error(err)
		if err := facades.Storage().Delete(path); err != nil {
			facades.Log().Error(err)
		}
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not save the file.",
		})
	}

	return ctx.Response().Json(201, attachment)
}

func (r *AttachmentController) Show(ctx http.Context) http.Response {
	var task models.Task
	err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The task does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the task.",
		})
	}

	response := facades.Gate().WithContext(ctx).Inspect(policies.ViewAttachment, map[string]any{
		"task": &task,
	})
	if !response.Allowed() {
		return ctx.Response().Json(403, map[string]any{
			"error": response.Message(),
		})
	}

	var attachment models.Attachment
	err = facades.Orm().WithContext(ctx).Query().
		Where("task_id", task.ID).
		FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The file does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the file.",
		})
	}

	content, err := facades.Storage().GetBytes(attachment.Path)
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the file.",
		})
	}

	disposition := mime.FormatMediaType("attachment", map[string]string{"filename": attachment.Name})

	return ctx.Response().
		Header("Content-Disposition", disposition).
		Data(200, attachment.MimeType, content)
}

func (r *AttachmentController) Destroy(ctx http.Context) http.Response {
	var attachment models.Attachment
	err := facades.Orm().WithContext(ctx).Query().
		Where("task_id", ctx.Request().RouteInt("id")).
		FindOrFail(&attachment, ctx.Request().RouteInt("attachment"))
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return ctx.Response().Json(404, map[string]any{
			"error": "The file does not exist.",
		})
	}
	if err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not load the file.",
		})
	}

	response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteAttachment, map[string]any{
		"attachment": &attachment,
	})
	if !response.Allowed() {
		return ctx.Response().Json(403, map[string]any{
			"error": response.Message(),
		})
	}

	if _, err := facades.Orm().WithContext(ctx).Query().Delete(&attachment); err != nil {
		facades.Log().Error(err)
		return ctx.Response().Json(500, map[string]any{
			"error": "Could not delete the file.",
		})
	}

	if err := facades.Storage().Delete(attachment.Path); err != nil {
		facades.Log().Error(err)
	}

	return ctx.Response().NoContent()
}
```

The row is deleted first. If deleting the file fails after that, the client still gets `204`, and the error goes to the log. A file with no row only wastes space. A row with no file would be a broken download.

Ana uploads a text file, `notes.txt`, to Ben's task. Ben can't delete her file, but he can delete his own:

```bash
$ curl -X POST http://localhost:3000/api/v1/tasks/8/attachments -H "Authorization: Bearer $ANA" -F file=@notes.txt
{…,"id":2,"task_id":8,"uploaded_by":1,"name":"notes.txt","size":19,"mime_type":"text/plain; charset=utf-8"}

$ curl -X DELETE http://localhost:3000/api/v1/tasks/8/attachments/2 -H "Authorization: Bearer $BEN"
{"error":"You can only delete files you uploaded."}

$ curl -i -X DELETE http://localhost:3000/api/v1/tasks/8/attachments/1 -H "Authorization: Bearer $BEN"
HTTP/1.1 204 No Content
```

### When a task is deleted

`CascadeOnDelete` deletes a task's rows, but Postgres doesn't know about the disk. Ana deletes task 8. Its row is gone, but `notes.txt` is still in its folder:

```bash
$ curl -X DELETE http://localhost:3000/api/v1/tasks/8 -H "Authorization: Bearer $ANA"
$ docker compose exec postgres psql -U goravel taskflow -c "select count(*) from attachments where task_id = 8"
 count 
-------
     0
(1 row)

$ find storage/app/attachments -type f
storage/app/attachments/8/MyNSQ6l8gjPiwTvZA7JO9qZ6OH5EurXPcJrYy62K.txt
```

Each task's files are in one folder. So the task's `Destroy` deletes that folder after the row:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={8-216} collapse={219-236}
 package controllers
 
 import (
 	"errors"
+	"fmt"
 
 	"taskflow/app/enums"
 	"taskflow/app/facades"
 	"taskflow/app/http/authuser"
 	"taskflow/app/http/requests"
 	"taskflow/app/models"
 	"taskflow/app/policies"
 
 	"github.com/goravel/framework/contracts/http"
 	frameworkerrors "github.com/goravel/framework/errors"
 	"github.com/goravel/framework/validation"
 )
 
 type TaskController struct {
 	// Dependent services
 }
 
 func NewTaskController() *TaskController {
 	return &TaskController{
 		// Inject services
 	}
 }
 
 func (r *TaskController) Store(ctx http.Context) http.Response {
 	if response := facades.Gate().WithContext(ctx).Inspect(policies.CreateTask, nil); !response.Allowed() {
 		return ctx.Response().Json(403, map[string]any{
 			"error": response.Message(),
 		})
 	}
 
 	var request requests.StoreTaskRequest
 	validationErrors, err := ctx.Request().ValidateRequest(&request)
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not save the task.",
 		})
 	}
 	if validationErrors != nil {
 		return ctx.Response().Json(422, map[string]any{
 			"errors": validationErrors.All(),
 		})
 	}
 
 	task := models.Task{
 		CreatedBy:   authuser.From(ctx).ID,
 		AssignedTo:  request.AssignedTo,
 		Title:       request.Title,
 		Description: request.Description,
 		Status:      enums.StatusPending,
 		DueAt:       request.DueAt,
 	}
 	if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not save the task.",
 		})
 	}
 
 	var saved models.Task
 	err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not load the task.",
 		})
 	}
 
 	return ctx.Response().Json(201, saved)
 }
 
 func (r *TaskController) Show(ctx http.Context) http.Response {
 	var task models.Task
 	err := facades.Orm().WithContext(ctx).Query().
 		With("Creator").
 		With("Assignee").
 		With("Attachments").
 		FindOrFail(&task, ctx.Request().RouteInt("id"))
 	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
 		return ctx.Response().Json(404, map[string]any{
 			"error": "The task does not exist.",
 		})
 	}
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not load the task.",
 		})
 	}
 
 	return ctx.Response().Json(200, task)
 }
 
 func (r *TaskController) Index(ctx http.Context) http.Response {
 	validator, err := ctx.Request().Validate(map[string]any{
 		"status":   "in:pending,in-progress,done",
 		"page":     "bail|filled|integer|min:1",
 		"per_page": "bail|filled|integer|min:1|max:100",
 	})
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not list the tasks.",
 		})
 	}
 	if validator.Fails() {
 		return ctx.Response().Json(422, map[string]any{
 			"errors": validator.Errors().All(),
 		})
 	}
 
 	page := ctx.Request().QueryInt("page", 1)
 	perPage := ctx.Request().QueryInt("per_page", 15)
 
 	query := facades.Orm().WithContext(ctx).Query()
 	if status := ctx.Request().Query("status"); status != "" {
 		query = query.Where("status", status)
 	}
 
 	var tasks []models.Task
 	var total int64
 	err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not list the tasks.",
 		})
 	}
 
 	return ctx.Response().Json(200, map[string]any{
 		"data": tasks,
 		"meta": map[string]any{
 			"page":     page,
 			"per_page": perPage,
 			"total":    total,
 		},
 	})
 }
 
 func (r *TaskController) Update(ctx http.Context) http.Response {
 	var task models.Task
 	err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
 	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
 		return ctx.Response().Json(404, map[string]any{
 			"error": "The task does not exist.",
 		})
 	}
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not load the task.",
 		})
 	}
 
 	var request requests.UpdateTaskRequest
 	if err := request.Authorize(ctx); err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not save the task.",
 		})
 	}
 
 	validator, err := ctx.Request().Validate(
 		request.Rules(ctx),
 		validation.Filters(request.Filters(ctx)),
 		validation.Attributes(request.Attributes(ctx)),
 	)
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not save the task.",
 		})
 	}
 	if validator.Fails() {
 		return ctx.Response().Json(422, map[string]any{
 			"errors": validator.Errors().All(),
 		})
 	}
 
 	changes := validator.Validated()
 	response := facades.Gate().WithContext(ctx).Inspect(policies.UpdateTask, map[string]any{
 		"task":    &task,
 		"changes": changes,
 	})
 	if !response.Allowed() {
 		return ctx.Response().Json(403, map[string]any{
 			"error": response.Message(),
 		})
 	}
 
 	if len(changes) > 0 {
 		if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
 			facades.Log().Error(err)
 			return ctx.Response().Json(500, map[string]any{
 				"error": "Could not save the task.",
 			})
 		}
 	}
 
 	var saved models.Task
 	err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not load the task.",
 		})
 	}
 
 	return ctx.Response().Json(200, saved)
 }
 
 func (r *TaskController) Destroy(ctx http.Context) http.Response {
 	var task models.Task
 	err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
 	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
 		return ctx.Response().Json(404, map[string]any{
 			"error": "The task does not exist.",
 		})
 	}
 	if err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not load the task.",
 		})
 	}
 
 	if response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteTask, nil); !response.Allowed() {
 		return ctx.Response().Json(403, map[string]any{
 			"error": response.Message(),
 		})
 	}
 
 	if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
 		facades.Log().Error(err)
 		return ctx.Response().Json(500, map[string]any{
 			"error": "Could not delete the task.",
 		})
 	}
 
+	if err := facades.Storage().DeleteDirectory(fmt.Sprintf("attachments/%d", task.ID)); err != nil {
+		facades.Log().Error(err)
+	}
+
 	return ctx.Response().NoContent()
 }
```

Here is the whole trip, from the upload to the delete:

*An upload, a download and a task delete, one message at a time.*

Upload: the client sends the file as multipart/form-data, the API checks the policy and the rules, copies the file into the temp folder, stores it on the disk under attachments/8 with a random name, inserts a row in Postgres, removes the temp copy and answers 201. Download: the API loads the row for the task, reads the file from the disk and sends it with a Content-Disposition header. Delete the task: Postgres deletes the task and, through the cascade, its attachment rows, and the API deletes the folder attachments/8 from the disk.

## Tests

The tests for this part are in [`tests/feature/attachment_test.go`](https://github.com/krishankumar01/goravel-taskflow/blob/part-14/tests/feature/attachment_test.go) in TaskFlow. They upload, download and delete files, check every rule in the table above, and check that no temp file is left behind. They run on the `testing` disk:

```diff lang="bash" title=".env.testing" collapse={1-28}
 APP_NAME=Goravel
 APP_ENV=testing
 APP_KEY=2Nr9bpCIal8Dj0qYE7IviHcYcwzmVqoh
 APP_DEBUG=true
 
 LOG_CHANNEL=stack
 LOG_LEVEL=debug
 
 SESSION_DRIVER=file
 SESSION_LIFETIME=120
 
 APP_URL=http://localhost
 APP_HOST=127.0.0.1
 APP_PORT=3000
 
 JWT_SECRET=xRQKZ4WHIu8bIHANdQVnskdTsa47cya8
 
 DB_CONNECTION=postgres
 DB_HOST=127.0.0.1
 DB_PORT=5433
 DB_DATABASE=taskflow_test
 DB_USERNAME=goravel
 DB_PASSWORD=secret
 
 REDIS_HOST=127.0.0.1
 REDIS_PASSWORD=
 REDIS_PORT=6380
 
 CACHE_STORE=memory
 HASH_ROUNDS=4
+FILESYSTEM_DISK=testing
```

Each test deletes the `attachments` folder first. So the test case only runs on the `testing` disk, just as it only runs on a database whose name ends in `_test`:

```diff lang="go" title="tests/test_case.go" collapse={1-15}
 package tests
 
 import (
 	"strings"
 
 	"github.com/goravel/framework/testing"
 
 	"taskflow/app/facades"
 	"taskflow/bootstrap"
 )
 
 func init() {
 	bootstrap.Boot()
 
 	if !strings.HasSuffix(facades.Config().GetString("database.connections.postgres.database"), "_test") {
 		panic("the tests drop every table, so they only run on a database whose name ends in _test: go test ./tests/... --env=.env.testing")
 	}
+
+	if facades.Config().GetString("filesystems.default") != "testing" {
+		panic("the tests delete uploaded files, so they only run on the testing disk: go test ./tests/... --env=.env.testing")
+	}
 }
 
 type TestCase struct {
 	testing.TestCase
 }
```

```bash
$ go test ./tests/... --env=.env.testing
?   	taskflow/tests	[no test files]
ok  	taskflow/tests/feature	1.491s
ok  	taskflow/tests/unit	0.756s
```

Tasks now have files. They are saved on a private disk under random names. Only the people working on the task can download them, and they are deleted with the task.

---

Previous: [13. Feature Testing](https://devchalk.com/goravel/13-feature-testing.md)
Next: [15. Events & Listeners](https://devchalk.com/goravel/15-events-listeners-notifications.md)
Series: [Goravel](https://devchalk.com/goravel.md)
