tina4ruby 3.0.0 → 3.9.2
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/README.md +120 -32
- data/lib/tina4/auth.rb +137 -27
- data/lib/tina4/auto_crud.rb +55 -3
- data/lib/tina4/cli.rb +228 -28
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +230 -26
- data/lib/tina4/database_result.rb +122 -8
- data/lib/tina4/dev_mailbox.rb +1 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/frond.rb +314 -7
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
- data/lib/tina4/localization.rb +1 -1
- data/lib/tina4/messenger.rb +111 -33
- data/lib/tina4/middleware.rb +349 -1
- data/lib/tina4/migration.rb +132 -11
- data/lib/tina4/orm.rb +149 -18
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
- data/lib/tina4/public/js/tina4js.min.js +47 -0
- data/lib/tina4/query_builder.rb +374 -0
- data/lib/tina4/queue.rb +219 -61
- data/lib/tina4/queue_backends/lite_backend.rb +42 -7
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
- data/lib/tina4/rack_app.rb +200 -11
- data/lib/tina4/request.rb +14 -1
- data/lib/tina4/response.rb +26 -0
- data/lib/tina4/response_cache.rb +446 -29
- data/lib/tina4/router.rb +127 -0
- data/lib/tina4/service_runner.rb +1 -1
- data/lib/tina4/session.rb +6 -1
- data/lib/tina4/session_handlers/database_handler.rb +66 -0
- data/lib/tina4/swagger.rb +1 -1
- data/lib/tina4/templates/errors/404.twig +2 -2
- data/lib/tina4/templates/errors/500.twig +1 -1
- data/lib/tina4/validator.rb +174 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +23 -4
- data/lib/tina4/websocket_backplane.rb +118 -0
- data/lib/tina4.rb +126 -5
- metadata +40 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 101a34de44fea572c9c21c8fea286f7adfb4c34fa5227be1c82b7def86ec8d71
|
|
4
|
+
data.tar.gz: d86ffef03c13ef707952678dde2961f9b430f81a81d7d618dd2a3c4811295611
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4446bf6ba80cdfb3d2ab17dabd523641f26a9eb09dbbff3cb9b6397e501206afa18bf85c1fe7b147d2aa0910ad2ca225322d7329cc6e3022ad493daee2f0e5a6
|
|
7
|
+
data.tar.gz: d4a7c8481f75d1fce92031da88b72ac88eda029aef5ebd417010c5545429b68c8f4fffd7a9048565df0e4512bfe6a23d3a578b4b952211ef2147978c18ca31ab
|
data/README.md
CHANGED
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
Laravel joy. Ruby speed. 10x less code. Zero third-party dependencies.
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://rubygems.org/gems/tina4"><img src="https://img.shields.io/gem/v/tina4?color=7b1fa2&label=RubyGems" alt="Gem"></a>
|
|
14
|
+
<img src="https://img.shields.io/badge/tests-1%2C578%20passing-brightgreen" alt="Tests">
|
|
15
|
+
<img src="https://img.shields.io/badge/features-38-blue" alt="Features">
|
|
16
|
+
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero Deps">
|
|
17
|
+
<a href="https://tina4.com"><img src="https://img.shields.io/badge/docs-tina4.com-7b1fa2" alt="Docs"></a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
12
20
|
<p align="center">
|
|
13
21
|
<a href="https://tina4.com">Documentation</a> •
|
|
14
22
|
<a href="#getting-started">Getting Started</a> •
|
|
@@ -17,27 +25,55 @@
|
|
|
17
25
|
<a href="https://tina4.com">tina4.com</a>
|
|
18
26
|
</p>
|
|
19
27
|
|
|
20
|
-
<p align="center">
|
|
21
|
-
<img src="https://img.shields.io/badge/tests-1334%20passing-brightgreen" alt="Tests">
|
|
22
|
-
<img src="https://img.shields.io/badge/carbonah-A%2B%20rated-00cc44" alt="Carbonah A+">
|
|
23
|
-
<img src="https://img.shields.io/badge/zero--dep-core-blue" alt="Zero Dependencies">
|
|
24
|
-
<img src="https://img.shields.io/badge/ruby-3.1%2B-blue" alt="Ruby 3.1+">
|
|
25
|
-
<img src="https://img.shields.io/badge/license-MIT-lightgrey" alt="MIT License">
|
|
26
|
-
</p>
|
|
27
|
-
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Quick Start
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
# Install the Tina4 CLI
|
|
34
|
+
cargo install tina4 # or download binary from https://github.com/tina4stack/tina4/releases
|
|
35
|
+
|
|
36
|
+
# Create a project
|
|
37
|
+
tina4 init ruby ./my-app
|
|
38
|
+
|
|
39
|
+
# Run it
|
|
40
|
+
cd my-app && tina4 serve
|
|
38
41
|
```
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
Open http://localhost:7147 — your app is running.
|
|
44
|
+
|
|
45
|
+
<details>
|
|
46
|
+
<summary><strong>Without the Tina4 CLI</strong></summary>
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# 1. Create project
|
|
50
|
+
mkdir my-app && cd my-app
|
|
51
|
+
echo 'source "https://rubygems.org"' > Gemfile
|
|
52
|
+
echo 'gem "tina4-ruby", "~> 3.0"' >> Gemfile
|
|
53
|
+
bundle install
|
|
54
|
+
|
|
55
|
+
# 2. Create entry point
|
|
56
|
+
cat > app.rb << 'EOF'
|
|
57
|
+
require "tina4"
|
|
58
|
+
Tina4.initialize!(__dir__)
|
|
59
|
+
app = Tina4::RackApp.new
|
|
60
|
+
Tina4::WebServer.new(app, host: "0.0.0.0", port: 7147).start
|
|
61
|
+
EOF
|
|
62
|
+
|
|
63
|
+
# 3. Create .env
|
|
64
|
+
echo 'TINA4_DEBUG=true' > .env
|
|
65
|
+
echo 'TINA4_LOG_LEVEL=ALL' >> .env
|
|
66
|
+
|
|
67
|
+
# 4. Create route directory
|
|
68
|
+
mkdir -p src/routes
|
|
69
|
+
|
|
70
|
+
# 5. Run
|
|
71
|
+
bundle exec ruby app.rb
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Open http://localhost:7147
|
|
75
|
+
|
|
76
|
+
</details>
|
|
41
77
|
|
|
42
78
|
---
|
|
43
79
|
|
|
@@ -50,17 +86,17 @@ Every feature is built from scratch -- no gem install, no node_modules, no third
|
|
|
50
86
|
| **HTTP** | Rack 3 server, block routing, path params (`{id:int}`, `{p:path}`), middleware pipeline, CORS, rate limiting, graceful shutdown |
|
|
51
87
|
| **Templates** | Frond engine (Twig-compatible), inheritance, partials, 35+ filters, macros, fragment caching, sandboxing |
|
|
52
88
|
| **ORM** | Active Record, typed fields with validation, soft delete, relationships (`has_one`/`has_many`/`belongs_to`), scopes, result caching, multi-database |
|
|
53
|
-
| **Database** | SQLite, PostgreSQL, MySQL, MSSQL, Firebird -- unified adapter interface |
|
|
54
|
-
| **Auth** | Zero-dep JWT (RS256), sessions (file
|
|
55
|
-
| **API** | Swagger/OpenAPI auto-generation, GraphQL with ORM auto-schema and GraphiQL IDE |
|
|
56
|
-
| **Background** |
|
|
89
|
+
| **Database** | SQLite, PostgreSQL, MySQL, MSSQL, Firebird -- unified adapter interface, query caching (TINA4_DB_CACHE=true for 4x speedup) |
|
|
90
|
+
| **Auth** | Zero-dep JWT (HS256/RS256), sessions (file/Redis/Valkey/MongoDB/database), password hashing, form tokens |
|
|
91
|
+
| **API** | Swagger/OpenAPI auto-generation, GraphQL with ORM auto-schema and GraphiQL IDE, WSDL/SOAP with auto WSDL |
|
|
92
|
+
| **Background** | Queue (SQLite/RabbitMQ/Kafka/MongoDB) with priority, delayed jobs, retry, batch processing |
|
|
57
93
|
| **Real-time** | Native WebSocket (RFC 6455), per-path routing, connection manager |
|
|
58
94
|
| **Frontend** | tina4-css (~24 KB), frond.js helper, SCSS compiler, live reload, CSS hot-reload |
|
|
59
95
|
| **DX** | Dev admin dashboard, error overlay, request inspector, AI tool integration, Carbonah green benchmarks |
|
|
60
96
|
| **Data** | Migrations with rollback, 50+ fake data generators, ORM and table seeders |
|
|
61
|
-
| **Other** | REST client, localization (6 languages),
|
|
97
|
+
| **Other** | REST client, localization (6 languages), cache (memory/Redis/file), event system, inline testing, messenger (.env driven), configurable error pages |
|
|
62
98
|
|
|
63
|
-
**
|
|
99
|
+
**1,578 tests across 38 built-in features. Zero dependencies. All Carbonah benchmarks rated A+.**
|
|
64
100
|
|
|
65
101
|
For full documentation visit **[tina4.com](https://tina4.com)**.
|
|
66
102
|
|
|
@@ -322,7 +358,7 @@ db = Tina4::Database.new("mysql://localhost:3306/mydb", username: "user", passwo
|
|
|
322
358
|
db = Tina4::Database.new("mssql://localhost:1433/mydb", username: "sa", password: "pass")
|
|
323
359
|
db = Tina4::Database.new("firebird://localhost:3050/path/to/db", username: "SYSDBA", password: "masterkey")
|
|
324
360
|
|
|
325
|
-
result = db.fetch("SELECT * FROM users WHERE age > ?", [18], limit: 20,
|
|
361
|
+
result = db.fetch("SELECT * FROM users WHERE age > ?", [18], limit: 20, offset: 0)
|
|
326
362
|
row = db.fetch_one("SELECT * FROM users WHERE id = ?", [1])
|
|
327
363
|
db.insert("users", { name: "Alice", email: "alice@test.com" })
|
|
328
364
|
db.commit
|
|
@@ -345,9 +381,9 @@ end
|
|
|
345
381
|
### JWT Authentication
|
|
346
382
|
|
|
347
383
|
```ruby
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
payload =
|
|
384
|
+
token = Tina4::Auth.get_token({ user_id: 42 })
|
|
385
|
+
result = Tina4::Auth.valid_token(token)
|
|
386
|
+
payload = Tina4::Auth.get_payload(token)
|
|
351
387
|
```
|
|
352
388
|
|
|
353
389
|
POST/PUT/PATCH/DELETE routes require `Authorization: Bearer <token>` by default. Use `auth: false` to make public, `secure_get` to protect GET routes.
|
|
@@ -364,11 +400,10 @@ Backends: file (default), Redis, MongoDB. Set via `TINA4_SESSION_HANDLER` in `.e
|
|
|
364
400
|
### Queues
|
|
365
401
|
|
|
366
402
|
```ruby
|
|
367
|
-
|
|
368
|
-
|
|
403
|
+
queue = Tina4::Queue.new(topic: "emails")
|
|
404
|
+
queue.produce("emails", { to: "alice@example.com" })
|
|
369
405
|
|
|
370
|
-
|
|
371
|
-
consumer.each { |msg| send_email(msg.data) }
|
|
406
|
+
queue.consume("emails") { |msg| send_email(msg.payload) }
|
|
372
407
|
```
|
|
373
408
|
|
|
374
409
|
### GraphQL
|
|
@@ -486,7 +521,7 @@ cache.tag("users").flush
|
|
|
486
521
|
|
|
487
522
|
## Dev Mode
|
|
488
523
|
|
|
489
|
-
Set `
|
|
524
|
+
Set `TINA4_DEBUG=true` in `.env` to enable:
|
|
490
525
|
|
|
491
526
|
- **Live reload** -- browser auto-refreshes on code changes
|
|
492
527
|
- **CSS hot-reload** -- SCSS changes apply without page refresh
|
|
@@ -500,9 +535,14 @@ Set `TINA4_DEBUG_LEVEL=DEBUG` in `.env` to enable:
|
|
|
500
535
|
```bash
|
|
501
536
|
tina4ruby init [dir] # Scaffold a new project
|
|
502
537
|
tina4ruby serve [port] # Start dev server (default: 7147)
|
|
538
|
+
tina4ruby serve --production # Auto-install and use Puma production server
|
|
503
539
|
tina4ruby migrate # Run pending migrations
|
|
504
540
|
tina4ruby migrate --create <desc># Create a migration file
|
|
505
541
|
tina4ruby migrate --rollback # Rollback last batch
|
|
542
|
+
tina4ruby generate model <name> # Generate ORM model scaffold
|
|
543
|
+
tina4ruby generate route <name> # Generate route scaffold
|
|
544
|
+
tina4ruby generate migration <d> # Generate migration file
|
|
545
|
+
tina4ruby generate middleware <n># Generate middleware scaffold
|
|
506
546
|
tina4ruby seed # Run seeders from src/seeds/
|
|
507
547
|
tina4ruby routes # List all registered routes
|
|
508
548
|
tina4ruby test # Run test suite
|
|
@@ -510,6 +550,53 @@ tina4ruby build # Build distributable gem
|
|
|
510
550
|
tina4ruby ai [--all] # Detect AI tools and install context
|
|
511
551
|
```
|
|
512
552
|
|
|
553
|
+
### Production Server Auto-Detection
|
|
554
|
+
|
|
555
|
+
`tina4 serve` automatically detects and uses the best available production server:
|
|
556
|
+
|
|
557
|
+
- **Ruby**: Puma (if installed), otherwise WEBrick -- Puma gives 2.8x improvement
|
|
558
|
+
- Use `tina4ruby serve --production` to auto-install Puma
|
|
559
|
+
|
|
560
|
+
### Scaffolding with `tina4 generate`
|
|
561
|
+
|
|
562
|
+
Quickly scaffold new components:
|
|
563
|
+
|
|
564
|
+
```bash
|
|
565
|
+
tina4ruby generate model User # Creates src/orm/user.rb with field stubs
|
|
566
|
+
tina4ruby generate route users # Creates src/routes/users.rb with CRUD stubs
|
|
567
|
+
tina4ruby generate migration "add age" # Creates migration SQL file
|
|
568
|
+
tina4ruby generate middleware AuthLog # Creates middleware class
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### ORM Relationships & Eager Loading
|
|
572
|
+
|
|
573
|
+
```ruby
|
|
574
|
+
# Relationships
|
|
575
|
+
orders = user.has_many("Order", "user_id")
|
|
576
|
+
profile = user.has_one("Profile", "user_id")
|
|
577
|
+
customer = order.belongs_to("Customer", "customer_id")
|
|
578
|
+
|
|
579
|
+
# Eager loading with include:
|
|
580
|
+
users = User.all(include: ["orders", "profile"])
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### DB Query Caching
|
|
584
|
+
|
|
585
|
+
Enable query caching for up to 4x speedup on read-heavy workloads:
|
|
586
|
+
|
|
587
|
+
```bash
|
|
588
|
+
# .env
|
|
589
|
+
TINA4_DB_CACHE=true
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
### Frond Pre-Compilation
|
|
593
|
+
|
|
594
|
+
Templates are pre-compiled for 2.8x faster rendering.
|
|
595
|
+
|
|
596
|
+
### Gallery
|
|
597
|
+
|
|
598
|
+
7 interactive examples with **Try It** deploy.
|
|
599
|
+
|
|
513
600
|
## Environment
|
|
514
601
|
|
|
515
602
|
```bash
|
|
@@ -517,8 +604,9 @@ SECRET=your-jwt-secret
|
|
|
517
604
|
DATABASE_URL=sqlite://data/app.db
|
|
518
605
|
DATABASE_USERNAME=
|
|
519
606
|
DATABASE_PASSWORD=
|
|
520
|
-
|
|
521
|
-
|
|
607
|
+
TINA4_DEBUG=true # Enable dev toolbar, error overlay
|
|
608
|
+
TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
|
|
609
|
+
TINA4_LOCALE=en # en, fr, af, zh, ja, es
|
|
522
610
|
TINA4_SESSION_HANDLER=SessionFileHandler
|
|
523
611
|
SWAGGER_TITLE=My API
|
|
524
612
|
```
|
data/lib/tina4/auth.rb
CHANGED
|
@@ -12,27 +12,130 @@ module Tina4
|
|
|
12
12
|
def setup(root_dir = Dir.pwd)
|
|
13
13
|
@keys_dir = File.join(root_dir, KEYS_DIR)
|
|
14
14
|
FileUtils.mkdir_p(@keys_dir)
|
|
15
|
-
ensure_keys
|
|
15
|
+
ensure_keys unless use_hmac?
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
# ── HS256 helpers (stdlib only, no gem) ──────────────────────
|
|
19
|
+
|
|
20
|
+
# Returns true when SECRET env var is set and no RSA keys exist in .keys/
|
|
21
|
+
def use_hmac?
|
|
22
|
+
secret = ENV["SECRET"]
|
|
23
|
+
return false if secret.nil? || secret.empty?
|
|
24
|
+
|
|
25
|
+
# If RSA keys already exist on disk, prefer RS256 for backward compat
|
|
26
|
+
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
27
|
+
!(File.exist?(File.join(@keys_dir, "private.pem")) &&
|
|
28
|
+
File.exist?(File.join(@keys_dir, "public.pem")))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def hmac_secret
|
|
32
|
+
ENV["SECRET"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Base64url-encode without padding (JWT spec)
|
|
36
|
+
def base64url_encode(data)
|
|
37
|
+
Base64.urlsafe_encode64(data, padding: false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Base64url-decode (handles missing padding)
|
|
41
|
+
def base64url_decode(str)
|
|
42
|
+
# Add back padding
|
|
43
|
+
remainder = str.length % 4
|
|
44
|
+
str += "=" * ((4 - remainder) % 4) if remainder != 0
|
|
45
|
+
Base64.urlsafe_decode64(str)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build a JWT using HS256 with Ruby's OpenSSL::HMAC (no gem needed)
|
|
49
|
+
def hmac_encode(claims, secret)
|
|
50
|
+
header = { "alg" => "HS256", "typ" => "JWT" }
|
|
51
|
+
segments = [
|
|
52
|
+
base64url_encode(JSON.generate(header)),
|
|
53
|
+
base64url_encode(JSON.generate(claims))
|
|
54
|
+
]
|
|
55
|
+
signing_input = segments.join(".")
|
|
56
|
+
signature = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
|
|
57
|
+
segments << base64url_encode(signature)
|
|
58
|
+
segments.join(".")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Decode and verify a JWT signed with HS256. Returns the payload hash or nil.
|
|
62
|
+
def hmac_decode(token, secret)
|
|
63
|
+
parts = token.split(".")
|
|
64
|
+
return nil unless parts.length == 3
|
|
65
|
+
|
|
66
|
+
header_json = base64url_decode(parts[0])
|
|
67
|
+
header = JSON.parse(header_json)
|
|
68
|
+
return nil unless header["alg"] == "HS256"
|
|
69
|
+
|
|
70
|
+
# Verify signature
|
|
71
|
+
signing_input = "#{parts[0]}.#{parts[1]}"
|
|
72
|
+
expected_sig = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
|
|
73
|
+
actual_sig = base64url_decode(parts[2])
|
|
74
|
+
|
|
75
|
+
# Constant-time comparison to prevent timing attacks
|
|
76
|
+
return nil unless OpenSSL.fixed_length_secure_compare(expected_sig, actual_sig)
|
|
77
|
+
|
|
78
|
+
payload = JSON.parse(base64url_decode(parts[1]))
|
|
79
|
+
|
|
80
|
+
# Check expiry
|
|
81
|
+
now = Time.now.to_i
|
|
82
|
+
return nil if payload["exp"] && now >= payload["exp"]
|
|
83
|
+
return nil if payload["nbf"] && now < payload["nbf"]
|
|
84
|
+
|
|
85
|
+
payload
|
|
86
|
+
rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# ── Token API (auto-selects HS256 or RS256) ─────────────────
|
|
91
|
+
|
|
92
|
+
def get_token(payload, expires_in: 3600)
|
|
20
93
|
now = Time.now.to_i
|
|
21
94
|
claims = payload.merge(
|
|
22
95
|
"iat" => now,
|
|
23
96
|
"exp" => now + expires_in,
|
|
24
97
|
"nbf" => now
|
|
25
98
|
)
|
|
26
|
-
|
|
27
|
-
|
|
99
|
+
|
|
100
|
+
if use_hmac?
|
|
101
|
+
hmac_encode(claims, hmac_secret)
|
|
102
|
+
else
|
|
103
|
+
ensure_keys
|
|
104
|
+
require "jwt"
|
|
105
|
+
JWT.encode(claims, private_key, "RS256")
|
|
106
|
+
end
|
|
28
107
|
end
|
|
29
108
|
|
|
30
109
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
110
|
+
def valid_token(token)
|
|
111
|
+
if use_hmac?
|
|
112
|
+
hmac_decode(token, hmac_secret)
|
|
113
|
+
else
|
|
114
|
+
ensure_keys
|
|
115
|
+
require "jwt"
|
|
116
|
+
decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
117
|
+
decoded[0]
|
|
118
|
+
end
|
|
119
|
+
rescue JWT::ExpiredSignature
|
|
120
|
+
nil
|
|
121
|
+
rescue JWT::DecodeError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def valid_token_detail(token)
|
|
126
|
+
if use_hmac?
|
|
127
|
+
payload = hmac_decode(token, hmac_secret)
|
|
128
|
+
if payload
|
|
129
|
+
{ valid: true, payload: payload }
|
|
130
|
+
else
|
|
131
|
+
{ valid: false, error: "Invalid or expired token" }
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
ensure_keys
|
|
135
|
+
require "jwt"
|
|
136
|
+
decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
137
|
+
{ valid: true, payload: decoded[0] }
|
|
138
|
+
end
|
|
36
139
|
rescue JWT::ExpiredSignature
|
|
37
140
|
{ valid: false, error: "Token expired" }
|
|
38
141
|
rescue JWT::DecodeError => e
|
|
@@ -53,34 +156,36 @@ module Tina4
|
|
|
53
156
|
|
|
54
157
|
|
|
55
158
|
def get_payload(token)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
159
|
+
parts = token.split(".")
|
|
160
|
+
return nil unless parts.length == 3
|
|
161
|
+
|
|
162
|
+
payload_json = base64url_decode(parts[1])
|
|
163
|
+
JSON.parse(payload_json)
|
|
164
|
+
rescue ArgumentError, JSON::ParserError
|
|
60
165
|
nil
|
|
61
166
|
end
|
|
62
167
|
|
|
63
168
|
def refresh_token(token, expires_in: 3600)
|
|
64
|
-
|
|
65
|
-
return nil unless
|
|
169
|
+
payload = valid_token(token)
|
|
170
|
+
return nil unless payload
|
|
66
171
|
|
|
67
|
-
payload =
|
|
68
|
-
|
|
172
|
+
payload = payload.reject { |k, _| %w[iat exp nbf].include?(k) }
|
|
173
|
+
get_token(payload, expires_in: expires_in)
|
|
69
174
|
end
|
|
70
175
|
|
|
71
176
|
def authenticate_request(headers)
|
|
72
177
|
auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
|
|
73
|
-
return
|
|
178
|
+
return nil unless auth_header =~ /\ABearer\s+(.+)\z/i
|
|
74
179
|
|
|
75
180
|
token = Regexp.last_match(1)
|
|
76
181
|
|
|
77
182
|
# API_KEY bypass — matches tina4_python behavior
|
|
78
183
|
api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
79
184
|
if api_key && !api_key.empty? && token == api_key
|
|
80
|
-
return {
|
|
185
|
+
return { "api_key" => true }
|
|
81
186
|
end
|
|
82
187
|
|
|
83
|
-
|
|
188
|
+
valid_token(token)
|
|
84
189
|
end
|
|
85
190
|
|
|
86
191
|
def validate_api_key(provided, expected: nil)
|
|
@@ -107,15 +212,15 @@ module Tina4
|
|
|
107
212
|
token = Regexp.last_match(1)
|
|
108
213
|
|
|
109
214
|
# API_KEY bypass — matches tina4_python behavior
|
|
110
|
-
api_key = ENV["API_KEY"]
|
|
215
|
+
api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
111
216
|
if api_key && !api_key.empty? && token == api_key
|
|
112
217
|
env["tina4.auth"] = { "api_key" => true }
|
|
113
218
|
return true
|
|
114
219
|
end
|
|
115
220
|
|
|
116
|
-
|
|
117
|
-
if
|
|
118
|
-
env["tina4.auth"] =
|
|
221
|
+
payload = valid_token(token)
|
|
222
|
+
if payload
|
|
223
|
+
env["tina4.auth"] = payload
|
|
119
224
|
true
|
|
120
225
|
else
|
|
121
226
|
false
|
|
@@ -129,6 +234,10 @@ module Tina4
|
|
|
129
234
|
@default_secure_auth ||= bearer_auth
|
|
130
235
|
end
|
|
131
236
|
|
|
237
|
+
# Legacy aliases
|
|
238
|
+
alias_method :create_token, :get_token
|
|
239
|
+
alias_method :validate_token, :valid_token_detail
|
|
240
|
+
|
|
132
241
|
def private_key
|
|
133
242
|
@private_key ||= OpenSSL::PKey::RSA.new(File.read(private_key_path))
|
|
134
243
|
end
|
|
@@ -140,6 +249,8 @@ module Tina4
|
|
|
140
249
|
private
|
|
141
250
|
|
|
142
251
|
def ensure_keys
|
|
252
|
+
return if use_hmac?
|
|
253
|
+
|
|
143
254
|
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
144
255
|
FileUtils.mkdir_p(@keys_dir)
|
|
145
256
|
unless File.exist?(private_key_path) && File.exist?(public_key_path)
|
|
@@ -169,8 +280,7 @@ module Tina4
|
|
|
169
280
|
return true if auth_header.empty?
|
|
170
281
|
|
|
171
282
|
if auth_header =~ /\ABearer\s+(.+)\z/i
|
|
172
|
-
|
|
173
|
-
result[:valid]
|
|
283
|
+
!valid_token(Regexp.last_match(1)).nil?
|
|
174
284
|
else
|
|
175
285
|
false
|
|
176
286
|
end
|
data/lib/tina4/auto_crud.rb
CHANGED
|
@@ -21,10 +21,36 @@ module Tina4
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Build a sample request body from ORM field definitions.
|
|
25
|
+
def build_example(model_class)
|
|
26
|
+
example = {}
|
|
27
|
+
return example unless model_class.respond_to?(:field_definitions)
|
|
28
|
+
|
|
29
|
+
model_class.field_definitions.each do |name, opts|
|
|
30
|
+
next if opts[:primary_key] && opts[:auto_increment]
|
|
31
|
+
|
|
32
|
+
case opts[:type]
|
|
33
|
+
when :integer
|
|
34
|
+
example[name.to_s] = 0
|
|
35
|
+
when :numeric, :float, :decimal
|
|
36
|
+
example[name.to_s] = 0.0
|
|
37
|
+
when :boolean
|
|
38
|
+
example[name.to_s] = true
|
|
39
|
+
when :datetime
|
|
40
|
+
example[name.to_s] = "2024-01-01T00:00:00"
|
|
41
|
+
else
|
|
42
|
+
example[name.to_s] = "string"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
example
|
|
46
|
+
end
|
|
47
|
+
|
|
24
48
|
# Generate REST endpoints for a single model class
|
|
25
49
|
def generate_routes_for(model_class, prefix: "/api")
|
|
26
50
|
table = model_class.table_name
|
|
27
51
|
pk = model_class.primary_key_field || :id
|
|
52
|
+
pretty_name = table.to_s.split("_").map(&:capitalize).join(" ")
|
|
53
|
+
example_body = build_example(model_class)
|
|
28
54
|
|
|
29
55
|
# GET /api/{table} -- list all with pagination, filtering, sorting
|
|
30
56
|
Tina4::Router.add_route("GET", "#{prefix}/#{table}", proc { |req, res|
|
|
@@ -63,7 +89,7 @@ module Tina4
|
|
|
63
89
|
rescue => e
|
|
64
90
|
res.json({ error: e.message }, status: 500)
|
|
65
91
|
end
|
|
66
|
-
})
|
|
92
|
+
}, swagger_meta: { summary: "List all #{pretty_name}", tags: [table.to_s] })
|
|
67
93
|
|
|
68
94
|
# GET /api/{table}/{id} -- get single record
|
|
69
95
|
Tina4::Router.add_route("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
@@ -78,7 +104,7 @@ module Tina4
|
|
|
78
104
|
rescue => e
|
|
79
105
|
res.json({ error: e.message }, status: 500)
|
|
80
106
|
end
|
|
81
|
-
})
|
|
107
|
+
}, swagger_meta: { summary: "Get #{pretty_name} by ID", tags: [table.to_s] })
|
|
82
108
|
|
|
83
109
|
# POST /api/{table} -- create record
|
|
84
110
|
Tina4::Router.add_route("POST", "#{prefix}/#{table}", proc { |req, res|
|
|
@@ -93,6 +119,19 @@ module Tina4
|
|
|
93
119
|
rescue => e
|
|
94
120
|
res.json({ error: e.message }, status: 500)
|
|
95
121
|
end
|
|
122
|
+
}, swagger_meta: {
|
|
123
|
+
summary: "Create #{pretty_name}",
|
|
124
|
+
tags: [table.to_s],
|
|
125
|
+
request_body: {
|
|
126
|
+
"description" => "#{pretty_name} data",
|
|
127
|
+
"required" => true,
|
|
128
|
+
"content" => {
|
|
129
|
+
"application/json" => {
|
|
130
|
+
"schema" => { "type" => "object" },
|
|
131
|
+
"example" => example_body
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
96
135
|
})
|
|
97
136
|
|
|
98
137
|
# PUT /api/{table}/{id} -- update record
|
|
@@ -118,6 +157,19 @@ module Tina4
|
|
|
118
157
|
rescue => e
|
|
119
158
|
res.json({ error: e.message }, status: 500)
|
|
120
159
|
end
|
|
160
|
+
}, swagger_meta: {
|
|
161
|
+
summary: "Update #{pretty_name}",
|
|
162
|
+
tags: [table.to_s],
|
|
163
|
+
request_body: {
|
|
164
|
+
"description" => "#{pretty_name} data",
|
|
165
|
+
"required" => true,
|
|
166
|
+
"content" => {
|
|
167
|
+
"application/json" => {
|
|
168
|
+
"schema" => { "type" => "object" },
|
|
169
|
+
"example" => example_body
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
121
173
|
})
|
|
122
174
|
|
|
123
175
|
# DELETE /api/{table}/{id} -- delete record
|
|
@@ -137,7 +189,7 @@ module Tina4
|
|
|
137
189
|
rescue => e
|
|
138
190
|
res.json({ error: e.message }, status: 500)
|
|
139
191
|
end
|
|
140
|
-
})
|
|
192
|
+
}, swagger_meta: { summary: "Delete #{pretty_name}", tags: [table.to_s] })
|
|
141
193
|
end
|
|
142
194
|
|
|
143
195
|
def clear!
|