forget-passwords 0.2.9
Sign up to get free protection for your applications and to get access to all the features.
- 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
|