Repository: rclone/rclone (Go)

Issue:
Set 'Last-Modified' for files served by '--rc-serve'
#### What is your current rclone version (output from `rclone version`)?

```
rclone v1.64.0-DEV
- os/version: ubuntu 22.04 (64 bit)
- os/kernel: 5.15.90.1-microsoft-standard-WSL2 (x86_64)
- os/type: linux
- os/arch: amd64
- go/version: go1.21.0
- go/linking: static
- go/tags: none
```

#### What problem are you are trying to solve?

Files served by `--rc-serve` don't have the `Last-Modified` header. Here you can see the comparison of response headers for the same file:

<table>
<tr>
<th>

`rclone serve http`
</th>
<th>

`rclone rcd --rc-no-auth --rc-serve`
</th>
</tr>

<tr>
<td>

```diff
Accept-Ranges: bytes



Content-Length: 73
Content-Type: text/plain; charset=utf-8
Server: rclone/v1.64.0-DEV
Date: Thu, 21 Sep 2023 23:56:15 GMT
+Last-Modified: Sat, 23 Jul 2022 15:17:10 GMT
```
</td>

<td>

```diff
Accept-Ranges: bytes
+Access-Control-Allow-Headers: authorization, Content-Type
+Access-Control-Allow-Origin: http://127.0.0.1:5572/
+Access-Control-Request-Method: POST, OPTIONS, GET, HEAD
Content-Length: 73
Content-Type: text/plain; charset=utf-8
Server: rclone/v1.64.0-DEV
Date: Thu, 21 Sep 2023 23:51:08 GMT
 
```
</td>
</tr>
</table>

#### How do you think rclone should be changed to solve that?

As I can see, we can't just set the `Last-Modified` header in the function `lib/http/serve.Object` because `(fs.DirEntry).ModTime()` can make additional requests. Maybe we can accept some query param (for example `?modTime=true`), so users who need this information would be able to access it?

<!--- Please keep the note below for others who read your feature request. -->

#### How to use GitHub

* Please use the 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to show that you are affected by the same issue.
* Please don't comment if you have no relevant information to add. It's just extra noise for everyone subscribed to this issue.
* Subscribe to receive notifications on status change and new comments.



Discussion:
Are those headers for a `HEAD` or a `GET` request?

If you make the `Last-Modified` conditional on the `f.Features().SlowModTime` then you can be sure it won't take another transaction.

Though I note we are already reading mime type which can take another transaction. And in fact in `s3` which is the most popular backend with `SlowModTime` the act of reading the mime type will have done a transaction which pulls in the modtime already. So calling ModTime after it won't cause any transactions.

If you delay setting the `Last-Modified` header until after the `o.Open` then all the backends I can think of will have read the modtime into `o` at that point. That won't work for `HEAD` requests though.

Anyway, after thinking about it, I'd say we should probably just read the ModTime for everything and set LastModified regardless so it works for `HEAD` and `GET`.

Do you want to send a PR for that?
Thanks for the detailed answer! I have sent a PR to fix this issue.



Patch:
diff --git a/lib/http/serve/serve.go b/lib/http/serve/serve.go
index c4b70f1fd1b33..6f97d0d8a04b6 100644
--- a/lib/http/serve/serve.go
+++ b/lib/http/serve/serve.go
@@ -35,6 +35,10 @@ func Object(w http.ResponseWriter, r *http.Request, o fs.Object) {
 		w.Header().Set("Content-Type", mimeType)
 	}
 
+	// Set last modified
+	modTime := o.ModTime(r.Context())
+	w.Header().Set("Last-Modified", modTime.UTC().Format(http.TimeFormat))
+
 	if r.Method == "HEAD" {
 		return
 	}


