capsium 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +1 -4
  3. data/.github/workflows/release.yml +1 -2
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +34 -3
  6. data/CHANGELOG.md +58 -0
  7. data/Gemfile +4 -4
  8. data/LICENSE +21 -0
  9. data/README.adoc +177 -135
  10. data/Rakefile +8 -1
  11. data/bin/console +1 -1
  12. data/capsium.gemspec +16 -23
  13. data/exe/capsium +1 -1
  14. data/lib/capsium/cli/convert.rb +18 -0
  15. data/lib/capsium/cli/package.rb +86 -0
  16. data/lib/capsium/cli/reactor.rb +25 -0
  17. data/lib/capsium/cli.rb +5 -79
  18. data/lib/capsium/converters/jekyll.rb +62 -31
  19. data/lib/capsium/converters.rb +7 -0
  20. data/lib/capsium/package/dataset.rb +76 -56
  21. data/lib/capsium/package/manifest.rb +41 -50
  22. data/lib/capsium/package/manifest_config.rb +64 -0
  23. data/lib/capsium/package/metadata.rb +11 -29
  24. data/lib/capsium/package/metadata_config.rb +105 -0
  25. data/lib/capsium/package/routes.rb +51 -111
  26. data/lib/capsium/package/routes_config.rb +150 -0
  27. data/lib/capsium/package/security.rb +123 -0
  28. data/lib/capsium/package/security_config.rb +60 -0
  29. data/lib/capsium/package/storage.rb +26 -26
  30. data/lib/capsium/package/storage_config.rb +106 -0
  31. data/lib/capsium/package/validator.rb +149 -0
  32. data/lib/capsium/package.rb +84 -70
  33. data/lib/capsium/packager.rb +60 -62
  34. data/lib/capsium/reactor/introspection.rb +88 -0
  35. data/lib/capsium/reactor.rb +108 -36
  36. data/lib/capsium/version.rb +1 -1
  37. data/lib/capsium.rb +7 -9
  38. data/sig/capsium/package/dataset.rbs +28 -0
  39. data/sig/capsium/package/manifest.rbs +34 -0
  40. data/sig/capsium/package/manifest_config.rbs +38 -0
  41. data/sig/capsium/package/metadata.rbs +26 -0
  42. data/sig/capsium/package/metadata_config.rbs +49 -0
  43. data/sig/capsium/package/routes.rbs +37 -0
  44. data/sig/capsium/package/routes_config.rbs +74 -0
  45. data/sig/capsium/package/security.rbs +65 -0
  46. data/sig/capsium/package/security_config.rbs +57 -0
  47. data/sig/capsium/package/storage.rbs +28 -0
  48. data/sig/capsium/package/storage_config.rbs +61 -0
  49. data/sig/capsium/package/validator.rbs +29 -0
  50. data/sig/capsium/package.rbs +53 -0
  51. data/sig/capsium/packager.rbs +23 -0
  52. data/sig/capsium/reactor/introspection.rbs +31 -0
  53. data/sig/capsium/reactor.rbs +30 -0
  54. data/sig/capsium.rbs +3 -1
  55. metadata +63 -146
  56. data/images/005df7415a331c466ad2d9a42efdd212b6f42fe50b5fd3b3174c86c706f58244.png +0 -0
  57. data/images/0374025b3af99b8a473282c8cbbf9fcd29573cf41e586982f328f86c0690f43d.png +0 -0
  58. data/images/0bb4da785be40ef58e219470ebccb979325928b75453dc46bac23c6ee8a7a7cb.png +0 -0
  59. data/images/6aa294dccc81af594aacbe804e7ddffdc17eacc28357338108aea5d021d831ff.png +0 -0
  60. data/images/72dd3fbf3f4b475e27a0e7fb8137c475c32c41f8d222bcf62d6a9ccf102d9532.png +0 -0
  61. data/images/8772b6961d169738d7b0fa0b669b06fc2f40632d4c62586c7634fc17b93182a3.png +0 -0
  62. data/images/a998d842405933d45723606ff3f70162ec95b4ef30db25464a366184fd08fb9b.png +0 -0
  63. data/images/aa8980547e8c003d33273ab4d80e62da7f317bd7581b293c06d67f5331f24f31.png +0 -0
  64. data/images/bb78a872b539e0e9b2d80dee58acbb688f3f2727b324a5bf8bf417a69d94a166.png +0 -0
  65. data/images/c48fc83b17725d85fbb64d971196ebfccd8c5c757fe6aa5845303f6e315879b6.png +0 -0
  66. data/images/f08ef07308d08119ac2124bb7428c8bef17ef1ca70045696604d6e83015a9b91.png +0 -0
  67. data/images/f7514206111b695647eae9adfcf498ba3e0ff83ecfe25f3fc3ed8e9f04c5c726-1.png +0 -0
  68. data/images/f7514206111b695647eae9adfcf498ba3e0ff83ecfe25f3fc3ed8e9f04c5c726.png +0 -0
  69. data/lib/capsium/protector.rb +0 -95
