Skip to content

Commit c735b50

Browse files
authored
fix(sftp): recover from failed profile migration (#2840)
* fix(sftp): recover from failed profile migration * fix(sftp): migrate folder-state URL keys * fix * fix
1 parent d31a864 commit c735b50

3 files changed

Lines changed: 363 additions & 51 deletions

File tree

‎src/lib/sftpProfiles.js‎

Lines changed: 154 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Url from "utils/Url";
33

44
const PROFILE_PREFIX = "profile-";
55
const MIGRATION_MARKER = "sftpNativeProfileMigration";
6-
const MIGRATION_VERSION = "1";
6+
const MIGRATION_VERSION = "2";
77
const 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

1617
export function getSftpProfileId(url) {
17-
if (!/^sftp:/.test(url || "")) return null;
18-
const { hostname } = Url.decodeUrl(url);
19-
return hostname?.startsWith(PROFILE_PREFIX) ? hostname : null;
18+
if (!/^sftp:/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

2231
export 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
*/
98108
export 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 || !/sftp:/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 (!/^sftp:/.test(value) || getSftpProfileId(value)) {
220+
async function migrateUrl(value, preferredProfileId = null) {
221+
if (!/^sftp:/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(/sftp:\/\/[^\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

211321
function normalizeLegacyValue(value) {

‎src/main.js‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import Contextmenu from "components/contextmenu";
2828
import Sidebar from "components/sidebar";
2929
import tile from "components/tile";
3030
import toast from "components/toast";
31+
import alert from "dialogs/alert";
3132
import confirm from "dialogs/confirm";
3233
import intentHandler, { processPendingIntents } from "handlers/intent";
3334
import keyboardHandler, { keydownState } from "handlers/keyboard";
@@ -322,7 +323,15 @@ async function onDeviceReady() {
322323
await lang.set(settings.value.lang);
323324

324325
acode.setLoadingMessage("Securing SFTP profiles...");
325-
await migrateLegacySftpProfiles();
326+
const sftpMigration = await migrateLegacySftpProfiles();
327+
if (sftpMigration.failures.length) {
328+
for (const failure of sftpMigration.failures) {
329+
logger.log(
330+
"error",
331+
`SFTP profile migration failed for ${failure.username}@${failure.hostname}: ${failure.message}`,
332+
);
333+
}
334+
}
326335

327336
if (settings.value.developerMode) {
328337
try {
@@ -335,6 +344,9 @@ async function onDeviceReady() {
335344

336345
try {
337346
await loadApp();
347+
if (sftpMigration.failures.length) {
348+
showSftpMigrationReport(sftpMigration);
349+
}
338350
} catch (error) {
339351
window.log("error", error);
340352
toast(`Error: ${error.message}`);
@@ -481,6 +493,36 @@ async function onDeviceReady() {
481493
.catch(console.error);
482494
}
483495

496+
function showSftpMigrationReport({
497+
failures,
498+
removedReferences,
499+
recoveredFiles,
500+
}) {
501+
const details = failures
502+
.map(
503+
({ username, hostname, message }) =>
504+
`${escapeHtml(username)}@${escapeHtml(hostname)}: ${escapeHtml(message)}`,
505+
)
506+
.join("<br>");
507+
const recoveryMessage = recoveredFiles
508+
? `<br><br>${recoveredFiles} unsaved remote file${recoveredFiles === 1 ? " was" : "s were"} kept as a recovery tab.`
509+
: "";
510+
511+
alert(
512+
"Some SFTP connections were removed",
513+
`Acode could not move ${failures.length} saved SFTP connection${failures.length === 1 ? "" : "s"} into encrypted storage. The affected connection data and ${removedReferences} saved reference${removedReferences === 1 ? " were" : "s were"} removed so Acode could start safely. Please add the connection${failures.length === 1 ? "" : "s"} again.<br><br>${details}${recoveryMessage}`,
514+
);
515+
}
516+
517+
function escapeHtml(value) {
518+
return String(value)
519+
.replaceAll("&", "&amp;")
520+
.replaceAll("<", "&lt;")
521+
.replaceAll(">", "&gt;")
522+
.replaceAll('"', "&quot;")
523+
.replaceAll("'", "&#039;");
524+
}
525+
484526
async function onLogin() {
485527
try {
486528
const user = await auth.getLoggedInUser();

0 commit comments

Comments
 (0)