Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions client/www/app/intern/restore/content.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
LandingContainer,
LegacyNav,
Expand Down Expand Up @@ -36,6 +36,43 @@ type RestoreJob = {

const TERMINAL = new Set(['completed', 'errored', 'cancelled']);

// Reads config.json (the first entry) out of a backup zip in the browser.
// Returns the parsed config, or null if the zip can't be read/parsed (a bad zip
// is left for the server to reject on upload).
async function readBackupConfig(file: File): Promise<any | null> {
try {
const { ZipReader, BlobReader, TextWriter } = await import(
'@zip.js/zip.js'
);
const reader = new ZipReader(new BlobReader(file));
try {
const entries = await reader.getEntries();
const entry = entries.find((e) => e.filename === 'config.json');
if (!entry || entry.directory) return null;
return JSON.parse(await entry.getData(new TextWriter()));
} finally {
await reader.close();
}
} catch {
return null;
}
}

// Backups written before we fixed a bug that dropped permission rules from the
// config lack the `appId` key (added by the same change that fixed the bug), so
// its absence means the rules may not have been captured.
function missingRulesWarning(config: any): string | null {
if (config && config.appId == null) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return (
'This backup may be missing your permission rules. After the restore ' +
'finishes, open the Permissions tab on the new app and re-add your rules ' +
'if they’re missing.\n\n' +
'The bug was fixed in backups created after August 12, 2026.'
);
}
return null;
}

// A live restore bumps `updated_at` every second (see report-progress! on the
// server). If a non-terminal job hasn't updated in this long, the machine
// running it probably died/restarted -- the row will never finalize on its own,
Expand All @@ -62,6 +99,20 @@ function RestoreDialog({
const [title, setTitle] = useState('');
const [uploading, setUploading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [rulesWarning, setRulesWarning] = useState<string | null>(null);
const fileChangeToken = useRef(0);

async function onFileChange(next: File | null) {
const changeToken = ++fileChangeToken.current;
setFile(next);
setRulesWarning(null);
if (next) {
const config = await readBackupConfig(next);
if (changeToken === fileChangeToken.current) {
setRulesWarning(missingRulesWarning(config));
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

async function onSubmit(e: React.FormEvent) {
e.preventDefault();
Expand Down Expand Up @@ -94,6 +145,7 @@ function RestoreDialog({
}
successToast('Restore started');
setFile(null);
setRulesWarning(null);
setAppId('');
setTitle('');
dialog.onClose();
Expand Down Expand Up @@ -147,12 +199,18 @@ function RestoreDialog({
<input
type="file"
accept=".zip,application/zip"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="hidden"
onChange={(e) => onFileChange(e.target.files?.[0] ?? null)}
className="sr-only"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</label>
</div>

{rulesWarning && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm whitespace-pre-line text-amber-800">
{rulesWarning}
</div>
)}

<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-gray-700">
App id (optional)
Expand Down
17 changes: 14 additions & 3 deletions server/src/instant/backup.clj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
[instant.jdbc.sql :as sql]
[instant.jdbc.wal :as wal]
[instant.model.app :as app-model]
[instant.model.app-authorized-redirect-origin :as redirect-origin-model]
[instant.model.app-file :as app-file-model]
[instant.model.rule :as rule-model]
[instant.model.schema :as schema]
Expand Down Expand Up @@ -317,10 +318,13 @@
schema/schema->defs))

(defn get-rules [conn app-id]
(rule-model/get-by-app-id conn app-id))
(rule-model/get-by-app-id conn {:app-id app-id}))

(defn get-webhooks [conn app-id]
(webhook-model/get-all-by-app-id conn app-id))
(webhook-model/get-all-by-app-id conn {:app-id app-id}))

(defn get-redirect-origins [conn app-id]
(redirect-origin-model/get-all-for-app conn {:app-id app-id}))

(def app-email-templates-q
(uhsql/preformat {:select [[:t.email-type :type] :t.body :t.name :t.subject :s.email]
Expand Down Expand Up @@ -379,7 +383,14 @@
(select-keys webhook [:namespaces :sink :status :actions]))
(get-webhooks query-conn app-id))
:emailTemplates (get-app-email-templates query-conn app-id)
:title (str (:title (app-model/get-by-id query-conn {:id app-id})))}
:authorizedRedirectOrigins (mapv (fn [origin]
(select-keys origin [:service :params]))
(get-redirect-origins query-conn app-id))
:title (str (:title (app-model/get-by-id query-conn {:id app-id})))
:appId app-id
:backupAt backup-at
:isn isn
:description (or description "Automated Daily Snapshot")}
^bytes config-bytes (json/->json-bytes config)
ba (Zstd/compress config-bytes compression-level)
;; Total uncompressed bytes that the client will end up with in the
Expand Down
13 changes: 12 additions & 1 deletion server/src/instant/restore.clj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
[instant.jdbc.sql :as sql]
[instant.jdbc.wal :as wal]
[instant.model.app :as app-model]
[instant.model.app-authorized-redirect-origin :as redirect-origin-model]
[instant.model.app-email-template :as app-email-template-model]
[instant.model.instant-user :as instant-user-model]
[instant.model.org :as org-model]
Expand Down Expand Up @@ -106,8 +107,17 @@
:name name
:body body})))

(defn restore-redirect-origins!
"Restores the app's authorized redirect origins from the backup config."
[app-id origins]
(doseq [{:keys [service params]} origins]
(redirect-origin-model/add! {:app-id app-id
:service service
:params params})))

(defn initialize-app-from-config
"Creates a new ephemeral app with rules, schema, and email templates.
"Creates a new ephemeral app with rules, schema, email templates, and
authorized redirect origins.
At the end of the process we'll transfer the app to the org or user.
We don't want the app to be discoverable while we're in the process of
doing a restore."
Expand All @@ -123,6 +133,7 @@
:background-updates? false})
(schema-model/apply-plan! (:id app)))
(restore-email-templates! (:id app) (:emailTemplates config))
(restore-redirect-origins! (:id app) (:authorizedRedirectOrigins config))
app))

(defn enqueue-file!
Expand Down
Loading