Skip to content

Commit 1f650d2

Browse files
committed
feat: add methods for generating upload URLs and committing to records
1 parent 7f1cf0c commit 1f650d2

2 files changed

Lines changed: 397 additions & 1 deletion

File tree

index.ts

Lines changed: 294 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11

2-
import { PluginOptions, UploadFromBufferParams, UploadFromBufferToExistingRecordParams } from './types.js';
2+
import {
3+
PluginOptions,
4+
UploadFromBufferParams,
5+
UploadFromBufferToExistingRecordParams,
6+
GetUploadUrlForExistingRecordParams,
7+
GetUploadUrlForNewRecordParams,
8+
CommitUrlToUpdateExistingRecordParams,
9+
CommitUrlToNewRecordParams,
10+
GetUploadUrlParams,
11+
} from './types.js';
312
import { AdminForthPlugin, AdminForthResourceColumn, AdminForthResource, Filters, IAdminForth, IHttpServer, suggestIfTypo, RateLimiter, AdminUser, HttpExtra } from "adminforth";
413
import { Readable } from "stream";
514
import { randomUUID } from "crypto";
@@ -832,4 +841,288 @@ export default class UploadPlugin extends AdminForthPlugin {
832841
};
833842
}
834843

844+
/**
845+
* Generates a new signed upload URL for future uploading from the frontend via a direct upload (e.g. using fetch + FormData).
846+
*
847+
* After the upload, file still will be marked for auto-deletion after short time, so to keep it permanently,
848+
* you need to either:
849+
* * Use commitUrlToExistingRecord to commit the URL to an existing record. This will replace the path in the existing record and will do a cleanup of the old
850+
* file pointed in this path column.
851+
* * If you want to create a new record with this URL, you can call commitUrlToNewRecord, which will create a new record and set the path column to the uploaded file path.
852+
* * Write URL to special field called pathColumnName so afterSave hook installed by the plugin will automatically mark as not candidate for auto-deletion
853+
*
854+
* ```ts
855+
* const file = input.files[0];
856+
*
857+
* // 1) Ask your backend to call getUploadUrlForExistingRecord
858+
* const { uploadUrl, filePath, uploadExtraParams } = await fetch('/api/uploads/get-url-existing', {
859+
* method: 'POST',
860+
* headers: { 'Content-Type': 'application/json' },
861+
* body: JSON.stringify({
862+
* recordId,
863+
* filename: file.name,
864+
* contentType: file.type,
865+
* size: file.size,
866+
* }),
867+
* }).then(r => r.json());
868+
*
869+
* const formData = new FormData();
870+
* if (uploadExtraParams) {
871+
* Object.entries(uploadExtraParams).forEach(([key, value]) => {
872+
* formData.append(key, value as string);
873+
* });
874+
* }
875+
* formData.append('file', file);
876+
*
877+
* // 2) Direct upload from the browser to storage (multipart/form-data)
878+
* const uploadResp = await fetch(uploadUrl, {
879+
* method: 'POST',
880+
* body: formData,
881+
* });
882+
* if (!uploadResp.ok) {
883+
* throw new Error('Upload failed');
884+
* }
885+
*
886+
* // 3) Tell your backend to commit the URL to the record e.g. from rest API call
887+
* await fetch('/api/uploads/commit-existing', {
888+
* method: 'POST',
889+
* headers: { 'Content-Type': 'application/json' },
890+
* body: JSON.stringify({ recordId, filePath }),
891+
* });
892+
* ```
893+
*/
894+
async getUploadUrl({
895+
recordId,
896+
filename,
897+
contentType,
898+
size,
899+
}: GetUploadUrlParams): Promise<{
900+
uploadUrl: string;
901+
filePath: string;
902+
uploadExtraParams?: Record<string, string>;
903+
pathColumnName: string;
904+
}> {
905+
if (!filename || !contentType) {
906+
throw new Error('filename and contentType are required');
907+
}
908+
if (!this.resourceConfig) {
909+
throw new Error('resourceConfig is not initialized yet');
910+
}
911+
912+
const pkColumn = this.resourceConfig.columns.find((column: any) => column.primaryKey);
913+
const pkName = pkColumn?.name;
914+
if (!pkName) {
915+
throw new Error('Primary key column not found in resource configuration');
916+
}
917+
918+
let existingRecord: any = undefined;
919+
if (recordId !== undefined && recordId !== null) {
920+
existingRecord = await this.adminforth
921+
.resource(this.resourceConfig.resourceId)
922+
.get([Filters.EQ(pkName, recordId)]);
923+
924+
if (!existingRecord) {
925+
throw new Error(`Record with id ${recordId} not found`);
926+
}
927+
}
928+
929+
const lastDotIndex = filename.lastIndexOf('.');
930+
if (lastDotIndex === -1) {
931+
throw new Error('filename must contain an extension');
932+
}
933+
934+
const originalExtension = filename.substring(lastDotIndex + 1).toLowerCase();
935+
const originalFilename = filename.substring(0, lastDotIndex);
936+
937+
if (
938+
this.options.allowedFileExtensions &&
939+
!this.options.allowedFileExtensions.includes(originalExtension)
940+
) {
941+
throw new Error(
942+
`File extension "${originalExtension}" is not allowed, allowed extensions are: ${this.options.allowedFileExtensions.join(
943+
', ',
944+
)}`,
945+
);
946+
}
947+
948+
if (size != null && this.options.maxFileSize && size > this.options.maxFileSize) {
949+
throw new Error(
950+
`File size ${size} is too large. Maximum allowed size is ${this.options.maxFileSize}`,
951+
);
952+
}
953+
954+
const existingValue = existingRecord?.[this.options.pathColumnName];
955+
const existingPaths = existingValue ? this.normalizePaths(existingValue) : undefined;
956+
957+
const filePath: string = this.options.filePath({
958+
originalFilename,
959+
originalExtension,
960+
contentType,
961+
record: existingRecord,
962+
});
963+
964+
if (filePath.startsWith('/')) {
965+
throw new Error(
966+
's3Path should not start with /, please adjust s3path function to not return / at the start of the path',
967+
);
968+
}
969+
970+
if (existingPaths && existingPaths.includes(filePath)) {
971+
throw new Error(
972+
'New file path cannot be the same as existing path to avoid caching issues',
973+
);
974+
}
975+
976+
const { uploadUrl, uploadExtraParams } = await this.options.storageAdapter.getUploadSignedUrl(
977+
filePath,
978+
contentType,
979+
1800,
980+
);
981+
982+
return {
983+
uploadUrl,
984+
filePath,
985+
uploadExtraParams,
986+
pathColumnName: this.options.pathColumnName,
987+
};
988+
}
989+
990+
/**
991+
* Commits a previously generated upload URL to an existing record.
992+
*
993+
* Never call this method from edit afterSave and beforeSave hooks of the same resource,
994+
* as it would create infinite loop of record updates.
995+
* You should call this method from your own custom API endpoint after the upload is done.
996+
*/
997+
async commitUrlToUpdateExistingRecord({
998+
recordId,
999+
filePath,
1000+
adminUser,
1001+
extra,
1002+
}: CommitUrlToUpdateExistingRecordParams): Promise<{ path: string; previewUrl: string }> {
1003+
if (recordId === undefined || recordId === null) {
1004+
throw new Error('recordId is required');
1005+
}
1006+
if (!filePath) {
1007+
throw new Error('filePath is required');
1008+
}
1009+
if (!this.resourceConfig) {
1010+
throw new Error('resourceConfig is not initialized yet');
1011+
}
1012+
1013+
const pkColumn = this.resourceConfig.columns.find((column: any) => column.primaryKey);
1014+
const pkName = pkColumn?.name;
1015+
if (!pkName) {
1016+
throw new Error('Primary key column not found in resource configuration');
1017+
}
1018+
1019+
const existingRecord = await this.adminforth
1020+
.resource(this.resourceConfig.resourceId)
1021+
.get([Filters.EQ(pkName, recordId)]);
1022+
1023+
if (!existingRecord) {
1024+
throw new Error(`Record with id ${recordId} not found`);
1025+
}
1026+
1027+
const existingValue = existingRecord[this.options.pathColumnName];
1028+
const existingPaths = this.normalizePaths(existingValue);
1029+
1030+
if (existingPaths.includes(filePath)) {
1031+
throw new Error(
1032+
'New file path cannot be the same as existing path to avoid caching issues',
1033+
);
1034+
}
1035+
1036+
const { error: updateError } = await this.adminforth.updateResourceRecord({
1037+
resource: this.resourceConfig,
1038+
recordId,
1039+
oldRecord: existingRecord,
1040+
adminUser,
1041+
extra,
1042+
updates: {
1043+
[this.options.pathColumnName]: filePath,
1044+
},
1045+
} as any);
1046+
1047+
if (updateError) {
1048+
try {
1049+
await this.markKeyForDeletion(filePath);
1050+
} catch (e) {
1051+
// best-effort cleanup, ignore error
1052+
}
1053+
throw new Error(`Error updating record after upload: ${updateError}`);
1054+
}
1055+
1056+
let previewUrl: string;
1057+
if (this.options.preview?.previewUrl) {
1058+
previewUrl = this.options.preview.previewUrl({ filePath });
1059+
} else {
1060+
previewUrl = await this.options.storageAdapter.getDownloadUrl(filePath, 1800);
1061+
}
1062+
1063+
return {
1064+
path: filePath,
1065+
previewUrl,
1066+
};
1067+
}
1068+
1069+
/**
1070+
* Commits a previously generated upload URL to a new record.
1071+
*
1072+
* Never call this method from create afterSave and beforeSave hooks of the same resource,
1073+
* as it would create infinite loop of record creations.
1074+
*
1075+
* You should call this method from your own custom API endpoint after the upload is done.
1076+
*/
1077+
async commitUrlToNewRecord({
1078+
filePath,
1079+
adminUser,
1080+
extra,
1081+
recordAttributes,
1082+
}: CommitUrlToNewRecordParams): Promise<{ path: string; previewUrl: string; newRecordPk: any }> {
1083+
if (!filePath) {
1084+
throw new Error('filePath is required');
1085+
}
1086+
if (!this.resourceConfig) {
1087+
throw new Error('resourceConfig is not initialized yet');
1088+
}
1089+
1090+
// Mark this key as used so lifecycle rules do not delete it.
1091+
await this.markKeyForNotDeletion(filePath);
1092+
1093+
const { error: createError, createdRecord, newRecordId }: any =
1094+
await this.adminforth.createResourceRecord({
1095+
resource: this.resourceConfig,
1096+
record: { ...(recordAttributes ?? {}), [this.options.pathColumnName]: filePath },
1097+
adminUser,
1098+
extra,
1099+
});
1100+
1101+
if (createError) {
1102+
try {
1103+
await this.markKeyForDeletion(filePath);
1104+
} catch (e) {
1105+
// best-effort cleanup, ignore error
1106+
}
1107+
throw new Error(`Error creating record after upload: ${createError}`);
1108+
}
1109+
1110+
const pkColumn = this.resourceConfig.columns.find((column: any) => column.primaryKey);
1111+
const pkName = pkColumn?.name;
1112+
const newRecordPk = newRecordId ?? (pkName && createdRecord ? createdRecord[pkName] : undefined);
1113+
1114+
let previewUrl: string;
1115+
if (this.options.preview?.previewUrl) {
1116+
previewUrl = this.options.preview.previewUrl({ filePath });
1117+
} else {
1118+
previewUrl = await this.options.storageAdapter.getDownloadUrl(filePath, 1800);
1119+
}
1120+
1121+
return {
1122+
path: filePath,
1123+
previewUrl,
1124+
newRecordPk,
1125+
};
1126+
}
1127+
8351128
}

0 commit comments

Comments
 (0)