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
data/lib/jimmu.rb
ADDED
|
@@ -0,0 +1,1916 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
#
|
|
3
|
+
# Jimmu (神武) - A lightweight, single-file Ruby web framework.
|
|
4
|
+
#
|
|
5
|
+
# "Full-featured but simple."
|
|
6
|
+
# "Build small web apps quickly, from a single file."
|
|
7
|
+
#
|
|
8
|
+
# Jimmu bundles ERB templating, file-based routing, SQLite (via a
|
|
9
|
+
# dependency-free Fiddle binding to the system libsqlite3), WebSocket
|
|
10
|
+
# support, sessions, cookies, and a small JSON/HTML/text response API
|
|
11
|
+
# into one file with no gem dependencies beyond the Ruby standard
|
|
12
|
+
# library.
|
|
13
|
+
#
|
|
14
|
+
# https://github.com/ (project home - edit me)
|
|
15
|
+
#
|
|
16
|
+
# Copyright and license: see LICENSE.
|
|
17
|
+
|
|
18
|
+
require 'erb'
|
|
19
|
+
require 'socket'
|
|
20
|
+
require 'digest/sha1'
|
|
21
|
+
require 'base64'
|
|
22
|
+
require 'securerandom'
|
|
23
|
+
require 'time'
|
|
24
|
+
require 'json'
|
|
25
|
+
require 'uri'
|
|
26
|
+
require 'cgi'
|
|
27
|
+
require 'fileutils'
|
|
28
|
+
require 'optparse'
|
|
29
|
+
require 'monitor'
|
|
30
|
+
require 'fiddle'
|
|
31
|
+
require 'fiddle/import'
|
|
32
|
+
|
|
33
|
+
module Jimmu
|
|
34
|
+
VERSION = '1.0.0'
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# The single Application instance that app.rb's top-level DSL
|
|
38
|
+
# calls (port, before, websocket, run, ...) delegate to. Set by
|
|
39
|
+
# Application#load_app_file just before `load`ing app.rb.
|
|
40
|
+
attr_accessor :application
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Base class for all Jimmu-specific errors.
|
|
44
|
+
class Error < StandardError; end
|
|
45
|
+
|
|
46
|
+
# Raised when the SQLite shared library cannot be located, or when a
|
|
47
|
+
# query fails.
|
|
48
|
+
class DatabaseError < Error; end
|
|
49
|
+
|
|
50
|
+
# Raised when app.rb cannot be found or fails to load.
|
|
51
|
+
class ConfigError < Error; end
|
|
52
|
+
|
|
53
|
+
# Internal symbol used with `throw`/`catch` to unwind out of ERB
|
|
54
|
+
# evaluation as soon as a response-terminating DSL method
|
|
55
|
+
# (redirect / json / text / html / render) is called. This is a
|
|
56
|
+
# lightweight, non-exception based control-flow jump: the methods
|
|
57
|
+
# that call it have already recorded the final status/headers/body
|
|
58
|
+
# on the Context object before throwing, so the catch site simply
|
|
59
|
+
# reads those back off the Context.
|
|
60
|
+
HALT = :jimmu_halt
|
|
61
|
+
|
|
62
|
+
# Maximum accepted request body size (form posts / uploads), in bytes.
|
|
63
|
+
# This exists purely as a safety net against a client claiming an
|
|
64
|
+
# enormous Content-Length; it is not a tuning knob most apps need to
|
|
65
|
+
# touch. 50 MB is generous for a "small-to-medium" app.
|
|
66
|
+
MAX_BODY_SIZE = 50 * 1024 * 1024
|
|
67
|
+
|
|
68
|
+
# File extension -> MIME type, used for static file serving.
|
|
69
|
+
MIME_TYPES = {
|
|
70
|
+
'.html' => 'text/html; charset=utf-8',
|
|
71
|
+
'.htm' => 'text/html; charset=utf-8',
|
|
72
|
+
'.css' => 'text/css; charset=utf-8',
|
|
73
|
+
'.js' => 'text/javascript; charset=utf-8',
|
|
74
|
+
'.mjs' => 'text/javascript; charset=utf-8',
|
|
75
|
+
'.json' => 'application/json; charset=utf-8',
|
|
76
|
+
'.txt' => 'text/plain; charset=utf-8',
|
|
77
|
+
'.xml' => 'application/xml; charset=utf-8',
|
|
78
|
+
'.csv' => 'text/csv; charset=utf-8',
|
|
79
|
+
'.md' => 'text/markdown; charset=utf-8',
|
|
80
|
+
'.png' => 'image/png',
|
|
81
|
+
'.jpg' => 'image/jpeg',
|
|
82
|
+
'.jpeg' => 'image/jpeg',
|
|
83
|
+
'.gif' => 'image/gif',
|
|
84
|
+
'.svg' => 'image/svg+xml',
|
|
85
|
+
'.webp' => 'image/webp',
|
|
86
|
+
'.ico' => 'image/x-icon',
|
|
87
|
+
'.bmp' => 'image/bmp',
|
|
88
|
+
'.woff' => 'font/woff',
|
|
89
|
+
'.woff2' => 'font/woff2',
|
|
90
|
+
'.ttf' => 'font/ttf',
|
|
91
|
+
'.otf' => 'font/otf',
|
|
92
|
+
'.eot' => 'application/vnd.ms-fontobject',
|
|
93
|
+
'.mp3' => 'audio/mpeg',
|
|
94
|
+
'.wav' => 'audio/wav',
|
|
95
|
+
'.ogg' => 'audio/ogg',
|
|
96
|
+
'.mp4' => 'video/mp4',
|
|
97
|
+
'.webm' => 'video/webm',
|
|
98
|
+
'.pdf' => 'application/pdf',
|
|
99
|
+
'.zip' => 'application/zip',
|
|
100
|
+
'.wasm' => 'application/wasm',
|
|
101
|
+
'.map' => 'application/json; charset=utf-8'
|
|
102
|
+
}
|
|
103
|
+
MIME_TYPES.default = 'application/octet-stream'
|
|
104
|
+
MIME_TYPES.freeze
|
|
105
|
+
|
|
106
|
+
# HTTP status code -> reason phrase.
|
|
107
|
+
STATUS_PHRASES = {
|
|
108
|
+
100 => 'Continue', 101 => 'Switching Protocols',
|
|
109
|
+
200 => 'OK', 201 => 'Created', 202 => 'Accepted', 204 => 'No Content', 206 => 'Partial Content',
|
|
110
|
+
301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified',
|
|
111
|
+
307 => 'Temporary Redirect', 308 => 'Permanent Redirect',
|
|
112
|
+
400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found',
|
|
113
|
+
405 => 'Method Not Allowed', 406 => 'Not Acceptable', 408 => 'Request Timeout',
|
|
114
|
+
409 => 'Conflict', 410 => 'Gone', 411 => 'Length Required', 413 => 'Payload Too Large',
|
|
115
|
+
414 => 'URI Too Long', 415 => 'Unsupported Media Type', 422 => 'Unprocessable Entity',
|
|
116
|
+
426 => 'Upgrade Required', 429 => 'Too Many Requests',
|
|
117
|
+
500 => 'Internal Server Error', 501 => 'Not Implemented', 502 => 'Bad Gateway',
|
|
118
|
+
503 => 'Service Unavailable', 504 => 'Gateway Timeout'
|
|
119
|
+
}.freeze
|
|
120
|
+
|
|
121
|
+
def self.status_phrase(code)
|
|
122
|
+
STATUS_PHRASES[code.to_i] || 'Unknown Status'
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Percent-decode a URL path segment (`%XX` sequences only; unlike
|
|
126
|
+
# form/query decoding, `+` is left as a literal plus in paths).
|
|
127
|
+
def self.percent_decode(str)
|
|
128
|
+
str.gsub(/%([0-9A-Fa-f]{2})/) { [Regexp.last_match(1)].pack('H2') }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
module Jimmu
|
|
132
|
+
# Minimal Fiddle-based binding to the system's libsqlite3. This
|
|
133
|
+
# avoids requiring the `sqlite3` gem (and therefore avoids a native
|
|
134
|
+
# extension compile step) by calling directly into the SQLite C API
|
|
135
|
+
# that ships with the OS (Linux/macOS) or can be dropped in as a DLL
|
|
136
|
+
# (Windows).
|
|
137
|
+
module SQLite3FFI
|
|
138
|
+
extend Fiddle::Importer
|
|
139
|
+
|
|
140
|
+
LIB_CANDIDATES =
|
|
141
|
+
if Fiddle::WINDOWS
|
|
142
|
+
%w[sqlite3.dll]
|
|
143
|
+
elsif RbConfig::CONFIG['host_os'] =~ /darwin/
|
|
144
|
+
%w[libsqlite3.dylib /usr/lib/libsqlite3.dylib /opt/homebrew/lib/libsqlite3.dylib /usr/local/lib/libsqlite3.dylib]
|
|
145
|
+
else
|
|
146
|
+
%w[libsqlite3.so.0 libsqlite3.so /usr/lib/x86_64-linux-gnu/libsqlite3.so.0 /usr/lib/libsqlite3.so.0]
|
|
147
|
+
end.freeze
|
|
148
|
+
|
|
149
|
+
loaded_lib = LIB_CANDIDATES.find do |lib|
|
|
150
|
+
begin
|
|
151
|
+
dlload lib
|
|
152
|
+
true
|
|
153
|
+
rescue Fiddle::DLError
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
LOADED = !loaded_lib.nil?
|
|
159
|
+
|
|
160
|
+
if LOADED
|
|
161
|
+
extern 'int sqlite3_open(const char*, void**)'
|
|
162
|
+
extern 'int sqlite3_close(void*)'
|
|
163
|
+
extern 'int sqlite3_prepare_v2(void*, const char*, int, void**, void**)'
|
|
164
|
+
extern 'int sqlite3_step(void*)'
|
|
165
|
+
extern 'int sqlite3_finalize(void*)'
|
|
166
|
+
extern 'int sqlite3_reset(void*)'
|
|
167
|
+
extern 'int sqlite3_column_count(void*)'
|
|
168
|
+
extern 'const char* sqlite3_column_name(void*, int)'
|
|
169
|
+
extern 'int sqlite3_column_type(void*, int)'
|
|
170
|
+
extern 'const char* sqlite3_column_text(void*, int)'
|
|
171
|
+
extern 'double sqlite3_column_double(void*, int)'
|
|
172
|
+
extern 'long long sqlite3_column_int64(void*, int)'
|
|
173
|
+
extern 'int sqlite3_column_bytes(void*, int)'
|
|
174
|
+
extern 'const void* sqlite3_column_blob(void*, int)'
|
|
175
|
+
extern 'int sqlite3_bind_text(void*, int, const char*, int, void*)'
|
|
176
|
+
extern 'int sqlite3_bind_int64(void*, int, long long)'
|
|
177
|
+
extern 'int sqlite3_bind_double(void*, int, double)'
|
|
178
|
+
extern 'int sqlite3_bind_null(void*, int)'
|
|
179
|
+
extern 'int sqlite3_bind_blob(void*, int, const void*, int, void*)'
|
|
180
|
+
extern 'const char* sqlite3_errmsg(void*)'
|
|
181
|
+
extern 'int sqlite3_changes(void*)'
|
|
182
|
+
extern 'long long sqlite3_last_insert_rowid(void*)'
|
|
183
|
+
extern 'int sqlite3_busy_timeout(void*, int)'
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A small SQLite wrapper exposing the query / exec / transaction API
|
|
188
|
+
# described in the Jimmu spec. A single Database instance may be
|
|
189
|
+
# shared across the many request-handling threads the server spawns;
|
|
190
|
+
# all access to the underlying sqlite3 connection handle is
|
|
191
|
+
# serialized through a re-entrant Monitor so that (a) concurrent
|
|
192
|
+
# requests never touch the C-level handle at the same time and
|
|
193
|
+
# (b) a block passed to #transaction can itself call #query / #exec
|
|
194
|
+
# on the same thread without deadlocking.
|
|
195
|
+
class Database
|
|
196
|
+
SQLITE_OK = 0
|
|
197
|
+
SQLITE_ROW = 100
|
|
198
|
+
SQLITE_DONE = 101
|
|
199
|
+
SQLITE_INTEGER = 1
|
|
200
|
+
SQLITE_FLOAT = 2
|
|
201
|
+
SQLITE_TEXT = 3
|
|
202
|
+
SQLITE_BLOB = 4
|
|
203
|
+
SQLITE_NULL = 5
|
|
204
|
+
SQLITE_TRANSIENT = Fiddle::Pointer.new(-1)
|
|
205
|
+
|
|
206
|
+
attr_reader :path
|
|
207
|
+
|
|
208
|
+
def initialize(path)
|
|
209
|
+
unless SQLite3FFI::LOADED
|
|
210
|
+
raise DatabaseError,
|
|
211
|
+
"Could not locate the SQLite3 shared library on this system " \
|
|
212
|
+
"(looked for: #{SQLite3FFI::LIB_CANDIDATES.join(', ')}). " \
|
|
213
|
+
"Install SQLite3 (e.g. `apt install libsqlite3-0`, `brew install sqlite3`); " \
|
|
214
|
+
"it ships with macOS by default, and Windows needs sqlite3.dll on the PATH."
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
@path = path
|
|
218
|
+
dir = File.dirname(path)
|
|
219
|
+
FileUtils.mkdir_p(dir) unless dir == '.' || Dir.exist?(dir)
|
|
220
|
+
|
|
221
|
+
@lock = Monitor.new
|
|
222
|
+
dbptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP)
|
|
223
|
+
rc = SQLite3FFI.sqlite3_open(path.to_s, dbptr)
|
|
224
|
+
@db = dbptr.ptr
|
|
225
|
+
if rc != SQLITE_OK
|
|
226
|
+
raise DatabaseError, "Could not open database at #{path.inspect} (sqlite3_open returned #{rc})"
|
|
227
|
+
end
|
|
228
|
+
SQLite3FFI.sqlite3_busy_timeout(@db, 5000)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Run a SELECT (or any statement that returns rows) and return an
|
|
232
|
+
# Array of Hashes, keyed by column name (as Symbols).
|
|
233
|
+
#
|
|
234
|
+
# db.query("SELECT * FROM users")
|
|
235
|
+
# db.query("SELECT * FROM users WHERE id = ?", id)
|
|
236
|
+
def query(sql, *binds)
|
|
237
|
+
@lock.synchronize { run_query(sql, binds) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Run an INSERT / UPDATE / DELETE / DDL statement. Returns the
|
|
241
|
+
# number of rows changed by the statement (via sqlite3_changes).
|
|
242
|
+
#
|
|
243
|
+
# db.exec("INSERT INTO users(name) VALUES (?)", "Yamato")
|
|
244
|
+
def exec(sql, *binds)
|
|
245
|
+
@lock.synchronize { run_exec(sql, binds) }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Wrap a block in BEGIN / COMMIT. If the block raises, the
|
|
249
|
+
# transaction is rolled back and the error re-raised.
|
|
250
|
+
#
|
|
251
|
+
# db.transaction do
|
|
252
|
+
# db.exec(...)
|
|
253
|
+
# db.exec(...)
|
|
254
|
+
# end
|
|
255
|
+
def transaction
|
|
256
|
+
@lock.synchronize do
|
|
257
|
+
run_exec('BEGIN', [])
|
|
258
|
+
begin
|
|
259
|
+
result = yield
|
|
260
|
+
run_exec('COMMIT', [])
|
|
261
|
+
result
|
|
262
|
+
rescue Exception => e
|
|
263
|
+
begin
|
|
264
|
+
run_exec('ROLLBACK', [])
|
|
265
|
+
rescue Exception
|
|
266
|
+
nil # the original error is more useful than a rollback failure
|
|
267
|
+
end
|
|
268
|
+
raise e
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# The rowid of the most recent successful INSERT on this
|
|
274
|
+
# connection.
|
|
275
|
+
def last_insert_rowid
|
|
276
|
+
@lock.synchronize { SQLite3FFI.sqlite3_last_insert_rowid(@db) }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def close
|
|
280
|
+
@lock.synchronize do
|
|
281
|
+
next if @closed
|
|
282
|
+
SQLite3FFI.sqlite3_close(@db)
|
|
283
|
+
@closed = true
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
def prepare(sql)
|
|
290
|
+
stmtptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP)
|
|
291
|
+
tailptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP)
|
|
292
|
+
sql_bytes = sql.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
293
|
+
rc = SQLite3FFI.sqlite3_prepare_v2(@db, sql_bytes, sql_bytes.bytesize, stmtptr, tailptr)
|
|
294
|
+
unless rc == SQLITE_OK
|
|
295
|
+
raise DatabaseError, "SQL error: #{SQLite3FFI.sqlite3_errmsg(@db)} (in query: #{sql})"
|
|
296
|
+
end
|
|
297
|
+
stmtptr.ptr
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def bind_all(stmt, binds)
|
|
301
|
+
binds.each_with_index do |val, i|
|
|
302
|
+
idx = i + 1
|
|
303
|
+
case val
|
|
304
|
+
when nil
|
|
305
|
+
SQLite3FFI.sqlite3_bind_null(stmt, idx)
|
|
306
|
+
when Integer
|
|
307
|
+
SQLite3FFI.sqlite3_bind_int64(stmt, idx, val)
|
|
308
|
+
when Float
|
|
309
|
+
SQLite3FFI.sqlite3_bind_double(stmt, idx, val)
|
|
310
|
+
when true
|
|
311
|
+
SQLite3FFI.sqlite3_bind_int64(stmt, idx, 1)
|
|
312
|
+
when false
|
|
313
|
+
SQLite3FFI.sqlite3_bind_int64(stmt, idx, 0)
|
|
314
|
+
else
|
|
315
|
+
str = val.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
316
|
+
SQLite3FFI.sqlite3_bind_text(stmt, idx, str, str.bytesize, SQLITE_TRANSIENT)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def run_query(sql, binds)
|
|
322
|
+
stmt = prepare(sql)
|
|
323
|
+
bind_all(stmt, binds)
|
|
324
|
+
results = []
|
|
325
|
+
loop do
|
|
326
|
+
rc = SQLite3FFI.sqlite3_step(stmt)
|
|
327
|
+
case rc
|
|
328
|
+
when SQLITE_ROW
|
|
329
|
+
results << extract_row(stmt)
|
|
330
|
+
when SQLITE_DONE
|
|
331
|
+
break
|
|
332
|
+
else
|
|
333
|
+
SQLite3FFI.sqlite3_finalize(stmt)
|
|
334
|
+
raise DatabaseError, "SQL error: #{SQLite3FFI.sqlite3_errmsg(@db)} (in query: #{sql})"
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
SQLite3FFI.sqlite3_finalize(stmt)
|
|
338
|
+
results
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def run_exec(sql, binds)
|
|
342
|
+
stmt = prepare(sql)
|
|
343
|
+
bind_all(stmt, binds)
|
|
344
|
+
rc = SQLite3FFI.sqlite3_step(stmt)
|
|
345
|
+
unless rc == SQLITE_DONE || rc == SQLITE_ROW
|
|
346
|
+
SQLite3FFI.sqlite3_finalize(stmt)
|
|
347
|
+
raise DatabaseError, "SQL error: #{SQLite3FFI.sqlite3_errmsg(@db)} (in query: #{sql})"
|
|
348
|
+
end
|
|
349
|
+
SQLite3FFI.sqlite3_finalize(stmt)
|
|
350
|
+
SQLite3FFI.sqlite3_changes(@db)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def extract_row(stmt)
|
|
354
|
+
count = SQLite3FFI.sqlite3_column_count(stmt)
|
|
355
|
+
row = {}
|
|
356
|
+
count.times do |i|
|
|
357
|
+
name = SQLite3FFI.sqlite3_column_name(stmt, i).to_s.to_sym
|
|
358
|
+
row[name] = extract_value(stmt, i)
|
|
359
|
+
end
|
|
360
|
+
row
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def extract_value(stmt, i)
|
|
364
|
+
type = SQLite3FFI.sqlite3_column_type(stmt, i)
|
|
365
|
+
case type
|
|
366
|
+
when SQLITE_INTEGER
|
|
367
|
+
SQLite3FFI.sqlite3_column_int64(stmt, i)
|
|
368
|
+
when SQLITE_FLOAT
|
|
369
|
+
SQLite3FFI.sqlite3_column_double(stmt, i)
|
|
370
|
+
when SQLITE_TEXT
|
|
371
|
+
bytes = SQLite3FFI.sqlite3_column_bytes(stmt, i)
|
|
372
|
+
return ''.dup.force_encoding(Encoding::UTF_8) if bytes.zero?
|
|
373
|
+
ptr = SQLite3FFI.sqlite3_column_text(stmt, i)
|
|
374
|
+
ptr.to_s(bytes).force_encoding(Encoding::UTF_8)
|
|
375
|
+
when SQLITE_BLOB
|
|
376
|
+
bytes = SQLite3FFI.sqlite3_column_bytes(stmt, i)
|
|
377
|
+
return ''.b if bytes.zero?
|
|
378
|
+
ptr = SQLite3FFI.sqlite3_column_blob(stmt, i)
|
|
379
|
+
ptr.to_s(bytes)
|
|
380
|
+
when SQLITE_NULL
|
|
381
|
+
nil
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
module Jimmu
|
|
387
|
+
# Represents one uploaded file from a multipart/form-data request.
|
|
388
|
+
# Data is held in memory as a binary String; call #save to write it
|
|
389
|
+
# to disk.
|
|
390
|
+
#
|
|
391
|
+
# file = params[:image]
|
|
392
|
+
# file.filename # "photo.png"
|
|
393
|
+
# file.content_type # "image/png"
|
|
394
|
+
# file.size # 48213
|
|
395
|
+
# file.save("public/uploads/photo.png")
|
|
396
|
+
UploadedFile = Struct.new(:filename, :content_type, :data) do
|
|
397
|
+
def size
|
|
398
|
+
data.bytesize
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def save(path)
|
|
402
|
+
dir = File.dirname(path)
|
|
403
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
404
|
+
File.open(path, 'wb') { |f| f.write(data) }
|
|
405
|
+
path
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def empty?
|
|
409
|
+
data.nil? || data.empty?
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Parses application/x-www-form-urlencoded and multipart/form-data
|
|
414
|
+
# request bodies, and application/json bodies. All parsing is done
|
|
415
|
+
# byte-safe (ASCII-8BIT) throughout and only the final text values
|
|
416
|
+
# are tagged back to UTF-8, so binary upload data is never at risk
|
|
417
|
+
# of being mangled or raising encoding errors partway through.
|
|
418
|
+
module BodyParser
|
|
419
|
+
module_function
|
|
420
|
+
|
|
421
|
+
def parse_urlencoded(raw_body)
|
|
422
|
+
str = raw_body.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
423
|
+
result = {}
|
|
424
|
+
URI.decode_www_form(str, Encoding::UTF_8).each do |k, v|
|
|
425
|
+
result[k.to_sym] = v
|
|
426
|
+
end
|
|
427
|
+
result
|
|
428
|
+
rescue ArgumentError
|
|
429
|
+
{}
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def parse_json(raw_body)
|
|
433
|
+
return {} if raw_body.nil? || raw_body.empty?
|
|
434
|
+
data = JSON.parse(raw_body.dup.force_encoding(Encoding::UTF_8), symbolize_names: true)
|
|
435
|
+
data.is_a?(Hash) ? data : { _json: data }
|
|
436
|
+
rescue JSON::ParserError
|
|
437
|
+
{}
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Returns [fields_hash, files_hash], both keyed by Symbol.
|
|
441
|
+
def parse_multipart(raw_body, boundary)
|
|
442
|
+
fields = {}
|
|
443
|
+
files = {}
|
|
444
|
+
return [fields, files] if raw_body.nil? || boundary.nil? || boundary.empty?
|
|
445
|
+
|
|
446
|
+
body = raw_body.dup.force_encoding(Encoding::BINARY)
|
|
447
|
+
delim = "--#{boundary}".b
|
|
448
|
+
|
|
449
|
+
body.split(delim).each do |part|
|
|
450
|
+
next if part.nil? || part.empty?
|
|
451
|
+
next if part.start_with?('--'.b)
|
|
452
|
+
|
|
453
|
+
part = part.sub(/\A\r\n/n, ''.b)
|
|
454
|
+
part = part.sub(/\r\n\z/n, ''.b)
|
|
455
|
+
next if part.empty?
|
|
456
|
+
|
|
457
|
+
header_section, sep, body_section = part.partition("\r\n\r\n".b)
|
|
458
|
+
next if sep.empty?
|
|
459
|
+
|
|
460
|
+
headers = {}
|
|
461
|
+
header_section.split("\r\n".b).each do |line|
|
|
462
|
+
k, v = line.split(':'.b, 2)
|
|
463
|
+
next unless k && v
|
|
464
|
+
headers[k.strip.downcase] = v.strip
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
disposition = headers['content-disposition'.b]
|
|
468
|
+
next unless disposition
|
|
469
|
+
|
|
470
|
+
name = disposition[/name="([^"]*)"/n, 1] || disposition[/name=([^;]*)/n, 1]
|
|
471
|
+
next unless name
|
|
472
|
+
|
|
473
|
+
name_sym = name.dup.force_encoding(Encoding::UTF_8).to_sym
|
|
474
|
+
filename = disposition[/filename="([^"]*)"/n, 1]
|
|
475
|
+
|
|
476
|
+
if filename
|
|
477
|
+
content_type = (headers['content-type'.b] || 'application/octet-stream').dup.force_encoding(Encoding::UTF_8)
|
|
478
|
+
files[name_sym] = UploadedFile.new(filename.dup.force_encoding(Encoding::UTF_8), content_type, body_section)
|
|
479
|
+
else
|
|
480
|
+
fields[name_sym] = body_section.dup.force_encoding(Encoding::UTF_8)
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
[fields, files]
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def extract_boundary(content_type)
|
|
488
|
+
return nil unless content_type
|
|
489
|
+
content_type[/boundary="?([^";]+)"?/, 1]
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# A parsed HTTP request. Constructed once per connection by
|
|
494
|
+
# Application's connection-handling code; passed into Context.
|
|
495
|
+
class Request
|
|
496
|
+
attr_reader :http_method, :path, :query_string, :raw_headers, :body, :ip, :http_version
|
|
497
|
+
attr_accessor :route_params
|
|
498
|
+
|
|
499
|
+
def initialize(http_method:, path:, query_string:, headers:, body:, ip:)
|
|
500
|
+
@http_method = http_method
|
|
501
|
+
@path = path
|
|
502
|
+
@query_string = query_string || ''
|
|
503
|
+
@raw_headers = headers # Hash, lowercase String keys => String values
|
|
504
|
+
@body = body || ''.b
|
|
505
|
+
@ip = ip
|
|
506
|
+
@route_params = {}
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Public DSL name (spec: request.method)
|
|
510
|
+
def method
|
|
511
|
+
@http_method
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def header(name)
|
|
515
|
+
@raw_headers[name.to_s.downcase]
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def content_type
|
|
519
|
+
header('content-type')
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def query_params
|
|
523
|
+
@query_params ||= begin
|
|
524
|
+
h = {}
|
|
525
|
+
URI.decode_www_form(@query_string, Encoding::UTF_8).each { |k, v| h[k.to_sym] = v } unless @query_string.empty?
|
|
526
|
+
h
|
|
527
|
+
rescue ArgumentError
|
|
528
|
+
{}
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def body_params
|
|
533
|
+
@body_params ||= compute_body_params
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def uploaded_files
|
|
537
|
+
@uploaded_files ||= begin
|
|
538
|
+
compute_body_params
|
|
539
|
+
@uploaded_files_cache || {}
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Query params, then body params, then route (file-based dynamic
|
|
544
|
+
# segment) params, then uploaded files -- later sources win on key
|
|
545
|
+
# collision, with route params taking precedence as the most
|
|
546
|
+
# specific/intentional source, and uploaded files layered on top
|
|
547
|
+
# since their field names are defined by the form itself.
|
|
548
|
+
def params
|
|
549
|
+
@params ||= query_params.merge(body_params).merge(route_params).merge(uploaded_files)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
private
|
|
553
|
+
|
|
554
|
+
def compute_body_params
|
|
555
|
+
return @body_params_cache if defined?(@body_params_cache)
|
|
556
|
+
ctype = content_type.to_s
|
|
557
|
+
if ctype.start_with?('multipart/form-data')
|
|
558
|
+
boundary = BodyParser.extract_boundary(ctype)
|
|
559
|
+
fields, files = BodyParser.parse_multipart(@body, boundary)
|
|
560
|
+
@uploaded_files_cache = files
|
|
561
|
+
@body_params_cache = fields
|
|
562
|
+
elsif ctype.start_with?('application/json')
|
|
563
|
+
@uploaded_files_cache = {}
|
|
564
|
+
@body_params_cache = BodyParser.parse_json(@body)
|
|
565
|
+
elsif ctype.start_with?('application/x-www-form-urlencoded')
|
|
566
|
+
@uploaded_files_cache = {}
|
|
567
|
+
@body_params_cache = BodyParser.parse_urlencoded(@body)
|
|
568
|
+
else
|
|
569
|
+
@uploaded_files_cache = {}
|
|
570
|
+
@body_params_cache = {}
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Read/write access to request and response cookies for a single
|
|
576
|
+
# request. `cookie[:user]` reads an incoming cookie value (or nil).
|
|
577
|
+
# `cookie[:user] = "value"` queues a Set-Cookie header on the
|
|
578
|
+
# response. Values are transparently percent-encoded/decoded so any
|
|
579
|
+
# text (including Japanese) round-trips safely.
|
|
580
|
+
class CookieJar
|
|
581
|
+
def initialize(cookie_header)
|
|
582
|
+
@incoming = {}
|
|
583
|
+
cookie_header.to_s.split(';').each do |pair|
|
|
584
|
+
k, v = pair.split('=', 2)
|
|
585
|
+
next unless k
|
|
586
|
+
k = k.strip
|
|
587
|
+
next if k.empty?
|
|
588
|
+
v = (v || '').strip
|
|
589
|
+
@incoming[k] = begin
|
|
590
|
+
URI.decode_www_form_component(v)
|
|
591
|
+
rescue ArgumentError
|
|
592
|
+
v
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
@outgoing = []
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def [](name)
|
|
599
|
+
@incoming[name.to_s]
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def []=(name, value)
|
|
603
|
+
set(name, value)
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Full control over cookie attributes when needed.
|
|
607
|
+
def set(name, value, path: '/', max_age: nil, expires: nil, http_only: false, secure: false, same_site: nil)
|
|
608
|
+
parts = ["#{name}=#{URI.encode_www_form_component(value.to_s)}"]
|
|
609
|
+
parts << "Path=#{path}" if path
|
|
610
|
+
parts << "Max-Age=#{max_age.to_i}" if max_age
|
|
611
|
+
parts << "Expires=#{expires.httpdate}" if expires
|
|
612
|
+
parts << 'HttpOnly' if http_only
|
|
613
|
+
parts << 'Secure' if secure
|
|
614
|
+
parts << "SameSite=#{same_site}" if same_site
|
|
615
|
+
@outgoing << parts.join('; ')
|
|
616
|
+
value
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def delete(name, path: '/')
|
|
620
|
+
set(name, '', path: path, max_age: 0, expires: Time.at(0))
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def to_a
|
|
624
|
+
@incoming.dup
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def response_header_values
|
|
628
|
+
@outgoing
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# Thread-safe, in-memory server-side session store shared by the
|
|
633
|
+
# whole Application. Session data does not survive a server
|
|
634
|
+
# restart -- this is a development-focused framework, and adding a
|
|
635
|
+
# persistent backend is left as an extension point.
|
|
636
|
+
class SessionStore
|
|
637
|
+
COOKIE_NAME = '_jimmu_session'
|
|
638
|
+
DEFAULT_MAX_AGE = 60 * 60 * 24 # 24 hours, refreshed on every response
|
|
639
|
+
|
|
640
|
+
def initialize
|
|
641
|
+
@sessions = {}
|
|
642
|
+
@lock = Mutex.new
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# Returns [session_id, session_hash, newly_created]
|
|
646
|
+
def fetch_or_create(session_id)
|
|
647
|
+
@lock.synchronize do
|
|
648
|
+
if session_id && @sessions.key?(session_id)
|
|
649
|
+
[session_id, @sessions[session_id], false]
|
|
650
|
+
else
|
|
651
|
+
id = SecureRandom.hex(16)
|
|
652
|
+
@sessions[id] = {}
|
|
653
|
+
[id, @sessions[id], true]
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def destroy(session_id)
|
|
659
|
+
@lock.synchronize { @sessions.delete(session_id) }
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
module Jimmu
|
|
664
|
+
BUILTIN_ERROR_CSS = <<~CSS.freeze
|
|
665
|
+
body{margin:0;padding:0;background:#1e1f26;color:#e8e8ec;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif}
|
|
666
|
+
.jimmu-error{max-width:760px;margin:6vh auto;padding:0 24px}
|
|
667
|
+
.jimmu-error h1{font-size:22px;color:#ff6b6b;border-bottom:1px solid #34353f;padding-bottom:14px;margin-bottom:18px}
|
|
668
|
+
.jimmu-error p{line-height:1.6;color:#c3c4cf}
|
|
669
|
+
.jimmu-error-class{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;background:#2a2b35;padding:10px 14px;border-radius:6px;color:#ffb1b1}
|
|
670
|
+
.jimmu-error-trace{background:#26272f;border-radius:6px;padding:16px;overflow-x:auto;font-size:12.5px;line-height:1.6;color:#9fa3b5}
|
|
671
|
+
.jimmu-error-hint{font-size:13px;color:#767a8c;margin-top:20px}
|
|
672
|
+
.jimmu-error a{color:#7aa2ff}
|
|
673
|
+
CSS
|
|
674
|
+
|
|
675
|
+
# Context is instantiated once per HTTP request. It is the `self`
|
|
676
|
+
# against which ERB templates, layouts, and before/after hook blocks
|
|
677
|
+
# all run (via instance_eval / a bound top-level `binding`), which is
|
|
678
|
+
# what makes bare DSL calls like `params`, `redirect "/login"`, or
|
|
679
|
+
# `session[:user]` work naturally inside a .erb file.
|
|
680
|
+
class Context
|
|
681
|
+
attr_reader :status_code, :response_headers, :session_id
|
|
682
|
+
attr_accessor :response_body, :error
|
|
683
|
+
|
|
684
|
+
def initialize(application, request, cookie_jar, session_id, session_hash, new_session)
|
|
685
|
+
@application = application
|
|
686
|
+
@request = request
|
|
687
|
+
@cookie_jar = cookie_jar
|
|
688
|
+
@session_id = session_id
|
|
689
|
+
@session_hash = session_hash
|
|
690
|
+
@new_session = new_session
|
|
691
|
+
@status_code = 200
|
|
692
|
+
@response_headers = {}
|
|
693
|
+
@response_body = nil
|
|
694
|
+
@use_layout = true
|
|
695
|
+
@error = nil
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# ---- DSL: read accessors -------------------------------------------
|
|
699
|
+
|
|
700
|
+
def params
|
|
701
|
+
@request.params
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def request
|
|
705
|
+
@request
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def cookie
|
|
709
|
+
@cookie_jar
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def session
|
|
713
|
+
@session_hash
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# ---- DSL: response building -----------------------------------------
|
|
717
|
+
|
|
718
|
+
def status(code)
|
|
719
|
+
@status_code = code.to_i
|
|
720
|
+
nil
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
def header(name, value)
|
|
724
|
+
@response_headers[name.to_s] = value.to_s
|
|
725
|
+
nil
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def log(message)
|
|
729
|
+
@application.log(message)
|
|
730
|
+
nil
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def db
|
|
734
|
+
@application.default_db
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def layout(enabled)
|
|
738
|
+
@use_layout = enabled ? true : false
|
|
739
|
+
nil
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# ---- DSL: response-terminating actions ------------------------------
|
|
743
|
+
# Each of these fully determines the response and immediately
|
|
744
|
+
# unwinds out of whatever ERB template (or before-hook) called it,
|
|
745
|
+
# via `throw Jimmu::HALT`. The dispatch loop in Application wraps
|
|
746
|
+
# request handling in `catch(Jimmu::HALT)`, so this jump is always
|
|
747
|
+
# caught -- see Application#dispatch.
|
|
748
|
+
|
|
749
|
+
def redirect(location, code = 302)
|
|
750
|
+
@status_code = code
|
|
751
|
+
@response_headers['Location'] = location.to_s
|
|
752
|
+
@response_body = ''
|
|
753
|
+
throw HALT
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def json(data)
|
|
757
|
+
@response_headers['Content-Type'] = 'application/json; charset=utf-8'
|
|
758
|
+
@response_body = data.to_json
|
|
759
|
+
throw HALT
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def text(str)
|
|
763
|
+
@response_headers['Content-Type'] = 'text/plain; charset=utf-8'
|
|
764
|
+
@response_body = str.to_s
|
|
765
|
+
throw HALT
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
def html(str)
|
|
769
|
+
@response_headers['Content-Type'] = 'text/html; charset=utf-8'
|
|
770
|
+
@response_body = str.to_s
|
|
771
|
+
throw HALT
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def render(name)
|
|
775
|
+
@response_body = render_file(resolve_view_path(name))
|
|
776
|
+
@response_headers['Content-Type'] ||= 'text/html; charset=utf-8'
|
|
777
|
+
throw HALT
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# ---- Internal rendering pipeline (not part of the ERB-facing DSL) --
|
|
781
|
+
|
|
782
|
+
# Renders a static file's bytes directly, without ERB or layout.
|
|
783
|
+
def serve_static_file(path)
|
|
784
|
+
@response_body = File.binread(path)
|
|
785
|
+
@response_headers['Content-Type'] = MIME_TYPES[File.extname(path).downcase]
|
|
786
|
+
nil
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# Renders `views/<code>.erb` if present (through the normal
|
|
790
|
+
# ERB+layout pipeline, so it has full access to params/session/etc),
|
|
791
|
+
# falling back to a built-in page if it is missing or itself
|
|
792
|
+
# raises.
|
|
793
|
+
def render_error_page(code)
|
|
794
|
+
@status_code = code
|
|
795
|
+
path = resolve_view_path(code.to_s)
|
|
796
|
+
if File.file?(path)
|
|
797
|
+
begin
|
|
798
|
+
return render_file(path)
|
|
799
|
+
rescue Exception => e
|
|
800
|
+
@application.log("[jimmu] error while rendering views/#{code}.erb: #{e.class}: #{e.message}")
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
built_in_error_page(code)
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# Renders the .erb file at `path`, wrapping it in views/layout.erb
|
|
807
|
+
# unless the template disabled that via `layout false`. This is
|
|
808
|
+
# used both for the initially-matched route and by the `render`
|
|
809
|
+
# DSL method above.
|
|
810
|
+
def render_file(path)
|
|
811
|
+
raise Jimmu::Error, "Template not found: #{path}" unless File.file?(path)
|
|
812
|
+
|
|
813
|
+
@use_layout = true
|
|
814
|
+
content = evaluate_erb(read_template(path))
|
|
815
|
+
return content unless @use_layout
|
|
816
|
+
|
|
817
|
+
layout_path = File.join(@application.views_dir, 'layout.erb')
|
|
818
|
+
return content unless File.file?(layout_path)
|
|
819
|
+
|
|
820
|
+
layout_src = read_template(layout_path)
|
|
821
|
+
wrap_with_layout(content, layout_src)
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
private
|
|
825
|
+
|
|
826
|
+
def read_template(path)
|
|
827
|
+
File.read(path, encoding: Encoding::UTF_8)
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def resolve_view_path(name)
|
|
831
|
+
rel = name.to_s
|
|
832
|
+
rel = "#{rel}.erb" unless rel.end_with?('.erb')
|
|
833
|
+
File.join(@application.views_dir, rel)
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def evaluate_erb(src)
|
|
837
|
+
erb = ERB.new(src, trim_mode: '-')
|
|
838
|
+
erb.result(binding)
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
def wrap_with_layout(content, layout_src)
|
|
842
|
+
erb = ERB.new(layout_src, trim_mode: '-')
|
|
843
|
+
capture_and_render(erb) { content }
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
# `binding` is captured directly in this method's own body (not
|
|
847
|
+
# nested inside a further block), so that a bare `yield` inside
|
|
848
|
+
# the layout ERB -- once compiled and eval'd against that binding
|
|
849
|
+
# -- correctly resolves to the block passed to *this* call
|
|
850
|
+
# (`{ content }` from wrap_with_layout above), rather than to any
|
|
851
|
+
# outer method. This is the standard trick ERB-based frameworks
|
|
852
|
+
# use to support `<%= yield %>` in layouts.
|
|
853
|
+
def capture_and_render(erb)
|
|
854
|
+
erb.result(binding)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def built_in_error_page(code)
|
|
858
|
+
title = code == 404 ? '404 Not Found' : "#{code} Internal Server Error"
|
|
859
|
+
if code == 500 && @error
|
|
860
|
+
backtrace = @error.backtrace ? @error.backtrace.first(25).join("\n") : '(no backtrace available)'
|
|
861
|
+
<<~HTML
|
|
862
|
+
<!DOCTYPE html>
|
|
863
|
+
<html><head><meta charset="UTF-8"><title>500 Internal Server Error</title>
|
|
864
|
+
<style>#{BUILTIN_ERROR_CSS}</style></head>
|
|
865
|
+
<body><div class="jimmu-error">
|
|
866
|
+
<h1>500 Internal Server Error</h1>
|
|
867
|
+
<p class="jimmu-error-class">#{CGI.escapeHTML(@error.class.to_s)}: #{CGI.escapeHTML(@error.message.to_s)}</p>
|
|
868
|
+
<pre class="jimmu-error-trace">#{CGI.escapeHTML(backtrace)}</pre>
|
|
869
|
+
<p class="jimmu-error-hint">This detailed page is shown because no <code>views/500.erb</code> was found (or it raised too). Add one to customize error handling -- it can read <code>error.message</code> and <code>error.backtrace</code> if you want to keep showing details, or just show a friendly message.</p>
|
|
870
|
+
</div></body></html>
|
|
871
|
+
HTML
|
|
872
|
+
else
|
|
873
|
+
message = code == 404 ? 'The page you requested could not be found.' : 'Something went wrong while handling this request.'
|
|
874
|
+
<<~HTML
|
|
875
|
+
<!DOCTYPE html>
|
|
876
|
+
<html><head><meta charset="UTF-8"><title>#{title}</title>
|
|
877
|
+
<style>#{BUILTIN_ERROR_CSS}</style></head>
|
|
878
|
+
<body><div class="jimmu-error"><h1>#{title}</h1><p>#{message}</p></div></body></html>
|
|
879
|
+
HTML
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
module Jimmu
|
|
885
|
+
# Resolves request paths to .erb files under views/, following the
|
|
886
|
+
# file-based routing convention described in the Jimmu spec:
|
|
887
|
+
#
|
|
888
|
+
# views/index.erb -> /
|
|
889
|
+
# views/about.erb -> /about
|
|
890
|
+
# views/blog/index.erb -> /blog
|
|
891
|
+
# views/blog/post.erb -> /blog/post
|
|
892
|
+
# views/user/[id].erb -> /user/:id
|
|
893
|
+
# views/post/[category]/[id].erb -> /post/:category/:id
|
|
894
|
+
#
|
|
895
|
+
# A literal path segment always wins over a `[dynamic]` one at the
|
|
896
|
+
# same directory level (so views/user/admin.erb beats
|
|
897
|
+
# views/user/[id].erb for the request "/user/admin", while any other
|
|
898
|
+
# value still falls through to the dynamic route).
|
|
899
|
+
#
|
|
900
|
+
# Directory entries are enumerated directly (Dir.children) rather
|
|
901
|
+
# than matched with Dir.glob bracket patterns, since glob's `[...]`
|
|
902
|
+
# character-class syntax collides with the literal `[name]` folder
|
|
903
|
+
# naming convention and is easy to get subtly wrong across relative
|
|
904
|
+
# vs. absolute paths.
|
|
905
|
+
module Router
|
|
906
|
+
module_function
|
|
907
|
+
|
|
908
|
+
# Returns [file_path, params] or nil.
|
|
909
|
+
def resolve_view(views_dir, request_path)
|
|
910
|
+
segments = split_segments(request_path)
|
|
911
|
+
resolve_dir(views_dir, segments, {})
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
# Returns the on-disk path if `request_path` maps to a real file
|
|
915
|
+
# under public_dir, else nil. Guards against path traversal.
|
|
916
|
+
def resolve_static(public_dir, request_path)
|
|
917
|
+
return nil if request_path.include?('..')
|
|
918
|
+
decoded = Jimmu.percent_decode(request_path)
|
|
919
|
+
return nil if decoded.include?('..')
|
|
920
|
+
path = File.join(public_dir, decoded)
|
|
921
|
+
return nil unless path.start_with?(File.expand_path(public_dir) + File::SEPARATOR) ||
|
|
922
|
+
File.expand_path(path).start_with?(File.expand_path(public_dir) + File::SEPARATOR)
|
|
923
|
+
File.file?(path) ? path : nil
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# Matches a single registered pattern (e.g. "/room/[name]") against
|
|
927
|
+
# an incoming request path, used for `websocket` routes. Returns a
|
|
928
|
+
# params Hash on match, or nil.
|
|
929
|
+
def match_pattern(pattern, request_path)
|
|
930
|
+
pattern_segments = split_segments(pattern)
|
|
931
|
+
path_segments = split_segments(request_path)
|
|
932
|
+
return nil unless pattern_segments.length == path_segments.length
|
|
933
|
+
|
|
934
|
+
params = {}
|
|
935
|
+
pattern_segments.each_with_index do |seg, i|
|
|
936
|
+
actual = path_segments[i]
|
|
937
|
+
if seg.start_with?('[') && seg.end_with?(']')
|
|
938
|
+
params[seg[1..-2].to_sym] = actual
|
|
939
|
+
elsif seg != actual
|
|
940
|
+
return nil
|
|
941
|
+
end
|
|
942
|
+
end
|
|
943
|
+
params
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
def split_segments(path)
|
|
947
|
+
path.to_s.split('/').reject(&:empty?)
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
def resolve_dir(base_dir, segments, params)
|
|
951
|
+
return nil unless Dir.exist?(base_dir)
|
|
952
|
+
|
|
953
|
+
if segments.empty?
|
|
954
|
+
idx = File.join(base_dir, 'index.erb')
|
|
955
|
+
return File.file?(idx) ? [idx, params] : nil
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
seg = segments[0]
|
|
959
|
+
rest = segments[1..-1]
|
|
960
|
+
|
|
961
|
+
if rest.empty?
|
|
962
|
+
exact = File.join(base_dir, "#{seg}.erb")
|
|
963
|
+
return [exact, params] if File.file?(exact)
|
|
964
|
+
|
|
965
|
+
subdir = File.join(base_dir, seg)
|
|
966
|
+
if Dir.exist?(subdir)
|
|
967
|
+
idx = File.join(subdir, 'index.erb')
|
|
968
|
+
return [idx, params] if File.file?(idx)
|
|
969
|
+
end
|
|
970
|
+
else
|
|
971
|
+
subdir = File.join(base_dir, seg)
|
|
972
|
+
if Dir.exist?(subdir)
|
|
973
|
+
result = resolve_dir(subdir, rest, params)
|
|
974
|
+
return result if result
|
|
975
|
+
end
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
resolve_dynamic(base_dir, seg, rest, params)
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
def resolve_dynamic(base_dir, seg, rest, params)
|
|
982
|
+
entries = Dir.children(base_dir).sort
|
|
983
|
+
if rest.empty?
|
|
984
|
+
entries.each do |entry|
|
|
985
|
+
next unless entry.start_with?('[') && entry.end_with?('].erb')
|
|
986
|
+
f = File.join(base_dir, entry)
|
|
987
|
+
next unless File.file?(f)
|
|
988
|
+
name = entry[1...-('].erb'.length)]
|
|
989
|
+
return [f, params.merge(name.to_sym => seg)]
|
|
990
|
+
end
|
|
991
|
+
else
|
|
992
|
+
entries.each do |entry|
|
|
993
|
+
next unless entry.start_with?('[') && entry.end_with?(']')
|
|
994
|
+
d = File.join(base_dir, entry)
|
|
995
|
+
next unless Dir.exist?(d)
|
|
996
|
+
name = entry[1..-2]
|
|
997
|
+
result = resolve_dir(d, rest, params.merge(name.to_sym => seg))
|
|
998
|
+
return result if result
|
|
999
|
+
end
|
|
1000
|
+
end
|
|
1001
|
+
nil
|
|
1002
|
+
end
|
|
1003
|
+
private_class_method :resolve_dynamic
|
|
1004
|
+
end
|
|
1005
|
+
end
|
|
1006
|
+
module Jimmu
|
|
1007
|
+
# RFC 6455 handshake + frame codec. Implements unfragmented and
|
|
1008
|
+
# fragmented text/binary messages, ping/pong, and close frames.
|
|
1009
|
+
# Extensions (permessage-deflate etc.) are not negotiated or
|
|
1010
|
+
# supported, which is the right amount of protocol for a
|
|
1011
|
+
# small-to-medium app framework talking to browser clients.
|
|
1012
|
+
module WebSocketProtocol
|
|
1013
|
+
MAGIC_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
1014
|
+
|
|
1015
|
+
module_function
|
|
1016
|
+
|
|
1017
|
+
def accept_key(client_key)
|
|
1018
|
+
Base64.strict_encode64(Digest::SHA1.digest(client_key.to_s + MAGIC_GUID))
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
# Reads one complete (possibly fragmented) message from `socket`.
|
|
1022
|
+
# Returns {opcode:, payload:} or nil on EOF/disconnect. Control
|
|
1023
|
+
# frames (close/ping/pong) are returned immediately and are never
|
|
1024
|
+
# fragmented, per spec.
|
|
1025
|
+
def read_message(socket)
|
|
1026
|
+
first_opcode = nil
|
|
1027
|
+
buffer = +''.b
|
|
1028
|
+
loop do
|
|
1029
|
+
frame = read_single_frame(socket)
|
|
1030
|
+
return nil if frame.nil?
|
|
1031
|
+
return frame if [0x8, 0x9, 0xA].include?(frame[:opcode])
|
|
1032
|
+
|
|
1033
|
+
first_opcode ||= frame[:opcode]
|
|
1034
|
+
buffer << frame[:payload]
|
|
1035
|
+
if frame[:fin]
|
|
1036
|
+
payload = first_opcode == 0x1 ? buffer.force_encoding(Encoding::UTF_8) : buffer
|
|
1037
|
+
return { opcode: first_opcode, payload: payload }
|
|
1038
|
+
end
|
|
1039
|
+
end
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
def read_single_frame(socket)
|
|
1043
|
+
hdr = read_exactly(socket, 2)
|
|
1044
|
+
return nil if hdr.nil?
|
|
1045
|
+
b0, b1 = hdr.unpack('C2')
|
|
1046
|
+
fin = (b0 & 0x80) != 0
|
|
1047
|
+
opcode = b0 & 0x0F
|
|
1048
|
+
masked = (b1 & 0x80) != 0
|
|
1049
|
+
len = b1 & 0x7F
|
|
1050
|
+
|
|
1051
|
+
if len == 126
|
|
1052
|
+
ext = read_exactly(socket, 2)
|
|
1053
|
+
return nil if ext.nil?
|
|
1054
|
+
len = ext.unpack1('n')
|
|
1055
|
+
elsif len == 127
|
|
1056
|
+
ext = read_exactly(socket, 8)
|
|
1057
|
+
return nil if ext.nil?
|
|
1058
|
+
len = ext.unpack1('Q>')
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
mask_key = nil
|
|
1062
|
+
if masked
|
|
1063
|
+
mask_key = read_exactly(socket, 4)
|
|
1064
|
+
return nil if mask_key.nil?
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
payload = len.positive? ? read_exactly(socket, len) : ''.b
|
|
1068
|
+
return nil if payload.nil?
|
|
1069
|
+
|
|
1070
|
+
if masked
|
|
1071
|
+
unmasked = +''.b
|
|
1072
|
+
payload.each_byte.with_index { |byte, i| unmasked << (byte ^ mask_key.getbyte(i % 4)) }
|
|
1073
|
+
payload = unmasked
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
{ fin: fin, opcode: opcode, payload: payload }
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
def write_frame(socket, opcode, payload, fin: true)
|
|
1080
|
+
payload = payload.to_s.dup.force_encoding(Encoding::BINARY)
|
|
1081
|
+
b0 = (fin ? 0x80 : 0x00) | (opcode & 0x0F)
|
|
1082
|
+
out = +''.b
|
|
1083
|
+
out << [b0].pack('C')
|
|
1084
|
+
len = payload.bytesize
|
|
1085
|
+
if len <= 125
|
|
1086
|
+
out << [len].pack('C')
|
|
1087
|
+
elsif len <= 0xFFFF
|
|
1088
|
+
out << [126, len].pack('Cn')
|
|
1089
|
+
else
|
|
1090
|
+
out << [127, len].pack('CQ>')
|
|
1091
|
+
end
|
|
1092
|
+
out << payload
|
|
1093
|
+
socket.write(out)
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
def read_exactly(socket, n)
|
|
1097
|
+
data = socket.read(n)
|
|
1098
|
+
return nil if data.nil? || data.bytesize < n
|
|
1099
|
+
data
|
|
1100
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::ETIMEDOUT
|
|
1101
|
+
nil
|
|
1102
|
+
end
|
|
1103
|
+
private_class_method :read_exactly
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
# Passed as `self` into a `websocket "/path" do ... end` block (via
|
|
1107
|
+
# instance_exec), and as `self` into the on_message / on_close
|
|
1108
|
+
# handler blocks registered from inside it. This is what lets those
|
|
1109
|
+
# blocks call bare `send(...)`, `close`, `params`, `session`, etc.
|
|
1110
|
+
#
|
|
1111
|
+
# websocket "/room/[name]" do
|
|
1112
|
+
# log "opened: #{params[:name]}"
|
|
1113
|
+
# on_message { |msg| send("echo: #{msg}") }
|
|
1114
|
+
# on_close { log "closed" }
|
|
1115
|
+
# end
|
|
1116
|
+
class WebSocketConnection
|
|
1117
|
+
attr_reader :params, :request, :cookie, :session
|
|
1118
|
+
|
|
1119
|
+
def initialize(application, socket, request, route_params, cookie_jar, session_hash)
|
|
1120
|
+
@application = application
|
|
1121
|
+
@socket = socket
|
|
1122
|
+
@request = request
|
|
1123
|
+
@params = route_params
|
|
1124
|
+
@cookie = cookie_jar
|
|
1125
|
+
@session = session_hash
|
|
1126
|
+
@message_handler = nil
|
|
1127
|
+
@close_handler = nil
|
|
1128
|
+
@open = true
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
def open?
|
|
1132
|
+
@open
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
def on_message(&block)
|
|
1136
|
+
@message_handler = block
|
|
1137
|
+
nil
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
def on_close(&block)
|
|
1141
|
+
@close_handler = block
|
|
1142
|
+
nil
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
def log(message)
|
|
1146
|
+
@application.log(message)
|
|
1147
|
+
nil
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
def db
|
|
1151
|
+
@application.default_db
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
# Sends a text frame to this client.
|
|
1155
|
+
def send(message)
|
|
1156
|
+
return false unless @open
|
|
1157
|
+
WebSocketProtocol.write_frame(@socket, 0x1, message.to_s)
|
|
1158
|
+
true
|
|
1159
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
1160
|
+
@open = false
|
|
1161
|
+
false
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
# Sends a binary frame to this client.
|
|
1165
|
+
def send_binary(bytes)
|
|
1166
|
+
return false unless @open
|
|
1167
|
+
WebSocketProtocol.write_frame(@socket, 0x2, bytes)
|
|
1168
|
+
true
|
|
1169
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
1170
|
+
@open = false
|
|
1171
|
+
false
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1174
|
+
def close(code = 1000, reason = '')
|
|
1175
|
+
return unless @open
|
|
1176
|
+
payload = [code].pack('n') + reason.to_s
|
|
1177
|
+
begin
|
|
1178
|
+
WebSocketProtocol.write_frame(@socket, 0x8, payload)
|
|
1179
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
1180
|
+
nil
|
|
1181
|
+
end
|
|
1182
|
+
@open = false
|
|
1183
|
+
begin
|
|
1184
|
+
@socket.close
|
|
1185
|
+
rescue IOError
|
|
1186
|
+
nil
|
|
1187
|
+
end
|
|
1188
|
+
nil
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
# Runs the blocking read loop for this connection's lifetime.
|
|
1192
|
+
# Called by Application after the block passed to `websocket` has
|
|
1193
|
+
# been instance_exec'd (so on_message/on_close are registered).
|
|
1194
|
+
def run_loop
|
|
1195
|
+
loop do
|
|
1196
|
+
break unless @open
|
|
1197
|
+
message = WebSocketProtocol.read_message(@socket)
|
|
1198
|
+
break if message.nil?
|
|
1199
|
+
|
|
1200
|
+
case message[:opcode]
|
|
1201
|
+
when 0x1, 0x2
|
|
1202
|
+
safely { @message_handler&.call(message[:payload]) }
|
|
1203
|
+
when 0x8
|
|
1204
|
+
close(1000, '')
|
|
1205
|
+
break
|
|
1206
|
+
when 0x9
|
|
1207
|
+
WebSocketProtocol.write_frame(@socket, 0xA, message[:payload]) rescue nil
|
|
1208
|
+
when 0xA
|
|
1209
|
+
nil # pong received, nothing to do
|
|
1210
|
+
end
|
|
1211
|
+
end
|
|
1212
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::ETIMEDOUT
|
|
1213
|
+
nil
|
|
1214
|
+
ensure
|
|
1215
|
+
@open = false
|
|
1216
|
+
safely { @close_handler&.call }
|
|
1217
|
+
begin
|
|
1218
|
+
@socket.close
|
|
1219
|
+
rescue IOError
|
|
1220
|
+
nil
|
|
1221
|
+
end
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
private
|
|
1225
|
+
|
|
1226
|
+
def safely
|
|
1227
|
+
yield
|
|
1228
|
+
rescue Exception => e
|
|
1229
|
+
@application.log("[jimmu] websocket handler error: #{e.class}: #{e.message}")
|
|
1230
|
+
end
|
|
1231
|
+
end
|
|
1232
|
+
end
|
|
1233
|
+
module Jimmu
|
|
1234
|
+
# Application is the object app.rb runs against (via instance_eval),
|
|
1235
|
+
# which is what makes bare top-level calls like `port 3000`,
|
|
1236
|
+
# `before do ... end`, or `run` work as a DSL. It also owns the
|
|
1237
|
+
# actual TCPServer accept loop and per-connection dispatch once
|
|
1238
|
+
# `run` is called.
|
|
1239
|
+
class Application
|
|
1240
|
+
attr_reader :views_dir, :public_dir, :db_dir, :app_root
|
|
1241
|
+
|
|
1242
|
+
def initialize(app_root)
|
|
1243
|
+
@app_root = File.expand_path(app_root)
|
|
1244
|
+
@views_dir = File.join(@app_root, 'views')
|
|
1245
|
+
@public_dir = File.join(@app_root, 'public')
|
|
1246
|
+
@db_dir = File.join(@app_root, 'db')
|
|
1247
|
+
|
|
1248
|
+
@port = 3000
|
|
1249
|
+
@host = '127.0.0.1'
|
|
1250
|
+
@port_forced = false
|
|
1251
|
+
@host_forced = false
|
|
1252
|
+
|
|
1253
|
+
@before_hooks = []
|
|
1254
|
+
@after_hooks = []
|
|
1255
|
+
@websocket_routes = []
|
|
1256
|
+
@databases = []
|
|
1257
|
+
@default_db = nil
|
|
1258
|
+
|
|
1259
|
+
@session_store = SessionStore.new
|
|
1260
|
+
@log_mutex = Mutex.new
|
|
1261
|
+
@run_called = false
|
|
1262
|
+
@server = nil
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
# ================= DSL methods (called from app.rb) =================
|
|
1266
|
+
|
|
1267
|
+
# `port 8080` sets it (unless overridden with -p on the command
|
|
1268
|
+
# line); `port` with no argument reads the current value.
|
|
1269
|
+
def port(value = nil)
|
|
1270
|
+
return @port if value.nil?
|
|
1271
|
+
@port = value.to_i unless @port_forced
|
|
1272
|
+
nil
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
def host(value = nil)
|
|
1276
|
+
return @host if value.nil?
|
|
1277
|
+
@host = value.to_s unless @host_forced
|
|
1278
|
+
nil
|
|
1279
|
+
end
|
|
1280
|
+
|
|
1281
|
+
# Used internally by the CLI's -p/-h flags, which always win over
|
|
1282
|
+
# whatever app.rb itself requests.
|
|
1283
|
+
def force_port!(value)
|
|
1284
|
+
@port = value.to_i
|
|
1285
|
+
@port_forced = true
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
def force_host!(value)
|
|
1289
|
+
@host = value.to_s
|
|
1290
|
+
@host_forced = true
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
# `db = sqlite "db/app.db"` -- opens (creating if needed) a SQLite
|
|
1294
|
+
# database, relative to the app root. The most recently opened
|
|
1295
|
+
# connection becomes the implicit `db` available inside views.
|
|
1296
|
+
def sqlite(path)
|
|
1297
|
+
full_path = File.expand_path(path, @app_root)
|
|
1298
|
+
database = Database.new(full_path)
|
|
1299
|
+
@databases << database
|
|
1300
|
+
@default_db = database
|
|
1301
|
+
database
|
|
1302
|
+
end
|
|
1303
|
+
|
|
1304
|
+
def default_db
|
|
1305
|
+
@default_db
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
def before(&block)
|
|
1309
|
+
raise ArgumentError, 'before requires a block' unless block
|
|
1310
|
+
@before_hooks << block
|
|
1311
|
+
nil
|
|
1312
|
+
end
|
|
1313
|
+
|
|
1314
|
+
def after(&block)
|
|
1315
|
+
raise ArgumentError, 'after requires a block' unless block
|
|
1316
|
+
@after_hooks << block
|
|
1317
|
+
nil
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1320
|
+
# `websocket "/room/[name]" do ... end` -- the block runs with
|
|
1321
|
+
# self bound to a WebSocketConnection (see WebSocketConnection);
|
|
1322
|
+
# register on_message / on_close from inside it.
|
|
1323
|
+
def websocket(path, &block)
|
|
1324
|
+
raise ArgumentError, 'websocket requires a block' unless block
|
|
1325
|
+
@websocket_routes << [path.to_s, block]
|
|
1326
|
+
nil
|
|
1327
|
+
end
|
|
1328
|
+
|
|
1329
|
+
def log(message, level = :info)
|
|
1330
|
+
@log_mutex.synchronize do
|
|
1331
|
+
ts = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')
|
|
1332
|
+
$stdout.puts "[#{ts}] #{level.to_s.upcase.ljust(5)} #{message}"
|
|
1333
|
+
$stdout.flush
|
|
1334
|
+
end
|
|
1335
|
+
nil
|
|
1336
|
+
end
|
|
1337
|
+
|
|
1338
|
+
def run
|
|
1339
|
+
@run_called = true
|
|
1340
|
+
start_server
|
|
1341
|
+
end
|
|
1342
|
+
|
|
1343
|
+
def run_called?
|
|
1344
|
+
@run_called
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1347
|
+
# ================= App loading =================
|
|
1348
|
+
|
|
1349
|
+
def load_app_file(path)
|
|
1350
|
+
raise ConfigError, "app.rb not found at #{path}" unless File.file?(path)
|
|
1351
|
+
# Deliberately `load` rather than `instance_eval` a string: Ruby
|
|
1352
|
+
# scopes `def` / `module` / `class` / constants written inside
|
|
1353
|
+
# instance_eval'd source to the receiver's singleton class, which
|
|
1354
|
+
# would make helper methods, modules, and constants that app.rb
|
|
1355
|
+
# defines invisible from views (Context runs its own bindings) or
|
|
1356
|
+
# from before/after/websocket blocks running against a different
|
|
1357
|
+
# object. Loading the file normally puts them at the true,
|
|
1358
|
+
# ordinary Ruby top level, and Jimmu.application (see the DSL
|
|
1359
|
+
# module below) is what makes bare `port 3000`-style calls still
|
|
1360
|
+
# work despite that.
|
|
1361
|
+
Jimmu.application = self
|
|
1362
|
+
load(path)
|
|
1363
|
+
end
|
|
1364
|
+
|
|
1365
|
+
# ================= Server =================
|
|
1366
|
+
|
|
1367
|
+
def start_server
|
|
1368
|
+
begin
|
|
1369
|
+
@server = TCPServer.new(@host, @port)
|
|
1370
|
+
rescue Errno::EADDRINUSE
|
|
1371
|
+
raise ConfigError, "Port #{@port} is already in use. Try `jimmu server -p <port>` with a different port."
|
|
1372
|
+
rescue Errno::EACCES
|
|
1373
|
+
raise ConfigError, "Permission denied binding to #{@host}:#{@port}. Ports below 1024 usually need elevated privileges -- try a port like 3000 or 8080."
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
log("Jimmu #{VERSION} listening on http://#{display_host}:#{@port} (app: #{@app_root})")
|
|
1377
|
+
log('Press Ctrl+C to stop.')
|
|
1378
|
+
|
|
1379
|
+
# Signal handlers run in a restricted "trap context" where
|
|
1380
|
+
# blocking operations (Mutex#synchronize, which our #log uses)
|
|
1381
|
+
# are not allowed. So the handler itself does nothing but raise
|
|
1382
|
+
# Interrupt back into the accepting thread; all real cleanup
|
|
1383
|
+
# (closing databases, logging, closing the socket) happens down
|
|
1384
|
+
# in the rescue/ensure below, in ordinary thread context.
|
|
1385
|
+
trap('INT') { raise Interrupt }
|
|
1386
|
+
trap('TERM') { raise Interrupt }
|
|
1387
|
+
|
|
1388
|
+
begin
|
|
1389
|
+
loop do
|
|
1390
|
+
client =
|
|
1391
|
+
begin
|
|
1392
|
+
@server.accept
|
|
1393
|
+
rescue IOError, Errno::EBADF
|
|
1394
|
+
break
|
|
1395
|
+
end
|
|
1396
|
+
Thread.new(client) { |socket| handle_connection(socket) }
|
|
1397
|
+
end
|
|
1398
|
+
rescue Interrupt
|
|
1399
|
+
nil
|
|
1400
|
+
ensure
|
|
1401
|
+
log('Shutting down...')
|
|
1402
|
+
@databases.each { |d| d.close rescue nil }
|
|
1403
|
+
begin
|
|
1404
|
+
@server.close
|
|
1405
|
+
rescue IOError
|
|
1406
|
+
nil
|
|
1407
|
+
end
|
|
1408
|
+
end
|
|
1409
|
+
end
|
|
1410
|
+
|
|
1411
|
+
def display_host
|
|
1412
|
+
@host == '0.0.0.0' ? 'localhost' : @host
|
|
1413
|
+
end
|
|
1414
|
+
|
|
1415
|
+
# ================= Per-connection handling =================
|
|
1416
|
+
|
|
1417
|
+
def handle_connection(socket)
|
|
1418
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) rescue nil
|
|
1419
|
+
socket.set_encoding(Encoding::BINARY)
|
|
1420
|
+
|
|
1421
|
+
request = parse_request(socket)
|
|
1422
|
+
return if request.nil?
|
|
1423
|
+
|
|
1424
|
+
if websocket_upgrade_request?(request)
|
|
1425
|
+
route = find_websocket_route(request.path)
|
|
1426
|
+
if route
|
|
1427
|
+
handle_websocket_upgrade(socket, request, route)
|
|
1428
|
+
return
|
|
1429
|
+
end
|
|
1430
|
+
end
|
|
1431
|
+
|
|
1432
|
+
handle_http_request(socket, request)
|
|
1433
|
+
rescue Exception => e
|
|
1434
|
+
log("connection error: #{e.class}: #{e.message}", :error)
|
|
1435
|
+
ensure
|
|
1436
|
+
begin
|
|
1437
|
+
socket.close unless socket.closed?
|
|
1438
|
+
rescue IOError
|
|
1439
|
+
nil
|
|
1440
|
+
end
|
|
1441
|
+
end
|
|
1442
|
+
|
|
1443
|
+
def parse_request(socket)
|
|
1444
|
+
request_line = read_line(socket)
|
|
1445
|
+
return nil if request_line.nil? || request_line.empty?
|
|
1446
|
+
|
|
1447
|
+
parts = request_line.split(' ')
|
|
1448
|
+
return nil if parts.length < 2
|
|
1449
|
+
http_method, target = parts[0], parts[1]
|
|
1450
|
+
|
|
1451
|
+
raw_path, _, query = target.partition('?')
|
|
1452
|
+
path = Jimmu.percent_decode(raw_path).dup.force_encoding(Encoding::UTF_8)
|
|
1453
|
+
path = path.valid_encoding? ? path : path.scrub
|
|
1454
|
+
path = '/' if path.empty?
|
|
1455
|
+
|
|
1456
|
+
headers = {}
|
|
1457
|
+
loop do
|
|
1458
|
+
line = read_line(socket)
|
|
1459
|
+
break if line.nil? || line.empty?
|
|
1460
|
+
k, v = line.split(':', 2)
|
|
1461
|
+
next unless k && v
|
|
1462
|
+
headers[k.strip.downcase] = v.strip
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
body = ''.b
|
|
1466
|
+
content_length = headers['content-length'].to_i
|
|
1467
|
+
if content_length.positive?
|
|
1468
|
+
return nil if content_length > MAX_BODY_SIZE
|
|
1469
|
+
remaining = content_length
|
|
1470
|
+
chunks = []
|
|
1471
|
+
while remaining.positive?
|
|
1472
|
+
chunk = socket.read([remaining, 65536].min)
|
|
1473
|
+
break if chunk.nil?
|
|
1474
|
+
chunks << chunk
|
|
1475
|
+
remaining -= chunk.bytesize
|
|
1476
|
+
end
|
|
1477
|
+
body = chunks.join.b
|
|
1478
|
+
end
|
|
1479
|
+
|
|
1480
|
+
peer_ip =
|
|
1481
|
+
begin
|
|
1482
|
+
socket.peeraddr(false)[3]
|
|
1483
|
+
rescue IOError, Errno::ENOTCONN, SocketError
|
|
1484
|
+
'unknown'
|
|
1485
|
+
end
|
|
1486
|
+
|
|
1487
|
+
Request.new(
|
|
1488
|
+
http_method: http_method.to_s.upcase,
|
|
1489
|
+
path: path,
|
|
1490
|
+
query_string: query.to_s,
|
|
1491
|
+
headers: headers,
|
|
1492
|
+
body: body,
|
|
1493
|
+
ip: peer_ip
|
|
1494
|
+
)
|
|
1495
|
+
rescue IOError, Errno::ECONNRESET, Errno::ETIMEDOUT
|
|
1496
|
+
nil
|
|
1497
|
+
end
|
|
1498
|
+
|
|
1499
|
+
def read_line(socket)
|
|
1500
|
+
line = socket.gets("\n")
|
|
1501
|
+
return nil if line.nil?
|
|
1502
|
+
line.sub(/\r?\n\z/, '')
|
|
1503
|
+
rescue IOError, Errno::ECONNRESET
|
|
1504
|
+
nil
|
|
1505
|
+
end
|
|
1506
|
+
|
|
1507
|
+
def websocket_upgrade_request?(request)
|
|
1508
|
+
upgrade = request.header('upgrade').to_s.downcase
|
|
1509
|
+
connection = request.header('connection').to_s.downcase
|
|
1510
|
+
upgrade == 'websocket' && connection.include?('upgrade')
|
|
1511
|
+
end
|
|
1512
|
+
|
|
1513
|
+
def find_websocket_route(path)
|
|
1514
|
+
@websocket_routes.each do |pattern, block|
|
|
1515
|
+
params = Router.match_pattern(pattern, path)
|
|
1516
|
+
return [block, params] if params
|
|
1517
|
+
end
|
|
1518
|
+
nil
|
|
1519
|
+
end
|
|
1520
|
+
|
|
1521
|
+
def handle_websocket_upgrade(socket, request, route)
|
|
1522
|
+
block, route_params = route
|
|
1523
|
+
key = request.header('sec-websocket-key')
|
|
1524
|
+
unless key
|
|
1525
|
+
write_simple_response(socket, 400, 'Bad Request', 'Missing Sec-WebSocket-Key header.')
|
|
1526
|
+
return
|
|
1527
|
+
end
|
|
1528
|
+
|
|
1529
|
+
cookie_jar = CookieJar.new(request.header('cookie'))
|
|
1530
|
+
session_id, session_hash, = @session_store.fetch_or_create(cookie_jar[SessionStore::COOKIE_NAME])
|
|
1531
|
+
cookie_jar.set(SessionStore::COOKIE_NAME, session_id, http_only: true, max_age: SessionStore::DEFAULT_MAX_AGE)
|
|
1532
|
+
request.route_params = route_params
|
|
1533
|
+
|
|
1534
|
+
accept = WebSocketProtocol.accept_key(key)
|
|
1535
|
+
out = +''.b
|
|
1536
|
+
out << "HTTP/1.1 101 Switching Protocols\r\n"
|
|
1537
|
+
out << "Upgrade: websocket\r\n"
|
|
1538
|
+
out << "Connection: Upgrade\r\n"
|
|
1539
|
+
out << "Sec-WebSocket-Accept: #{accept}\r\n"
|
|
1540
|
+
cookie_jar.response_header_values.each { |v| out << "Set-Cookie: #{v}\r\n" }
|
|
1541
|
+
out << "\r\n"
|
|
1542
|
+
socket.write(out)
|
|
1543
|
+
|
|
1544
|
+
ws = WebSocketConnection.new(self, socket, request, route_params, cookie_jar, session_hash)
|
|
1545
|
+
log("WS #{request.path} connected [#{request.ip}]")
|
|
1546
|
+
|
|
1547
|
+
begin
|
|
1548
|
+
ws.instance_exec(&block)
|
|
1549
|
+
rescue Exception => e
|
|
1550
|
+
log("websocket setup error on #{request.path}: #{e.class}: #{e.message}", :error)
|
|
1551
|
+
(e.backtrace || []).first(10).each { |l| log(" #{l}", :error) }
|
|
1552
|
+
ws.close rescue nil
|
|
1553
|
+
return
|
|
1554
|
+
end
|
|
1555
|
+
|
|
1556
|
+
ws.run_loop
|
|
1557
|
+
log("WS #{request.path} disconnected [#{request.ip}]")
|
|
1558
|
+
end
|
|
1559
|
+
|
|
1560
|
+
def handle_http_request(socket, request)
|
|
1561
|
+
start_time = Time.now
|
|
1562
|
+
cookie_jar = CookieJar.new(request.header('cookie'))
|
|
1563
|
+
session_id, session_hash, = @session_store.fetch_or_create(cookie_jar[SessionStore::COOKIE_NAME])
|
|
1564
|
+
ctx = Context.new(self, request, cookie_jar, session_id, session_hash, false)
|
|
1565
|
+
|
|
1566
|
+
begin
|
|
1567
|
+
catch(HALT) do
|
|
1568
|
+
run_before_hooks(ctx)
|
|
1569
|
+
dispatch(ctx, request)
|
|
1570
|
+
end
|
|
1571
|
+
run_after_hooks(ctx)
|
|
1572
|
+
rescue Exception => e
|
|
1573
|
+
log("#{request.http_method} #{request.path} -> 500 #{e.class}: #{e.message}", :error)
|
|
1574
|
+
(e.backtrace || []).each { |line| log(" #{line}", :error) }
|
|
1575
|
+
ctx.error = e
|
|
1576
|
+
ctx.response_body = ctx.render_error_page(500)
|
|
1577
|
+
ctx.response_headers['Content-Type'] ||= 'text/html; charset=utf-8'
|
|
1578
|
+
begin
|
|
1579
|
+
run_after_hooks(ctx)
|
|
1580
|
+
rescue Exception => e2
|
|
1581
|
+
log("error in after-hook while handling 500: #{e2.class}: #{e2.message}", :error)
|
|
1582
|
+
end
|
|
1583
|
+
end
|
|
1584
|
+
|
|
1585
|
+
cookie_jar.set(SessionStore::COOKIE_NAME, session_id, http_only: true, max_age: SessionStore::DEFAULT_MAX_AGE)
|
|
1586
|
+
send_response(socket, ctx, request)
|
|
1587
|
+
|
|
1588
|
+
duration_ms = ((Time.now - start_time) * 1000).round(1)
|
|
1589
|
+
log("#{request.http_method} #{request.path} -> #{ctx.status_code} (#{ctx.response_body.to_s.bytesize}B, #{duration_ms}ms) [#{request.ip}]")
|
|
1590
|
+
end
|
|
1591
|
+
|
|
1592
|
+
def run_before_hooks(ctx)
|
|
1593
|
+
@before_hooks.each { |hook| ctx.instance_exec(&hook) }
|
|
1594
|
+
end
|
|
1595
|
+
|
|
1596
|
+
def run_after_hooks(ctx)
|
|
1597
|
+
@after_hooks.each { |hook| ctx.instance_exec(&hook) }
|
|
1598
|
+
end
|
|
1599
|
+
|
|
1600
|
+
def dispatch(ctx, request)
|
|
1601
|
+
if %w[GET HEAD].include?(request.http_method)
|
|
1602
|
+
static_path = Router.resolve_static(@public_dir, request.path)
|
|
1603
|
+
if static_path
|
|
1604
|
+
ctx.serve_static_file(static_path)
|
|
1605
|
+
return
|
|
1606
|
+
end
|
|
1607
|
+
end
|
|
1608
|
+
|
|
1609
|
+
if find_websocket_route(request.path)
|
|
1610
|
+
ctx.status(426)
|
|
1611
|
+
ctx.text('Upgrade Required: this endpoint only accepts WebSocket connections.')
|
|
1612
|
+
return
|
|
1613
|
+
end
|
|
1614
|
+
|
|
1615
|
+
match = Router.resolve_view(@views_dir, request.path)
|
|
1616
|
+
if match
|
|
1617
|
+
file_path, route_params = match
|
|
1618
|
+
request.route_params = route_params
|
|
1619
|
+
ctx.response_body = ctx.render_file(file_path)
|
|
1620
|
+
ctx.response_headers['Content-Type'] ||= 'text/html; charset=utf-8'
|
|
1621
|
+
else
|
|
1622
|
+
ctx.response_body = ctx.render_error_page(404)
|
|
1623
|
+
ctx.response_headers['Content-Type'] ||= 'text/html; charset=utf-8'
|
|
1624
|
+
end
|
|
1625
|
+
end
|
|
1626
|
+
|
|
1627
|
+
def send_response(socket, ctx, request)
|
|
1628
|
+
body_bytes = ctx.response_body.to_s.dup.force_encoding(Encoding::BINARY)
|
|
1629
|
+
|
|
1630
|
+
headers = ctx.response_headers.dup
|
|
1631
|
+
headers['Content-Type'] ||= 'text/html; charset=utf-8'
|
|
1632
|
+
headers['Content-Length'] = body_bytes.bytesize.to_s
|
|
1633
|
+
headers['Connection'] = 'close'
|
|
1634
|
+
headers['Server'] = "Jimmu/#{VERSION}"
|
|
1635
|
+
|
|
1636
|
+
out = +''.b
|
|
1637
|
+
out << "HTTP/1.1 #{ctx.status_code} #{Jimmu.status_phrase(ctx.status_code)}\r\n"
|
|
1638
|
+
headers.each { |k, v| out << "#{k}: #{v}\r\n" }
|
|
1639
|
+
ctx.cookie.response_header_values.each { |v| out << "Set-Cookie: #{v}\r\n" }
|
|
1640
|
+
out << "\r\n"
|
|
1641
|
+
out << body_bytes unless request.http_method == 'HEAD'
|
|
1642
|
+
|
|
1643
|
+
socket.write(out)
|
|
1644
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
1645
|
+
nil
|
|
1646
|
+
end
|
|
1647
|
+
|
|
1648
|
+
def write_simple_response(socket, code, title, message)
|
|
1649
|
+
body = message.to_s.b
|
|
1650
|
+
out = +''.b
|
|
1651
|
+
out << "HTTP/1.1 #{code} #{title}\r\n"
|
|
1652
|
+
out << "Content-Type: text/plain; charset=utf-8\r\n"
|
|
1653
|
+
out << "Content-Length: #{body.bytesize}\r\n"
|
|
1654
|
+
out << "Connection: close\r\n\r\n"
|
|
1655
|
+
out << body
|
|
1656
|
+
socket.write(out)
|
|
1657
|
+
rescue IOError
|
|
1658
|
+
nil
|
|
1659
|
+
end
|
|
1660
|
+
end
|
|
1661
|
+
|
|
1662
|
+
# Mixed into Object (below) so that app.rb -- an ordinary, normally
|
|
1663
|
+
# `load`ed Ruby file, not an instance_eval'd string -- can still use
|
|
1664
|
+
# top-level DSL calls like `port 3000` or `before do ... end`. Each
|
|
1665
|
+
# method simply forwards to whatever Application instance is
|
|
1666
|
+
# currently active (Jimmu.application), which the CLI sets just
|
|
1667
|
+
# before loading app.rb.
|
|
1668
|
+
module DSL
|
|
1669
|
+
def port(value = nil)
|
|
1670
|
+
Jimmu.application.port(value)
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
def host(value = nil)
|
|
1674
|
+
Jimmu.application.host(value)
|
|
1675
|
+
end
|
|
1676
|
+
|
|
1677
|
+
def sqlite(path)
|
|
1678
|
+
Jimmu.application.sqlite(path)
|
|
1679
|
+
end
|
|
1680
|
+
|
|
1681
|
+
def before(&block)
|
|
1682
|
+
Jimmu.application.before(&block)
|
|
1683
|
+
end
|
|
1684
|
+
|
|
1685
|
+
def after(&block)
|
|
1686
|
+
Jimmu.application.after(&block)
|
|
1687
|
+
end
|
|
1688
|
+
|
|
1689
|
+
def websocket(path, &block)
|
|
1690
|
+
Jimmu.application.websocket(path, &block)
|
|
1691
|
+
end
|
|
1692
|
+
|
|
1693
|
+
def run
|
|
1694
|
+
Jimmu.application.run
|
|
1695
|
+
end
|
|
1696
|
+
|
|
1697
|
+
def log(message, level = :info)
|
|
1698
|
+
Jimmu.application.log(message, level)
|
|
1699
|
+
end
|
|
1700
|
+
end
|
|
1701
|
+
end
|
|
1702
|
+
|
|
1703
|
+
# Extend only the single top-level "main" object (not Object globally)
|
|
1704
|
+
# with the DSL, so app.rb's bare `port 3000` / `before do ... end`
|
|
1705
|
+
# calls work without making these method names appear on every object
|
|
1706
|
+
# in the process.
|
|
1707
|
+
TOPLEVEL_BINDING.eval('self').singleton_class.include(Jimmu::DSL)
|
|
1708
|
+
module Jimmu
|
|
1709
|
+
module CLI
|
|
1710
|
+
TEMPLATE_APP_RB = <<~'RUBY'
|
|
1711
|
+
# Jimmu application entry point.
|
|
1712
|
+
# Start the development server with: jimmu server
|
|
1713
|
+
|
|
1714
|
+
port 3000
|
|
1715
|
+
host "127.0.0.1"
|
|
1716
|
+
|
|
1717
|
+
before do
|
|
1718
|
+
log "#{request.method} #{request.path}"
|
|
1719
|
+
end
|
|
1720
|
+
|
|
1721
|
+
run
|
|
1722
|
+
RUBY
|
|
1723
|
+
|
|
1724
|
+
TEMPLATE_LAYOUT_ERB = <<~'ERB'
|
|
1725
|
+
<!DOCTYPE html>
|
|
1726
|
+
<html lang="en">
|
|
1727
|
+
<head>
|
|
1728
|
+
<meta charset="UTF-8">
|
|
1729
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1730
|
+
<title>Jimmu App</title>
|
|
1731
|
+
<link rel="stylesheet" href="/css/style.css">
|
|
1732
|
+
</head>
|
|
1733
|
+
<body>
|
|
1734
|
+
<%= yield %>
|
|
1735
|
+
<script src="/js/app.js"></script>
|
|
1736
|
+
</body>
|
|
1737
|
+
</html>
|
|
1738
|
+
ERB
|
|
1739
|
+
|
|
1740
|
+
TEMPLATE_INDEX_ERB = <<~'ERB'
|
|
1741
|
+
<h1>Welcome to Jimmu</h1>
|
|
1742
|
+
<p>Edit <code>views/index.erb</code> to get started.</p>
|
|
1743
|
+
<p>This page is automatically wrapped by <code>views/layout.erb</code>.</p>
|
|
1744
|
+
ERB
|
|
1745
|
+
|
|
1746
|
+
TEMPLATE_404_ERB = <<~'ERB'
|
|
1747
|
+
<h1>404 - Page Not Found</h1>
|
|
1748
|
+
<p>The page you requested does not exist.</p>
|
|
1749
|
+
<p><a href="/">Go back home</a></p>
|
|
1750
|
+
ERB
|
|
1751
|
+
|
|
1752
|
+
TEMPLATE_500_ERB = <<~'ERB'
|
|
1753
|
+
<h1>500 - Internal Server Error</h1>
|
|
1754
|
+
<p>Something went wrong on our end.</p>
|
|
1755
|
+
ERB
|
|
1756
|
+
|
|
1757
|
+
TEMPLATE_CSS = <<~'CSS'
|
|
1758
|
+
:root { color-scheme: light dark; }
|
|
1759
|
+
* { box-sizing: border-box; }
|
|
1760
|
+
body {
|
|
1761
|
+
margin: 0;
|
|
1762
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
1763
|
+
line-height: 1.6;
|
|
1764
|
+
max-width: 760px;
|
|
1765
|
+
padding: 48px 24px;
|
|
1766
|
+
margin: 0 auto;
|
|
1767
|
+
}
|
|
1768
|
+
code {
|
|
1769
|
+
background: rgba(127,127,127,0.15);
|
|
1770
|
+
padding: 2px 6px;
|
|
1771
|
+
border-radius: 4px;
|
|
1772
|
+
font-size: 0.9em;
|
|
1773
|
+
}
|
|
1774
|
+
a { color: #3b6fe0; }
|
|
1775
|
+
CSS
|
|
1776
|
+
|
|
1777
|
+
TEMPLATE_JS = <<~'JS'
|
|
1778
|
+
console.log("Jimmu app loaded");
|
|
1779
|
+
JS
|
|
1780
|
+
|
|
1781
|
+
# ================= entry point =================
|
|
1782
|
+
|
|
1783
|
+
def self.start(argv)
|
|
1784
|
+
command = argv[0]
|
|
1785
|
+
case command
|
|
1786
|
+
when 'new'
|
|
1787
|
+
cmd_new(argv[1..-1])
|
|
1788
|
+
when 'server'
|
|
1789
|
+
cmd_server(argv[1..-1])
|
|
1790
|
+
when 'version', '-v', '--version'
|
|
1791
|
+
puts "jimmu #{Jimmu::VERSION}"
|
|
1792
|
+
when 'build'
|
|
1793
|
+
puts 'jimmu build: not implemented yet. Future versions of Jimmu will support'
|
|
1794
|
+
puts 'bundling an app into a single distributable executable/package.'
|
|
1795
|
+
when 'help', '-h', '--help', nil
|
|
1796
|
+
print_help
|
|
1797
|
+
else
|
|
1798
|
+
warn "Unknown command: #{command}\n\n"
|
|
1799
|
+
print_help
|
|
1800
|
+
exit(1)
|
|
1801
|
+
end
|
|
1802
|
+
end
|
|
1803
|
+
|
|
1804
|
+
def self.print_help
|
|
1805
|
+
puts <<~HELP
|
|
1806
|
+
Jimmu #{Jimmu::VERSION} - a lightweight, single-file Ruby web framework
|
|
1807
|
+
|
|
1808
|
+
Usage:
|
|
1809
|
+
jimmu new <name> Create a new Jimmu project in ./<name>
|
|
1810
|
+
jimmu server [options] Start the development server (reads ./app.rb)
|
|
1811
|
+
jimmu version Print the installed Jimmu version
|
|
1812
|
+
jimmu help Show this help message
|
|
1813
|
+
jimmu build (reserved for future use)
|
|
1814
|
+
|
|
1815
|
+
Server options:
|
|
1816
|
+
-p, --port PORT Port to listen on (overrides app.rb's `port`, default 3000)
|
|
1817
|
+
-h, --host HOST Host/IP to bind to (overrides app.rb's `host`, default 127.0.0.1)
|
|
1818
|
+
|
|
1819
|
+
Examples:
|
|
1820
|
+
jimmu new myapp
|
|
1821
|
+
cd myapp
|
|
1822
|
+
jimmu server
|
|
1823
|
+
jimmu server -p 8080
|
|
1824
|
+
jimmu server -h 0.0.0.0 -p 8080
|
|
1825
|
+
HELP
|
|
1826
|
+
end
|
|
1827
|
+
|
|
1828
|
+
def self.cmd_new(args)
|
|
1829
|
+
name = args[0]
|
|
1830
|
+
if name.nil? || name.strip.empty?
|
|
1831
|
+
warn 'Usage: jimmu new <name>'
|
|
1832
|
+
exit(1)
|
|
1833
|
+
end
|
|
1834
|
+
|
|
1835
|
+
root = File.expand_path(name)
|
|
1836
|
+
if Dir.exist?(root) && !Dir.empty?(root)
|
|
1837
|
+
warn "Error: directory #{name.inspect} already exists and is not empty."
|
|
1838
|
+
exit(1)
|
|
1839
|
+
end
|
|
1840
|
+
|
|
1841
|
+
%w[views public/css public/js public/img db].each do |dir|
|
|
1842
|
+
FileUtils.mkdir_p(File.join(root, dir))
|
|
1843
|
+
end
|
|
1844
|
+
|
|
1845
|
+
write_file(File.join(root, 'app.rb'), TEMPLATE_APP_RB)
|
|
1846
|
+
write_file(File.join(root, 'views/layout.erb'), TEMPLATE_LAYOUT_ERB)
|
|
1847
|
+
write_file(File.join(root, 'views/index.erb'), TEMPLATE_INDEX_ERB)
|
|
1848
|
+
write_file(File.join(root, 'views/404.erb'), TEMPLATE_404_ERB)
|
|
1849
|
+
write_file(File.join(root, 'views/500.erb'), TEMPLATE_500_ERB)
|
|
1850
|
+
write_file(File.join(root, 'public/css/style.css'), TEMPLATE_CSS)
|
|
1851
|
+
write_file(File.join(root, 'public/js/app.js'), TEMPLATE_JS)
|
|
1852
|
+
write_file(File.join(root, 'public/img/.gitkeep'), '')
|
|
1853
|
+
write_file(File.join(root, 'db/.gitkeep'), '')
|
|
1854
|
+
|
|
1855
|
+
puts "Created new Jimmu app in ./#{name}"
|
|
1856
|
+
puts ''
|
|
1857
|
+
puts ' cd ' + name
|
|
1858
|
+
puts ' jimmu server'
|
|
1859
|
+
puts ''
|
|
1860
|
+
end
|
|
1861
|
+
|
|
1862
|
+
def self.write_file(path, content)
|
|
1863
|
+
File.write(path, content, encoding: Encoding::UTF_8)
|
|
1864
|
+
end
|
|
1865
|
+
|
|
1866
|
+
def self.cmd_server(args)
|
|
1867
|
+
forced_port = nil
|
|
1868
|
+
forced_host = nil
|
|
1869
|
+
|
|
1870
|
+
parser = OptionParser.new do |opts|
|
|
1871
|
+
opts.banner = 'Usage: jimmu server [options]'
|
|
1872
|
+
opts.on('-p PORT', '--port PORT', 'Port to listen on') { |v| forced_port = v }
|
|
1873
|
+
opts.on('-h HOST', '--host HOST', 'Host/IP to bind to') { |v| forced_host = v }
|
|
1874
|
+
end
|
|
1875
|
+
|
|
1876
|
+
begin
|
|
1877
|
+
parser.parse!(args)
|
|
1878
|
+
rescue OptionParser::ParseError => e
|
|
1879
|
+
warn "Error: #{e.message}"
|
|
1880
|
+
exit(1)
|
|
1881
|
+
end
|
|
1882
|
+
|
|
1883
|
+
app_rb_path = File.expand_path('app.rb', Dir.pwd)
|
|
1884
|
+
unless File.file?(app_rb_path)
|
|
1885
|
+
warn "Error: no app.rb found in the current directory (#{Dir.pwd})."
|
|
1886
|
+
warn 'Run `jimmu new <name>` to create a new project, or `cd` into an existing one.'
|
|
1887
|
+
exit(1)
|
|
1888
|
+
end
|
|
1889
|
+
|
|
1890
|
+
app = Jimmu::Application.new(Dir.pwd)
|
|
1891
|
+
app.force_port!(forced_port) if forced_port
|
|
1892
|
+
app.force_host!(forced_host) if forced_host
|
|
1893
|
+
|
|
1894
|
+
begin
|
|
1895
|
+
app.load_app_file(app_rb_path)
|
|
1896
|
+
unless app.run_called?
|
|
1897
|
+
app.log('app.rb did not call `run` -- starting the server automatically.')
|
|
1898
|
+
app.run
|
|
1899
|
+
end
|
|
1900
|
+
rescue Jimmu::ConfigError => e
|
|
1901
|
+
warn "Error: #{e.message}"
|
|
1902
|
+
exit(1)
|
|
1903
|
+
rescue Jimmu::DatabaseError => e
|
|
1904
|
+
warn "Database error: #{e.message}"
|
|
1905
|
+
exit(1)
|
|
1906
|
+
rescue Interrupt
|
|
1907
|
+
puts "\nShutting down..."
|
|
1908
|
+
exit(0)
|
|
1909
|
+
rescue Exception => e
|
|
1910
|
+
warn "Error loading app.rb: #{e.class}: #{e.message}"
|
|
1911
|
+
(e.backtrace || []).first(15).each { |l| warn " #{l}" }
|
|
1912
|
+
exit(1)
|
|
1913
|
+
end
|
|
1914
|
+
end
|
|
1915
|
+
end
|
|
1916
|
+
end
|