Skip to content

Commit 8a8b538

Browse files
authored
filestore: Allow customising the storage path (#1083)
* filestore: Allow customising the destination path * Compatible with uploads from previous version * Add todo about documentation * More comments * Documentation
1 parent a4ece9a commit 8a8b538

File tree

4 files changed

+139
-9
lines changed

4 files changed

+139
-9
lines changed

docs/_advanced-topics/hooks.md

+22-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ Below you can find an annotated, JSON-ish encoded example of a hook request:
8282
"PartialUploads": null,
8383
// Storage contains information about where the upload is stored. The exact values
8484
// depend on the storage that is used and are not available in the pre-create hook.
85-
// This example belongs to the file store.
8685
"Storage": {
8786
// For example, the filestore supplies the absolute file path:
8887
"Type": "filestore",
@@ -190,6 +189,28 @@ Below you can find an annotated, JSON-ish encoded example of a hook response:
190189
// in the Upload-Metadata header in HEAD responses.
191190
"MetaData": {
192191
"my-custom-field": "..."
192+
},
193+
// Storage can be used to customize the location where the uploaded file (aka the binary
194+
// file is saved). The exact behavior depends on the storage that is used. Please note
195+
// that this only influences the location of the binary file. tusd will still create an
196+
// info file whose location is derived from the upload ID and can not be customized using
197+
// this ChangeFileInfo.Storage property, but only using ChangeFileInfo.ID.
198+
//
199+
// The location can contain forward slashes (/) to store uploads in a hierarchical structure,
200+
// such as nested directories.
201+
//
202+
// Similar to ChangeFileInfo.ID, tusd will not check whether a file is already saved under
203+
// this location and might overwrite it. It is the hooks responsibility to ensure that
204+
// the location is save to use. A good approach is to embed a random part (e.g. a UUID) in
205+
// the location.
206+
"Storage": {
207+
// When the filestore is used, the Path property defines where the uploaded file is saved.
208+
// The path may be absolute or relative, and point towards a location outside of the directory
209+
// defined using the `-dir` flag. If it's relative, the path will be resolved relative to `-dir`.
210+
"Path": "./upload-e7a036dc-33f4-451f-9520-49032b87e952/presentation.pdf"
211+
212+
// Other storages, such as S3Store, GCSStore, and AzureStore, do not support the Storage
213+
// property yet.
193214
}
194215
},
195216

docs/_storage-backends/local-disk.md

+19
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,25 @@ When a new upload is created, for example with the upload ID `abcdef123`, tusd c
1717
- `./uploads/abcdef123` to hold the file content that is uploaded
1818
- `./uploads/abcdef123.info` to hold [meta information about the upload]({{ site.baseurl }}/storage-backends/overview/#storage-format)
1919

20+
## Custom storage location
21+
22+
The locations of the two files mentioned above can be fully customized using the [pre-create hook](({ site.baseurl }}/advanced-topics/hooks/). The location of the `.info` file is derived from the upload ID, which can be customized by the pre-create hook using the [`ChangeFileInfo.ID` setting]({ site.baseurl }}/advanced-topics/hooks/#hook-requests-and-responses). Similarly, the location where the file content is saved is by default derived from the upload ID, but can be fully customized using the [`ChangeFileInfo.Storage.Path` setting]({ site.baseurl }}/advanced-topics/hooks/#hook-requests-and-responses).
23+
24+
For example, if the pre-create hook returns the following hook response, an upload with ID `project-123/abc` is created, the info file is saved at `./uploads/project-123/abc.info`, and the file content is saved at `./uploads/project-123/abc/presentation.pdf`:
25+
26+
```js
27+
{
28+
"ChangeFileInfo": {
29+
"ID": "project-123/abc",
30+
"Storage": {
31+
"Path": "project-123/abc/presentation.pdf"
32+
}
33+
},
34+
}
35+
```
36+
37+
If the defined path is relative, it will be resolved from the directory defined using `-dir`.
38+
2039
## Issues with NFS and shared folders
2140

2241
Tusd maintains [upload locks]({{ site.baseurl }}/advanced-topics/locks/) on disk to ensure exclusive access to uploads and prevent data corruption. These disk-based locks utilize hard links, which might not be supported by older NFS versions or when a folder is shared in a VM using VirtualBox or Vagrant. In these cases, you might get errors like this:

pkg/filestore/filestore.go

+30-8
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,17 @@ func (store FileStore) NewUpload(ctx context.Context, info handler.FileInfo) (ha
5656
if info.ID == "" {
5757
info.ID = uid.Uid()
5858
}
59-
binPath := store.binPath(info.ID)
59+
60+
// The .info file's location can directly be deduced from the upload ID
61+
infoPath := store.infoPath(info.ID)
62+
// The binary file's location might be modified by the pre-create hook.
63+
var binPath string
64+
if info.Storage != nil && info.Storage["Path"] != "" {
65+
binPath = filepath.Join(store.Path, info.Storage["Path"])
66+
} else {
67+
binPath = store.defaultBinPath(info.ID)
68+
}
69+
6070
info.Storage = map[string]string{
6171
"Type": "filestore",
6272
"Path": binPath,
@@ -69,7 +79,7 @@ func (store FileStore) NewUpload(ctx context.Context, info handler.FileInfo) (ha
6979

7080
upload := &fileUpload{
7181
info: info,
72-
infoPath: store.infoPath(info.ID),
82+
infoPath: infoPath,
7383
binPath: binPath,
7484
}
7585

@@ -82,21 +92,32 @@ func (store FileStore) NewUpload(ctx context.Context, info handler.FileInfo) (ha
8292
}
8393

8494
func (store FileStore) GetUpload(ctx context.Context, id string) (handler.Upload, error) {
85-
info := handler.FileInfo{}
86-
data, err := os.ReadFile(store.infoPath(id))
95+
infoPath := store.infoPath(id)
96+
data, err := os.ReadFile(infoPath)
8797
if err != nil {
8898
if os.IsNotExist(err) {
8999
// Interpret os.ErrNotExist as 404 Not Found
90100
err = handler.ErrNotFound
91101
}
92102
return nil, err
93103
}
104+
var info handler.FileInfo
94105
if err := json.Unmarshal(data, &info); err != nil {
95106
return nil, err
96107
}
97108

98-
binPath := store.binPath(id)
99-
infoPath := store.infoPath(id)
109+
// If the info file contains a custom path to the binary file, we use that. If not, we
110+
// fall back to the default value (although the Path property should always be set in recent
111+
// tusd versions).
112+
var binPath string
113+
if info.Storage != nil && info.Storage["Path"] != "" {
114+
// No filepath.Join here because the joining already happened in NewUpload. Duplicate joining
115+
// with relative paths lead to incorrect paths
116+
binPath = info.Storage["Path"]
117+
} else {
118+
binPath = store.defaultBinPath(info.ID)
119+
}
120+
100121
stat, err := os.Stat(binPath)
101122
if err != nil {
102123
if os.IsNotExist(err) {
@@ -127,8 +148,9 @@ func (store FileStore) AsConcatableUpload(upload handler.Upload) handler.Concata
127148
return upload.(*fileUpload)
128149
}
129150

130-
// binPath returns the path to the file storing the binary data.
131-
func (store FileStore) binPath(id string) string {
151+
// defaultBinPath returns the path to the file storing the binary data, if it is
152+
// not customized using the pre-create hook.
153+
func (store FileStore) defaultBinPath(id string) string {
132154
return filepath.Join(store.Path, id)
133155
}
134156

pkg/filestore/filestore_test.go

+68
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,71 @@ func TestDeclareLength(t *testing.T) {
245245
a.EqualValues(100, updatedInfo.Size)
246246
a.Equal(false, updatedInfo.SizeIsDeferred)
247247
}
248+
249+
// TestCustomPath tests whether the upload's destination can be customized.
250+
func TestCustomPath(t *testing.T) {
251+
a := assert.New(t)
252+
253+
tmp, err := os.MkdirTemp("", "tusd-filestore-")
254+
a.NoError(err)
255+
256+
store := FileStore{tmp}
257+
ctx := context.Background()
258+
259+
// Create new upload
260+
upload, err := store.NewUpload(ctx, handler.FileInfo{
261+
ID: "folder1/info",
262+
Size: 42,
263+
Storage: map[string]string{
264+
"Path": "./folder2/bin",
265+
},
266+
})
267+
a.NoError(err)
268+
a.NotEqual(nil, upload)
269+
270+
// Check info without writing
271+
info, err := upload.GetInfo(ctx)
272+
a.NoError(err)
273+
a.EqualValues(42, info.Size)
274+
a.EqualValues(0, info.Offset)
275+
a.Equal(2, len(info.Storage))
276+
a.Equal("filestore", info.Storage["Type"])
277+
a.Equal(filepath.Join(tmp, "./folder2/bin"), info.Storage["Path"])
278+
279+
// Write data to upload
280+
bytesWritten, err := upload.WriteChunk(ctx, 0, strings.NewReader("hello world"))
281+
a.NoError(err)
282+
a.EqualValues(len("hello world"), bytesWritten)
283+
284+
// Check new offset
285+
info, err = upload.GetInfo(ctx)
286+
a.NoError(err)
287+
a.EqualValues(42, info.Size)
288+
a.EqualValues(11, info.Offset)
289+
290+
// Read content
291+
reader, err := upload.GetReader(ctx)
292+
a.NoError(err)
293+
294+
content, err := io.ReadAll(reader)
295+
a.NoError(err)
296+
a.Equal("hello world", string(content))
297+
reader.(io.Closer).Close()
298+
299+
// Check that the output file and info file exist on disk
300+
statInfo, err := os.Stat(filepath.Join(tmp, "folder2/bin"))
301+
a.NoError(err)
302+
a.True(statInfo.Mode().IsRegular())
303+
a.EqualValues(11, statInfo.Size())
304+
statInfo, err = os.Stat(filepath.Join(tmp, "folder1/info.info"))
305+
a.NoError(err)
306+
a.True(statInfo.Mode().IsRegular())
307+
308+
// Terminate upload
309+
a.NoError(store.AsTerminatableUpload(upload).Terminate(ctx))
310+
311+
// Test if upload is deleted
312+
upload, err = store.GetUpload(ctx, info.ID)
313+
a.Equal(nil, upload)
314+
a.Equal(handler.ErrNotFound, err)
315+
}

0 commit comments

Comments
 (0)