@@ -1,39 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package.rb
4
3
  require "fileutils"
5
- require "json"
6
- require "yaml"
7
- require "csv"
8
- require "sqlite3"
9
- require "zip"
10
- require_relative "package/manifest"
11
- require_relative "package/metadata"
12
- require_relative "package/routes"
13
- require_relative "package/dataset"
14
- require_relative "package/storage"
15
- require_relative "packager"
4
+ require "pathname"
5
+ require "tmpdir"
16
6
 
17
7
  module Capsium
18
8
  class Package
19
- attr_reader :name, :path, :manifest, :metadata, :routes, :datasets, :storage
9
+ autoload :Dataset, "capsium/package/dataset"
10
+ autoload :DatasetConfig, "capsium/package/storage_config"
11
+ autoload :DigitalSignatures, "capsium/package/security_config"
12
+ autoload :IntegrityChecks, "capsium/package/security_config"
13
+ autoload :Manifest, "capsium/package/manifest"
14
+ autoload :ManifestConfig, "capsium/package/manifest_config"
15
+ autoload :Metadata, "capsium/package/metadata"
16
+ autoload :MetadataData, "capsium/package/metadata_config"
17
+ autoload :Repository, "capsium/package/metadata_config"
18
+ autoload :Resource, "capsium/package/manifest_config"
19
+ autoload :Route, "capsium/package/routes_config"
20
+ autoload :Routes, "capsium/package/routes"
21
+ autoload :RoutesConfig, "capsium/package/routes_config"
22
+ autoload :Security, "capsium/package/security"
23
+ autoload :SecurityConfig, "capsium/package/security_config"
24
+ autoload :SecurityData, "capsium/package/security_config"
25
+ autoload :Storage, "capsium/package/storage"
26
+ autoload :StorageConfig, "capsium/package/storage_config"
27
+ autoload :StorageData, "capsium/package/storage_config"
28
+ autoload :Validator, "capsium/package/validator"
29
+
30
+ attr_reader :name, :path, :manifest, :metadata, :routes, :storage,
31
+ :security, :load_type
20
32
 
21
33
  MANIFEST_FILE = "manifest.json"
22
34
  METADATA_FILE = "metadata.json"
23
- PACKAGING_FILE = "packaging.json"
24
- SIGNATURE_FILE = "signature.json"
25
35
  STORAGE_FILE = "storage.json"
26
36
  ROUTES_FILE = "routes.json"
37
+ SECURITY_FILE = "security.json"
27
38
  CONTENT_DIR = "content"
28
39
  DATA_DIR = "data"
29
- ENCRYPTED_PACKAGING_FILE = "package.enc"
30
40
 
31
- def initialize(path)
41
+ def initialize(path, load_type: nil)
32
42
  @original_path = Pathname.new(path).expand_path
33
- @path = prepare_package(@original_path)
43
+ @path = prepare_package(@original_path).to_s
44
+ @load_type = load_type || determine_load_type(path)
34
45
  create_package_structure
35
46
  load_package
36
47
  @name = metadata.name
48
+ verify_integrity!
37
49
  end
38
50
 
39
51
  def prepare_package(path)
@@ -42,47 +54,23 @@ module Capsium
42
54
  if File.file?(path)
43
55
  return decompress_cap_file(path) if File.extname(path) == ".cap"
44
56
 
45
- raise "Error: The package must have a .cap extension"
46
-
57
+ raise Error, "The package must have a .cap extension"
47
58
  end
48
59
 
49
- raise "Invalid package path: #{path}"
60
+ raise Error, "Invalid package path: #{path}"
50
61
  end
