Diff -- /var/www/vhosts/3dshawn.com/site1/sync.cgi
Diff
/var/www/vhosts/3dshawn.com/site1/sync.cgi
modified on local at 2026-07-12 00:19:43
Added
+83
lines
Removed
-0
lines
Context
171
unchanged
Blobs
from 13830ac21919
to d24875ee9de9
to d24875ee9de9
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | #!/usr/bin/perl |
| 2 | 2 | #====================================================================== |
| 3 | 3 | # DriftSense -- /sync.cgi |
| 4 | 4 | # |
| 5 | 5 | # Cross-site "make N sites match this file" workflow. Given a source |
| 6 | 6 | # file_changes_id, find every other monitor whose latest capture of a |
| 7 | 7 | # same-basename file has a DIFFERENT SHA. Multi-select the targets + |
| 8 | 8 | # confirm -> DriftSense writes the source blob content to each target's |
| 9 | 9 | # file path in one pass, using MODS::Restore under the hood (which |
| 10 | 10 | # handles the local / ssh_agent branch + backup + verify per target). |
| 11 | 11 | # |
| 12 | 12 | # GET /sync.cgi?id=N -> preview + multi-select |
| 13 | 13 | # POST id=N confirm=1 targets=N,N -> execute the fanned-out restores |
| 14 | 14 | #====================================================================== |
| 15 | 15 | use strict; |
| 16 | 16 | use warnings; |
| 17 | 17 | use CGI (); |
| 18 | 18 | use Compress::Zlib qw(uncompress); |
| 19 | use IPC::Open3; | |
| 20 | use Symbol 'gensym'; | |
| 19 | 21 | use MODS::Config; |
| 20 | 22 | use MODS::DBConnect; |
| 21 | 23 | use MODS::Template; |
| 22 | 24 | use MODS::PageWrapper; |
| 23 | 25 | use MODS::Restore; |
| 26 | use MODS::Diff; | |
| 24 | 27 | |
| 25 | 28 | my $cgi = CGI->new; |
| 26 | 29 | my $db = MODS::DBConnect->new; |
| 27 | 30 | my $tpl = MODS::Template->new; |
| 28 | 31 | $|=1; |
| 29 | 32 | |
| 30 | 33 | my $dbh = $db->db_connect or die "DB connect failed\n"; |
| 31 | 34 | |
| 32 | 35 | my $method = $ENV{REQUEST_METHOD} || 'GET'; |
| 33 | 36 | my $id = int($cgi->param('id') || 0); |
| 34 | 37 | |
| 35 | 38 | # ---- Source row ---------------------------------------------------- |
| 36 | 39 | my $src = $id ? $db->db_readwrite($dbh, qq~ |
| 37 | 40 | SELECT fc.file_changes_id AS id, fc.file_name, fc.blob_sha, fc.status, |
| 38 | 41 | fc.date_time, fc.file_monitor_list_id, |
| 39 | 42 | m.scan_name AS src_scan_name, |
| 40 | 43 | s.server_name AS src_server_name |
| 41 | 44 | FROM FILE_CHANGES fc |
| 42 | 45 | LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id |
| 43 | 46 | LEFT JOIN SERVERS s ON s.server_id = fc.server_id |
| 44 | 47 | WHERE fc.file_changes_id = $id LIMIT 1 |
| 45 | 48 | ~, __FILE__, __LINE__) : undef; |
| 46 | 49 | |
| 47 | 50 | unless ($src && $src->{blob_sha}) { |
| 48 | 51 | print "Content-Type: text/html; charset=utf-8\n\n"; |
| 49 | 52 | my @body = $tpl->template('sync.html', { |
| 50 | 53 | has_error => 1, |
| 51 | 54 | error_msg => "Change #$id not found or has no blob.", |
| 52 | 55 | }); |
| 53 | 56 | MODS::PageWrapper->new->wrapper( |
| 54 | 57 | page_title => 'Sync', page_key => '', |
| 55 | 58 | body_html => join('', @body), userinfo => { display_name => 'Operator' }, |
| 56 | 59 | ); |
| 57 | 60 | exit; |
| 58 | 61 | } |
| 59 | 62 | |
| 60 | 63 | # ---- Find candidate targets ---------------------------------------- |
| 61 | 64 | my $basename = $src->{file_name}; $basename =~ s!.*/!!; |
| 62 | 65 | my $q_base_like = $dbh->quote('%/' . $basename); |
| 63 | 66 | my @targets = $db->db_readwrite_multiple($dbh, qq~ |
| 64 | 67 | SELECT fc.file_changes_id AS latest_id, |
| 65 | 68 | fc.file_name, |
| 66 | 69 | fc.blob_sha, |
| 67 | 70 | fc.status, |
| 68 | 71 | fc.file_monitor_list_id AS mid, |
| 69 | 72 | fc.server_id, |
| 70 | 73 | m.scan_name, |
| 71 | 74 | s.server_name, |
| 72 | 75 | s.kind AS server_kind, |
| 73 | 76 | s.ssh_host, s.ssh_user, s.ssh_port, |
| 74 | 77 | s.ssh_key_path, s.ssh_options, s.ssh_key_blob |
| 75 | 78 | FROM FILE_CHANGES fc |
| 76 | 79 | JOIN ( |
| 77 | 80 | SELECT file_monitor_list_id, file_name, MAX(file_changes_id) AS max_id |
| 78 | 81 | FROM FILE_CHANGES |
| 79 | 82 | WHERE file_name LIKE $q_base_like |
| 80 | 83 | GROUP BY file_monitor_list_id, file_name |
| 81 | 84 | ) latest ON latest.max_id = fc.file_changes_id |
| 82 | 85 | LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id |
| 83 | 86 | LEFT JOIN SERVERS s ON s.server_id = fc.server_id |
| 84 | 87 | WHERE fc.file_monitor_list_id != $src->{file_monitor_list_id} |
| 85 | 88 | AND fc.blob_sha IS NOT NULL |
| 86 | 89 | AND fc.blob_sha != @{[ $dbh->quote($src->{blob_sha}) ]} |
| 87 | 90 | AND fc.status != 'deleted' |
| 88 | 91 | ORDER BY m.scan_name ASC |
| 89 | 92 | ~, __FILE__, __LINE__); |
| 90 | 93 | |
| 91 | 94 | # ---- POST: execute the fanned-out writes --------------------------- |
| 92 | 95 | if ($method eq 'POST' && $cgi->param('confirm')) { |
| 93 | 96 | my %wanted = map { int($_) => 1 } $cgi->param('targets'); |
| 94 | 97 | # Fetch the source blob content ONCE |
| 95 | 98 | my $sth = $dbh->prepare("SELECT content_gz FROM BLOB_STORE WHERE blob_sha = ?"); |
| 96 | 99 | $sth->execute($src->{blob_sha}); |
| 97 | 100 | my $blob_row = $sth->fetchrow_arrayref; |
| 98 | 101 | $sth->finish; |
| 99 | 102 | my $content = ($blob_row && $blob_row->[0]) ? eval { uncompress($blob_row->[0]) } : undef; |
| 100 | 103 | unless (defined $content) { |
| 101 | 104 | $db->db_disconnect($dbh); |
| 102 | 105 | print "Status: 302 Found\nLocation: /sync.cgi?id=$id&fail=99\n\n"; |
| 103 | 106 | exit; |
| 104 | 107 | } |
| 105 | 108 | |
| 106 | 109 | my ($ok_ct, $fail_ct) = (0, 0); |
| 107 | 110 | foreach my $t (@targets) { |
| 108 | 111 | next unless $wanted{$t->{latest_id}}; |
| 109 | 112 | my ($ok, $msg, $backup) = MODS::Restore::write_blob( |
| 110 | 113 | server_row => $t, |
| 111 | 114 | target => $t->{file_name}, |
| 112 | 115 | blob_sha => $src->{blob_sha}, |
| 113 | 116 | content => $content, |
| 114 | 117 | ); |
| 115 | 118 | # Log a RESTORE_LOG row per target |
| 116 | 119 | my $q_file = $dbh->quote($t->{file_name}); |
| 117 | 120 | my $q_sha = $dbh->quote($src->{blob_sha}); |
| 118 | 121 | my $q_bak = $dbh->quote($backup // ''); |
| 119 | 122 | my $q_stat = $ok ? "'success'" : "'failed'"; |
| 120 | 123 | my $q_err = $dbh->quote($ok ? '' : ($msg // '')); |
| 121 | 124 | my $q_by = $dbh->quote('sync-from-' . $src->{id}); |
| 122 | 125 | $db->db_readwrite($dbh, qq~ |
| 123 | 126 | INSERT INTO RESTORE_LOG |
| 124 | 127 | (source_change_id, server_id, target_file, source_blob_sha, |
| 125 | 128 | backup_path, status, error_message, restored_by) |
| 126 | 129 | VALUES |
| 127 | 130 | ($src->{id}, $t->{server_id}, $q_file, $q_sha, |
| 128 | 131 | $q_bak, $q_stat, $q_err, $q_by) |
| 129 | 132 | ~, __FILE__, __LINE__); |
| 130 | 133 | $ok ? $ok_ct++ : $fail_ct++; |
| 131 | 134 | } |
| 132 | 135 | $db->db_disconnect($dbh); |
| 133 | 136 | print "Status: 302 Found\nLocation: /sync.cgi?id=$id&ok=$ok_ct&fail=$fail_ct\n\n"; |
| 134 | 137 | exit; |
| 135 | 138 | } |
| 136 | 139 | |
| 137 | 140 | # ---- GET: preview -------------------------------------------------- |
| 141 | my $dry_run = $cgi->param('dry_run') ? 1 : 0; | |
| 142 | ||
| 143 | # Fetch source blob content once (needed for dry-run diff) | |
| 144 | my $src_content; | |
| 145 | if ($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 | ||
| 138 | 153 | foreach my $t (@targets) { |
| 139 | 154 | $t->{sha_short} = substr($t->{blob_sha} // '', 0, 12); |
| 140 | 155 | $t->{file_short} = length($t->{file_name} // '') > 60 |
| 141 | 156 | ? '...' . substr($t->{file_name}, -57) : $t->{file_name}; |
| 142 | 157 | $t->{diff_url} = "/diff.cgi?kind=file&id=$t->{latest_id}"; |
| 143 | 158 | $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 | } | |
| 144 | 183 | } |
| 145 | 184 | my $ok_ct = int($cgi->param('ok') || 0); |
| 146 | 185 | my $fail_ct = int($cgi->param('fail') || 0); |
| 147 | 186 | |
| 148 | 187 | $db->db_disconnect($dbh); |
| 149 | 188 | |
| 150 | 189 | my $tvars = { |
| 151 | 190 | src_id => $src->{id}, |
| 152 | 191 | src_file => $src->{file_name}, |
| 153 | 192 | src_scan => $src->{src_scan_name}, |
| 154 | 193 | src_server => $src->{src_server_name}, |
| 155 | 194 | src_sha => substr($src->{blob_sha}, 0, 12), |
| 156 | 195 | src_sha_full => $src->{blob_sha}, |
| 157 | 196 | basename => $basename, |
| 158 | 197 | targets => \@targets, |
| 159 | 198 | has_targets => scalar(@targets) ? 1 : 0, |
| 160 | 199 | total_targets => scalar @targets, |
| 161 | 200 | ok_ct => $ok_ct, |
| 162 | 201 | fail_ct => $fail_ct, |
| 163 | 202 | has_result => ($ok_ct + $fail_ct) ? 1 : 0, |
| 164 | 203 | 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}", | |
| 165 | 207 | }; |
| 166 | 208 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n"; |
| 167 | 209 | my @body = $tpl->template('sync.html', $tvars); |
| 168 | 210 | MODS::PageWrapper->new->wrapper( |
| 169 | 211 | page_title => 'Sync', page_key => '', |
| 170 | 212 | body_html => join('', @body), userinfo => { display_name => 'Operator' }, |
| 171 | 213 | ); |
| 214 | exit; | |
| 215 | ||
| 216 | #--------------------------------------------------------------------- | |
| 217 | # _read_current($target_row) -> current on-disk content or undef | |
| 218 | #--------------------------------------------------------------------- | |
| 219 | sub _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 | } | |
| 254 | sub _sh_q { my $s = shift; $s //= ''; $s =~ s/'/'\\''/g; return "'$s'"; } |