Test patch:
diff --git a/fstest/mockobject/mockobject.go b/fstest/mockobject/mockobject.go
index 084536b85a1a5..dad56a6340035 100644
--- a/fstest/mockobject/mockobject.go
+++ b/fstest/mockobject/mockobject.go
@@ -93,13 +93,14 @@ const (
 // SeekModes contains all valid SeekMode's
 var SeekModes = []SeekMode{SeekModeNone, SeekModeRegular, SeekModeRange}
 
-// ContentMockObject mocks an fs.Object and has content
+// ContentMockObject mocks an fs.Object and has content, mod time
 type ContentMockObject struct {
 	Object
 	content     []byte
 	seekMode    SeekMode
 	f           fs.Fs
 	unknownSize bool
+	modTime     time.Time
 }
 
 // WithContent returns an fs.Object with the given content.
@@ -192,6 +193,18 @@ func (o *ContentMockObject) Hash(ctx context.Context, t hash.Type) (string, erro
 	return hasher.Sums()[t], nil
 }
 
+// ModTime returns the modification date of the file
+// It should return a best guess if one isn't available
+func (o *ContentMockObject) ModTime(ctx context.Context) time.Time {
+	return o.modTime
+}
+
+// SetModTime sets the metadata on the object to set the modification date
+func (o *ContentMockObject) SetModTime(ctx context.Context, t time.Time) error {
+	o.modTime = t
+	return nil
+}
+
 type readCloser struct{ io.Reader }
 
 func (r *readCloser) Close() error { return nil }
diff --git a/lib/http/serve/serve_test.go b/lib/http/serve/serve_test.go
index b6cc70976d32b..dc90984281534 100644
--- a/lib/http/serve/serve_test.go
+++ b/lib/http/serve/serve_test.go
@@ -1,10 +1,12 @@
 package serve
 
 import (
+	"context"
 	"io"
 	"net/http"
 	"net/http/httptest"
 	"testing"
+	"time"
 
 	"github.com/rclone/rclone/fstest/mockobject"
 	"github.com/stretchr/testify/assert"
@@ -25,11 +27,13 @@ func TestObjectHEAD(t *testing.T) {
 	w := httptest.NewRecorder()
 	r := httptest.NewRequest("HEAD", "http://example.com/aFile", nil)
 	o := mockobject.New("aFile").WithContent([]byte("hello"), mockobject.SeekModeNone)
+	_ = o.SetModTime(context.Background(), time.Date(2023, 9, 20, 12, 11, 15, 0, time.FixedZone("", 4*60*60))) // UTC+4
 	Object(w, r, o)
 	resp := w.Result()
 	assert.Equal(t, http.StatusOK, resp.StatusCode)
 	assert.Equal(t, "5", resp.Header.Get("Content-Length"))
 	assert.Equal(t, "bytes", resp.Header.Get("Accept-Ranges"))
+	assert.Equal(t, "Wed, 20 Sep 2023 08:11:15 GMT", resp.Header.Get("Last-Modified"))
 	body, _ := io.ReadAll(resp.Body)
 	assert.Equal(t, "", string(body))
 }
@@ -38,11 +42,13 @@ func TestObjectGET(t *testing.T) {
 	w := httptest.NewRecorder()
 	r := httptest.NewRequest("GET", "http://example.com/aFile", nil)
 	o := mockobject.New("aFile").WithContent([]byte("hello"), mockobject.SeekModeNone)
+	_ = o.SetModTime(context.Background(), time.Date(2023, 9, 20, 12, 11, 15, 0, time.FixedZone("", 2*60*60))) // UTC+2
 	Object(w, r, o)
 	resp := w.Result()
 	assert.Equal(t, http.StatusOK, resp.StatusCode)
 	assert.Equal(t, "5", resp.Header.Get("Content-Length"))
 	assert.Equal(t, "bytes", resp.Header.Get("Accept-Ranges"))
+	assert.Equal(t, "Wed, 20 Sep 2023 10:11:15 GMT", resp.Header.Get("Last-Modified"))
 	body, _ := io.ReadAll(resp.Body)
 	assert.Equal(t, "hello", string(body))
 }