51
62
 
52
63
  def solidify
53
64
  @manifest.save_to_file
54
65
  @metadata.save_to_file
55
66
  @routes.save_to_file
56
- @storage.save_to_file
67
+ @storage.save_to_file unless @storage.empty?
57
68
  end
58
69
 
59
70
  def decompress_cap_file(file_path)
60
- temp_dir = Dir.mktmpdir
61
- metadata_path = File.join(temp_dir, METADATA_FILE)
62
-
63
- # Extract metadata.json first
64
- Zip::File.open(file_path) do |zip_file|
65
- if entry = zip_file.find_entry(METADATA_FILE)
66
- entry.extract(metadata_path)
67
- end
68
- end
69
-
70
- metadata = Metadata.new(metadata_path)
71
- package_name = metadata.name
72
- package_version = metadata.version
73
- package_dependencies = metadata.dependencies
74
-
75
- package_path = File.join(temp_dir, "#{package_name}-#{package_version}")
71
+ package_path = File.join(Dir.mktmpdir, package_stem(file_path))
76
72
  FileUtils.mkdir_p(package_path)
77
-
78
- Zip::File.open(file_path) do |zip_file|
79
- zip_file.each do |entry|
80
- entry_path = File.join(package_path, entry.name)
81
- FileUtils.mkdir_p(File.dirname(entry_path))
82
- entry.extract(entry_path)
83
- end
84
- end
85
-
73
+ Packager.new.unpack(file_path, package_path)
86
74
  package_path
87
75
  end
88
76
 
@@ -92,51 +80,77 @@ module Capsium
92
80
 
93
81
  # Optional
94
82
  @manifest = Manifest.new(manifest_path)
95
- @routes = Routes.new(routes_path, @manifest)
96
83
  @storage = Storage.new(storage_path)
97
- # @datasets = load_datasets
84
+ @routes = Routes.new(routes_path, @manifest, @storage)
85
+ @security = Security.new(security_path)
98
86
  end
99
87
 
100
88
  def cleanup
101
- return unless @path != @original_path && File.directory?(@path)
89
+ return unless @path != @original_path.to_s && File.directory?(@path)
102
90
 
103
91
  FileUtils.remove_entry(@path)
104
92
  end
105
93
 
106
- def package_files
107
- @packager.package_files
94
+ def datasets
95
+ storage.datasets
108
96
  end
109
97
 
110
- private
111
-
112
- def create_package_structure
113
- FileUtils.mkdir_p(@path)
114
- FileUtils.mkdir_p(content_path)
115
- FileUtils.mkdir_p(data_path)
98
+ # The .cap file this package was loaded from, or nil when loaded
99
+ # from a directory.
100
+ def cap_file_path
101
+ @original_path.to_s if load_type == :cap_file
116
102
  end
117
103
 
118
- def content_path
119
- File.join(@path, CONTENT_DIR)
104
+ def content_files
105
+ Dir.glob(File.join(content_path, "**", "*")).select do |file|
106
+ File.file?(file)
107
+ end
120
108
  end
121
109
 
122
- def data_path
123
- File.join(@path, DATA_DIR)
110
+ def determine_load_type(path)
111
+ return :directory if File.directory?(path)
112
+ return :cap_file if File.extname(path) == ".cap"
113
+
114
+ :unsaved
124
115
  end
125
116
 
126
- def routes_path
127
- File.join(@path, ROUTES_FILE)
117
+ # Verifies the package against security.json (ARCHITECTURE.md section
118
+ # 6). Returns a list of typed errors; empty when no security.json is
119
+ # present or all checksums match.
120
+ def verify_integrity
121
+ return [] unless @security.present?
122
+
123
+ @security.verify(@path)
128
124
  end
129
125
 
130
- def storage_path
131
- File.join(@path, STORAGE_FILE)
126
+ def verify_integrity!
127
+ @security.verify!(@path) if @security.present?
132
128
  end
133
129
 
134
- def metadata_path
135
- File.join(@path, METADATA_FILE)
130
+ private
131
+
132
+ def package_stem(file_path)
133
+ File.basename(file_path, ".cap")
136
134
  end
137
135
 
