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' ;
312import { AdminForthPlugin , AdminForthResourceColumn , AdminForthResource , Filters , IAdminForth , IHttpServer , suggestIfTypo , RateLimiter , AdminUser , HttpExtra } from "adminforth" ;
413import { Readable } from "stream" ;
514import { 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