Skip to content

Commit 6e52388

Browse files
committed
feat(*): implement the base logic for uploading files and handling disks
Still need to implement delete and transfer and write some tests for the repository functionality before this is ready to ship.
1 parent 78213d8 commit 6e52388

File tree

15 files changed

+622
-12
lines changed

15 files changed

+622
-12
lines changed

.eslintrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"node/prefer-global/url": ["error", "always"],
3333
"node/no-unsupported-features/es-syntax": "off",
3434
"@typescript-eslint/explicit-function-return-type": "error",
35+
"@typescript-eslint/no-explicit-any": "off",
3536
"curly": ["error", "all"],
3637
"max-len": ["error", { "code": 140, "ignoreUrls": true }],
3738
"no-undefined": "error",

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ npm install --save @carimus/node-uploads
2020

2121
TODO
2222

23+
## TODO
24+
25+
- [ ] Usage docs
26+
- [ ] Detailed API docs
27+
- [ ] Do URL generation for publicly available disks.
28+
- [ ] Support deleting files was @carimus/node-disks supports it
29+
- [ ] Support temporary URLs (e.g. presigned URLs for S3 buckets) for disks that support it
30+
- [ ] Support transfer logic for transferring single uploads from one disk to another and in bulk.
31+
- [ ] Support `getTemporaryFile` to copy an upload file to the local filesystem tmp directory for direct manipulation
32+
2333
## Development
2434

2535
This project is based on the `carimus-node-ts-package-template`. Check out the

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,5 +74,8 @@
7474
"extends": [
7575
"@commitlint/config-conventional"
7676
]
77+
},
78+
"dependencies": {
79+
"@carimus/node-disks": "^1.2.0"
7780
}
7881
}

src/errors/InvalidConfigError.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export class InvalidConfigError extends Error {
2+
public constructor(missing: string[] = []) {
3+
super(
4+
missing.length === 0
5+
? 'Invalid configuration provided.'
6+
: `Missing required configuration: ${missing.join(', ')}`,
7+
);
8+
}
9+
}

src/errors/PathNotUniqueError.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export class PathNotUniqueError extends Error {
2+
public constructor(
3+
disk: string,
4+
oldPath: string,
5+
newPath: string,
6+
operation: string,
7+
) {
8+
super(
9+
`Generated path is not unique on the same disk during ${operation}: ` +
10+
`'${oldPath}' -> '${newPath}'. If you provided a custom \`generatePath\` function, ensure it` +
11+
`always generates unique paths (i.e. include a timestamp).`,
12+
);
13+
}
14+
}

src/errors/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { InvalidConfigError } from './InvalidConfigError';
2+
export { PathNotUniqueError } from './PathNotUniqueError';

src/index.test.ts

Lines changed: 0 additions & 5 deletions
This file was deleted.

src/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
export function main(): boolean {
2-
console.log('this is a test');
3-
return true;
4-
}
1+
// Export types
2+
export * from './types';
3+
4+
// Export classes and other public interfaces
5+
export * from './lib/Uploads';
6+
export * from './lib/defaults';
7+
export * from './errors';

