-
Notifications
You must be signed in to change notification settings - Fork 85
Expand file tree
/
Copy pathstorage_controller.rb
More file actions
828 lines (729 loc) · 32.3 KB
/
Copy pathstorage_controller.rb
File metadata and controls
828 lines (729 loc) · 32.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
# encoding: utf-8
# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See LICENSE.md for more details.
# Modified by OpenC3, Inc.
# All changes Copyright 2026, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.
require 'openc3/utilities/local_mode'
require 'openc3/utilities/bucket'
require 'openc3/utilities/bucket_utilities'
require 'openc3/utilities/ctrf'
require 'openc3/utilities/process_manager'
require 'openc3/utilities/questdb_client'
require 'openc3/logs/packet_log_reader'
require 'openc3/models/reingest_job_model'
require 'openc3/topics/telemetry_topic'
require 'fileutils'
require 'securerandom'
begin
require 'openc3-enterprise/version'
STORAGE_VERSION = OPENC3_ENTERPRISE_VERSION
rescue LoadError
require 'openc3/version'
STORAGE_VERSION = OPENC3_VERSION
end
class StorageController < ApplicationController
class StorageError < StandardError; end
# Check if a bucket requires RBAC (config and logs do, tools does not)
def bucket_requires_rbac?(bucket_param)
# Tools bucket is accessible to all users with system permission
return false if bucket_param == 'OPENC3_TOOLS_BUCKET'
# Config and logs buckets require scope-based RBAC
return true if bucket_param == 'OPENC3_CONFIG_BUCKET' || bucket_param == 'OPENC3_LOGS_BUCKET'
# Default to requiring RBAC for unknown buckets
true
end
# Extract the scope from a bucket path (first component)
# Returns nil if path is empty or at bucket root
def extract_scope_from_path(path)
return nil if path.nil? || path.empty? || path == '/'
parts = path.split('/').reject(&:empty?)
return nil if parts.empty?
parts[0]
end
# Directories in the config bucket that contain target subdirectories
CONFIG_TARGET_DIRECTORIES = ['targets', 'targets_modified', 'target_archives'].freeze
# Extract the target name from a bucket path based on known path patterns
# Config bucket: {SCOPE}/targets/{TARGET_NAME}/... or {SCOPE}/targets_modified/{TARGET_NAME}/... or {SCOPE}/target_archives/{TARGET_NAME}/...
# Logs bucket: {SCOPE}/raw_logs/{tlm|cmd}/{TARGET_NAME}/...
# Returns nil if target cannot be determined from the path
def extract_target_from_path(bucket_param, path)
return nil if path.nil? || path.empty?
parts = path.split('/').reject(&:empty?)
return nil if parts.length < 3 # Need at least scope/folder/target
if bucket_param == 'OPENC3_CONFIG_BUCKET'
# Config bucket: {SCOPE}/targets/{TARGET_NAME}/... or {SCOPE}/targets_modified/{TARGET_NAME}/... or {SCOPE}/target_archives/{TARGET_NAME}/...
if CONFIG_TARGET_DIRECTORIES.include?(parts[1])
return parts[2] if parts.length >= 3
end
elsif bucket_param == 'OPENC3_LOGS_BUCKET'
# Logs bucket: {SCOPE}/{type}_logs/{tlm|cmd}/{TARGET_NAME}/...
# Examples: DEFAULT/decom_logs/tlm/INST/..., DEFAULT/raw_logs/cmd/INST/...
if parts[1] =~ /_logs$/ && (parts[2] == 'tlm' || parts[2] == 'cmd')
return parts[3] if parts.length >= 4
end
end
nil
end
# Get the path depth where targets are listed (for filtering)
# Returns the index in the path parts array where target names appear
def target_list_depth(bucket_param, path)
return nil if path.nil? || path.empty?
parts = path.split('/').reject(&:empty?)
if bucket_param == 'OPENC3_CONFIG_BUCKET'
# Targets are listed at depth 2: {SCOPE}/targets/ or {SCOPE}/targets_modified/ or {SCOPE}/target_archives/
if parts.length == 2 && CONFIG_TARGET_DIRECTORIES.include?(parts[1])
return 2
end
elsif bucket_param == 'OPENC3_LOGS_BUCKET'
# Targets are listed at depth 3: {SCOPE}/{type}_logs/{tlm|cmd}/
if parts.length == 3 && parts[1] =~ /_logs$/ && (parts[2] == 'tlm' || parts[2] == 'cmd')
return 3
end
end
nil
end
# Authorize access to a bucket path based on scope and optionally target
# Returns true if authorized, false otherwise
# For tools bucket: always authorized (with basic system permission)
# For config/logs buckets: checks if user has access to the scope and target in the path
# When a target is in the path, uses 'tlm' permission as the baseline for access
def authorize_bucket_path(bucket_param, path, permission: 'system')
# Tools bucket doesn't require scope-based RBAC
return true unless bucket_requires_rbac?(bucket_param)
# Extract scope from the path
path_scope = extract_scope_from_path(path)
# If no scope in path (listing root), allow listing - filtering happens in the method
return true if path_scope.nil?
# Extract target from the path (may be nil if not at target level)
target_name = extract_target_from_path(bucket_param, path)
# When accessing target-specific paths, use 'tlm' permission as the baseline
# since most bucket files are telemetry-related (logs, configs)
effective_permission = target_name ? 'tlm' : permission
# Check authorization for the specific scope (and target if present) in the path
begin
authorize(
permission: effective_permission,
target_name: target_name,
scope: path_scope,
token: request.headers['HTTP_AUTHORIZATION'],
)
return true
rescue OpenC3::AuthError, OpenC3::ForbiddenError
return false
end
end
# Filter a list of targets based on user authorization
# Uses 'tlm' permission as the baseline for target access since most bucket
# files are telemetry-related (logs, configs). Users who can view telemetry
# for a target should be able to browse that target's bucket files.
def filter_authorized_targets(targets, scope, permission: 'tlm')
targets.select do |target_name|
begin
authorize(
permission: permission,
target_name: target_name,
scope: scope,
token: request.headers['HTTP_AUTHORIZATION'],
)
true
rescue OpenC3::AuthError, OpenC3::ForbiddenError
false
end
end
end
def buckets
return unless authorization('system')
# ENV.map returns a big array of mostly nils which is why we compact
# The non-nil are MatchData objects due to the regex match
matches = ENV.map { |key, _value| key.match(/^OPENC3_(.+)_BUCKET$/) }.compact
# MatchData [0] is the full text, [1] is the captured group
# downcase to make it look nicer, BucketExplorer.vue calls toUpperCase on the API requests
buckets = matches.map { |match| match[1].downcase }.sort
render json: buckets
end
def volumes
return unless authorization('system')
# ENV.map returns a big array of mostly nils which is why we compact
# The non-nil are MatchData objects due to the regex match
matches = ENV.map { |key, _value| key.match(/^OPENC3_(.+)_VOLUME$/) }.compact
# MatchData [0] is the full text, [1] is the captured group
# downcase to make it look nicer, BucketExplorer.vue calls toUpperCase on the API requests
volumes = matches.map { |match| match[1].downcase }.sort
# Add a slash prefix to identify volumes separately from buckets
volumes.map! {|volume| "/#{volume}" }
render json: volumes
end
def files
return unless authorization('system')
root = ENV.fetch(params[:root]) { |name| raise StorageError, "Unknown bucket / volume #{name}" }
results = []
if params[:root].include?('_BUCKET')
bucket = OpenC3::Bucket.getClient()
path = sanitize_path(params[:path])
path = '/' if path.empty?
# Check scope-based RBAC for config and logs buckets
if bucket_requires_rbac?(params[:root])
path_scope = extract_scope_from_path(path)
if path_scope
# Accessing a specific scope - verify authorization
unless authorize_bucket_path(params[:root], path)
render json: { status: 'error', message: "Not authorized for scope: #{path_scope}" }, status: :forbidden
return
end
end
# If at root level (no scope), we'll filter the results below
end
# if user wants metadata returned
metadata = params[:metadata].present? ? true : false
results = bucket.list_files(bucket: root, path: path, metadata: metadata)
# Filter results based on RBAC
if bucket_requires_rbac?(params[:root])
if path == '/' || path.empty?
# At bucket root level - filter to only show scopes the user has access to
# results[0] contains directories (scopes at root level)
if results[0].is_a?(Array)
results[0] = results[0].select do |dir_name|
begin
authorize(
permission: 'system',
scope: dir_name,
token: request.headers['HTTP_AUTHORIZATION'],
)
true
rescue OpenC3::AuthError, OpenC3::ForbiddenError
false
end
end
end
else
# Check if we're at a target listing level and filter targets
target_depth = target_list_depth(params[:root], path)
if target_depth && results[0].is_a?(Array)
path_scope = extract_scope_from_path(path)
results[0] = filter_authorized_targets(results[0], path_scope)
end
end
end
elsif params[:root].include?('_VOLUME')
dirs = []
files = []
path = sanitize_path(params[:path])
list = Dir["/#{root}/#{path}/*"] # Ok for path to be blank
list.each do |file|
if File.directory?(file)
dirs << File.basename(file)
else
stat = File.stat(file)
files << { name: File.basename(file), size: stat.size, modified: stat.mtime }
end
end
results << dirs
results << files
else
raise StorageError, "Unknown root #{params[:root]}"
end
render json: results
rescue OpenC3::Bucket::NotFound => e
log_error(e)
render json: { status: 'error', message: e.message }, status: :not_found
rescue Exception => e
log_error(e)
OpenC3::Logger.error("File listing failed: #{e.message}", user: username())
render json: { status: 'error', message: e.message }, status: :internal_server_error
end
def exists
return unless authorization('system')
params.require(:bucket)
bucket_name = ENV.fetch(params[:bucket]) { |name| raise StorageError, "Unknown bucket #{name}" }
path = sanitize_path(params[:object_id])
# Check scope-based RBAC for config and logs buckets
if bucket_requires_rbac?(params[:bucket])
unless authorize_bucket_path(params[:bucket], path)
path_scope = extract_scope_from_path(path)
render json: { status: 'error', message: "Not authorized for scope: #{path_scope}" }, status: :forbidden
return
end
end
bucket = OpenC3::Bucket.getClient()
# Returns true or false if the object is found
result = bucket.check_object(bucket: bucket_name,
key: path,
retries: false)
if result
render json: result
else
render json: result, status: :not_found
end
rescue Exception => e
log_error(e)
OpenC3::Logger.error("File exists request failed: #{e.message}", user: username())
render json: { status: 'error', message: e.message }, status: :internal_server_error
end
def download_file
return unless authorization('system')
tmp_dir = nil
begin
storage_type, storage_name = validate_storage_source
object_id = sanitize_path(params[:object_id])
# Guard against a missing / literal 'null' object_id (e.g. frontend sending
# an undefined log or report filename). Return 404 instead of a 500.
if object_id.empty? || object_id == 'null'
render json: { status: 'error', message: "Invalid object_id: #{params[:object_id]}" }, status: :not_found
return
end
# Check scope-based RBAC for bucket downloads
if storage_type == :bucket && params[:bucket] && bucket_requires_rbac?(params[:bucket])
unless authorize_bucket_path(params[:bucket], object_id)
path_scope = extract_scope_from_path(object_id)
render json: { status: 'error', message: "Not authorized for scope: #{path_scope}" }, status: :forbidden
return
end
end
filename = if storage_type == :volume
sanitize_path("/#{storage_name}/#{object_id}")
else
tmp_dir = Dir.mktmpdir
temp_path = File.join(tmp_dir, object_id)
FileUtils.mkdir_p(File.dirname(temp_path))
OpenC3::Bucket.getClient().get_object(bucket: storage_name, key: object_id, path: temp_path)
temp_path
end
# get_object returns nil for a missing key (never writes temp_path), and a
# volume file may not exist. Return 404 rather than letting File.read 500.
unless File.exist?(filename)
render json: { status: 'error', message: "File not found: #{params[:object_id]}" }, status: :not_found
return
end
file = File.read(filename, mode: 'rb')
# Check if CTRF conversion is requested
if params[:format] == 'ctrf'
file_content = file.force_encoding('UTF-8')
ctrf_data = OpenC3::Ctrf.convert_report(file_content, version: STORAGE_VERSION)
render json: { filename: "#{File.basename(params[:object_id], '.*')}.ctrf.json", contents: Base64.encode64(ctrf_data.to_json) }
else
render json: { filename: params[:object_id], contents: Base64.encode64(file) }
end
rescue Exception => e
log_error(e)
OpenC3::Logger.error("Download failed: #{e.message}", user: username())
render json: { status: 'error', message: e.message }, status: :internal_server_error
ensure
FileUtils.remove_entry_secure(tmp_dir, true) if tmp_dir
end
end
def download_multiple_files
return unless authorization('system')
tmp_dir = Dir.mktmpdir
zip_path = File.join(tmp_dir, 'download.zip')
begin
files = params[:files] || []
raise StorageError, "No files specified" if files.empty?
path = sanitize_path(params[:path] || '')
storage_type, storage_name = validate_storage_source
# Check scope-based RBAC for bucket downloads
if storage_type == :bucket && params[:bucket] && bucket_requires_rbac?(params[:bucket])
unless authorize_bucket_path(params[:bucket], path)
path_scope = extract_scope_from_path(path)
render json: { status: 'error', message: "Not authorized for scope: #{path_scope}" }, status: :forbidden
return
end
end
# Create zip file with files from storage
Zip::File.open(zip_path, create: true) do |zipfile|
if storage_type == :volume
add_volume_files_to_zip(zipfile, storage_name, path, files)
else
add_bucket_files_to_zip(zipfile, storage_name, path, files, tmp_dir)
end
end
# Read the zip file and encode it
zip_data = File.read(zip_path, mode: 'rb')
zip_filename = "download_#{Time.now.strftime('%Y%m%d_%H%M%S')}.zip"
render json: { filename: zip_filename, contents: Base64.encode64(zip_data) }
rescue Exception => e
log_error(e)
OpenC3::Logger.error("Multiple file download failed: #{e.message}", user: username())
render json: { status: 'error', message: e.message }, status: :internal_server_error
ensure
FileUtils.remove_entry_secure(tmp_dir, true) if tmp_dir
end
end
def get_download_presigned_request
return unless authorization('system')
params.require(:bucket)
bucket_name = ENV.fetch(params[:bucket]) { |name| raise StorageError, "Unknown bucket #{name}" }
path = sanitize_path(params[:object_id])
# Check scope-based RBAC for config and logs buckets
if bucket_requires_rbac?(params[:bucket])
unless authorize_bucket_path(params[:bucket], path)
path_scope = extract_scope_from_path(path)
render json: { status: 'error', message: "Not authorized for scope: #{path_scope}" }, status: :forbidden
return
end
end
bucket = OpenC3::Bucket.getClient()
result = bucket.presigned_request(bucket: bucket_name,
key: path,
method: :get_object,
internal: params[:internal])
render json: result, status: :created
rescue Exception => e
log_error(e)
OpenC3::Logger.error("Download request failed: #{e.message}", user: username())
render json: { status: 'error', message: e.message }, status: :internal_server_error
end
def get_upload_presigned_request
return unless authorization('system_set')
params.require(:bucket)
bucket_name = ENV.fetch(params[:bucket]) { |name| raise StorageError, "Unknown bucket #{name}" }
path = sanitize_path(params[:object_id])
# Check scope-based RBAC for config and logs buckets
if bucket_requires_rbac?(params[:bucket])
unless authorize_bucket_path(params[:bucket], path, permission: 'system_set')
path_scope = extract_scope_from_path(path)
render json: { status: 'error', message: "Not authorized for scope: #{path_scope}" }, status: :forbidden
return
end
end
# Non-admins may only write the user-writable overlay (config/SCOPE/targets_modified
# or config/SCOPE/tmp), and never the cmd_tlm overlay, which PacketConfig ERB-renders
# and whose GENERIC_*_CONVERSION blocks it evaluates as code. Everything else is admin.
return if !non_admin_config_overlay_write?(params[:bucket], path) && !authorization('admin')
bucket = OpenC3::Bucket.getClient()
result = bucket.presigned_request(bucket: bucket_name,
key: path,
method: :put_object,
internal: params[:internal])
OpenC3::Logger.info("S3 upload presigned request generated: #{bucket_name}/#{path}",
scope: params[:scope], user: username())
render json: result, status: :created
rescue Exception => e
log_error(e)
OpenC3::Logger.error("Upload request failed: #{e.message}", user: username())
render json: { status: 'error', message: e.message }, status: :internal_server_error
end
def delete
return unless authorization('system_set')
if params[:bucket].presence
return unless delete_bucket_item(params)
elsif params[:volume].presence
return unless delete_volume_item(params)
else
raise "Must pass bucket or volume parameter!"
end
head :ok
rescue Exception => e
log_error(e)
OpenC3::Logger.error("Delete failed: #{e.message}", user: username())
render json: { status: 'error', message: e.message }, status: :internal_server_error
end
def delete_directory
return unless authorization('system_set')
if params[:bucket].presence
return unless delete_bucket_directory(params)
elsif params[:volume].presence
return unless delete_volume_directory(params)
else
raise StorageError, "Must pass bucket or volume parameter!"
end
rescue Exception => e
log_error(e)
OpenC3::Logger.error("Delete directory failed: #{e.message}", user: username())
render json: { status: 'error', message: e.message }, status: :internal_server_error
end
def reingest_files
return unless authorization('admin')
files = params[:files] || []
raise StorageError, "No files specified" if files.empty?
invalid = files.reject { |f| f.end_with?('.bin.gz') }
raise StorageError, "Only .bin.gz files can be reingested: #{invalid.join(', ')}" if invalid.any?
# Reject path traversal, absolute paths, or null bytes in filenames before
# they reach the job's temp-file layout. sanitize_path already rejects '..'.
files = files.map do |f|
raise StorageError, "Invalid filename: #{f}" if f.to_s.empty? || f.to_s.start_with?('/') || f.to_s.include?("\0")
sanitize_path(f)
end
path = sanitize_path(params[:path] || '')
storage_type, _storage_name = validate_storage_source
raise StorageError, "Reingest only supported for buckets" unless storage_type == :bucket
scope = params[:scope] || 'DEFAULT'
if params[:bucket] && bucket_requires_rbac?(params[:bucket]) && !authorize_bucket_path(params[:bucket], path)
path_scope = extract_scope_from_path(path)
render json: { status: 'error', message: "Not authorized for scope: #{path_scope}" }, status: :forbidden
return
end
target_version = params[:target_version].to_s
target_version = 'as_logged' if target_version.empty?
unless %w[as_logged current].include?(target_version) || target_version.match?(/\A[a-f0-9]{32,}\z/)
raise StorageError, "Invalid target_version: #{target_version}"
end
job_id = SecureRandom.uuid
job = OpenC3::ReingestJobModel.new(
name: job_id,
files: files,
bucket: params[:bucket],
path: path,
target_version: target_version,
scope: scope,
)
job.create
# Run the reingest in its own process so System.reset_instance! / setup_targets
# in ReingestJob can't race with the cmd-tlm-api server's System singleton.
timeout = (ENV['OPENC3_REINGEST_TIMEOUT_SECS'] || 24 * 3600).to_i
OpenC3::ProcessManager.instance.spawn(
["ruby", "/openc3/bin/openc3cli", "reingest", job_id, scope],
"reingest", job_id, Time.now + timeout, scope: scope,
)
OpenC3::Logger.info("Reingest job #{job_id} enqueued: #{files.length} file(s)", user: username())
render json: { job_id: job_id, state: 'Queued' }, status: :accepted
rescue Exception => e
log_error(e)
OpenC3::Logger.error("Reingest enqueue failed: #{e.message}", user: username())
render json: { status: 'error', message: e.message }, status: :internal_server_error
end
def reingest_status
return unless authorization('admin')
scope = params[:scope] || 'DEFAULT'
job = OpenC3::ReingestJobModel.get_model(name: params[:job_id], scope: scope)
if job.nil?
render json: { status: 'error', message: "Reingest job not found: #{params[:job_id]}" }, status: :not_found
return
end
render json: job.as_json, status: :ok
rescue Exception => e
log_error(e)
render json: { status: 'error', message: e.message }, status: :internal_server_error
end
def repair_candidates
return unless authorization('admin')
scope = (params[:scope] || 'DEFAULT').to_s
target = params[:target].to_s
cmd_or_tlm = (params[:cmd_or_tlm] || 'TLM').to_s.upcase
raise StorageError, "cmd_or_tlm must be TLM (CMD repair not yet supported)" unless cmd_or_tlm == 'TLM'
raise StorageError, "target is required" if target.empty?
# scope and target are interpolated into an S3 key prefix below; reject
# anything that isn't a plain identifier so path separators, traversal
# sequences, and control characters can't reshape the prefix.
raise StorageError, "Invalid scope: #{scope}" unless scope.match?(/\A[A-Z0-9_]+\z/i)
raise StorageError, "Invalid target: #{target}" unless target.match?(/\A[A-Z0-9_]+\z/i)
start_time = parse_repair_time(params[:start_time])
end_time = parse_repair_time(params[:end_time])
bucket_env = 'OPENC3_LOGS_BUCKET'
bucket_name = ENV.fetch(bucket_env) { |n| raise StorageError, "Unknown bucket #{n}" }
prefix = "#{scope}/raw_logs/tlm/#{target}"
if bucket_requires_rbac?(bucket_env) && !authorize_bucket_path(bucket_env, "#{prefix}/")
render json: { status: 'error', message: "Not authorized for scope: #{scope}" }, status: :forbidden
return
end
keys = OpenC3::BucketUtilities.files_between_time(
bucket_name, prefix, start_time, end_time,
file_suffix: '.bin.gz', overlap: true,
)
files = keys.map do |key|
basename = key.split('/').last
{ 'key' => key, 'filename' => basename }
end
render json: {
bucket: bucket_env,
path: "#{prefix}/",
files: files,
}, status: :ok
rescue Exception => e
log_error(e)
render json: { status: 'error', message: e.message }, status: :internal_server_error
end
private
# True if the given config-bucket key is an overlay path a non-admin is allowed
# to write. Non-admins may write config/SCOPE/targets_modified/... and
# config/SCOPE/tmp/..., but NOT the cmd_tlm overlay
# (config/SCOPE/targets_modified/TARGET/cmd_tlm/...), which is loaded and
# executed as code by PacketConfig (ERB rendering + GENERIC_*_CONVERSION eval).
#
# The key must be canonical: a positional segment check (parts[1], parts[3])
# can otherwise be bypassed by a key the object store normalizes differently,
# so empty '//' segments, '.'/'..' segments, and leading/trailing slashes are
# rejected here (forcing the admin path). sanitize_path already rejects '..'.
def non_admin_config_overlay_write?(bucket_param, path)
return false unless bucket_param == 'OPENC3_CONFIG_BUCKET'
return false if path.nil? || path.empty?
return false if path.start_with?('/') || path.end_with?('/')
parts = path.split('/')
return false if parts.any? { |p| p.empty? || p == '.' || p == '..' }
# parts: SCOPE / <area> / TARGET / <subdir> / ...
area = parts[1]
return false unless area == 'targets_modified' || area == 'tmp'
# cmd_tlm overlay (parts[3]) is code-execution territory; require admin.
return false if area == 'targets_modified' && parts[3] == 'cmd_tlm'
true
end
def sanitize_path(path)
return '' if path.nil?
# path is passed as a parameter thus we have to sanitize it or the code scanner detects:
# "Uncontrolled data used in path expression"
# This method is taken directly from the Rails source:
# https://api.rubyonrails.org/v5.2/classes/ActiveStorage/Filename.html#method-i-sanitized
# NOTE: I removed the '/' character because we have to allow this in order to traverse the path
sanitized = path.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�").strip.tr("\u{202E}%$|:;\t\r\n\\", "-").gsub('..', '-')
if sanitized != path
raise StorageError, "Invalid path: #{path}"
end
sanitized
end
def delete_bucket_item(params)
params.require(:bucket)
bucket_name = ENV.fetch(params[:bucket]) { |name| raise StorageError, "Unknown bucket #{name}" }
path = sanitize_path(params[:object_id])
# Check scope-based RBAC for config and logs buckets
if bucket_requires_rbac?(params[:bucket])
unless authorize_bucket_path(params[:bucket], path, permission: 'system_set')
path_scope = extract_scope_from_path(path)
render json: { status: 'error', message: "Not authorized for scope: #{path_scope}" }, status: :forbidden
return false
end
end
# Non-admins may only touch the user-writable config overlay (targets_modified or
# tmp), never the cmd_tlm overlay which is evaluated as code. Everything else is admin.
authorized = non_admin_config_overlay_write?(params[:bucket], path) || authorization('admin')
if authorized
if ENV.fetch('OPENC3_LOCAL_MODE', false)
OpenC3::LocalMode.delete_local(path)
end
OpenC3::Bucket.getClient().delete_object(bucket: bucket_name, key: path)
OpenC3::Logger.info("Deleted: #{bucket_name}/#{path}", scope: params[:scope], user: username())
return true
else
return false
end
end
def delete_volume_item(params)
# Deleting requires admin
if authorization('admin')
volume = ENV.fetch(params[:volume]) { |name| raise StorageError, "Unknown volume #{name}" }
filename = "/#{volume}/#{params[:object_id]}"
filename = sanitize_path(filename)
FileUtils.rm filename
OpenC3::Logger.info("Deleted: #{filename}", scope: params[:scope], user: username())
return true
else
return false
end
end
def delete_bucket_directory(params)
bucket_name = ENV.fetch(params[:bucket]) { |name| raise StorageError, "Unknown bucket #{name}" }
path = sanitize_path(params[:object_id])
# Evaluate the overlay check on the canonical (un-slashed) path before appending
# the trailing slash, since the helper rejects trailing slashes.
overlay_write = non_admin_config_overlay_write?(params[:bucket], path)
path = "#{path}/" unless path.end_with?('/')
# Check scope-based RBAC
if bucket_requires_rbac?(params[:bucket]) && !authorize_bucket_path(params[:bucket], path, permission: 'system_set')
path_scope = extract_scope_from_path(path)
render json: { status: 'error', message: "Not authorized for scope: #{path_scope}" }, status: :forbidden
return false
end
# Non-admins may only touch the user-writable config overlay (targets_modified or
# tmp), never the cmd_tlm overlay which is evaluated as code. Everything else is admin.
return false unless overlay_write || authorization('admin')
# List all objects under the prefix (same pattern as TargetModel#undeploy)
bucket = OpenC3::Bucket.getClient()
objects = bucket.list_objects(bucket: bucket_name, prefix: path)
keys = objects.map(&:key)
if keys.empty?
render json: { deleted_count: 0 }
return true
end
# Delete in batches of 1000 (S3 limit)
deleted_count = 0
keys.each_slice(1000) do |key_batch|
bucket.delete_objects(bucket: bucket_name, keys: key_batch)
deleted_count += key_batch.length
# Handle local mode
if ENV.fetch('OPENC3_LOCAL_MODE', false)
key_batch.each { |key| OpenC3::LocalMode.delete_local(key) }
end
end
OpenC3::Logger.info("Deleted directory: #{bucket_name}/#{path} (#{deleted_count} files)",
scope: params[:scope], user: username())
render json: { deleted_count: deleted_count }
true
end
def delete_volume_directory(params)
return false unless authorization('admin')
volume = ENV.fetch(params[:volume]) { |name| raise StorageError, "Unknown volume #{name}" }
path = sanitize_path(params[:object_id])
full_path = "/#{volume}/#{path}"
unless File.directory?(full_path)
render json: { status: 'error', message: "Not a directory: #{path}" }, status: :bad_request
return false
end
# Count files before deletion for reporting
file_count = Dir.glob("#{full_path}/**/*").count { |f| File.file?(f) }
FileUtils.rm_rf(full_path)
OpenC3::Logger.info("Deleted directory: #{full_path} (#{file_count} files)",
scope: params[:scope], user: username())
render json: { deleted_count: file_count }
true
end
# Validates and returns storage source information
# @return [Array<Symbol, String>] Storage type (:volume or :bucket) and storage name
# Accepts a time param as nsec-from-epoch integer, an ISO8601 string, or nil.
# Returns a Ruby Time (UTC) or nil.
def parse_repair_time(value)
return nil if value.nil? || value.to_s.empty?
s = value.to_s
if s.match?(/\A\d+\z/)
# Raw logs use nsec-from-epoch throughout the codebase; use that.
Time.from_nsec_from_epoch(s.to_i)
else
Time.parse(s).utc
end
end
def validate_storage_source
if params[:volume]
volume = ENV.fetch(params[:volume]) { |name| raise StorageError, "Unknown volume #{name}" }
[:volume, volume]
elsif params[:bucket]
bucket = ENV.fetch(params[:bucket]) { |name| raise StorageError, "Unknown bucket #{name}" }
[:bucket, bucket]
else
raise StorageError, "No volume or bucket given"
end
end
# Adds files from a volume to the zip archive
def add_volume_files_to_zip(zipfile, volume_name, path, files)
volume_path = "/#{volume_name}"
files.each do |filename|
file_path = "#{volume_path}/#{path}#{filename}"
file_path = sanitize_path(file_path)
if File.exist?(file_path)
zipfile.add(filename, file_path)
else
OpenC3::Logger.warn("File not found: #{file_path}", user: username())
end
end
end
# Adds files from a bucket to the zip archive
def add_bucket_files_to_zip(zipfile, bucket_name, path, files, tmp_dir)
bucket = OpenC3::Bucket.getClient()
files.each do |filename|
key = "#{path}#{filename}"
key = sanitize_path(key)
temp_file = File.join(tmp_dir, filename)
begin
bucket.get_object(bucket: bucket_name, key: key, path: temp_file)
zipfile.add(filename, temp_file)
rescue => e
OpenC3::Logger.warn("Failed to download #{key}: #{e.message}", user: username())
end
end
end
end