pocketbase 0.1.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 +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +21 -0
- data/README.md +275 -0
- data/lib/pocketbase/auth_store.rb +224 -0
- data/lib/pocketbase/client.rb +390 -0
- data/lib/pocketbase/client_response_error.rb +96 -0
- data/lib/pocketbase/multipart.rb +139 -0
- data/lib/pocketbase/services/backup_service.rb +54 -0
- data/lib/pocketbase/services/base_service.rb +49 -0
- data/lib/pocketbase/services/batch_service.rb +143 -0
- data/lib/pocketbase/services/collection_service.rb +52 -0
- data/lib/pocketbase/services/cron_service.rb +21 -0
- data/lib/pocketbase/services/crud_service.rb +113 -0
- data/lib/pocketbase/services/file_service.rb +39 -0
- data/lib/pocketbase/services/health_service.rb +14 -0
- data/lib/pocketbase/services/log_service.rb +42 -0
- data/lib/pocketbase/services/realtime_service.rb +379 -0
- data/lib/pocketbase/services/record_service.rb +367 -0
- data/lib/pocketbase/services/settings_service.rb +50 -0
- data/lib/pocketbase/services/sql_service.rb +14 -0
- data/lib/pocketbase/utils.rb +146 -0
- data/lib/pocketbase/version.rb +5 -0
- data/lib/pocketbase.rb +35 -0
- metadata +72 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "thread"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module PocketBase
|
|
8
|
+
module Services
|
|
9
|
+
# Record/collection-auth service (port of js-sdk RecordService).
|
|
10
|
+
class RecordService < CrudService
|
|
11
|
+
attr_reader :collection_id_or_name
|
|
12
|
+
|
|
13
|
+
def initialize(client, collection_id_or_name)
|
|
14
|
+
super(client)
|
|
15
|
+
@collection_id_or_name = collection_id_or_name.to_s
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def base_collection_path
|
|
19
|
+
"/api/collections/#{Utils.encode_uri_component(@collection_id_or_name)}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def base_crud_path
|
|
23
|
+
"#{base_collection_path}/records"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Whether the service collection is the superusers collection.
|
|
27
|
+
def superusers?
|
|
28
|
+
@collection_id_or_name == "_superusers" || @collection_id_or_name == "_pbc_2773867675"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# --- Realtime ------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
# Subscribes to realtime changes of a topic ("*" or a record id).
|
|
34
|
+
# Returns an unsubscribe lambda.
|
|
35
|
+
#
|
|
36
|
+
# unsub = pb.collection("posts").subscribe("*") { |e| puts e["action"] }
|
|
37
|
+
# unsub.call
|
|
38
|
+
def subscribe(topic, options = {}, &callback)
|
|
39
|
+
raise ArgumentError, "topic must be set" if topic.nil? || topic.to_s.empty?
|
|
40
|
+
raise ArgumentError, "callback block required" unless callback
|
|
41
|
+
|
|
42
|
+
@client.realtime.subscribe("#{@collection_id_or_name}/#{topic}", options, &callback)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Unsubscribes from a topic, or from everything of this collection
|
|
46
|
+
# when no topic is given.
|
|
47
|
+
def unsubscribe(topic = nil)
|
|
48
|
+
if topic
|
|
49
|
+
@client.realtime.unsubscribe("#{@collection_id_or_name}/#{topic}")
|
|
50
|
+
else
|
|
51
|
+
@client.realtime.unsubscribe_by_prefix(@collection_id_or_name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- CRUD overrides (auth store syncing) ----------------------------
|
|
56
|
+
|
|
57
|
+
def update(id, body_params = nil, options = {})
|
|
58
|
+
item = super
|
|
59
|
+
store_record = @client.auth_store.record
|
|
60
|
+
if store_record.is_a?(Hash) && record_id(store_record) == item_id(item) &&
|
|
61
|
+
auth_record_collection?(store_record)
|
|
62
|
+
auth_expand = ((store_record["expand"] || store_record[:expand]) || {}).dup
|
|
63
|
+
auth_record = store_record.merge(item)
|
|
64
|
+
merged_expand = auth_expand.merge(item["expand"] || item[:expand] || {})
|
|
65
|
+
auth_record["expand"] = merged_expand unless merged_expand.empty? && auth_expand.empty?
|
|
66
|
+
@client.auth_store.save(@client.auth_store.token, auth_record)
|
|
67
|
+
end
|
|
68
|
+
item
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def delete(id, options = {})
|
|
72
|
+
success = super
|
|
73
|
+
store_record = @client.auth_store.record
|
|
74
|
+
if success && store_record.is_a?(Hash) && record_id(store_record) == id.to_s &&
|
|
75
|
+
auth_record_collection?(store_record)
|
|
76
|
+
@client.auth_store.clear
|
|
77
|
+
end
|
|
78
|
+
success
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- Auth -----------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
# Returns all available collection auth methods.
|
|
84
|
+
def list_auth_methods(options = {})
|
|
85
|
+
opts = send_options({ method: "GET", query: { "fields" => "mfa,otp,password,oauth2" } }, options)
|
|
86
|
+
@client.send_request("#{base_collection_path}/auth-methods", opts)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Authenticates with username/email + password. On success the client
|
|
90
|
+
# auth store is updated and a Hash with token, record, and meta is returned.
|
|
91
|
+
#
|
|
92
|
+
# For the _superusers collection you may pass
|
|
93
|
+
# `auto_refresh_threshold:` (seconds) to auto-refresh the token.
|
|
94
|
+
def auth_with_password(username_or_email, password, options = {})
|
|
95
|
+
auto_refresh_threshold = options.delete(:auto_refresh_threshold)
|
|
96
|
+
|
|
97
|
+
if superusers?
|
|
98
|
+
@client.reset_auto_refresh unless options[:autoRefresh] || options["autoRefresh"]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
opts = send_options(
|
|
102
|
+
{ method: "POST", body: { "identity" => username_or_email, "password" => password } },
|
|
103
|
+
options
|
|
104
|
+
)
|
|
105
|
+
auth_data = @client.send_request("#{base_collection_path}/auth-with-password", opts)
|
|
106
|
+
auth_data = auth_response(auth_data)
|
|
107
|
+
|
|
108
|
+
if auto_refresh_threshold && superusers?
|
|
109
|
+
@client.register_auto_refresh(
|
|
110
|
+
auto_refresh_threshold,
|
|
111
|
+
-> { auth_refresh(autoRefresh: true) },
|
|
112
|
+
-> { auth_with_password(username_or_email, password, options.merge(autoRefresh: true)) }
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
auth_data
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Authenticates with an OAuth2 code (see also #auth_with_oauth2 for the
|
|
120
|
+
# assisted flow and #oauth2_login_url for manual flows).
|
|
121
|
+
def auth_with_oauth2_code(provider, code, code_verifier, redirect_url, create_data = nil, options = {})
|
|
122
|
+
opts = send_options(
|
|
123
|
+
{
|
|
124
|
+
method: "POST",
|
|
125
|
+
body: {
|
|
126
|
+
"provider" => provider.to_s,
|
|
127
|
+
"code" => code,
|
|
128
|
+
"codeVerifier" => code_verifier,
|
|
129
|
+
"redirectURL" => redirect_url,
|
|
130
|
+
"createData" => create_data,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
options
|
|
134
|
+
)
|
|
135
|
+
auth_response(@client.send_request("#{base_collection_path}/auth-with-oauth2", opts))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Assisted OAuth2 sign-in: subscribes to the realtime @oauth2 channel,
|
|
139
|
+
# yields the provider sign-in URL to the given block (open it in a
|
|
140
|
+
# browser), waits for the provider redirect, then exchanges the code.
|
|
141
|
+
#
|
|
142
|
+
# auth_data = pb.collection("users").auth_with_oauth2(provider: "google") do |url|
|
|
143
|
+
# puts "Open this URL: #{url}"
|
|
144
|
+
# end
|
|
145
|
+
#
|
|
146
|
+
# Configure `https://yourdomain.com/api/oauth2-redirect` as the redirect
|
|
147
|
+
# URL in the provider dashboard.
|
|
148
|
+
def auth_with_oauth2(provider:, scopes: nil, create_data: nil, redirect_url: nil,
|
|
149
|
+
timeout: 300, **options, &url_callback)
|
|
150
|
+
raise ArgumentError, "a url_callback block is required" unless url_callback
|
|
151
|
+
|
|
152
|
+
info = find_oauth2_provider(provider)
|
|
153
|
+
redirect_url ||= @client.build_url("/api/oauth2-redirect")
|
|
154
|
+
|
|
155
|
+
queue = Queue.new
|
|
156
|
+
realtime = @client.realtime
|
|
157
|
+
unsub = realtime.subscribe("@oauth2") { |e| queue << e }
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
replacements = { "state" => realtime.client_id }
|
|
161
|
+
replacements["scope"] = Array(scopes).join(" ") if scopes && !Array(scopes).empty?
|
|
162
|
+
url = Utils.replace_query_params(info["authURL"].to_s + redirect_url.to_s, replacements)
|
|
163
|
+
url_callback.call(url)
|
|
164
|
+
|
|
165
|
+
event = Timeout.timeout(timeout) { queue.pop }
|
|
166
|
+
current_state = realtime.client_id
|
|
167
|
+
if event["state"].nil? || event["state"] != current_state
|
|
168
|
+
raise ClientResponseError.new("message" => "State parameters don't match.")
|
|
169
|
+
end
|
|
170
|
+
if event["error"] || !event["code"]
|
|
171
|
+
raise ClientResponseError.new("message" => "OAuth2 redirect error or missing code: #{event["error"]}")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
auth_with_oauth2_code(provider.to_s, event["code"], info["codeVerifier"],
|
|
175
|
+
redirect_url, create_data, options)
|
|
176
|
+
ensure
|
|
177
|
+
unsub.call
|
|
178
|
+
end
|
|
179
|
+
rescue Timeout::Error
|
|
180
|
+
raise ClientResponseError.new("message" => "OAuth2 flow timed out waiting for the provider redirect.")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Builds an OAuth2 sign-in URL for manual flows. Returns a Hash with
|
|
184
|
+
# "url", "state" and "code_verifier" — pass the code_verifier to
|
|
185
|
+
# #auth_with_oauth2_code once you have the provider code.
|
|
186
|
+
def oauth2_login_url(provider, redirect_url: nil, scopes: nil)
|
|
187
|
+
info = find_oauth2_provider(provider)
|
|
188
|
+
redirect_url ||= @client.build_url("/api/oauth2-redirect")
|
|
189
|
+
state = SecureRandom.hex(16)
|
|
190
|
+
replacements = { "state" => state }
|
|
191
|
+
replacements["scope"] = Array(scopes).join(" ") if scopes && !Array(scopes).empty?
|
|
192
|
+
{
|
|
193
|
+
"url" => Utils.replace_query_params(info["authURL"].to_s + redirect_url.to_s, replacements),
|
|
194
|
+
"state" => state,
|
|
195
|
+
"code_verifier" => info["codeVerifier"],
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Refreshes the current authenticated record, returning a new token.
|
|
200
|
+
def auth_refresh(options = {})
|
|
201
|
+
opts = send_options({ method: "POST" }, options)
|
|
202
|
+
auth_response(@client.send_request("#{base_collection_path}/auth-refresh", opts))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Sends a password reset email. Returns true on success.
|
|
206
|
+
def request_password_reset(email, options = {})
|
|
207
|
+
opts = send_options({ method: "POST", body: { "email" => email } }, options)
|
|
208
|
+
@client.send_request("#{base_collection_path}/request-password-reset", opts)
|
|
209
|
+
true
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Confirms a password reset. Returns true on success.
|
|
213
|
+
def confirm_password_reset(password_reset_token, password, password_confirm, options = {})
|
|
214
|
+
opts = send_options(
|
|
215
|
+
{ method: "POST",
|
|
216
|
+
body: { "token" => password_reset_token, "password" => password,
|
|
217
|
+
"passwordConfirm" => password_confirm } },
|
|
218
|
+
options
|
|
219
|
+
)
|
|
220
|
+
@client.send_request("#{base_collection_path}/confirm-password-reset", opts)
|
|
221
|
+
true
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Sends a verification email. Returns true on success.
|
|
225
|
+
def request_verification(email, options = {})
|
|
226
|
+
opts = send_options({ method: "POST", body: { "email" => email } }, options)
|
|
227
|
+
@client.send_request("#{base_collection_path}/request-verification", opts)
|
|
228
|
+
true
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Confirms an email verification. Updates the auth store verified flag
|
|
232
|
+
# when it matches the current auth record. Returns true on success.
|
|
233
|
+
def confirm_verification(verification_token, options = {})
|
|
234
|
+
opts = send_options({ method: "POST", body: { "token" => verification_token } }, options)
|
|
235
|
+
@client.send_request("#{base_collection_path}/confirm-verification", opts)
|
|
236
|
+
|
|
237
|
+
payload = Utils.token_payload(verification_token)
|
|
238
|
+
model = @client.auth_store.record
|
|
239
|
+
if model.is_a?(Hash) && !verified?(model) &&
|
|
240
|
+
record_id(model) == payload["id"] && record_collection_id(model) == payload["collectionId"]
|
|
241
|
+
model = model.merge("verified" => true)
|
|
242
|
+
@client.auth_store.save(@client.auth_store.token, model)
|
|
243
|
+
end
|
|
244
|
+
true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Sends an email change request to the authenticated record.
|
|
248
|
+
def request_email_change(new_email, options = {})
|
|
249
|
+
opts = send_options({ method: "POST", body: { "newEmail" => new_email } }, options)
|
|
250
|
+
@client.send_request("#{base_collection_path}/request-email-change", opts)
|
|
251
|
+
true
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Confirms a new email address. Clears the auth store when it matches
|
|
255
|
+
# the current auth record. Returns true on success.
|
|
256
|
+
def confirm_email_change(email_change_token, password, options = {})
|
|
257
|
+
opts = send_options(
|
|
258
|
+
{ method: "POST", body: { "token" => email_change_token, "password" => password } },
|
|
259
|
+
options
|
|
260
|
+
)
|
|
261
|
+
@client.send_request("#{base_collection_path}/confirm-email-change", opts)
|
|
262
|
+
|
|
263
|
+
payload = Utils.token_payload(email_change_token)
|
|
264
|
+
model = @client.auth_store.record
|
|
265
|
+
if model.is_a?(Hash) && record_id(model) == payload["id"] &&
|
|
266
|
+
record_collection_id(model) == payload["collectionId"]
|
|
267
|
+
@client.auth_store.clear
|
|
268
|
+
end
|
|
269
|
+
true
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Sends an OTP to the given email. Returns { "otpId" => ... }.
|
|
273
|
+
def request_otp(email, options = {})
|
|
274
|
+
opts = send_options({ method: "POST", body: { "email" => email } }, options)
|
|
275
|
+
@client.send_request("#{base_collection_path}/request-otp", opts)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Authenticates with an OTP id + password.
|
|
279
|
+
def auth_with_otp(otp_id, password, options = {})
|
|
280
|
+
opts = send_options(
|
|
281
|
+
{ method: "POST", body: { "otpId" => otp_id, "password" => password } },
|
|
282
|
+
options
|
|
283
|
+
)
|
|
284
|
+
auth_response(@client.send_request("#{base_collection_path}/auth-with-otp", opts))
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Impersonates a record (superusers only). Returns a NEW client loaded
|
|
288
|
+
# with the impersonated auth state.
|
|
289
|
+
def impersonate(record_id, duration = 0, options = {})
|
|
290
|
+
opts = send_options({ method: "POST", body: { "duration" => duration } }, options)
|
|
291
|
+
headers = (opts[:headers] || {}).dup
|
|
292
|
+
headers["Authorization"] ||= @client.auth_store.token
|
|
293
|
+
opts[:headers] = headers
|
|
294
|
+
|
|
295
|
+
client = Client.new(@client.base_url, lang: @client.lang)
|
|
296
|
+
auth_data = client.send_request(
|
|
297
|
+
"#{base_collection_path}/impersonate/#{Utils.encode_uri_component(record_id.to_s)}", opts
|
|
298
|
+
)
|
|
299
|
+
client.auth_store.save(auth_data["token"], decode(auth_data["record"] || {}))
|
|
300
|
+
client
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# @deprecated Use collection("_externalAuths") directly.
|
|
304
|
+
def list_external_auths(record_id, options = {})
|
|
305
|
+
@client.collection("_externalAuths").get_full_list(
|
|
306
|
+
options.merge(filter: @client.filter("recordRef = {:id}", id: record_id))
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# @deprecated Use collection("_externalAuths") directly.
|
|
311
|
+
def unlink_external_auth(record_id, provider, options = {})
|
|
312
|
+
ea = @client.collection("_externalAuths").get_first_list_item(
|
|
313
|
+
@client.filter("recordRef = {:recordId} && provider = {:provider}",
|
|
314
|
+
recordId: record_id, provider: provider)
|
|
315
|
+
)
|
|
316
|
+
@client.collection("_externalAuths").delete(ea["id"], options)
|
|
317
|
+
true
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private
|
|
321
|
+
|
|
322
|
+
def auth_response(response_data)
|
|
323
|
+
record = decode(response_data["record"] || response_data[:record] || {})
|
|
324
|
+
@client.auth_store.save(response_data["token"] || response_data[:token], record)
|
|
325
|
+
(response_data || {}).merge("token" => response_data["token"] || response_data[:token] || "",
|
|
326
|
+
"record" => record)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def find_oauth2_provider(provider)
|
|
330
|
+
methods = list_auth_methods
|
|
331
|
+
info = methods.dig("oauth2", "providers")&.find { |p| p["name"] == provider.to_s }
|
|
332
|
+
unless info
|
|
333
|
+
raise ClientResponseError.new("message" => %(Missing or invalid provider "#{provider}".))
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
info
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def auth_record_collection?(store_record)
|
|
340
|
+
[store_record["collectionId"], store_record["collectionName"],
|
|
341
|
+
store_record[:collectionId], store_record[:collectionName]]
|
|
342
|
+
.compact.map(&:to_s).include?(@collection_id_or_name)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def record_id(model)
|
|
346
|
+
(model["id"] || model[:id]).to_s
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def item_id(item)
|
|
350
|
+
((item["id"] || item[:id]) || "").to_s
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def record_collection(model)
|
|
354
|
+
model["collectionName"] || model[:collectionName] ||
|
|
355
|
+
model["collectionId"] || model[:collectionId]
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def record_collection_id(model)
|
|
359
|
+
model["collectionId"] || model[:collectionId]
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def verified?(model)
|
|
363
|
+
model["verified"] || model[:verified]
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PocketBase
|
|
4
|
+
module Services
|
|
5
|
+
# Settings service (port of js-sdk SettingsService).
|
|
6
|
+
class SettingsService < BaseService
|
|
7
|
+
# Fetches all available app settings.
|
|
8
|
+
def get_all(options = {})
|
|
9
|
+
opts = send_options({ method: "GET" }, options)
|
|
10
|
+
@client.send_request("/api/settings", opts)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Bulk updates app settings.
|
|
14
|
+
def update(body_params = nil, options = {})
|
|
15
|
+
opts = send_options({ method: "PATCH", body: body_params }, options)
|
|
16
|
+
@client.send_request("/api/settings", opts)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Performs an S3 filesystem connection test ("storage" or "backups").
|
|
20
|
+
def test_s3(filesystem = "storage", options = {})
|
|
21
|
+
opts = send_options({ method: "POST", body: { "filesystem" => filesystem } }, options)
|
|
22
|
+
@client.send_request("/api/settings/test/s3", opts)
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Sends a test email (templates: verification, password-reset, email-change).
|
|
27
|
+
def test_email(collection_id_or_name, to_email, email_template, options = {})
|
|
28
|
+
opts = send_options(
|
|
29
|
+
{ method: "POST",
|
|
30
|
+
body: { "email" => to_email, "template" => email_template,
|
|
31
|
+
"collection" => collection_id_or_name } },
|
|
32
|
+
options
|
|
33
|
+
)
|
|
34
|
+
@client.send_request("/api/settings/test/email", opts)
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Generates a new Apple OAuth2 client secret.
|
|
39
|
+
def generate_apple_client_secret(client_id, team_id, key_id, private_key, duration, options = {})
|
|
40
|
+
opts = send_options(
|
|
41
|
+
{ method: "POST",
|
|
42
|
+
body: { "clientId" => client_id, "teamId" => team_id, "keyId" => key_id,
|
|
43
|
+
"privateKey" => private_key, "duration" => duration } },
|
|
44
|
+
options
|
|
45
|
+
)
|
|
46
|
+
@client.send_request("/api/settings/apple/generate-client-secret", opts)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PocketBase
|
|
4
|
+
module Services
|
|
5
|
+
# SQL service (port of js-sdk SQLService). Superusers only.
|
|
6
|
+
class SQLService < BaseService
|
|
7
|
+
# Executes a raw SQL query.
|
|
8
|
+
def run(query, options = {})
|
|
9
|
+
opts = send_options({ method: "POST", body: { "query" => query } }, options)
|
|
10
|
+
@client.send_request("/api/sql", opts)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "cgi"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
require "date"
|
|
8
|
+
|
|
9
|
+
module PocketBase
|
|
10
|
+
# Shared helpers mirroring the js-sdk tools (jwt, options, filter).
|
|
11
|
+
module Utils
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Matches JavaScript's encodeURIComponent (does NOT escape A-Za-z0-9 -_.!~*'()).
|
|
15
|
+
def encode_uri_component(str)
|
|
16
|
+
str.to_s.gsub(/[^A-Za-z0-9\-_.!~*'()]/) do |c|
|
|
17
|
+
c.bytes.map { |b| format("%%%02X", b) }.join
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def decode_uri_component(str)
|
|
22
|
+
CGI.unescape(str.to_s.gsub("+", "%2B"))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# --- JWT ---------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
# Returns the payload of a JWT token as a Hash ({} when invalid).
|
|
28
|
+
def token_payload(token)
|
|
29
|
+
return {} if token.nil? || token.to_s.empty?
|
|
30
|
+
|
|
31
|
+
segment = token.to_s.split(".")[1]
|
|
32
|
+
return {} if segment.nil? || segment.empty?
|
|
33
|
+
|
|
34
|
+
padded = segment + ("=" * ((4 - (segment.length % 4)) % 4))
|
|
35
|
+
JSON.parse(Base64.urlsafe_decode64(padded)) || {}
|
|
36
|
+
rescue StandardError
|
|
37
|
+
{}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Checks whether a JWT token is expired.
|
|
41
|
+
# Tokens without an `exp` claim are considered valid;
|
|
42
|
+
# empty/invalid tokens are considered expired.
|
|
43
|
+
# `threshold` is subtracted from `exp` (in seconds).
|
|
44
|
+
def token_expired?(token, threshold = 0)
|
|
45
|
+
payload = token_payload(token)
|
|
46
|
+
return true if payload.empty?
|
|
47
|
+
|
|
48
|
+
exp = payload["exp"]
|
|
49
|
+
return false if exp.nil?
|
|
50
|
+
|
|
51
|
+
exp.to_f - threshold <= Time.now.to_f
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# --- Query params (mirrors js-sdk serializeQueryParams) -----------------
|
|
55
|
+
|
|
56
|
+
def serialize_query_params(params)
|
|
57
|
+
parts = []
|
|
58
|
+
(params || {}).each do |key, value|
|
|
59
|
+
values = value.is_a?(Array) ? value : [value]
|
|
60
|
+
values.each do |v|
|
|
61
|
+
v = prepare_query_param_value(v)
|
|
62
|
+
next if v.nil?
|
|
63
|
+
|
|
64
|
+
parts << "#{encode_uri_component(key.to_s)}=#{v}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
parts.join("&")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def prepare_query_param_value(value)
|
|
71
|
+
return nil if value.nil?
|
|
72
|
+
|
|
73
|
+
case value
|
|
74
|
+
when Time, DateTime
|
|
75
|
+
encode_uri_component(value.utc.iso8601(3).sub("T", " "))
|
|
76
|
+
when Date
|
|
77
|
+
encode_uri_component(value.strftime("%Y-%m-%d"))
|
|
78
|
+
when Hash, Array
|
|
79
|
+
encode_uri_component(JSON.generate(value))
|
|
80
|
+
when TrueClass, FalseClass, Numeric
|
|
81
|
+
encode_uri_component(value.to_s)
|
|
82
|
+
else
|
|
83
|
+
encode_uri_component(value.to_s)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# --- Filter builder (mirrors js-sdk Client#filter) ---------------------
|
|
88
|
+
|
|
89
|
+
# Constructs a filter expression with placeholders populated from params.
|
|
90
|
+
#
|
|
91
|
+
# Placeholders use the `{:paramName}` notation:
|
|
92
|
+
# pb.filter('title ~ {:title} && created >= {:created}',
|
|
93
|
+
# title: "hello", created: Time.now)
|
|
94
|
+
def build_filter(raw, params = nil)
|
|
95
|
+
return raw.to_s if params.nil? || params.empty?
|
|
96
|
+
|
|
97
|
+
mapping = {}
|
|
98
|
+
params.each { |k, v| mapping["{:#{k}}"] = stringify_filter_param(v) }
|
|
99
|
+
return raw.to_s if mapping.empty?
|
|
100
|
+
|
|
101
|
+
pattern = Regexp.union(mapping.keys)
|
|
102
|
+
raw.to_s.gsub(pattern) { |m| mapping[m] }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def stringify_filter_param(val)
|
|
106
|
+
case val
|
|
107
|
+
when TrueClass, FalseClass, Numeric
|
|
108
|
+
val.to_s
|
|
109
|
+
when String
|
|
110
|
+
JSON.generate(val)
|
|
111
|
+
when Time, DateTime
|
|
112
|
+
JSON.generate(val.utc.iso8601(3).sub("T", " "))
|
|
113
|
+
when Date
|
|
114
|
+
JSON.generate(val.strftime("%Y-%m-%d"))
|
|
115
|
+
when NilClass
|
|
116
|
+
"null"
|
|
117
|
+
else
|
|
118
|
+
s = JSON.generate(val)
|
|
119
|
+
s = JSON.generate(s) if s.start_with?("[", "{")
|
|
120
|
+
s
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Very small URL query-string replacement helper (mirrors the js-sdk
|
|
125
|
+
# private _replaceQueryParams used for the OAuth2 auth URL).
|
|
126
|
+
def replace_query_params(url, replacements = {})
|
|
127
|
+
path, query = url.to_s.split("?", 2)
|
|
128
|
+
parsed = {}
|
|
129
|
+
(query || "").split("&").each do |param|
|
|
130
|
+
next if param.empty?
|
|
131
|
+
|
|
132
|
+
k, v = param.split("=", 2)
|
|
133
|
+
parsed[decode_uri_component(k)] = decode_uri_component(v || "")
|
|
134
|
+
end
|
|
135
|
+
replacements.each do |k, v|
|
|
136
|
+
if v.nil?
|
|
137
|
+
parsed.delete(k.to_s)
|
|
138
|
+
else
|
|
139
|
+
parsed[k.to_s] = v.to_s
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
rebuilt = parsed.map { |k, v| "#{encode_uri_component(k)}=#{encode_uri_component(v)}" }.join("&")
|
|
143
|
+
rebuilt.empty? ? path : "#{path}?#{rebuilt}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
data/lib/pocketbase.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pocketbase/version"
|
|
4
|
+
require_relative "pocketbase/utils"
|
|
5
|
+
require_relative "pocketbase/client_response_error"
|
|
6
|
+
require_relative "pocketbase/multipart"
|
|
7
|
+
require_relative "pocketbase/auth_store"
|
|
8
|
+
require_relative "pocketbase/services/base_service"
|
|
9
|
+
require_relative "pocketbase/services/crud_service"
|
|
10
|
+
require_relative "pocketbase/services/realtime_service"
|
|
11
|
+
require_relative "pocketbase/services/record_service"
|
|
12
|
+
require_relative "pocketbase/services/collection_service"
|
|
13
|
+
require_relative "pocketbase/services/file_service"
|
|
14
|
+
require_relative "pocketbase/services/log_service"
|
|
15
|
+
require_relative "pocketbase/services/settings_service"
|
|
16
|
+
require_relative "pocketbase/services/backup_service"
|
|
17
|
+
require_relative "pocketbase/services/health_service"
|
|
18
|
+
require_relative "pocketbase/services/cron_service"
|
|
19
|
+
require_relative "pocketbase/services/sql_service"
|
|
20
|
+
require_relative "pocketbase/services/batch_service"
|
|
21
|
+
require_relative "pocketbase/client"
|
|
22
|
+
|
|
23
|
+
# Ruby SDK for PocketBase (port of the official pocketbase/js-sdk).
|
|
24
|
+
#
|
|
25
|
+
# require "pocketbase"
|
|
26
|
+
#
|
|
27
|
+
# pb = PocketBase.new("http://127.0.0.1:8090")
|
|
28
|
+
# pb.collection("users").auth_with_password("test@example.com", "1234567890")
|
|
29
|
+
# pb.collection("posts").get_list(1, 30, filter: "published = true")
|
|
30
|
+
module PocketBase
|
|
31
|
+
# Shortcut for PocketBase::Client.new
|
|
32
|
+
def self.new(base_url = "/", auth_store: nil, lang: "en-US")
|
|
33
|
+
Client.new(base_url, auth_store: auth_store, lang: lang)
|
|
34
|
+
end
|
|
35
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pocketbase
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Azmi Muwahid
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'A Ruby port of the official PocketBase JavaScript SDK (pocketbase/js-sdk):
|
|
13
|
+
record CRUD, auth (password, OAuth2, OTP), file uploads, realtime subscriptions
|
|
14
|
+
over SSE, backups, settings, logs, cron jobs, SQL and batch requests. Zero runtime
|
|
15
|
+
dependencies (stdlib only).'
|
|
16
|
+
email:
|
|
17
|
+
- azmimuwahid@gmail.com
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- lib/pocketbase.rb
|
|
26
|
+
- lib/pocketbase/auth_store.rb
|
|
27
|
+
- lib/pocketbase/client.rb
|
|
28
|
+
- lib/pocketbase/client_response_error.rb
|
|
29
|
+
- lib/pocketbase/multipart.rb
|
|
30
|
+
- lib/pocketbase/services/backup_service.rb
|
|
31
|
+
- lib/pocketbase/services/base_service.rb
|
|
32
|
+
- lib/pocketbase/services/batch_service.rb
|
|
33
|
+
- lib/pocketbase/services/collection_service.rb
|
|
34
|
+
- lib/pocketbase/services/cron_service.rb
|
|
35
|
+
- lib/pocketbase/services/crud_service.rb
|
|
36
|
+
- lib/pocketbase/services/file_service.rb
|
|
37
|
+
- lib/pocketbase/services/health_service.rb
|
|
38
|
+
- lib/pocketbase/services/log_service.rb
|
|
39
|
+
- lib/pocketbase/services/realtime_service.rb
|
|
40
|
+
- lib/pocketbase/services/record_service.rb
|
|
41
|
+
- lib/pocketbase/services/settings_service.rb
|
|
42
|
+
- lib/pocketbase/services/sql_service.rb
|
|
43
|
+
- lib/pocketbase/utils.rb
|
|
44
|
+
- lib/pocketbase/version.rb
|
|
45
|
+
homepage: https://github.com/azmi2409/pocketbase-ruby
|
|
46
|
+
licenses:
|
|
47
|
+
- MIT
|
|
48
|
+
metadata:
|
|
49
|
+
homepage_uri: https://github.com/azmi2409/pocketbase-ruby
|
|
50
|
+
source_code_uri: https://github.com/azmi2409/pocketbase-ruby/tree/main
|
|
51
|
+
bug_tracker_uri: https://github.com/azmi2409/pocketbase-ruby/issues
|
|
52
|
+
changelog_uri: https://github.com/azmi2409/pocketbase-ruby/blob/main/CHANGELOG.md
|
|
53
|
+
documentation_uri: https://www.rubydoc.info/gems/pocketbase/0.1.1
|
|
54
|
+
allowed_push_host: https://rubygems.org
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '2.7'
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 4.0.4
|
|
70
|
+
specification_version: 4
|
|
71
|
+
summary: Ruby SDK for PocketBase
|
|
72
|
+
test_files: []
|