Restore
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/settings.cgi |
| Site | DriftSense self-monitor on local |
| Kind | local |
| Captured at | 2026-07-12 00:02:14 |
| Captured SHA | c41f0faf4e6b5de233f0754d8075353fbd01ae093b2a1a94f5c0de14fd7cb30d |
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
| File present | yes |
| Size | 5538 bytes |
| Current SHA | 7dd39d832ea1 |
| 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, -27 deletions, 105 unchanged context lines.
| 1 | 1 | #!/usr/bin/perl |
| 2 | 2 | #====================================================================== |
| 3 | 3 | # DriftSense -- /settings.cgi |
| 4 | 4 | # |
| 5 | 5 | # Read-only view of the resolved config file + editable form for the |
| 6 | 6 | # SCAN_GLOBALS row (global ignore patterns, allowed file types, max |
| 7 | 7 | # file size). |
| 8 | 8 | #====================================================================== |
| 9 | 9 | use strict; |
| 10 | 10 | use warnings; |
| 11 | 11 | use CGI (); |
| 12 | 12 | use MODS::Config; |
| 13 | 13 | use MODS::DBConnect; |
| 14 | 14 | use MODS::Template; |
| 15 | 15 | use MODS::PageWrapper; |
| 16 | 16 | |
| 17 | 17 | my $cgi = CGI->new; |
| 18 | 18 | my $cfg = MODS::Config->new; |
| 19 | 19 | my $db = MODS::DBConnect->new; |
| 20 | 20 | my $tpl = MODS::Template->new; |
| 21 | 21 | |
| 22 | 22 | $|=1; |
| 23 | 23 | my $dbh = $db->db_connect or die "DB connect failed\n"; |
| 24 | 24 | |
| 25 | 25 | # ---- POST: update globals ------------------------------------------ |
| 26 | 26 | if (($ENV{REQUEST_METHOD} || '') eq 'POST' && $cgi->param('save_globals')) { |
| 27 | 27 | my $ign = $cgi->param('global_ignore_list') // ''; |
| 28 | 28 | my $ftyp = $cgi->param('global_file_type_filter') // ''; |
| 29 | 29 | my $mx = int($cgi->param('global_max_file_size_bytes') || 0); |
| 30 | 30 | |
| 31 | 31 | # Normalize whitespace on ignore list -> comma-joined single-line |
| 32 | 32 | my @clean_ign; |
| 33 | 33 | for my $tok (split /[,\r\n]+/, $ign) { |
| 34 | 34 | $tok =~ s/^\s+|\s+$//g; |
| 35 | 35 | push @clean_ign, $tok if length $tok; |
| 36 | 36 | } |
| 37 | 37 | my $normalized = join(',', @clean_ign); |
| 38 | 38 | |
| 39 | 39 | my $q_ign = $dbh->quote($normalized); |
| 40 | 40 | my $q_ftyp = $dbh->quote($ftyp); |
| 41 | 41 | $mx = 'NULL' unless $mx; |
| 42 | 42 | |
| 43 | 43 | $db->db_readwrite($dbh, qq~ |
| 44 | 44 | UPDATE SCAN_GLOBALS |
| 45 | 45 | SET global_ignore_list = $q_ign, |
| 46 | 46 | global_file_type_filter = $q_ftyp, |
| 47 | 47 | global_max_file_size_bytes = $mx |
| 48 | 48 | WHERE id = 1 |
| 49 | 49 | ~, __FILE__, __LINE__); |
| 50 | 50 | $db->db_disconnect($dbh); |
| 51 | 51 | print "Status: 302 Found\nLocation: /settings.cgi?saved=1\n\n"; |
| 52 | 52 | exit; |
| 53 | 53 | } |
| 54 | 54 | |
| 55 | 55 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n"; |
| 56 | 56 | |
| 57 | 57 | my $all = $cfg->all; |
| 58 | 58 | |
| 59 | 59 | # Blob store stats |
| 60 | 60 | my $blob_stats = $db->db_readwrite($dbh, q~ |
| 61 | 61 | SELECT COUNT(*) AS blob_ct, |
| 62 | 62 | COALESCE(SUM(size_uncompressed), 0) AS bytes_uncomp, |
| 63 | 63 | COALESCE(SUM(size_compressed), 0) AS bytes_comp, |
| 64 | 64 | COALESCE(SUM(ref_count), 0) AS total_refs |
| 65 | 65 | FROM BLOB_STORE |
| 66 | ~, __FILE__, __LINE__); | |
| 67 | ||
| 68 | # Recent purge stats (last 30 days) for the tile | |
| 69 | my $recent_purge = $db->db_readwrite($dbh, q~ | |
| 70 | SELECT | |
| 71 | SUM(CASE WHEN dry_run = 1 THEN 1 ELSE 0 END) AS dry_ct, | |
| 72 | SUM(CASE WHEN dry_run = 1 THEN freed_bytes ELSE 0 END) AS dry_bytes, | |
| 73 | SUM(CASE WHEN dry_run = 0 THEN 1 ELSE 0 END) AS live_ct, | |
| 74 | SUM(CASE WHEN dry_run = 0 THEN freed_bytes ELSE 0 END) AS live_bytes, | |
| 75 | DATE_FORMAT(MAX(purged_at), '%b %d %H:%i') AS last_purge_h, | |
| 76 | UNIX_TIMESTAMP(MAX(purged_at)) AS last_purge_epoch | |
| 77 | FROM PURGE_LOG | |
| 78 | WHERE purged_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) | |
| 79 | 66 | ~, __FILE__, __LINE__); |
| 80 | 67 | |
| 81 | 68 | # Global scan settings |
| 82 | 69 | my $g = $db->db_readwrite($dbh, q~ |
| 83 | 70 | SELECT global_ignore_list, global_file_type_filter, global_max_file_size_bytes |
| 84 | 71 | FROM SCAN_GLOBALS WHERE id = 1 LIMIT 1 |
| 85 | 72 | ~, __FILE__, __LINE__); |
| 86 | 73 | my $global_ignore = ($g && $g->{global_ignore_list}) // ''; |
| 87 | 74 | my $global_ftypes = ($g && $g->{global_file_type_filter}) // ''; |
| 88 | 75 | my $global_max_sz = ($g && $g->{global_max_file_size_bytes}) || 0; |
| 89 | 76 | # Split the ignore list onto lines for the textarea (nicer edit UX) |
| 90 | 77 | my $ignore_pretty = join("\n", split /,/, $global_ignore); |
| 91 | 78 | |
| 92 | 79 | $db->db_disconnect($dbh); |
| 93 | 80 | |
| 94 | 81 | my @cfg_rows; |
| 95 | 82 | foreach my $k (sort keys %$all) { |
| 96 | 83 | my $v = $all->{$k} // ''; |
| 97 | 84 | $v = length($v) > 4 ? '***' . substr($v, -4) : '****' |
| 98 | 85 | if $k =~ /pass(word)?|secret|hmac|smtp_password/i && length $v; |
| 99 | 86 | push @cfg_rows, { key => $k, value => $v }; |
| 100 | 87 | } |
| 101 | 88 | |
| 102 | 89 | my $tvars = { |
| 103 | 90 | cfg_rows => \@cfg_rows, |
| 104 | 91 | blob_ct => ($blob_stats && $blob_stats->{blob_ct}) || 0, |
| 105 | 92 | bytes_uncomp => ($blob_stats && $blob_stats->{bytes_uncomp}) || 0, |
| 106 | 93 | bytes_comp => ($blob_stats && $blob_stats->{bytes_comp}) || 0, |
| 107 | 94 | total_refs => ($blob_stats && $blob_stats->{total_refs}) || 0, |
| 108 | 95 | ratio => (($blob_stats && $blob_stats->{bytes_uncomp}) ? sprintf('%.1fx', $blob_stats->{bytes_uncomp} / ($blob_stats->{bytes_comp} || 1)) : '-'), |
| 109 | 96 | global_ignore_pretty => $ignore_pretty, |
| 110 | 97 | global_file_type_filter => $global_ftypes, |
| 111 | 98 | global_max_file_size_bytes => $global_max_sz, |
| 112 | 99 | saved => $cgi->param('saved') ? 1 : 0, |
| 113 | purge_dry_ct => ($recent_purge && $recent_purge->{dry_ct}) || 0, | |
| 114 | purge_dry_bytes => _fmt_bytes(($recent_purge && $recent_purge->{dry_bytes}) || 0), | |
| 115 | purge_live_ct => ($recent_purge && $recent_purge->{live_ct}) || 0, | |
| 116 | purge_live_bytes => _fmt_bytes(($recent_purge && $recent_purge->{live_bytes}) || 0), | |
| 117 | last_purge_h => ($recent_purge && $recent_purge->{last_purge_h}) // 'never', | |
| 118 | last_purge_epoch => ($recent_purge && $recent_purge->{last_purge_epoch}) || 0, | |
| 119 | 100 | }; |
| 120 | ||
| 121 | sub _fmt_bytes { | |
| 122 | my $n = shift; $n //= 0; | |
| 123 | return "$n B" if $n < 1024; | |
| 124 | return sprintf('%.1f KB', $n/1024) if $n < 1024*1024; | |
| 125 | return sprintf('%.1f MB', $n/1024/1024) if $n < 1024*1024*1024; | |
| 126 | return sprintf('%.1f GB', $n/1024/1024/1024); | |
| 127 | } | |
| 128 | 101 | my @body = $tpl->template('settings.html', $tvars); |
| 129 | 102 | MODS::PageWrapper->new->wrapper( |
| 130 | 103 | page_title => 'Settings', page_key => 'settings', |
| 131 | 104 | body_html => join('', @body), userinfo => { display_name => 'Operator' }, |
| 132 | 105 | ); |
Confirm restore
Backs current content to <target>.drift_restore_backup_<epoch>, writes the captured content, verifies SHA post-write.