jimmu 1.0.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/LICENSE +21 -0
- data/README.md +279 -0
- data/examples/bbs/app.rb +21 -0
- data/examples/bbs/public/css/style.css +15 -0
- data/examples/bbs/views/404.erb +1 -0
- data/examples/bbs/views/500.erb +1 -0
- data/examples/bbs/views/index.erb +29 -0
- data/examples/bbs/views/layout.erb +15 -0
- data/examples/blog/app.rb +28 -0
- data/examples/blog/public/css/style.css +18 -0
- data/examples/blog/views/404.erb +1 -0
- data/examples/blog/views/500.erb +1 -0
- data/examples/blog/views/index.erb +9 -0
- data/examples/blog/views/layout.erb +15 -0
- data/examples/blog/views/new.erb +18 -0
- data/examples/blog/views/post/[id].erb +17 -0
- data/examples/chatroom/app.rb +44 -0
- data/examples/chatroom/public/css/style.css +15 -0
- data/examples/chatroom/views/404.erb +1 -0
- data/examples/chatroom/views/500.erb +1 -0
- data/examples/chatroom/views/index.erb +18 -0
- data/examples/chatroom/views/layout.erb +12 -0
- data/examples/chatroom/views/room/[name].erb +47 -0
- data/examples/counter/app.rb +15 -0
- data/examples/counter/public/css/style.css +8 -0
- data/examples/counter/public/js/app.js +11 -0
- data/examples/counter/views/404.erb +1 -0
- data/examples/counter/views/500.erb +1 -0
- data/examples/counter/views/api/increment.erb +5 -0
- data/examples/counter/views/api/reset.erb +4 -0
- data/examples/counter/views/index.erb +8 -0
- data/examples/counter/views/layout.erb +13 -0
- data/examples/hello-world/app.rb +6 -0
- data/examples/hello-world/public/css/style.css +3 -0
- data/examples/hello-world/views/404.erb +2 -0
- data/examples/hello-world/views/500.erb +1 -0
- data/examples/hello-world/views/greet/[name].erb +2 -0
- data/examples/hello-world/views/index.erb +3 -0
- data/examples/hello-world/views/layout.erb +12 -0
- data/examples/image-upload/app.rb +22 -0
- data/examples/image-upload/public/css/style.css +11 -0
- data/examples/image-upload/views/404.erb +1 -0
- data/examples/image-upload/views/500.erb +1 -0
- data/examples/image-upload/views/index.erb +31 -0
- data/examples/image-upload/views/layout.erb +15 -0
- data/examples/json-api/app.rb +27 -0
- data/examples/json-api/views/404.erb +2 -0
- data/examples/json-api/views/500.erb +2 -0
- data/examples/json-api/views/api/notes/[id].erb +33 -0
- data/examples/json-api/views/api/notes.erb +20 -0
- data/examples/json-api/views/index.erb +13 -0
- data/examples/login-demo/app.rb +31 -0
- data/examples/login-demo/public/css/style.css +12 -0
- data/examples/login-demo/views/404.erb +1 -0
- data/examples/login-demo/views/500.erb +1 -0
- data/examples/login-demo/views/index.erb +6 -0
- data/examples/login-demo/views/layout.erb +24 -0
- data/examples/login-demo/views/login.erb +22 -0
- data/examples/login-demo/views/logout.erb +4 -0
- data/examples/login-demo/views/mypage/index.erb +3 -0
- data/examples/login-demo/views/signup.erb +24 -0
- data/examples/poll/app.rb +55 -0
- data/examples/poll/public/css/style.css +10 -0
- data/examples/poll/public/js/app.js +24 -0
- data/examples/poll/views/404.erb +1 -0
- data/examples/poll/views/500.erb +1 -0
- data/examples/poll/views/index.erb +6 -0
- data/examples/poll/views/layout.erb +13 -0
- data/examples/todo/app.rb +21 -0
- data/examples/todo/public/css/style.css +17 -0
- data/examples/todo/views/404.erb +1 -0
- data/examples/todo/views/500.erb +1 -0
- data/examples/todo/views/delete/[id].erb +4 -0
- data/examples/todo/views/index.erb +27 -0
- data/examples/todo/views/layout.erb +15 -0
- data/examples/todo/views/toggle/[id].erb +5 -0
- data/examples/url-shortener/app.rb +39 -0
- data/examples/url-shortener/public/css/style.css +13 -0
- data/examples/url-shortener/views/404.erb +1 -0
- data/examples/url-shortener/views/500.erb +1 -0
- data/examples/url-shortener/views/[code].erb +10 -0
- data/examples/url-shortener/views/api/shorten.erb +13 -0
- data/examples/url-shortener/views/index.erb +35 -0
- data/examples/url-shortener/views/layout.erb +15 -0
- data/examples/visitor-counter/app.rb +22 -0
- data/examples/visitor-counter/public/css/style.css +4 -0
- data/examples/visitor-counter/views/404.erb +1 -0
- data/examples/visitor-counter/views/500.erb +1 -0
- data/examples/visitor-counter/views/index.erb +11 -0
- data/examples/visitor-counter/views/layout.erb +12 -0
- data/exe/jimmu +5 -0
- data/lib/jimmu.rb +1916 -0
- metadata +143 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<div class="wrap chat">
|
|
2
|
+
<h1>#<%= CGI.escapeHTML(params[:name]) %> <span id="online" class="badge"></span></h1>
|
|
3
|
+
<div id="log" class="log"></div>
|
|
4
|
+
<form id="send-form">
|
|
5
|
+
<input type="text" id="msg" placeholder="Message" autocomplete="off" required>
|
|
6
|
+
<button type="submit">Send</button>
|
|
7
|
+
</form>
|
|
8
|
+
<p><a href="/">← leave</a></p>
|
|
9
|
+
</div>
|
|
10
|
+
<script>
|
|
11
|
+
var room = <%= params[:name].to_json %>;
|
|
12
|
+
var params = new URLSearchParams(window.location.search);
|
|
13
|
+
var name = params.get('name') || ('Guest' + Math.floor(Math.random() * 1000));
|
|
14
|
+
var proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
15
|
+
var ws = new WebSocket(proto + '//' + window.location.host + '/ws/' + encodeURIComponent(room) + '?name=' + encodeURIComponent(name));
|
|
16
|
+
var log = document.getElementById('log');
|
|
17
|
+
var online = document.getElementById('online');
|
|
18
|
+
|
|
19
|
+
function addLine(cls, html) {
|
|
20
|
+
var div = document.createElement('div');
|
|
21
|
+
div.className = 'line ' + cls;
|
|
22
|
+
div.innerHTML = html;
|
|
23
|
+
log.appendChild(div);
|
|
24
|
+
log.scrollTop = log.scrollHeight;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ws.onmessage = function (event) {
|
|
28
|
+
var data = JSON.parse(event.data);
|
|
29
|
+
if (data.type === 'system') {
|
|
30
|
+
addLine('system', data.text);
|
|
31
|
+
if (typeof data.count === 'number') online.textContent = data.count + ' online';
|
|
32
|
+
} else {
|
|
33
|
+
var safe = document.createElement('div');
|
|
34
|
+
safe.textContent = data.text;
|
|
35
|
+
addLine('message', '<strong>' + data.author + ':</strong> ' + safe.innerHTML);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
ws.onclose = function () { addLine('system', 'Disconnected.'); };
|
|
39
|
+
|
|
40
|
+
document.getElementById('send-form').addEventListener('submit', function (e) {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
var input = document.getElementById('msg');
|
|
43
|
+
if (!input.value.trim()) return;
|
|
44
|
+
ws.send(input.value);
|
|
45
|
+
input.value = '';
|
|
46
|
+
});
|
|
47
|
+
</script>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Click counter.
|
|
2
|
+
# Demonstrates: a tiny JSON API called from client-side JS (fetch),
|
|
3
|
+
# and SQLite used to persist a single value across server restarts.
|
|
4
|
+
|
|
5
|
+
port 3000
|
|
6
|
+
|
|
7
|
+
db = sqlite "db/counter.db"
|
|
8
|
+
db.exec "CREATE TABLE IF NOT EXISTS counters (name TEXT PRIMARY KEY, value INTEGER NOT NULL)"
|
|
9
|
+
db.exec "INSERT OR IGNORE INTO counters(name, value) VALUES ('clicks', 0)"
|
|
10
|
+
|
|
11
|
+
before do
|
|
12
|
+
log "#{request.method} #{request.path}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
run
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
:root{color-scheme:light dark}
|
|
2
|
+
body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#111;color:#fff}
|
|
3
|
+
.wrap{text-align:center}
|
|
4
|
+
.count{font-size:96px;font-weight:800;font-variant-numeric:tabular-nums;margin:10px 0}
|
|
5
|
+
button{font-size:16px;padding:12px 28px;border-radius:8px;border:0;background:#4a7dff;color:#fff;cursor:pointer;margin:4px;font-weight:600}
|
|
6
|
+
button.reset{background:#333}
|
|
7
|
+
.hint{max-width:420px;color:#888;font-size:13px;margin-top:24px}
|
|
8
|
+
code{background:#222;padding:2px 6px;border-radius:4px}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
var countEl = document.getElementById('count');
|
|
2
|
+
document.getElementById('btn').addEventListener('click', function () {
|
|
3
|
+
fetch('/api/increment', { method: 'POST' })
|
|
4
|
+
.then(function (r) { return r.json(); })
|
|
5
|
+
.then(function (data) { countEl.textContent = data.value; });
|
|
6
|
+
});
|
|
7
|
+
document.getElementById('reset').addEventListener('click', function () {
|
|
8
|
+
fetch('/api/reset', { method: 'POST' })
|
|
9
|
+
.then(function (r) { return r.json(); })
|
|
10
|
+
.then(function (data) { countEl.textContent = data.value; });
|
|
11
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>404</h1>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>500</h1>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<% count = db.query("SELECT value FROM counters WHERE name = 'clicks'").first[:value] %>
|
|
2
|
+
<div class="wrap">
|
|
3
|
+
<h1>Click Counter</h1>
|
|
4
|
+
<div class="count" id="count"><%= count %></div>
|
|
5
|
+
<button id="btn">Click me</button>
|
|
6
|
+
<button id="reset" class="reset">Reset</button>
|
|
7
|
+
<p class="hint">The count is stored in SQLite, so it survives a server restart. The button calls a small JSON API (<code>POST /api/increment</code>) via <code>fetch()</code>.</p>
|
|
8
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Jimmu Counter</title>
|
|
7
|
+
<link rel="stylesheet" href="/css/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<%= yield %>
|
|
11
|
+
<script src="/js/app.js"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>500 - Server Error</h1>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Hello, Jimmu</title>
|
|
7
|
+
<link rel="stylesheet" href="/css/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<%= yield %>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Image upload gallery.
|
|
2
|
+
# Demonstrates: multipart/form-data file uploads (params[:image]),
|
|
3
|
+
# saving uploaded files under public/ so they're served as static
|
|
4
|
+
# files, and SQLite for the gallery listing.
|
|
5
|
+
|
|
6
|
+
port 3000
|
|
7
|
+
|
|
8
|
+
db = sqlite "db/gallery.db"
|
|
9
|
+
db.exec <<~SQL
|
|
10
|
+
CREATE TABLE IF NOT EXISTS images (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
filename TEXT NOT NULL,
|
|
13
|
+
original_name TEXT NOT NULL,
|
|
14
|
+
uploaded_at TEXT NOT NULL
|
|
15
|
+
)
|
|
16
|
+
SQL
|
|
17
|
+
|
|
18
|
+
before do
|
|
19
|
+
log "#{request.method} #{request.path}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
run
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
:root{color-scheme:light dark}
|
|
2
|
+
*{box-sizing:border-box}
|
|
3
|
+
body{font-family:-apple-system,sans-serif;background:#fafafa;color:#1a1a1a;margin:0}
|
|
4
|
+
.wrap{max-width:760px;margin:0 auto;padding:40px 20px}
|
|
5
|
+
.upload-form{display:flex;gap:10px;align-items:center;margin-bottom:28px;background:#fff;padding:16px;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
6
|
+
.upload-form button{padding:10px 20px;border-radius:6px;border:0;background:#3b6fe0;color:#fff;font-weight:600;cursor:pointer}
|
|
7
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:14px}
|
|
8
|
+
.grid figure{margin:0;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
9
|
+
.grid img{width:100%;height:140px;object-fit:cover;display:block}
|
|
10
|
+
.grid figcaption{font-size:12px;color:#777;padding:8px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
11
|
+
.empty{color:#999;text-align:center;padding:40px 0}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>404</h1>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>500</h1>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<%
|
|
2
|
+
if request.method == "POST"
|
|
3
|
+
file = params[:image]
|
|
4
|
+
if file && !file.empty? && file.content_type.to_s.start_with?("image/")
|
|
5
|
+
ext = File.extname(file.filename)
|
|
6
|
+
ext = ".bin" if ext.empty?
|
|
7
|
+
stored_name = "#{Time.now.strftime('%Y%m%d%H%M%S')}-#{SecureRandom.hex(4)}#{ext}"
|
|
8
|
+
file.save(File.join("public/uploads", stored_name))
|
|
9
|
+
db.exec("INSERT INTO images(filename, original_name, uploaded_at) VALUES (?, ?, ?)",
|
|
10
|
+
stored_name, file.filename, Time.now.strftime("%Y-%m-%d %H:%M"))
|
|
11
|
+
else
|
|
12
|
+
status 422
|
|
13
|
+
end
|
|
14
|
+
redirect "/"
|
|
15
|
+
end
|
|
16
|
+
images = db.query("SELECT * FROM images ORDER BY id DESC")
|
|
17
|
+
%>
|
|
18
|
+
<form method="post" action="/" enctype="multipart/form-data" class="upload-form">
|
|
19
|
+
<input type="file" name="image" accept="image/*" required>
|
|
20
|
+
<button type="submit">Upload</button>
|
|
21
|
+
</form>
|
|
22
|
+
|
|
23
|
+
<div class="grid">
|
|
24
|
+
<% images.each do |img| %>
|
|
25
|
+
<figure>
|
|
26
|
+
<img src="/uploads/<%= img[:filename] %>" alt="<%= CGI.escapeHTML(img[:original_name]) %>">
|
|
27
|
+
<figcaption><%= CGI.escapeHTML(img[:original_name]) %></figcaption>
|
|
28
|
+
</figure>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
31
|
+
<% if images.empty? %><p class="empty">No images yet -- upload one above.</p><% end %>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Jimmu Gallery</title>
|
|
7
|
+
<link rel="stylesheet" href="/css/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="wrap">
|
|
11
|
+
<h1>Image Gallery</h1>
|
|
12
|
+
<%= yield %>
|
|
13
|
+
</div>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Headless JSON API (no HTML views except a tiny docs index).
|
|
2
|
+
# Demonstrates: building a REST-ish API purely with json()/text(),
|
|
3
|
+
# request.method for verb-based branching within one file-routed
|
|
4
|
+
# endpoint, and status codes.
|
|
5
|
+
#
|
|
6
|
+
# GET /api/notes -> list all notes
|
|
7
|
+
# POST /api/notes -> create a note (JSON body: {"text": "..."})
|
|
8
|
+
# GET /api/notes/[id] -> fetch one note
|
|
9
|
+
# PUT /api/notes/[id] -> update a note (JSON body: {"text": "..."})
|
|
10
|
+
# DELETE /api/notes/[id] -> delete a note
|
|
11
|
+
|
|
12
|
+
port 3000
|
|
13
|
+
|
|
14
|
+
db = sqlite "db/notes.db"
|
|
15
|
+
db.exec <<~SQL
|
|
16
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
17
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
18
|
+
text TEXT NOT NULL,
|
|
19
|
+
created_at TEXT NOT NULL
|
|
20
|
+
)
|
|
21
|
+
SQL
|
|
22
|
+
|
|
23
|
+
before do
|
|
24
|
+
log "#{request.method} #{request.path}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
run
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<%
|
|
2
|
+
note = db.query("SELECT * FROM notes WHERE id = ?", params[:id]).first
|
|
3
|
+
case request.method
|
|
4
|
+
when "GET"
|
|
5
|
+
if note
|
|
6
|
+
json(note)
|
|
7
|
+
else
|
|
8
|
+
status 404
|
|
9
|
+
json(error: "not found")
|
|
10
|
+
end
|
|
11
|
+
when "PUT"
|
|
12
|
+
if note.nil?
|
|
13
|
+
status 404
|
|
14
|
+
json(error: "not found")
|
|
15
|
+
else
|
|
16
|
+
text = params[:text].to_s.strip
|
|
17
|
+
if text.empty?
|
|
18
|
+
status 422
|
|
19
|
+
json(error: "text is required")
|
|
20
|
+
else
|
|
21
|
+
db.exec("UPDATE notes SET text = ? WHERE id = ?", text, params[:id])
|
|
22
|
+
json(id: params[:id].to_i, text: text)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
when "DELETE"
|
|
26
|
+
db.exec("DELETE FROM notes WHERE id = ?", params[:id])
|
|
27
|
+
status 204
|
|
28
|
+
text ""
|
|
29
|
+
else
|
|
30
|
+
status 405
|
|
31
|
+
json(error: "method not allowed")
|
|
32
|
+
end
|
|
33
|
+
%>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%
|
|
2
|
+
case request.method
|
|
3
|
+
when "GET"
|
|
4
|
+
notes = db.query("SELECT * FROM notes ORDER BY id DESC")
|
|
5
|
+
json(notes: notes)
|
|
6
|
+
when "POST"
|
|
7
|
+
text = params[:text].to_s.strip
|
|
8
|
+
if text.empty?
|
|
9
|
+
status 422
|
|
10
|
+
json(error: "text is required")
|
|
11
|
+
else
|
|
12
|
+
db.exec("INSERT INTO notes(text, created_at) VALUES (?, ?)", text, Time.now.to_s)
|
|
13
|
+
status 201
|
|
14
|
+
json(id: db.last_insert_rowid, text: text)
|
|
15
|
+
end
|
|
16
|
+
else
|
|
17
|
+
status 405
|
|
18
|
+
json(error: "method not allowed")
|
|
19
|
+
end
|
|
20
|
+
%>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<% layout false %>
|
|
2
|
+
<!DOCTYPE html>
|
|
3
|
+
<html><head><meta charset="UTF-8"><title>Jimmu JSON API demo</title>
|
|
4
|
+
<style>body{font-family:-apple-system,sans-serif;max-width:640px;margin:60px auto;padding:0 20px;line-height:1.7}code,pre{background:#f2f2f2;padding:2px 6px;border-radius:4px}pre{padding:14px}</style>
|
|
5
|
+
</head><body>
|
|
6
|
+
<h1>Notes API</h1>
|
|
7
|
+
<p>This example has no real HTML pages -- it's a small JSON REST API. Try it with curl:</p>
|
|
8
|
+
<pre>curl <%= request.header('host') %>/api/notes
|
|
9
|
+
curl -X POST <%= request.header('host') %>/api/notes -d '{"text":"hello"}' -H 'Content-Type: application/json'
|
|
10
|
+
curl <%= request.header('host') %>/api/notes/1
|
|
11
|
+
curl -X PUT <%= request.header('host') %>/api/notes/1 -d '{"text":"updated"}' -H 'Content-Type: application/json'
|
|
12
|
+
curl -X DELETE <%= request.header('host') %>/api/notes/1</pre>
|
|
13
|
+
</body></html>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Session-based login demo.
|
|
2
|
+
# Demonstrates: session used for authentication state, before-hook
|
|
3
|
+
# route protection, and hashing passwords with the Ruby stdlib
|
|
4
|
+
# (no external gem needed).
|
|
5
|
+
|
|
6
|
+
require "digest/sha2"
|
|
7
|
+
|
|
8
|
+
port 3000
|
|
9
|
+
|
|
10
|
+
db = sqlite "db/users.db"
|
|
11
|
+
db.exec <<~SQL
|
|
12
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
13
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
|
+
username TEXT UNIQUE NOT NULL,
|
|
15
|
+
password_hash TEXT NOT NULL
|
|
16
|
+
)
|
|
17
|
+
SQL
|
|
18
|
+
|
|
19
|
+
def hash_password(password)
|
|
20
|
+
Digest::SHA256.hexdigest("jimmu-demo-salt:" + password)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
before do
|
|
24
|
+
log "#{request.method} #{request.path}"
|
|
25
|
+
next unless request.path.start_with?("/mypage")
|
|
26
|
+
if session[:user_id].nil?
|
|
27
|
+
redirect "/login"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
run
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
:root{color-scheme:light dark}
|
|
2
|
+
*{box-sizing:border-box}
|
|
3
|
+
body{font-family:-apple-system,sans-serif;background:#fafafa;color:#1a1a1a;margin:0}
|
|
4
|
+
.wrap{max-width:480px;margin:0 auto;padding:40px 20px}
|
|
5
|
+
nav{display:flex;gap:14px;align-items:center;margin-bottom:30px;font-size:14px;padding-bottom:14px;border-bottom:1px solid #ddd}
|
|
6
|
+
nav a{color:#3b6fe0;text-decoration:none}
|
|
7
|
+
.who{color:#888;margin-left:auto}
|
|
8
|
+
.auth-form{display:flex;flex-direction:column;gap:10px;margin-top:16px}
|
|
9
|
+
.auth-form input{padding:10px;border-radius:6px;border:1px solid #ccc;font:inherit}
|
|
10
|
+
.auth-form button{padding:10px;border-radius:6px;border:0;background:#222;color:#fff;font-weight:600;cursor:pointer}
|
|
11
|
+
.error{background:#fde8e8;color:#a33;padding:10px 14px;border-radius:6px;font-size:14px}
|
|
12
|
+
code{background:#eee;padding:2px 6px;border-radius:4px}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>404</h1>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>500</h1>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Jimmu Login Demo</title>
|
|
7
|
+
<link rel="stylesheet" href="/css/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="wrap">
|
|
11
|
+
<nav>
|
|
12
|
+
<a href="/">Home</a>
|
|
13
|
+
<% if session[:user_id] %>
|
|
14
|
+
<span class="who">signed in as <%= session[:username] %></span>
|
|
15
|
+
<a href="/logout">Log out</a>
|
|
16
|
+
<% else %>
|
|
17
|
+
<a href="/login">Log in</a>
|
|
18
|
+
<a href="/signup">Sign up</a>
|
|
19
|
+
<% end %>
|
|
20
|
+
</nav>
|
|
21
|
+
<%= yield %>
|
|
22
|
+
</div>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%
|
|
2
|
+
error = nil
|
|
3
|
+
if request.method == "POST"
|
|
4
|
+
username = params[:username].to_s.strip
|
|
5
|
+
password = params[:password].to_s
|
|
6
|
+
user = db.query("SELECT * FROM users WHERE username = ?", username).first
|
|
7
|
+
if user && user[:password_hash] == hash_password(password)
|
|
8
|
+
session[:user_id] = user[:id]
|
|
9
|
+
session[:username] = user[:username]
|
|
10
|
+
redirect "/mypage"
|
|
11
|
+
else
|
|
12
|
+
error = "Invalid username or password."
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
%>
|
|
16
|
+
<h1>Log in</h1>
|
|
17
|
+
<% if error %><p class="error"><%= CGI.escapeHTML(error) %></p><% end %>
|
|
18
|
+
<form method="post" action="/login" class="auth-form">
|
|
19
|
+
<input type="text" name="username" placeholder="Username" required value="<%= CGI.escapeHTML(params[:username].to_s) %>">
|
|
20
|
+
<input type="password" name="password" placeholder="Password" required>
|
|
21
|
+
<button type="submit">Log in</button>
|
|
22
|
+
</form>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<h1>Welcome, <%= CGI.escapeHTML(session[:username].to_s) %></h1>
|
|
2
|
+
<p>This page is only reachable when signed in -- the <code>before</code> hook in <code>app.rb</code> redirects to <code>/login</code> otherwise.</p>
|
|
3
|
+
<p>Your user id is <code><%= session[:user_id] %></code>.</p>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%
|
|
2
|
+
error = nil
|
|
3
|
+
if request.method == "POST"
|
|
4
|
+
username = params[:username].to_s.strip
|
|
5
|
+
password = params[:password].to_s
|
|
6
|
+
if username.empty? || password.length < 4
|
|
7
|
+
error = "Username required, password must be at least 4 characters."
|
|
8
|
+
elsif db.query("SELECT 1 AS x FROM users WHERE username = ?", username).any?
|
|
9
|
+
error = "That username is taken."
|
|
10
|
+
else
|
|
11
|
+
db.exec("INSERT INTO users(username, password_hash) VALUES (?, ?)", username, hash_password(password))
|
|
12
|
+
session[:user_id] = db.last_insert_rowid
|
|
13
|
+
session[:username] = username
|
|
14
|
+
redirect "/mypage"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
%>
|
|
18
|
+
<h1>Sign up</h1>
|
|
19
|
+
<% if error %><p class="error"><%= CGI.escapeHTML(error) %></p><% end %>
|
|
20
|
+
<form method="post" action="/signup" class="auth-form">
|
|
21
|
+
<input type="text" name="username" placeholder="Username" required value="<%= CGI.escapeHTML(params[:username].to_s) %>">
|
|
22
|
+
<input type="password" name="password" placeholder="Password (min 4 chars)" required>
|
|
23
|
+
<button type="submit">Create account</button>
|
|
24
|
+
</form>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Live poll.
|
|
2
|
+
# Demonstrates: WebSocket used for live-updating UI (not just chat),
|
|
3
|
+
# SQLite for vote storage, and broadcasting results to every
|
|
4
|
+
# connected client whenever a new vote comes in.
|
|
5
|
+
|
|
6
|
+
port 3000
|
|
7
|
+
|
|
8
|
+
db = sqlite "db/poll.db"
|
|
9
|
+
db.exec <<~SQL
|
|
10
|
+
CREATE TABLE IF NOT EXISTS votes (
|
|
11
|
+
option_key TEXT PRIMARY KEY,
|
|
12
|
+
label TEXT NOT NULL,
|
|
13
|
+
count INTEGER NOT NULL DEFAULT 0
|
|
14
|
+
)
|
|
15
|
+
SQL
|
|
16
|
+
[["ruby", "Ruby"], ["python", "Python"], ["js", "JavaScript"], ["other", "Something else"]].each do |key, label|
|
|
17
|
+
db.exec("INSERT OR IGNORE INTO votes(option_key, label, count) VALUES (?, ?, 0)", key, label)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
WATCHERS = []
|
|
21
|
+
WATCHERS_LOCK = Mutex.new
|
|
22
|
+
|
|
23
|
+
def results_json(db)
|
|
24
|
+
rows = db.query("SELECT option_key, label, count FROM votes ORDER BY rowid")
|
|
25
|
+
total = rows.sum { |r| r[:count] }
|
|
26
|
+
{ total: total, options: rows.map { |r| { key: r[:option_key], label: r[:label], count: r[:count] } } }.to_json
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def broadcast_results(db)
|
|
30
|
+
payload = results_json(db)
|
|
31
|
+
WATCHERS_LOCK.synchronize { WATCHERS.dup }.each { |w| w.send(payload) if w.open? }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
before do
|
|
35
|
+
log "#{request.method} #{request.path}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
websocket "/ws/poll" do
|
|
39
|
+
WATCHERS_LOCK.synchronize { WATCHERS << self }
|
|
40
|
+
send(results_json(db))
|
|
41
|
+
|
|
42
|
+
on_message do |raw|
|
|
43
|
+
data = JSON.parse(raw) rescue nil
|
|
44
|
+
key = data && data["vote"]
|
|
45
|
+
next unless key
|
|
46
|
+
changed = db.exec("UPDATE votes SET count = count + 1 WHERE option_key = ?", key)
|
|
47
|
+
broadcast_results(db) if changed > 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
on_close do
|
|
51
|
+
WATCHERS_LOCK.synchronize { WATCHERS.delete(self) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
run
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
:root{color-scheme:light dark}
|
|
2
|
+
*{box-sizing:border-box}
|
|
3
|
+
body{font-family:-apple-system,sans-serif;background:#f6f7fb;color:#1a1a1a;margin:0}
|
|
4
|
+
.wrap{max-width:520px;margin:0 auto;padding:48px 20px}
|
|
5
|
+
.sub{color:#777;font-size:14px;margin-bottom:28px}
|
|
6
|
+
.option{margin-bottom:14px}
|
|
7
|
+
.option button{width:100%;text-align:left;padding:14px 16px;border-radius:8px;border:1px solid #ddd;background:#fff;cursor:pointer;font-size:15px;position:relative;overflow:hidden}
|
|
8
|
+
.option button .bar{position:absolute;left:0;top:0;bottom:0;background:#dbe6ff;z-index:0}
|
|
9
|
+
.option button .label{position:relative;z-index:1;display:flex;justify-content:space-between}
|
|
10
|
+
.total{text-align:center;color:#888;font-size:13px;margin-top:20px}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
var proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
2
|
+
var ws = new WebSocket(proto + '//' + window.location.host + '/ws/poll');
|
|
3
|
+
var optionsEl = document.getElementById('options');
|
|
4
|
+
var totalEl = document.getElementById('total');
|
|
5
|
+
|
|
6
|
+
function render(data) {
|
|
7
|
+
optionsEl.innerHTML = '';
|
|
8
|
+
data.options.forEach(function (opt) {
|
|
9
|
+
var pct = data.total > 0 ? Math.round((opt.count / data.total) * 100) : 0;
|
|
10
|
+
var div = document.createElement('div');
|
|
11
|
+
div.className = 'option';
|
|
12
|
+
div.innerHTML = '<button data-key="' + opt.key + '"><span class="bar" style="width:' + pct + '%"></span>' +
|
|
13
|
+
'<span class="label"><span>' + opt.label + '</span><span>' + opt.count + ' (' + pct + '%)</span></span></button>';
|
|
14
|
+
optionsEl.appendChild(div);
|
|
15
|
+
});
|
|
16
|
+
totalEl.textContent = data.total + ' vote' + (data.total === 1 ? '' : 's') + ' total';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
ws.onmessage = function (event) { render(JSON.parse(event.data)); };
|
|
20
|
+
optionsEl.addEventListener('click', function (e) {
|
|
21
|
+
var btn = e.target.closest('button');
|
|
22
|
+
if (!btn) return;
|
|
23
|
+
ws.send(JSON.stringify({ vote: btn.getAttribute('data-key') }));
|
|
24
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div class="wrap"><h1>404</h1></div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div class="wrap"><h1>500</h1></div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Jimmu Live Poll</title>
|
|
7
|
+
<link rel="stylesheet" href="/css/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<%= yield %>
|
|
11
|
+
<script src="/js/app.js"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|