sgfa 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +674 -0
- data/README.txt +14 -0
- data/bin/sgfa +15 -0
- data/data/sgfa_web.css +240 -0
- data/lib/sgfa/binder.rb +627 -0
- data/lib/sgfa/binder_fs.rb +203 -0
- data/lib/sgfa/cli/binder.rb +160 -0
- data/lib/sgfa/cli/jacket.rb +299 -0
- data/lib/sgfa/cli.rb +36 -0
- data/lib/sgfa/demo/web_binders.rb +111 -0
- data/lib/sgfa/demo/web_css.rb +60 -0
- data/lib/sgfa/entry.rb +697 -0
- data/lib/sgfa/error.rb +95 -0
- data/lib/sgfa/history.rb +445 -0
- data/lib/sgfa/jacket.rb +556 -0
- data/lib/sgfa/jacket_fs.rb +136 -0
- data/lib/sgfa/lock_fs.rb +141 -0
- data/lib/sgfa/state_fs.rb +342 -0
- data/lib/sgfa/store_fs.rb +214 -0
- data/lib/sgfa/web/base.rb +225 -0
- data/lib/sgfa/web/binder.rb +1190 -0
- data/lib/sgfa.rb +37 -0
- metadata +68 -0
data/lib/sgfa/error.rb
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
#
|
2
|
+
# Simple Group of Filing Applications
|
3
|
+
# Errors
|
4
|
+
#
|
5
|
+
# Copyright (C) 2015 by Graham A. Field.
|
6
|
+
#
|
7
|
+
# See LICENSE.txt for licensing information.
|
8
|
+
#
|
9
|
+
# This program is distributed WITHOUT ANY WARRANTY; without even the
|
10
|
+
# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
11
|
+
|
12
|
+
# This ; prevents YARD from using the header as docs for Sgfa module
|
13
|
+
;
|
14
|
+
|
15
|
+
module Sgfa
|
16
|
+
|
17
|
+
|
18
|
+
##########################################################################
|
19
|
+
# Errors generated by the Sgfa system
|
20
|
+
module Error
|
21
|
+
|
22
|
+
|
23
|
+
##########################################
|
24
|
+
# Most limit checks
|
25
|
+
#
|
26
|
+
# @param str [String] String to check
|
27
|
+
# @param min [Integer] Minimum length
|
28
|
+
# @param max [Integer] max Maximum length
|
29
|
+
# @param inv [Regexp, String] Invalid characters
|
30
|
+
# @param desc [String] desc Description of string
|
31
|
+
# @raise [Error::Limits] if limits are violated
|
32
|
+
def self.limits(str, min, max, inv, desc)
|
33
|
+
|
34
|
+
# string
|
35
|
+
if !str || !str.is_a?(String)
|
36
|
+
raise Error::Limits, desc + ' is not a string.'
|
37
|
+
end
|
38
|
+
|
39
|
+
# size
|
40
|
+
size = str.size
|
41
|
+
if str.size > max
|
42
|
+
raise Error::Limits, '%s length %d is too large, limit %d' %
|
43
|
+
[desc, size, max]
|
44
|
+
end
|
45
|
+
if str.size < min
|
46
|
+
raise Error::Limits, '%s length %d is too small, limit %d' %
|
47
|
+
[desc, size, min]
|
48
|
+
end
|
49
|
+
|
50
|
+
# invalid characters
|
51
|
+
idx = str.index(inv)
|
52
|
+
if idx
|
53
|
+
raise Error::Limits,
|
54
|
+
'%s contains invalid character \'%s\'' % [desc, str[idx]]
|
55
|
+
end
|
56
|
+
end # def self.limits()
|
57
|
+
|
58
|
+
|
59
|
+
###########################################################
|
60
|
+
# Sanity check failed
|
61
|
+
class Sanity < StandardError; end
|
62
|
+
|
63
|
+
|
64
|
+
###########################################################
|
65
|
+
# Outside of limits
|
66
|
+
class Limits < StandardError; end
|
67
|
+
|
68
|
+
|
69
|
+
###########################################################
|
70
|
+
# Corrupt file
|
71
|
+
class Corrupt < StandardError; end
|
72
|
+
|
73
|
+
|
74
|
+
###########################################################
|
75
|
+
# Outside of limits
|
76
|
+
class Limits < StandardError; end
|
77
|
+
|
78
|
+
|
79
|
+
###########################################################
|
80
|
+
# Conflict of some type
|
81
|
+
class Conflict < StandardError; end
|
82
|
+
|
83
|
+
|
84
|
+
###########################################################
|
85
|
+
# Does not exist
|
86
|
+
class NonExistent < StandardError; end
|
87
|
+
|
88
|
+
|
89
|
+
###########################################################
|
90
|
+
# Permission check failed
|
91
|
+
class Permission < StandardError; end
|
92
|
+
|
93
|
+
end # module Error
|
94
|
+
|
95
|
+
end # module Sgfa
|
data/lib/sgfa/history.rb
ADDED
@@ -0,0 +1,445 @@
|
|
1
|
+
#
|
2
|
+
# Simple Group of Filing Applications
|
3
|
+
# History item
|
4
|
+
#
|
5
|
+
# Copyright (C) 2015 by Graham A. Field.
|
6
|
+
#
|
7
|
+
# See LICENSE.txt for licensing information.
|
8
|
+
#
|
9
|
+
# This program is distributed WITHOUT ANY WARRANTY; without even the
|
10
|
+
# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
11
|
+
|
12
|
+
require 'digest/sha2'
|
13
|
+
|
14
|
+
require_relative 'error'
|
15
|
+
|
16
|
+
module Sgfa
|
17
|
+
|
18
|
+
|
19
|
+
#####################################################################
|
20
|
+
# The history item provides a cryptographic chain of changes made to a
|
21
|
+
# {Jacket}.
|
22
|
+
#
|
23
|
+
# The History has attributes:
|
24
|
+
# * hash - SHA256 hash of the canonical encoding
|
25
|
+
# * canonical - the canonical encoded string
|
26
|
+
# * jacket - The {Jacket} hash ID the history belongs to
|
27
|
+
# * previous - Previous history number
|
28
|
+
# * history - History number
|
29
|
+
# * entry_max - Number of entries present in the Jacket
|
30
|
+
# * time - Date and time of the change
|
31
|
+
# * user - User name who made the change
|
32
|
+
# * entries - List of entries changed \[entry_num, revision_num, hash\]
|
33
|
+
# * attachments - List of attachments made \[entry_num, attach_num, hash\]
|
34
|
+
#
|
35
|
+
class History
|
36
|
+
|
37
|
+
#########################################################
|
38
|
+
# @!group Limits checks
|
39
|
+
|
40
|
+
# Max chars in user name
|
41
|
+
LimUserMax = 64
|
42
|
+
|
43
|
+
# Invalid character in user name
|
44
|
+
LimUserInv = /[[:cntrl:]]/
|
45
|
+
|
46
|
+
#####################################
|
47
|
+
# Limit check, user name
|
48
|
+
def self.limits_user(str)
|
49
|
+
Error.limits(str, 1, LimUserMax, LimUserInv, 'User name')
|
50
|
+
end # def self.limits_user()
|
51
|
+
|
52
|
+
|
53
|
+
#########################################################
|
54
|
+
# @!group Read attributes
|
55
|
+
|
56
|
+
|
57
|
+
#####################################
|
58
|
+
# Get history item hash
|
59
|
+
#
|
60
|
+
# @return [String] The hash of the history
|
61
|
+
# @raise (see #canonical)
|
62
|
+
def hash
|
63
|
+
if !@hash
|
64
|
+
@hash = Digest::SHA256.new.update(canonical).hexdigest
|
65
|
+
end
|
66
|
+
return @hash.dup
|
67
|
+
end # def hash
|
68
|
+
|
69
|
+
|
70
|
+
#####################################
|
71
|
+
# Generate canonical encoded string
|
72
|
+
#
|
73
|
+
# @return [String] Canonical output
|
74
|
+
# @raise [Error::Sanity] if the history is not complete
|
75
|
+
def canonical
|
76
|
+
if !@canon
|
77
|
+
raise Error::Sanity, 'History not complete' if !@history
|
78
|
+
|
79
|
+
txt = "jckt %s\n" % @jacket
|
80
|
+
txt << "hist %d\n" % @history
|
81
|
+
txt << "emax %d\n" % @entry_max
|
82
|
+
txt << "time %s\n" % time_str
|
83
|
+
txt << "prev %s\n" % @previous
|
84
|
+
txt << "user %s\n" % @user
|
85
|
+
@entries.each{|ary| txt << "entr %d %d %s\n" % ary }
|
86
|
+
@attach.each{|ary| txt << "atch %d %d %s\n" % ary }
|
87
|
+
@canon = txt
|
88
|
+
end
|
89
|
+
return @canon.dup
|
90
|
+
end # def canonical
|
91
|
+
|
92
|
+
|
93
|
+
#####################################
|
94
|
+
# Get jacket
|
95
|
+
#
|
96
|
+
# @return [String, Boolean] The jacket hash ID or false if not set
|
97
|
+
def jacket
|
98
|
+
if @jacket
|
99
|
+
return @jacket.dup
|
100
|
+
else
|
101
|
+
return false
|
102
|
+
end
|
103
|
+
end # def jacket
|
104
|
+
|
105
|
+
|
106
|
+
#####################################
|
107
|
+
# Get previous history hash
|
108
|
+
#
|
109
|
+
# @return [String, Boolean] The hash of the previous history item,
|
110
|
+
# or false if not set
|
111
|
+
def previous
|
112
|
+
if @previous
|
113
|
+
return @previous.dup
|
114
|
+
else
|
115
|
+
return false
|
116
|
+
end
|
117
|
+
end # def previous
|
118
|
+
|
119
|
+
|
120
|
+
#####################################
|
121
|
+
# Get history number
|
122
|
+
#
|
123
|
+
# @return [Integer, Boolean] History number, or false if not set
|
124
|
+
def history
|
125
|
+
return @history
|
126
|
+
end # def history
|
127
|
+
|
128
|
+
|
129
|
+
#####################################
|
130
|
+
# Get maximum entry
|
131
|
+
#
|
132
|
+
# @return [Integer, Boolean] Maximum entry in a Jacket, or false if not set
|
133
|
+
def entry_max
|
134
|
+
return @entry_max
|
135
|
+
end # def entry_max
|
136
|
+
|
137
|
+
|
138
|
+
# Regex to parse time string
|
139
|
+
TimeStrReg = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/
|
140
|
+
private_constant :TimeStrReg
|
141
|
+
|
142
|
+
|
143
|
+
#####################################
|
144
|
+
# Get time
|
145
|
+
#
|
146
|
+
# @return [Time, Boolean] The time, or false if not set
|
147
|
+
def time
|
148
|
+
if !@time
|
149
|
+
return false if !@time_str
|
150
|
+
ma = TimeStrReg.match(@time_str)
|
151
|
+
ary = ma[1,6].map{|str| str.to_i}
|
152
|
+
@time = Time.utc(*ary)
|
153
|
+
end
|
154
|
+
return @time.dup
|
155
|
+
end # def time
|
156
|
+
|
157
|
+
|
158
|
+
#####################################
|
159
|
+
# Get time string
|
160
|
+
#
|
161
|
+
# @return [String, Boolean] Encoded time string of the history, or false
|
162
|
+
# if time not set
|
163
|
+
def time_str
|
164
|
+
if !@time_str
|
165
|
+
return false if !@time
|
166
|
+
@time_str = @time.strftime('%F %T')
|
167
|
+
end
|
168
|
+
return @time_str.dup
|
169
|
+
end # def time_str
|
170
|
+
|
171
|
+
|
172
|
+
#####################################
|
173
|
+
# Get user
|
174
|
+
#
|
175
|
+
# @return [String, Boolean] The user string, or false if not set
|
176
|
+
def user
|
177
|
+
if @user
|
178
|
+
return @user.dup
|
179
|
+
else
|
180
|
+
return false
|
181
|
+
end
|
182
|
+
end # def user
|
183
|
+
|
184
|
+
|
185
|
+
#####################################
|
186
|
+
# Get entries
|
187
|
+
#
|
188
|
+
# @return [Array] of Entry information \[entry_num, revision_num, hash\]
|
189
|
+
def entries
|
190
|
+
return @entries.map{|enum, rnum, hash| [enum, rnum, hash.dup] }
|
191
|
+
end # def entries
|
192
|
+
|
193
|
+
|
194
|
+
#####################################
|
195
|
+
# Get attachments
|
196
|
+
#
|
197
|
+
# @return [Array] of Attachment information \[entry_num, attach_num, hash\]
|
198
|
+
def attachments
|
199
|
+
return @attach.map{|enum, anum, hash| [enum, anum, hash.dup] }
|
200
|
+
end # def attachments
|
201
|
+
|
202
|
+
|
203
|
+
|
204
|
+
#########################################################
|
205
|
+
# @!group General interface
|
206
|
+
|
207
|
+
#####################################
|
208
|
+
# Create a new History item
|
209
|
+
#
|
210
|
+
# @param (see #jacket=)
|
211
|
+
# @raise (see #jacket=)
|
212
|
+
def initialize(jck=nil)
|
213
|
+
reset
|
214
|
+
if jck
|
215
|
+
self.jacket = jck
|
216
|
+
end
|
217
|
+
end # def initialize()
|
218
|
+
|
219
|
+
|
220
|
+
#####################################
|
221
|
+
# Reset to blank History item
|
222
|
+
#
|
223
|
+
# @return [History] self
|
224
|
+
def reset
|
225
|
+
@hash = nil
|
226
|
+
@canon = nil
|
227
|
+
@jacket = nil
|
228
|
+
@history = nil
|
229
|
+
@entry_max = 0
|
230
|
+
@time = nil
|
231
|
+
@time_str = nil
|
232
|
+
@previous = nil
|
233
|
+
@user = nil
|
234
|
+
@entries = []
|
235
|
+
@attach = []
|
236
|
+
|
237
|
+
@change_entry = nil
|
238
|
+
@change_tag = nil
|
239
|
+
@change_attach = nil
|
240
|
+
end # def reset
|
241
|
+
|
242
|
+
|
243
|
+
#####################################
|
244
|
+
# Set History using canonical encoding
|
245
|
+
#
|
246
|
+
# @param str [String] Canonical encoded History
|
247
|
+
# @raise [Error::Corrupt] if encoding does not follow canonical rules
|
248
|
+
def canonical=(str)
|
249
|
+
@hash = nil
|
250
|
+
@canon = str.dup
|
251
|
+
lines = str.lines
|
252
|
+
|
253
|
+
ma = /^jckt ([0-9a-f]{64})$/.match lines.shift
|
254
|
+
raise(Error::Corrupt, 'Canonical history jacket error') if !ma
|
255
|
+
@jacket = ma[1]
|
256
|
+
|
257
|
+
ma = /^hist (\d+)$/.match lines.shift
|
258
|
+
raise(Error::Corrupt, 'Canonical history history error') if !ma
|
259
|
+
@history = ma[1].to_i
|
260
|
+
|
261
|
+
ma = /^emax (\d+)$/.match lines.shift
|
262
|
+
raise(Error::Corrupt, 'Canonical history entry_max error') if !ma
|
263
|
+
@entry_max = ma[1].to_i
|
264
|
+
|
265
|
+
ma = /^time (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})$/.match lines.shift
|
266
|
+
raise(Error::Corrupt, 'Canonical history time error') if !ma
|
267
|
+
@time_str = ma[1]
|
268
|
+
|
269
|
+
ma = /^prev ([0-9a-f]{64})$/.match lines.shift
|
270
|
+
raise(Error::Corrupt, 'Canonical history previous error') if !ma
|
271
|
+
@previous = ma[1]
|
272
|
+
|
273
|
+
ma = /^user (.+)$/.match lines.shift
|
274
|
+
raise(Error::Corrupt, 'Canonical history user error') if !ma
|
275
|
+
@user = ma[1]
|
276
|
+
|
277
|
+
li = lines.shift
|
278
|
+
@entries = []
|
279
|
+
while(li && ma = /^entr (\d+) (\d+) (.+)$/.match(li) )
|
280
|
+
@entries.push [ma[1].to_i, ma[2].to_i, ma[3]]
|
281
|
+
li = lines.shift
|
282
|
+
end
|
283
|
+
|
284
|
+
@attach = []
|
285
|
+
while(li && ma = /^atch (\d+) (\d+) ([0-9a-f]{64})$/.match(li) )
|
286
|
+
@attach.push [ma[1].to_i, ma[2].to_i, ma[3]]
|
287
|
+
li = lines.shift
|
288
|
+
end
|
289
|
+
|
290
|
+
if li
|
291
|
+
raise Error::Corrupt, 'Canonical history error'
|
292
|
+
end
|
293
|
+
|
294
|
+
rescue
|
295
|
+
reset
|
296
|
+
raise
|
297
|
+
end # def canonical=()
|
298
|
+
|
299
|
+
|
300
|
+
#####################################
|
301
|
+
# Set jacket
|
302
|
+
#
|
303
|
+
# @param [String] jck The jacket hash ID
|
304
|
+
# @raise [Error::Limits] if jck is not a valid hash
|
305
|
+
# @raise [Error::Sanity] if changing an already set jacket hash ID
|
306
|
+
def jacket=(jck)
|
307
|
+
ma = /^([0-9a-f]{64})$/.match jck
|
308
|
+
raise(Error::Limits, 'Jacket hash not valid') if !ma
|
309
|
+
if @jacket
|
310
|
+
raise(Error::Sanity, 'Jacket already set') if @jacket != jck
|
311
|
+
else
|
312
|
+
@jacket = jck.dup
|
313
|
+
end
|
314
|
+
end # def jacket=()
|
315
|
+
|
316
|
+
|
317
|
+
# Tag added to every entry
|
318
|
+
TagAll = '_all'
|
319
|
+
private_constant :TagAll
|
320
|
+
|
321
|
+
|
322
|
+
#####################################
|
323
|
+
# Generate next history
|
324
|
+
#
|
325
|
+
# @param [String] user User making the change
|
326
|
+
# @param [Array] ents Entries to update
|
327
|
+
# @param [Time] tme Time to use for the history
|
328
|
+
# @return [Array] the next history item, and the changes hash
|
329
|
+
# @raise (see #hash)
|
330
|
+
# @raise (see #process)
|
331
|
+
def next(user, ents, tme=nil)
|
332
|
+
raise Error::Sanity, 'History not complete' if !@history
|
333
|
+
nxt = History.new(@jacket)
|
334
|
+
cng = nxt.process(@history + 1, hash, @entry_max, user, ents, tme)
|
335
|
+
return [nxt, cng]
|
336
|
+
end # def next
|
337
|
+
|
338
|
+
|
339
|
+
#####################################
|
340
|
+
# Process entries
|
341
|
+
#
|
342
|
+
# @note Entries without a jacket set are automatically set to the correct
|
343
|
+
# value
|
344
|
+
# @note If time is not supplied, current time is used
|
345
|
+
#
|
346
|
+
# The changes returned consist of:
|
347
|
+
# * :entry - Array of entries provided in ents
|
348
|
+
# * :tag - Hash of tag => Hash of Entry => time_str or nil
|
349
|
+
# * :attach - Attached files \[entry_num, attach_num, file\]
|
350
|
+
#
|
351
|
+
# @param [Integer] hnum History number
|
352
|
+
# @param [String] prev Hash of previous item
|
353
|
+
# @param [Integer] emax Maximum previous entry number
|
354
|
+
# @param [String] user User making the change
|
355
|
+
# @param [Array] ents Entries to update
|
356
|
+
# @param [Time] tme Time to use for the history
|
357
|
+
# @return [Hash] Record of changes made
|
358
|
+
# @raise [Error::Sanity] if entry does not belong to the same jacket
|
359
|
+
# @raise (see Entry#update)
|
360
|
+
def process(hnum, prev, emax, user, ents, tme=nil)
|
361
|
+
|
362
|
+
cng = {}
|
363
|
+
add = []
|
364
|
+
|
365
|
+
# initial values
|
366
|
+
@hash = nil
|
367
|
+
@canon = nil
|
368
|
+
@previous = prev
|
369
|
+
@history = hnum
|
370
|
+
@entry_max = emax
|
371
|
+
if tme
|
372
|
+
@time = tme.utc
|
373
|
+
else
|
374
|
+
@time = Time.now.utc
|
375
|
+
end
|
376
|
+
@user = user.dup
|
377
|
+
@entries = []
|
378
|
+
@attach = []
|
379
|
+
|
380
|
+
# process the entries
|
381
|
+
ents.each do |entry|
|
382
|
+
# set/check jacket
|
383
|
+
if !entry.jacket
|
384
|
+
entry.jacket = @jacket
|
385
|
+
elsif entry.jacket != @jacket
|
386
|
+
raise Error::Sanity, 'Entry belongs to different jacket'
|
387
|
+
end
|
388
|
+
|
389
|
+
# set entry for new entries
|
390
|
+
if !entry.entry
|
391
|
+
@entry_max += 1
|
392
|
+
entry.entry = @entry_max
|
393
|
+
end
|
394
|
+
|
395
|
+
# update the entry
|
396
|
+
ecng = entry.update(@history)
|
397
|
+
enum = entry.entry
|
398
|
+
ts = entry.time_str
|
399
|
+
|
400
|
+
# time changed, all tags update
|
401
|
+
if ecng[:time]
|
402
|
+
cng[TagAll] = {} if !cng[TagAll]
|
403
|
+
cng[TagAll][enum] = ts
|
404
|
+
entry.tags.each do |tag|
|
405
|
+
cng[tag] = {} if !cng[tag]
|
406
|
+
cng[tag][enum] = ts
|
407
|
+
end
|
408
|
+
|
409
|
+
# just new tags
|
410
|
+
else
|
411
|
+
ecng[:tags_add].each do |tag|
|
412
|
+
cng[tag] = {} if !cng[tag]
|
413
|
+
cng[tag][enum] = ts
|
414
|
+
end
|
415
|
+
end
|
416
|
+
|
417
|
+
# deleted tags
|
418
|
+
ecng[:tags_del].each do |tag|
|
419
|
+
cng[tag] = {} if !cng[tag]
|
420
|
+
cng[tag][enum] = nil
|
421
|
+
end
|
422
|
+
|
423
|
+
# record entry and attachments
|
424
|
+
@entries.push [enum, entry.revision, entry.hash]
|
425
|
+
ecng[:files].each do |anum, ary|
|
426
|
+
file, hash = ary
|
427
|
+
@attach.push [enum, anum, hash]
|
428
|
+
add.push [enum, anum, file]
|
429
|
+
end
|
430
|
+
|
431
|
+
end
|
432
|
+
|
433
|
+
ret = {
|
434
|
+
:entry => ents,
|
435
|
+
:tag => cng,
|
436
|
+
:attach => add,
|
437
|
+
}
|
438
|
+
|
439
|
+
return ret
|
440
|
+
end # def process()
|
441
|
+
|
442
|
+
|
443
|
+
end # class History
|
444
|
+
|
445
|
+
end # module Sgfa
|