tina4ruby 0.5.2 → 3.2.1
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 +434 -544
- 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 +389 -97
- 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 +144 -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 +1497 -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 +325 -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 +562 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +463 -35
- 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 +162 -6
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +331 -27
- 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 +551 -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 +118 -21
- metadata +68 -8
- data/lib/tina4/public/js/tina4.js +0 -134
- data/lib/tina4/public/js/tina4helper.js +0 -387
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
# Multi-backend response cache for GET requests.
|
|
5
|
+
#
|
|
6
|
+
# Backends are selected via the TINA4_CACHE_BACKEND env var:
|
|
7
|
+
# memory — in-process LRU cache (default, zero deps)
|
|
8
|
+
# redis — Redis / Valkey (uses `redis` gem or raw RESP over TCP)
|
|
9
|
+
# file — JSON files in data/cache/
|
|
10
|
+
#
|
|
11
|
+
# Environment:
|
|
12
|
+
# TINA4_CACHE_BACKEND — memory | redis | file (default: memory)
|
|
13
|
+
# TINA4_CACHE_URL — redis://localhost:6379 (redis only)
|
|
14
|
+
# TINA4_CACHE_TTL — default TTL in seconds (default: 0 = disabled)
|
|
15
|
+
# TINA4_CACHE_MAX_ENTRIES — maximum cache entries (default: 1000)
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# cache = Tina4::ResponseCache.new(ttl: 60, max_entries: 1000)
|
|
19
|
+
# cache.cache_response("GET", "/api/users", 200, "application/json", '{"users":[]}')
|
|
20
|
+
# hit = cache.get("GET", "/api/users")
|
|
21
|
+
#
|
|
22
|
+
# # Direct API (same across all 4 languages)
|
|
23
|
+
# cache.cache_set("key", {"data" => "value"}, ttl: 120)
|
|
24
|
+
# value = cache.cache_get("key")
|
|
25
|
+
# cache.cache_delete("key")
|
|
26
|
+
#
|
|
27
|
+
class ResponseCache
|
|
28
|
+
CacheEntry = Struct.new(:body, :content_type, :status_code, :expires_at)
|
|
29
|
+
|
|
30
|
+
# @param ttl [Integer] default TTL in seconds (0 = disabled)
|
|
31
|
+
# @param max_entries [Integer] maximum cache entries
|
|
32
|
+
# @param status_codes [Array<Integer>] only cache these status codes
|
|
33
|
+
# @param backend [String, nil] cache backend: memory|redis|file
|
|
34
|
+
# @param cache_url [String, nil] Redis URL
|
|
35
|
+
# @param cache_dir [String, nil] File cache directory
|
|
36
|
+
def initialize(ttl: nil, max_entries: nil, status_codes: [200],
|
|
37
|
+
backend: nil, cache_url: nil, cache_dir: nil)
|
|
38
|
+
@ttl = ttl || (ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 0)
|
|
39
|
+
@max_entries = max_entries || (ENV["TINA4_CACHE_MAX_ENTRIES"] ? ENV["TINA4_CACHE_MAX_ENTRIES"].to_i : 1000)
|
|
40
|
+
@status_codes = status_codes
|
|
41
|
+
@backend_name = backend || ENV.fetch("TINA4_CACHE_BACKEND", "memory").downcase.strip
|
|
42
|
+
@cache_url = cache_url || ENV.fetch("TINA4_CACHE_URL", "redis://localhost:6379")
|
|
43
|
+
@cache_dir = cache_dir || ENV.fetch("TINA4_CACHE_DIR", "data/cache")
|
|
44
|
+
@store = {}
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
@hits = 0
|
|
47
|
+
@misses = 0
|
|
48
|
+
|
|
49
|
+
# Initialize backend
|
|
50
|
+
init_backend
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check if caching is enabled.
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def enabled?
|
|
57
|
+
@ttl > 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Build a cache key from method and URL.
|
|
61
|
+
#
|
|
62
|
+
# @param method [String]
|
|
63
|
+
# @param url [String]
|
|
64
|
+
# @return [String]
|
|
65
|
+
def cache_key(method, url)
|
|
66
|
+
"#{method}:#{url}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Retrieve a cached response. Returns nil on miss or expired entry.
|
|
70
|
+
#
|
|
71
|
+
# @param method [String]
|
|
72
|
+
# @param url [String]
|
|
73
|
+
# @return [CacheEntry, nil]
|
|
74
|
+
def get(method, url)
|
|
75
|
+
return nil unless enabled?
|
|
76
|
+
return nil unless method == "GET"
|
|
77
|
+
|
|
78
|
+
key = cache_key(method, url)
|
|
79
|
+
entry = backend_get(key)
|
|
80
|
+
|
|
81
|
+
if entry.nil?
|
|
82
|
+
@mutex.synchronize { @misses += 1 }
|
|
83
|
+
return nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# For memory backend, entry is a CacheEntry; for others, reconstruct
|
|
87
|
+
if entry.is_a?(CacheEntry)
|
|
88
|
+
if Time.now.to_f > entry.expires_at
|
|
89
|
+
backend_delete(key)
|
|
90
|
+
@mutex.synchronize { @misses += 1 }
|
|
91
|
+
return nil
|
|
92
|
+
end
|
|
93
|
+
@mutex.synchronize { @hits += 1 }
|
|
94
|
+
entry
|
|
95
|
+
elsif entry.is_a?(Hash)
|
|
96
|
+
expires_at = entry["expires_at"] || entry[:expires_at] || 0
|
|
97
|
+
if Time.now.to_f > expires_at
|
|
98
|
+
backend_delete(key)
|
|
99
|
+
@mutex.synchronize { @misses += 1 }
|
|
100
|
+
return nil
|
|
101
|
+
end
|
|
102
|
+
@mutex.synchronize { @hits += 1 }
|
|
103
|
+
CacheEntry.new(
|
|
104
|
+
entry["body"] || entry[:body],
|
|
105
|
+
entry["content_type"] || entry[:content_type],
|
|
106
|
+
entry["status_code"] || entry[:status_code],
|
|
107
|
+
expires_at
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Store a response in the cache.
|
|
113
|
+
#
|
|
114
|
+
# @param method [String]
|
|
115
|
+
# @param url [String]
|
|
116
|
+
# @param status_code [Integer]
|
|
117
|
+
# @param content_type [String]
|
|
118
|
+
# @param body [String]
|
|
119
|
+
# @param ttl [Integer, nil] override default TTL
|
|
120
|
+
def cache_response(method, url, status_code, content_type, body, ttl: nil)
|
|
121
|
+
return unless enabled?
|
|
122
|
+
return unless method == "GET"
|
|
123
|
+
return unless @status_codes.include?(status_code)
|
|
124
|
+
|
|
125
|
+
effective_ttl = ttl || @ttl
|
|
126
|
+
key = cache_key(method, url)
|
|
127
|
+
expires_at = Time.now.to_f + effective_ttl
|
|
128
|
+
|
|
129
|
+
entry_data = {
|
|
130
|
+
"body" => body,
|
|
131
|
+
"content_type" => content_type,
|
|
132
|
+
"status_code" => status_code,
|
|
133
|
+
"expires_at" => expires_at
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case @backend_name
|
|
137
|
+
when "memory"
|
|
138
|
+
@mutex.synchronize do
|
|
139
|
+
if @store.size >= @max_entries && !@store.key?(key)
|
|
140
|
+
oldest_key = @store.keys.first
|
|
141
|
+
@store.delete(oldest_key)
|
|
142
|
+
end
|
|
143
|
+
@store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
|
|
144
|
+
end
|
|
145
|
+
else
|
|
146
|
+
backend_set(key, entry_data, effective_ttl)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# ── Direct Cache API (same across all 4 languages) ──────────
|
|
151
|
+
|
|
152
|
+
# Get a value from the cache by key.
|
|
153
|
+
#
|
|
154
|
+
# @param key [String]
|
|
155
|
+
# @return [Object, nil]
|
|
156
|
+
def cache_get(key)
|
|
157
|
+
full_key = "direct:#{key}"
|
|
158
|
+
raw = backend_get(full_key)
|
|
159
|
+
|
|
160
|
+
if raw.nil?
|
|
161
|
+
@mutex.synchronize { @misses += 1 }
|
|
162
|
+
return nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
if raw.is_a?(Hash)
|
|
166
|
+
expires_at = raw["expires_at"] || raw[:expires_at] || 0
|
|
167
|
+
if expires_at > 0 && Time.now.to_f > expires_at
|
|
168
|
+
backend_delete(full_key)
|
|
169
|
+
@mutex.synchronize { @misses += 1 }
|
|
170
|
+
return nil
|
|
171
|
+
end
|
|
172
|
+
@mutex.synchronize { @hits += 1 }
|
|
173
|
+
raw["value"] || raw[:value]
|
|
174
|
+
else
|
|
175
|
+
@mutex.synchronize { @hits += 1 }
|
|
176
|
+
raw
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Store a value in the cache with optional TTL.
|
|
181
|
+
#
|
|
182
|
+
# @param key [String]
|
|
183
|
+
# @param value [Object]
|
|
184
|
+
# @param ttl [Integer] TTL in seconds (0 uses default)
|
|
185
|
+
def cache_set(key, value, ttl: 0)
|
|
186
|
+
effective_ttl = ttl > 0 ? ttl : @ttl
|
|
187
|
+
effective_ttl = 60 if effective_ttl <= 0 # fallback for direct API
|
|
188
|
+
full_key = "direct:#{key}"
|
|
189
|
+
entry = {
|
|
190
|
+
"value" => value,
|
|
191
|
+
"expires_at" => Time.now.to_f + effective_ttl
|
|
192
|
+
}
|
|
193
|
+
backend_set(full_key, entry, effective_ttl)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Delete a key from the cache.
|
|
197
|
+
#
|
|
198
|
+
# @param key [String]
|
|
199
|
+
# @return [Boolean]
|
|
200
|
+
def cache_delete(key)
|
|
201
|
+
backend_delete("direct:#{key}")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Get cache statistics.
|
|
205
|
+
#
|
|
206
|
+
# @return [Hash] with :hits, :misses, :size, :backend, :keys
|
|
207
|
+
def cache_stats
|
|
208
|
+
@mutex.synchronize do
|
|
209
|
+
case @backend_name
|
|
210
|
+
when "memory"
|
|
211
|
+
now = Time.now.to_f
|
|
212
|
+
@store.reject! { |_k, v| v.is_a?(CacheEntry) && now > v.expires_at }
|
|
213
|
+
{ hits: @hits, misses: @misses, size: @store.size, backend: @backend_name, keys: @store.keys.dup }
|
|
214
|
+
when "file"
|
|
215
|
+
sweep
|
|
216
|
+
files = Dir.glob(File.join(@cache_dir, "*.json"))
|
|
217
|
+
{ hits: @hits, misses: @misses, size: files.size, backend: @backend_name, keys: [] }
|
|
218
|
+
when "redis"
|
|
219
|
+
size = 0
|
|
220
|
+
if @redis_client
|
|
221
|
+
begin
|
|
222
|
+
keys = @redis_client.keys("tina4:cache:*")
|
|
223
|
+
size = keys.size
|
|
224
|
+
rescue StandardError
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
{ hits: @hits, misses: @misses, size: size, backend: @backend_name, keys: [] }
|
|
228
|
+
else
|
|
229
|
+
{ hits: @hits, misses: @misses, size: @store.size, backend: @backend_name, keys: @store.keys.dup }
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Clear all cached responses.
|
|
235
|
+
def clear_cache
|
|
236
|
+
@mutex.synchronize do
|
|
237
|
+
@hits = 0
|
|
238
|
+
@misses = 0
|
|
239
|
+
|
|
240
|
+
case @backend_name
|
|
241
|
+
when "memory"
|
|
242
|
+
@store.clear
|
|
243
|
+
when "file"
|
|
244
|
+
Dir.glob(File.join(@cache_dir, "*.json")).each { |f| File.delete(f) rescue nil }
|
|
245
|
+
when "redis"
|
|
246
|
+
if @redis_client
|
|
247
|
+
begin
|
|
248
|
+
keys = @redis_client.keys("tina4:cache:*")
|
|
249
|
+
@redis_client.del(*keys) unless keys.empty?
|
|
250
|
+
rescue StandardError
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Remove expired entries.
|
|
258
|
+
#
|
|
259
|
+
# @return [Integer] number of entries removed
|
|
260
|
+
def sweep
|
|
261
|
+
case @backend_name
|
|
262
|
+
when "memory"
|
|
263
|
+
@mutex.synchronize do
|
|
264
|
+
now = Time.now.to_f
|
|
265
|
+
keys_to_remove = @store.select { |_k, v| v.is_a?(CacheEntry) && now > v.expires_at }.keys
|
|
266
|
+
keys_to_remove += @store.select { |_k, v| v.is_a?(Hash) && (v["expires_at"] || 0) > 0 && now > (v["expires_at"] || 0) }.keys
|
|
267
|
+
keys_to_remove.each { |k| @store.delete(k) }
|
|
268
|
+
keys_to_remove.size
|
|
269
|
+
end
|
|
270
|
+
when "file"
|
|
271
|
+
removed = 0
|
|
272
|
+
now = Time.now.to_f
|
|
273
|
+
Dir.glob(File.join(@cache_dir, "*.json")).each do |f|
|
|
274
|
+
begin
|
|
275
|
+
data = JSON.parse(File.read(f))
|
|
276
|
+
if data["expires_at"] && now > data["expires_at"]
|
|
277
|
+
File.delete(f)
|
|
278
|
+
removed += 1
|
|
279
|
+
end
|
|
280
|
+
rescue StandardError
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
removed
|
|
284
|
+
else
|
|
285
|
+
0
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Current TTL setting.
|
|
290
|
+
attr_reader :ttl
|
|
291
|
+
|
|
292
|
+
# Maximum entries setting.
|
|
293
|
+
attr_reader :max_entries
|
|
294
|
+
|
|
295
|
+
# Active backend name.
|
|
296
|
+
def backend_name
|
|
297
|
+
@backend_name
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
private
|
|
301
|
+
|
|
302
|
+
# ── Backend initialization ─────────────────────────────────
|
|
303
|
+
|
|
304
|
+
def init_backend
|
|
305
|
+
case @backend_name
|
|
306
|
+
when "redis"
|
|
307
|
+
init_redis
|
|
308
|
+
when "file"
|
|
309
|
+
init_file_dir
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def init_redis
|
|
314
|
+
@redis_client = nil
|
|
315
|
+
begin
|
|
316
|
+
require "redis"
|
|
317
|
+
parsed = parse_redis_url(@cache_url)
|
|
318
|
+
@redis_client = Redis.new(host: parsed[:host], port: parsed[:port], db: parsed[:db], timeout: 5)
|
|
319
|
+
@redis_client.ping
|
|
320
|
+
rescue LoadError, StandardError
|
|
321
|
+
@redis_client = nil
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def parse_redis_url(url)
|
|
326
|
+
cleaned = url.sub(%r{^redis://}, "")
|
|
327
|
+
parts = cleaned.split(":")
|
|
328
|
+
host = parts[0].empty? ? "localhost" : parts[0]
|
|
329
|
+
port_and_db = parts[1] ? parts[1].split("/") : ["6379"]
|
|
330
|
+
port = port_and_db[0].to_i
|
|
331
|
+
port = 6379 if port == 0
|
|
332
|
+
db = port_and_db[1] ? port_and_db[1].to_i : 0
|
|
333
|
+
{ host: host, port: port, db: db }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def init_file_dir
|
|
337
|
+
require "json"
|
|
338
|
+
require "fileutils"
|
|
339
|
+
FileUtils.mkdir_p(@cache_dir)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# ── Backend operations ─────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
def backend_get(key)
|
|
345
|
+
case @backend_name
|
|
346
|
+
when "memory"
|
|
347
|
+
@mutex.synchronize { @store[key] }
|
|
348
|
+
when "redis"
|
|
349
|
+
redis_get(key)
|
|
350
|
+
when "file"
|
|
351
|
+
file_get(key)
|
|
352
|
+
else
|
|
353
|
+
@mutex.synchronize { @store[key] }
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def backend_set(key, entry, ttl)
|
|
358
|
+
case @backend_name
|
|
359
|
+
when "memory"
|
|
360
|
+
@mutex.synchronize do
|
|
361
|
+
if @store.size >= @max_entries && !@store.key?(key)
|
|
362
|
+
oldest_key = @store.keys.first
|
|
363
|
+
@store.delete(oldest_key)
|
|
364
|
+
end
|
|
365
|
+
@store[key] = entry
|
|
366
|
+
end
|
|
367
|
+
when "redis"
|
|
368
|
+
redis_set(key, entry, ttl)
|
|
369
|
+
when "file"
|
|
370
|
+
file_set(key, entry)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def backend_delete(key)
|
|
375
|
+
case @backend_name
|
|
376
|
+
when "memory"
|
|
377
|
+
@mutex.synchronize do
|
|
378
|
+
!@store.delete(key).nil?
|
|
379
|
+
end
|
|
380
|
+
when "redis"
|
|
381
|
+
redis_delete(key)
|
|
382
|
+
when "file"
|
|
383
|
+
file_delete(key)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# ── Redis operations ───────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
def redis_get(key)
|
|
390
|
+
full_key = "tina4:cache:#{key}"
|
|
391
|
+
if @redis_client
|
|
392
|
+
begin
|
|
393
|
+
raw = @redis_client.get(full_key)
|
|
394
|
+
return nil if raw.nil?
|
|
395
|
+
JSON.parse(raw)
|
|
396
|
+
rescue StandardError
|
|
397
|
+
nil
|
|
398
|
+
end
|
|
399
|
+
else
|
|
400
|
+
resp_get(full_key)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def redis_set(key, entry, ttl)
|
|
405
|
+
full_key = "tina4:cache:#{key}"
|
|
406
|
+
serialized = JSON.generate(entry)
|
|
407
|
+
if @redis_client
|
|
408
|
+
begin
|
|
409
|
+
if ttl > 0
|
|
410
|
+
@redis_client.setex(full_key, ttl, serialized)
|
|
411
|
+
else
|
|
412
|
+
@redis_client.set(full_key, serialized)
|
|
413
|
+
end
|
|
414
|
+
rescue StandardError
|
|
415
|
+
end
|
|
416
|
+
else
|
|
417
|
+
if ttl > 0
|
|
418
|
+
resp_command("SETEX", full_key, ttl.to_s, serialized)
|
|
419
|
+
else
|
|
420
|
+
resp_command("SET", full_key, serialized)
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def redis_delete(key)
|
|
426
|
+
full_key = "tina4:cache:#{key}"
|
|
427
|
+
if @redis_client
|
|
428
|
+
begin
|
|
429
|
+
@redis_client.del(full_key) > 0
|
|
430
|
+
rescue StandardError
|
|
431
|
+
false
|
|
432
|
+
end
|
|
433
|
+
else
|
|
434
|
+
result = resp_command("DEL", full_key)
|
|
435
|
+
result == "1"
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def resp_get(key)
|
|
440
|
+
result = resp_command("GET", key)
|
|
441
|
+
return nil if result.nil?
|
|
442
|
+
JSON.parse(result) rescue nil
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def resp_command(*args)
|
|
446
|
+
parsed = parse_redis_url(@cache_url)
|
|
447
|
+
cmd = "*#{args.size}\r\n"
|
|
448
|
+
args.each { |arg| s = arg.to_s; cmd += "$#{s.bytesize}\r\n#{s}\r\n" }
|
|
449
|
+
|
|
450
|
+
sock = TCPSocket.new(parsed[:host], parsed[:port])
|
|
451
|
+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack("l_2"))
|
|
452
|
+
if parsed[:db] > 0
|
|
453
|
+
select_cmd = "*2\r\n$6\r\nSELECT\r\n$#{parsed[:db].to_s.bytesize}\r\n#{parsed[:db]}\r\n"
|
|
454
|
+
sock.write(select_cmd)
|
|
455
|
+
sock.recv(1024)
|
|
456
|
+
end
|
|
457
|
+
sock.write(cmd)
|
|
458
|
+
response = sock.recv(65536)
|
|
459
|
+
sock.close
|
|
460
|
+
|
|
461
|
+
if response.start_with?("+")
|
|
462
|
+
response[1..].strip
|
|
463
|
+
elsif response.start_with?("$-1")
|
|
464
|
+
nil
|
|
465
|
+
elsif response.start_with?("$")
|
|
466
|
+
lines = response.split("\r\n")
|
|
467
|
+
lines[1]
|
|
468
|
+
elsif response.start_with?(":")
|
|
469
|
+
response[1..].strip
|
|
470
|
+
else
|
|
471
|
+
nil
|
|
472
|
+
end
|
|
473
|
+
rescue StandardError
|
|
474
|
+
nil
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# ── File operations ────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
def file_key_path(key)
|
|
480
|
+
require "digest"
|
|
481
|
+
safe = Digest::SHA256.hexdigest(key)
|
|
482
|
+
File.join(@cache_dir, "#{safe}.json")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def file_get(key)
|
|
486
|
+
path = file_key_path(key)
|
|
487
|
+
return nil unless File.exist?(path)
|
|
488
|
+
begin
|
|
489
|
+
data = JSON.parse(File.read(path))
|
|
490
|
+
if data["expires_at"] && Time.now.to_f > data["expires_at"]
|
|
491
|
+
File.delete(path) rescue nil
|
|
492
|
+
return nil
|
|
493
|
+
end
|
|
494
|
+
data
|
|
495
|
+
rescue StandardError
|
|
496
|
+
nil
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def file_set(key, entry)
|
|
501
|
+
init_file_dir
|
|
502
|
+
files = Dir.glob(File.join(@cache_dir, "*.json")).sort_by { |f| File.mtime(f) }
|
|
503
|
+
while files.size >= @max_entries
|
|
504
|
+
File.delete(files.shift) rescue nil
|
|
505
|
+
end
|
|
506
|
+
path = file_key_path(key)
|
|
507
|
+
File.write(path, JSON.generate(entry))
|
|
508
|
+
rescue StandardError
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def file_delete(key)
|
|
512
|
+
path = file_key_path(key)
|
|
513
|
+
if File.exist?(path)
|
|
514
|
+
File.delete(path) rescue nil
|
|
515
|
+
true
|
|
516
|
+
else
|
|
517
|
+
false
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# ── Module-level convenience (singleton) ───────────────────────
|
|
523
|
+
|
|
524
|
+
@default_cache = nil
|
|
525
|
+
|
|
526
|
+
class << self
|
|
527
|
+
def cache_instance
|
|
528
|
+
@default_cache ||= ResponseCache.new(ttl: ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 60)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def cache_get(key)
|
|
532
|
+
cache_instance.cache_get(key)
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def cache_set(key, value, ttl: 0)
|
|
536
|
+
cache_instance.cache_set(key, value, ttl: ttl)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def cache_delete(key)
|
|
540
|
+
cache_instance.cache_delete(key)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def cache_clear
|
|
544
|
+
cache_instance.clear_cache
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def cache_stats
|
|
548
|
+
cache_instance.cache_stats
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
end
|