src/lib/Uploads.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { Readable } from 'stream';
2+
import { DiskManager } from '@carimus/node-disks';
3+
import { InvalidConfigError } from '../errors/InvalidConfigError';
4+
import {
5+
Upload,
6+
UploadedFile,
7+
UploadMeta,
8+
UploadRepository,
9+
UploadsConfig,
10+
} from '../types';
11+
import { trimPath } from './utils';
12+
import { defaultSanitizeFilename, defaultGeneratePath } from './defaults';
13+
import { PathNotUniqueError } from '../errors/PathNotUniqueError';
14+
15+
/**
16+
* A service for handling uploaded files.
17+
*
18+
* TODO Do URL generation for publicly available disks.
19+
* TODO Support deleting files was @carimus/node-disks supports it
20+
* TODO Support temporary URLs (e.g. presigned URLs for S3 buckets) for disks that support it
21+
* TODO Support transfer logic for transferring single uploads from one disk to another and in bulk.
22+
* TODO Support `getTemporaryFile` to copy an upload file to the local filesystem tmp directory for direct manipulation
23+
*/
24+
export class Uploads {
25+
private config: UploadsConfig;
26+
private repository: UploadRepository;
27+
private disks: DiskManager;
28+
29+
public constructor(config: UploadsConfig) {
30+
this.config = config;
31+
this.repository = config.repository;
32+
if (typeof config.disks === 'object') {
33+
this.disks =
34+
config.disks instanceof DiskManager
35+
? config.disks
36+
: new DiskManager(config.disks);
37+
} else {
38+
throw new InvalidConfigError(['disks']);
39+
}
40+
}
41+
42+
/**
43+
* Sanitize a client provided filename before storing on the disk.
44+
*
45+
* @param uploadedAs
46+
*/
47+
public sanitizeFilename(uploadedAs: string): string {
48+
return this.config.sanitizeFilename
49+
? this.config.sanitizeFilename(uploadedAs)
50+
: defaultSanitizeFilename(uploadedAs);
51+
}
52+
53+
/**
54+
* Generate a timestamped unique path and filename based on the client-provided filename.
55+
*
56+
* @param sanitizedUploadedAs
57+
*/
58+
public generatePath(sanitizedUploadedAs: string): string {
59+
return this.config.generatePath
60+
? this.config.generatePath(sanitizedUploadedAs)
61+
: defaultGeneratePath(sanitizedUploadedAs);
62+
}
63+
64+
/**
65+
* Get the full final storage path on the disk for an upload being stored based off of the sanitized filename
66+
* and the configuration path prefix.
67+
*
68+
* @param sanitizedUploadedAs
69+
*/
70+
public generateStoragePath(sanitizedUploadedAs: string): string {
71+
const path = this.generatePath(sanitizedUploadedAs);
72+
return `/${trimPath(this.config.pathPrefix || '')}/${trimPath(path)}`;
73+
}
74+
75+
/**
76+
* Get the default disk name based on config.
77+
*/
78+
private getDefaultDiskName(): string {
79+
return this.config.defaultDisk || 'default';
80+
}
81+
82+
/**
83+
* Take the file data and raw client-provided filename and place that file on the disk in the proper
84+
* location by sanitizing the filename and generating a unique path for it.
85+
*
86+
* @param fileData
87+
* @param uploadedAs
88+
*/
89+
public async place(
90+
fileData: Buffer | Readable,
91+
uploadedAs: string,
92+
): Promise<UploadedFile> {
93+
// Generate a filename and path.
94+
const sanitizedUploadedAs = this.sanitizeFilename(uploadedAs);
95+
const path = this.generateStoragePath(sanitizedUploadedAs);
96+
// Grab a disk instance and write the data to the disk.
97+
const diskName = this.getDefaultDiskName();
98+
const disk = this.disks.getDisk(diskName);
99+
await disk.write(path, fileData);
100+
// Return a record representing the uploaded file and where it lives on the disk.
101+
return {
102+
disk: diskName,
103+
path,
104+
uploadedAs: sanitizedUploadedAs,
105+
};
106+
}
107+
108+
/**
109+
* Take an uploaded file and copy it to a new unique generated location on the default disk, regardless of the
110+
* source disk.
111+
*
112+
* TODO Once @carimus/node-disks supports the copy operation, use that when oldDisk === newDisk
113+
*
114+
* @param originalFile
115+
*/
116+
public async copy(originalFile: UploadedFile): Promise<UploadedFile> {
117+
// Get the current disk
118+
const newDiskName = this.getDefaultDiskName();
119+
120+
// Clone the original file upload info, setting the new disk and regenerating the path.
121+
const newFile: UploadedFile = {
122+
...originalFile,
123+
disk: newDiskName,
124+
path: this.generateStoragePath(originalFile.uploadedAs),
125+
};
126+
127+
// Throw if the locations are exactly the same.
128+
if (
129+
originalFile.disk === newFile.disk &&
130+
originalFile.path === newFile.path
131+
) {
132+
throw new PathNotUniqueError(
133+
originalFile.disk,
134+
originalFile.path,
135+
newFile.path,
136+
'copy',
137+
);
138+
}
139+
140+
// Resolve the disks
141+
const originalDisk = this.disks.getDisk(originalFile.disk);
142+
const newDisk = this.disks.getDisk(newFile.disk);
143+
144+
// Perform the copy
145+
await newDisk.write(
146+
originalFile.path,
147+
await originalDisk.createReadStream(newFile.path),
148+
);
149+
150+
// Return the newly uploaded file
151+
return newFile;
152+
}
153+
154+
/**
155+
* Place an uploaded file on the disk and create it in the repository.
156+
*
157+
* @param fileData
158+
* @param uploadedAs
159+
* @param meta
160+
*/
161+
public async upload(
162+
fileData: Buffer | Readable,
163+
uploadedAs: string,
164+
meta: UploadMeta = {},
165+
): Promise<Upload> {
166+
const uploadedFile = await this.place(fileData, uploadedAs);
167+
return this.repository.create(uploadedFile, meta);
168+
}
169+
170+
/**
171+
* Update an existing upload with a new uploaded file by placing the new file and deleting the old file, updating
172+
* the upload itself in the repository.
173+
*
174+
* @param upload
175+
* @param fileData
176+
* @param uploadedAs
177+
* @param meta
178+
*/
179+
public async update(
180+
upload: Upload,
181+
fileData: Buffer | Readable,
182+
uploadedAs: string,
183+
meta: UploadMeta = {},
184+
): Promise<Upload> {
185+
const uploadedFile = await this.place(fileData, uploadedAs);
186+
return this.repository.update(upload, uploadedFile, meta);
187+
}
188+
189+
/**
190+
* Duplicate an existing upload to the default disk no matter what the current uploads disk is and create it in
191+
* the repository.
192+
*
193+
* This will regenerate the path even if the upload is remaining on the same disk.
194+
*
195+
* The original upload will not be touched.
196+
*
197+
*
198+
* @param original
199+
* @param meta
200+
*/
201+
public async duplicate(
202+
original: Upload,
203+
meta: UploadMeta = {},
204+
): Promise<Upload> {
205+
// Ask the repository for info on where and how the original upload file is stored.
206+
const originalFile = await this.repository.getUploadedFileInfo(
207+
original,
208+
);
209+
210+
// Copy the original file to the new file location.
211+
const newFile = await this.copy(originalFile);
212+
213+
// Store the new upload in the repository and return it
214+
return this.repository.create(newFile, meta);
215+
}
216+
217+
// /**
218+
// * Delete an uploaded file from the disk.
219+
// *
220+
// * TODO Actually delete off the disk once @carimus/node-disks implements the operation 😅
221+
// *
222+
// * @param upload
223+
// * @param onlyFile
224+
// */
225+
// public async delete(upload: Upload, onlyFile: boolean = false): Promise<void> {
226+
// // Ask the repository for info on where and how the upload file is stored.
227+
// const file = await this.repository.getUploadedFileInfo(upload);
228+
//
229+
// // Resolve the disk for the file
230+
// const disk = this.disks.getDisk(file.disk);
231+
//
232+
// // Delete the upload in the repository before deleting it on the disk
233+
// if (!onlyFile) {
234+
// await this.repository.delete(upload);
235+
// }
236+
//
237+
// // Delete the file on the disk (See jsDoc)
238+
// await disk.delete(file.path);
239+
// }
240+
}

