forget-passwords 0.2.9

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.
@@ -0,0 +1,269 @@
1
+ require 'nokogiri'
2
+ require 'stringio'
3
+ require 'xml-mixup'
4
+ require 'http-negotiate'
5
+ require 'forget-passwords/types'
6
+ require 'uri'
7
+
8
+ module ForgetPasswords
9
+
10
+ class Template
11
+
12
+ # XXX do we even need this?
13
+ class Mapper
14
+
15
+ private
16
+
17
+ # XXX i can't remember what the nice way to get the gem root is lol
18
+ DEFAULT_PATH = (
19
+ Pathname(__FILE__).parent + '../../content').expand_path.freeze
20
+
21
+
22
+ # Normalize the input to symbol `:like_this`.
23
+ #
24
+ # @param key [#to_s, #to_sym] the input to normalize
25
+ #
26
+ # @return [Symbol] the normalized key
27
+ #
28
+ def normalize key
29
+ key.to_s.strip.downcase.gsub(/[[:space:]-]/, ?_).tr_s(?_, ?_).to_sym
30
+ end
31
+
32
+ TType = ForgetPasswords::Types.Instance(ForgetPasswords::Template)
33
+
34
+ THash = ForgetPasswords::Types::Hash.map(ForgetPasswords::Types::NormSym,
35
+ ForgetPasswords::Types::String).default({}.freeze)
36
+
37
+ RawParams = ForgetPasswords::Types::SymbolHash.schema(
38
+ path: ForgetPasswords::Types::ExtantPathname.default(DEFAULT_PATH),
39
+ mapping: THash,
40
+ base?: ForgetPasswords::Types::URI,
41
+ transform?: ForgetPasswords::Types::URI,
42
+ ).hash_default
43
+
44
+ public
45
+
46
+ Type = ForgetPasswords::Types.Constructor(self) do |input|
47
+ # what we're gonna do is validate the input as a hash, then use it
48
+ input = RawParams.(input)
49
+ path = input.delete :path
50
+ self.new(path, **input)
51
+ end
52
+
53
+ attr_reader :path, :base, :transform
54
+
55
+ def initialize path = DEFAULT_PATH, base: nil,
56
+ transform: nil, mapping: {}
57
+ @path = Pathname(path).expand_path
58
+ @base = base
59
+ @transform = transform
60
+ @mapping = mapping.map do |k, v|
61
+ name = normalize k
62
+ template = v.is_a?(ForgetPasswords::Template) ? v :
63
+ ForgetPasswords::Template.new(self, k, @path + v)
64
+ [name, template]
65
+ end.to_h
66
+ end
67
+
68
+ def [] key
69
+ @mapping[normalize key]
70
+ end
71
+
72
+ def []= key, path
73
+ name = normalize key
74
+ @mapping[name] = path.is_a?(ForgetPasswords::Template) ? path :
75
+ ForgetPasswords::Template.new(self, key, @path + path)
76
+ end
77
+
78
+ def manifest
79
+ @mapping.keys
80
+ end
81
+
82
+ # Ensure that the mapper contains templates with the given names.
83
+ #
84
+ # @param *names [Array<#to_s, #to_sym>] the template names
85
+ #
86
+ # @return [true, false]
87
+ #
88
+ def verify *names
89
+ names = names.first if names.first.is_a? Array
90
+ # i dunno, is there a better way to do this?
91
+ (names.map { |k| normalize k } - manifest).empty?
92
+ end
93
+
94
+ # Ensure that the mapper contains templates with the given names
95
+ # and raise an exception if it doesn't.
96
+ #
97
+ # @param *names [Array<#to_sym>] the template names
98
+ #
99
+ # @return [true, false]
100
+ #
101
+ def verify! *names
102
+ verify(*names) or raise "Could not verify names: #{names.join ?,}"
103
+ end
104
+ end
105
+
106
+ include XML::Mixup
107
+
108
+ private
109
+
110
+ TEXT_TEMPLATE = Nokogiri::XSLT.parse(
111
+ (Mapper::DEFAULT_PATH + '../etc/text-only.xsl').expand_path.read)
112
+
113
+ # this is gonna be run in the context of the document
114
+ TO_XML = -> { to_xml }
115
+ TO_HTML = -> { to_html }
116
+ TO_TEXT = -> {
117
+ TEXT_TEMPLATE.apply_to(self).to_s
118
+ }
119
+
120
+ ATTRS = %w[
121
+ about typeof rel rev property resource href src
122
+ action data id class name value
123
+ ].freeze
124
+
125
+ ATTRS_XPATH = ('//*[%s]/@*' % ATTRS.map { |a| "@#{a}" }.join(?|)).freeze
126
+
127
+ XPATHNS = {
128
+ html: 'http://www.w3.org/1999/xhtml',
129
+ svg: 'http://www.w3.org/2000/svg',
130
+ xsl: 'http://www.w3.org/1999/XSL/Transform',
131
+ }.freeze
132
+
133
+ public
134
+
135
+ attr_reader :name, :doc, :mapper
136
+
137
+ def initialize mapper, name, content
138
+ # boring members
139
+ @mapper = mapper
140
+ @name = name
141
+
142
+ # resolve content
143
+ @doc = case content
144
+ when Nokogiri::XML::Document then content
145
+ when IO, Pathname
146
+ content = mapper.path + content
147
+ fh = content.respond_to?(:open) ? content.open : content
148
+ Nokogiri::XML.parse fh
149
+ when String
150
+ Nokogiri::XML.parse content
151
+ else
152
+ raise ArgumentError, "Not sure what to do with #{content.class}"
153
+ end
154
+ end
155
+
156
+ # Perform the variable substitution on the associated document and
157
+ # return it.
158
+ #
159
+ # @param vars [#to_h] a hash-like object of variables.
160
+ #
161
+ # @return [Nokogiri::XML::Document] the altered document
162
+ #
163
+ def process vars: {}, base: nil, transform: nil
164
+ # sub all the placeholders for variables
165
+ doc = @doc.dup
166
+
167
+ # add doctype if missing
168
+ doc.create_internal_subset('html', nil, nil) unless doc.internal_subset
169
+
170
+ # set the base URI
171
+ if base ||= mapper.base
172
+ if b = doc.at_xpath('(/html:html/html:head/html:base)[1]', XPATHNS)
173
+ # check for a <base href="..."/> already
174
+ b['href'] = base.to_s
175
+ elsif t = doc.at_xpath('(/html:html/html:head/html:title)[1]', XPATHNS)
176
+ # otherwise check for a <title>, after which we'll plunk it
177
+ markup spec: { nil => :base, href: base.to_s }, after: t
178
+ elsif h = doc.at_xpath('/html:html/html:head[1]', XPATHNS)
179
+ # otherwise check for <head>, to which we will prepend
180
+ markup spec: { nil => :base, href: base.to_s }, parent: h
181
+ end
182
+ end
183
+
184
+ # add xsl transform if present
185
+ if transform ||= mapper.transform
186
+ pi = { '#pi' => 'xml-stylesheet',
187
+ type: 'text/xsl', href: transform.to_s }
188
+ if t = doc.at_xpath("/processing-instruction('xml-stylesheet')[1]")
189
+ t = markup spec: pi, replace: t
190
+ else
191
+ t = markup spec: pi, before: doc.children.first
192
+ end
193
+ end
194
+
195
+ # do the processing instructions
196
+ doc.xpath("/*//processing-instruction('var')").each do |pi|
197
+ key = pi.content.delete_prefix(?$).delete_suffix(??).to_sym
198
+ if vars[key]
199
+ text = pi.document.create_text_node vars[key].to_s
200
+ pi.replace text
201
+ end
202
+ end
203
+
204
+ # do the attributes
205
+ doc.xpath(ATTRS_XPATH).each do |attr|
206
+ attr.content = attr.content.gsub(/\$([A-Z_][0-9A-Z_]*)/) do |key|
207
+ key = key.delete_prefix ?$
208
+ vars[key.to_sym] || "$#{key}"
209
+ end
210
+ end
211
+
212
+ doc
213
+ end
214
+
215
+ # Given a document, perform rudimentary content negotiation.
216
+ # Return the resulting string, or nil if no variant was chosen.
217
+ #
218
+ # @param doc [Nokogiri::XML::Document] the document
219
+ # @param headers [#to_h] the header set for content negotiation
220
+ # @param full [false, true] whether to return a content-header pair
221
+ #
222
+ # @return [String, Array<(String, String)>, nil] the serialized
223
+ # document (maybe, or maybe the Content-Type header too).
224
+ #
225
+ def serialize doc, headers = {}, full: false
226
+ # XXX TODO go back and make it possible for this method to
227
+ # return a hash with all the headers etc so i don't have to do
228
+ # this dumb hack
229
+ method, type = HTTP::Negotiate.negotiate(headers, {
230
+ [TO_XML, 'application/xhtml+xml'] => {
231
+ weight: 1.0, type: 'application/xhtml+xml' },
232
+ [TO_HTML, 'text/html'] => { weight: 0.8, type: 'text/html' },
233
+ [TO_TEXT, 'text/plain'] => { weight: 0.5, type: 'text/plain' },
234
+ })
235
+
236
+ # no type selected
237
+ return unless method
238
+
239
+ # warn method.inspect
240
+
241
+ out = [doc.instance_exec(&method), type]
242
+
243
+ full ? out : out.first
244
+ end
245
+
246
+ # Give us the Rack::Response object and we'll populate the headers
247
+ # and body for you.
248
+ #
249
+ # @param resp [Rack::Response] the response to populate
250
+ # @param headers [#to_h] the header set
251
+ # @param vars [#to_h] the variable bindings
252
+ #
253
+ # @return [Rack::Response] the response object, updated in place
254
+ #
255
+ def populate resp, headers = {}, vars = {}, base: nil
256
+ if (body, type = serialize(
257
+ process(vars: vars, base: base), headers, full: true))
258
+ #resp.length = body.bytesize # not sure if necessary
259
+ resp.write body
260
+ resp.content_type = type
261
+ else
262
+ # otherwise 406 lol, the client didn't like any of our responses
263
+ resp.status = 406
264
+ end
265
+
266
+ resp
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,118 @@
1
+ require 'pathname'
2
+ require 'iso8601'
3
+ require 'uri'
4
+ require 'dry-schema'
5
+
6
+ module ForgetPasswords
7
+ module Types
8
+ include Dry::Types()
9
+
10
+ private
11
+
12
+ # ascii token
13
+ ASCII = /^[A-Za-z_][0-9A-Za-z_.-]*$/
14
+
15
+ # hostname
16
+ HN = /^(?:[0-9a-z-]+(?:\.[0-9a-z-]+)*|[0-9a-f]{,4}(?::[0-9a-f]{,4}){,7})$/i
17
+
18
+ public
19
+
20
+ # XXX THIS IS A BAD SOLUTION TO THE URI PROBLEM
21
+ Dry::Schema::PredicateInferrer::Compiler.infer_predicate_by_class_name false
22
+
23
+ # config primitives
24
+
25
+ ASCIIToken = Strict::String.constrained(format: ASCII).constructor(&:strip)
26
+
27
+ # actually pretty sure i can define constraints for this type, oh well
28
+
29
+ Hostname = String.constructor(&:strip).constrained(format: HN)
30
+
31
+ Duration = Types.Constructor(ISO8601::Duration) do |x|
32
+ begin
33
+ out = ISO8601::Duration.new x.to_s.strip.upcase
34
+ rescue ISO8601::Errors::UnknownPattern => e
35
+ raise Dry::Types::CoercionError.new e
36
+ end
37
+
38
+ out
39
+ end
40
+
41
+ # okay so this shit doesn't seem to work (2022-04-12 huh? what doesn't?)
42
+
43
+ # XXX note this is a fail in dry-types
44
+ URI = Types.Constructor(::URI) do |x|
45
+ begin
46
+ out = ::URI.parse(x)
47
+ rescue ::URI::InvalidURIError => e
48
+ raise Dry::Types::CoercionError, e
49
+ end
50
+
51
+ out
52
+ end
53
+
54
+ RelativePathname = Types.Constructor(::Pathname) { |x| Pathname(x) }
55
+
56
+ ExtantPathname = Types.Constructor(::Pathname) do |x|
57
+ out = Pathname(x).expand_path
58
+ dir = out.dirname
59
+ raise Dry::Types::CoercionError, "#{dir} does not exist" unless
60
+ out.exist? || dir.exist?
61
+
62
+ out
63
+ end
64
+
65
+
66
+ # should be WritablePathname but whatever
67
+ WritablePathname = Types.Constructor(::Pathname) do |x|
68
+ out = Pathname(x)
69
+ dir = out.expand_path.dirname
70
+ raise Dry::Types::CoercionError, "#{dir} is not writable" unless
71
+ dir.writable?
72
+ raise Dry::Types::CoercionError, "#{out} can't be overwritten" if
73
+ out.exist? and !out.writable?
74
+ out
75
+ end
76
+
77
+ NormSym = Symbol.constructor do |k|
78
+ k.to_s.strip.downcase.tr_s(' _-', ?_).to_sym
79
+ end
80
+
81
+ # symbol hash
82
+ SymbolHash = Hash.schema({}).with_key_transform do |k|
83
+ NormSym.call k
84
+ end
85
+
86
+ # apparently you can't go from schema to map
87
+ SymbolMap = Hash.map NormSym, Any
88
+
89
+ # this is a generic type for stuff that comes off the command line
90
+ # or out of a config file that we don't want to explicitly define
91
+ # but nevertheless needs to be coerced (in particular integers,
92
+ # floats) so it can be passed into eg a constructor
93
+ Atomic = Coercible::Integer | Coercible::Float | Coercible::String
94
+ end
95
+
96
+ end
97
+
98
+ module Dry::Types::Builder
99
+ def hash_default
100
+ # obtain all the required keys from the spec
101
+ reqd = keys.select(&:required?)
102
+
103
+ if reqd.empty?
104
+ # there aren't any requireed keys, but we'll set the empty hash
105
+ # as a default if there exist optional keys, otherwise any
106
+ # default will interfere with input from upstream.
107
+ return default({}.freeze) unless keys.empty?
108
+ else
109
+ # similarly, we only set a default if all the required keys have them.
110
+ return default(reqd.map { |k| [k.name, k.value] }.to_h.freeze) if
111
+ reqd.all?(&:default?)
112
+ # XXX THIS WILL FAIL IF THE DEFAULT IS A PROC; THE FAIL IS IN DRY-RB
113
+ end
114
+
115
+ # otherwise just return self
116
+ self
117
+ end
118
+ end
@@ -0,0 +1,3 @@
1
+ module ForgetPasswords
2
+ VERSION = '0.2.9'
3
+ end