Diff -- /var/www/vhosts/webstls.com/httpdocs/download.cgi
Diff

/var/www/vhosts/webstls.com/httpdocs/download.cgi

added on WebSTLs (webstls.com) at 2026-07-01 22:26:32

Added
+302
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to a94e1b0cbd4b
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# WebSTLs - Authenticated download endpoint.
4#
5# GET /download.cgi?order=N&item=M (download every clean file in the
6# order_item's model as a single zip)
7# GET /download.cgi?order=N&file=F (download one specific model_file)
8#
9# Authorization: the request must either come from the buyer who
10# placed the order (via webstls_buyer_session cookie on the
11# storefront's account) OR include a one-time signed token that the
12# email receipt embeds. The 30-day window is enforced by paid_at.
13#
14# Storage: this implementation handles local-disk storage today. R2/
15# B2/S3 backends will plug in once the upload pipeline writes there.
16# Until then storage_key is treated as a path relative to the private
17# files dir.
18#======================================================================
19use strict;
20use warnings;
21
22use lib '/var/www/vhosts/webstls.com/httpdocs';
23use CGI;
24use MODS::DBConnect;
25use MODS::WebSTLs::Config;
26use MODS::WebSTLs::BuyerAuth;
27use MODS::WebSTLs::SignedDownload;
28use MODS::WebSTLs::R2Sign;
29use MODS::WebSTLs::Compress;
30use Digest::SHA qw(sha256_hex);
31
32$| = 1;
33
34my $q = CGI->new;
35my $form = $q->Vars;
36my $db = MODS::DBConnect->new;
37my $cfg = MODS::WebSTLs::Config->new;
38my $buyer_auth = MODS::WebSTLs::BuyerAuth->new;
39my $DB = $cfg->settings('database_name');
40
41my $PRIVATE_FILES = $cfg->settings('private_files_dir')
42 || '/var/www/vhosts/webstls.com/private/files';
43my $DOWNLOAD_WINDOW_DAYS = 30;
44
45sub _bail {
46 my ($code, $msg) = @_;
47 print "Status: $code\nContent-Type: text/plain\n\n$msg\n";
48 exit;
49}
50
51my $oid = $form->{order} || 0;
52$oid =~ s/[^0-9]//g;
53my $item_id = $form->{item} || 0;
54$item_id =~ s/[^0-9]//g;
55my $file_id = $form->{file} || 0;
56$file_id =~ s/[^0-9]//g;
57# Bundle-component access: when set, restricts the file lookup to a
58# specific component model of the bundle on the order_items row. Lets
59# my_orders.cgi expand a digital bundle into per-file download buttons.
60my $bundle_model = $form->{bundle_model} || 0;
61$bundle_model =~ s/[^0-9]//g;
62
63_bail('404 Not Found', 'Missing order id.') unless $oid;
64
65my $dbh = $db->db_connect();
66my $order = $db->db_readwrite($dbh, qq~
67 SELECT id, storefront_id, buyer_account_id, buyer_email, status, paid_at,
68 TIMESTAMPDIFF(DAY, paid_at, NOW()) AS days_since_paid
69 FROM `${DB}`.orders
70 WHERE id='$oid' LIMIT 1
71~, $ENV{SCRIPT_NAME}, __LINE__);
72
73unless ($order && $order->{id}) {
74 $db->db_disconnect($dbh);
75 _bail('404 Not Found', 'Order not found.');
76}
77unless (($order->{status} || '') eq 'paid') {
78 $db->db_disconnect($dbh);
79 _bail('402 Payment Required', 'Order is not paid.');
80}
81if (defined $order->{days_since_paid} && $order->{days_since_paid} > $DOWNLOAD_WINDOW_DAYS) {
82 $db->db_disconnect($dbh);
83 _bail('410 Gone', 'Download window has expired.');
84}
85
86# Authorize the request via one of two paths:
87# 1. Buyer is signed in to the storefront via cookie AND the session
88# email matches the order's buyer_email.
89# 2. The URL carries a signed token (?t=...) minted by SignedDownload
90# with the same order/file/paid_at, proving it came from a receipt
91# email we sent. Guests checking out without an account can only
92# download via this path.
93# Either path counts as authorized; both checks happen so a logged-in
94# buyer can also use a receipt link if they prefer.
95my $authorized = 0;
96my $buyer = $buyer_auth->verify($q, $db, $dbh, $DB);
97if ($buyer && $buyer->{storefront_id}
98 && $buyer->{storefront_id} == $order->{storefront_id}
99 && lc($buyer->{email}) eq lc($order->{buyer_email} || '')) {
100 $authorized = 1;
101}
102if (!$authorized && $form->{t}) {
103 my $tok = $form->{t};
104 $tok =~ s/[^a-zA-Z0-9._\-]//g;
105 my $v = MODS::WebSTLs::SignedDownload->verify($tok);
106 if ($v->{ok} && $v->{order_id} == $oid) {
107 # Token is bound to a specific file_id -- restrict the request
108 # to that file if the caller didn't already specify one. If they
109 # did, we require the two match so a leaked token for file A
110 # can't be used to grab file B.
111 if (!$file_id) {
112 $file_id = $v->{file_id};
113 } elsif ($file_id != $v->{file_id}) {
114 $db->db_disconnect($dbh);
115 _bail('403 Forbidden', 'Token does not authorize this file.');
116 }
117 $authorized = 1;
118 }
119}
120unless ($authorized) {
121 $db->db_disconnect($dbh);
122 _bail('403 Forbidden', 'Sign in to download or use the link from your receipt email.');
123}
124
125# Resolve the file row. Two paths:
126# ?file=F -> serve that specific model_file (must belong to a model in this order)
127# ?item=M -> serve the primary (first clean) model_file of that order_item
128my $file;
129if ($file_id) {
130 $file = $db->db_readwrite($dbh, qq~
131 SELECT mf.id, mf.filename, mf.storage_provider, mf.storage_key,
132 mf.file_size_bytes, mf.original_size_bytes,
133 mf.is_compressed, mf.compression_algo,
134 mf.scan_status, mf.model_id
135 FROM `${DB}`.model_files mf
136 JOIN `${DB}`.order_items oi ON oi.model_id = mf.model_id
137 WHERE oi.order_id='$oid' AND mf.id='$file_id'
138 LIMIT 1
139 ~, $ENV{SCRIPT_NAME}, __LINE__);
140}
141elsif ($item_id && $bundle_model) {
142 # Bundle-component access: verify the order_item is a bundle row
143 # AND the requested model belongs to that bundle. Then serve the
144 # first clean file of that component model.
145 my $check = $db->db_readwrite($dbh, qq~
146 SELECT bi.model_id FROM `${DB}`.order_items oi
147 JOIN `${DB}`.bundle_items bi ON bi.bundle_id = oi.bundle_id
148 WHERE oi.id='$item_id' AND oi.order_id='$oid'
149 AND bi.model_id='$bundle_model' LIMIT 1
150 ~, $ENV{SCRIPT_NAME}, __LINE__);
151 if ($check && $check->{model_id}) {
152 $file = $db->db_readwrite($dbh, qq~
153 SELECT mf.id, mf.filename, mf.storage_provider, mf.storage_key,
154 mf.file_size_bytes, mf.scan_status, mf.model_id
155 FROM `${DB}`.model_files mf
156 WHERE mf.model_id='$bundle_model' AND mf.scan_status='clean'
157 ORDER BY mf.is_supported_variant DESC, mf.id ASC
158 LIMIT 1
159 ~, $ENV{SCRIPT_NAME}, __LINE__);
160 }
161}
162elsif ($item_id) {
163 $file = $db->db_readwrite($dbh, qq~
164 SELECT mf.id, mf.filename, mf.storage_provider, mf.storage_key,
165 mf.file_size_bytes, mf.original_size_bytes,
166 mf.is_compressed, mf.compression_algo,
167 mf.scan_status, mf.model_id
168 FROM `${DB}`.model_files mf
169 JOIN `${DB}`.order_items oi ON oi.model_id = mf.model_id
170 WHERE oi.id='$item_id' AND oi.order_id='$oid'
171 AND mf.scan_status='clean'
172 ORDER BY mf.is_supported_variant DESC, mf.id ASC
173 LIMIT 1
174 ~, $ENV{SCRIPT_NAME}, __LINE__);
175}
176
177unless ($file && $file->{id}) {
178 $db->db_disconnect($dbh);
179 _bail('404 Not Found', 'No downloadable file for this order item.');
180}
181unless (($file->{scan_status} || '') eq 'clean') {
182 $db->db_disconnect($dbh);
183 _bail('423 Locked', 'File is still being scanned.');
184}
185
186# Storage resolution. Two paths:
187# local -> stream the file from /var/www/.../private/files/<key>
188# r2 -> 302 redirect to a short-lived AWS-sigv4 pre-signed URL
189# at the R2 endpoint. R2 streams the bytes; our CGI just
190# hands the client a URL and exits.
191#
192# b2 / s3 would slot in here as additional providers using the same
193# pattern. The downloads ledger is written either way so usage
194# accounting is unchanged.
195my $provider = $file->{storage_provider} || 'local';
196my $ip_hash = sha256_hex(($ENV{REMOTE_ADDR} || '') . '|webstls');
197my $ua_hash = sha256_hex(($ENV{HTTP_USER_AGENT} || '') . '|webstls');
198my $fn = $file->{filename} || 'download.stl';
199$fn =~ s/["\\\r\n]/_/g;
200
201if ($provider eq 'r2') {
202 my $r2 = MODS::WebSTLs::R2Sign->new;
203 unless ($r2->is_configured) {
204 $db->db_disconnect($dbh);
205 _bail('501 Not Implemented', 'R2 is the storage provider for this file but R2 credentials are not configured. Add them in Software Configuration.');
206 }
207 my $key = $file->{storage_key} || '';
208 # We don't validate path-traversal characters on R2 keys -- the
209 # bucket namespace is flat and sigv4 will reject malformed keys
210 # via the canonical URI mismatch. But we DO reject empty keys.
211 unless (length $key) {
212 $db->db_disconnect($dbh);
213 _bail('404 Not Found', 'No storage key on the file row.');
214 }
215
216 my $url = $r2->presigned_get_url(
217 key => $key,
218 expires => 600,
219 response_content_disposition => qq~attachment; filename="$fn"~,
220 response_content_type => 'application/octet-stream',
221 );
222 unless ($url) {
223 $db->db_disconnect($dbh);
224 _bail('500 Internal Server Error', 'Could not sign R2 URL.');
225 }
226
227 # Log the download. bytes_served=0 because the actual streaming
228 # happens at R2 and we don't see the size; the file's recorded
229 # size_bytes column on model_files is the better metric for
230 # accounting.
231 $db->db_readwrite($dbh, qq~
232 INSERT INTO `${DB}`.downloads
233 SET order_id='$oid',
234 model_file_id='$file->{id}',
235 ip_hash='$ip_hash',
236 user_agent_hash='$ua_hash',
237 bytes_served='0'
238 ~, $ENV{SCRIPT_NAME}, __LINE__);
239 $db->db_disconnect($dbh);
240
241 print "Status: 302 Found\nLocation: $url\nCache-Control: no-cache, private\n\n";
242 exit;
243}
244
245if ($provider ne 'local') {
246 $db->db_disconnect($dbh);
247 _bail('501 Not Implemented', 'Remote storage provider "' . $provider . '" is not supported. Supported: local, r2.');
248}
249
250# ---- local-disk path ----
251my $key = $file->{storage_key} || '';
252if ($key =~ m{(^/|\\|\.\.)}) {
253 $db->db_disconnect($dbh);
254 _bail('400 Bad Request', 'Invalid storage key.');
255}
256my $disk_path = "$PRIVATE_FILES/$key";
257unless (-r $disk_path) {
258 $db->db_disconnect($dbh);
259 _bail('404 Not Found', 'File missing on disk.');
260}
261
262my $disk_size = -s $disk_path;
263# If we stored the file compressed, the buyer-facing size is the
264# pre-compression size we stamped at upload time. bytes_served is the
265# uncompressed payload because that's what's sent over the wire.
266my $is_compressed = $file->{is_compressed} ? 1 : 0;
267my $algo = $file->{compression_algo} || 'none';
268my $served_size = $is_compressed
269 ? ($file->{original_size_bytes} || $disk_size)
270 : $disk_size;
271
272$db->db_readwrite($dbh, qq~
273 INSERT INTO `${DB}`.downloads
274 SET order_id='$oid',
275 model_file_id='$file->{id}',
276 ip_hash='$ip_hash',
277 user_agent_hash='$ua_hash',
278 bytes_served='$served_size'
279~, $ENV{SCRIPT_NAME}, __LINE__);
280$db->db_disconnect($dbh);
281
282binmode(STDOUT);
283print "Content-Type: application/octet-stream\n";
284print "Content-Disposition: attachment; filename=\"$fn\"\n";
285print "Content-Length: $served_size\n";
286print "Cache-Control: no-cache, private\n\n";
287
288if ($is_compressed && $algo ne 'none') {
289 # Stream the decompressed bytes via zstd / gzip. The compressor
290 # binary is invoked as a child process and we pipe its stdout
291 # straight back to the browser.
292 my $c = MODS::WebSTLs::Compress->new;
293 my ($ok, $bytes, $err) = $c->decompress_to_handle($disk_path, $algo, \*STDOUT);
294 exit unless $ok;
295} else {
296 open(my $fh, '<', $disk_path) or _bail('500 Internal Server Error', 'open failed');
297 binmode($fh);
298 my $buf;
299 while (read($fh, $buf, 65536)) { print $buf; }
300 close($fh);
301}
302exit;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help