138
- def manifest_path
139
- File.join(@path, MANIFEST_FILE)
136
+ def create_package_structure
137
+ FileUtils.mkdir_p(@path)
138
+ FileUtils.mkdir_p(content_path)
139
+ FileUtils.mkdir_p(data_path)
140
140
  end
141
+
142
+ def content_path = File.join(@path, CONTENT_DIR)
143
+
144
+ def data_path = File.join(@path, DATA_DIR)
145
+
146
+ def routes_path = File.join(@path, ROUTES_FILE)
147
+
148
+ def storage_path = File.join(@path, STORAGE_FILE)
149
+
150
+ def metadata_path = File.join(@path, METADATA_FILE)
151
+
152
+ def manifest_path = File.join(@path, MANIFEST_FILE)
153
+
154
+ def security_path = File.join(@path, SECURITY_FILE)
141
155
  end
142
156
  end
@@ -1,100 +1,98 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/packager.rb
4
- require "json"
5
3
  require "fileutils"
6
- require "rubygems"
4
+ require "pathname"
5
+ require "tmpdir"
7
6
  require "zip"
8
7
 
9
8
  module Capsium
10
9
  class Packager
11
10
  class FileAlreadyExistsError < StandardError; end
12
11
 
13
- def pack(package, options = {})
14
- package_name = package.metadata.name
15
- package_version = package.metadata.version
16
- cap_file_path = File.expand_path(
17
- File.join(package.path, "..", "#{package_name}-#{package_version}.cap")
18
- )
12
+ # Raised when a .cap archive contains an entry whose name would be
13
+ # written outside the destination directory (absolute paths, drive
14
+ # letters, ".." segments) — zip-slip protection, on par with the Lua
15
+ # reactor's extractor.
16
+ class UnsafeEntryError < Capsium::Error; end
19
17
 
20
- if File.exist?(cap_file_path)
21
- unless options[:force]
22
- raise FileAlreadyExistsError,
23
- "Package target already exists, aborting: `#{relative_path_current(cap_file_path)}`"
24
- end
18
+ DRIVE_LETTER_PATTERN = %r{\A[A-Za-z]:[/\\]}
25
19
 
20
+ def pack(package, options = {})
21
+ directory = package.path
22
+ output_file_name = "#{package.metadata.name}-#{package.metadata.version}.cap"
23
+ output_directory = File.dirname(directory)
24
+ cap_file_path = File.join(output_directory, output_file_name)
25
+
26
+ if File.exist?(cap_file_path) && !options[:force]
27
+ raise FileAlreadyExistsError,
28
+ "Package target already exists, aborting: `#{relative_path_current(cap_file_path)}`"
29
+ elsif File.exist?(cap_file_path)
26
30
  puts "Package target already exists, overwriting: `#{relative_path_current(cap_file_path)}`"
27
31
  FileUtils.rm_f(cap_file_path)
28
32
  end
29
33
 
30
34
  Dir.mktmpdir do |dir|
31
- FileUtils.cp_r("#{package.path}/.", dir)
35
+ FileUtils.cp_r("#{directory}/.", dir)
36
+ FileUtils.rm_f(File.join(dir, Package::SECURITY_FILE))
32
37
  new_package = Package.new(dir)
33
38
  new_package.solidify
34
- compress_package(new_package, cap_file_path)
39
+ generate_security(new_package)
40
+ new_cap_file_path = File.join(dir, output_file_name)
41
+ compress_package(new_package, new_cap_file_path)
42
+ puts "Package built at: #{new_cap_file_path}"
43
+ FileUtils.mv(new_cap_file_path, cap_file_path)
35
44
  puts "Package created: #{relative_path_current(cap_file_path)}"
45
+ return cap_file_path
46
+ end
47
+ end
48
+
49
+ def unpack(cap_file_path, destination)
50
+ destination = File.expand_path(destination)
51
+ Zip::File.open(cap_file_path) do |zip_file|
52
+ zip_file.each do |entry|
53
+ entry_path = safe_entry_path(destination, entry.name)
54
+ FileUtils.mkdir_p(File.dirname(entry_path))
55
+ entry.extract(entry_path)
56
+ end
36
57
  end
37
58
  end
38
59
 
39
60
  def compress_package(package, cap_file)
