open_rosa 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/CLAUDE.md +1 -0
- data/LICENSE.txt +21 -0
- data/README.md +285 -0
- data/Rakefile +12 -0
- data/examples/authentication.rb +80 -0
- data/examples/rack_app.rb +178 -0
- data/examples/rails_integration.rb +152 -0
- data/lib/open_rosa/field_context.rb +59 -0
- data/lib/open_rosa/fields/base.rb +18 -0
- data/lib/open_rosa/fields/boolean.rb +17 -0
- data/lib/open_rosa/fields/group.rb +19 -0
- data/lib/open_rosa/fields/input.rb +32 -0
- data/lib/open_rosa/fields/range.rb +34 -0
- data/lib/open_rosa/fields/repeat.rb +20 -0
- data/lib/open_rosa/fields/select.rb +28 -0
- data/lib/open_rosa/fields/select1.rb +27 -0
- data/lib/open_rosa/fields/trigger.rb +17 -0
- data/lib/open_rosa/fields/upload.rb +18 -0
- data/lib/open_rosa/form.rb +146 -0
- data/lib/open_rosa/form_dsl.rb +70 -0
- data/lib/open_rosa/form_list.rb +97 -0
- data/lib/open_rosa/manifest.rb +105 -0
- data/lib/open_rosa/media_file.rb +97 -0
- data/lib/open_rosa/middleware.rb +352 -0
- data/lib/open_rosa/submission.rb +195 -0
- data/lib/open_rosa/version.rb +5 -0
- data/lib/open_rosa/xform.rb +250 -0
- data/lib/open_rosa.rb +27 -0
- data/sig/openrosa.rbs +4 -0
- metadata +105 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module OpenRosa
|
|
6
|
+
# Generates OpenRosa Form List API XML responses
|
|
7
|
+
# Spec: https://docs.getodk.org/openrosa-form-list/
|
|
8
|
+
class FormList
|
|
9
|
+
XFORMS_LIST_NS = "http://openrosa.org/xforms/xformsList"
|
|
10
|
+
|
|
11
|
+
def initialize(forms, options = {})
|
|
12
|
+
@forms = forms
|
|
13
|
+
@verbose = options.fetch(:verbose, false)
|
|
14
|
+
@form_id = options[:form_id]
|
|
15
|
+
@base_url = options[:base_url]
|
|
16
|
+
@mount_path = options[:mount_path] || "/openrosa"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_xml
|
|
20
|
+
builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
|
|
21
|
+
xml.xforms(xmlns: XFORMS_LIST_NS) do
|
|
22
|
+
filtered_forms.each do |form_class|
|
|
23
|
+
generate_xform_entry(xml, form_class)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
builder.to_xml
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def filtered_forms
|
|
34
|
+
return @forms unless @form_id
|
|
35
|
+
|
|
36
|
+
@forms.select { |form| form.form_id == @form_id }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def generate_xform_entry(xml, form_class)
|
|
40
|
+
xml.xform do
|
|
41
|
+
add_required_fields(xml, form_class)
|
|
42
|
+
add_verbose_fields(xml, form_class) if @verbose
|
|
43
|
+
add_optional_fields(xml, form_class)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add_required_fields(xml, form_class)
|
|
48
|
+
xml.formID form_class.form_id
|
|
49
|
+
xml.name form_class.name if form_class.name
|
|
50
|
+
xml.version form_class.version if form_class.version
|
|
51
|
+
xml.hash_ form_class.form_hash
|
|
52
|
+
url = download_url_for(form_class)
|
|
53
|
+
xml.downloadUrl url if url
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def download_url_for(form_class)
|
|
57
|
+
# Use explicit download_url if set on the form
|
|
58
|
+
return form_class.download_url if form_class.download_url
|
|
59
|
+
|
|
60
|
+
# Auto-generate from base_url if configured
|
|
61
|
+
unless @base_url
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
"Form '#{form_class.form_id}' has no download_url. " \
|
|
64
|
+
"Either set download_url on the form or configure base_url in the middleware."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
"#{@base_url}#{@mount_path}/forms/#{form_class.form_id}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_verbose_fields(xml, form_class)
|
|
71
|
+
xml.descriptionText form_class.description_text if form_class.description_text
|
|
72
|
+
xml.descriptionUrl form_class.description_url if form_class.description_url
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def add_optional_fields(xml, form_class)
|
|
76
|
+
url = manifest_url_for(form_class)
|
|
77
|
+
xml.manifestUrl url if url
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def manifest_url_for(form_class)
|
|
81
|
+
# Use explicit manifest_url if set on the form
|
|
82
|
+
return form_class.manifest_url if form_class.manifest_url
|
|
83
|
+
|
|
84
|
+
# Auto-generate from base_url if form has a manifest defined
|
|
85
|
+
return nil unless @base_url
|
|
86
|
+
return nil unless form_has_manifest?(form_class)
|
|
87
|
+
|
|
88
|
+
"#{@base_url}#{@mount_path}/manifests/#{form_class.form_id}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def form_has_manifest?(form_class)
|
|
92
|
+
# Handle both class and instance (middleware passes instances)
|
|
93
|
+
klass = form_class.is_a?(Class) ? form_class : form_class.class
|
|
94
|
+
klass.respond_to?(:manifest) && klass.manifest
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module OpenRosa
|
|
6
|
+
# Represents a manifest of media files associated with a form.
|
|
7
|
+
# The manifest lists all supporting files (images, audio, video, entity lists)
|
|
8
|
+
# that need to be downloaded along with the form definition.
|
|
9
|
+
#
|
|
10
|
+
# @example Create a manifest with media files
|
|
11
|
+
# manifest = OpenRosa::Manifest.new
|
|
12
|
+
# manifest.add_media_file(
|
|
13
|
+
# OpenRosa::MediaFile.new(
|
|
14
|
+
# filename: "images/logo.png",
|
|
15
|
+
# hash: "md5:abc123",
|
|
16
|
+
# download_url: "https://example.com/media/logo.png"
|
|
17
|
+
# )
|
|
18
|
+
# )
|
|
19
|
+
# xml = manifest.to_xml
|
|
20
|
+
#
|
|
21
|
+
# @example Create a manifest with entity list
|
|
22
|
+
# manifest = OpenRosa::Manifest.new
|
|
23
|
+
# manifest.add_media_file(
|
|
24
|
+
# OpenRosa::MediaFile.new(
|
|
25
|
+
# filename: "entities.csv",
|
|
26
|
+
# hash: "md5:xyz789",
|
|
27
|
+
# download_url: "https://example.com/entities.csv",
|
|
28
|
+
# type: "entityList",
|
|
29
|
+
# integrity_url: "https://example.com/entities/integrity"
|
|
30
|
+
# )
|
|
31
|
+
# )
|
|
32
|
+
class Manifest
|
|
33
|
+
attr_reader :media_files
|
|
34
|
+
|
|
35
|
+
# Creates a new Manifest
|
|
36
|
+
#
|
|
37
|
+
# @param media_files [Array<MediaFile>] Optional array of MediaFile objects
|
|
38
|
+
def initialize(media_files: [])
|
|
39
|
+
@media_files = media_files
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Adds a media file to the manifest
|
|
43
|
+
#
|
|
44
|
+
# @param media_file [MediaFile] The media file to add
|
|
45
|
+
# @return [Manifest] self for chaining
|
|
46
|
+
def add_media_file(media_file)
|
|
47
|
+
@media_files << media_file
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Generates OpenRosa manifest XML
|
|
52
|
+
#
|
|
53
|
+
# @return [String] XML string conforming to OpenRosa manifest spec
|
|
54
|
+
def to_xml
|
|
55
|
+
builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
|
|
56
|
+
xml.manifest(xmlns: "http://openrosa.org/xforms/xformsManifest") do
|
|
57
|
+
media_files.each do |media_file|
|
|
58
|
+
build_media_file(xml, media_file)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
builder.to_xml
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if the manifest is empty
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean] true if no media files
|
|
69
|
+
def empty?
|
|
70
|
+
media_files.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Count of media files in the manifest
|
|
74
|
+
#
|
|
75
|
+
# @return [Integer] number of media files
|
|
76
|
+
def count
|
|
77
|
+
media_files.count
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def build_media_file(xml, media_file)
|
|
83
|
+
# Add type attribute only if specified
|
|
84
|
+
if media_file.type
|
|
85
|
+
xml.mediaFile(type: media_file.type) do
|
|
86
|
+
build_media_file_elements(xml, media_file)
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
xml.mediaFile do
|
|
90
|
+
build_media_file_elements(xml, media_file)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_media_file_elements(xml, media_file)
|
|
96
|
+
xml.filename media_file.filename
|
|
97
|
+
# Use text approach to avoid conflict with Object#hash
|
|
98
|
+
xml.text("\n ")
|
|
99
|
+
xml.parent.add_child(Nokogiri::XML::Node.new("hash", xml.doc).tap { |n| n.content = media_file.hash })
|
|
100
|
+
xml.text("\n ")
|
|
101
|
+
xml.downloadUrl media_file.download_url
|
|
102
|
+
xml.integrityUrl media_file.integrity_url if media_file.integrity_url
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module OpenRosa
|
|
6
|
+
# Represents a media file (image, audio, video, entity list) associated with a form.
|
|
7
|
+
# Media files are listed in the form's manifest and downloaded separately from the form definition.
|
|
8
|
+
#
|
|
9
|
+
# @example Create a media file with manual hash
|
|
10
|
+
# media_file = OpenRosa::MediaFile.new(
|
|
11
|
+
# filename: "images/logo.png",
|
|
12
|
+
# hash: "md5:abc123def456",
|
|
13
|
+
# download_url: "https://example.com/media/logo.png"
|
|
14
|
+
# )
|
|
15
|
+
#
|
|
16
|
+
# @example Create a media file with automatic hash generation
|
|
17
|
+
# media_file = OpenRosa::MediaFile.new(
|
|
18
|
+
# filename: "images/logo.png",
|
|
19
|
+
# file: "/path/to/logo.png",
|
|
20
|
+
# download_url: "https://example.com/media/logo.png"
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# @example Create an entity list media file
|
|
24
|
+
# media_file = OpenRosa::MediaFile.new(
|
|
25
|
+
# filename: "entities.csv",
|
|
26
|
+
# hash: "md5:xyz789",
|
|
27
|
+
# download_url: "https://example.com/entities.csv",
|
|
28
|
+
# type: "entityList",
|
|
29
|
+
# integrity_url: "https://example.com/entities/integrity"
|
|
30
|
+
# )
|
|
31
|
+
class MediaFile
|
|
32
|
+
attr_reader :filename, :hash, :download_url, :type, :integrity_url
|
|
33
|
+
|
|
34
|
+
# Creates a new MediaFile
|
|
35
|
+
#
|
|
36
|
+
# @param filename [String] Unrooted file path (no drive letters, no absolute paths, no backslashes)
|
|
37
|
+
# @param hash [String, nil] MD5 hash in format "md5:..." (auto-generated if file provided)
|
|
38
|
+
# @param file [String, nil] Path to file for hash generation
|
|
39
|
+
# @param download_url [String] Full URI for downloading the file
|
|
40
|
+
# @param type [String, nil] Optional type (e.g., "entityList")
|
|
41
|
+
# @param integrity_url [String, nil] Required if type="entityList"
|
|
42
|
+
# @raise [ArgumentError] if filename is invalid, or if hash/file not provided, or entityList missing integrity_url
|
|
43
|
+
# rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
44
|
+
def initialize(filename:, download_url:, hash: nil, file: nil, type: nil, integrity_url: nil)
|
|
45
|
+
# rubocop:enable Metrics/ParameterLists
|
|
46
|
+
@filename = validate_filename(filename)
|
|
47
|
+
@download_url = download_url
|
|
48
|
+
@type = type
|
|
49
|
+
@integrity_url = integrity_url
|
|
50
|
+
|
|
51
|
+
# Hash can be provided or generated from file
|
|
52
|
+
if hash
|
|
53
|
+
@hash = hash
|
|
54
|
+
elsif file
|
|
55
|
+
@hash = generate_hash(file)
|
|
56
|
+
else
|
|
57
|
+
raise ArgumentError, "Either hash or file parameter must be provided"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Validate entityList requirements
|
|
61
|
+
validate_entity_list if type == "entityList"
|
|
62
|
+
end
|
|
63
|
+
# rubocop:enable Metrics/MethodLength
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def validate_filename(filename)
|
|
68
|
+
# Check for Windows drive letters (C:, D:, etc.)
|
|
69
|
+
raise ArgumentError, "Filename cannot contain a drive letter: #{filename}" if filename.match?(/^[A-Za-z]:/)
|
|
70
|
+
|
|
71
|
+
# Check for relative paths (..)
|
|
72
|
+
if filename.include?("..")
|
|
73
|
+
raise ArgumentError, "Filename cannot contain relative path components (..): #{filename}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check for absolute paths (starting with /)
|
|
77
|
+
raise ArgumentError, "Filename cannot be an absolute path: #{filename}" if filename.start_with?("/")
|
|
78
|
+
|
|
79
|
+
# Check for backslashes (Windows-style paths)
|
|
80
|
+
raise ArgumentError, "Filename cannot contain backslash characters: #{filename}" if filename.include?("\\")
|
|
81
|
+
|
|
82
|
+
filename
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def generate_hash(file_path)
|
|
86
|
+
content = File.read(file_path)
|
|
87
|
+
digest = Digest::MD5.hexdigest(content)
|
|
88
|
+
"md5:#{digest}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_entity_list
|
|
92
|
+
return if integrity_url
|
|
93
|
+
|
|
94
|
+
raise ArgumentError, "integrity_url is required when type is 'entityList'"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
|
|
5
|
+
module OpenRosa
|
|
6
|
+
# Rack middleware for OpenRosa endpoints
|
|
7
|
+
#
|
|
8
|
+
# Provides endpoints for:
|
|
9
|
+
# - GET /formList - List available forms
|
|
10
|
+
# - GET /forms/:id - Download a specific form as XForm XML
|
|
11
|
+
# - POST /submission - Receive form submissions
|
|
12
|
+
# - HEAD /submission - Pre-flight check for submissions
|
|
13
|
+
#
|
|
14
|
+
# Example usage:
|
|
15
|
+
# use OpenRosa::Middleware do |config|
|
|
16
|
+
# config.forms = [MyForm, AnotherForm]
|
|
17
|
+
# config.mount_path = "/openrosa"
|
|
18
|
+
#
|
|
19
|
+
# # Handle submissions
|
|
20
|
+
# config.on_submission do |submission|
|
|
21
|
+
# # Save to database, process files, etc.
|
|
22
|
+
# MySubmission.create!(
|
|
23
|
+
# form_id: submission.form_id,
|
|
24
|
+
# data: submission.data,
|
|
25
|
+
# instance_id: submission.instance_id
|
|
26
|
+
# )
|
|
27
|
+
# "Thank you for your submission!"
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
class Middleware # rubocop:disable Metrics/ClassLength
|
|
31
|
+
OPENROSA_VERSION = "1.0"
|
|
32
|
+
|
|
33
|
+
attr_reader :app, :config
|
|
34
|
+
|
|
35
|
+
def initialize(app = nil, &block)
|
|
36
|
+
@app = app
|
|
37
|
+
@config = Configuration.new
|
|
38
|
+
block&.call(@config)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(env)
|
|
42
|
+
request = Rack::Request.new(env)
|
|
43
|
+
|
|
44
|
+
return delegate_to_app(env) unless handle_request?(request)
|
|
45
|
+
|
|
46
|
+
# Check authentication if configured
|
|
47
|
+
return unauthorized_response unless authenticated?(env, request)
|
|
48
|
+
|
|
49
|
+
route_request(request)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def delegate_to_app(env)
|
|
55
|
+
@app ? @app.call(env) : not_found_response
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def handle_request?(request)
|
|
59
|
+
@app.nil? || openrosa_path?(request.path_info)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
63
|
+
def route_request(request)
|
|
64
|
+
path = request.path_info
|
|
65
|
+
method = request.request_method
|
|
66
|
+
|
|
67
|
+
case [method, path]
|
|
68
|
+
when ["GET", "#{mount_path}/formList"]
|
|
69
|
+
handle_form_list(request)
|
|
70
|
+
when ["POST", "#{mount_path}/submission"]
|
|
71
|
+
handle_submission(request)
|
|
72
|
+
when ["HEAD", "#{mount_path}/submission"]
|
|
73
|
+
handle_submission_head(request)
|
|
74
|
+
else
|
|
75
|
+
# Check for form download pattern (GET /forms/:id)
|
|
76
|
+
if method == "GET" && (match = path.match(%r{^#{Regexp.escape(mount_path)}/forms/(.+)$}))
|
|
77
|
+
handle_form_download(request, match[1])
|
|
78
|
+
# Check for manifest download pattern (GET /manifests/:id)
|
|
79
|
+
elsif method == "GET" && (match = path.match(%r{^#{Regexp.escape(mount_path)}/manifests/(.+)$}))
|
|
80
|
+
handle_manifest(request, match[1])
|
|
81
|
+
else
|
|
82
|
+
delegate_to_app(request.env)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
87
|
+
|
|
88
|
+
def mount_path
|
|
89
|
+
@config.mount_path
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def openrosa_path?(path)
|
|
93
|
+
path.start_with?(mount_path)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def handle_form_list(request)
|
|
97
|
+
form_list = FormList.new(available_forms, form_list_options(request))
|
|
98
|
+
openrosa_xml_response(form_list.to_xml)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def form_list_options(request)
|
|
102
|
+
{
|
|
103
|
+
form_id: request.params["formID"],
|
|
104
|
+
verbose: request.params["verbose"] == "true",
|
|
105
|
+
base_url: @config.base_url,
|
|
106
|
+
mount_path: mount_path
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_form_download(_request, form_id)
|
|
111
|
+
form = find_form(form_id)
|
|
112
|
+
return not_found_response unless form
|
|
113
|
+
|
|
114
|
+
xml = form.to_xml
|
|
115
|
+
|
|
116
|
+
openrosa_xml_response(xml)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def handle_manifest(_request, form_id)
|
|
120
|
+
form = find_form(form_id)
|
|
121
|
+
return not_found_response("Form not found") unless form
|
|
122
|
+
|
|
123
|
+
# Check if the form has a manifest method
|
|
124
|
+
return not_found_response("Manifest not found for this form") unless form.class.respond_to?(:manifest)
|
|
125
|
+
|
|
126
|
+
manifest = form.class.manifest
|
|
127
|
+
return not_found_response("Manifest not found for this form") if manifest.nil? || manifest.empty?
|
|
128
|
+
|
|
129
|
+
xml = manifest.to_xml
|
|
130
|
+
|
|
131
|
+
openrosa_xml_response(xml)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def available_forms
|
|
135
|
+
@config.forms.map do |form_class|
|
|
136
|
+
# If it's a class, instantiate it; otherwise use as-is
|
|
137
|
+
form_class.is_a?(Class) ? form_class.new : form_class
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def find_form(form_id)
|
|
142
|
+
available_forms.find { |form| form.form_id == form_id }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def openrosa_xml_response(xml)
|
|
146
|
+
[
|
|
147
|
+
200,
|
|
148
|
+
{
|
|
149
|
+
"Content-Type" => "text/xml; charset=utf-8",
|
|
150
|
+
"X-OpenRosa-Version" => OPENROSA_VERSION,
|
|
151
|
+
"Date" => Time.now.httpdate
|
|
152
|
+
},
|
|
153
|
+
[xml]
|
|
154
|
+
]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def handle_submission(request)
|
|
158
|
+
submission = parse_submission(request)
|
|
159
|
+
return submission if submission.is_a?(Array) # Error response
|
|
160
|
+
|
|
161
|
+
message = process_submission(submission)
|
|
162
|
+
return message if message.is_a?(Array) # Error response
|
|
163
|
+
|
|
164
|
+
openrosa_submission_response(message || "Submission received successfully")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def parse_submission(request)
|
|
168
|
+
Submission.parse(request)
|
|
169
|
+
rescue Submission::ParseError => e
|
|
170
|
+
error_response(400, "Invalid submission: #{e.message}")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def process_submission(submission)
|
|
174
|
+
form_class = find_form_class_for_submission(submission)
|
|
175
|
+
execute_submission_handler(form_class, submission)
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
error_response(500, "Server error: #{e.message}")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def find_form_class_for_submission(submission)
|
|
181
|
+
@config.forms.find do |fc|
|
|
182
|
+
form = fc.is_a?(Class) ? fc.new : fc
|
|
183
|
+
form.form_id == submission.form_id
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def execute_submission_handler(form_class, submission)
|
|
188
|
+
# Try form-specific handler first
|
|
189
|
+
form_handler_result = form_class&.handle_submission(submission) if form_class
|
|
190
|
+
|
|
191
|
+
if form_handler_result
|
|
192
|
+
form_handler_result
|
|
193
|
+
elsif @config.submission_handler
|
|
194
|
+
@config.submission_handler.call(submission)
|
|
195
|
+
else
|
|
196
|
+
"Submission received successfully"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def handle_submission_head(_request)
|
|
201
|
+
# HEAD request for pre-flight check
|
|
202
|
+
# Return max content length we'll accept (10MB default)
|
|
203
|
+
max_size = @config.max_submission_size || 10_485_760
|
|
204
|
+
|
|
205
|
+
[
|
|
206
|
+
204,
|
|
207
|
+
{
|
|
208
|
+
"X-OpenRosa-Version" => OPENROSA_VERSION,
|
|
209
|
+
"X-OpenRosa-Accept-Content-Length" => max_size.to_s,
|
|
210
|
+
"Date" => Time.now.httpdate
|
|
211
|
+
},
|
|
212
|
+
[]
|
|
213
|
+
]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def openrosa_submission_response(message)
|
|
217
|
+
xml = build_openrosa_response_xml(message)
|
|
218
|
+
|
|
219
|
+
[
|
|
220
|
+
201,
|
|
221
|
+
{
|
|
222
|
+
"Content-Type" => "text/xml; charset=utf-8",
|
|
223
|
+
"X-OpenRosa-Version" => OPENROSA_VERSION,
|
|
224
|
+
"Date" => Time.now.httpdate
|
|
225
|
+
},
|
|
226
|
+
[xml]
|
|
227
|
+
]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def build_openrosa_response_xml(message)
|
|
231
|
+
builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
|
|
232
|
+
xml.OpenRosaResponse(xmlns: "http://openrosa.org/http/response") do
|
|
233
|
+
xml.message message
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
builder.to_xml
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def error_response(status, message)
|
|
241
|
+
[
|
|
242
|
+
status,
|
|
243
|
+
{
|
|
244
|
+
"Content-Type" => "text/plain",
|
|
245
|
+
"X-OpenRosa-Version" => OPENROSA_VERSION,
|
|
246
|
+
"Date" => Time.now.httpdate
|
|
247
|
+
},
|
|
248
|
+
[message]
|
|
249
|
+
]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def not_found_response(message = "Not Found")
|
|
253
|
+
[
|
|
254
|
+
404,
|
|
255
|
+
{ "Content-Type" => "text/plain" },
|
|
256
|
+
[message]
|
|
257
|
+
]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def authenticated?(env, request)
|
|
261
|
+
# No authentication handler configured = allow all requests
|
|
262
|
+
return true unless @config.authentication_handler
|
|
263
|
+
|
|
264
|
+
# Check if this path should skip authentication
|
|
265
|
+
return true if skip_authentication?(request.path_info)
|
|
266
|
+
|
|
267
|
+
# Call the authentication handler
|
|
268
|
+
result = @config.authentication_handler.call(env)
|
|
269
|
+
|
|
270
|
+
# Store the authenticated user/result in env for later use
|
|
271
|
+
env["openrosa.authenticated_user"] = result if result
|
|
272
|
+
|
|
273
|
+
# Return true if result is truthy (user object, true, etc.)
|
|
274
|
+
!!result
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def skip_authentication?(path)
|
|
278
|
+
@config.skip_authentication_for.any? do |skip_path|
|
|
279
|
+
if skip_path.is_a?(Regexp)
|
|
280
|
+
path =~ skip_path
|
|
281
|
+
else
|
|
282
|
+
path == normalize_skip_path(skip_path)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def normalize_skip_path(path)
|
|
288
|
+
# Ensure skip path includes mount_path if it's a relative path
|
|
289
|
+
return path if path.start_with?(mount_path)
|
|
290
|
+
|
|
291
|
+
"#{mount_path}#{path}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def unauthorized_response
|
|
295
|
+
[
|
|
296
|
+
401,
|
|
297
|
+
{
|
|
298
|
+
"Content-Type" => "text/plain",
|
|
299
|
+
"WWW-Authenticate" => "Basic realm=\"#{@config.authentication_realm}\"",
|
|
300
|
+
"X-OpenRosa-Version" => OPENROSA_VERSION,
|
|
301
|
+
"Date" => Time.now.httpdate
|
|
302
|
+
},
|
|
303
|
+
["Unauthorized"]
|
|
304
|
+
]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Configuration object for the middleware
|
|
308
|
+
class Configuration
|
|
309
|
+
attr_accessor :forms, :mount_path, :base_url, :submission_handler, :max_submission_size,
|
|
310
|
+
:authentication_handler, :skip_authentication_for, :authentication_realm
|
|
311
|
+
|
|
312
|
+
def initialize
|
|
313
|
+
@forms = []
|
|
314
|
+
@mount_path = "/openrosa"
|
|
315
|
+
@base_url = nil
|
|
316
|
+
@submission_handler = nil
|
|
317
|
+
@max_submission_size = 10_485_760 # 10MB default
|
|
318
|
+
@authentication_handler = nil
|
|
319
|
+
@skip_authentication_for = []
|
|
320
|
+
@authentication_realm = "OpenRosa"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Set the submission handler callback
|
|
324
|
+
#
|
|
325
|
+
# @yield [submission] The parsed submission
|
|
326
|
+
# @yieldparam submission [Submission] The submission object
|
|
327
|
+
# @yieldreturn [String, nil] Optional success message
|
|
328
|
+
def on_submission(&block)
|
|
329
|
+
@submission_handler = block
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Set the authentication handler callback
|
|
333
|
+
#
|
|
334
|
+
# @yield [env] The Rack environment hash
|
|
335
|
+
# @yieldparam env [Hash] The Rack environment
|
|
336
|
+
# @yieldreturn [Object, true, false, nil] Return truthy to authenticate, falsy to deny
|
|
337
|
+
#
|
|
338
|
+
# Example:
|
|
339
|
+
# config.authenticate do |env|
|
|
340
|
+
# request = Rack::Request.new(env)
|
|
341
|
+
# auth = Rack::Auth::Basic::Request.new(env)
|
|
342
|
+
# if auth.provided? && auth.basic?
|
|
343
|
+
# username, password = auth.credentials
|
|
344
|
+
# User.authenticate(username, password) # Returns user object or nil
|
|
345
|
+
# end
|
|
346
|
+
# end
|
|
347
|
+
def authenticate(&block)
|
|
348
|
+
@authentication_handler = block
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|