diary-ruby 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/.gitignore +11 -0
- data/.travis.yml +4 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +4 -0
- data/LICENSE +674 -0
- data/README.md +56 -0
- data/Rakefile +10 -0
- data/TODO.md +4 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/diary-ruby.gemspec +39 -0
- data/exe/diaryrb +188 -0
- data/lib/diary-ruby.rb +20 -0
- data/lib/diary-ruby/configuration.rb +88 -0
- data/lib/diary-ruby/entry.rb +85 -0
- data/lib/diary-ruby/ext/encryptor.rb +95 -0
- data/lib/diary-ruby/ext/secure_pstore.rb +86 -0
- data/lib/diary-ruby/parser.rb +68 -0
- data/lib/diary-ruby/server/public/script.js +57 -0
- data/lib/diary-ruby/server/public/style.css +33 -0
- data/lib/diary-ruby/server/server.rb +93 -0
- data/lib/diary-ruby/server/views/index.erb +61 -0
- data/lib/diary-ruby/store.rb +79 -0
- data/lib/diary-ruby/version.rb +3 -0
- metadata +182 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# Create an encrypted bundled string version of a message, given a particular
|
|
4
|
+
# passphrase.
|
|
5
|
+
#
|
|
6
|
+
# Decrypt a bundled document given a particular passphrase.
|
|
7
|
+
#
|
|
8
|
+
# NOTE: the encrypted bundle chooses a random initialization vector
|
|
9
|
+
# and salt and includes them in the bundle in plain text alongside the
|
|
10
|
+
# encrypted message.
|
|
11
|
+
#
|
|
12
|
+
|
|
13
|
+
require 'openssl'
|
|
14
|
+
require 'base64'
|
|
15
|
+
|
|
16
|
+
module Encryptor
|
|
17
|
+
class Error < StandardError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.encrypt(msg, pwd)
|
|
21
|
+
cipher = OpenSSL::Cipher.new 'AES-128-CBC'
|
|
22
|
+
cipher.encrypt
|
|
23
|
+
|
|
24
|
+
# random salt
|
|
25
|
+
salt = OpenSSL::Random.random_bytes(16)
|
|
26
|
+
|
|
27
|
+
# random initialization vector
|
|
28
|
+
iv = cipher.random_iv
|
|
29
|
+
|
|
30
|
+
iter = 20000
|
|
31
|
+
key_len = cipher.key_len
|
|
32
|
+
digest = OpenSSL::Digest::SHA256.new
|
|
33
|
+
|
|
34
|
+
key = OpenSSL::PKCS5.pbkdf2_hmac(pwd, salt, iter, key_len, digest)
|
|
35
|
+
cipher.key = key
|
|
36
|
+
|
|
37
|
+
# Now encrypt the data:
|
|
38
|
+
encrypted = cipher.update(msg)
|
|
39
|
+
encrypted << cipher.final
|
|
40
|
+
|
|
41
|
+
# And encode final format
|
|
42
|
+
wrap(iv, salt, encrypted)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.decrypt(document, pwd)
|
|
46
|
+
iv, salt, encrypted = unwrap(document)
|
|
47
|
+
|
|
48
|
+
Diary.debug "DECRYPT WITH"
|
|
49
|
+
Diary.debug " iv #{ Base64.encode64(iv) }"
|
|
50
|
+
Diary.debug " salt #{ Base64.encode64(salt) }"
|
|
51
|
+
Diary.debug " msg #{ Base64.encode64(encrypted) }"
|
|
52
|
+
|
|
53
|
+
## Decrypt
|
|
54
|
+
cipher = OpenSSL::Cipher.new 'AES-128-CBC'
|
|
55
|
+
cipher.decrypt
|
|
56
|
+
cipher.iv = iv
|
|
57
|
+
|
|
58
|
+
salt = salt
|
|
59
|
+
iter = 20000
|
|
60
|
+
key_len = cipher.key_len
|
|
61
|
+
digest = OpenSSL::Digest::SHA256.new
|
|
62
|
+
|
|
63
|
+
key = OpenSSL::PKCS5.pbkdf2_hmac(pwd, salt, iter, key_len, digest)
|
|
64
|
+
cipher.key = key
|
|
65
|
+
|
|
66
|
+
decrypted = cipher.update(encrypted)
|
|
67
|
+
decrypted << cipher.final
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.wrap(iv, salt, encrypted)
|
|
71
|
+
[
|
|
72
|
+
Base64.encode64(iv),
|
|
73
|
+
Base64.encode64(salt),
|
|
74
|
+
Base64.encode64(encrypted)
|
|
75
|
+
].join('|')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.unwrap(document)
|
|
79
|
+
if document.is_a?(File)
|
|
80
|
+
document = document.read
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if document.count('|') != 2
|
|
84
|
+
raise Encryptor::Error.new("Document is not a vaild encrypted store.")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
iv64, salt64, encrypted64 = document.split('|')
|
|
88
|
+
|
|
89
|
+
iv = Base64.decode64(iv64.to_s.strip)
|
|
90
|
+
salt = Base64.decode64(salt64.to_s.strip)
|
|
91
|
+
encrypted = Base64.decode64(encrypted64.to_s.strip)
|
|
92
|
+
|
|
93
|
+
[iv, salt, encrypted]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
|
|
2
|
+
require 'pstore'
|
|
3
|
+
require 'diary-ruby/ext/encryptor'
|
|
4
|
+
|
|
5
|
+
#
|
|
6
|
+
# Wrap PStore, combine with OpenSSL::Cipher to secure store on disk with a
|
|
7
|
+
# given passphrase
|
|
8
|
+
#
|
|
9
|
+
# SecurePStore
|
|
10
|
+
#
|
|
11
|
+
# Useable exactly like PStore except for initialization.
|
|
12
|
+
#
|
|
13
|
+
# With PStore:
|
|
14
|
+
#
|
|
15
|
+
# wiki = PStore.new("wiki_pages.pstore")
|
|
16
|
+
# wiki.transaction do # begin transaction; do all of this or none of it
|
|
17
|
+
# # store page...
|
|
18
|
+
# wiki[home_page.page_name] = home_page
|
|
19
|
+
# # ensure that an index has been created...
|
|
20
|
+
# wiki[:wiki_index] ||= Array.new
|
|
21
|
+
# # update wiki index...
|
|
22
|
+
# wiki[:wiki_index].push(*home_page.wiki_page_references)
|
|
23
|
+
# end # commit changes to wiki data store file
|
|
24
|
+
#
|
|
25
|
+
# With SecurePStore:
|
|
26
|
+
#
|
|
27
|
+
# wiki = SecurePStore.new("wiki_pages.pstore", passphrase: 'do it this way instead')
|
|
28
|
+
# wiki.transaction do # begin transaction; do all of this or none of it
|
|
29
|
+
# # store page...
|
|
30
|
+
# wiki[home_page.page_name] = home_page
|
|
31
|
+
# # ensure that an index has been created...
|
|
32
|
+
# wiki[:wiki_index] ||= Array.new
|
|
33
|
+
# # update wiki index...
|
|
34
|
+
# wiki[:wiki_index].push(*home_page.wiki_page_references)
|
|
35
|
+
# end # commit changes to wiki data store file
|
|
36
|
+
#
|
|
37
|
+
# Simple!
|
|
38
|
+
#
|
|
39
|
+
class SecurePStore < PStore
|
|
40
|
+
# :call-seq:
|
|
41
|
+
# initialize( file_name, secure_opts = {} )
|
|
42
|
+
#
|
|
43
|
+
# Creates a new SecureStore object, which will store data in +file_name+.
|
|
44
|
+
# If the file does not already exist, it will be created.
|
|
45
|
+
#
|
|
46
|
+
# Options passed in through +secure_opts+ will be used behind the scenes
|
|
47
|
+
# when writing the encrypted file to disk.
|
|
48
|
+
def initialize file_name, secure_opts = {}
|
|
49
|
+
@opt = secure_opts
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Override PStore's private low-level storage methods, similar to YAML::Store
|
|
54
|
+
#
|
|
55
|
+
def dump(table) # :nodoc:
|
|
56
|
+
marshalled = Marshal::dump(table)
|
|
57
|
+
# return encrypted
|
|
58
|
+
Encryptor.encrypt(marshalled, @opt[:passphrase])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load(content) # :nodoc:
|
|
62
|
+
begin
|
|
63
|
+
dcontent = Encryptor.decrypt(content, @opt[:passphrase])
|
|
64
|
+
Marshal::load(dcontent)
|
|
65
|
+
rescue OpenSSL::Cipher::CipherError => ex
|
|
66
|
+
raise PStore::Error.new("Failed to decrypt stored data: #{ ex.message }")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def marshal_dump_supports_canonical_option?
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def empty_marshal_data
|
|
75
|
+
@empty_marshal_data ||= begin
|
|
76
|
+
m = Marshal.dump({})
|
|
77
|
+
Encryptor.encrypt(m, @opt[:passphrase])
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def empty_marshal_checksum
|
|
82
|
+
@empty_marshal_checksum ||= begin
|
|
83
|
+
Digest::MD5.digest(empty_marshal_data)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Parse a plaintext diary entry into a Diary::Entry object
|
|
2
|
+
|
|
3
|
+
module Diary
|
|
4
|
+
class Parser
|
|
5
|
+
def self.parse(infile)
|
|
6
|
+
header = []
|
|
7
|
+
body = []
|
|
8
|
+
in_header = true
|
|
9
|
+
split_match = /^---+$/
|
|
10
|
+
|
|
11
|
+
Diary.debug("PARSE #{ infile.size } BYTES")
|
|
12
|
+
|
|
13
|
+
infile.lines.each do |line|
|
|
14
|
+
if in_header
|
|
15
|
+
if split_match =~ line
|
|
16
|
+
in_header = false
|
|
17
|
+
next
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# check for line
|
|
21
|
+
header << line
|
|
22
|
+
else
|
|
23
|
+
body << line
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
metadata = {}
|
|
28
|
+
|
|
29
|
+
key_match = /^([A-Za-z_-]+):? (.+)$/
|
|
30
|
+
header.each do |h_line|
|
|
31
|
+
if key_match =~ h_line
|
|
32
|
+
key = $1.strip.downcase
|
|
33
|
+
val = $2.strip
|
|
34
|
+
|
|
35
|
+
if /tags/i =~ key
|
|
36
|
+
val = val.split(',').map {|v| v.strip}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
metadata[key] = val
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
key = Entry.keygen(metadata['day'], metadata['time'])
|
|
44
|
+
Diary.debug "KEY #{ key }"
|
|
45
|
+
Diary.debug "METADATA #{ metadata.inspect }"
|
|
46
|
+
Diary.debug "BODY #{ body.join(" ") }"
|
|
47
|
+
|
|
48
|
+
return Entry.new(
|
|
49
|
+
nil,
|
|
50
|
+
day: metadata['day'],
|
|
51
|
+
time: metadata['time'],
|
|
52
|
+
tags: metadata['tags'],
|
|
53
|
+
text: body.join("\n").strip,
|
|
54
|
+
title: metadata['title'],
|
|
55
|
+
key: key,
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.parse_file(file)
|
|
60
|
+
# read
|
|
61
|
+
file.seek(0)
|
|
62
|
+
contents = file.read
|
|
63
|
+
|
|
64
|
+
# now parse
|
|
65
|
+
self.parse(contents)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// alert('hello world!')
|
|
2
|
+
|
|
3
|
+
// vanilla js AJAX
|
|
4
|
+
/*
|
|
5
|
+
var r = new XMLHttpRequest();
|
|
6
|
+
r.open("POST", "path/to/api", true);
|
|
7
|
+
r.onreadystatechange = function () {
|
|
8
|
+
if (r.readyState != 4 || r.status != 200) return;
|
|
9
|
+
alert("Success: " + r.responseText);
|
|
10
|
+
};
|
|
11
|
+
r.send("banana=yellow");
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
var each = function (el, func) {
|
|
15
|
+
if (el instanceof NodeList) {
|
|
16
|
+
var forEach = Array.prototype.forEach
|
|
17
|
+
forEach.call(el, func)
|
|
18
|
+
} else {
|
|
19
|
+
func(el)
|
|
20
|
+
}
|
|
21
|
+
return el
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// get NodeList of elements
|
|
25
|
+
var $$ = function (selector) {
|
|
26
|
+
return document.querySelectorAll(selector)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// get single element
|
|
30
|
+
var $ = function (selector) {
|
|
31
|
+
return document.querySelector(selector)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var zp = function (n) {
|
|
35
|
+
n = parseInt(n)
|
|
36
|
+
if (n < 10 && n >= 0) {
|
|
37
|
+
return "0" + n
|
|
38
|
+
} else {
|
|
39
|
+
return n
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
var strftime_F = function () {
|
|
44
|
+
var now = new Date()
|
|
45
|
+
return now.getFullYear() + "-" + zp(now.getMonth() + 1) + "-" + zp(now.getDate())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
var strftime_T = function () {
|
|
49
|
+
var now = new Date()
|
|
50
|
+
return zp(now.getHours()) + ":" + zp(now.getMinutes()) + ":" + zp(now.getSeconds())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
window.onload = function () {
|
|
54
|
+
$('input.day').value = strftime_F()
|
|
55
|
+
$('input.time').value = strftime_T()
|
|
56
|
+
}
|
|
57
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
.wrapper {
|
|
2
|
+
width: 960px;
|
|
3
|
+
margin: 0 auto;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
form {
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
form input[type=text] {
|
|
10
|
+
border: 0;
|
|
11
|
+
border-bottom: 1px solid black;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
form input.tags {
|
|
15
|
+
width: 300px;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
form label {
|
|
19
|
+
display: block;
|
|
20
|
+
margin-bottom: 8px;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
form label span {
|
|
24
|
+
display: inline-block;
|
|
25
|
+
width: 50px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
form textarea {
|
|
29
|
+
display: block;
|
|
30
|
+
width: 600px;
|
|
31
|
+
height: 300px;
|
|
32
|
+
margin-top: 8px;
|
|
33
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require 'sinatra'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'rdiscount'
|
|
4
|
+
require 'tilt/erb'
|
|
5
|
+
|
|
6
|
+
module Diary
|
|
7
|
+
class Server < Sinatra::Base
|
|
8
|
+
configure :production, :development do
|
|
9
|
+
enable :logging
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.store=(val)
|
|
13
|
+
@store = val
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.store
|
|
17
|
+
@store
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def store
|
|
21
|
+
self.class.store
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
get '/' do
|
|
25
|
+
keys = store.read(:entries)
|
|
26
|
+
|
|
27
|
+
if keys.nil?
|
|
28
|
+
store.write do |db|
|
|
29
|
+
db[:entries] = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
keys = []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@entries = keys.uniq.map {|entry_key|
|
|
36
|
+
entry = store.read(entry_key)
|
|
37
|
+
|
|
38
|
+
if entry
|
|
39
|
+
logger.debug "LOAD #{ entry }"
|
|
40
|
+
Entry.from_store(entry)
|
|
41
|
+
else
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
}.compact
|
|
45
|
+
|
|
46
|
+
logger.info "returning keys: #{ keys }"
|
|
47
|
+
logger.info "returning entries: #{ @entries }"
|
|
48
|
+
|
|
49
|
+
erb :index
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
get '/entry/:key' do
|
|
53
|
+
content_type :json
|
|
54
|
+
|
|
55
|
+
key = params[:key]
|
|
56
|
+
entry_hash = store.read(key)
|
|
57
|
+
entry = Entry.from_store(entry_hash)
|
|
58
|
+
|
|
59
|
+
content = RDiscount.new entry.text
|
|
60
|
+
entry_hash[:formatted] = content.to_html
|
|
61
|
+
|
|
62
|
+
if entry
|
|
63
|
+
entry_hash.to_json
|
|
64
|
+
else
|
|
65
|
+
{}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# create
|
|
70
|
+
post '/entries' do
|
|
71
|
+
logger.info "ENTRY"
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
tags = params[:tags].split(',').map(&:strip)
|
|
75
|
+
rescue
|
|
76
|
+
tags = []
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
entry = Entry.new(
|
|
80
|
+
nil,
|
|
81
|
+
day: params[:day],
|
|
82
|
+
time: params[:time],
|
|
83
|
+
tags: tags,
|
|
84
|
+
text: params[:text],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
store.write_entry(entry)
|
|
88
|
+
|
|
89
|
+
redirect to('/')
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<link href='style.css' rel='stylesheet' type='text/css' />
|
|
5
|
+
<script src='script.js'></script>
|
|
6
|
+
</head>
|
|
7
|
+
|
|
8
|
+
<body>
|
|
9
|
+
<div class='wrapper'>
|
|
10
|
+
<h1>Existing Entries</h1>
|
|
11
|
+
<% if @entries.size == 0 %>
|
|
12
|
+
<p>Nothing to see here...</p>
|
|
13
|
+
<% else %>
|
|
14
|
+
<% @entries.each do |entry| %>
|
|
15
|
+
<div class='entry'>
|
|
16
|
+
<p>
|
|
17
|
+
<strong><%= entry.day %> <%= entry.time %></strong>
|
|
18
|
+
<a href='/entry/<%= entry.key %>'>json</a>
|
|
19
|
+
</p>
|
|
20
|
+
<div class='entry--text'>
|
|
21
|
+
<%= entry.formatted_text %>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<hr>
|
|
28
|
+
|
|
29
|
+
<h3>New Entry</h3>
|
|
30
|
+
<form action='/entries' method='POST'>
|
|
31
|
+
<label>
|
|
32
|
+
<span>Day</span>
|
|
33
|
+
<input class='day' name='day' type='text' />
|
|
34
|
+
</label>
|
|
35
|
+
|
|
36
|
+
<label>
|
|
37
|
+
<span>Time</span>
|
|
38
|
+
<input class='time' name='time' type='text' />
|
|
39
|
+
</label>
|
|
40
|
+
|
|
41
|
+
<label>
|
|
42
|
+
<span>Tags</span>
|
|
43
|
+
<input type='text' class='tags' name='tags' />
|
|
44
|
+
</label>
|
|
45
|
+
|
|
46
|
+
<label>
|
|
47
|
+
<span>Text</span>
|
|
48
|
+
<textarea class='text' name='text'></textarea>
|
|
49
|
+
</label>
|
|
50
|
+
|
|
51
|
+
<input type='submit' value='commit' />
|
|
52
|
+
</form>
|
|
53
|
+
</div>
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|