Restore

O Operator
Restore

Restore file to captured state

Every captured change carries its SHA-256-addressed content in BLOB_STORE. Restoring writes that content back to the original path — current file gets backed up to <path>.drift_restore_backup_<epoch> before overwrite.

What will change
Preview -- nothing has been written yet.
Target file/var/www/vhosts/3dshawn.com/site1/sync.cgi
SiteDriftSense self-monitor on local
Kindlocal
Captured at2026-07-12 00:02:16
Captured SHA13830ac2191936ce438eeffc2e957f67e863c6e778288a532579adeea20e985a
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
File presentyes
Size9853 bytes
Current SHAd24875ee9de9
Same as captured?no -- restore will change it
What restore will change — live diff
Left column shows current on-disk content; right shows what restore will write. +0 additions, -83 deletions, 171 unchanged context lines.
11#!/usr/bin/perl
22#======================================================================
33# DriftSense -- /sync.cgi
44#
55# Cross-site "make N sites match this file" workflow. Given a source
66# file_changes_id, find every other monitor whose latest capture of a
77# same-basename file has a DIFFERENT SHA. Multi-select the targets +
88# confirm -> DriftSense writes the source blob content to each target's
99# file path in one pass, using MODS::Restore under the hood (which
1010# handles the local / ssh_agent branch + backup + verify per target).
1111#
1212# GET /sync.cgi?id=N -> preview + multi-select
1313# POST id=N confirm=1 targets=N,N -> execute the fanned-out restores
1414#======================================================================
1515use strict;
1616use warnings;
1717use CGI ();
1818use Compress::Zlib qw(uncompress);
19use IPC::Open3;
20use Symbol 'gensym';
2119use MODS::Config;
2220use MODS::DBConnect;
2321use MODS::Template;
2422use MODS::PageWrapper;
2523use MODS::Restore;
26use MODS::Diff;
2724
2825my $cgi = CGI->new;
2926my $db = MODS::DBConnect->new;
3027my $tpl = MODS::Template->new;
3128$|=1;
3229
3330my $dbh = $db->db_connect or die "DB connect failed\n";
3431
3532my $method = $ENV{REQUEST_METHOD} || 'GET';
3633my $id = int($cgi->param('id') || 0);
3734
3835# ---- Source row ----------------------------------------------------
3936my $src = $id ? $db->db_readwrite($dbh, qq~
4037 SELECT fc.file_changes_id AS id, fc.file_name, fc.blob_sha, fc.status,
4138 fc.date_time, fc.file_monitor_list_id,
4239 m.scan_name AS src_scan_name,
4340 s.server_name AS src_server_name
4441 FROM FILE_CHANGES fc
4542 LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id
4643 LEFT JOIN SERVERS s ON s.server_id = fc.server_id
4744 WHERE fc.file_changes_id = $id LIMIT 1
4845~, __FILE__, __LINE__) : undef;
4946
5047unless ($src && $src->{blob_sha}) {
5148 print "Content-Type: text/html; charset=utf-8\n\n";
5249 my @body = $tpl->template('sync.html', {
5350 has_error => 1,
5451 error_msg => "Change #$id not found or has no blob.",
5552 });
5653 MODS::PageWrapper->new->wrapper(
5754 page_title => 'Sync', page_key => '',
5855 body_html => join('', @body), userinfo => { display_name => 'Operator' },
5956 );
6057 exit;
6158}
6259
6360# ---- Find candidate targets ----------------------------------------
6461my $basename = $src->{file_name}; $basename =~ s!.*/!!;
6562my $q_base_like = $dbh->quote('%/' . $basename);
6663my @targets = $db->db_readwrite_multiple($dbh, qq~
6764 SELECT fc.file_changes_id AS latest_id,
6865 fc.file_name,
6966 fc.blob_sha,
7067 fc.status,
7168 fc.file_monitor_list_id AS mid,
7269 fc.server_id,
7370 m.scan_name,
7471 s.server_name,
7572 s.kind AS server_kind,
7673 s.ssh_host, s.ssh_user, s.ssh_port,
7774 s.ssh_key_path, s.ssh_options, s.ssh_key_blob
7875 FROM FILE_CHANGES fc
7976 JOIN (
8077 SELECT file_monitor_list_id, file_name, MAX(file_changes_id) AS max_id
8178 FROM FILE_CHANGES
8279 WHERE file_name LIKE $q_base_like
8380 GROUP BY file_monitor_list_id, file_name
8481 ) latest ON latest.max_id = fc.file_changes_id
8582 LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id
8683 LEFT JOIN SERVERS s ON s.server_id = fc.server_id
8784 WHERE fc.file_monitor_list_id != $src->{file_monitor_list_id}
8885 AND fc.blob_sha IS NOT NULL
8986 AND fc.blob_sha != @{[ $dbh->quote($src->{blob_sha}) ]}
9087 AND fc.status != 'deleted'
9188 ORDER BY m.scan_name ASC
9289~, __FILE__, __LINE__);
9390
9491# ---- POST: execute the fanned-out writes ---------------------------
9592if ($method eq 'POST' && $cgi->param('confirm')) {
9693 my %wanted = map { int($_) => 1 } $cgi->param('targets');
9794 # Fetch the source blob content ONCE
9895 my $sth = $dbh->prepare("SELECT content_gz FROM BLOB_STORE WHERE blob_sha = ?");
9996 $sth->execute($src->{blob_sha});
10097 my $blob_row = $sth->fetchrow_arrayref;
10198 $sth->finish;
10299 my $content = ($blob_row && $blob_row->[0]) ? eval { uncompress($blob_row->[0]) } : undef;
103100 unless (defined $content) {
104101 $db->db_disconnect($dbh);
105102 print "Status: 302 Found\nLocation: /sync.cgi?id=$id&fail=99\n\n";
106103 exit;
107104 }
108105
109106 my ($ok_ct, $fail_ct) = (0, 0);
110107 foreach my $t (@targets) {
111108 next unless $wanted{$t->{latest_id}};
112109 my ($ok, $msg, $backup) = MODS::Restore::write_blob(
113110 server_row => $t,
114111 target => $t->{file_name},
115112 blob_sha => $src->{blob_sha},
116113 content => $content,
117114 );
118115 # Log a RESTORE_LOG row per target
119116 my $q_file = $dbh->quote($t->{file_name});
120117 my $q_sha = $dbh->quote($src->{blob_sha});
121118 my $q_bak = $dbh->quote($backup // '');
122119 my $q_stat = $ok ? "'success'" : "'failed'";
123120 my $q_err = $dbh->quote($ok ? '' : ($msg // ''));
124121 my $q_by = $dbh->quote('sync-from-' . $src->{id});
125122 $db->db_readwrite($dbh, qq~
126123 INSERT INTO RESTORE_LOG
127124 (source_change_id, server_id, target_file, source_blob_sha,
128125 backup_path, status, error_message, restored_by)
129126 VALUES
130127 ($src->{id}, $t->{server_id}, $q_file, $q_sha,
131128 $q_bak, $q_stat, $q_err, $q_by)
132129 ~, __FILE__, __LINE__);
133130 $ok ? $ok_ct++ : $fail_ct++;
134131 }
135132 $db->db_disconnect($dbh);
136133 print "Status: 302 Found\nLocation: /sync.cgi?id=$id&ok=$ok_ct&fail=$fail_ct\n\n";
137134 exit;
138135}
139136
140137# ---- GET: preview --------------------------------------------------
141my $dry_run = $cgi->param('dry_run') ? 1 : 0;
142
143# Fetch source blob content once (needed for dry-run diff)
144my $src_content;
145if ($dry_run) {
146 my $sth = $dbh->prepare("SELECT content_gz FROM BLOB_STORE WHERE blob_sha = ?");
147 $sth->execute($src->{blob_sha});
148 my $r = $sth->fetchrow_arrayref;
149 $sth->finish;
150 $src_content = ($r && $r->[0]) ? eval { uncompress($r->[0]) } : undef;
151}
152
153138foreach my $t (@targets) {
154139 $t->{sha_short} = substr($t->{blob_sha} // '', 0, 12);
155140 $t->{file_short} = length($t->{file_name} // '') > 60
156141 ? '...' . substr($t->{file_name}, -57) : $t->{file_name};
157142 $t->{diff_url} = "/diff.cgi?kind=file&id=$t->{latest_id}";
158143 $t->{kind_pill} = ($t->{server_kind} // '') eq 'ssh_agent' ? 'pill-info' : 'pill-mute';
159
160 if ($dry_run) {
161 # Fetch target's current on-disk content and diff vs source
162 my $cur_content = _read_current($t);
163 if (defined $cur_content && defined $src_content) {
164 my @lines = MODS::Diff::html_escape_lines(MODS::Diff::lines($cur_content, $src_content));
165 my ($add, $del) = (0, 0);
166 for my $L (@lines) {
167 $add++ if $L->{op} eq 'add';
168 $del++ if $L->{op} eq 'del';
169 }
170 $t->{diff_lines} = \@lines;
171 $t->{has_diff} = scalar(@lines) ? 1 : 0;
172 $t->{diff_add} = $add;
173 $t->{diff_del} = $del;
174 $t->{diff_ctx} = scalar(@lines) - $add - $del;
175 } else {
176 $t->{diff_lines} = [];
177 $t->{has_diff} = 0;
178 $t->{diff_add} = 0;
179 $t->{diff_del} = 0;
180 $t->{diff_ctx} = 0;
181 }
182 }
183144}
184145my $ok_ct = int($cgi->param('ok') || 0);
185146my $fail_ct = int($cgi->param('fail') || 0);
186147
187148$db->db_disconnect($dbh);
188149
189150my $tvars = {
190151 src_id => $src->{id},
191152 src_file => $src->{file_name},
192153 src_scan => $src->{src_scan_name},
193154 src_server => $src->{src_server_name},
194155 src_sha => substr($src->{blob_sha}, 0, 12),
195156 src_sha_full => $src->{blob_sha},
196157 basename => $basename,
197158 targets => \@targets,
198159 has_targets => scalar(@targets) ? 1 : 0,
199160 total_targets => scalar @targets,
200161 ok_ct => $ok_ct,
201162 fail_ct => $fail_ct,
202163 has_result => ($ok_ct + $fail_ct) ? 1 : 0,
203164 src_diff_url => "/diff.cgi?kind=file&id=$src->{id}",
204 dry_run => $dry_run,
205 preview_url => "/sync.cgi?id=$src->{id}&dry_run=1",
206 confirm_url => "/sync.cgi?id=$src->{id}",
207165};
208166print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
209167my @body = $tpl->template('sync.html', $tvars);
210168MODS::PageWrapper->new->wrapper(
211169 page_title => 'Sync', page_key => '',
212170 body_html => join('', @body), userinfo => { display_name => 'Operator' },
213171);
214exit;
215
216#---------------------------------------------------------------------
217# _read_current($target_row) -> current on-disk content or undef
218#---------------------------------------------------------------------
219sub _read_current {
220 my ($t) = @_;
221 my $path = $t->{file_name};
222 if (($t->{server_kind} // 'local') eq 'local') {
223 return undef unless -r $path;
224 open(my $fh, '<:raw', $path) or return undef;
225 local $/; my $c = <$fh>; close $fh;
226 # Cap at 512 KB for preview responsiveness
227 return length($c) > 512_000 ? substr($c, 0, 512_000) : $c;
228 }
229 # SSH agent
230 my $user = $t->{ssh_user} || 'root';
231 my $host = $t->{ssh_host};
232 my $port = $t->{ssh_port} || 22;
233 my $key = $t->{ssh_key_path} || '';
234 my @argv = ('/usr/bin/ssh',
235 '-o', 'BatchMode=yes',
236 '-o', 'StrictHostKeyChecking=no',
237 '-o', 'UserKnownHostsFile=/dev/null',
238 '-o', 'LogLevel=ERROR',
239 '-o', 'ConnectTimeout=6',
240 '-p', $port);
241 push @argv, '-i', $key if $key && -r $key;
242 push @argv, "$user\@$host",
243 "head -c 524288 " . _sh_q($path);
244 my ($wtr, $rdr, $err_fh);
245 $err_fh = gensym;
246 my $pid = eval { open3($wtr, $rdr, $err_fh, @argv) };
247 return undef if $@ || !$pid;
248 close $wtr;
249 my $out = do { local $/; <$rdr> // '' };
250 close $rdr; close $err_fh;
251 waitpid $pid, 0;
252 return ($? >> 8) == 0 ? $out : undef;
253}
254sub _sh_q { my $s = shift; $s //= ''; $s =~ s/'/'\\''/g; return "'$s'"; }
Confirm restore
Backs current content to <target>.drift_restore_backup_<epoch>, writes the captured content, verifies SHA post-write.
Cancel Logged to RESTORE_LOG regardless of outcome.