Diff -- /var/www/vhosts/3dshawn.com/site1/sync.cgi

O Operator
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
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
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';
1921use MODS::Config;
2022use MODS::DBConnect;
2123use MODS::Template;
2224use MODS::PageWrapper;
2325use MODS::Restore;
26use MODS::Diff;
2427
2528my $cgi = CGI->new;
2629my $db = MODS::DBConnect->new;
2730my $tpl = MODS::Template->new;
2831$|=1;
2932
3033my $dbh = $db->db_connect or die "DB connect failed\n";
3134
3235my $method = $ENV{REQUEST_METHOD} || 'GET';
3336my $id = int($cgi->param('id') || 0);
3437
3538# ---- Source row ----------------------------------------------------
3639my $src = $id ? $db->db_readwrite($dbh, qq~
3740 SELECT fc.file_changes_id AS id, fc.file_name, fc.blob_sha, fc.status,
3841 fc.date_time, fc.file_monitor_list_id,
3942 m.scan_name AS src_scan_name,
4043 s.server_name AS src_server_name
4144 FROM FILE_CHANGES fc
4245 LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id
4346 LEFT JOIN SERVERS s ON s.server_id = fc.server_id
4447 WHERE fc.file_changes_id = $id LIMIT 1
4548~, __FILE__, __LINE__) : undef;
4649
4750unless ($src && $src->{blob_sha}) {
4851 print "Content-Type: text/html; charset=utf-8\n\n";
4952 my @body = $tpl->template('sync.html', {
5053 has_error => 1,
5154 error_msg => "Change #$id not found or has no blob.",
5255 });
5356 MODS::PageWrapper->new->wrapper(
5457 page_title => 'Sync', page_key => '',
5558 body_html => join('', @body), userinfo => { display_name => 'Operator' },
5659 );
5760 exit;
5861}
5962
6063# ---- Find candidate targets ----------------------------------------
6164my $basename = $src->{file_name}; $basename =~ s!.*/!!;
6265my $q_base_like = $dbh->quote('%/' . $basename);
6366my @targets = $db->db_readwrite_multiple($dbh, qq~
6467 SELECT fc.file_changes_id AS latest_id,
6568 fc.file_name,
6669 fc.blob_sha,
6770 fc.status,
6871 fc.file_monitor_list_id AS mid,
6972 fc.server_id,
7073 m.scan_name,
7174 s.server_name,
7275 s.kind AS server_kind,
7376 s.ssh_host, s.ssh_user, s.ssh_port,
7477 s.ssh_key_path, s.ssh_options, s.ssh_key_blob
7578 FROM FILE_CHANGES fc
7679 JOIN (
7780 SELECT file_monitor_list_id, file_name, MAX(file_changes_id) AS max_id
7881 FROM FILE_CHANGES
7982 WHERE file_name LIKE $q_base_like
8083 GROUP BY file_monitor_list_id, file_name
8184 ) latest ON latest.max_id = fc.file_changes_id
8285 LEFT JOIN FILE_MONITOR_SETTINGS m ON m.file_monitor_list_id = fc.file_monitor_list_id
8386 LEFT JOIN SERVERS s ON s.server_id = fc.server_id
8487 WHERE fc.file_monitor_list_id != $src->{file_monitor_list_id}
8588 AND fc.blob_sha IS NOT NULL
8689 AND fc.blob_sha != @{[ $dbh->quote($src->{blob_sha}) ]}
8790 AND fc.status != 'deleted'
8891 ORDER BY m.scan_name ASC
8992~, __FILE__, __LINE__);
9093
9194# ---- POST: execute the fanned-out writes ---------------------------
9295if ($method eq 'POST' && $cgi->param('confirm')) {
9396 my %wanted = map { int($_) => 1 } $cgi->param('targets');
9497 # Fetch the source blob content ONCE
9598 my $sth = $dbh->prepare("SELECT content_gz FROM BLOB_STORE WHERE blob_sha = ?");
9699 $sth->execute($src->{blob_sha});
97100 my $blob_row = $sth->fetchrow_arrayref;
98101 $sth->finish;
99102 my $content = ($blob_row && $blob_row->[0]) ? eval { uncompress($blob_row->[0]) } : undef;
100103 unless (defined $content) {
101104 $db->db_disconnect($dbh);
102105 print "Status: 302 Found\nLocation: /sync.cgi?id=$id&fail=99\n\n";
103106 exit;
104107 }
105108
106109 my ($ok_ct, $fail_ct) = (0, 0);
107110 foreach my $t (@targets) {
108111 next unless $wanted{$t->{latest_id}};
109112 my ($ok, $msg, $backup) = MODS::Restore::write_blob(
110113 server_row => $t,
111114 target => $t->{file_name},
112115 blob_sha => $src->{blob_sha},
113116 content => $content,
114117 );
115118 # Log a RESTORE_LOG row per target
116119 my $q_file = $dbh->quote($t->{file_name});
117120 my $q_sha = $dbh->quote($src->{blob_sha});
118121 my $q_bak = $dbh->quote($backup // '');
119122 my $q_stat = $ok ? "'success'" : "'failed'";
120123 my $q_err = $dbh->quote($ok ? '' : ($msg // ''));
121124 my $q_by = $dbh->quote('sync-from-' . $src->{id});
122125 $db->db_readwrite($dbh, qq~
123126 INSERT INTO RESTORE_LOG
124127 (source_change_id, server_id, target_file, source_blob_sha,
125128 backup_path, status, error_message, restored_by)
126129 VALUES
127130 ($src->{id}, $t->{server_id}, $q_file, $q_sha,
128131 $q_bak, $q_stat, $q_err, $q_by)
129132 ~, __FILE__, __LINE__);
130133 $ok ? $ok_ct++ : $fail_ct++;
131134 }
132135 $db->db_disconnect($dbh);
133136 print "Status: 302 Found\nLocation: /sync.cgi?id=$id&ok=$ok_ct&fail=$fail_ct\n\n";
134137 exit;
135138}
136139
137140# ---- 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
138153foreach my $t (@targets) {
139154 $t->{sha_short} = substr($t->{blob_sha} // '', 0, 12);
140155 $t->{file_short} = length($t->{file_name} // '') > 60
141156 ? '...' . substr($t->{file_name}, -57) : $t->{file_name};
142157 $t->{diff_url} = "/diff.cgi?kind=file&id=$t->{latest_id}";
143158 $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 }
144183}
145184my $ok_ct = int($cgi->param('ok') || 0);
146185my $fail_ct = int($cgi->param('fail') || 0);
147186
148187$db->db_disconnect($dbh);
149188
150189my $tvars = {
151190 src_id => $src->{id},
152191 src_file => $src->{file_name},
153192 src_scan => $src->{src_scan_name},
154193 src_server => $src->{src_server_name},
155194 src_sha => substr($src->{blob_sha}, 0, 12),
156195 src_sha_full => $src->{blob_sha},
157196 basename => $basename,
158197 targets => \@targets,
159198 has_targets => scalar(@targets) ? 1 : 0,
160199 total_targets => scalar @targets,
161200 ok_ct => $ok_ct,
162201 fail_ct => $fail_ct,
163202 has_result => ($ok_ct + $fail_ct) ? 1 : 0,
164203 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}",
165207};
166208print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
167209my @body = $tpl->template('sync.html', $tvars);
168210MODS::PageWrapper->new->wrapper(
169211 page_title => 'Sync', page_key => '',
170212 body_html => join('', @body), userinfo => { display_name => 'Operator' },
171213);
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'"; }