src/lib/defaults.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
defaultSanitizeFilename,
3+
defaultGeneratePathForInstant,
4+
} from './defaults';
5+
6+
test("defaultSanitizeFilename doesn't modify string with only basic allowed characters", () => {
7+
expect(defaultSanitizeFilename('foo_123.txt')).toBe('foo_123.txt');
8+
});
9+
10+
test('defaultSanitizeFilename handles whitespace on the ends', () => {
11+
expect(defaultSanitizeFilename(' foo_123.txt ')).toBe('foo_123.txt');
12+
});
13+
14+
test('defaultSanitizeFilename replaces invalid characters', () => {
15+
expect(defaultSanitizeFilename('foo_1 2-3.txt')).toBe(
16+
'foo_1______2-3.txt',
17+
);
18+
expect(defaultSanitizeFilename('test*&^%$)¶§∞¶•∆µ.abc••')).toBe(
19+
'test__________________________.abc____',
20+
);
21+
});
22+
23+
test('defaultGeneratePathForInstant generates paths in the format YYYY/MM/DD/HHmmssuuu-name.ext', () => {
24+
expect(
25+
defaultGeneratePathForInstant(
26+
new Date('2019-04-04T23:52:26.473Z'),
27+
'test.png',
28+
),
29+
).toBe('2019/04/04/235226473-test.png');
30+
expect(
31+
defaultGeneratePathForInstant(
32+
new Date('1992-12-04T05:02:00.000Z'),
33+
'test.png',
34+
),
35+
).toBe('1992/12/04/050200000-test.png');
36+
});

0 commit comments

Comments
 (0)