40
- Zip::File.open(cap_file, Zip::File::CREATE) do |zipfile|
41
- Dir[File.join(package.path, "**", "**")].each do |file|
61
+ entries = Dir[File.join(package.path, "**", "**")].reject do |file|
62
+ File.expand_path(file) == File.expand_path(cap_file)
63
+ end
64
+ Zip::File.open(cap_file, create: true) do |zipfile|
65
+ entries.each do |file|
42
66
  zipfile.add(file.sub("#{package.path}/", ""), file)
43
67
  end
44
68
  end
45
69
  end
46
70
 
47
- # def package_files
48
- # create_metadata_file
49
- # create_manifest_file
50
- # create_packaging_file
51
-
52
- # compressor = Compressor.new(@package, @package.metadata[:compression])
53
- # compressor.compress
54
-
55
- # protector = Protector.new(@package, @package.metadata[:encryption], @package.metadata[:signature])
56
- # protector.apply_encryption_and_sign
57
- # end
58
-
59
- # private
71
+ def relative_path_current(absolute_path)
72
+ Pathname.new(absolute_path).relative_path_from(Dir.pwd).to_s
73
+ end
60
74
 
61
- # def create_metadata_file
62
- # metadata_path = File.join(@package.path, Package::METADATA_FILE)
63
- # metadata_content = @package.metadata.to_h
64
- # write_json_file(metadata_path, metadata_content)
65
- # end
75
+ private
66
76
 
67
- # def create_manifest_file
68
- # manifest_path = File.join(@package.path, Package::MANIFEST_FILE)
69
- # manifest_content = {
70
- # files: Dir[File.join(@package.path, '**', '*')].reject { |f| File.directory?(f) }
71
- # }
72
- # write_json_file(manifest_path, manifest_content)
73
- # end
77
+ # Resolves an entry name against the destination and returns the
78
+ # absolute target path, raising UnsafeEntryError when the entry would
79
+ # escape the destination (zip-slip).
80
+ def safe_entry_path(destination, entry_name)
81
+ entry_path = File.expand_path(entry_name, destination)
82
+ return entry_path if contained?(entry_path, destination) &&
83
+ !entry_name.match?(DRIVE_LETTER_PATTERN)
74
84
 
75
- def create_packaging_file
76
- packaging_path = File.join(@package.path, Package::PACKAGING_FILE)
77
- packaging_content = {
78
- name: @package.name,
79
- content_path: relative_path_package(@package.content_path),
80
- data_path: relative_path_package(@package.data_path),
81
- datasets: @package.datasets.map(&:to_h)
82
- }
83
- write_json_file(packaging_path, packaging_content)
85
+ raise UnsafeEntryError,
86
+ "Refusing to extract unsafe zip entry: #{entry_name}"
84
87
  end
85
88
 
86
- def write_json_file(path, content)
87
- File.open(path, "w") do |file|
88
- file.write(JSON.pretty_generate(content))
89
- end
89
+ def contained?(entry_path, destination)
90
+ entry_path.start_with?("#{destination}#{File::SEPARATOR}")
90
91
  end
91
92
 
92
- def relative_path_package(absolute_path)
93
- Pathname.new(absolute_path).relative_path_from(Pathname.new(@package.path)).to_s
94
- end
95
-
96
- def relative_path_current(absolute_path)
97
- Pathname.new(absolute_path).relative_path_from(Dir.pwd).to_s
93
+ def generate_security(package)
94
+ security = Package::Security.generate(package.path)
95
+ security.save_to_file
98
96
  end
99
97
  end
