tep 0.11.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/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- metadata +264 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# tep blog -- a flagship demo exercising every batteries-included
|
|
2
|
+
# tep feature in a coherent ~200 lines:
|
|
3
|
+
#
|
|
4
|
+
# - Tep::SQLite posts + users tables
|
|
5
|
+
# - Tep::Password PBKDF2 password hashing
|
|
6
|
+
# - Tep::Jwt JSON API token issue / verify
|
|
7
|
+
# - Sessions web-side login (signed cookie)
|
|
8
|
+
# - Tep::Json JSON encode + flat-key decode
|
|
9
|
+
# - Tep::Logger request log + auth events
|
|
10
|
+
# - Tep::Security CORS + secure-headers
|
|
11
|
+
# - ERB + @ivar locals public-facing views
|
|
12
|
+
#
|
|
13
|
+
# Build + run:
|
|
14
|
+
#
|
|
15
|
+
# bin/tep build examples/blog/app.rb -o /tmp/blog
|
|
16
|
+
# TEP_SESSION_SECRET=$(openssl rand -hex 32) /tmp/blog -p 4567
|
|
17
|
+
#
|
|
18
|
+
# First-time setup creates /tmp/tep_blog.db and seeds an admin
|
|
19
|
+
# user (alice / hunter2). See SINATRA_COMPAT.md for the feature
|
|
20
|
+
# matrix this app exercises end-to-end.
|
|
21
|
+
|
|
22
|
+
require 'sinatra'
|
|
23
|
+
|
|
24
|
+
# -------------------------------------------------------------------
|
|
25
|
+
# Configuration
|
|
26
|
+
# -------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
DB_PATH = ENV.fetch("TEP_BLOG_DB", "/tmp/tep_blog.db")
|
|
29
|
+
JWT_SECRET = ENV.fetch("TEP_JWT_SECRET", "dev-jwt-secret-change-me")
|
|
30
|
+
SESSION_SEED = ENV.fetch("TEP_SESSION_SECRET", "dev-session-secret-change-me")
|
|
31
|
+
SEED_USER = "alice"
|
|
32
|
+
SEED_PASSWORD = "hunter2"
|
|
33
|
+
|
|
34
|
+
# Sessions need a stable HMAC secret; in production set
|
|
35
|
+
# TEP_SESSION_SECRET to 32 random bytes. We accept the dev default
|
|
36
|
+
# at build time for convenience.
|
|
37
|
+
Tep.session_secret = SESSION_SEED
|
|
38
|
+
|
|
39
|
+
LOGGER = Tep::Logger.new
|
|
40
|
+
LOGGER.set_level("info")
|
|
41
|
+
|
|
42
|
+
CORS = Tep::Security::Cors.new
|
|
43
|
+
CORS.set_origin("*")
|
|
44
|
+
CORS.set_allowed_verbs("GET,POST,OPTIONS")
|
|
45
|
+
CORS.set_allowed_headers("Content-Type,Authorization")
|
|
46
|
+
Tep.before CORS
|
|
47
|
+
|
|
48
|
+
HEADERS = Tep::Security::Headers.new
|
|
49
|
+
Tep.after HEADERS
|
|
50
|
+
|
|
51
|
+
set :views, File.expand_path("views", __dir__)
|
|
52
|
+
|
|
53
|
+
# -------------------------------------------------------------------
|
|
54
|
+
# Schema + seed
|
|
55
|
+
# -------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
on_start do
|
|
58
|
+
db = Tep::SQLite.new
|
|
59
|
+
if db.open(DB_PATH)
|
|
60
|
+
db.exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT UNIQUE, pwd_hash TEXT)")
|
|
61
|
+
db.exec("CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, title TEXT, body TEXT, author TEXT, created_at INTEGER)")
|
|
62
|
+
# Seed the admin user once if the table is empty.
|
|
63
|
+
n = db.first_int("SELECT count(*) FROM users", "")
|
|
64
|
+
if n == 0
|
|
65
|
+
hash = Tep::Password.hash(SEED_PASSWORD)
|
|
66
|
+
db.prepare("INSERT INTO users (name, pwd_hash) VALUES (?, ?)")
|
|
67
|
+
db.bind_str(1, SEED_USER)
|
|
68
|
+
db.bind_str(2, hash)
|
|
69
|
+
db.step
|
|
70
|
+
db.finalize
|
|
71
|
+
LOGGER.info("seeded admin user: " + SEED_USER)
|
|
72
|
+
end
|
|
73
|
+
# Seed an introductory post on the first boot so the homepage
|
|
74
|
+
# isn't empty for a new install. Idempotent: only inserts when
|
|
75
|
+
# the posts table is still empty, so wiping `users` on its own
|
|
76
|
+
# won't double-seed and re-seeding never duplicates.
|
|
77
|
+
pn = db.first_int("SELECT count(*) FROM posts", "")
|
|
78
|
+
if pn == 0
|
|
79
|
+
seed_body =
|
|
80
|
+
"<p>This blog is the flagship demo for " +
|
|
81
|
+
"<a href=\"https://github.com/OriPekelman/tep\">tep</a>, " +
|
|
82
|
+
"a Sinatra-flavoured framework that compiles to a single " +
|
|
83
|
+
"static binary via <a href=\"https://github.com/matz/spinel\">Spinel</a> " +
|
|
84
|
+
"(an AOT Ruby compiler).</p>" +
|
|
85
|
+
"<p>The whole site -- routes, ERB views, sessions, JSON " +
|
|
86
|
+
"API, JWT-authed writes, and the SQLite store you're " +
|
|
87
|
+
"reading from -- ships in <code>examples/blog/app.rb</code> " +
|
|
88
|
+
"(~250 lines) plus four ERB templates. No Rack, no Bundler, " +
|
|
89
|
+
"no MRI runtime: <code>tep build</code> turns it into a " +
|
|
90
|
+
"C-compiled binary that links libsqlite3 and serves HTTP " +
|
|
91
|
+
"directly via a small <code>sphttp.c</code> shim.</p>" +
|
|
92
|
+
"<p>Browse around: log in as <code>alice / hunter2</code> " +
|
|
93
|
+
"to write a post, or hit <code>GET /api/posts</code> for " +
|
|
94
|
+
"the JSON view. <code>POST /api/token</code> issues a JWT " +
|
|
95
|
+
"you can use against <code>POST /api/posts</code>.</p>"
|
|
96
|
+
db.prepare("INSERT INTO posts (title, body, author, created_at) VALUES (?, ?, ?, ?)")
|
|
97
|
+
db.bind_str(1, "Welcome to tep + spinel")
|
|
98
|
+
db.bind_str(2, seed_body)
|
|
99
|
+
db.bind_str(3, SEED_USER)
|
|
100
|
+
db.bind_int(4, Time.now.to_i)
|
|
101
|
+
db.step
|
|
102
|
+
db.finalize
|
|
103
|
+
LOGGER.info("seeded intro post")
|
|
104
|
+
end
|
|
105
|
+
db.close
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# -------------------------------------------------------------------
|
|
110
|
+
# Per-request log
|
|
111
|
+
# -------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
before do
|
|
114
|
+
LOGGER.info(req.verb + " " + req.path)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# -------------------------------------------------------------------
|
|
118
|
+
# Helpers (inlined per route -- spinel's translator doesn't do
|
|
119
|
+
# `helpers do ... end` blocks, by design)
|
|
120
|
+
# -------------------------------------------------------------------
|
|
121
|
+
#
|
|
122
|
+
# db_open() -> Tep::SQLite already open on DB_PATH
|
|
123
|
+
# current_user(req) -> session-cookie name or "" when absent
|
|
124
|
+
# require_login(req, res) -> set 401 + halt if not logged in
|
|
125
|
+
# verify_jwt_user(req) -> the `sub` claim from the bearer token, or "" on failure
|
|
126
|
+
#
|
|
127
|
+
# Callers use simple if-checks; no closures.
|
|
128
|
+
|
|
129
|
+
# -------------------------------------------------------------------
|
|
130
|
+
# Public web pages
|
|
131
|
+
# -------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
get '/' do
|
|
134
|
+
db = Tep::SQLite.new
|
|
135
|
+
db.open(DB_PATH)
|
|
136
|
+
|
|
137
|
+
posts_html = ""
|
|
138
|
+
db.prepare("SELECT id, title, author, created_at FROM posts ORDER BY id DESC")
|
|
139
|
+
while db.step == 1
|
|
140
|
+
posts_html = posts_html +
|
|
141
|
+
"<li><a href=\"/post/" + db.col_int(0).to_s + "\">" +
|
|
142
|
+
Tep.h(db.col_str(1)) + "</a> <span>by " +
|
|
143
|
+
Tep.h(db.col_str(2)) + "</span></li>"
|
|
144
|
+
end
|
|
145
|
+
db.finalize
|
|
146
|
+
db.close
|
|
147
|
+
|
|
148
|
+
@posts_html = posts_html
|
|
149
|
+
@logged_in = req.session.has?("user") ? "1" : ""
|
|
150
|
+
@user = req.session.get("user")
|
|
151
|
+
erb :index
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
get '/post/:id' do
|
|
155
|
+
db = Tep::SQLite.new
|
|
156
|
+
db.open(DB_PATH)
|
|
157
|
+
id = params[:id]
|
|
158
|
+
@title = db.first_str("SELECT title FROM posts WHERE id = ?", id)
|
|
159
|
+
@body = db.first_str("SELECT body FROM posts WHERE id = ?", id)
|
|
160
|
+
@author = db.first_str("SELECT author FROM posts WHERE id = ?", id)
|
|
161
|
+
db.close
|
|
162
|
+
|
|
163
|
+
if @title.length == 0
|
|
164
|
+
res.set_status(404)
|
|
165
|
+
return "<h1>not found</h1>"
|
|
166
|
+
end
|
|
167
|
+
erb :show
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# -------------------------------------------------------------------
|
|
171
|
+
# Auth (web): sessions
|
|
172
|
+
# -------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
get '/login' do
|
|
175
|
+
@flash = ""
|
|
176
|
+
erb :login
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
post '/login' do
|
|
180
|
+
user = params[:user]
|
|
181
|
+
pwd = params[:password]
|
|
182
|
+
|
|
183
|
+
db = Tep::SQLite.new
|
|
184
|
+
db.open(DB_PATH)
|
|
185
|
+
db.prepare("SELECT pwd_hash FROM users WHERE name = ?")
|
|
186
|
+
db.bind_str(1, user)
|
|
187
|
+
hash = ""
|
|
188
|
+
if db.step == 1
|
|
189
|
+
hash = db.col_str(0)
|
|
190
|
+
end
|
|
191
|
+
db.finalize
|
|
192
|
+
db.close
|
|
193
|
+
|
|
194
|
+
if hash.length > 0 && Tep::Password.verify(pwd, hash)
|
|
195
|
+
req.session.set("user", user)
|
|
196
|
+
LOGGER.info("login ok: " + user)
|
|
197
|
+
res.headers["Location"] = "/"
|
|
198
|
+
res.set_status(302)
|
|
199
|
+
return ""
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
LOGGER.warn("login failed: " + user)
|
|
203
|
+
@flash = "invalid credentials"
|
|
204
|
+
res.set_status(401)
|
|
205
|
+
erb :login
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
post '/logout' do
|
|
209
|
+
user = req.session.get("user")
|
|
210
|
+
req.session.set("user", "")
|
|
211
|
+
LOGGER.info("logout: " + user)
|
|
212
|
+
res.headers["Location"] = "/"
|
|
213
|
+
res.set_status(302)
|
|
214
|
+
""
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# -------------------------------------------------------------------
|
|
218
|
+
# Admin (session-required) -- create posts
|
|
219
|
+
# -------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
get '/admin/new' do
|
|
222
|
+
if !req.session.has?("user")
|
|
223
|
+
res.set_status(401)
|
|
224
|
+
return "<h1>401</h1><p><a href=\"/login\">log in</a></p>"
|
|
225
|
+
end
|
|
226
|
+
@user = req.session.get("user")
|
|
227
|
+
erb :new_post
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
post '/admin/new' do
|
|
231
|
+
if !req.session.has?("user")
|
|
232
|
+
res.set_status(401)
|
|
233
|
+
return ""
|
|
234
|
+
end
|
|
235
|
+
user = req.session.get("user")
|
|
236
|
+
|
|
237
|
+
db = Tep::SQLite.new
|
|
238
|
+
db.open(DB_PATH)
|
|
239
|
+
db.prepare("INSERT INTO posts (title, body, author, created_at) VALUES (?, ?, ?, ?)")
|
|
240
|
+
db.bind_str(1, params[:title])
|
|
241
|
+
db.bind_str(2, params[:body])
|
|
242
|
+
db.bind_str(3, user)
|
|
243
|
+
db.bind_int(4, Time.now.to_i)
|
|
244
|
+
db.step
|
|
245
|
+
db.finalize
|
|
246
|
+
id = db.last_rowid
|
|
247
|
+
db.close
|
|
248
|
+
|
|
249
|
+
LOGGER.info("post created id=" + id.to_s + " by " + user)
|
|
250
|
+
res.headers["Location"] = "/post/" + id.to_s
|
|
251
|
+
res.set_status(302)
|
|
252
|
+
""
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# -------------------------------------------------------------------
|
|
256
|
+
# JSON API
|
|
257
|
+
# -------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
get '/api/posts' do
|
|
260
|
+
res.headers["Content-Type"] = "application/json"
|
|
261
|
+
db = Tep::SQLite.new
|
|
262
|
+
db.open(DB_PATH)
|
|
263
|
+
|
|
264
|
+
out = "["
|
|
265
|
+
first = true
|
|
266
|
+
db.prepare("SELECT id, title, author FROM posts ORDER BY id DESC")
|
|
267
|
+
while db.step == 1
|
|
268
|
+
if !first
|
|
269
|
+
out = out + ","
|
|
270
|
+
end
|
|
271
|
+
first = false
|
|
272
|
+
out = out + "{" +
|
|
273
|
+
Tep::Json.encode_pair_int("id", db.col_int(0)) + "," +
|
|
274
|
+
Tep::Json.encode_pair_str("title", db.col_str(1)) + "," +
|
|
275
|
+
Tep::Json.encode_pair_str("author", db.col_str(2)) + "}"
|
|
276
|
+
end
|
|
277
|
+
db.finalize
|
|
278
|
+
db.close
|
|
279
|
+
out + "]"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
get '/api/posts/:id' do
|
|
283
|
+
res.headers["Content-Type"] = "application/json"
|
|
284
|
+
db = Tep::SQLite.new
|
|
285
|
+
db.open(DB_PATH)
|
|
286
|
+
id = params[:id]
|
|
287
|
+
title = db.first_str("SELECT title FROM posts WHERE id = ?", id)
|
|
288
|
+
body = db.first_str("SELECT body FROM posts WHERE id = ?", id)
|
|
289
|
+
author = db.first_str("SELECT author FROM posts WHERE id = ?", id)
|
|
290
|
+
db.close
|
|
291
|
+
if title.length == 0
|
|
292
|
+
res.set_status(404)
|
|
293
|
+
return "{}"
|
|
294
|
+
end
|
|
295
|
+
"{" +
|
|
296
|
+
Tep::Json.encode_pair_str("title", title) + "," +
|
|
297
|
+
Tep::Json.encode_pair_str("body", body) + "," +
|
|
298
|
+
Tep::Json.encode_pair_str("author", author) + "}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Issue a JWT for API access. Same credentials as web login.
|
|
302
|
+
post '/api/token' do
|
|
303
|
+
res.headers["Content-Type"] = "application/json"
|
|
304
|
+
user = Tep::Json.get_str(req.raw_body, "user")
|
|
305
|
+
pwd = Tep::Json.get_str(req.raw_body, "password")
|
|
306
|
+
|
|
307
|
+
db = Tep::SQLite.new
|
|
308
|
+
db.open(DB_PATH)
|
|
309
|
+
db.prepare("SELECT pwd_hash FROM users WHERE name = ?")
|
|
310
|
+
db.bind_str(1, user)
|
|
311
|
+
hash = ""
|
|
312
|
+
if db.step == 1
|
|
313
|
+
hash = db.col_str(0)
|
|
314
|
+
end
|
|
315
|
+
db.finalize
|
|
316
|
+
db.close
|
|
317
|
+
|
|
318
|
+
if hash.length == 0 || !Tep::Password.verify(pwd, hash)
|
|
319
|
+
res.set_status(401)
|
|
320
|
+
LOGGER.warn("api token denied: " + user)
|
|
321
|
+
return "{\"error\":\"invalid credentials\"}"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
payload = "{" +
|
|
325
|
+
Tep::Json.encode_pair_str("sub", user) + "," +
|
|
326
|
+
Tep::Json.encode_pair_int("exp", Time.now.to_i + 3600) + "}"
|
|
327
|
+
token = Tep::Jwt.encode_hs256(payload, JWT_SECRET)
|
|
328
|
+
LOGGER.info("api token issued: " + user)
|
|
329
|
+
"{\"token\":\"" + token + "\"}"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
post '/api/posts' do
|
|
333
|
+
res.headers["Content-Type"] = "application/json"
|
|
334
|
+
auth = req.req_headers["authorization"]
|
|
335
|
+
bearer = ""
|
|
336
|
+
if auth.length > 7 && auth[0, 7] == "Bearer "
|
|
337
|
+
bearer = auth[7, auth.length - 7]
|
|
338
|
+
end
|
|
339
|
+
payload = ""
|
|
340
|
+
if bearer.length > 0
|
|
341
|
+
payload = Tep::Jwt.verify_and_decode(bearer, JWT_SECRET)
|
|
342
|
+
end
|
|
343
|
+
if payload.length == 0
|
|
344
|
+
res.set_status(401)
|
|
345
|
+
return "{\"error\":\"unauthorized\"}"
|
|
346
|
+
end
|
|
347
|
+
user = Tep::Json.get_str(payload, "sub")
|
|
348
|
+
|
|
349
|
+
title = Tep::Json.get_str(req.raw_body, "title")
|
|
350
|
+
body = Tep::Json.get_str(req.raw_body, "body")
|
|
351
|
+
|
|
352
|
+
db = Tep::SQLite.new
|
|
353
|
+
db.open(DB_PATH)
|
|
354
|
+
db.prepare("INSERT INTO posts (title, body, author, created_at) VALUES (?, ?, ?, ?)")
|
|
355
|
+
db.bind_str(1, title)
|
|
356
|
+
db.bind_str(2, body)
|
|
357
|
+
db.bind_str(3, user)
|
|
358
|
+
db.bind_int(4, Time.now.to_i)
|
|
359
|
+
db.step
|
|
360
|
+
db.finalize
|
|
361
|
+
id = db.last_rowid
|
|
362
|
+
db.close
|
|
363
|
+
|
|
364
|
+
LOGGER.info("api post created id=" + id.to_s + " by " + user)
|
|
365
|
+
res.set_status(201)
|
|
366
|
+
"{" + Tep::Json.encode_pair_int("id", id) + "}"
|
|
367
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>tep blog</title>
|
|
6
|
+
<style>
|
|
7
|
+
body { font: 16px/1.5 -apple-system, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; }
|
|
8
|
+
nav { display: flex; justify-content: space-between; padding-bottom: 1em; border-bottom: 1px solid #ddd; }
|
|
9
|
+
li { margin: .5em 0; }
|
|
10
|
+
li span { color: #888; font-size: .9em; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<nav>
|
|
15
|
+
<h1 style="margin:0">tep blog</h1>
|
|
16
|
+
<div>
|
|
17
|
+
<% if @logged_in == "1" %>
|
|
18
|
+
signed in as <strong><%= @user %></strong>
|
|
19
|
+
· <a href="/admin/new">new post</a>
|
|
20
|
+
· <form method="post" action="/logout" style="display:inline"><button>log out</button></form>
|
|
21
|
+
<% else %>
|
|
22
|
+
<a href="/login">log in</a>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
</nav>
|
|
26
|
+
|
|
27
|
+
<h2>posts</h2>
|
|
28
|
+
<ul>
|
|
29
|
+
<%= @posts_html %>
|
|
30
|
+
</ul>
|
|
31
|
+
|
|
32
|
+
<% if @posts_html.length == 0 %>
|
|
33
|
+
<p>no posts yet. <a href="/login">log in</a> as <code>alice / hunter2</code> to write one.</p>
|
|
34
|
+
<% end %>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>tep blog -- log in</title>
|
|
6
|
+
<style>
|
|
7
|
+
body { font: 16px/1.5 -apple-system, sans-serif; max-width: 360px; margin: 4em auto; padding: 0 1em; }
|
|
8
|
+
label { display: block; margin: 1em 0 .25em; }
|
|
9
|
+
input { width: 100%; padding: .5em; box-sizing: border-box; }
|
|
10
|
+
button { margin-top: 1em; padding: .6em 1em; }
|
|
11
|
+
.flash { color: #b00; }
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<h1>log in</h1>
|
|
16
|
+
<% if @flash.length > 0 %>
|
|
17
|
+
<p class="flash"><%= @flash %></p>
|
|
18
|
+
<% end %>
|
|
19
|
+
<form method="post" action="/login">
|
|
20
|
+
<label>username</label>
|
|
21
|
+
<input name="user" autofocus>
|
|
22
|
+
<label>password</label>
|
|
23
|
+
<input name="password" type="password">
|
|
24
|
+
<button>log in</button>
|
|
25
|
+
</form>
|
|
26
|
+
<p style="margin-top:2em;color:#888">demo user: <code>alice / hunter2</code></p>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>tep blog -- new post</title>
|
|
6
|
+
<style>
|
|
7
|
+
body { font: 16px/1.5 -apple-system, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; }
|
|
8
|
+
label { display: block; margin: 1em 0 .25em; }
|
|
9
|
+
input, textarea { width: 100%; padding: .5em; box-sizing: border-box; font: inherit; }
|
|
10
|
+
textarea { min-height: 200px; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<p><a href="/">← all posts</a></p>
|
|
15
|
+
<h1>new post</h1>
|
|
16
|
+
<p style="color:#888">posting as <strong><%= @user %></strong></p>
|
|
17
|
+
<form method="post" action="/admin/new">
|
|
18
|
+
<label>title</label>
|
|
19
|
+
<input name="title">
|
|
20
|
+
<label>body (HTML allowed)</label>
|
|
21
|
+
<textarea name="body"></textarea>
|
|
22
|
+
<button>publish</button>
|
|
23
|
+
</form>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title><%= @title %></title>
|
|
6
|
+
<style>body { font: 16px/1.5 -apple-system, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; }</style>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<p><a href="/">← all posts</a></p>
|
|
10
|
+
<article>
|
|
11
|
+
<h1><%= @title %></h1>
|
|
12
|
+
<p style="color:#888">by <%= @author %></p>
|
|
13
|
+
<div><%= @body %></div>
|
|
14
|
+
</article>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|