@@ -3,7 +3,7 @@ import Url from "utils/Url";
33
44const PROFILE_PREFIX = "profile-" ;
55const MIGRATION_MARKER = "sftpNativeProfileMigration" ;
6- const MIGRATION_VERSION = "1 " ;
6+ const MIGRATION_VERSION = "2 " ;
77const MIGRATED_STORAGE_KEYS = [
88 "storageList" ,
99 "folders" ,
@@ -12,11 +12,20 @@ const MIGRATED_STORAGE_KEYS = [
1212 "recentFolders" ,
1313 "fileBrowserState" ,
1414] ;
15+ const REMOVE_VALUE = Symbol ( "remove-value" ) ;
1516
1617export function getSftpProfileId ( url ) {
17- if ( ! / ^ s f t p : / . test ( url || "" ) ) return null ;
18- const { hostname } = Url . decodeUrl ( url ) ;
19- return hostname ?. startsWith ( PROFILE_PREFIX ) ? hostname : null ;
18+ if ( ! / ^ s f t p : / i. test ( url || "" ) ) return null ;
19+ try {
20+ const { hostname, username, password, query } = Url . decodeUrl ( url ) ;
21+ const hasLegacyCredentials =
22+ username || password || query ?. keyFile || query ?. passPhrase ;
23+ return hostname ?. startsWith ( PROFILE_PREFIX ) && ! hasLegacyCredentials
24+ ? hostname
25+ : null ;
26+ } catch {
27+ return null ;
28+ }
2029}
2130
2231export function createSftpProfileUrl ( profileId , pathname = "/" ) {
@@ -93,28 +102,42 @@ export function deleteSftpProfile(profileId) {
93102
94103/**
95104 * Moves legacy credential-bearing SFTP URLs into encrypted native profiles.
96- * Migration fails closed before third-party plugins load if encryption is unavailable.
105+ * Profiles that cannot be encrypted are removed before third-party plugins load.
106+ * Unsaved editor tabs keep their cache and are restored as local recovery tabs.
97107 */
98108export async function migrateLegacySftpProfiles ( ) {
99- if ( localStorage . getItem ( MIGRATION_MARKER ) === MIGRATION_VERSION ) return ;
109+ if ( localStorage . getItem ( MIGRATION_MARKER ) === MIGRATION_VERSION ) {
110+ return { failures : [ ] , removedReferences : 0 , recoveredFiles : 0 } ;
111+ }
100112
101113 const profileCache = new Map ( ) ;
102114 const copiedKeys = new Set ( ) ;
103- let migrationError = null ;
115+ const failures = [ ] ;
116+ let removedReferences = 0 ;
117+ let recoveredFiles = 0 ;
104118
105119 for ( const storageKey of MIGRATED_STORAGE_KEYS ) {
106120 const raw = localStorage . getItem ( storageKey ) ;
107- if ( ! raw ) continue ;
121+ if ( ! raw || ! / s f t p : / i . test ( raw ) ) continue ;
108122 let value ;
123+ let isJson = true ;
109124 try {
110125 value = JSON . parse ( raw ) ;
111126 } catch {
112- continue ;
127+ value = raw ;
128+ isJson = false ;
113129 }
114130
115- const migrated = await migrateValue ( value ) ;
131+ const migrated = await migrateValue ( value , storageKey ) ;
116132 if ( migrated . changed ) {
117- localStorage . setItem ( storageKey , JSON . stringify ( migrated . value ) ) ;
133+ if ( migrated . value === REMOVE_VALUE ) {
134+ localStorage . removeItem ( storageKey ) ;
135+ } else {
136+ localStorage . setItem (
137+ storageKey ,
138+ isJson ? JSON . stringify ( migrated . value ) : migrated . value ,
139+ ) ;
140+ }
118141 }
119142 }
120143
@@ -127,51 +150,95 @@ export async function migrateLegacySftpProfiles() {
127150 }
128151 }
129152
130- if ( migrationError ) {
131- throw new Error (
132- "SFTP credentials could not be moved to encrypted native storage" ,
133- { cause : migrationError } ,
134- ) ;
135- }
136-
137153 localStorage . setItem ( MIGRATION_MARKER , MIGRATION_VERSION ) ;
154+ return { failures, removedReferences, recoveredFiles } ;
138155
139- async function migrateValue ( value ) {
156+ async function migrateValue ( value , storageKey , folderProfileId = null ) {
140157 if ( typeof value === "string" ) return migrateUrl ( value ) ;
141158 if ( Array . isArray ( value ) ) {
142159 let changed = false ;
143160 const next = [ ] ;
144161 for ( const item of value ) {
145- const migrated = await migrateValue ( item ) ;
162+ const migrated = await migrateValue ( item , storageKey , folderProfileId ) ;
146163 changed ||= migrated . changed ;
147- next . push ( migrated . value ) ;
164+ if ( migrated . value !== REMOVE_VALUE ) next . push ( migrated . value ) ;
148165 }
149166 return { value : next , changed } ;
150167 }
151168 if ( value && typeof value === "object" ) {
152169 let changed = false ;
153170 const next = { } ;
154- for ( const [ key , item ] of Object . entries ( value ) ) {
155- const migrated = await migrateValue ( item ) ;
171+ let recoverFile = false ;
172+ let nestedFolderProfileId =
173+ storageKey === "folders"
174+ ? getSftpProfileId ( value . url ) || folderProfileId
175+ : null ;
176+ const entries = Object . entries ( value ) ;
177+ if ( storageKey === "folders" ) {
178+ const urlIndex = entries . findIndex ( ( [ key ] ) => key === "url" ) ;
179+ if ( urlIndex > 0 ) entries . unshift ( entries . splice ( urlIndex , 1 ) [ 0 ] ) ;
180+ }
181+ for ( const [ key , item ] of entries ) {
182+ const migratedKey = await migrateUrl ( key , folderProfileId ) ;
183+ changed ||= migratedKey . changed ;
184+ if ( migratedKey . value === REMOVE_VALUE ) continue ;
185+
186+ const migrated = await migrateValue (
187+ item ,
188+ storageKey ,
189+ nestedFolderProfileId ,
190+ ) ;
156191 changed ||= migrated . changed ;
157- next [ key ] = migrated . value ;
192+ if ( migrated . value === REMOVE_VALUE ) {
193+ if ( key === "uri" && storageKey === "files" && value . isUnsaved ) {
194+ recoverFile = true ;
195+ continue ;
196+ }
197+ if ( key === "url" || key === "uri" ) {
198+ return { value : REMOVE_VALUE , changed : true } ;
199+ }
200+ continue ;
201+ }
202+ if ( storageKey === "folders" && key === "url" ) {
203+ nestedFolderProfileId =
204+ getSftpProfileId ( migrated . value ) || nestedFolderProfileId ;
205+ }
206+ next [ migratedKey . value ] = migrated . value ;
207+ }
208+ if ( recoverFile ) {
209+ next . uri = null ;
210+ next . filename = `Recovered ${ value . filename || "remote file" } ` ;
211+ next . isUnsaved = true ;
212+ next . deletedFile = true ;
213+ recoveredFiles ++ ;
158214 }
159215 return { value : next , changed } ;
160216 }
161217 return { value, changed : false } ;
162218 }
163219
164- async function migrateUrl ( value ) {
165- if ( ! / ^ s f t p : / . test ( value ) || getSftpProfileId ( value ) ) {
220+ async function migrateUrl ( value , preferredProfileId = null ) {
221+ if ( ! / ^ s f t p : / i . test ( value ) || getSftpProfileId ( value ) ) {
166222 return { value, changed : false } ;
167223 }
168224
225+ let credentials ;
169226 try {
227+ credentials = Url . decodeUrl ( value ) ;
170228 const { username, password, hostname, pathname, port, query } =
171- Url . decodeUrl ( value ) ;
172- if ( ! hostname || ! username ) return { value, changed : false } ;
229+ credentials ;
230+ if ( ! hostname || ! username ) {
231+ throw new Error ( "The saved SFTP address is incomplete" ) ;
232+ }
173233 const keyFile = normalizeLegacyValue ( query ?. keyFile ) ;
174234 const passPhrase = normalizeLegacyValue ( query ?. passPhrase ) ;
235+ if ( keyFile ) copiedKeys . add ( keyFile ) ;
236+ if ( preferredProfileId ) {
237+ return {
238+ value : createSftpProfileUrl ( preferredProfileId , pathname || "/" ) ,
239+ changed : true ,
240+ } ;
241+ }
175242 const authType = keyFile ? "key" : "password" ;
176243 const signature = JSON . stringify ( {
177244 hostname,
@@ -182,30 +249,73 @@ export async function migrateLegacySftpProfiles() {
182249 passPhrase,
183250 } ) ;
184251
185- let profileId = profileCache . get ( signature ) ;
186- if ( ! profileId ) {
187- profileId = await saveSftpProfile ( {
188- hostname,
189- port : port || 22 ,
190- username,
191- authType,
192- password : password || "" ,
193- keyFile,
194- passPhrase,
195- } ) ;
196- profileCache . set ( signature , profileId ) ;
197- if ( keyFile ) copiedKeys . add ( keyFile ) ;
252+ let cachedProfile = profileCache . get ( signature ) ;
253+ if ( ! cachedProfile ) {
254+ try {
255+ const profileId = await saveSftpProfile ( {
256+ hostname,
257+ port : port || 22 ,
258+ username,
259+ authType,
260+ password : password || "" ,
261+ keyFile,
262+ passPhrase,
263+ } ) ;
264+ cachedProfile = { profileId } ;
265+ } catch ( error ) {
266+ cachedProfile = {
267+ error,
268+ failure : createFailure ( error , credentials ) ,
269+ } ;
270+ failures . push ( cachedProfile . failure ) ;
271+ }
272+ profileCache . set ( signature , cachedProfile ) ;
273+ }
274+ if ( cachedProfile . error ) {
275+ removedReferences ++ ;
276+ return { value : REMOVE_VALUE , changed : true } ;
198277 }
199278 return {
200- value : createSftpProfileUrl ( profileId , pathname || "/" ) ,
279+ value : createSftpProfileUrl ( cachedProfile . profileId , pathname || "/" ) ,
201280 changed : true ,
202281 } ;
203282 } catch ( error ) {
204283 console . warn ( "Could not migrate legacy SFTP URL" , error ) ;
205- migrationError ||= error ;
206- return { value, changed : false } ;
284+ const failure = createFailure ( error , credentials ) ;
285+ failures . push ( failure ) ;
286+ removedReferences ++ ;
287+ return { value : REMOVE_VALUE , changed : true } ;
288+ }
289+ }
290+ }
291+
292+ function createFailure ( error , credentials = { } ) {
293+ credentials ||= { } ;
294+ const sensitiveValues = [
295+ credentials . password ,
296+ credentials . query ?. passPhrase ,
297+ credentials . query ?. keyFile ,
298+ ] . filter ( Boolean ) ;
299+ return {
300+ hostname : credentials . hostname || "unknown host" ,
301+ username : credentials . username || "unknown user" ,
302+ message : sanitizeError ( error , sensitiveValues ) ,
303+ } ;
304+ }
305+
306+ function sanitizeError ( error , sensitiveValues ) {
307+ let message = error ?. message || String ( error || "Unknown migration error" ) ;
308+ if ( error ?. cause ) {
309+ const cause = error . cause ?. message || String ( error . cause ) ;
310+ if ( cause && ! message . includes ( cause ) ) message += `: ${ cause } ` ;
311+ }
312+ message = message . replace ( / s f t p : \/ \/ [ ^ \s " ' < > ] + / gi, "sftp://[redacted]" ) ;
313+ for ( const value of sensitiveValues ) {
314+ for ( const secret of [ String ( value ) , encodeURIComponent ( String ( value ) ) ] ) {
315+ if ( secret ) message = message . split ( secret ) . join ( "[redacted]" ) ;
207316 }
208317 }
318+ return message ;
209319}
210320
211321function normalizeLegacyValue ( value ) {
0 commit comments