100
98
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Capsium
8
+ class Reactor
9
+ # Monitoring HTTP API reports for the package this reactor serves
10
+ # (ARCHITECTURE.md section 7). Each report wraps its single entry in
11
+ # the list shape all reactors converge on.
12
+ class Introspection
13
+ METADATA_PATH = "/api/v1/introspect/metadata"
14
+ ROUTES_PATH = "/api/v1/introspect/routes"
15
+ CONTENT_HASHES_PATH = "/api/v1/introspect/content-hashes"
16
+ CONTENT_VALIDITY_PATH = "/api/v1/introspect/content-validity"
17
+ PATHS = [METADATA_PATH, ROUTES_PATH, CONTENT_HASHES_PATH,
18
+ CONTENT_VALIDITY_PATH].freeze
19
+
20
+ attr_reader :package
21
+
22
+ def initialize(package)
23
+ @package = package
24
+ end
25
+
26
+ def endpoint?(path)
27
+ PATHS.include?(path)
28
+ end
29
+
30
+ # The report body for an introspection endpoint, or nil when the
31
+ # path is not an introspection endpoint.
32
+ def report_for(path)
33
+ case path
34
+ when METADATA_PATH then metadata_report
35
+ when ROUTES_PATH then routes_report
36
+ when CONTENT_HASHES_PATH then content_hashes_report
37
+ when CONTENT_VALIDITY_PATH then content_validity_report
38
+ end
39
+ end
40
+
41
+ def metadata_report
42
+ metadata = package.metadata
43
+ { packages: [{
44
+ name: metadata.name,
45
+ version: metadata.version,
46
+ author: metadata.author,
47
+ description: metadata.description
48
+ }] }
49
+ end
50
+
51
+ def routes_report
52
+ entries = package.routes.config.routes.map do |route|
53
+ { method: route.http_method || "GET", path: route.path }
54
+ end
55
+ { routes: [{ package: package.name, routes: entries }] }
56
+ end
57
+
58
+ def content_hashes_report
59
+ { contentHashes: [{ package: package.name, hash: content_hash }] }
60
+ end
61
+
62
+ def content_validity_report
63
+ errors = package.verify_integrity
64
+ entry = {
65
+ package: package.name,
66
+ valid: errors.empty?,
67
+ lastChecked: Time.now.utc.iso8601
68
+ }
69
+ entry[:reason] = errors.map(&:message).join("; ") unless errors.empty?
70
+ { contentValidity: [entry] }
71
+ end
72
+
73
+ private
74
+
75
+ # SHA-256 of the .cap blob when the package was loaded from one.
76
+ # For directory sources there is no blob, so the hash covers a
77
+ # canonical (sorted-key) JSON serialization of the package content
78
+ # checksums — the same data security.json integrityChecks carry.
79
+ def content_hash
80
+ cap_file = package.cap_file_path
81
+ return Digest::SHA256.file(cap_file).hexdigest if cap_file
82
+
83
+ checksums = Package::Security.checksums_for(package.path)
84
+ Digest::SHA256.hexdigest(JSON.generate(checksums))
85
+ end
86
+ end
87
+ end
88
+ end
@@ -1,69 +1,78 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/reactor.rb
4
- require "webrick"
5
3
  require "json"
6
4
  require "listen"
7
- require "capsium/package"
5
+ require "webrick"
8
6
 
9
7
  module Capsium
10
8
  class Reactor
9
+ autoload :Introspection, "capsium/reactor/introspection"
10
+
11
11
  DEFAULT_PORT = 8864
12
- attr_accessor :package, :package_path, :routes, :port, :server, :server_thread
12
+ DEFAULT_CACHE_CONTROL = "public, max-age=31536000"
13
13
 
14
- def initialize(package, port = DEFAULT_PORT)
15
- @package = package
16
- @package_path = package.path
14
+ attr_reader :package, :package_path, :routes, :port, :cache_control,
15
+ :server, :server_thread, :introspection
16
+
17
+ def initialize(package:, port: DEFAULT_PORT,
18
+ cache_control: DEFAULT_CACHE_CONTROL, do_not_listen: false)
19
+ @package = package.is_a?(String) ? Package.new(package) : package
20
+ @package_path = @package.path
17
21
  @port = port
18
- @server = WEBrick::HTTPServer.new(Port: @port)
19
- load_routes
22
+ @cache_control = cache_control
23
+ setup_server(do_not_listen)
24
+ load_state
20
25
  mount_routes
21
26
  end
22
27
 
23
28
  def serve
29
+ trap("INT") { shutdown_server }
24
30
  @server_thread = start_server
25
31
  start_listener
26
32
  end
27
33
 
28
- private
34
+ def handle_request(request, response)
35
+ return serve_introspection(request, response) if @introspection.endpoint?(request.path)
29
36
 
30
- def load_routes
31
- @routes = @package.routes.data.routes
37
+ route = @routes.resolve(request.path)
38
+ route ? serve_route(route, response) : respond_not_found(response)
32
39
  end
33
40
 
34
41
  def mount_routes
