Diff -- /var/www/vhosts/3dshawn.com/repricer.3dshawn.com/_attic/download.cgi
Diff

/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/_attic/download.cgi

added on local at 2026-07-01 21:47:32

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