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/file_changes.cgi
SiteDriftSense self-monitor on local
Kindlocal
Captured at2026-07-10 22:19:06
Captured SHA359054848baa834c7904d04338b83452032f66df61837cab5d876a7eb2266ac3
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
File presentyes
Size4464 bytes
Current SHA5cd91f273405
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. +25 additions, -46 deletions, 83 unchanged context lines.
11#!/usr/bin/perl
22use strict;
33use warnings;
44use CGI ();
55use MODS::Config;
66use MODS::DBConnect;
77use MODS::Template;
88use MODS::PageWrapper;
99use MODS::Charts;
10use MODS::RangePicker;
1110use POSIX ();
1211
1312my $cgi = CGI->new;
1413my $db = MODS::DBConnect->new;
1514my $tpl = MODS::Template->new;
1615
1716$|=1; print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
1817
1918my $dbh = $db->db_connect or die "DB connect failed\n";
2019
21my $r = MODS::RangePicker::parse($cgi);
22my $q = $cgi->param('q') || '';
20my $days = int($cgi->param('days') || 7); $days = 1 if $days < 1; $days = 365 if $days > 365;
21my $q = $cgi->param('q') || '';
2322my $server_filter = int($cgi->param('server') || 0);
2423
25my $where = $r->{sql_where}->('fc.date_time');
26$where .= " AND fc.file_name LIKE " . $dbh->quote('%' . $q . '%') if length $q;
27$where .= " AND fc.server_id = $server_filter" if $server_filter;
24my $where = "date_time >= DATE_SUB(NOW(), INTERVAL $days DAY)";
25$where .= " AND file_name LIKE " . $dbh->quote('%' . $q . '%') if length $q;
26$where .= " AND server_id = $server_filter" if $server_filter;
2827
2928my @rows = $db->db_readwrite_multiple($dbh, qq~
30 SELECT fc.file_changes_id AS id, fc.file_name, fc.status,
31 UNIX_TIMESTAMP(fc.date_time) AS ts_epoch,
32 fc.date_time,
29 SELECT fc.file_changes_id AS id, fc.file_name, fc.status, fc.date_time,
3330 fc.blob_sha, fc.diff_blob_sha, fc.is_ts_only, fc.uid, fc.gid, fc.permissions,
3431 s.server_name
3532 FROM FILE_CHANGES fc
3633 LEFT JOIN SERVERS s ON s.server_id = fc.server_id
3734 WHERE $where
3835 ORDER BY fc.date_time DESC
3936 LIMIT 200
4037~, __FILE__, __LINE__);
41
4238# --- Per-file 30-day sparkline data. One query, then bucket in Perl. ---
43my %spark;
39my %spark; # file_name => arrayref [30 daily counts, oldest -> newest]
4440{
4541 my @dedupe_files;
4642 my %seen;
47 foreach my $r_row (@rows) {
48 next unless defined $r_row->{file_name};
49 next if $seen{$r_row->{file_name}}++;
50 push @dedupe_files, $r_row->{file_name};
43 foreach my $r (@rows) {
44 next unless defined $r->{file_name};
45 next if $seen{$r->{file_name}}++;
46 push @dedupe_files, $r->{file_name};
5147 }
5248 if (@dedupe_files) {
5349 my @quoted = map { $dbh->quote($_) } @dedupe_files;
5450 my $in_list = join(',', @quoted);
5551 my @day_rows = $db->db_readwrite_multiple($dbh, qq~
5652 SELECT file_name,
5753 DATE_FORMAT(date_time, '%Y-%m-%d') AS d,
5854 COUNT(*) AS n
5955 FROM FILE_CHANGES
6056 WHERE file_name IN ($in_list)
6157 AND date_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
6258 GROUP BY file_name, d
6359 ~, __FILE__, __LINE__);
6460 my %by_file;
6561 foreach my $dr (@day_rows) {
6662 $by_file{$dr->{file_name}}{$dr->{d}} = $dr->{n};
6763 }
6864 my $now = time;
6965 my @day_keys;
7066 for (my $i = 29; $i >= 0; $i--) {
7167 push @day_keys, POSIX::strftime('%Y-%m-%d', localtime($now - $i * 86400));
7268 }
7369 foreach my $fname (@dedupe_files) {
7470 my @series;
7571 for my $k (@day_keys) {
7672 push @series, ($by_file{$fname}{$k} || 0);
7773 }
7874 $spark{$fname} = \@series;
7975 }
8076 }
8177}
8278
83foreach my $row (@rows) {
84 $row->{status_pill} = $row->{status} && $row->{status} eq 'added' ? 'pill-ok'
85 : $row->{status} && $row->{status} eq 'modified' ? 'pill-warn'
86 : $row->{status} && $row->{status} eq 'deleted' ? 'pill-bad'
87 : 'pill-mute';
88 $row->{status} //= 'unknown';
89 $row->{file_name_h} = $row->{file_name} // '';
90 $row->{file_name_h} = length($row->{file_name_h}) > 90
91 ? '...' . substr($row->{file_name_h}, -87)
92 : $row->{file_name_h};
93 $row->{blob_short} = $row->{blob_sha} ? substr($row->{blob_sha}, 0, 8) : '-';
94 $row->{diff_url} = "/diff.cgi?kind=file&id=$row->{id}";
95 $row->{restore_url} = "/restore.cgi?id=$row->{id}";
96 $row->{can_restore} = ($row->{blob_sha} && $row->{status} ne 'deleted') ? 1 : 0;
97 $row->{ts_epoch} //= 0;
98 my $series = $spark{$row->{file_name} // ''};
99 $row->{sparkline_svg} = $series
79foreach my $r (@rows) {
80 $r->{status_pill} = $r->{status} && $r->{status} eq 'added' ? 'pill-ok'
81 : $r->{status} && $r->{status} eq 'modified' ? 'pill-warn'
82 : $r->{status} && $r->{status} eq 'deleted' ? 'pill-bad'
83 : 'pill-mute';
84 $r->{status} //= 'unknown';
85 $r->{file_name_h} = $r->{file_name} // '';
86 $r->{file_name_h} = length($r->{file_name_h}) > 90 ? '...' . substr($r->{file_name_h}, -87) : $r->{file_name_h};
87 $r->{blob_short} = $r->{blob_sha} ? substr($r->{blob_sha}, 0, 8) : '-';
88 $r->{diff_url} = "/diff.cgi?kind=file&id=$r->{id}";
89 my $series = $spark{$r->{file_name} // ''};
90 $r->{sparkline_svg} = $series
10091 ? MODS::Charts::sparkline(values => $series, width => 88, height => 20, color => '#14b8a6')
10192 : '';
10293}
10394my $total = scalar @rows;
10495
10596$db->db_disconnect($dbh);
106
107my $picker_html = MODS::RangePicker::picker_html(
108 action => '/file_changes.cgi',
109 range => $r,
110 hidden => { q => $q, ($server_filter ? (server => $server_filter) : ()) },
111);
11297
11398my $tvars = {
114 rows => \@rows,
115 has_rows => $total ? 1 : 0,
116 total => $total,
117 q => $q,
118 picker_html => $picker_html,
119 range_label => $r->{range_label},
120 is_day_view => $r->{is_day} ? 1 : 0,
121 day_label => $r->{on},
99 rows => \@rows, has_rows => $total ? 1 : 0, total => $total,
100 days => $days, q => $q,
122101};
123102my @body = $tpl->template('file_changes.html', $tvars);
124103
125104MODS::PageWrapper->new->wrapper(
126105 page_title => 'File changes', page_key => 'file_changes',
127106 body_html => join('', @body),
128107 userinfo => { display_name => 'Operator' },
129108);
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.