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.
- checksums.yaml +7 -0
- data/.gitignore +27 -0
- data/.rspec +3 -0
- data/.travis.yml +5 -0
- data/Gemfile +6 -0
- data/LICENSE +202 -0
- data/README.md +603 -0
- data/Rakefile +6 -0
- data/behaviour.org +112 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/content/basic-401.xhtml +15 -0
- data/content/basic-404.xhtml +10 -0
- data/content/basic-409.xhtml +14 -0
- data/content/basic-500.xhtml +10 -0
- data/content/cookie-expired.xhtml +15 -0
- data/content/email-409.xhtml +15 -0
- data/content/email-sent.xhtml +11 -0
- data/content/email.xhtml +10 -0
- data/content/logged-out-all.xhtml +10 -0
- data/content/logged-out.xhtml +10 -0
- data/content/nonce-expired.xhtml +15 -0
- data/content/not-on-list.xhtml +15 -0
- data/content/post-405.xhtml +10 -0
- data/content/uri-409.xhtml +10 -0
- data/etc/text-only.xsl +105 -0
- data/exe/forgetpw +7 -0
- data/forget-passwords.gemspec +67 -0
- data/lib/forget-passwords/cli.rb +514 -0
- data/lib/forget-passwords/fastcgi.rb +28 -0
- data/lib/forget-passwords/state.rb +535 -0
- data/lib/forget-passwords/template.rb +269 -0
- data/lib/forget-passwords/types.rb +118 -0
- data/lib/forget-passwords/version.rb +3 -0
- data/lib/forget-passwords.rb +635 -0
- metadata +312 -0
@@ -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
|