tina4ruby 0.5.2 → 3.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 +4 -4
- data/CHANGELOG.md +1 -1
- data/README.md +360 -559
- data/exe/{tina4 → tina4ruby} +1 -0
- data/lib/tina4/ai.rb +312 -0
- data/lib/tina4/auth.rb +44 -3
- data/lib/tina4/auto_crud.rb +163 -0
- data/lib/tina4/cli.rb +242 -77
- data/lib/tina4/constants.rb +46 -0
- data/lib/tina4/cors.rb +74 -0
- data/lib/tina4/database/sqlite3_adapter.rb +139 -0
- data/lib/tina4/database.rb +43 -7
- data/lib/tina4/debug.rb +4 -79
- data/lib/tina4/dev_admin.rb +1162 -0
- data/lib/tina4/dev_mailbox.rb +191 -0
- data/lib/tina4/dev_reload.rb +9 -9
- data/lib/tina4/drivers/firebird_driver.rb +19 -3
- data/lib/tina4/drivers/mssql_driver.rb +3 -3
- data/lib/tina4/drivers/mysql_driver.rb +4 -4
- data/lib/tina4/drivers/postgres_driver.rb +9 -2
- data/lib/tina4/drivers/sqlite_driver.rb +1 -1
- data/lib/tina4/env.rb +42 -2
- data/lib/tina4/error_overlay.rb +252 -0
- data/lib/tina4/events.rb +90 -0
- data/lib/tina4/field_types.rb +4 -0
- data/lib/tina4/frond.rb +1336 -0
- data/lib/tina4/gallery/auth/meta.json +1 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
- data/lib/tina4/gallery/database/meta.json +1 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
- data/lib/tina4/gallery/error-overlay/meta.json +1 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
- data/lib/tina4/gallery/orm/meta.json +1 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
- data/lib/tina4/gallery/queue/meta.json +1 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
- data/lib/tina4/gallery/rest-api/meta.json +1 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
- data/lib/tina4/gallery/templates/meta.json +1 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
- data/lib/tina4/health.rb +39 -0
- data/lib/tina4/html_element.rb +148 -0
- data/lib/tina4/localization.rb +2 -2
- data/lib/tina4/log.rb +203 -0
- data/lib/tina4/messenger.rb +484 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +337 -31
- data/lib/tina4/public/css/tina4.css +178 -1
- data/lib/tina4/public/css/tina4.min.css +1 -2
- data/lib/tina4/public/favicon.ico +0 -0
- data/lib/tina4/public/images/logo.svg +5 -0
- data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
- data/lib/tina4/public/js/frond.min.js +420 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
- data/lib/tina4/public/js/tina4.min.js +93 -0
- data/lib/tina4/public/swagger/index.html +90 -0
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
- data/lib/tina4/queue.rb +40 -4
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +314 -23
- data/lib/tina4/rate_limiter.rb +123 -0
- data/lib/tina4/request.rb +61 -15
- data/lib/tina4/response.rb +54 -24
- data/lib/tina4/response_cache.rb +134 -0
- data/lib/tina4/router.rb +90 -15
- data/lib/tina4/scss_compiler.rb +2 -2
- data/lib/tina4/seeder.rb +56 -61
- data/lib/tina4/service_runner.rb +303 -0
- data/lib/tina4/session.rb +85 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
- data/lib/tina4/shutdown.rb +84 -0
- data/lib/tina4/sql_translation.rb +295 -0
- data/lib/tina4/template.rb +36 -6
- data/lib/tina4/templates/base.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +14 -0
- data/lib/tina4/templates/errors/401.twig +9 -0
- data/lib/tina4/templates/errors/403.twig +22 -15
- data/lib/tina4/templates/errors/404.twig +22 -15
- data/lib/tina4/templates/errors/500.twig +31 -15
- data/lib/tina4/templates/errors/502.twig +9 -0
- data/lib/tina4/templates/errors/503.twig +12 -0
- data/lib/tina4/templates/errors/base.twig +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -18
- data/lib/tina4.rb +57 -21
- metadata +51 -19
- data/lib/tina4/public/js/tina4.js +0 -134
- data/lib/tina4/public/js/tina4helper.js +0 -387
data/exe/{tina4 → tina4ruby}
RENAMED
data/lib/tina4/ai.rb
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
# Detect AI coding assistants and scaffold Tina4 context files.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# tools = Tina4::AI.detect_ai("/path/to/project")
|
|
8
|
+
# files = Tina4::AI.install_ai_context("/path/to/project")
|
|
9
|
+
#
|
|
10
|
+
module AI
|
|
11
|
+
AI_TOOLS = {
|
|
12
|
+
"claude-code" => {
|
|
13
|
+
description: "Claude Code (Anthropic CLI)",
|
|
14
|
+
detect: ->(root) { Dir.exist?(File.join(root, ".claude")) || File.exist?(File.join(root, "CLAUDE.md")) },
|
|
15
|
+
config_dir: ".claude",
|
|
16
|
+
context_file: "CLAUDE.md"
|
|
17
|
+
},
|
|
18
|
+
"cursor" => {
|
|
19
|
+
description: "Cursor IDE",
|
|
20
|
+
detect: ->(root) { Dir.exist?(File.join(root, ".cursor")) || File.exist?(File.join(root, ".cursorules")) },
|
|
21
|
+
config_dir: ".cursor",
|
|
22
|
+
context_file: ".cursorules"
|
|
23
|
+
},
|
|
24
|
+
"copilot" => {
|
|
25
|
+
description: "GitHub Copilot",
|
|
26
|
+
detect: ->(root) { File.exist?(File.join(root, ".github", "copilot-instructions.md")) || Dir.exist?(File.join(root, ".github")) },
|
|
27
|
+
config_dir: ".github",
|
|
28
|
+
context_file: ".github/copilot-instructions.md"
|
|
29
|
+
},
|
|
30
|
+
"windsurf" => {
|
|
31
|
+
description: "Windsurf (Codeium)",
|
|
32
|
+
detect: ->(root) { File.exist?(File.join(root, ".windsurfrules")) },
|
|
33
|
+
config_dir: nil,
|
|
34
|
+
context_file: ".windsurfrules"
|
|
35
|
+
},
|
|
36
|
+
"aider" => {
|
|
37
|
+
description: "Aider",
|
|
38
|
+
detect: ->(root) { File.exist?(File.join(root, ".aider.conf.yml")) || File.exist?(File.join(root, "CONVENTIONS.md")) },
|
|
39
|
+
config_dir: nil,
|
|
40
|
+
context_file: "CONVENTIONS.md"
|
|
41
|
+
},
|
|
42
|
+
"cline" => {
|
|
43
|
+
description: "Cline (VS Code)",
|
|
44
|
+
detect: ->(root) { File.exist?(File.join(root, ".clinerules")) },
|
|
45
|
+
config_dir: nil,
|
|
46
|
+
context_file: ".clinerules"
|
|
47
|
+
},
|
|
48
|
+
"codex" => {
|
|
49
|
+
description: "OpenAI Codex CLI",
|
|
50
|
+
detect: ->(root) { File.exist?(File.join(root, "AGENTS.md")) || File.exist?(File.join(root, "codex.md")) },
|
|
51
|
+
config_dir: nil,
|
|
52
|
+
context_file: "AGENTS.md"
|
|
53
|
+
}
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
# Detect which AI coding tools are present in the project.
|
|
58
|
+
#
|
|
59
|
+
# @param root [String] project root directory (default: current directory)
|
|
60
|
+
# @return [Array<Hash>] each hash has :name, :description, :config_file, :status
|
|
61
|
+
def detect_ai(root = ".")
|
|
62
|
+
root = File.expand_path(root)
|
|
63
|
+
AI_TOOLS.map do |name, tool|
|
|
64
|
+
installed = tool[:detect].call(root)
|
|
65
|
+
{
|
|
66
|
+
name: name,
|
|
67
|
+
description: tool[:description],
|
|
68
|
+
config_file: tool[:context_file],
|
|
69
|
+
status: installed ? "detected" : "not detected"
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Return just the names of detected AI tools.
|
|
75
|
+
#
|
|
76
|
+
# @param root [String] project root directory
|
|
77
|
+
# @return [Array<String>]
|
|
78
|
+
def detect_ai_names(root = ".")
|
|
79
|
+
detect_ai(root).select { |t| t[:status] == "detected" }.map { |t| t[:name] }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Install Tina4 context files for detected (or all) AI tools.
|
|
83
|
+
#
|
|
84
|
+
# @param root [String] project root directory
|
|
85
|
+
# @param tools [Array<String>, nil] specific tools to install for (nil = auto-detect)
|
|
86
|
+
# @param force [Boolean] overwrite existing context files
|
|
87
|
+
# @return [Array<String>] list of files created/updated
|
|
88
|
+
def install_ai_context(root = ".", tools: nil, force: false)
|
|
89
|
+
root = File.expand_path(root)
|
|
90
|
+
created = []
|
|
91
|
+
|
|
92
|
+
tool_names = tools || detect_ai_names(root)
|
|
93
|
+
context = generate_context
|
|
94
|
+
|
|
95
|
+
tool_names.each do |tool_name|
|
|
96
|
+
tool = AI_TOOLS[tool_name]
|
|
97
|
+
next unless tool
|
|
98
|
+
|
|
99
|
+
files = install_for_tool(root, tool_name, tool, context, force)
|
|
100
|
+
created.concat(files)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
created
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Install Tina4 context for ALL known AI tools (not just detected ones).
|
|
107
|
+
#
|
|
108
|
+
# @param root [String] project root directory
|
|
109
|
+
# @param force [Boolean] overwrite existing context files
|
|
110
|
+
# @return [Array<String>] list of files created/updated
|
|
111
|
+
def install_all(root = ".", force: false)
|
|
112
|
+
root = File.expand_path(root)
|
|
113
|
+
created = []
|
|
114
|
+
context = generate_context
|
|
115
|
+
|
|
116
|
+
AI_TOOLS.each do |tool_name, tool|
|
|
117
|
+
files = install_for_tool(root, tool_name, tool, context, force)
|
|
118
|
+
created.concat(files)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
created
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Generate a human-readable status report of AI tool detection.
|
|
125
|
+
#
|
|
126
|
+
# @param root [String] project root directory
|
|
127
|
+
# @return [String]
|
|
128
|
+
def status_report(root = ".")
|
|
129
|
+
tools = detect_ai(root)
|
|
130
|
+
installed = tools.select { |t| t[:status] == "detected" }
|
|
131
|
+
missing = tools.reject { |t| t[:status] == "detected" }
|
|
132
|
+
|
|
133
|
+
lines = ["\nTina4 AI Context Status\n"]
|
|
134
|
+
|
|
135
|
+
if installed.any?
|
|
136
|
+
lines << "Detected AI tools:"
|
|
137
|
+
installed.each { |t| lines << " + #{t[:description]} (#{t[:name]})" }
|
|
138
|
+
else
|
|
139
|
+
lines << "No AI coding tools detected."
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if missing.any?
|
|
143
|
+
lines << "\nNot detected (install context with `tina4ruby ai --all`):"
|
|
144
|
+
missing.each { |t| lines << " - #{t[:description]} (#{t[:name]})" }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
lines << ""
|
|
148
|
+
lines.join("\n")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def install_for_tool(root, name, tool, context, force)
|
|
154
|
+
created = []
|
|
155
|
+
context_path = File.join(root, tool[:context_file])
|
|
156
|
+
|
|
157
|
+
# Create config directory if needed
|
|
158
|
+
if tool[:config_dir]
|
|
159
|
+
FileUtils.mkdir_p(File.join(root, tool[:config_dir]))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Ensure parent directory exists for the context file
|
|
163
|
+
FileUtils.mkdir_p(File.dirname(context_path))
|
|
164
|
+
|
|
165
|
+
if !File.exist?(context_path) || force
|
|
166
|
+
File.write(context_path, context)
|
|
167
|
+
rel_path = context_path.sub("#{root}/", "")
|
|
168
|
+
created << rel_path
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Install Claude Code skills if it's Claude
|
|
172
|
+
if name == "claude-code"
|
|
173
|
+
skills = install_claude_skills(root, force)
|
|
174
|
+
created.concat(skills)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
created
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def install_claude_skills(root, force)
|
|
181
|
+
created = []
|
|
182
|
+
|
|
183
|
+
# Determine the framework root (where lib/tina4/ lives)
|
|
184
|
+
framework_root = File.expand_path("../../..", __FILE__)
|
|
185
|
+
|
|
186
|
+
# Copy .skill files from the framework's skills/ directory to project root
|
|
187
|
+
skills_source = File.join(framework_root, "skills")
|
|
188
|
+
if Dir.exist?(skills_source)
|
|
189
|
+
Dir.glob(File.join(skills_source, "*.skill")).each do |skill_file|
|
|
190
|
+
target = File.join(root, File.basename(skill_file))
|
|
191
|
+
if !File.exist?(target) || force
|
|
192
|
+
FileUtils.cp(skill_file, target)
|
|
193
|
+
created << File.basename(skill_file)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Copy skill directories from .claude/skills/ in the framework to the project
|
|
199
|
+
framework_skills_dir = File.join(framework_root, ".claude", "skills")
|
|
200
|
+
if Dir.exist?(framework_skills_dir)
|
|
201
|
+
target_skills_dir = File.join(root, ".claude", "skills")
|
|
202
|
+
FileUtils.mkdir_p(target_skills_dir)
|
|
203
|
+
Dir.children(framework_skills_dir).each do |entry|
|
|
204
|
+
skill_dir = File.join(framework_skills_dir, entry)
|
|
205
|
+
next unless File.directory?(skill_dir)
|
|
206
|
+
|
|
207
|
+
target_dir = File.join(target_skills_dir, entry)
|
|
208
|
+
if !Dir.exist?(target_dir) || force
|
|
209
|
+
FileUtils.rm_rf(target_dir) if Dir.exist?(target_dir)
|
|
210
|
+
FileUtils.cp_r(skill_dir, target_dir)
|
|
211
|
+
rel_path = target_dir.sub("#{root}/", "")
|
|
212
|
+
created << rel_path
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
created
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def generate_context
|
|
221
|
+
<<~CONTEXT
|
|
222
|
+
# Tina4 Ruby -- AI Context
|
|
223
|
+
|
|
224
|
+
This project uses **Tina4 Ruby**, a lightweight, batteries-included web framework
|
|
225
|
+
with zero third-party dependencies for core features.
|
|
226
|
+
|
|
227
|
+
**Documentation:** https://tina4.com
|
|
228
|
+
|
|
229
|
+
## Quick Start
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
tina4ruby init . # Scaffold project
|
|
233
|
+
tina4ruby start # Start dev server on port 7147
|
|
234
|
+
tina4ruby migrate # Run database migrations
|
|
235
|
+
tina4ruby test # Run test suite
|
|
236
|
+
tina4ruby routes # List all registered routes
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Project Structure
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
routes/ -- Route handlers (auto-discovered, one per resource)
|
|
243
|
+
src/routes/ -- Alternative route location
|
|
244
|
+
templates/ -- Twig/ERB templates (extends base template)
|
|
245
|
+
public/ -- Static assets served at /
|
|
246
|
+
scss/ -- SCSS files (auto-compiled to public/css/)
|
|
247
|
+
migrations/ -- SQL migration files (sequential numbered)
|
|
248
|
+
seeds/ -- Database seeder scripts
|
|
249
|
+
spec/ -- RSpec test files
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Built-in Features (No External Gems Needed for Core)
|
|
253
|
+
|
|
254
|
+
| Feature | Module |
|
|
255
|
+
|---------|--------|
|
|
256
|
+
| Routing | Tina4::Router |
|
|
257
|
+
| ORM | Tina4::ORM |
|
|
258
|
+
| Database | Tina4::Database |
|
|
259
|
+
| Templates | Tina4::Template |
|
|
260
|
+
| JWT Auth | Tina4::Auth |
|
|
261
|
+
| REST API Client | Tina4::API |
|
|
262
|
+
| GraphQL | Tina4::GraphQL |
|
|
263
|
+
| WebSocket | Tina4::WebSocket |
|
|
264
|
+
| SOAP/WSDL | Tina4::WSDL |
|
|
265
|
+
| Email (SMTP+IMAP) | Tina4::Messenger |
|
|
266
|
+
| Background Queue | Tina4::Queue |
|
|
267
|
+
| SCSS Compilation | Tina4::ScssCompiler |
|
|
268
|
+
| Migrations | Tina4::Migration |
|
|
269
|
+
| Seeder | Tina4::FakeData |
|
|
270
|
+
| i18n | Tina4::Localization |
|
|
271
|
+
| Swagger/OpenAPI | Tina4::Swagger |
|
|
272
|
+
| Sessions | Tina4::Session |
|
|
273
|
+
| Middleware | Tina4::Middleware |
|
|
274
|
+
|
|
275
|
+
## Key Conventions
|
|
276
|
+
|
|
277
|
+
1. **Routes use block handlers** with `|request, response|` params
|
|
278
|
+
2. **GET routes are public**, POST/PUT/PATCH/DELETE require auth by default
|
|
279
|
+
3. **Use `auth: false`** to make write routes public, `secure_get` to protect GET routes
|
|
280
|
+
4. **Every template extends a base template** -- no standalone HTML pages
|
|
281
|
+
5. **No inline styles** -- use SCSS with CSS variables
|
|
282
|
+
6. **All schema changes via migrations** -- never create tables in route code
|
|
283
|
+
7. **Service pattern** -- complex logic goes in service classes, routes stay thin
|
|
284
|
+
8. **Use built-in features** -- never install gems for things Tina4 already provides
|
|
285
|
+
|
|
286
|
+
## Common Patterns
|
|
287
|
+
|
|
288
|
+
### Route
|
|
289
|
+
```ruby
|
|
290
|
+
Tina4.get "/api/widgets" do |request, response|
|
|
291
|
+
response.json({ widgets: Widget.all })
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
Tina4.post "/api/widgets", auth: false do |request, response|
|
|
295
|
+
widget = Widget.create(request.body)
|
|
296
|
+
response.json({ created: true }, 201)
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### ORM Model
|
|
301
|
+
```ruby
|
|
302
|
+
class Widget < Tina4::ORM
|
|
303
|
+
integer_field :id, primary_key: true, auto_increment: true
|
|
304
|
+
string_field :name
|
|
305
|
+
numeric_field :price
|
|
306
|
+
end
|
|
307
|
+
```
|
|
308
|
+
CONTEXT
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
data/lib/tina4/auth.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Tina4
|
|
|
15
15
|
ensure_keys
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def
|
|
18
|
+
def create_token(payload, expires_in: 3600)
|
|
19
19
|
ensure_keys
|
|
20
20
|
now = Time.now.to_i
|
|
21
21
|
claims = payload.merge(
|
|
@@ -27,6 +27,7 @@ module Tina4
|
|
|
27
27
|
JWT.encode(claims, private_key, "RS256")
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
|
|
30
31
|
def validate_token(token)
|
|
31
32
|
ensure_keys
|
|
32
33
|
require "jwt"
|
|
@@ -43,13 +44,53 @@ module Tina4
|
|
|
43
44
|
BCrypt::Password.create(password)
|
|
44
45
|
end
|
|
45
46
|
|
|
46
|
-
def
|
|
47
|
+
def check_password(password, hash)
|
|
47
48
|
require "bcrypt"
|
|
48
49
|
BCrypt::Password.new(hash) == password
|
|
49
50
|
rescue BCrypt::Errors::InvalidHash
|
|
50
51
|
false
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
|
|
55
|
+
def get_payload(token)
|
|
56
|
+
require "jwt"
|
|
57
|
+
decoded = JWT.decode(token, nil, false)
|
|
58
|
+
decoded[0]
|
|
59
|
+
rescue JWT::DecodeError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def refresh_token(token, expires_in: 3600)
|
|
64
|
+
result = validate_token(token)
|
|
65
|
+
return nil unless result[:valid]
|
|
66
|
+
|
|
67
|
+
payload = result[:payload].reject { |k, _| %w[iat exp nbf].include?(k) }
|
|
68
|
+
create_token(payload, expires_in: expires_in)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def authenticate_request(headers)
|
|
72
|
+
auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
|
|
73
|
+
return { valid: false, error: "No authorization header" } unless auth_header =~ /\ABearer\s+(.+)\z/i
|
|
74
|
+
|
|
75
|
+
token = Regexp.last_match(1)
|
|
76
|
+
|
|
77
|
+
# API_KEY bypass — matches tina4_python behavior
|
|
78
|
+
api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
79
|
+
if api_key && !api_key.empty? && token == api_key
|
|
80
|
+
return { valid: true, payload: { "api_key" => true } }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
validate_token(token)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_api_key(provided, expected: nil)
|
|
87
|
+
expected ||= ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
88
|
+
return false if expected.nil? || expected.empty?
|
|
89
|
+
return false if provided.nil? || provided.empty?
|
|
90
|
+
|
|
91
|
+
provided == expected
|
|
92
|
+
end
|
|
93
|
+
|
|
53
94
|
def auth_handler(&block)
|
|
54
95
|
if block_given?
|
|
55
96
|
@custom_handler = block
|
|
@@ -107,7 +148,7 @@ module Tina4
|
|
|
107
148
|
end
|
|
108
149
|
|
|
109
150
|
def generate_keys
|
|
110
|
-
Tina4::
|
|
151
|
+
Tina4::Log.info("Generating RSA key pair for JWT authentication")
|
|
111
152
|
key = OpenSSL::PKey::RSA.generate(2048)
|
|
112
153
|
File.write(private_key_path, key.to_pem)
|
|
113
154
|
File.write(public_key_path, key.public_key.to_pem)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Tina4
|
|
5
|
+
module AutoCrud
|
|
6
|
+
class << self
|
|
7
|
+
# Track registered model classes
|
|
8
|
+
def models
|
|
9
|
+
@models ||= []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Register a model for auto-CRUD
|
|
13
|
+
def register(model_class)
|
|
14
|
+
models << model_class unless models.include?(model_class)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Generate REST endpoints for all registered models
|
|
18
|
+
def generate_routes(prefix: "/api")
|
|
19
|
+
models.each do |model_class|
|
|
20
|
+
generate_routes_for(model_class, prefix: prefix)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Generate REST endpoints for a single model class
|
|
25
|
+
def generate_routes_for(model_class, prefix: "/api")
|
|
26
|
+
table = model_class.table_name
|
|
27
|
+
pk = model_class.primary_key_field || :id
|
|
28
|
+
|
|
29
|
+
# GET /api/{table} -- list all with pagination, filtering, sorting
|
|
30
|
+
Tina4::Router.add_route("GET", "#{prefix}/#{table}", proc { |req, res|
|
|
31
|
+
begin
|
|
32
|
+
limit = (req.query["limit"] || 10).to_i
|
|
33
|
+
offset = (req.query["offset"] || 0).to_i
|
|
34
|
+
order_by = parse_sort(req.query["sort"])
|
|
35
|
+
|
|
36
|
+
# Filter support: ?filter[field]=value
|
|
37
|
+
filter_conditions = []
|
|
38
|
+
filter_values = []
|
|
39
|
+
req.query.each do |key, value|
|
|
40
|
+
if key =~ /\Afilter\[(\w+)\]\z/
|
|
41
|
+
filter_conditions << "#{$1} = ?"
|
|
42
|
+
filter_values << value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if filter_conditions.empty?
|
|
47
|
+
records = model_class.all(limit: limit, offset: offset, order_by: order_by)
|
|
48
|
+
total = model_class.count
|
|
49
|
+
else
|
|
50
|
+
where_clause = filter_conditions.join(" AND ")
|
|
51
|
+
records = model_class.where(where_clause, filter_values)
|
|
52
|
+
total = records.length
|
|
53
|
+
# Apply manual pagination for filtered results
|
|
54
|
+
records = records.slice(offset, limit) || []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
res.json({
|
|
58
|
+
data: records.map { |r| r.to_h },
|
|
59
|
+
total: total,
|
|
60
|
+
limit: limit,
|
|
61
|
+
offset: offset
|
|
62
|
+
})
|
|
63
|
+
rescue => e
|
|
64
|
+
res.json({ error: e.message }, status: 500)
|
|
65
|
+
end
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
# GET /api/{table}/{id} -- get single record
|
|
69
|
+
Tina4::Router.add_route("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
70
|
+
begin
|
|
71
|
+
id = req.params["id"]
|
|
72
|
+
record = model_class.find(id.to_i)
|
|
73
|
+
if record
|
|
74
|
+
res.json({ data: record.to_h })
|
|
75
|
+
else
|
|
76
|
+
res.json({ error: "Not found" }, status: 404)
|
|
77
|
+
end
|
|
78
|
+
rescue => e
|
|
79
|
+
res.json({ error: e.message }, status: 500)
|
|
80
|
+
end
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
# POST /api/{table} -- create record
|
|
84
|
+
Tina4::Router.add_route("POST", "#{prefix}/#{table}", proc { |req, res|
|
|
85
|
+
begin
|
|
86
|
+
attributes = req.body_parsed
|
|
87
|
+
record = model_class.create(attributes)
|
|
88
|
+
if record.persisted?
|
|
89
|
+
res.json({ data: record.to_h }, status: 201)
|
|
90
|
+
else
|
|
91
|
+
res.json({ errors: record.errors }, status: 422)
|
|
92
|
+
end
|
|
93
|
+
rescue => e
|
|
94
|
+
res.json({ error: e.message }, status: 500)
|
|
95
|
+
end
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
# PUT /api/{table}/{id} -- update record
|
|
99
|
+
Tina4::Router.add_route("PUT", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
100
|
+
begin
|
|
101
|
+
id = req.params["id"]
|
|
102
|
+
record = model_class.find(id.to_i)
|
|
103
|
+
unless record
|
|
104
|
+
next res.json({ error: "Not found" }, status: 404)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
attributes = req.body_parsed
|
|
108
|
+
attributes.each do |key, value|
|
|
109
|
+
setter = "#{key}="
|
|
110
|
+
record.__send__(setter, value) if record.respond_to?(setter)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if record.save
|
|
114
|
+
res.json({ data: record.to_h })
|
|
115
|
+
else
|
|
116
|
+
res.json({ errors: record.errors }, status: 422)
|
|
117
|
+
end
|
|
118
|
+
rescue => e
|
|
119
|
+
res.json({ error: e.message }, status: 500)
|
|
120
|
+
end
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
# DELETE /api/{table}/{id} -- delete record
|
|
124
|
+
Tina4::Router.add_route("DELETE", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
125
|
+
begin
|
|
126
|
+
id = req.params["id"]
|
|
127
|
+
record = model_class.find(id.to_i)
|
|
128
|
+
unless record
|
|
129
|
+
next res.json({ error: "Not found" }, status: 404)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if record.delete
|
|
133
|
+
res.json({ message: "Deleted" })
|
|
134
|
+
else
|
|
135
|
+
res.json({ error: "Delete failed" }, status: 500)
|
|
136
|
+
end
|
|
137
|
+
rescue => e
|
|
138
|
+
res.json({ error: e.message }, status: 500)
|
|
139
|
+
end
|
|
140
|
+
})
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def clear!
|
|
144
|
+
@models = []
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# Parse sort parameter: "-name,created_at" => "name DESC, created_at ASC"
|
|
150
|
+
def parse_sort(sort_str)
|
|
151
|
+
return nil if sort_str.nil? || sort_str.empty?
|
|
152
|
+
sort_str.split(",").map do |field|
|
|
153
|
+
field = field.strip
|
|
154
|
+
if field.start_with?("-")
|
|
155
|
+
"#{field[1..-1]} DESC"
|
|
156
|
+
else
|
|
157
|
+
"#{field} ASC"
|
|
158
|
+
end
|
|
159
|
+
end.join(", ")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|