35
- @routes.each do |route|
36
- path = route.path
37
- target = route.target
38
- content_path = target.fs_path(@package.manifest)
39
- puts "mounting route: #{path} => #{content_path}"
40
- @server.mount_proc(path.to_s) do |_req, res|
41
- res.body = File.read(content_path)
42
- res.content_type = target.mime(@package.manifest)
43
- end
42
+ paths = @routes.config.routes.map(&:path) + Introspection::PATHS
43
+ paths.each do |path|
44
+ @server.mount_proc(path.to_s) { |req, res| handle_request(req, res) }
44
45
  end
45
46
  end
46
47
 
47
- def start_server
48
- @server_thread = Thread.new do
49
- trap("INT") do
50
- puts "\nShutting down server..."
51
- @server.shutdown
52
- exit
53
- end
48
+ def restart_server
49
+ @server.shutdown
50
+ @server_thread&.join
51
+ load_package
52
+ setup_server(false)
53
+ mount_routes
54
+ @server_thread = start_server
55
+ end
56
+
57
+ private
54
58
 
59
+ def setup_server(do_not_listen)
60
+ server_options = { Port: @port }
61
+ server_options[:DoNotListen] = true if do_not_listen
62
+ @server = WEBrick::HTTPServer.new(server_options)
63
+ end
64
+
65
+ def start_server
66
+ Thread.new do
55
67
  puts "Starting server on http://localhost:#{@port}"
56
68
  @server.start
57
69
  end
58
70
  end
59
71
 
60
- def restart_server
72
+ def shutdown_server
73
+ puts "\nShutting down server..."
61
74
  @server.shutdown
62
- # @server_thread.kill
63
- load_package
64
- @server = WEBrick::HTTPServer.new(Port: @port)
65
- mount_routes
66
- start_server
75
+ exit
67
76
  end
68
77
 
69
78
  def start_listener
@@ -74,9 +83,72 @@ module Capsium
74
83
 
75
84
  listener.start
76
85
  puts "Listening for changes in #{@package_path}..."
77
-
78
- # Wait for the server thread to finish
79
86
  @server_thread.join
80
87
  end
88
+
89
+ def load_package
90
+ @package = Package.new(@package_path)
91
+ load_state
92
+ end
93
+
94
+ def load_state
95
+ @routes = @package.routes
96
+ @introspection = Introspection.new(@package)
97
+ end
98
+
99
+ def serve_introspection(request, response)
100
+ return respond_method_not_allowed(response) unless request.request_method == "GET"
101
+
102
+ response.status = 200
103
+ response["Content-Type"] = "application/json"
104
+ response.body = JSON.generate(@introspection.report_for(request.path))
105
+ end
106
+
107
+ def serve_route(route, response)
108
+ case route.kind
109
+ when :dataset then serve_dataset(route.dataset, response)
110
+ when :resource then serve_file(route, response)
111
+ else respond_not_implemented(response)
112
+ end
113
+ end
114
+
115
+ def serve_file(route, response)
116
+ content_path = route.fs_path(@package.path)
117
+ if content_path && File.exist?(content_path)
118
+ response.status = 200
119
+ response["Content-Type"] = route.mime(@package.manifest) || "application/octet-stream"
120
+ headers_for(route).each { |name, value| response[name] = value }
121
+ response.body = File.read(content_path)
122
+ else
123
+ respond_not_found(response)
124
+ end
125
+ end
126
+
127
+ def headers_for(route)
128
+ route.headers || (@cache_control ? { "Cache-Control" => @cache_control } : {})
129
+ end
130
+
131
+ def serve_dataset(dataset_name, response)
132
+ dataset = @package.storage.dataset(dataset_name)
133
+ if dataset
134
+ response.status = 200
135
+ response["Content-Type"] = "application/json"
136
+ response.body = JSON.generate(dataset.data)
137
+ else
138
+ respond_not_found(response)
139
+ end
140
+ end
141
+
142
+ def respond_not_found(response) = respond_text(response, 404, "Not Found")
143
+
144
+ def respond_not_implemented(response) = respond_text(response, 501, "Not Implemented")
145
+
146
+ def respond_method_not_allowed(response) = respond_text(response, 405, "Method Not Allowed")
147
+
148
+ def respond_text(response, status, body)
149
+ response.status = status
150
+ response["Content-Type"] = "text/plain"
151
+ response.body = body
152
+ end
81
153
  end
82
154
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Capsium
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end