Compare commits

..
53 Commits
Author SHA1 Message Date
Gani Georgiev 75bba1d0f9 updated modernc.org/sqlite 2025-03-25 22:16:32 +02:00
Gani Georgiev 81cc276ab9 fixed logs clipboard data copy 2025-03-25 22:12:43 +02:00
Gani Georgiev ffc848e92d updated ui/dist 2025-03-25 20:00:47 +02:00
Gani Georgiev 778869c318 [#6639] fixed RecordErrorEvent.Error and CollectionErrorEvent.Error sync with ModelErrorEvent.Error 2025-03-25 19:57:57 +02:00
Gani Georgiev 865408a9b4 updated changelogs 2025-03-24 06:31:58 +02:00
Gani Georgiev 7407bdcaf5 [#6631] fixed and normalized logs error serialization 2025-03-23 20:48:05 +02:00
Gani Georgiev 16539335be updated golang-jwt 2025-03-22 06:08:31 +02:00
Gani Georgiev e6573d2549 [#6600] removed filesystem io.EOF error wrapping 2025-03-16 10:43:21 +02:00
Gani Georgiev 8aa3545efe updated changelog 2025-03-15 14:36:43 +02:00
Gani Georgiev 7f7d451b31 updated go deps 2025-03-15 13:30:41 +02:00
Gani Georgiev 44007a7c8c reorganized sqlLogReplacements 2025-03-15 13:28:39 +02:00
Gani Georgiev 613af90fac bumped app version 2025-03-15 12:41:44 +02:00
Gani Georgiev 64e2931379 fixed sql nested json value dev print log 2025-03-15 12:29:51 +02:00
Gani Georgiev 6e9ecb2b7a updated modernc.org/sqlite dependency 2025-03-14 23:10:28 +02:00
Gani Georgiev 04a3dc7bb7 fixed typos and pass the correct custom abortCtx 2025-03-14 23:09:24 +02:00
Gani Georgiev 24c4b63960 [#6590] apply nullifyMisingField for request.auth.* and request.body.* back relations when the relation field is pointing to a different collection 2025-03-14 23:00:40 +02:00
Gani Georgiev 4ced91f95d updated ui/dist 2025-03-14 09:11:41 +02:00
Gani Georgiev 33613b27b0 [#6481] allowed calling cronAdd and cronRemove from inside other JSVM handlers 2025-03-11 15:00:56 +02:00
Gani Georgiev 4e148f7224 added log warning for async marked JSVM handlers and resolve when possible the returned Promise as fallback 2025-03-10 19:00:46 +02:00
Gani Georgiev 4a7a639df1 added s3blob WriterOptions custom headers test 2025-03-09 20:56:33 +02:00
Gani Georgiev ed36add853 added basic s3blob tests 2025-03-08 22:32:00 +02:00
Gani Georgiev f799083c4f added godoc comments and license notes for the gocloud.dev vendored code 2025-03-08 01:10:13 +02:00
Gani Georgiev 087eaa7ea4 updated tygoja and the generated jsvm types 2025-03-07 20:52:54 +02:00
Gani Georgiev 803ebb8f40 bumped default server timeouts 2025-03-06 21:45:16 +02:00
Gani Georgiev 58dab5bf70 restored DynamicModel types cache 2025-03-06 21:26:57 +02:00
Gani Georgiev 856cc604a7 synced with master 2025-03-06 12:50:07 +02:00
Gani Georgiev 3912874517 updated changelog 2025-03-05 23:56:05 +02:00
Gani Georgiev 5c58703a59 [#6563] fixed DynamicModel object/array props reflect type caching 2025-03-05 23:55:56 +02:00
Gani Georgiev c054c3b0a5 updated ui/dist and jsvm types 2025-03-05 22:19:36 +02:00
Gani Georgiev 3da16b2d74 updated go deps 2025-03-05 20:17:48 +02:00
Gani Georgiev dd5afd44d2 updated modernc.org/sqlite 2025-03-05 16:54:40 +02:00
Gani Georgiev 501c49012e [poc] replaced aws-sdk-go-v2 and gocloud.dev/blob 2025-03-05 16:31:21 +02:00
Gani Georgiev 48a9b82024 synced with master 2025-02-25 20:12:09 +02:00
Gani Georgiev 4155f50fe1 [#6529] added default leeway for the id_token checks 2025-02-24 11:45:59 +02:00
Gani Georgiev 653f2d8b16 [#6493] fixed request.body.json.* values extraction 2025-02-21 18:00:13 +02:00
Gani Georgiev eb3129d1f3 updated ui/dist 2025-02-21 16:54:51 +02:00
Gani Georgiev 4db497c5e1 added subscription.Message.WriteSSE method 2025-02-21 13:04:23 +02:00
Gani Georgiev 973916bb48 synced with master 2025-02-21 12:51:44 +02:00
Gani Georgiev d607695600 [#6490] restore meta.isNew OAuth2 response field 2025-02-21 10:30:43 +02:00
Gani Georgiev 3f51fb941b updated modernc deps and bumped app version 2025-02-18 18:34:38 +02:00
Gani Georgiev 4d40463d8d specified a default goja script name when executing plain JS strings 2025-02-18 12:18:08 +02:00
Gani Georgiev 543b8f5e7a added fields example to the JSON method 2025-02-17 22:35:15 +02:00
Gani Georgiev 8e39998f63 added clarification tooltips for the create and update API rules 2025-02-15 18:40:42 +02:00
Gani Georgiev e8a554b4ad synced with master 2025-02-15 17:04:25 +02:00
Gani Georgiev 5aa380927a dowgraded aws-sdk-go-v2 2025-02-11 20:13:43 +02:00
Gani Georgiev aaa3d67659 added Store.SetFunc method 2025-02-11 13:24:13 +02:00
Gani Georgiev 5c41938cb9 updated logs code comments 2025-02-11 00:38:22 +02:00
Gani Georgiev f1d44c7847 synced with master 2025-02-11 00:25:30 +02:00
Gani Georgiev c0b7762abd [#6440] added a temporary exception for Backblaze S3 endpoints to exclude the new aws-sdk-go-v2 checksum headers 2025-02-10 23:37:23 +02:00
Gani Georgiev 26f0df36bc [#6402] load the request info context during password/OAuth2/OTP authentication 2025-02-10 16:57:25 +02:00
Gani Georgiev 6a7f3a21fb synced with master 2025-02-10 09:38:15 +02:00
Gani Georgiev 9da7a8f72b [#6407] added os.Stat jsvm binding 2025-02-05 19:07:34 +02:00
Gani Georgiev 9856c59de0 prioritized user submitted OAuth2 createData.email 2025-02-03 12:57:15 +02:00
109 changed files with 11248 additions and 7374 deletions
+112
View File
@@ -1,3 +1,115 @@
## v0.26.4
- Fixed `RecordErrorEvent.Error` and `CollectionErrorEvent.Error` sync with `ModelErrorEvent.Error` ([#6639](https://github.com/pocketbase/pocketbase/issues/6639)).
- Fixed logs details clipboard copy.
- Updated `modernc.org/sqlite` to 1.36.2.
## v0.26.3
- Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
## v0.26.2
- Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
## v0.26.1
- Removed the wrapping of `io.EOF` error when reading files since currently `io.ReadAll` doesn't check for wrapped errors ([#6600](https://github.com/pocketbase/pocketbase/issues/6600)).
## v0.26.0
- ⚠️ Replaced `aws-sdk-go-v2` and `gocloud.dev/blob` with custom lighter implementation ([#6562](https://github.com/pocketbase/pocketbase/discussions/6562)).
As a side-effect of the dependency removal, the binary size has been reduced with ~10MB and builds ~30% faster.
_Although the change is expected to be backward-compatible, I'd recommend to test first locally the new version with your S3 provider (if you use S3 for files storage and backups)._
- ⚠️ Prioritized the user submitted non-empty `createData.email` (_it will be unverified_) when creating the PocketBase user during the first OAuth2 auth.
- Load the request info context during password/OAuth2/OTP authentication ([#6402](https://github.com/pocketbase/pocketbase/issues/6402)).
This could be useful in case you want to target the auth method as part of the MFA and Auth API rules.
For example, to disable MFA for the OAuth2 auth could be expressed as `@request.context != "oauth2"` MFA rule.
- Added `store.Store.SetFunc(key, func(old T) new T)` to set/update a store value with the return result of the callback in a concurrent safe manner.
- Added `subscription.Message.WriteSSE(w, id)` for writing an SSE formatted message into the provided writer interface (_used mostly to assist with the unit testing_).
- Added `$os.stat(file)` JSVM helper ([#6407](https://github.com/pocketbase/pocketbase/discussions/6407)).
- Added log warning for `async` marked JSVM handlers and resolve when possible the returned `Promise` as fallback ([#6476](https://github.com/pocketbase/pocketbase/issues/6476)).
- Allowed calling `cronAdd`, `cronRemove` from inside other JSVM handlers ([#6481](https://github.com/pocketbase/pocketbase/discussions/6481)).
- Bumped the default request read and write timeouts to 5mins (_old 3mins_) to accommodate slower internet connections and larger file uploads/downloads.
_If you want to change them you can modify the `OnServe` hook's `ServeEvent.ReadTimeout/WriteTimeout` fields as shown in [#6550](https://github.com/pocketbase/pocketbase/discussions/6550#discussioncomment-12364515)._
- Normalized the `@request.auth.*` and `@request.body.*` back relations resolver to always return `null` when the relation field is pointing to a different collection ([#6590](https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581)).
- Other minor improvements (_fixed query dev log nested parameters output, reintroduced `DynamicModel` object/array props reflect types caching, updated Go and npm deps, etc._)
## v0.25.9
- Fixed `DynamicModel` object/array props reflect type caching ([#6563](https://github.com/pocketbase/pocketbase/discussions/6563)).
## v0.25.8
- Added a default leeway of 5 minutes for the Apple/OIDC `id_token` timestamp claims check to account for clock-skew ([#6529](https://github.com/pocketbase/pocketbase/issues/6529)).
It can be further customized if needed with the `PB_ID_TOKEN_LEEWAY` env variable (_the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute_).
## v0.25.7
- Fixed `@request.body.jsonObjOrArr.*` values extraction ([#6493](https://github.com/pocketbase/pocketbase/discussions/6493)).
## v0.25.6
- Restore the missing `meta.isNew` field of the OAuth2 success response ([#6490](https://github.com/pocketbase/pocketbase/issues/6490)).
- Updated npm dependencies.
## v0.25.5
- Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
- Updated `modernc.org/sqlite` and `modernc.org/libc` dependencies.
## v0.25.4
- Downgraded `aws-sdk-go-v2` to the version before the default data integrity checks because there have been reports for non-AWS S3 providers in addition to Backblaze (IDrive, R2) that no longer or partially work with the latest AWS SDK changes.
While we try to enforce `when_required` by default, it is not enough to disable the new AWS SDK integrity checks entirely and some providers will require additional manual adjustments to make them compatible with the latest AWS SDK (e.g. removing the `x-aws-checksum-*` headers, unsetting the checksums calculation or reinstantiating the old MD5 checksums for some of the required operations, etc.) which as a result leads to a configuration mess that I'm not sure it would be a good idea to introduce.
This unfornuatelly is not a PocketBase or Go specific issue and the official AWS SDKs for other languages are in the same situation (even the latest aws-cli).
For those of you that extend PocketBase with Go: if your S3 vendor doesn't support the [AWS Data integrity checks](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html) and you are updating with `go get -u`, then make sure that the `aws-sdk-go-v2` dependencies in your `go.mod` are the same as in the repo:
```
// go.mod
github.com/aws/aws-sdk-go-v2 v1.36.1
github.com/aws/aws-sdk-go-v2/config v1.28.10
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
// after that run
go clean -modcache && go mod tidy
```
_The versions pinning is temporary until the non-AWS S3 vendors patch their implementation or until I manage to find time to remove/replace the `aws-sdk-go-v2` dependency (I'll consider prioritizing it for the v0.26 or v0.27 release)._
## v0.25.3
- Added a temporary exception for Backblaze S3 endpoints to exclude the new `aws-sdk-go-v2` checksum headers ([#6440](https://github.com/pocketbase/pocketbase/discussions/6440)).
## v0.25.2
- Fixed realtime delete event not being fired for `RecordProxy`-ies and added basic realtime record resolve automated tests ([#6433](https://github.com/pocketbase/pocketbase/issues/6433)).
+15
View File
@@ -2,6 +2,21 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
---
## v0.22.33
- (_Backported from v0.26.3_) Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
## v0.22.32
- (_Backported from v0.26.2_) Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
## v0.22.31
- (_Backported from v0.25.5_) Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
## v0.22.30
- (_Backported from v0.24.4_) Fixed fields extraction for view queries with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
+2 -2
View File
@@ -13,7 +13,7 @@ import (
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"gocloud.dev/blob"
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
)
func TestBackupsList(t *testing.T) {
@@ -490,7 +490,7 @@ func TestBackupsDownload(t *testing.T) {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
+8 -10
View File
@@ -84,11 +84,10 @@ func realtimeConnect(e *core.RequestEvent) error {
Data: []byte(`{"clientId":"` + ce.Client.Id() + `"}`),
}
connectMsgErr := ce.App.OnRealtimeMessageSend().Trigger(connectMsgEvent, func(me *core.RealtimeMessageEvent) error {
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
me.Response.Write([]byte("data:"))
me.Response.Write(me.Message.Data)
me.Response.Write([]byte("\n\n"))
err := me.Message.WriteSSE(me.Response, me.Client.Id())
if err != nil {
return err
}
return me.Flush()
})
if connectMsgErr != nil {
@@ -123,11 +122,10 @@ func realtimeConnect(e *core.RequestEvent) error {
msgEvent.Client = ce.Client
msgEvent.Message = &msg
msgErr := ce.App.OnRealtimeMessageSend().Trigger(msgEvent, func(me *core.RealtimeMessageEvent) error {
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
me.Response.Write([]byte("data:"))
me.Response.Write(me.Message.Data)
me.Response.Write([]byte("\n\n"))
err := me.Message.WriteSSE(me.Response, me.Client.Id())
if err != nil {
return err
}
return me.Flush()
})
if msgErr != nil {
+18 -8
View File
@@ -3,6 +3,7 @@ package apis
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -14,7 +15,6 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/filesystem"
"golang.org/x/oauth2"
@@ -35,6 +35,8 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
fallbackAuthRecord = e.Auth
}
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOAuth2)
form := new(recordOAuth2LoginForm)
form.collection = collection
if err = e.BindBody(form); err != nil {
@@ -134,13 +136,17 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
}
meta := struct {
*auth.AuthUser
IsNew bool `json:"isNew"`
}{
AuthUser: e.OAuth2User,
IsNew: e.IsNewRecord,
// @todo revert back to struct after removing the custom auth.AuthUser marshalization
meta := map[string]any{}
rawOAuth2User, err := json.Marshal(e.OAuth2User)
if err != nil {
return err
}
err = json.Unmarshal(rawOAuth2User, &meta)
if err != nil {
return err
}
meta["isNew"] = e.IsNewRecord
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
})
@@ -232,7 +238,11 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
payload = map[string]any{}
}
payload[core.FieldNameEmail] = e.OAuth2User.Email
// assign the OAuth2 user email only if the user hasn't submitted one
// (ignore empty/invalid values for consistency with the OAuth2->existing user update flow)
if v, _ := payload[core.FieldNameEmail].(string); v == "" {
payload[core.FieldNameEmail] = e.OAuth2User.Email
}
// map known fields (unless the field was explicitly submitted as part of CreateData)
if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
+119 -6
View File
@@ -175,6 +175,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
if err := app.Save(ea); err != nil {
t.Fatal(err)
}
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
if err != nil {
t.Fatal(err)
}
if info.Context != core.RequestInfoContextOAuth2 {
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOAuth2, info.Context)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -293,6 +307,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test2@example.com"`,
`"id":"oap640cot4yru2s"`,
`"id":"test_id"`,
@@ -384,6 +399,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -489,6 +505,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -602,6 +619,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test_oauth2@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -707,6 +725,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -796,6 +815,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":true`,
`"email":""`,
`"id":"test_id"`,
`"verified":true`,
@@ -877,7 +897,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"verified":{"code":"validation_values_mismatch"`,
},
NotExpectedContent: []string{
`"email":`, // the value is always overwritten with the OAuth2 user email
`"email":`, // ignored because the record validator never ran
`"rel":`, // ignored because the record validator never ran
`"file":`, // ignored because the record validator never ran
},
@@ -930,12 +950,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"email":{"code":"validation_is_email"`,
`"rel":{"code":"validation_missing_rel_records"`,
`"file":{"code":"validation_invalid_file"`,
},
NotExpectedContent: []string{
`"email":`, // the value is always overwritten with the OAuth2 user email
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
@@ -949,7 +967,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "creating user (valid create data)",
Name: "creating user (valid create data with empty submitted email)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -957,7 +975,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"code":"123",
"redirectURL": "https://example.com",
"createData": {
"email": "invalid",
"email": "",
"emailVisibility": true,
"password": "1234567890",
"passwordConfirm": "1234567890",
@@ -994,6 +1012,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":""`,
`"emailVisibility":true`,
`"name":"test_name"`,
@@ -1041,6 +1060,95 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
},
},
{
Name: "creating user (valid create data with non-empty valid submitted email)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"123",
"redirectURL": "https://example.com",
"createData": {
"email": "test_create@example.com",
"emailVisibility": true,
"password": "1234567890",
"passwordConfirm": "1234567890",
"name": "test_name",
"username": "test_username",
"rel": "0yxhwia2amd8gec"
}
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
usersCol, err := app.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{
Id: "test_id",
Email: "oauth2@example.com", // should be ignored because of the explicit submitted email
},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// add the test provider in the collection
usersCol.MFA.Enabled = false
usersCol.OAuth2.Enabled = true
usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(usersCol); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test_create@example.com"`,
`"emailVisibility":true`,
`"name":"test_name"`,
`"username":"test_username"`,
`"verified":false`,
`"rel":"0yxhwia2amd8gec"`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
"OnRecordAuthRequest": 1,
"OnRecordCreateRequest": 1,
"OnRecordEnrich": 2, // the auth response and from the create request
// ---
"OnModelCreate": 3, // record + authOrigins + externalAuths
"OnModelCreateExecute": 3,
"OnModelAfterCreateSuccess": 3,
"OnRecordCreate": 3,
"OnRecordCreateExecute": 3,
"OnRecordAfterCreateSuccess": 3,
// ---
"OnModelValidate": 3,
"OnRecordValidate": 3,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindFirstRecordByData("users", "username", "test_username")
if err != nil {
t.Fatal(err)
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
},
},
{
Name: "creating user (with mapped OAuth2 fields and avatarURL->file field)",
Method: http.MethodPost,
@@ -1093,6 +1201,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":true`,
`"name":"test_name"`,
@@ -1175,6 +1284,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1257,6 +1367,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"username":"tESt2_username"`,
@@ -1347,6 +1458,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1428,6 +1540,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
+2
View File
@@ -26,6 +26,8 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
}
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOTP)
event := new(core.RecordAuthWithOTPRequestEvent)
event.RequestEvent = e
event.Collection = collection
+14
View File
@@ -269,6 +269,20 @@ func TestRecordAuthWithOTP(t *testing.T) {
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
if err != nil {
t.Fatal(err)
}
if info.Context != core.RequestInfoContextOTP {
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOTP, info.Context)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
+2
View File
@@ -32,6 +32,8 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
}
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextPasswordAuth)
var foundRecord *core.Record
var foundErr error
+15
View File
@@ -126,6 +126,21 @@ func TestRecordAuthWithPassword(t *testing.T) {
"identity":"test@example.com",
"password":"1234567890"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
if err != nil {
t.Fatal(err)
}
if info.Context != core.RequestInfoContextPasswordAuth {
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextPasswordAuth, info.Context)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test@example.com"`,
+3 -3
View File
@@ -143,9 +143,9 @@ func Serve(app core.App, config ServeConfig) error {
NextProtos: []string{acme.ALPNProto},
},
// higher defaults to accommodate large file uploads/downloads
WriteTimeout: 3 * time.Minute,
ReadTimeout: 3 * time.Minute,
ReadHeaderTimeout: 30 * time.Second,
WriteTimeout: 5 * time.Minute,
ReadTimeout: 5 * time.Minute,
ReadHeaderTimeout: 1 * time.Minute,
Addr: mainAddr,
BaseContext: func(l net.Listener) context.Context {
return baseCtx
+2 -2
View File
@@ -323,10 +323,10 @@ type App interface {
// FindLogById finds a single Log entry by its id.
FindLogById(id string) (*Log, error)
// LogsStatsItem defines the total number of logs for a specific time period.
// LogsStatsItem returns hourly grouped logs statistics.
LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error)
// DeleteOldLogs delete all requests that are created before createdBefore.
// DeleteOldLogs delete all logs that are created before createdBefore.
DeleteOldLogs(createdBefore time.Time) error
// ---------------------------------------------------------------
+13 -14
View File
@@ -1139,26 +1139,25 @@ func (app *BaseApp) initDataDB() error {
return nil
}
var sqlLogReplacements = map[string]string{
"{{": "`",
"}}": "`",
"[[": "`",
"]]": "`",
"<nil>": "NULL",
var sqlLogReplacements = []struct {
pattern *regexp.Regexp
replacement string
}{
{regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`), "`$1`.`$2`"},
{regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`), "`$1`.`$2`"},
{regexp.MustCompile(`([^'"])\{\{`), "$1`"},
{regexp.MustCompile(`\}\}([^'"])`), "`$1"},
{regexp.MustCompile(`([^'"])\[\[`), "$1`"},
{regexp.MustCompile(`\]\]([^'"])`), "`$1"},
{regexp.MustCompile(`<nil>`), "NULL"},
}
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`)
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`)
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
// The query is still not suitable for execution and should be used only for log and debug purposes
// (the normalization is done here to avoid breaking changes in dbx).
func normalizeSQLLog(sql string) string {
sql = sqlLogPrefixedTableIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
sql = sqlLogPrefixedColumnIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
for old, new := range sqlLogReplacements {
sql = strings.ReplaceAll(sql, old, new)
for _, item := range sqlLogReplacements {
sql = item.pattern.ReplaceAllString(sql, item.replacement)
}
return sql
+275
View File
@@ -1,9 +1,12 @@
package core_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"testing"
@@ -11,6 +14,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -976,6 +980,277 @@ func TestCollectionDelete(t *testing.T) {
}
}
func TestCollectionModelEventSync(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
testCollections := make([]*core.Collection, 4)
for i := 0; i < 4; i++ {
testCollections[i] = core.NewBaseCollection("sync_test_" + strconv.Itoa(i))
if err := app.Save(testCollections[i]); err != nil {
t.Fatal(err)
}
}
createModelEvent := func() *core.ModelEvent {
event := new(core.ModelEvent)
event.App = app
event.Context = context.Background()
event.Type = "test_a"
event.Model = testCollections[0]
return event
}
createModelErrorEvent := func() *core.ModelErrorEvent {
event := new(core.ModelErrorEvent)
event.ModelEvent = *createModelEvent()
event.Error = errors.New("error_a")
return event
}
changeCollectionEventBefore := func(e *core.CollectionEvent) {
e.Type = "test_b"
e.Context = context.WithValue(context.Background(), "test", 123)
e.Collection = testCollections[1]
}
modelEventFinalizerChange := func(e *core.ModelEvent) {
e.Type = "test_c"
e.Context = context.WithValue(context.Background(), "test", 456)
e.Model = testCollections[2]
}
changeCollectionEventAfter := func(e *core.CollectionEvent) {
e.Type = "test_d"
e.Context = context.WithValue(context.Background(), "test", 789)
e.Collection = testCollections[3]
}
expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Model.PK() != testCollections[0].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Model.PK(), 0)
}
}
expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_d" {
t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
}
if v := e.Context.Value("test"); v != 789 {
t.Fatalf("Expected context value %v, got %v", 789, v)
}
if e.Model.PK() != testCollections[3].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[3].Id, e.Model.PK(), 3)
}
}
expectedBeforeCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Collection.Id != testCollections[0].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Collection.Id, 0)
}
}
expectedAfterCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
if e.Type != "test_c" {
t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
}
if v := e.Context.Value("test"); v != 456 {
t.Fatalf("Expected context value %v, got %v", 456, v)
}
if e.Collection.Id != testCollections[2].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[2].Id, e.Collection.Id, 2)
}
}
modelEventFinalizer := func(e *core.ModelEvent) error {
modelEventFinalizerChange(e)
return nil
}
modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
modelEventFinalizerChange(&e.ModelEvent)
e.Error = errors.New("error_c")
return nil
}
modelEventHandler := &hook.Handler[*core.ModelEvent]{
Priority: -999,
Func: func(e *core.ModelEvent) error {
t.Run("before model", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, e)
})
_ = e.Next()
t.Run("after model", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, e)
})
return nil
},
}
modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
Priority: -999,
Func: func(e *core.ModelErrorEvent) error {
t.Run("before model error", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_a", v)
}
})
_ = e.Next()
t.Run("after model error", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_d" {
t.Fatalf("Expected error %q, got %q", "error_d", v)
}
})
return nil
},
}
recordEventHandler := &hook.Handler[*core.CollectionEvent]{
Priority: -999,
Func: func(e *core.CollectionEvent) error {
t.Run("before collection", func(t *testing.T) {
expectedBeforeCollectionEventHandlerChecks(t, e)
})
changeCollectionEventBefore(e)
_ = e.Next()
t.Run("after collection", func(t *testing.T) {
expectedAfterCollectionEventHandlerChecks(t, e)
})
changeCollectionEventAfter(e)
return nil
},
}
collectionErrorEventHandler := &hook.Handler[*core.CollectionErrorEvent]{
Priority: -999,
Func: func(e *core.CollectionErrorEvent) error {
t.Run("before collection error", func(t *testing.T) {
expectedBeforeCollectionEventHandlerChecks(t, &e.CollectionEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeCollectionEventBefore(&e.CollectionEvent)
e.Error = errors.New("error_b")
_ = e.Next()
t.Run("after collection error", func(t *testing.T) {
expectedAfterCollectionEventHandlerChecks(t, &e.CollectionEvent)
if v := e.Error.Error(); v != "error_c" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeCollectionEventAfter(&e.CollectionEvent)
e.Error = errors.New("error_d")
return nil
},
}
// OnModelValidate
app.OnCollectionValidate().Bind(recordEventHandler)
app.OnModelValidate().Bind(modelEventHandler)
app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreate
app.OnCollectionCreate().Bind(recordEventHandler)
app.OnModelCreate().Bind(modelEventHandler)
app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreateExecute
app.OnCollectionCreateExecute().Bind(recordEventHandler)
app.OnModelCreateExecute().Bind(modelEventHandler)
app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateSuccess
app.OnCollectionAfterCreateSuccess().Bind(recordEventHandler)
app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateError
app.OnCollectionAfterCreateError().Bind(collectionErrorEventHandler)
app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelUpdate
app.OnCollectionUpdate().Bind(recordEventHandler)
app.OnModelUpdate().Bind(modelEventHandler)
app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelUpdateExecute
app.OnCollectionUpdateExecute().Bind(recordEventHandler)
app.OnModelUpdateExecute().Bind(modelEventHandler)
app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateSuccess
app.OnCollectionAfterUpdateSuccess().Bind(recordEventHandler)
app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateError
app.OnCollectionAfterUpdateError().Bind(collectionErrorEventHandler)
app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelDelete
app.OnCollectionDelete().Bind(recordEventHandler)
app.OnModelDelete().Bind(modelEventHandler)
app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelDeleteExecute
app.OnCollectionDeleteExecute().Bind(recordEventHandler)
app.OnModelDeleteExecute().Bind(modelEventHandler)
app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteSuccess
app.OnCollectionAfterDeleteSuccess().Bind(recordEventHandler)
app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteError
app.OnCollectionAfterDeleteError().Bind(collectionErrorEventHandler)
app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
}
func TestCollectionSaveModel(t *testing.T) {
t.Parallel()
+5 -1
View File
@@ -34,7 +34,11 @@ func onCollectionValidate(e *CollectionEvent) error {
original,
)
return validator.run()
if err := validator.run(); err != nil {
return err
}
return e.Next()
}
func newCollectionValidator(ctx context.Context, app App, new, original *Collection) *collectionValidator {
+3 -1
View File
@@ -154,8 +154,10 @@ const (
RequestInfoContextExpand = "expand"
RequestInfoContextRealtime = "realtime"
RequestInfoContextProtectedFile = "protectedFile"
RequestInfoContextOAuth2 = "oauth2"
RequestInfoContextBatch = "batch"
RequestInfoContextOAuth2 = "oauth2"
RequestInfoContextOTP = "otp"
RequestInfoContextPasswordAuth = "password"
)
// RequestInfo defines a HTTP request data struct, usually used
+2 -2
View File
@@ -277,7 +277,7 @@ func syncModelErrorEventWithRecordErrorEvent(me *ModelErrorEvent, re *RecordErro
func syncRecordErrorEventWithModelErrorEvent(re *RecordErrorEvent, me *ModelErrorEvent) {
syncRecordEventWithModelEvent(&re.RecordEvent, &me.ModelEvent)
me.Error = re.Error
re.Error = me.Error
}
// -------------------------------------------------------------------
@@ -354,7 +354,7 @@ func syncModelErrorEventWithCollectionErrorEvent(me *ModelErrorEvent, ce *Collec
func syncCollectionErrorEventWithModelErrorEvent(ce *CollectionErrorEvent, me *ModelErrorEvent) {
syncCollectionEventWithModelEvent(&ce.CollectionEvent, &me.ModelEvent)
me.Error = ce.Error
ce.Error = me.Error
}
// -------------------------------------------------------------------
+2 -2
View File
@@ -34,7 +34,7 @@ type LogsStatsItem struct {
Total int `db:"total" json:"total"`
}
// LogsStats returns hourly grouped requests logs statistics.
// LogsStats returns hourly grouped logs statistics.
func (app *BaseApp) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
result := []*LogsStatsItem{}
@@ -51,7 +51,7 @@ func (app *BaseApp) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
return result, err
}
// DeleteOldLogs delete all requests that are created before createdBefore.
// DeleteOldLogs delete all logs that are created before createdBefore.
//
// For better performance the logs delete is executed as plain SQL statement,
// aka. no delete model hook events will be fired.
+10
View File
@@ -11,6 +11,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
@@ -300,6 +301,13 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
return mapVal(m, keys...)
case mapExtractor:
return mapVal(m.AsMap(), keys...)
case types.JSONRaw:
var raw any
err := json.Unmarshal(m, &raw)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal raw JSON in order extract nested value from: %w", err)
}
return extractNestedVal(raw, keys...)
// slices
case []string:
@@ -338,6 +346,8 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
return arrVal(extracted, keys...)
case []any:
return arrVal(m, keys...)
case []types.JSONRaw:
return arrVal(m, keys...)
default:
return nil, fmt.Errorf("expected map or array, got %#v", rawData)
}
+9 -1
View File
@@ -479,7 +479,11 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
}
return nil, fmt.Errorf("missing back relation field %q", parts[2])
}
if backField.Type() != FieldTypeRelation {
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
return nil, fmt.Errorf("invalid back relation field %q", parts[2])
}
@@ -492,7 +496,11 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
return nil, fmt.Errorf("failed to initialize back relation field %q", backField.GetName())
}
if backRelField.CollectionId != collection.Id {
return nil, fmt.Errorf("invalid back relation field %q collection reference", backField.GetName())
// https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
return nil, fmt.Errorf("invalid collection reference of a back relation field %q", backField.GetName())
}
// join the back relation to the main query
+22 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestRecordFieldResolverAllowedFields(t *testing.T) {
@@ -600,7 +601,8 @@ func TestRecordFieldResolverResolveCollectionFields(t *testing.T) {
{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.id", true, ""},
// back relations
{"rel_one_cascade.demo4_via_title.id", true, ""}, // non-relation via field
{"rel_one_cascade.demo4_via_title.id", true, ""}, // not a relation field
{"rel_one_cascade.demo4_via_self_rel_one.id", true, ""}, // relation field but to a different collection
{"rel_one_cascade.demo4_via_rel_one_cascade.id", false, "[[demo4_rel_one_cascade_demo4_via_rel_one_cascade.id]]"},
{"rel_one_cascade.demo4_via_rel_one_cascade.rel_one_cascade.demo4_via_rel_one_cascade.id", false, "[[demo4_rel_one_cascade_demo4_via_rel_one_cascade_rel_one_cascade_demo4_via_rel_one_cascade.id]]"},
@@ -614,8 +616,11 @@ func TestRecordFieldResolverResolveCollectionFields(t *testing.T) {
// @request.auth relation join
{"@request.auth.rel", false, "[[__auth_users.rel]]"},
{"@request.auth.rel.title", false, "[[__auth_users_rel.title]]"},
{"@request.auth.demo1_via_rel_many.id", false, "[[__auth_users_demo1_via_rel_many.id]]"},
{"@request.auth.rel.missing", false, "NULL"},
{"@request.auth.missing_via_rel", false, "NULL"},
{"@request.auth.demo1_via_file_one.id", false, "NULL"}, // not a relation field
{"@request.auth.demo1_via_rel_one.id", false, "NULL"}, // relation field but to a different collection
// @collection fieds
{"@collect", true, ""},
@@ -681,10 +686,14 @@ func TestRecordFieldResolverResolveStaticRequestInfoFields(t *testing.T) {
"a": "123",
},
Body: map[string]any{
"number": "10",
"number_unknown": "20",
"b": 456,
"c": map[string]int{"sub": 1},
"number": "10",
"number_unknown": "20",
"raw_json_obj": types.JSONRaw(`{"a":123}`),
"raw_json_arr1": types.JSONRaw(`[123, 456]`),
"raw_json_arr2": types.JSONRaw(`[{"a":123},{"b":456}]`),
"raw_json_simple": types.JSONRaw(`123`),
"b": 456,
"c": map[string]int{"sub": 1},
},
Headers: map[string]string{
"d": "789",
@@ -726,6 +735,14 @@ func TestRecordFieldResolverResolveStaticRequestInfoFields(t *testing.T) {
{"@request.auth.emailVisibility", false, `false`},
{"@request.auth.email", false, `"test@example.com"`}, // should always be returned no matter of the emailVisibility state
{"@request.auth.missing", false, `NULL`},
{"@request.body.raw_json_simple", false, `"123"`},
{"@request.body.raw_json_simple.a", false, `NULL`},
{"@request.body.raw_json_obj.a", false, `123`},
{"@request.body.raw_json_obj.b", false, `NULL`},
{"@request.body.raw_json_arr1.1", false, `456`},
{"@request.body.raw_json_arr1.3", false, `NULL`},
{"@request.body.raw_json_arr2.0.a", false, `123`},
{"@request.body.raw_json_arr2.0.b", false, `NULL`},
}
for _, s := range scenarios {
+282
View File
@@ -5,9 +5,11 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"time"
@@ -16,6 +18,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
@@ -1618,6 +1621,285 @@ func TestRecordValidate(t *testing.T) {
})
}
func TestRecordModelEventSync(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
col, err := app.FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
testRecords := make([]*core.Record, 4)
for i := 0; i < 4; i++ {
testRecords[i] = core.NewRecord(col)
testRecords[i].Set("title", "sync_test_"+strconv.Itoa(i))
if err := app.Save(testRecords[i]); err != nil {
t.Fatal(err)
}
}
createModelEvent := func() *core.ModelEvent {
event := new(core.ModelEvent)
event.App = app
event.Context = context.Background()
event.Type = "test_a"
event.Model = testRecords[0]
return event
}
createModelErrorEvent := func() *core.ModelErrorEvent {
event := new(core.ModelErrorEvent)
event.ModelEvent = *createModelEvent()
event.Error = errors.New("error_a")
return event
}
changeRecordEventBefore := func(e *core.RecordEvent) {
e.Type = "test_b"
e.Context = context.WithValue(context.Background(), "test", 123)
e.Record = testRecords[1]
}
modelEventFinalizerChange := func(e *core.ModelEvent) {
e.Type = "test_c"
e.Context = context.WithValue(context.Background(), "test", 456)
e.Model = testRecords[2]
}
changeRecordEventAfter := func(e *core.RecordEvent) {
e.Type = "test_d"
e.Context = context.WithValue(context.Background(), "test", 789)
e.Record = testRecords[3]
}
expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Model.PK() != testRecords[0].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[0].Id, e.Model.PK(), 0)
}
}
expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_d" {
t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
}
if v := e.Context.Value("test"); v != 789 {
t.Fatalf("Expected context value %v, got %v", 789, v)
}
// note: currently the Model and Record values are not synced due to performance consideration
if e.Model.PK() != testRecords[2].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[2].Id, e.Model.PK(), 2)
}
}
expectedBeforeRecordEventHandlerChecks := func(t *testing.T, e *core.RecordEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Record.Id != testRecords[0].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[0].Id, e.Record.Id, 2)
}
}
expectedAfterRecordEventHandlerChecks := func(t *testing.T, e *core.RecordEvent) {
if e.Type != "test_c" {
t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
}
if v := e.Context.Value("test"); v != 456 {
t.Fatalf("Expected context value %v, got %v", 456, v)
}
// note: currently the Model and Record values are not synced due to performance consideration
if e.Record.Id != testRecords[1].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[1].Id, e.Record.Id, 1)
}
}
modelEventFinalizer := func(e *core.ModelEvent) error {
modelEventFinalizerChange(e)
return nil
}
modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
modelEventFinalizerChange(&e.ModelEvent)
e.Error = errors.New("error_c")
return nil
}
modelEventHandler := &hook.Handler[*core.ModelEvent]{
Priority: -999,
Func: func(e *core.ModelEvent) error {
t.Run("before model", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, e)
})
_ = e.Next()
t.Run("after model", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, e)
})
return nil
},
}
modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
Priority: -999,
Func: func(e *core.ModelErrorEvent) error {
t.Run("before model error", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_a", v)
}
})
_ = e.Next()
t.Run("after model error", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_d" {
t.Fatalf("Expected error %q, got %q", "error_d", v)
}
})
return nil
},
}
recordEventHandler := &hook.Handler[*core.RecordEvent]{
Priority: -999,
Func: func(e *core.RecordEvent) error {
t.Run("before record", func(t *testing.T) {
expectedBeforeRecordEventHandlerChecks(t, e)
})
changeRecordEventBefore(e)
_ = e.Next()
t.Run("after record", func(t *testing.T) {
expectedAfterRecordEventHandlerChecks(t, e)
})
changeRecordEventAfter(e)
return nil
},
}
recordErrorEventHandler := &hook.Handler[*core.RecordErrorEvent]{
Priority: -999,
Func: func(e *core.RecordErrorEvent) error {
t.Run("before record error", func(t *testing.T) {
expectedBeforeRecordEventHandlerChecks(t, &e.RecordEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeRecordEventBefore(&e.RecordEvent)
e.Error = errors.New("error_b")
_ = e.Next()
t.Run("after record error", func(t *testing.T) {
expectedAfterRecordEventHandlerChecks(t, &e.RecordEvent)
if v := e.Error.Error(); v != "error_c" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeRecordEventAfter(&e.RecordEvent)
e.Error = errors.New("error_d")
return nil
},
}
// OnModelValidate
app.OnRecordValidate().Bind(recordEventHandler)
app.OnModelValidate().Bind(modelEventHandler)
app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreate
app.OnRecordCreate().Bind(recordEventHandler)
app.OnModelCreate().Bind(modelEventHandler)
app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreateExecute
app.OnRecordCreateExecute().Bind(recordEventHandler)
app.OnModelCreateExecute().Bind(modelEventHandler)
app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateSuccess
app.OnRecordAfterCreateSuccess().Bind(recordEventHandler)
app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateError
app.OnRecordAfterCreateError().Bind(recordErrorEventHandler)
app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelUpdate
app.OnRecordUpdate().Bind(recordEventHandler)
app.OnModelUpdate().Bind(modelEventHandler)
app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelUpdateExecute
app.OnRecordUpdateExecute().Bind(recordEventHandler)
app.OnModelUpdateExecute().Bind(modelEventHandler)
app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateSuccess
app.OnRecordAfterUpdateSuccess().Bind(recordEventHandler)
app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateError
app.OnRecordAfterUpdateError().Bind(recordErrorEventHandler)
app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelDelete
app.OnRecordDelete().Bind(recordEventHandler)
app.OnModelDelete().Bind(modelEventHandler)
app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelDeleteExecute
app.OnRecordDeleteExecute().Bind(recordEventHandler)
app.OnModelDeleteExecute().Bind(modelEventHandler)
app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteSuccess
app.OnRecordAfterDeleteSuccess().Bind(recordEventHandler)
app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteError
app.OnRecordAfterDeleteError().Bind(recordErrorEventHandler)
app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
}
func TestRecordSave(t *testing.T) {
t.Parallel()
+19 -47
View File
@@ -1,78 +1,50 @@
module github.com/pocketbase/pocketbase
go 1.23
go 1.23.0
require (
github.com/aws/aws-sdk-go-v2 v1.35.0
github.com/aws/aws-sdk-go-v2/config v1.29.3
github.com/aws/aws-sdk-go-v2/credentials v1.17.56
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.56
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.1
github.com/aws/smithy-go v1.22.2
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.6.2
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c
github.com/dop251/goja_nodejs v0.0.0-20250314160716-c55ecee183c0
github.com/fatih/color v1.18.0
github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.8
github.com/ganigeorgiev/fexpr v0.4.1
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119
github.com/spf13/cast v1.7.1
github.com/spf13/cobra v1.8.1
gocloud.dev v0.40.0
golang.org/x/crypto v0.32.0
golang.org/x/net v0.34.0
golang.org/x/oauth2 v0.25.0
golang.org/x/sync v0.10.0
modernc.org/sqlite v1.34.5
github.com/spf13/cobra v1.9.1
golang.org/x/crypto v0.36.0
golang.org/x/net v0.37.0
golang.org/x/oauth2 v0.28.0
golang.org/x/sync v0.12.0
modernc.org/sqlite v1.36.2
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.13 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.11 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/testify v1.8.2 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.29.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.219.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 // indirect
google.golang.org/grpc v1.70.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
modernc.org/libc v1.55.3 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.31.0 // indirect
modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
)
+53 -243
View File
@@ -1,93 +1,27 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/auth v0.14.0 h1:A5C4dKV/Spdvxcl0ggWwWEzzP7AZMJSEIgrkngwhGYM=
cloud.google.com/go/auth v0.14.0/go.mod h1:CYsoRL1PdiDuqeQpZE0bP2pnPrGqFcOkI0nldEQis+A=
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.35.0 h1:jTPxEJyzjSuuz0wB+302hr8Eu9KUI+Zv8zlujMGJpVI=
github.com/aws/aws-sdk-go-v2 v1.35.0/go.mod h1:JgstGg0JjWU1KpVJjD5H0y0yyAIpSdKEq556EI6yOOM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 h1:zAxi9p3wsZMIaVCdoiQp2uZ9k1LsZvmAnoTBeZPXom0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8/go.mod h1:3XkePX5dSaxveLAYY7nsbsZZrKxCyEuE5pM4ziFxyGg=
github.com/aws/aws-sdk-go-v2/config v1.29.3 h1:a5Ucjxe6iV+LHEBmYA9w40rT5aGxWybx/4l/O/fvJlE=
github.com/aws/aws-sdk-go-v2/config v1.29.3/go.mod h1:pt9z1x12zDiDb4iFLrxoeAKLVCU/Gp9DL/5BnwlY77o=
github.com/aws/aws-sdk-go-v2/credentials v1.17.56 h1:JKMBreKudV+ozx6rZJLvEtiexv48aEdhdC7mXUw9MLs=
github.com/aws/aws-sdk-go-v2/credentials v1.17.56/go.mod h1:S3xRjIHD8HHFgMTz4L56q/7IldfNtGL9JjH/vP3U6DA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.26 h1:XMBqBEuZLf8yxtH+mU/uUDyQbN4iD/xv9h6he2+lzhw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.26/go.mod h1:d0+wQ/3CYGPuHEfBTPpQdfUX7gjk0/Lxs5Q6KzdEGY8=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.56 h1:HcdORgkGzutGk89ANc5eKH3X4e2yRj/4L2yOfutGrXo=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.56/go.mod h1:ieBcO2kMlND/68XrpzLUWI9yc5gsJU8SyFApKO2dpj0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.30 h1:+7AzSGNhHoY53di13lvztf9Dyd/9ofzoYGBllkWp3a0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.30/go.mod h1:Jxd/FrCny99yURiQiMywgXvBhd7tmgdv6KdlUTNzMSo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.30 h1:Ex06eY6I5rO7IX0HalGfa5nGjpBoOsS1Qm3xfjkuszs=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.30/go.mod h1:AvyEMA9QcX59kFhVizBpIBpEMThUTXssuJe+emBdcGM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.30 h1:yQSv0NQ4CRHoki6AcV/Ldoa4/QCMJauZkF23qznBCPQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.30/go.mod h1:jH3z32wDrsducaYX26xnl41ksYFWqjHphIciwIANZkc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.4 h1:iwk7v5+lUtA0cIQcQM6EyCXtQJZ9MGIWWaf0JKud5UE=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.4/go.mod h1:o9mSr0x1NwImSmP9q38aTUhjYwcDm277YUURBjXcC2I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.11 h1:5JKQ2J3BBW4ovy6A/5Lwx9SpA6IzgH8jB3bquGZ1NUw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.11/go.mod h1:VShCk7rfCzK/b9U1aSkzLwcOoaDlYna16482QqEavis=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.11 h1:P8qJcYGVDswlMkVFhMi7SJmlf0jNA0JRbvE/q2PuXD8=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.11/go.mod h1:9yp5x5vYwyhnZZ9cKLBxZmrJTGv99C9iVmG7AKeUvdc=
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.1 h1:hbTWOPUgAnPpk5+G1jZjYnq4eKCAePwRJEqLN1Tj7Bg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.1/go.mod h1:Mo2xdnRzOyZQkGHEbhOgooG0eIV+GqS/g8LU4B5iftI=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.13 h1:q4pOAKxypbFoUJzOpgo939bF50qb4DgYshiDfcsdN0M=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.13/go.mod h1:G/0PTg7+vQT42ictQGjJhixzTcVZtHFvrN/OeTXrRfQ=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.12 h1:4sGSGshSSfO1vrcXruPick3ioSf8nhhD6nuB2ni37P4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.12/go.mod h1:NHpu/pLOelViA4qxkAFH10VLqh+XeLhZfXDaFyMVgSs=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.11 h1:RIXOjp7Dp4siCYJRwBHUcBdVgOWflSJGlq4ZhMI5Ta0=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.11/go.mod h1:ZR17k9bPKPR8u0IkyA6xVsjr56doNQ4ZB1fs7abYBfE=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3 h1:MXsAuToxwsTn5BEEYm2DheqIiC4jWGmkEJ1uy+KFhvQ=
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc h1:MKYt39yZJi0Z9xEeRmDX2L4ocE0ETKcHKw6MVL3R+co=
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc/go.mod h1:VULptt4Q/fNzQUJlqY/GP3qHyU7ZH46mFkBZe0ZTokU=
github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c h1:mxWGS0YyquJ/ikZOjSrRjjFIbUqIP9ojyYQ+QZTU3Rg=
github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20250314160716-c55ecee183c0 h1:jTwdYTGERaZ/3+glBUVQZV2NwGodd9HlkXJbTBUPLLo=
github.com/dop251/goja_nodejs v0.0.0-20250314160716-c55ecee183c0/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -96,61 +30,23 @@ github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3G
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -167,7 +63,6 @@ github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119 h1:TjQtEReJDTpvlNFTRjuHvPQpJHAeJdcQF130eCAAT/o=
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@@ -175,154 +70,69 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.219.0 h1:nnKIvxKs/06jWawp2liznTBnMRQBEPpGo7I+oEypTX0=
google.golang.org/api v0.219.0/go.mod h1:K6OmjGm+NtLrIkHxv1U3a0qIf/0JOvAHd5O/6AoyKYE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 h1:J1H9f+LEdWAfHcez/4cvaVBox7cOYT+IU6rgqj5x++8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+2 -2
View File
@@ -10,8 +10,8 @@ import (
)
const (
expectedDriverVersion = "v1.34.5"
expectedLibcVersion = "v1.55.3"
expectedDriverVersion = "v1.36.2"
expectedLibcVersion = "v1.61.13"
// ModerncDepsCheckHookId is the id of the hook that performs the modernc.org/* deps checks.
// It could be used for removing/unbinding the hook if you don't want the checks.
+103 -80
View File
@@ -13,6 +13,7 @@ import (
"path/filepath"
"reflect"
"slices"
"sort"
"strings"
"time"
@@ -58,7 +59,7 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
loader.Set(jsName, func(callback string, tags ...string) {
// overwrite the global $app with the hook scoped instance
callback = `function(e) { $app = e.app; return (` + callback + `).call(undefined, e) }`
pr := goja.MustCompile("", "{("+callback+").apply(undefined, __args)}", true)
pr := goja.MustCompile(defaultScriptPath, "{("+callback+").apply(undefined, __args)}", true)
tagsAsValues := make([]reflect.Value, len(tags))
for i, tag := range tags {
@@ -82,11 +83,9 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
// (legacy) check for returned Go error value
if res != nil {
if resErr, ok := res.Export().(error); ok {
return resErr
}
// check for returned Go error value
if resErr := checkGojaValueForError(app, res); resErr != nil {
return resErr
}
return normalizeException(err)
@@ -102,8 +101,8 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
}
func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
loader.Set("cronAdd", func(jobId, cronExpr, handler string) {
pr := goja.MustCompile("", "{("+handler+").apply(undefined)}", true)
cronAdd := func(jobId, cronExpr, handler string) {
pr := goja.MustCompile(defaultScriptPath, "{("+handler+").apply(undefined)}", true)
err := app.Cron().Add(jobId, cronExpr, func() {
err := executors.run(func(executor *goja.Runtime) error {
@@ -122,28 +121,27 @@ func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
if err != nil {
panic("[cronAdd] failed to register cron job " + jobId + ": " + err.Error())
}
})
}
loader.Set("cronAdd", cronAdd)
// note: it is not necessary needed but it is here for consistency
loader.Set("cronRemove", func(jobId string) {
cronRemove := func(jobId string) {
app.Cron().Remove(jobId)
})
}
loader.Set("cronRemove", cronRemove)
// register the removal helper also in the executors to allow removing cron jobs from everywhere
oldFactory := executors.factory
executors.factory = func() *goja.Runtime {
vm := oldFactory()
vm.Set("cronRemove", func(jobId string) {
app.Cron().Remove(jobId)
})
vm.Set("cronAdd", cronAdd)
vm.Set("cronRemove", cronRemove)
return vm
}
for _, item := range executors.items {
item.vm.Set("cronRemove", func(jobId string) {
app.Cron().Remove(jobId)
})
item.vm.Set("cronAdd", cronAdd)
item.vm.Set("cronRemove", cronRemove)
}
}
@@ -189,7 +187,7 @@ func wrapHandlerFunc(executors *vmsPool, handler goja.Value) (func(*core.Request
// "native" handler func - no need to wrap
return h, nil
case func(goja.FunctionCall) goja.Value, string:
pr := goja.MustCompile("", "{("+handler.String()+").apply(undefined, __args)}", true)
pr := goja.MustCompile(defaultScriptPath, "{("+handler.String()+").apply(undefined, __args)}", true)
wrappedHandler := func(e *core.RequestEvent) error {
return executors.run(func(executor *goja.Runtime) error {
@@ -198,11 +196,9 @@ func wrapHandlerFunc(executors *vmsPool, handler goja.Value) (func(*core.Request
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
// (legacy) check for returned Go error value
if res != nil {
if v, ok := res.Export().(error); ok {
return v
}
// check for returned Go error value
if resErr := checkGojaValueForError(e.App, res); resErr != nil {
return resErr
}
return normalizeException(err)
@@ -243,7 +239,7 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
return nil, errors.New("missing or invalid Middleware function")
}
pr := goja.MustCompile("", "{("+v.serializedFunc+").apply(undefined, __args)}", true)
pr := goja.MustCompile(defaultScriptPath, "{("+v.serializedFunc+").apply(undefined, __args)}", true)
wrappedMiddlewares[i] = &hook.Handler[*core.RequestEvent]{
Id: v.id,
@@ -255,11 +251,9 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
// (legacy) check for returned Go error value
if res != nil {
if v, ok := res.Export().(error); ok {
return v
}
// check for returned Go error value
if resErr := checkGojaValueForError(e.App, res); resErr != nil {
return resErr
}
return normalizeException(err)
@@ -267,7 +261,7 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
},
}
case func(goja.FunctionCall) goja.Value, string:
pr := goja.MustCompile("", "{("+m.String()+").apply(undefined, __args)}", true)
pr := goja.MustCompile(defaultScriptPath, "{("+m.String()+").apply(undefined, __args)}", true)
wrappedMiddlewares[i] = &hook.Handler[*core.RequestEvent]{
Func: func(e *core.RequestEvent) error {
@@ -277,11 +271,9 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
// (legacy) check for returned Go error value
if res != nil {
if v, ok := res.Export().(error); ok {
return v
}
// check for returned Go error value
if resErr := checkGojaValueForError(e.App, res); resErr != nil {
return resErr
}
return normalizeException(err)
@@ -715,6 +707,7 @@ func osBinds(vm *goja.Runtime) {
obj.Set("exit", os.Exit)
obj.Set("getenv", os.Getenv)
obj.Set("dirFS", os.DirFS)
obj.Set("stat", os.Stat)
obj.Set("readFile", os.ReadFile)
obj.Set("writeFile", os.WriteFile)
obj.Set("readDir", os.ReadDir)
@@ -918,6 +911,29 @@ func httpClientBinds(vm *goja.Runtime) {
// -------------------------------------------------------------------
// checkGojaValueForError resolves the provided goja.Value and tries
// to extract its underlying error value (if any).
func checkGojaValueForError(app core.App, value goja.Value) error {
if value == nil {
return nil
}
exported := value.Export()
switch v := exported.(type) {
case error:
return v
case *goja.Promise:
// Promise as return result is not officially supported but try to
// resolve any thrown exception to avoid silently ignoring it
app.Logger().Warn("the handler must a non-async function and not return a Promise")
if promiseErr, ok := v.Result().Export().(error); ok {
return normalizeException(promiseErr)
}
}
return nil
}
// normalizeException checks if the provided error is a goja.Exception
// and attempts to return its underlying Go error.
//
@@ -1019,11 +1035,19 @@ func structConstructorUnmarshal(vm *goja.Runtime, call goja.ConstructorCall, ins
return instanceValue
}
var cachedDynamicModels = store.New[string, *dynamicModelType](nil)
var cachedDynamicModelStructs = store.New[string, reflect.Type](nil)
// newDynamicModel creates a new dynamic struct with fields based
// on the specified "shape".
//
// The "shape" values are used as defaults and could be of type:
// - int (ex. 0)
// - float (ex. -0)
// - string (ex. "")
// - bool (ex. false)
// - slice (ex. [])
// - map (ex. map[string]any{})
//
// Example:
//
// m := newDynamicModel(map[string]any{
@@ -1031,46 +1055,21 @@ var cachedDynamicModels = store.New[string, *dynamicModelType](nil)
// "total": 0,
// })
func newDynamicModel(shape map[string]any) any {
var modelType *dynamicModelType
info := make([]*shapeFieldInfo, 0, len(shape))
shapeRaw, err := json.Marshal(shape)
if err != nil {
modelType = getDynamicModelStruct(shape)
} else {
modelType = cachedDynamicModels.GetOrSet(string(shapeRaw), func() *dynamicModelType {
return getDynamicModelStruct(shape)
})
var hash strings.Builder
sortedKeys := make([]string, 0, len(shape))
for k := range shape {
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)
rvShapeValues := make([]reflect.Value, len(modelType.shapeValues))
for i, v := range modelType.shapeValues {
rvShapeValues[i] = reflect.ValueOf(v)
}
elem := reflect.New(modelType.structType).Elem()
for i, v := range rvShapeValues {
elem.Field(i).Set(v)
}
return elem.Addr().Interface()
}
type dynamicModelType struct {
structType reflect.Type
shapeValues []any
}
func getDynamicModelStruct(shape map[string]any) *dynamicModelType {
result := new(dynamicModelType)
result.shapeValues = make([]any, 0, len(shape))
structFields := make([]reflect.StructField, 0, len(shape))
for k, v := range shape {
for _, k := range sortedKeys {
v := shape[k]
vt := reflect.TypeOf(v)
switch kind := vt.Kind(); kind {
switch vt.Kind() {
case reflect.Map:
raw, _ := json.Marshal(v)
newV := types.JSONMap[any]{}
@@ -1085,16 +1084,40 @@ func getDynamicModelStruct(shape map[string]any) *dynamicModelType {
vt = reflect.TypeOf(newV)
}
result.shapeValues = append(result.shapeValues, v)
hash.WriteString(k)
hash.WriteString(":")
hash.WriteString(vt.String()) // it doesn't guarantee to be unique across all types but it should be fine with the primitive types DynamicModel is used
hash.WriteString("|")
structFields = append(structFields, reflect.StructField{
Name: inflector.UcFirst(k), // ensures that the field is exportable
Type: vt,
Tag: reflect.StructTag(`db:"` + k + `" json:"` + k + `" form:"` + k + `"`),
})
info = append(info, &shapeFieldInfo{key: k, value: v, valueType: vt})
}
result.structType = reflect.StructOf(structFields)
st := cachedDynamicModelStructs.GetOrSet(hash.String(), func() reflect.Type {
structFields := make([]reflect.StructField, len(info))
return result
for i, item := range info {
structFields[i] = reflect.StructField{
Name: inflector.UcFirst(item.key), // ensures that the field is exportable
Type: item.valueType,
Tag: reflect.StructTag(`db:"` + item.key + `" json:"` + item.key + `" form:"` + item.key + `"`),
}
}
return reflect.StructOf(structFields)
})
elem := reflect.New(st).Elem()
// load default values into the new model
for i, item := range info {
elem.Field(i).Set(reflect.ValueOf(item.value))
}
return elem.Addr().Interface()
}
type shapeFieldInfo struct {
value any
valueType reflect.Type
key string
}
+105 -16
View File
@@ -83,7 +83,7 @@ func TestBaseBindsReaderToString(t *testing.T) {
}
}
func TestBaseBindsToString(t *testing.T) {
func TestBaseBindsToStringAndToBytes(t *testing.T) {
vm := goja.New()
baseBinds(vm)
vm.Set("scenarios", []struct {
@@ -1137,6 +1137,65 @@ func TestLoadingDynamicModel(t *testing.T) {
}
}
func TestDynamicModelMapFieldCaching(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
baseBinds(vm)
dbxBinds(vm)
vm.Set("$app", app)
_, err := vm.RunString(`
let m1 = new DynamicModel({
int: 0,
float: -0,
text: "",
bool: false,
obj: {},
arr: [],
})
let m2 = new DynamicModel({
int: 0,
float: -0,
text: "",
bool: false,
obj: {},
arr: [],
})
m1.int = 1
m1.float = 1.5
m1.text = "a"
m1.bool = true
m1.obj.set("a", 1)
m1.arr.push(1)
m2.int = 2
m2.float = 2.5
m2.text = "b"
m2.bool = false
m2.obj.set("b", 1)
m2.arr.push(2)
let m1Expected = '{"arr":[1],"bool":true,"float":1.5,"int":1,"obj":{"a":1},"text":"a"}';
let m1Serialized = JSON.stringify(m1);
if (m1Serialized != m1Expected) {
throw new Error("Expected m1 \n" + m1Expected + "\ngot\n" + m1Serialized);
}
let m2Expected = '{"arr":[2],"bool":false,"float":2.5,"int":2,"obj":{"b":1},"text":"b"}';
let m2Serialized = JSON.stringify(m2);
if (m2Serialized != m2Expected) {
throw new Error("Expected m2 \n" + m2Expected + "\ngot\n" + m2Serialized);
}
`)
if err != nil {
t.Fatal(err)
}
}
func TestLoadingArrayOf(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -1379,7 +1438,7 @@ func TestCronBindsCount(t *testing.T) {
testBindsCount(vm, "this", 2, t)
pool.run(func(poolVM *goja.Runtime) error {
testBindsCount(poolVM, "this", 1, t)
testBindsCount(poolVM, "this", 2, t)
return nil
})
}
@@ -1538,13 +1597,14 @@ func TestRouterBinds(t *testing.T) {
defer app.Cleanup()
result := &struct {
AddCount int
WithCount int
RouteMiddlewareCalls int
GlobalMiddlewareCalls int
}{}
vmFactory := func() *goja.Runtime {
vm := goja.New()
baseBinds(vm)
apisBinds(vm)
vm.Set("$app", app)
vm.Set("result", result)
return vm
@@ -1557,14 +1617,20 @@ func TestRouterBinds(t *testing.T) {
_, err := vm.RunString(`
routerAdd("GET", "/test", (e) => {
result.addCount++;
result.routeMiddlewareCalls++;
}, (e) => {
result.addCount++;
result.routeMiddlewareCalls++;
return e.next();
})
// Promise is not technically supported as return result
// but we try to resolve it at least for thrown errors
routerAdd("GET", "/error", async (e) => {
throw new ApiError(456, 'test', null)
})
routerUse((e) => {
result.withCount++;
result.globalMiddlewareCalls++;
return e.next();
})
@@ -1585,21 +1651,44 @@ func TestRouterBinds(t *testing.T) {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
mux, err := serveEvent.Router.BuildMux()
if err != nil {
t.Fatalf("Failed to build router mux: %v", err)
}
mux.ServeHTTP(rec, req)
if result.AddCount != 2 {
t.Fatalf("Expected AddCount %d, got %d", 2, result.AddCount)
scenarios := []struct {
method string
path string
expectedRouteMiddlewareCalls int
expectedGlobalMiddlewareCalls int
expectedCode int
}{
{"GET", "/test", 2, 1, 200},
{"GET", "/error", 0, 1, 456},
}
if result.WithCount != 1 {
t.Fatalf("Expected WithCount %d, got %d", 1, result.WithCount)
for _, s := range scenarios {
t.Run(s.method+" "+s.path, func(t *testing.T) {
// reset
result.RouteMiddlewareCalls = 0
result.GlobalMiddlewareCalls = 0
rec := httptest.NewRecorder()
req := httptest.NewRequest(s.method, s.path, nil)
mux.ServeHTTP(rec, req)
if result.RouteMiddlewareCalls != s.expectedRouteMiddlewareCalls {
t.Fatalf("Expected RouteMiddlewareCalls %d, got %d", s.expectedRouteMiddlewareCalls, result.RouteMiddlewareCalls)
}
if result.GlobalMiddlewareCalls != s.expectedGlobalMiddlewareCalls {
t.Fatalf("Expected GlobalMiddlewareCalls %d, got %d", s.expectedGlobalMiddlewareCalls, result.GlobalMiddlewareCalls)
}
if rec.Code != s.expectedCode {
t.Fatalf("Expected status code %d, got %d", s.expectedCode, rec.Code)
}
})
}
}
@@ -1614,5 +1703,5 @@ func TestOsBindsCount(t *testing.T) {
vm := goja.New()
osBinds(vm)
testBindsCount(vm, "$os", 17, t)
testBindsCount(vm, "$os", 18, t)
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -882,6 +882,7 @@ declare namespace $os {
export let dirFS: os.dirFS
export let readFile: os.readFile
export let writeFile: os.writeFile
export let stat: os.stat
export let readDir: os.readDir
export let tempDir: os.tempDir
export let truncate: os.truncate
@@ -1157,7 +1158,6 @@ func main() {
"pflag.*": "any",
"flag.*": "any",
"log.*": "any",
"aws.*": "any",
"http.Client": "any",
"mail.Address": "{ address: string; name?: string; }", // prevents the LSP to complain in case no name is provided
},
+20 -2
View File
@@ -35,6 +35,24 @@ import (
const typesFileName = "types.d.ts"
var defaultScriptPath = "pb.js"
func init() {
// For backward compatibility and consistency with the Go exposed
// methods that operate with relative paths (e.g. `$os.writeFile`),
// we define the "current JS module" as if it is a file in the current working directory
// (the filename itself doesn't really matter and in our case the hook handlers are executed as separate "programs").
//
// This is necessary for `require(module)` to properly traverse parents node_modules (goja_nodejs#95).
cwd, err := os.Getwd()
if err != nil {
// truly rare case, log just for debug purposes
color.Yellow("Failed to retrieve the current working directory: %v", err)
} else {
defaultScriptPath = filepath.Join(cwd, defaultScriptPath)
}
}
// Config defines the config options of the jsvm plugin.
type Config struct {
// OnInit is an optional function that will be called
@@ -191,7 +209,7 @@ func (p *plugin) registerMigrations() error {
p.config.OnInit(vm)
}
_, err := vm.RunString(string(content))
_, err := vm.RunScript(defaultScriptPath, string(content))
if err != nil {
return fmt.Errorf("failed to run migration %s: %w", file, err)
}
@@ -306,7 +324,7 @@ func (p *plugin) registerHooks() error {
}
}()
_, err := loader.RunString(string(content))
_, err := loader.RunScript(defaultScriptPath, string(content))
if err != nil {
panic(err)
}
+1
View File
@@ -141,6 +141,7 @@ func (p *Apple) parseAndVerifyIdToken(idToken string) (jwt.MapClaims, error) {
jwtValidator := jwt.NewValidator(
jwt.WithExpirationRequired(),
jwt.WithIssuedAt(),
jwt.WithLeeway(idTokenLeeway),
jwt.WithIssuer("https://appleid.apple.com"),
jwt.WithAudience(p.clientId),
)
+17
View File
@@ -10,7 +10,10 @@ import (
"io"
"math/big"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/pocketbase/tools/security"
@@ -19,10 +22,23 @@ import (
"golang.org/x/oauth2"
)
// idTokenLeeway is the optional leeway for the id_token timestamp fields validation.
//
// It can be changed externally using the PB_ID_TOKEN_LEEWAY env variable
// (the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute).
var idTokenLeeway time.Duration = 5 * time.Minute
func init() {
Providers[NameOIDC] = wrapFactory(NewOIDCProvider)
Providers[NameOIDC+"2"] = wrapFactory(NewOIDCProvider)
Providers[NameOIDC+"3"] = wrapFactory(NewOIDCProvider)
if leewayStr := os.Getenv("PB_ID_TOKEN_LEEWAY"); leewayStr != "" {
leeway, err := strconv.Atoi(leewayStr)
if err == nil {
idTokenLeeway = time.Duration(leeway) * time.Second
}
}
}
var _ Provider = (*OIDC)(nil)
@@ -132,6 +148,7 @@ func (p *OIDC) parseIdToken(token *oauth2.Token) (jwt.MapClaims, error) {
// validate common claims
jwtValidator := jwt.NewValidator(
jwt.WithIssuedAt(),
jwt.WithLeeway(idTokenLeeway),
jwt.WithAudience(p.clientId),
)
err = jwtValidator.Validate(claims)
+734
View File
@@ -0,0 +1,734 @@
// Package blob defines a lightweight abstration for interacting with
// various storage services (local filesystem, S3, etc.).
//
// NB!
// For compatibility with earlier PocketBase versions and to prevent
// unnecessary breaking changes, this package is based and implemented
// as a minimal, stripped down version of the previously used gocloud.dev/blob.
// While there is no promise that it won't diverge in the future to accommodate
// better some PocketBase specific use cases, currently it copies and
// tries to follow as close as possible the same implementations,
// conventions and rules for the key escaping/unescaping, blob read/write
// interfaces and struct options as gocloud.dev/blob, therefore the
// credits goes to the original Go Cloud Development Kit Authors.
package blob
import (
"bytes"
"context"
"crypto/md5"
"errors"
"fmt"
"io"
"log"
"mime"
"runtime"
"strings"
"sync"
"time"
"unicode/utf8"
)
var (
ErrNotFound = errors.New("resource not found")
ErrClosed = errors.New("bucket or blob is closed")
)
// Bucket provides an easy and portable way to interact with blobs
// within a "bucket", including read, write, and list operations.
// To create a Bucket, use constructors found in driver subpackages.
type Bucket struct {
drv Driver
// mu protects the closed variable.
// Read locks are kept to allow holding a read lock for long-running calls,
// and thereby prevent closing until a call finishes.
mu sync.RWMutex
closed bool
}
// NewBucket creates a new *Bucket based on a specific driver implementation.
func NewBucket(drv Driver) *Bucket {
return &Bucket{drv: drv}
}
// ListOptions sets options for listing blobs via Bucket.List.
type ListOptions struct {
// Prefix indicates that only blobs with a key starting with this prefix
// should be returned.
Prefix string
// Delimiter sets the delimiter used to define a hierarchical namespace,
// like a filesystem with "directories". It is highly recommended that you
// use "" or "/" as the Delimiter. Other values should work through this API,
// but service UIs generally assume "/".
//
// An empty delimiter means that the bucket is treated as a single flat
// namespace.
//
// A non-empty delimiter means that any result with the delimiter in its key
// after Prefix is stripped will be returned with ListObject.IsDir = true,
// ListObject.Key truncated after the delimiter, and zero values for other
// ListObject fields. These results represent "directories". Multiple results
// in a "directory" are returned as a single result.
Delimiter string
// PageSize sets the maximum number of objects to be returned.
// 0 means no maximum; driver implementations should choose a reasonable
// max. It is guaranteed to be >= 0.
PageSize int
// PageToken may be filled in with the NextPageToken from a previous
// ListPaged call.
PageToken []byte
}
// ListPage represents a page of results return from ListPaged.
type ListPage struct {
// Objects is the slice of objects found. If ListOptions.PageSize > 0,
// it should have at most ListOptions.PageSize entries.
//
// Objects should be returned in lexicographical order of UTF-8 encoded keys,
// including across pages. I.e., all objects returned from a ListPage request
// made using a PageToken from a previous ListPage request's NextPageToken
// should have Key >= the Key for all objects from the previous request.
Objects []*ListObject `json:"objects"`
// NextPageToken should be left empty unless there are more objects
// to return. The value may be returned as ListOptions.PageToken on a
// subsequent ListPaged call, to fetch the next page of results.
// It can be an arbitrary []byte; it need not be a valid key.
NextPageToken []byte `json:"nextPageToken"`
}
// ListIterator iterates over List results.
type ListIterator struct {
b *Bucket
opts *ListOptions
page *ListPage
nextIdx int
}
// Next returns a *ListObject for the next blob.
// It returns (nil, io.EOF) if there are no more.
func (i *ListIterator) Next(ctx context.Context) (*ListObject, error) {
if i.page != nil {
// We've already got a page of results.
if i.nextIdx < len(i.page.Objects) {
// Next object is in the page; return it.
dobj := i.page.Objects[i.nextIdx]
i.nextIdx++
return &ListObject{
Key: dobj.Key,
ModTime: dobj.ModTime,
Size: dobj.Size,
MD5: dobj.MD5,
IsDir: dobj.IsDir,
}, nil
}
if len(i.page.NextPageToken) == 0 {
// Done with current page, and there are no more; return io.EOF.
return nil, io.EOF
}
// We need to load the next page.
i.opts.PageToken = i.page.NextPageToken
}
i.b.mu.RLock()
defer i.b.mu.RUnlock()
if i.b.closed {
return nil, ErrClosed
}
// Loading a new page.
p, err := i.b.drv.ListPaged(ctx, i.opts)
if err != nil {
return nil, wrapError(i.b.drv, err, "")
}
i.page = p
i.nextIdx = 0
return i.Next(ctx)
}
// ListObject represents a single blob returned from List.
type ListObject struct {
// Key is the key for this blob.
Key string `json:"key"`
// ModTime is the time the blob was last modified.
ModTime time.Time `json:"modTime"`
// Size is the size of the blob's content in bytes.
Size int64 `json:"size"`
// MD5 is an MD5 hash of the blob contents or nil if not available.
MD5 []byte `json:"md5"`
// IsDir indicates that this result represents a "directory" in the
// hierarchical namespace, ending in ListOptions.Delimiter. Key can be
// passed as ListOptions.Prefix to list items in the "directory".
// Fields other than Key and IsDir will not be set if IsDir is true.
IsDir bool `json:"isDir"`
}
// List returns a ListIterator that can be used to iterate over blobs in a
// bucket, in lexicographical order of UTF-8 encoded keys. The underlying
// implementation fetches results in pages.
//
// A nil ListOptions is treated the same as the zero value.
//
// List is not guaranteed to include all recently-written blobs;
// some services are only eventually consistent.
func (b *Bucket) List(opts *ListOptions) *ListIterator {
if opts == nil {
opts = &ListOptions{}
}
dopts := &ListOptions{
Prefix: opts.Prefix,
Delimiter: opts.Delimiter,
}
return &ListIterator{b: b, opts: dopts}
}
// FirstPageToken is the pageToken to pass to ListPage to retrieve the first page of results.
var FirstPageToken = []byte("first page")
// ListPage returns a page of ListObject results for blobs in a bucket, in lexicographical
// order of UTF-8 encoded keys.
//
// To fetch the first page, pass FirstPageToken as the pageToken. For subsequent pages, pass
// the pageToken returned from a previous call to ListPage.
// It is not possible to "skip ahead" pages.
//
// Each call will return pageSize results, unless there are not enough blobs to fill the
// page, in which case it will return fewer results (possibly 0).
//
// If there are no more blobs available, ListPage will return an empty pageToken. Note that
// this may happen regardless of the number of returned results -- the last page might have
// 0 results (i.e., if the last item was deleted), pageSize results, or anything in between.
//
// Calling ListPage with an empty pageToken will immediately return io.EOF. When looping
// over pages, callers can either check for an empty pageToken, or they can make one more
// call and check for io.EOF.
//
// The underlying implementation fetches results in pages, but one call to ListPage may
// require multiple page fetches (and therefore, multiple calls to the BeforeList callback).
//
// A nil ListOptions is treated the same as the zero value.
//
// ListPage is not guaranteed to include all recently-written blobs;
// some services are only eventually consistent.
func (b *Bucket) ListPage(ctx context.Context, pageToken []byte, pageSize int, opts *ListOptions) (retval []*ListObject, nextPageToken []byte, err error) {
if opts == nil {
opts = &ListOptions{}
}
if pageSize <= 0 {
return nil, nil, fmt.Errorf("pageSize must be > 0 (%d)", pageSize)
}
// Nil pageToken means no more results.
if len(pageToken) == 0 {
return nil, nil, io.EOF
}
// FirstPageToken fetches the first page. Drivers use nil.
// The public API doesn't use nil for the first page because it would be too easy to
// keep fetching forever (since the last page return nil for the next pageToken).
if bytes.Equal(pageToken, FirstPageToken) {
pageToken = nil
}
b.mu.RLock()
defer b.mu.RUnlock()
if b.closed {
return nil, nil, ErrClosed
}
dopts := &ListOptions{
Prefix: opts.Prefix,
Delimiter: opts.Delimiter,
PageToken: pageToken,
PageSize: pageSize,
}
retval = make([]*ListObject, 0, pageSize)
for len(retval) < pageSize {
p, err := b.drv.ListPaged(ctx, dopts)
if err != nil {
return nil, nil, wrapError(b.drv, err, "")
}
for _, dobj := range p.Objects {
retval = append(retval, &ListObject{
Key: dobj.Key,
ModTime: dobj.ModTime,
Size: dobj.Size,
MD5: dobj.MD5,
IsDir: dobj.IsDir,
})
}
// ListPaged may return fewer results than pageSize. If there are more results
// available, signalled by non-empty p.NextPageToken, try to fetch the remainder
// of the page.
// It does not work to ask for more results than we need, because then we'd have
// a NextPageToken on a non-page boundary.
dopts.PageSize = pageSize - len(retval)
dopts.PageToken = p.NextPageToken
if len(dopts.PageToken) == 0 {
dopts.PageToken = nil
break
}
}
return retval, dopts.PageToken, nil
}
// Attributes contains attributes about a blob.
type Attributes struct {
// CacheControl specifies caching attributes that services may use
// when serving the blob.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
CacheControl string `json:"cacheControl"`
// ContentDisposition specifies whether the blob content is expected to be
// displayed inline or as an attachment.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
ContentDisposition string `json:"contentDisposition"`
// ContentEncoding specifies the encoding used for the blob's content, if any.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
ContentEncoding string `json:"contentEncoding"`
// ContentLanguage specifies the language used in the blob's content, if any.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language
ContentLanguage string `json:"contentLanguage"`
// ContentType is the MIME type of the blob. It will not be empty.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
ContentType string `json:"contentType"`
// Metadata holds key/value pairs associated with the blob.
// Keys are guaranteed to be in lowercase, even if the backend service
// has case-sensitive keys (although note that Metadata written via
// this package will always be lowercased). If there are duplicate
// case-insensitive keys (e.g., "foo" and "FOO"), only one value
// will be kept, and it is undefined which one.
Metadata map[string]string `json:"metadata"`
// CreateTime is the time the blob was created, if available. If not available,
// CreateTime will be the zero time.
CreateTime time.Time `json:"createTime"`
// ModTime is the time the blob was last modified.
ModTime time.Time `json:"modTime"`
// Size is the size of the blob's content in bytes.
Size int64 `json:"size"`
// MD5 is an MD5 hash of the blob contents or nil if not available.
MD5 []byte `json:"md5"`
// ETag for the blob; see https://en.wikipedia.org/wiki/HTTP_ETag.
ETag string `json:"etag"`
}
// Attributes returns attributes for the blob stored at key.
//
// If the blob does not exist, Attributes returns an error for which
// gcerrors.Code will return gcerrors.NotFound.
func (b *Bucket) Attributes(ctx context.Context, key string) (_ *Attributes, err error) {
if !utf8.ValidString(key) {
return nil, fmt.Errorf("Attributes key must be a valid UTF-8 string: %q", key)
}
b.mu.RLock()
defer b.mu.RUnlock()
if b.closed {
return nil, ErrClosed
}
a, err := b.drv.Attributes(ctx, key)
if err != nil {
return nil, wrapError(b.drv, err, key)
}
var md map[string]string
if len(a.Metadata) > 0 {
// Services are inconsistent, but at least some treat keys
// as case-insensitive. To make the behavior consistent, we
// force-lowercase them when writing and reading.
md = make(map[string]string, len(a.Metadata))
for k, v := range a.Metadata {
md[strings.ToLower(k)] = v
}
}
return &Attributes{
CacheControl: a.CacheControl,
ContentDisposition: a.ContentDisposition,
ContentEncoding: a.ContentEncoding,
ContentLanguage: a.ContentLanguage,
ContentType: a.ContentType,
Metadata: md,
CreateTime: a.CreateTime,
ModTime: a.ModTime,
Size: a.Size,
MD5: a.MD5,
ETag: a.ETag,
}, nil
}
// Exists returns true if a blob exists at key, false if it does not exist, or
// an error.
//
// It is a shortcut for calling Attributes and checking if it returns an error
// with code ErrNotFound.
func (b *Bucket) Exists(ctx context.Context, key string) (bool, error) {
_, err := b.Attributes(ctx, key)
if err == nil {
return true, nil
}
if errors.Is(err, ErrNotFound) {
return false, nil
}
return false, err
}
// NewReader is a shortcut for NewRangeReader with offset=0 and length=-1.
func (b *Bucket) NewReader(ctx context.Context, key string) (*Reader, error) {
return b.newRangeReader(ctx, key, 0, -1)
}
// NewRangeReader returns a Reader to read content from the blob stored at key.
// It reads at most length bytes starting at offset (>= 0).
// If length is negative, it will read till the end of the blob.
//
// For the purposes of Seek, the returned Reader will start at offset and
// end at the minimum of the actual end of the blob or (if length > 0) offset + length.
//
// Note that ctx is used for all reads performed during the lifetime of the reader.
//
// If the blob does not exist, NewRangeReader returns an error for which
// gcerrors.Code will return gcerrors.NotFound. Exists is a lighter-weight way
// to check for existence.
//
// A nil ReaderOptions is treated the same as the zero value.
//
// The caller must call Close on the returned Reader when done reading.
func (b *Bucket) NewRangeReader(ctx context.Context, key string, offset, length int64) (_ *Reader, err error) {
return b.newRangeReader(ctx, key, offset, length)
}
func (b *Bucket) newRangeReader(ctx context.Context, key string, offset, length int64) (_ *Reader, err error) {
b.mu.RLock()
defer b.mu.RUnlock()
if b.closed {
return nil, ErrClosed
}
if offset < 0 {
return nil, fmt.Errorf("NewRangeReader offset must be non-negative (%d)", offset)
}
if !utf8.ValidString(key) {
return nil, fmt.Errorf("NewRangeReader key must be a valid UTF-8 string: %q", key)
}
var dr DriverReader
dr, err = b.drv.NewRangeReader(ctx, key, offset, length)
if err != nil {
return nil, wrapError(b.drv, err, key)
}
r := &Reader{
drv: b.drv,
r: dr,
key: key,
ctx: ctx,
baseOffset: offset,
baseLength: length,
savedOffset: -1,
}
_, file, lineno, ok := runtime.Caller(2)
runtime.SetFinalizer(r, func(r *Reader) {
if !r.closed {
var caller string
if ok {
caller = fmt.Sprintf(" (%s:%d)", file, lineno)
}
log.Printf("A blob.Reader reading from %q was never closed%s", key, caller)
}
})
return r, nil
}
// WriterOptions sets options for NewWriter.
type WriterOptions struct {
// BufferSize changes the default size in bytes of the chunks that
// Writer will upload in a single request; larger blobs will be split into
// multiple requests.
//
// This option may be ignored by some drivers.
//
// If 0, the driver will choose a reasonable default.
//
// If the Writer is used to do many small writes concurrently, using a
// smaller BufferSize may reduce memory usage.
BufferSize int
// MaxConcurrency changes the default concurrency for parts of an upload.
//
// This option may be ignored by some drivers.
//
// If 0, the driver will choose a reasonable default.
MaxConcurrency int
// CacheControl specifies caching attributes that services may use
// when serving the blob.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
CacheControl string
// ContentDisposition specifies whether the blob content is expected to be
// displayed inline or as an attachment.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
ContentDisposition string
// ContentEncoding specifies the encoding used for the blob's content, if any.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
ContentEncoding string
// ContentLanguage specifies the language used in the blob's content, if any.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language
ContentLanguage string
// ContentType specifies the MIME type of the blob being written. If not set,
// it will be inferred from the content using the algorithm described at
// http://mimesniff.spec.whatwg.org/.
// Set DisableContentTypeDetection to true to disable the above and force
// the ContentType to stay empty.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
ContentType string
// When true, if ContentType is the empty string, it will stay the empty
// string rather than being inferred from the content.
// Note that while the blob will be written with an empty string ContentType,
// most providers will fill one in during reads, so don't expect an empty
// ContentType if you read the blob back.
DisableContentTypeDetection bool
// ContentMD5 is used as a message integrity check.
// If len(ContentMD5) > 0, the MD5 hash of the bytes written must match
// ContentMD5, or Close will return an error without completing the write.
// https://tools.ietf.org/html/rfc1864
ContentMD5 []byte
// Metadata holds key/value strings to be associated with the blob, or nil.
// Keys may not be empty, and are lowercased before being written.
// Duplicate case-insensitive keys (e.g., "foo" and "FOO") will result in
// an error.
Metadata map[string]string
}
// NewWriter returns a Writer that writes to the blob stored at key.
// A nil WriterOptions is treated the same as the zero value.
//
// If a blob with this key already exists, it will be replaced.
// The blob being written is not guaranteed to be readable until Close
// has been called; until then, any previous blob will still be readable.
// Even after Close is called, newly written blobs are not guaranteed to be
// returned from List; some services are only eventually consistent.
//
// The returned Writer will store ctx for later use in Write and/or Close.
// To abort a write, cancel ctx; otherwise, it must remain open until
// Close is called.
//
// The caller must call Close on the returned Writer, even if the write is
// aborted.
func (b *Bucket) NewWriter(ctx context.Context, key string, opts *WriterOptions) (_ *Writer, err error) {
if !utf8.ValidString(key) {
return nil, fmt.Errorf("NewWriter key must be a valid UTF-8 string: %q", key)
}
if opts == nil {
opts = &WriterOptions{}
}
dopts := &WriterOptions{
CacheControl: opts.CacheControl,
ContentDisposition: opts.ContentDisposition,
ContentEncoding: opts.ContentEncoding,
ContentLanguage: opts.ContentLanguage,
ContentMD5: opts.ContentMD5,
BufferSize: opts.BufferSize,
MaxConcurrency: opts.MaxConcurrency,
DisableContentTypeDetection: opts.DisableContentTypeDetection,
}
if len(opts.Metadata) > 0 {
// Services are inconsistent, but at least some treat keys
// as case-insensitive. To make the behavior consistent, we
// force-lowercase them when writing and reading.
md := make(map[string]string, len(opts.Metadata))
for k, v := range opts.Metadata {
if k == "" {
return nil, errors.New("WriterOptions.Metadata keys may not be empty strings")
}
if !utf8.ValidString(k) {
return nil, fmt.Errorf("WriterOptions.Metadata keys must be valid UTF-8 strings: %q", k)
}
if !utf8.ValidString(v) {
return nil, fmt.Errorf("WriterOptions.Metadata values must be valid UTF-8 strings: %q", v)
}
lowerK := strings.ToLower(k)
if _, found := md[lowerK]; found {
return nil, fmt.Errorf("WriterOptions.Metadata has a duplicate case-insensitive metadata key: %q", lowerK)
}
md[lowerK] = v
}
dopts.Metadata = md
}
b.mu.RLock()
defer b.mu.RUnlock()
if b.closed {
return nil, ErrClosed
}
ctx, cancel := context.WithCancel(ctx)
w := &Writer{
drv: b.drv,
cancel: cancel,
key: key,
contentMD5: opts.ContentMD5,
md5hash: md5.New(),
}
if opts.ContentType != "" || opts.DisableContentTypeDetection {
var ct string
if opts.ContentType != "" {
t, p, err := mime.ParseMediaType(opts.ContentType)
if err != nil {
cancel()
return nil, err
}
ct = mime.FormatMediaType(t, p)
}
dw, err := b.drv.NewTypedWriter(ctx, key, ct, dopts)
if err != nil {
cancel()
return nil, wrapError(b.drv, err, key)
}
w.w = dw
} else {
// Save the fields needed to called NewTypedWriter later, once we've gotten
// sniffLen bytes; see the comment on Writer.
w.ctx = ctx
w.opts = dopts
w.buf = bytes.NewBuffer([]byte{})
}
_, file, lineno, ok := runtime.Caller(1)
runtime.SetFinalizer(w, func(w *Writer) {
if !w.closed {
var caller string
if ok {
caller = fmt.Sprintf(" (%s:%d)", file, lineno)
}
log.Printf("A blob.Writer writing to %q was never closed%s", key, caller)
}
})
return w, nil
}
// Copy the blob stored at srcKey to dstKey.
// A nil CopyOptions is treated the same as the zero value.
//
// If the source blob does not exist, Copy returns an error for which
// gcerrors.Code will return gcerrors.NotFound.
//
// If the destination blob already exists, it is overwritten.
func (b *Bucket) Copy(ctx context.Context, dstKey, srcKey string) (err error) {
if !utf8.ValidString(srcKey) {
return fmt.Errorf("Copy srcKey must be a valid UTF-8 string: %q", srcKey)
}
if !utf8.ValidString(dstKey) {
return fmt.Errorf("Copy dstKey must be a valid UTF-8 string: %q", dstKey)
}
b.mu.RLock()
defer b.mu.RUnlock()
if b.closed {
return ErrClosed
}
return wrapError(b.drv, b.drv.Copy(ctx, dstKey, srcKey), fmt.Sprintf("%s -> %s", srcKey, dstKey))
}
// Delete deletes the blob stored at key.
//
// If the blob does not exist, Delete returns an error for which
// gcerrors.Code will return gcerrors.NotFound.
func (b *Bucket) Delete(ctx context.Context, key string) (err error) {
if !utf8.ValidString(key) {
return fmt.Errorf("Delete key must be a valid UTF-8 string: %q", key)
}
b.mu.RLock()
defer b.mu.RUnlock()
if b.closed {
return ErrClosed
}
return wrapError(b.drv, b.drv.Delete(ctx, key), key)
}
// Close releases any resources used for the bucket.
func (b *Bucket) Close() error {
b.mu.Lock()
prev := b.closed
b.closed = true
b.mu.Unlock()
if prev {
return ErrClosed
}
return wrapError(b.drv, b.drv.Close(), "")
}
func wrapError(b Driver, err error, key string) error {
if err == nil {
return nil
}
// don't wrap or normalize EOF errors since there are many places
// in the standard library (e.g. io.ReadAll) that rely on checks
// such as "err == io.EOF" and they will fail
if errors.Is(err, io.EOF) {
return err
}
err = b.NormalizeError(err)
if key != "" {
err = fmt.Errorf("[key: %s] %w", key, err)
}
return err
}
+110
View File
@@ -0,0 +1,110 @@
package blob
import (
"context"
"io"
"time"
)
// ReaderAttributes contains a subset of attributes about a blob that are
// accessible from Reader.
type ReaderAttributes struct {
// ContentType is the MIME type of the blob object. It must not be empty.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
ContentType string `json:"contentType"`
// ModTime is the time the blob object was last modified.
ModTime time.Time `json:"modTime"`
// Size is the size of the object in bytes.
Size int64 `json:"size"`
}
// DriverReader reads an object from the blob.
type DriverReader interface {
io.ReadCloser
// Attributes returns a subset of attributes about the blob.
// The portable type will not modify the returned ReaderAttributes.
Attributes() *ReaderAttributes
}
// DriverWriter writes an object to the blob.
type DriverWriter interface {
io.WriteCloser
}
// Driver provides read, write and delete operations on objects within it on the
// blob service.
type Driver interface {
NormalizeError(err error) error
// Attributes returns attributes for the blob. If the specified object does
// not exist, Attributes must return an error for which ErrorCode returns
// gcerrors.NotFound.
// The portable type will not modify the returned Attributes.
Attributes(ctx context.Context, key string) (*Attributes, error)
// ListPaged lists objects in the bucket, in lexicographical order by
// UTF-8-encoded key, returning pages of objects at a time.
// Services are only required to be eventually consistent with respect
// to recently written or deleted objects. That is to say, there is no
// guarantee that an object that's been written will immediately be returned
// from ListPaged.
// opts is guaranteed to be non-nil.
ListPaged(ctx context.Context, opts *ListOptions) (*ListPage, error)
// NewRangeReader returns a Reader that reads part of an object, reading at
// most length bytes starting at the given offset. If length is negative, it
// will read until the end of the object. If the specified object does not
// exist, NewRangeReader must return an error for which ErrorCode returns
// gcerrors.NotFound.
// opts is guaranteed to be non-nil.
//
// The returned Reader *may* also implement Downloader if the underlying
// implementation can take advantage of that. The Download call is guaranteed
// to be the only call to the Reader. For such readers, offset will always
// be 0 and length will always be -1.
NewRangeReader(ctx context.Context, key string, offset, length int64) (DriverReader, error)
// NewTypedWriter returns Writer that writes to an object associated with key.
//
// A new object will be created unless an object with this key already exists.
// Otherwise any previous object with the same key will be replaced.
// The object may not be available (and any previous object will remain)
// until Close has been called.
//
// contentType sets the MIME type of the object to be written.
// opts is guaranteed to be non-nil.
//
// The caller must call Close on the returned Writer when done writing.
//
// Implementations should abort an ongoing write if ctx is later canceled,
// and do any necessary cleanup in Close. Close should then return ctx.Err().
//
// The returned Writer *may* also implement Uploader if the underlying
// implementation can take advantage of that. The Upload call is guaranteed
// to be the only non-Close call to the Writer..
NewTypedWriter(ctx context.Context, key, contentType string, opts *WriterOptions) (DriverWriter, error)
// Copy copies the object associated with srcKey to dstKey.
//
// If the source object does not exist, Copy must return an error for which
// ErrorCode returns gcerrors.NotFound.
//
// If the destination object already exists, it should be overwritten.
//
// opts is guaranteed to be non-nil.
Copy(ctx context.Context, dstKey, srcKey string) error
// Delete deletes the object associated with key. If the specified object does
// not exist, Delete must return an error for which ErrorCode returns
// gcerrors.NotFound.
Delete(ctx context.Context, key string) error
// Close cleans up any resources used by the Bucket. Once Close is called,
// there will be no method calls to the Bucket other than As, ErrorAs, and
// ErrorCode. There may be open readers or writers that will receive calls.
// It is up to the driver as to how these will be handled.
Close() error
}
+153
View File
@@ -0,0 +1,153 @@
package blob
import (
"fmt"
"strconv"
)
// Copied from gocloud.dev/blob to avoid nuances around the specific
// HEX escaping/unescaping rules.
//
// -------------------------------------------------------------------
// Copyright 2019 The Go Cloud Development Kit Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// -------------------------------------------------------------------
// HexEscape returns s, with all runes for which shouldEscape returns true
// escaped to "__0xXXX__", where XXX is the hex representation of the rune
// value. For example, " " would escape to "__0x20__".
//
// Non-UTF-8 strings will have their non-UTF-8 characters escaped to
// unicode.ReplacementChar; the original value is lost. Please file an
// issue if you need non-UTF8 support.
//
// Note: shouldEscape takes the whole string as a slice of runes and an
// index. Passing it a single byte or a single rune doesn't provide
// enough context for some escape decisions; for example, the caller might
// want to escape the second "/" in "//" but not the first one.
// We pass a slice of runes instead of the string or a slice of bytes
// because some decisions will be made on a rune basis (e.g., encode
// all non-ASCII runes).
func HexEscape(s string, shouldEscape func(s []rune, i int) bool) string {
// Do a first pass to see which runes (if any) need escaping.
runes := []rune(s)
var toEscape []int
for i := range runes {
if shouldEscape(runes, i) {
toEscape = append(toEscape, i)
}
}
if len(toEscape) == 0 {
return s
}
// Each escaped rune turns into at most 14 runes ("__0x7fffffff__"),
// so allocate an extra 13 for each. We'll reslice at the end
// if we didn't end up using them.
escaped := make([]rune, len(runes)+13*len(toEscape))
n := 0 // current index into toEscape
j := 0 // current index into escaped
for i, r := range runes {
if n < len(toEscape) && i == toEscape[n] {
// We were asked to escape this rune.
for _, x := range fmt.Sprintf("__%#x__", r) {
escaped[j] = x
j++
}
n++
} else {
escaped[j] = r
j++
}
}
return string(escaped[0:j])
}
// unescape tries to unescape starting at r[i].
// It returns a boolean indicating whether the unescaping was successful,
// and (if true) the unescaped rune and the last index of r that was used
// during unescaping.
func unescape(r []rune, i int) (bool, rune, int) {
// Look for "__0x".
if r[i] != '_' {
return false, 0, 0
}
i++
if i >= len(r) || r[i] != '_' {
return false, 0, 0
}
i++
if i >= len(r) || r[i] != '0' {
return false, 0, 0
}
i++
if i >= len(r) || r[i] != 'x' {
return false, 0, 0
}
i++
// Capture the digits until the next "_" (if any).
var hexdigits []rune
for ; i < len(r) && r[i] != '_'; i++ {
hexdigits = append(hexdigits, r[i])
}
// Look for the trailing "__".
if i >= len(r) || r[i] != '_' {
return false, 0, 0
}
i++
if i >= len(r) || r[i] != '_' {
return false, 0, 0
}
// Parse the hex digits into an int32.
retval, err := strconv.ParseInt(string(hexdigits), 16, 32)
if err != nil {
return false, 0, 0
}
return true, rune(retval), i
}
// HexUnescape reverses HexEscape.
func HexUnescape(s string) string {
var unescaped []rune
runes := []rune(s)
for i := 0; i < len(runes); i++ {
if ok, newR, newI := unescape(runes, i); ok {
// We unescaped some runes starting at i, resulting in the
// unescaped rune newR. The last rune used was newI.
if unescaped == nil {
// This is the first rune we've encountered that
// needed unescaping. Allocate a buffer and copy any
// previous runes.
unescaped = make([]rune, i)
copy(unescaped, runes)
}
unescaped = append(unescaped, newR)
i = newI
} else if unescaped != nil {
unescaped = append(unescaped, runes[i])
}
}
if unescaped == nil {
return s
}
return string(unescaped)
}
+196
View File
@@ -0,0 +1,196 @@
package blob
import (
"context"
"fmt"
"io"
"log"
"time"
)
// Largely copied from gocloud.dev/blob.Reader to minimize breaking changes.
//
// -------------------------------------------------------------------
// Copyright 2019 The Go Cloud Development Kit Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// -------------------------------------------------------------------
var _ io.ReadSeekCloser = (*Reader)(nil)
// Reader reads bytes from a blob.
// It implements io.ReadSeekCloser, and must be closed after reads are finished.
type Reader struct {
ctx context.Context // Used to recreate r after Seeks
r DriverReader
drv Driver
key string
baseOffset int64 // The base offset provided to NewRangeReader.
baseLength int64 // The length provided to NewRangeReader (may be negative).
relativeOffset int64 // Current offset (relative to baseOffset).
savedOffset int64 // Last relativeOffset for r, saved after relativeOffset is changed in Seek, or -1 if no Seek.
closed bool
}
// Read implements io.Reader (https://golang.org/pkg/io/#Reader).
func (r *Reader) Read(p []byte) (int, error) {
if r.savedOffset != -1 {
// We've done one or more Seeks since the last read. We may have
// to recreate the Reader.
//
// Note that remembering the savedOffset and lazily resetting the
// reader like this allows the caller to Seek, then Seek again back,
// to the original offset, without having to recreate the reader.
// We only have to recreate the reader if we actually read after a Seek.
// This is an important optimization because it's common to Seek
// to (SeekEnd, 0) and use the return value to determine the size
// of the data, then Seek back to (SeekStart, 0).
saved := r.savedOffset
if r.relativeOffset == saved {
// Nope! We're at the same place we left off.
r.savedOffset = -1
} else {
// Yep! We've changed the offset. Recreate the reader.
length := r.baseLength
if length >= 0 {
length -= r.relativeOffset
if length < 0 {
// Shouldn't happen based on checks in Seek.
return 0, fmt.Errorf("invalid Seek (base length %d, relative offset %d)", r.baseLength, r.relativeOffset)
}
}
newR, err := r.drv.NewRangeReader(r.ctx, r.key, r.baseOffset+r.relativeOffset, length)
if err != nil {
return 0, wrapError(r.drv, err, r.key)
}
_ = r.r.Close()
r.savedOffset = -1
r.r = newR
}
}
n, err := r.r.Read(p)
r.relativeOffset += int64(n)
return n, wrapError(r.drv, err, r.key)
}
// Seek implements io.Seeker (https://golang.org/pkg/io/#Seeker).
func (r *Reader) Seek(offset int64, whence int) (int64, error) {
if r.savedOffset == -1 {
// Save the current offset for our reader. If the Seek changes the
// offset, and then we try to read, we'll need to recreate the reader.
// See comment above in Read for why we do it lazily.
r.savedOffset = r.relativeOffset
}
// The maximum relative offset is the minimum of:
// 1. The actual size of the blob, minus our initial baseOffset.
// 2. The length provided to NewRangeReader (if it was non-negative).
maxRelativeOffset := r.Size() - r.baseOffset
if r.baseLength >= 0 && r.baseLength < maxRelativeOffset {
maxRelativeOffset = r.baseLength
}
switch whence {
case io.SeekStart:
r.relativeOffset = offset
case io.SeekCurrent:
r.relativeOffset += offset
case io.SeekEnd:
r.relativeOffset = maxRelativeOffset + offset
}
if r.relativeOffset < 0 {
// "Seeking to an offset before the start of the file is an error."
invalidOffset := r.relativeOffset
r.relativeOffset = 0
return 0, fmt.Errorf("Seek resulted in invalid offset %d, using 0", invalidOffset)
}
if r.relativeOffset > maxRelativeOffset {
// "Seeking to any positive offset is legal, but the behavior of subsequent
// I/O operations on the underlying object is implementation-dependent."
// We'll choose to set the offset to the EOF.
log.Printf("blob.Reader.Seek set an offset after EOF (base offset/length from NewRangeReader %d, %d; actual blob size %d; relative offset %d -> absolute offset %d).", r.baseOffset, r.baseLength, r.Size(), r.relativeOffset, r.baseOffset+r.relativeOffset)
r.relativeOffset = maxRelativeOffset
}
return r.relativeOffset, nil
}
// Close implements io.Closer (https://golang.org/pkg/io/#Closer).
func (r *Reader) Close() error {
r.closed = true
err := wrapError(r.drv, r.r.Close(), r.key)
return err
}
// ContentType returns the MIME type of the blob.
func (r *Reader) ContentType() string {
return r.r.Attributes().ContentType
}
// ModTime returns the time the blob was last modified.
func (r *Reader) ModTime() time.Time {
return r.r.Attributes().ModTime
}
// Size returns the size of the blob content in bytes.
func (r *Reader) Size() int64 {
return r.r.Attributes().Size
}
// WriteTo reads from r and writes to w until there's no more data or
// an error occurs.
// The return value is the number of bytes written to w.
//
// It implements the io.WriterTo interface.
func (r *Reader) WriteTo(w io.Writer) (int64, error) {
// If the writer has a ReaderFrom method, use it to do the copy.
// Don't do this for our own *Writer to avoid infinite recursion.
// Avoids an allocation and a copy.
switch w.(type) {
case *Writer:
default:
if rf, ok := w.(io.ReaderFrom); ok {
n, err := rf.ReadFrom(r)
return n, err
}
}
_, nw, err := readFromWriteTo(r, w)
return nw, err
}
// readFromWriteTo is a helper for ReadFrom and WriteTo.
// It reads data from r and writes to w, until EOF or a read/write error.
// It returns the number of bytes read from r and the number of bytes
// written to w.
func readFromWriteTo(r io.Reader, w io.Writer) (int64, int64, error) {
// Note: can't use io.Copy because it will try to use r.WriteTo
// or w.WriteTo, which is recursive in this context.
buf := make([]byte, 1024)
var totalRead, totalWritten int64
for {
numRead, rerr := r.Read(buf)
if numRead > 0 {
totalRead += int64(numRead)
numWritten, werr := w.Write(buf[0:numRead])
totalWritten += int64(numWritten)
if werr != nil {
return totalRead, totalWritten, werr
}
}
if rerr == io.EOF {
// Done!
return totalRead, totalWritten, nil
}
if rerr != nil {
return totalRead, totalWritten, rerr
}
}
}
+184
View File
@@ -0,0 +1,184 @@
package blob
import (
"bytes"
"context"
"fmt"
"hash"
"io"
"net/http"
)
// Largely copied from gocloud.dev/blob.Writer to minimize breaking changes.
//
// -------------------------------------------------------------------
// Copyright 2019 The Go Cloud Development Kit Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// -------------------------------------------------------------------
var _ io.WriteCloser = (*Writer)(nil)
// Writer writes bytes to a blob.
//
// It implements io.WriteCloser (https://golang.org/pkg/io/#Closer), and must be
// closed after all writes are done.
type Writer struct {
drv Driver
w DriverWriter
key string
cancel func() // cancels the ctx provided to NewTypedWriter if contentMD5 verification fails
contentMD5 []byte
md5hash hash.Hash
bytesWritten int
closed bool
// These fields are non-zero values only when w is nil (not yet created).
//
// A ctx is stored in the Writer since we need to pass it into NewTypedWriter
// when we finish detecting the content type of the blob and create the
// underlying driver.Writer. This step happens inside Write or Close and
// neither of them take a context.Context as an argument.
//
// All 3 fields are only initialized when we create the Writer without
// setting the w field, and are reset to zero values after w is created.
ctx context.Context
opts *WriterOptions
buf *bytes.Buffer
}
// sniffLen is the byte size of Writer.buf used to detect content-type.
const sniffLen = 512
// Write implements the io.Writer interface (https://golang.org/pkg/io/#Writer).
//
// Writes may happen asynchronously, so the returned error can be nil
// even if the actual write eventually fails. The write is only guaranteed to
// have succeeded if Close returns no error.
func (w *Writer) Write(p []byte) (int, error) {
if len(w.contentMD5) > 0 {
if _, err := w.md5hash.Write(p); err != nil {
return 0, err
}
}
if w.w != nil {
return w.write(p)
}
// If w is not yet created due to no content-type being passed in, try to sniff
// the MIME type based on at most 512 bytes of the blob content of p.
// Detect the content-type directly if the first chunk is at least 512 bytes.
if w.buf.Len() == 0 && len(p) >= sniffLen {
return w.open(p)
}
// Store p in w.buf and detect the content-type when the size of content in
// w.buf is at least 512 bytes.
n, err := w.buf.Write(p)
if err != nil {
return 0, err
}
if w.buf.Len() >= sniffLen {
// Note that w.open will return the full length of the buffer; we don't want
// to return that as the length of this write since some of them were written in
// previous writes. Instead, we return the n from this write, above.
_, err := w.open(w.buf.Bytes())
return n, err
}
return n, nil
}
// Close closes the blob writer. The write operation is not guaranteed
// to have succeeded until Close returns with no error.
//
// Close may return an error if the context provided to create the
// Writer is canceled or reaches its deadline.
func (w *Writer) Close() (err error) {
w.closed = true
// Verify the MD5 hash of what was written matches the ContentMD5 provided by the user.
if len(w.contentMD5) > 0 {
md5sum := w.md5hash.Sum(nil)
if !bytes.Equal(md5sum, w.contentMD5) {
// No match! Return an error, but first cancel the context and call the
// driver's Close function to ensure the write is aborted.
w.cancel()
if w.w != nil {
_ = w.w.Close()
}
return fmt.Errorf("the WriterOptions.ContentMD5 you specified (%X) did not match what was written (%X)", w.contentMD5, md5sum)
}
}
defer w.cancel()
if w.w != nil {
return wrapError(w.drv, w.w.Close(), w.key)
}
if _, err := w.open(w.buf.Bytes()); err != nil {
return err
}
return wrapError(w.drv, w.w.Close(), w.key)
}
// open tries to detect the MIME type of p and write it to the blob.
// The error it returns is wrapped.
func (w *Writer) open(p []byte) (int, error) {
ct := http.DetectContentType(p)
var err error
w.w, err = w.drv.NewTypedWriter(w.ctx, w.key, ct, w.opts)
if err != nil {
return 0, wrapError(w.drv, err, w.key)
}
// Set the 3 fields needed for lazy NewTypedWriter back to zero values
// (see the comment on Writer).
w.buf = nil
w.ctx = nil
w.opts = nil
return w.write(p)
}
func (w *Writer) write(p []byte) (int, error) {
n, err := w.w.Write(p)
w.bytesWritten += n
return n, wrapError(w.drv, err, w.key)
}
// ReadFrom reads from r and writes to w until EOF or error.
// The return value is the number of bytes read from r.
//
// It implements the io.ReaderFrom interface.
func (w *Writer) ReadFrom(r io.Reader) (int64, error) {
// If the reader has a WriteTo method, use it to do the copy.
// Don't do this for our own *Reader to avoid infinite recursion.
// Avoids an allocation and a copy.
switch r.(type) {
case *Reader:
default:
if wt, ok := r.(io.WriterTo); ok {
return wt.WriteTo(w)
}
}
nr, _, err := readFromWriteTo(r, w)
return nr, err
}
+26 -107
View File
@@ -14,49 +14,23 @@ import (
"strconv"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/disintegration/imaging"
"github.com/gabriel-vasile/mimetype"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3lite"
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/fileblob"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
"github.com/pocketbase/pocketbase/tools/list"
"gocloud.dev/blob"
"gocloud.dev/blob/fileblob"
"gocloud.dev/gcerrors"
)
var gcpIgnoreHeaders = []string{"Accept-Encoding"}
var ErrNotFound = errors.New("blob not found")
// note: the same as blob.ErrNotFound for backward compatibility with earlier versions
var ErrNotFound = blob.ErrNotFound
type System struct {
ctx context.Context
bucket *blob.Bucket
}
// -------------------------------------------------------------------
var requestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
var responseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
// @todo consider removing after the other non-AWS vendors catched up with the new changes
// (https://github.com/aws/aws-sdk-go-v2/discussions/2960)
func init() {
reqEnv := os.Getenv("AWS_REQUEST_CHECKSUM_CALCULATION")
if reqEnv != "" && strings.EqualFold(reqEnv, "when_supported") {
requestChecksumCalculation = aws.RequestChecksumCalculationWhenSupported
}
resEnv := os.Getenv("AWS_RESPONSE_CHECKSUM_VALIDATION")
if resEnv != "" && strings.EqualFold(resEnv, "when_supported") {
responseChecksumValidation = aws.ResponseChecksumValidationWhenSupported
}
}
// -------------------------------------------------------------------
// NewS3 initializes an S3 filesystem instance.
//
// NB! Make sure to call `Close()` after you are done working with it.
@@ -70,44 +44,21 @@ func NewS3(
) (*System, error) {
ctx := context.Background() // default context
cred := credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")
client := &s3.S3{
Bucket: bucketName,
Region: region,
Endpoint: endpoint,
AccessKey: accessKey,
SecretKey: secretKey,
UsePathStyle: s3ForcePathStyle,
}
cfg, err := config.LoadDefaultConfig(
ctx,
config.WithCredentialsProvider(cred),
config.WithRegion(region),
)
drv, err := s3blob.New(client)
if err != nil {
return nil, err
}
cfg.RequestChecksumCalculation = requestChecksumCalculation
cfg.ResponseChecksumValidation = responseChecksumValidation
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
// ensure that the endpoint has url scheme for
// backward compatibility with v1 of the aws sdk
if !strings.Contains(endpoint, "://") {
endpoint = "https://" + endpoint
}
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = s3ForcePathStyle
// Google Cloud Storage alters the Accept-Encoding header,
// which breaks the v2 request signature
// (https://github.com/aws/aws-sdk-go-v2/issues/1816)
if strings.Contains(endpoint, "storage.googleapis.com") {
ignoreSigningHeaders(o, gcpIgnoreHeaders)
}
})
bucket, err := s3lite.OpenBucketV2(ctx, client, bucketName, nil)
if err != nil {
return nil, err
}
return &System{ctx: ctx, bucket: bucket}, nil
return &System{ctx: ctx, bucket: blob.NewBucket(drv)}, nil
}
// NewLocal initializes a new local filesystem instance.
@@ -121,14 +72,14 @@ func NewLocal(dirPath string) (*System, error) {
return nil, err
}
bucket, err := fileblob.OpenBucket(dirPath, &fileblob.Options{
drv, err := fileblob.New(dirPath, &fileblob.Options{
NoTempDir: true,
})
if err != nil {
return nil, err
}
return &System{ctx: ctx, bucket: bucket}, nil
return &System{ctx: ctx, bucket: blob.NewBucket(drv)}, nil
}
// SetContext assigns the specified context to the current filesystem.
@@ -142,29 +93,15 @@ func (s *System) Close() error {
}
// Exists checks if file with fileKey path exists or not.
//
// If the file doesn't exist returns false and ErrNotFound.
func (s *System) Exists(fileKey string) (bool, error) {
exists, err := s.bucket.Exists(s.ctx, fileKey)
if gcerrors.Code(err) == gcerrors.NotFound {
err = ErrNotFound
}
return exists, err
return s.bucket.Exists(s.ctx, fileKey)
}
// Attributes returns the attributes for the file with fileKey path.
//
// If the file doesn't exist it returns ErrNotFound.
func (s *System) Attributes(fileKey string) (*blob.Attributes, error) {
attrs, err := s.bucket.Attributes(s.ctx, fileKey)
if gcerrors.Code(err) == gcerrors.NotFound {
err = ErrNotFound
}
return attrs, err
return s.bucket.Attributes(s.ctx, fileKey)
}
// GetFile returns a file content reader for the given fileKey.
@@ -173,13 +110,7 @@ func (s *System) Attributes(fileKey string) (*blob.Attributes, error) {
//
// If the file doesn't exist returns ErrNotFound.
func (s *System) GetFile(fileKey string) (*blob.Reader, error) {
br, err := s.bucket.NewReader(s.ctx, fileKey, nil)
if gcerrors.Code(err) == gcerrors.NotFound {
err = ErrNotFound
}
return br, err
return s.bucket.NewReader(s.ctx, fileKey)
}
// Copy copies the file stored at srcKey to dstKey.
@@ -188,13 +119,7 @@ func (s *System) GetFile(fileKey string) (*blob.Reader, error) {
//
// If dstKey file already exists, it is overwritten.
func (s *System) Copy(srcKey, dstKey string) error {
err := s.bucket.Copy(s.ctx, dstKey, srcKey, nil)
if gcerrors.Code(err) == gcerrors.NotFound {
err = ErrNotFound
}
return err
return s.bucket.Copy(s.ctx, dstKey, srcKey)
}
// List returns a flat list with info for all files under the specified prefix.
@@ -208,7 +133,7 @@ func (s *System) List(prefix string) ([]*blob.ListObject, error) {
for {
obj, err := iter.Next(s.ctx)
if err != nil {
if err != io.EOF {
if !errors.Is(err, io.EOF) {
return nil, err
}
break
@@ -325,13 +250,7 @@ func (s *System) UploadMultipart(fh *multipart.FileHeader, fileKey string) error
//
// If the file doesn't exist returns ErrNotFound.
func (s *System) Delete(fileKey string) error {
err := s.bucket.Delete(s.ctx, fileKey)
if gcerrors.Code(err) == gcerrors.NotFound {
return ErrNotFound
}
return err
return s.bucket.Delete(s.ctx, fileKey)
}
// DeletePrefix deletes everything starting with the specified prefix.
@@ -363,7 +282,7 @@ func (s *System) DeletePrefix(prefix string) []error {
for {
obj, err := iter.Next(s.ctx)
if err != nil {
if err != io.EOF {
if !errors.Is(err, io.EOF) {
failed = append(failed, err)
}
break
@@ -422,7 +341,7 @@ func (s *System) IsEmptyDir(dir string) bool {
_, err := iter.Next(s.ctx)
return err == io.EOF
return err != nil && errors.Is(err, io.EOF)
}
var inlineServeContentTypes = []string{
+40 -26
View File
@@ -5,6 +5,7 @@ import (
"errors"
"image"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -364,7 +365,7 @@ func TestFileSystemServe(t *testing.T) {
map[string]string{
"Content-Disposition": "attachment; filename=test_name.txt",
"Content-Type": "application/octet-stream",
"Content-Length": "0",
"Content-Length": "4",
"Content-Security-Policy": csp,
"Cache-Control": cacheControl,
},
@@ -437,7 +438,7 @@ func TestFileSystemServe(t *testing.T) {
map[string]string{
"Content-Disposition": "1",
"Content-Type": "2",
"Content-Length": "3",
"Content-Length": "1",
"Content-Security-Policy": "4",
"Cache-Control": "5",
"X-Custom": "6",
@@ -446,7 +447,7 @@ func TestFileSystemServe(t *testing.T) {
map[string]string{
"Content-Disposition": "1",
"Content-Type": "2",
"Content-Length": "0", // overwriten by http.ServeContent
"Content-Length": "4", // overwriten by http.ServeContent
"Content-Security-Policy": "4",
"Cache-Control": "5",
"X-Custom": "6",
@@ -504,29 +505,42 @@ func TestFileSystemGetFile(t *testing.T) {
defer fsys.Close()
scenarios := []struct {
file string
expectError bool
file string
expectError bool
expectedContent string
}{
{"missing.png", true},
{"image.png", false},
{"test/missing.txt", true, ""},
{"test/sub1.txt", false, "sub1"},
}
for _, s := range scenarios {
t.Run(s.file, func(t *testing.T) {
f, err := fsys.GetFile(s.file)
defer func() {
if f != nil {
f.Close()
}
}()
hasErr := err != nil
if !hasErr {
defer f.Close()
}
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
}
if hasErr && !errors.Is(err, filesystem.ErrNotFound) {
t.Fatalf("Expected ErrNotFound error, got %v", err)
if hasErr {
if !errors.Is(err, filesystem.ErrNotFound) {
t.Fatalf("Expected ErrNotFound error, got %v", err)
}
return
}
raw, err := io.ReadAll(f)
if err != nil {
t.Fatal(err)
}
if str := string(raw); str != s.expectedContent {
t.Fatalf("Expected content\n%s\ngot\n%s", s.expectedContent, str)
}
})
}
@@ -744,25 +758,25 @@ func createTestDir(t *testing.T) string {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "empty"), os.ModePerm); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "test"), os.ModePerm); err != nil {
t.Fatal(err)
}
file1, err := os.OpenFile(filepath.Join(dir, "test/sub1.txt"), os.O_WRONLY|os.O_CREATE, 0644)
err = os.MkdirAll(filepath.Join(dir, "empty"), os.ModePerm)
if err != nil {
t.Fatal(err)
}
file1.Close()
file2, err := os.OpenFile(filepath.Join(dir, "test/sub2.txt"), os.O_WRONLY|os.O_CREATE, 0644)
err = os.MkdirAll(filepath.Join(dir, "test"), os.ModePerm)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir, "test/sub1.txt"), []byte("sub1"), 0644)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir, "test/sub2.txt"), []byte("sub2"), 0644)
if err != nil {
t.Fatal(err)
}
file2.Close()
file3, err := os.OpenFile(filepath.Join(dir, "image.png"), os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
@@ -1,72 +0,0 @@
package filesystem
import (
"context"
"fmt"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go/middleware"
smithyhttp "github.com/aws/smithy-go/transport/http"
)
// ignoreSigningHeaders excludes the listed headers
// from the request signing because some providers may alter them.
//
// See https://github.com/aws/aws-sdk-go-v2/issues/1816.
func ignoreSigningHeaders(o *s3.Options, headers []string) {
o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
if err := stack.Finalize.Insert(ignoreHeaders(headers), "Signing", middleware.Before); err != nil {
return err
}
if err := stack.Finalize.Insert(restoreIgnored(), "Signing", middleware.After); err != nil {
return err
}
return nil
})
}
type ignoredHeadersKey struct{}
func ignoreHeaders(headers []string) middleware.FinalizeMiddleware {
return middleware.FinalizeMiddlewareFunc(
"IgnoreHeaders",
func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (out middleware.FinalizeOutput, metadata middleware.Metadata, err error) {
req, ok := in.Request.(*smithyhttp.Request)
if !ok {
return out, metadata, &v4.SigningError{Err: fmt.Errorf("(ignoreHeaders) unexpected request middleware type %T", in.Request)}
}
ignored := make(map[string]string, len(headers))
for _, h := range headers {
ignored[h] = req.Header.Get(h)
req.Header.Del(h)
}
ctx = middleware.WithStackValue(ctx, ignoredHeadersKey{}, ignored)
return next.HandleFinalize(ctx, in)
},
)
}
func restoreIgnored() middleware.FinalizeMiddleware {
return middleware.FinalizeMiddlewareFunc(
"RestoreIgnored",
func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (out middleware.FinalizeOutput, metadata middleware.Metadata, err error) {
req, ok := in.Request.(*smithyhttp.Request)
if !ok {
return out, metadata, &v4.SigningError{Err: fmt.Errorf("(restoreIgnored) unexpected request middleware type %T", in.Request)}
}
ignored, _ := middleware.GetStackValue(ctx, ignoredHeadersKey{}).(map[string]string)
for k, v := range ignored {
req.Header.Set(k, v)
}
return next.HandleFinalize(ctx, in)
},
)
}
@@ -0,0 +1,84 @@
package fileblob
import (
"encoding/json"
"fmt"
"os"
)
// Largely copied from gocloud.dev/blob/fileblob to apply the same
// retrieve and write side-car .attrs rules.
//
// -------------------------------------------------------------------
// Copyright 2018 The Go Cloud Development Kit Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// -------------------------------------------------------------------
const attrsExt = ".attrs"
var errAttrsExt = fmt.Errorf("file extension %q is reserved", attrsExt)
// xattrs stores extended attributes for an object. The format is like
// filesystem extended attributes, see
// https://www.freedesktop.org/wiki/CommonExtendedAttributes.
type xattrs struct {
CacheControl string `json:"user.cache_control"`
ContentDisposition string `json:"user.content_disposition"`
ContentEncoding string `json:"user.content_encoding"`
ContentLanguage string `json:"user.content_language"`
ContentType string `json:"user.content_type"`
Metadata map[string]string `json:"user.metadata"`
MD5 []byte `json:"md5"`
}
// setAttrs creates a "path.attrs" file along with blob to store the attributes,
// it uses JSON format.
func setAttrs(path string, xa xattrs) error {
f, err := os.Create(path + attrsExt)
if err != nil {
return err
}
if err := json.NewEncoder(f).Encode(xa); err != nil {
f.Close()
os.Remove(f.Name())
return err
}
return f.Close()
}
// getAttrs looks at the "path.attrs" file to retrieve the attributes and
// decodes them into a xattrs struct. It doesn't return error when there is no
// such .attrs file.
func getAttrs(path string) (xattrs, error) {
f, err := os.Open(path + attrsExt)
if err != nil {
if os.IsNotExist(err) {
// Handle gracefully for non-existent .attr files.
return xattrs{
ContentType: "application/octet-stream",
}, nil
}
return xattrs{}, err
}
xa := new(xattrs)
if err := json.NewDecoder(f).Decode(xa); err != nil {
f.Close()
return xattrs{}, err
}
return *xa, f.Close()
}
@@ -0,0 +1,713 @@
// Package fileblob provides a blob.Bucket driver implementation.
//
// NB! To minimize breaking changes with older PocketBase releases,
// the driver is a stripped down and adapted version of the previously
// used gocloud.dev/blob/fileblob, hence many of the below doc comments,
// struct options and interface implementations are the same.
//
// To avoid partial writes, fileblob writes to a temporary file and then renames
// the temporary file to the final path on Close. By default, it creates these
// temporary files in `os.TempDir`. If `os.TempDir` is on a different mount than
// your base bucket path, the `os.Rename` will fail with `invalid cross-device link`.
// To avoid this, either configure the temp dir to use by setting the environment
// variable `TMPDIR`, or set `Options.NoTempDir` to `true` (fileblob will create
// the temporary files next to the actual files instead of in a temporary directory).
//
// By default fileblob stores blob metadata in "sidecar" files under the original
// filename with an additional ".attrs" suffix.
// This behaviour can be changed via `Options.Metadata`;
// writing of those metadata files can be suppressed by setting it to
// `MetadataDontWrite` or its equivalent "metadata=skip" in the URL for the opener.
// In either case, absent any stored metadata many `blob.Attributes` fields
// will be set to default values.
//
// The blob abstraction supports all UTF-8 strings; to make this work with services lacking
// full UTF-8 support, strings must be escaped (during writes) and unescaped
// (during reads). The following escapes are performed for fileblob:
// - Blob keys: ASCII characters 0-31 are escaped to "__0x<hex>__".
// If os.PathSeparator != "/", it is also escaped.
// Additionally, the "/" in "../", the trailing "/" in "//", and a trailing
// "/" is key names are escaped in the same way.
// On Windows, the characters "<>:"|?*" are also escaped.
//
// Example:
//
// drv, _ := fileblob.New("/path/to/dir", nil)
// bucket := blob.NewBucket(drv)
package fileblob
import (
"context"
"crypto/md5"
"errors"
"fmt"
"hash"
"io"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
)
const defaultPageSize = 1000
type metadataOption string // Not exported as subject to change.
// Settings for Options.Metadata.
const (
// Metadata gets written to a separate file.
MetadataInSidecar metadataOption = ""
// Writes won't carry metadata, as per the package docstring.
MetadataDontWrite metadataOption = "skip"
)
// Options sets options for constructing a *blob.Bucket backed by fileblob.
type Options struct {
// Refers to the strategy for how to deal with metadata (such as blob.Attributes).
// For supported values please see the Metadata* constants.
// If left unchanged, 'MetadataInSidecar' will be used.
Metadata metadataOption
// The FileMode to use when creating directories for the top-level directory
// backing the bucket (when CreateDir is true), and for subdirectories for keys.
// Defaults to 0777.
DirFileMode os.FileMode
// If true, create the directory backing the Bucket if it does not exist
// (using os.MkdirAll).
CreateDir bool
// If true, don't use os.TempDir for temporary files, but instead place them
// next to the actual files. This may result in "stranded" temporary files
// (e.g., if the application is killed before the file cleanup runs).
//
// If your bucket directory is on a different mount than os.TempDir, you will
// need to set this to true, as os.Rename will fail across mount points.
NoTempDir bool
}
// New creates a new instance of the fileblob driver backed by the
// filesystem and rooted at dir, which must exist.
func New(dir string, opts *Options) (blob.Driver, error) {
if opts == nil {
opts = &Options{}
}
if opts.DirFileMode == 0 {
opts.DirFileMode = os.FileMode(0o777)
}
absdir, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("failed to convert %s into an absolute path: %v", dir, err)
}
// Optionally, create the directory if it does not already exist.
info, err := os.Stat(absdir)
if err != nil && opts.CreateDir && os.IsNotExist(err) {
err = os.MkdirAll(absdir, opts.DirFileMode)
if err != nil {
return nil, fmt.Errorf("tried to create directory but failed: %v", err)
}
info, err = os.Stat(absdir)
}
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("%s is not a directory", absdir)
}
return &driver{dir: absdir, opts: opts}, nil
}
type driver struct {
opts *Options
dir string
}
// Close implements [blob/Driver.Close].
func (drv *driver) Close() error {
return nil
}
// NormalizeError implements [blob/Driver.NormalizeError].
func (drv *driver) NormalizeError(err error) error {
if os.IsNotExist(err) {
return errors.Join(err, blob.ErrNotFound)
}
return err
}
// path returns the full path for a key.
func (drv *driver) path(key string) (string, error) {
path := filepath.Join(drv.dir, escapeKey(key))
if strings.HasSuffix(path, attrsExt) {
return "", errAttrsExt
}
return path, nil
}
// forKey returns the full path, os.FileInfo, and attributes for key.
func (drv *driver) forKey(key string) (string, os.FileInfo, *xattrs, error) {
path, err := drv.path(key)
if err != nil {
return "", nil, nil, err
}
info, err := os.Stat(path)
if err != nil {
return "", nil, nil, err
}
if info.IsDir() {
return "", nil, nil, os.ErrNotExist
}
xa, err := getAttrs(path)
if err != nil {
return "", nil, nil, err
}
return path, info, &xa, nil
}
// ListPaged implements [blob/Driver.ListPaged].
func (drv *driver) ListPaged(ctx context.Context, opts *blob.ListOptions) (*blob.ListPage, error) {
var pageToken string
if len(opts.PageToken) > 0 {
pageToken = string(opts.PageToken)
}
pageSize := opts.PageSize
if pageSize == 0 {
pageSize = defaultPageSize
}
// If opts.Delimiter != "", lastPrefix contains the last "directory" key we
// added. It is used to avoid adding it again; all files in this "directory"
// are collapsed to the single directory entry.
var lastPrefix string
var lastKeyAdded string
// If the Prefix contains a "/", we can set the root of the Walk
// to the path specified by the Prefix as any files below the path will not
// match the Prefix.
// Note that we use "/" explicitly and not os.PathSeparator, as the opts.Prefix
// is in the unescaped form.
root := drv.dir
if i := strings.LastIndex(opts.Prefix, "/"); i > -1 {
root = filepath.Join(root, opts.Prefix[:i])
}
var result blob.ListPage
// Do a full recursive scan of the root directory.
err := filepath.WalkDir(root, func(path string, info fs.DirEntry, err error) error {
if err != nil {
// Couldn't read this file/directory for some reason; just skip it.
return nil
}
// Skip the self-generated attribute files.
if strings.HasSuffix(path, attrsExt) {
return nil
}
// os.Walk returns the root directory; skip it.
if path == drv.dir {
return nil
}
// Strip the <drv.dir> prefix from path.
prefixLen := len(drv.dir)
// Include the separator for non-root.
if drv.dir != "/" {
prefixLen++
}
path = path[prefixLen:]
// Unescape the path to get the key.
key := unescapeKey(path)
// Skip all directories. If opts.Delimiter is set, we'll create
// pseudo-directories later.
// Note that returning nil means that we'll still recurse into it;
// we're just not adding a result for the directory itself.
if info.IsDir() {
key += "/"
// Avoid recursing into subdirectories if the directory name already
// doesn't match the prefix; any files in it are guaranteed not to match.
if len(key) > len(opts.Prefix) && !strings.HasPrefix(key, opts.Prefix) {
return filepath.SkipDir
}
// Similarly, avoid recursing into subdirectories if we're making
// "directories" and all of the files in this subdirectory are guaranteed
// to collapse to a "directory" that we've already added.
if lastPrefix != "" && strings.HasPrefix(key, lastPrefix) {
return filepath.SkipDir
}
return nil
}
// Skip files/directories that don't match the Prefix.
if !strings.HasPrefix(key, opts.Prefix) {
return nil
}
var md5 []byte
if xa, err := getAttrs(path); err == nil {
// Note: we only have the MD5 hash for blobs that we wrote.
// For other blobs, md5 will remain nil.
md5 = xa.MD5
}
fi, err := info.Info()
if err != nil {
return err
}
obj := &blob.ListObject{
Key: key,
ModTime: fi.ModTime(),
Size: fi.Size(),
MD5: md5,
}
// If using Delimiter, collapse "directories".
if opts.Delimiter != "" {
// Strip the prefix, which may contain Delimiter.
keyWithoutPrefix := key[len(opts.Prefix):]
// See if the key still contains Delimiter.
// If no, it's a file and we just include it.
// If yes, it's a file in a "sub-directory" and we want to collapse
// all files in that "sub-directory" into a single "directory" result.
if idx := strings.Index(keyWithoutPrefix, opts.Delimiter); idx != -1 {
prefix := opts.Prefix + keyWithoutPrefix[0:idx+len(opts.Delimiter)]
// We've already included this "directory"; don't add it.
if prefix == lastPrefix {
return nil
}
// Update the object to be a "directory".
obj = &blob.ListObject{
Key: prefix,
IsDir: true,
}
lastPrefix = prefix
}
}
// If there's a pageToken, skip anything before it.
if pageToken != "" && obj.Key <= pageToken {
return nil
}
// If we've already got a full page of results, set NextPageToken and stop.
// Unless the current object is a directory, in which case there may
// still be objects coming that are alphabetically before it (since
// we appended the delimiter). In that case, keep going; we'll trim the
// extra entries (if any) before returning.
if len(result.Objects) == pageSize && !obj.IsDir {
result.NextPageToken = []byte(result.Objects[pageSize-1].Key)
return io.EOF
}
result.Objects = append(result.Objects, obj)
// Normally, objects are added in the correct order (by Key).
// However, sometimes adding the file delimiter messes that up
// (e.g., if the file delimiter is later in the alphabet than the last character of a key).
// Detect if this happens and swap if needed.
if len(result.Objects) > 1 && obj.Key < lastKeyAdded {
i := len(result.Objects) - 1
result.Objects[i-1], result.Objects[i] = result.Objects[i], result.Objects[i-1]
lastKeyAdded = result.Objects[i].Key
} else {
lastKeyAdded = obj.Key
}
return nil
})
if err != nil && err != io.EOF {
return nil, err
}
if len(result.Objects) > pageSize {
result.Objects = result.Objects[0:pageSize]
result.NextPageToken = []byte(result.Objects[pageSize-1].Key)
}
return &result, nil
}
// Attributes implements [blob/Driver.Attributes].
func (drv *driver) Attributes(ctx context.Context, key string) (*blob.Attributes, error) {
_, info, xa, err := drv.forKey(key)
if err != nil {
return nil, err
}
return &blob.Attributes{
CacheControl: xa.CacheControl,
ContentDisposition: xa.ContentDisposition,
ContentEncoding: xa.ContentEncoding,
ContentLanguage: xa.ContentLanguage,
ContentType: xa.ContentType,
Metadata: xa.Metadata,
// CreateTime left as the zero time.
ModTime: info.ModTime(),
Size: info.Size(),
MD5: xa.MD5,
ETag: fmt.Sprintf("\"%x-%x\"", info.ModTime().UnixNano(), info.Size()),
}, nil
}
// NewRangeReader implements [blob/Driver.NewRangeReader].
func (drv *driver) NewRangeReader(ctx context.Context, key string, offset, length int64) (blob.DriverReader, error) {
path, info, xa, err := drv.forKey(key)
if err != nil {
return nil, err
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
if offset > 0 {
if _, err := f.Seek(offset, io.SeekStart); err != nil {
return nil, err
}
}
r := io.Reader(f)
if length >= 0 {
r = io.LimitReader(r, length)
}
return &reader{
r: r,
c: f,
attrs: &blob.ReaderAttributes{
ContentType: xa.ContentType,
ModTime: info.ModTime(),
Size: info.Size(),
},
}, nil
}
func createTemp(path string, noTempDir bool) (*os.File, error) {
// Use a custom createTemp function rather than os.CreateTemp() as
// os.CreateTemp() sets the permissions of the tempfile to 0600, rather than
// 0666, making it inconsistent with the directories and attribute files.
try := 0
for {
// Append the current time with nanosecond precision and .tmp to the
// base path. If the file already exists try again. Nanosecond changes enough
// between each iteration to make a conflict unlikely. Using the full
// time lowers the chance of a collision with a file using a similar
// pattern, but has undefined behavior after the year 2262.
var name string
if noTempDir {
name = path
} else {
name = filepath.Join(os.TempDir(), filepath.Base(path))
}
name += "." + strconv.FormatInt(time.Now().UnixNano(), 16) + ".tmp"
f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666)
if os.IsExist(err) {
if try++; try < 10000 {
continue
}
return nil, &os.PathError{Op: "createtemp", Path: path + ".*.tmp", Err: os.ErrExist}
}
return f, err
}
}
// NewTypedWriter implements [blob/Driver.NewTypedWriter].
func (drv *driver) NewTypedWriter(ctx context.Context, key, contentType string, opts *blob.WriterOptions) (blob.DriverWriter, error) {
path, err := drv.path(key)
if err != nil {
return nil, err
}
err = os.MkdirAll(filepath.Dir(path), drv.opts.DirFileMode)
if err != nil {
return nil, err
}
f, err := createTemp(path, drv.opts.NoTempDir)
if err != nil {
return nil, err
}
if drv.opts.Metadata == MetadataDontWrite {
w := &writer{
ctx: ctx,
File: f,
path: path,
}
return w, nil
}
var metadata map[string]string
if len(opts.Metadata) > 0 {
metadata = opts.Metadata
}
return &writerWithSidecar{
ctx: ctx,
f: f,
path: path,
contentMD5: opts.ContentMD5,
md5hash: md5.New(),
attrs: xattrs{
CacheControl: opts.CacheControl,
ContentDisposition: opts.ContentDisposition,
ContentEncoding: opts.ContentEncoding,
ContentLanguage: opts.ContentLanguage,
ContentType: contentType,
Metadata: metadata,
},
}, nil
}
// Copy implements [blob/Driver.Copy].
func (drv *driver) Copy(ctx context.Context, dstKey, srcKey string) error {
// Note: we could use NewRangeReader here, but since we need to copy all of
// the metadata (from xa), it's more efficient to do it directly.
srcPath, _, xa, err := drv.forKey(srcKey)
if err != nil {
return err
}
f, err := os.Open(srcPath)
if err != nil {
return err
}
defer f.Close()
// We'll write the copy using Writer, to avoid re-implementing making of a
// temp file, cleaning up after partial failures, etc.
wopts := blob.WriterOptions{
CacheControl: xa.CacheControl,
ContentDisposition: xa.ContentDisposition,
ContentEncoding: xa.ContentEncoding,
ContentLanguage: xa.ContentLanguage,
Metadata: xa.Metadata,
}
// Create a cancelable context so we can cancel the write if there are problems.
writeCtx, cancel := context.WithCancel(ctx)
defer cancel()
w, err := drv.NewTypedWriter(writeCtx, dstKey, xa.ContentType, &wopts)
if err != nil {
return err
}
_, err = io.Copy(w, f)
if err != nil {
cancel() // cancel before Close cancels the write
w.Close()
return err
}
return w.Close()
}
// Delete implements [blob/Driver.Delete].
func (b *driver) Delete(ctx context.Context, key string) error {
path, err := b.path(key)
if err != nil {
return err
}
err = os.Remove(path)
if err != nil {
return err
}
err = os.Remove(path + attrsExt)
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// -------------------------------------------------------------------
type reader struct {
r io.Reader
c io.Closer
attrs *blob.ReaderAttributes
}
func (r *reader) Read(p []byte) (int, error) {
if r.r == nil {
return 0, io.EOF
}
return r.r.Read(p)
}
func (r *reader) Close() error {
if r.c == nil {
return nil
}
return r.c.Close()
}
// Attributes implements [blob/DriverReader.Attributes].
func (r *reader) Attributes() *blob.ReaderAttributes {
return r.attrs
}
// -------------------------------------------------------------------
// writerWithSidecar implements the strategy of storing metadata in a distinct file.
type writerWithSidecar struct {
ctx context.Context
md5hash hash.Hash
f *os.File
path string
attrs xattrs
contentMD5 []byte
}
func (w *writerWithSidecar) Write(p []byte) (n int, err error) {
n, err = w.f.Write(p)
if err != nil {
// Don't hash the unwritten tail twice when writing is resumed.
w.md5hash.Write(p[:n])
return n, err
}
if _, err := w.md5hash.Write(p); err != nil {
return n, err
}
return n, nil
}
func (w *writerWithSidecar) Close() error {
err := w.f.Close()
if err != nil {
return err
}
// Always delete the temp file. On success, it will have been
// renamed so the Remove will fail.
defer func() {
_ = os.Remove(w.f.Name())
}()
// Check if the write was cancelled.
if err := w.ctx.Err(); err != nil {
return err
}
md5sum := w.md5hash.Sum(nil)
w.attrs.MD5 = md5sum
// Write the attributes file.
if err := setAttrs(w.path, w.attrs); err != nil {
return err
}
// Rename the temp file to path.
if err := os.Rename(w.f.Name(), w.path); err != nil {
_ = os.Remove(w.path + attrsExt)
return err
}
return nil
}
// writer is a file with a temporary name until closed.
//
// Embedding os.File allows the likes of io.Copy to use optimizations,
// which is why it is not folded into writerWithSidecar.
type writer struct {
*os.File
ctx context.Context
path string
}
func (w *writer) Close() error {
err := w.File.Close()
if err != nil {
return err
}
// Always delete the temp file. On success, it will have been renamed so
// the Remove will fail.
tempname := w.File.Name()
defer os.Remove(tempname)
// Check if the write was cancelled.
if err := w.ctx.Err(); err != nil {
return err
}
// Rename the temp file to path.
return os.Rename(tempname, w.path)
}
// -------------------------------------------------------------------
// escapeKey does all required escaping for UTF-8 strings to work the filesystem.
func escapeKey(s string) string {
s = blob.HexEscape(s, func(r []rune, i int) bool {
c := r[i]
switch {
case c < 32:
return true
// We're going to replace '/' with os.PathSeparator below. In order for this
// to be reversible, we need to escape raw os.PathSeparators.
case os.PathSeparator != '/' && c == os.PathSeparator:
return true
// For "../", escape the trailing slash.
case i > 1 && c == '/' && r[i-1] == '.' && r[i-2] == '.':
return true
// For "//", escape the trailing slash.
case i > 0 && c == '/' && r[i-1] == '/':
return true
// Escape the trailing slash in a key.
case c == '/' && i == len(r)-1:
return true
// https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file
case os.PathSeparator == '\\' && (c == '>' || c == '<' || c == ':' || c == '"' || c == '|' || c == '?' || c == '*'):
return true
}
return false
})
// Replace "/" with os.PathSeparator if needed, so that the local filesystem
// can use subdirectories.
if os.PathSeparator != '/' {
s = strings.ReplaceAll(s, "/", string(os.PathSeparator))
}
return s
}
// unescapeKey reverses escapeKey.
func unescapeKey(s string) string {
if os.PathSeparator != '/' {
s = strings.ReplaceAll(s, string(os.PathSeparator), "/")
}
return blob.HexUnescape(s)
}
@@ -0,0 +1,59 @@
package s3
import (
"context"
"encoding/xml"
"net/http"
"net/url"
"strings"
"time"
)
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html#API_CopyObject_ResponseSyntax
type CopyObjectResponse struct {
CopyObjectResult xml.Name `json:"copyObjectResult" xml:"CopyObjectResult"`
ETag string `json:"etag" xml:"ETag"`
LastModified time.Time `json:"lastModified" xml:"LastModified"`
ChecksumType string `json:"checksumType" xml:"ChecksumType"`
ChecksumCRC32 string `json:"checksumCRC32" xml:"ChecksumCRC32"`
ChecksumCRC32C string `json:"checksumCRC32C" xml:"ChecksumCRC32C"`
ChecksumCRC64NVME string `json:"checksumCRC64NVME" xml:"ChecksumCRC64NVME"`
ChecksumSHA1 string `json:"checksumSHA1" xml:"ChecksumSHA1"`
ChecksumSHA256 string `json:"checksumSHA256" xml:"ChecksumSHA256"`
}
// CopyObject copies a single object from srcKey to dstKey destination.
// (both keys are expected to be operating within the same bucket).
//
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
func (s3 *S3) CopyObject(ctx context.Context, srcKey string, dstKey string, optReqFuncs ...func(*http.Request)) (*CopyObjectResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, s3.URL(dstKey), nil)
if err != nil {
return nil, err
}
// per the doc the header value must be URL-encoded
req.Header.Set("x-amz-copy-source", url.PathEscape(s3.Bucket+"/"+strings.TrimLeft(srcKey, "/")))
// apply optional request funcs
for _, fn := range optReqFuncs {
if fn != nil {
fn(req)
}
}
resp, err := s3.SignAndSend(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
result := &CopyObjectResponse{}
err = xml.NewDecoder(resp.Body).Decode(result)
if err != nil {
return nil, err
}
return result, nil
}
@@ -0,0 +1,67 @@
package s3_test
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests"
)
func TestS3CopyObject(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodPut,
URL: "http://test_bucket.example.com/@dst_test",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"test_header": "test",
"x-amz-copy-source": "test_bucket%2F@src_test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Body: io.NopCloser(strings.NewReader(`
<CopyObjectResult>
<LastModified>2025-01-01T01:02:03.456Z</LastModified>
<ETag>test_etag</ETag>
</CopyObjectResult>
`)),
},
},
)
s3Client := &s3.S3{
Client: httpClient,
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com",
AccessKey: "123",
SecretKey: "abc",
}
copyResp, err := s3Client.CopyObject(context.Background(), "@src_test", "@dst_test", func(r *http.Request) {
r.Header.Set("test_header", "test")
})
if err != nil {
t.Fatal(err)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
if copyResp.ETag != "test_etag" {
t.Fatalf("Expected ETag %q, got %q", "test_etag", copyResp.ETag)
}
if date := copyResp.LastModified.Format("2006-01-02T15:04:05.000Z"); date != "2025-01-01T01:02:03.456Z" {
t.Fatalf("Expected LastModified %q, got %q", "2025-01-01T01:02:03.456Z", date)
}
}
@@ -0,0 +1,31 @@
package s3
import (
"context"
"net/http"
)
// DeleteObject deletes a single object by its key.
//
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html
func (s3 *S3) DeleteObject(ctx context.Context, key string, optFuncs ...func(*http.Request)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s3.URL(key), nil)
if err != nil {
return err
}
// apply optional request funcs
for _, fn := range optFuncs {
if fn != nil {
fn(req)
}
}
resp, err := s3.SignAndSend(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
@@ -0,0 +1,48 @@
package s3_test
import (
"context"
"net/http"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests"
)
func TestS3DeleteObject(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodDelete,
URL: "http://test_bucket.example.com/test_key",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
},
)
s3Client := &s3.S3{
Client: httpClient,
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com",
AccessKey: "123",
SecretKey: "abc",
}
err := s3Client.DeleteObject(context.Background(), "test_key", func(r *http.Request) {
r.Header.Set("test_header", "test")
})
if err != nil {
t.Fatal(err)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,47 @@
package s3
import (
"encoding/xml"
"strconv"
"strings"
)
// ResponseError defines a general S3 response error.
//
// https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html
type ResponseError struct {
XMLName xml.Name `json:"-" xml:"Error"`
Code string `json:"code" xml:"Code"`
Message string `json:"message" xml:"Message"`
RequestId string `json:"requestId" xml:"RequestId"`
Resource string `json:"resource" xml:"Resource"`
Raw []byte `json:"-" xml:"-"`
Status int `json:"status" xml:"Status"`
}
// Error implements the std error interface.
func (err ResponseError) Error() string {
var strBuilder strings.Builder
strBuilder.WriteString(strconv.Itoa(err.Status))
strBuilder.WriteString(" ")
if err.Code != "" {
strBuilder.WriteString(err.Code)
} else {
strBuilder.WriteString("S3ResponseError")
}
if err.Message != "" {
strBuilder.WriteString(": ")
strBuilder.WriteString(err.Message)
}
if len(err.Raw) > 0 {
strBuilder.WriteString("\n(RAW: ")
strBuilder.Write(err.Raw)
strBuilder.WriteString(")")
}
return strBuilder.String()
}
@@ -0,0 +1,86 @@
package s3_test
import (
"encoding/json"
"encoding/xml"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
)
func TestResponseErrorSerialization(t *testing.T) {
raw := `
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>test_code</Code>
<Message>test_message</Message>
<RequestId>test_request_id</RequestId>
<Resource>test_resource</Resource>
</Error>
`
respErr := s3.ResponseError{
Status: 123,
Raw: []byte("test"),
}
err := xml.Unmarshal([]byte(raw), &respErr)
if err != nil {
t.Fatal(err)
}
jsonRaw, err := json.Marshal(respErr)
if err != nil {
t.Fatal(err)
}
jsonStr := string(jsonRaw)
expected := `{"code":"test_code","message":"test_message","requestId":"test_request_id","resource":"test_resource","status":123}`
if expected != jsonStr {
t.Fatalf("Expected JSON\n%s\ngot\n%s", expected, jsonStr)
}
}
func TestResponseErrorErrorInterface(t *testing.T) {
scenarios := []struct {
name string
err s3.ResponseError
expected string
}{
{
"empty",
s3.ResponseError{},
"0 S3ResponseError",
},
{
"with code and message (nil raw)",
s3.ResponseError{
Status: 123,
Code: "test_code",
Message: "test_message",
},
"123 test_code: test_message",
},
{
"with code and message (non-nil raw)",
s3.ResponseError{
Status: 123,
Code: "test_code",
Message: "test_message",
Raw: []byte("test_raw"),
},
"123 test_code: test_message\n(RAW: test_raw)",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := s.err.Error()
if result != s.expected {
t.Fatalf("Expected\n%s\ngot\n%s", s.expected, result)
}
})
}
}
@@ -0,0 +1,43 @@
package s3
import (
"context"
"io"
"net/http"
)
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html#API_GetObject_ResponseElements
type GetObjectResponse struct {
Body io.ReadCloser `json:"-" xml:"-"`
HeadObjectResponse
}
// GetObject retrieves a single object by its key.
//
// NB! Make sure to call GetObjectResponse.Body.Close() after done working with the result.
//
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html
func (s3 *S3) GetObject(ctx context.Context, key string, optFuncs ...func(*http.Request)) (*GetObjectResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s3.URL(key), nil)
if err != nil {
return nil, err
}
// apply optional request funcs
for _, fn := range optFuncs {
if fn != nil {
fn(req)
}
}
resp, err := s3.SignAndSend(req)
if err != nil {
return nil, err
}
result := &GetObjectResponse{Body: resp.Body}
result.load(resp.Header)
return result, nil
}
@@ -0,0 +1,92 @@
package s3_test
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests"
)
func TestS3GetObject(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodGet,
URL: "http://test_bucket.example.com/test_key",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Cache-Control": []string{"test_cache"},
"Content-Disposition": []string{"test_disposition"},
"Content-Encoding": []string{"test_encoding"},
"Content-Language": []string{"test_language"},
"Content-Type": []string{"test_type"},
"Content-Range": []string{"test_range"},
"Etag": []string{"test_etag"},
"Content-Length": []string{"100"},
"x-amz-meta-AbC": []string{"test_meta_a"},
"x-amz-meta-Def": []string{"test_meta_b"},
},
Body: io.NopCloser(strings.NewReader("test")),
},
},
)
s3Client := &s3.S3{
Client: httpClient,
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com",
AccessKey: "123",
SecretKey: "abc",
}
resp, err := s3Client.GetObject(context.Background(), "test_key", func(r *http.Request) {
r.Header.Set("test_header", "test")
})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
// check body
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
bodyStr := string(body)
if bodyStr != "test" {
t.Fatalf("Expected body\n%q\ngot\n%q", "test", bodyStr)
}
// check serialized attributes
raw, err := json.Marshal(resp)
if err != nil {
t.Fatal(err)
}
rawStr := string(raw)
expected := `{"metadata":{"abc":"test_meta_a","def":"test_meta_b"},"lastModified":"2025-02-01T03:04:05Z","cacheControl":"test_cache","contentDisposition":"test_disposition","contentEncoding":"test_encoding","contentLanguage":"test_language","contentType":"test_type","contentRange":"test_range","etag":"test_etag","contentLength":100}`
if rawStr != expected {
t.Fatalf("Expected attributes\n%s\ngot\n%s", expected, rawStr)
}
}
@@ -0,0 +1,89 @@
package s3
import (
"context"
"net/http"
"strconv"
"time"
)
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html#API_HeadObject_ResponseElements
type HeadObjectResponse struct {
// Metadata is the extra data that is stored with the S3 object (aka. the "x-amz-meta-*" header values).
//
// The map keys are normalized to lower-case.
Metadata map[string]string `json:"metadata"`
// LastModified date and time when the object was last modified.
LastModified time.Time `json:"lastModified"`
// CacheControl specifies caching behavior along the request/reply chain.
CacheControl string `json:"cacheControl"`
// ContentDisposition specifies presentational information for the object.
ContentDisposition string `json:"contentDisposition"`
// ContentEncoding indicates what content encodings have been applied to the object
// and thus what decoding mechanisms must be applied to obtain the
// media-type referenced by the Content-Type header field.
ContentEncoding string `json:"contentEncoding"`
// ContentLanguage indicates the language the content is in.
ContentLanguage string `json:"contentLanguage"`
// ContentType is a standard MIME type describing the format of the object data.
ContentType string `json:"contentType"`
// ContentRange is the portion of the object usually returned in the response for a GET request.
ContentRange string `json:"contentRange"`
// ETag is an opaque identifier assigned by a web
// server to a specific version of a resource found at a URL.
ETag string `json:"etag"`
// ContentLength is size of the body in bytes.
ContentLength int64 `json:"contentLength"`
}
// load parses and load the header values into the current HeadObjectResponse fields.
func (o *HeadObjectResponse) load(headers http.Header) {
o.LastModified, _ = time.Parse(time.RFC1123, headers.Get("Last-Modified"))
o.CacheControl = headers.Get("Cache-Control")
o.ContentDisposition = headers.Get("Content-Disposition")
o.ContentEncoding = headers.Get("Content-Encoding")
o.ContentLanguage = headers.Get("Content-Language")
o.ContentType = headers.Get("Content-Type")
o.ContentRange = headers.Get("Content-Range")
o.ETag = headers.Get("ETag")
o.ContentLength, _ = strconv.ParseInt(headers.Get("Content-Length"), 10, 0)
o.Metadata = extractMetadata(headers)
}
// HeadObject sends a HEAD request for a single object to check its
// existence and to retrieve its metadata.
//
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html
func (s3 *S3) HeadObject(ctx context.Context, key string, optFuncs ...func(*http.Request)) (*HeadObjectResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, s3.URL(key), nil)
if err != nil {
return nil, err
}
// apply optional request funcs
for _, fn := range optFuncs {
if fn != nil {
fn(req)
}
}
resp, err := s3.SignAndSend(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
result := &HeadObjectResponse{}
result.load(resp.Header)
return result, nil
}
@@ -0,0 +1,77 @@
package s3_test
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests"
)
func TestS3HeadObject(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodHead,
URL: "http://test_bucket.example.com/test_key",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Cache-Control": []string{"test_cache"},
"Content-Disposition": []string{"test_disposition"},
"Content-Encoding": []string{"test_encoding"},
"Content-Language": []string{"test_language"},
"Content-Type": []string{"test_type"},
"Content-Range": []string{"test_range"},
"Etag": []string{"test_etag"},
"Content-Length": []string{"100"},
"x-amz-meta-AbC": []string{"test_meta_a"},
"x-amz-meta-Def": []string{"test_meta_b"},
},
Body: http.NoBody,
},
},
)
s3Client := &s3.S3{
Client: httpClient,
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com",
AccessKey: "123",
SecretKey: "abc",
}
resp, err := s3Client.HeadObject(context.Background(), "test_key", func(r *http.Request) {
r.Header.Set("test_header", "test")
})
if err != nil {
t.Fatal(err)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatal(err)
}
rawStr := string(raw)
expected := `{"metadata":{"abc":"test_meta_a","def":"test_meta_b"},"lastModified":"2025-02-01T03:04:05Z","cacheControl":"test_cache","contentDisposition":"test_disposition","contentEncoding":"test_encoding","contentLanguage":"test_language","contentType":"test_type","contentRange":"test_range","etag":"test_etag","contentLength":100}`
if rawStr != expected {
t.Fatalf("Expected response\n%s\ngot\n%s", expected, rawStr)
}
}
@@ -0,0 +1,165 @@
package s3
import (
"context"
"encoding/xml"
"net/http"
"net/url"
"strconv"
"time"
)
// ListParams defines optional parameters for the ListObject request.
type ListParams struct {
// ContinuationToken indicates that the list is being continued on this bucket with a token.
// ContinuationToken is obfuscated and is not a real key.
// You can use this ContinuationToken for pagination of the list results.
ContinuationToken string `json:"continuationToken"`
// Delimiter is a character that you use to group keys.
//
// For directory buckets, "/" is the only supported delimiter.
Delimiter string `json:"delimiter"`
// Prefix limits the response to keys that begin with the specified prefix.
Prefix string `json:"prefix"`
// Encoding type is used to encode the object keys in the response.
// Responses are encoded only in UTF-8.
// An object key can contain any Unicode character.
// However, the XML 1.0 parser can't parse certain characters,
// such as characters with an ASCII value from 0 to 10.
// For characters that aren't supported in XML 1.0, you can add
// this parameter to request that S3 encode the keys in the response.
//
// Valid Values: url
EncodingType string `json:"encodingType"`
// StartAfter is where you want S3 to start listing from.
// S3 starts listing after this specified key.
// StartAfter can be any key in the bucket.
//
// This functionality is not supported for directory buckets.
StartAfter string `json:"startAfter"`
// MaxKeys Sets the maximum number of keys returned in the response.
// By default, the action returns up to 1,000 key names.
// The response might contain fewer keys but will never contain more.
MaxKeys int `json:"maxKeys"`
// FetchOwner returns the owner field with each key in the result.
FetchOwner bool `json:"fetchOwner"`
}
// Encode encodes the parameters in a properly formatted query string.
func (l *ListParams) Encode() string {
query := url.Values{}
query.Add("list-type", "2")
if l.ContinuationToken != "" {
query.Add("continuation-token", l.ContinuationToken)
}
if l.Delimiter != "" {
query.Add("delimiter", l.Delimiter)
}
if l.Prefix != "" {
query.Add("prefix", l.Prefix)
}
if l.EncodingType != "" {
query.Add("encoding-type", l.EncodingType)
}
if l.FetchOwner {
query.Add("fetch-owner", "true")
}
if l.MaxKeys > 0 {
query.Add("max-keys", strconv.Itoa(l.MaxKeys))
}
if l.StartAfter != "" {
query.Add("start-after", l.StartAfter)
}
return query.Encode()
}
// ListObjects retrieves paginated objects list.
//
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
func (s3 *S3) ListObjects(ctx context.Context, params ListParams, optReqFuncs ...func(*http.Request)) (*ListObjectsResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s3.URL("?"+params.Encode()), nil)
if err != nil {
return nil, err
}
// apply optional request funcs
for _, fn := range optReqFuncs {
if fn != nil {
fn(req)
}
}
resp, err := s3.SignAndSend(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
result := &ListObjectsResponse{}
err = xml.NewDecoder(resp.Body).Decode(result)
if err != nil {
return nil, err
}
return result, nil
}
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html#API_ListObjectsV2_ResponseSyntax
type ListObjectsResponse struct {
XMLName xml.Name `json:"-" xml:"ListBucketResult"`
EncodingType string `json:"encodingType" xml:"EncodingType"`
Name string `json:"name" xml:"Name"`
Prefix string `json:"prefix" xml:"Prefix"`
Delimiter string `json:"delimiter" xml:"Delimiter"`
ContinuationToken string `json:"continuationToken" xml:"ContinuationToken"`
NextContinuationToken string `json:"nextContinuationToken" xml:"NextContinuationToken"`
StartAfter string `json:"startAfter" xml:"StartAfter"`
CommonPrefixes []*ListObjectCommonPrefix `json:"commonPrefixes" xml:"CommonPrefixes"`
Contents []*ListObjectContent `json:"contents" xml:"Contents"`
KeyCount int `json:"keyCount" xml:"KeyCount"`
MaxKeys int `json:"maxKeys" xml:"MaxKeys"`
IsTruncated bool `json:"isTruncated" xml:"IsTruncated"`
}
type ListObjectCommonPrefix struct {
Prefix string `json:"prefix" xml:"Prefix"`
}
type ListObjectContent struct {
Owner struct {
DisplayName string `json:"displayName" xml:"DisplayName"`
ID string `json:"id" xml:"ID"`
} `json:"owner" xml:"Owner"`
ChecksumAlgorithm string `json:"checksumAlgorithm" xml:"ChecksumAlgorithm"`
ETag string `json:"etag" xml:"ETag"`
Key string `json:"key" xml:"Key"`
StorageClass string `json:"storageClass" xml:"StorageClass"`
LastModified time.Time `json:"lastModified" xml:"LastModified"`
RestoreStatus struct {
RestoreExpiryDate time.Time `json:"restoreExpiryDate" xml:"RestoreExpiryDate"`
IsRestoreInProgress bool `json:"isRestoreInProgress" xml:"IsRestoreInProgress"`
} `json:"restoreStatus" xml:"RestoreStatus"`
Size int64 `json:"size" xml:"Size"`
}
@@ -0,0 +1,157 @@
package s3_test
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests"
)
func TestS3ListParamsEncode(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
params s3.ListParams
expected string
}{
{
"blank",
s3.ListParams{},
"list-type=2",
},
{
"filled",
s3.ListParams{
ContinuationToken: "test_ct",
Delimiter: "test_delimiter",
Prefix: "test_prefix",
EncodingType: "test_et",
StartAfter: "test_sa",
MaxKeys: 1,
FetchOwner: true,
},
"continuation-token=test_ct&delimiter=test_delimiter&encoding-type=test_et&fetch-owner=true&list-type=2&max-keys=1&prefix=test_prefix&start-after=test_sa",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := s.params.Encode()
if result != s.expected {
t.Fatalf("Expected\n%s\ngot\n%s", s.expected, result)
}
})
}
}
func TestS3ListObjects(t *testing.T) {
t.Parallel()
listParams := s3.ListParams{
ContinuationToken: "test_ct",
Delimiter: "test_delimiter",
Prefix: "test_prefix",
EncodingType: "test_et",
StartAfter: "test_sa",
MaxKeys: 10,
FetchOwner: true,
}
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodGet,
URL: "http://test_bucket.example.com/?" + listParams.Encode(),
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Body: io.NopCloser(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>example</Name>
<EncodingType>test_encoding</EncodingType>
<Prefix>a/</Prefix>
<Delimiter>/</Delimiter>
<ContinuationToken>ct</ContinuationToken>
<NextContinuationToken>nct</NextContinuationToken>
<StartAfter>example0.txt</StartAfter>
<KeyCount>1</KeyCount>
<MaxKeys>3</MaxKeys>
<IsTruncated>true</IsTruncated>
<Contents>
<Key>example1.txt</Key>
<LastModified>2025-01-01T01:02:03.123Z</LastModified>
<ChecksumAlgorithm>test_ca</ChecksumAlgorithm>
<ETag>test_etag1</ETag>
<Size>123</Size>
<StorageClass>STANDARD</StorageClass>
<Owner>
<DisplayName>owner_dn</DisplayName>
<ID>owner_id</ID>
</Owner>
<RestoreStatus>
<RestoreExpiryDate>2025-01-02T01:02:03.123Z</RestoreExpiryDate>
<IsRestoreInProgress>true</IsRestoreInProgress>
</RestoreStatus>
</Contents>
<Contents>
<Key>example2.txt</Key>
<LastModified>2025-01-02T01:02:03.123Z</LastModified>
<ETag>test_etag2</ETag>
<Size>456</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<CommonPrefixes>
<Prefix>a/b/</Prefix>
</CommonPrefixes>
<CommonPrefixes>
<Prefix>a/c/</Prefix>
</CommonPrefixes>
</ListBucketResult>
`)),
},
},
)
s3Client := &s3.S3{
Client: httpClient,
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com",
AccessKey: "123",
SecretKey: "abc",
}
resp, err := s3Client.ListObjects(context.Background(), listParams, func(r *http.Request) {
r.Header.Set("test_header", "test")
})
if err != nil {
t.Fatal(err)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatal(err)
}
rawStr := string(raw)
expected := `{"encodingType":"test_encoding","name":"example","prefix":"a/","delimiter":"/","continuationToken":"ct","nextContinuationToken":"nct","startAfter":"example0.txt","commonPrefixes":[{"prefix":"a/b/"},{"prefix":"a/c/"}],"contents":[{"owner":{"displayName":"owner_dn","id":"owner_id"},"checksumAlgorithm":"test_ca","etag":"test_etag1","key":"example1.txt","storageClass":"STANDARD","lastModified":"2025-01-01T01:02:03.123Z","restoreStatus":{"restoreExpiryDate":"2025-01-02T01:02:03.123Z","isRestoreInProgress":true},"size":123},{"owner":{"displayName":"","id":""},"checksumAlgorithm":"","etag":"test_etag2","key":"example2.txt","storageClass":"STANDARD","lastModified":"2025-01-02T01:02:03.123Z","restoreStatus":{"restoreExpiryDate":"0001-01-01T00:00:00Z","isRestoreInProgress":false},"size":456}],"keyCount":1,"maxKeys":3,"isTruncated":true}`
if rawStr != expected {
t.Fatalf("Expected response\n%s\ngot\n%s", expected, rawStr)
}
}
+282
View File
@@ -0,0 +1,282 @@
// Package s3 implements a lightweight client for interacting with the
// REST APIs of any S3 compatible service.
//
// It implements only the minimal functionality required by PocketBase
// such as objects list, get, copy, delete and upload.
//
// For more details why we don't use the official aws-sdk-go-v2, you could check
// https://github.com/pocketbase/pocketbase/discussions/6562.
//
// Example:
//
// client := &s3.S3{
// Endpoint: "example.com",
// Region: "us-east-1",
// Bucket: "test",
// AccessKey: "...",
// SecretKey: "...",
// UsePathStyle: true,
// }
// resp, err := client.GetObject(context.Background(), "abc.txt")
package s3
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"slices"
"strings"
"time"
)
const (
awsS3ServiceCode = "s3"
awsSignAlgorithm = "AWS4-HMAC-SHA256"
awsTerminationString = "aws4_request"
metadataPrefix = "x-amz-meta-"
dateTimeFormat = "20060102T150405Z"
)
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type S3 struct {
// Client specifies a custom HTTP client to send the request with.
//
// If not explicitly set, fallbacks to http.DefaultClient.
Client HTTPClient
Bucket string
Region string
Endpoint string // can be with or without the schema
AccessKey string
SecretKey string
UsePathStyle bool
}
// URL constructs an S3 request URL based on the current configuration.
func (s3 *S3) URL(key string) string {
scheme := "https"
endpoint := strings.TrimRight(s3.Endpoint, "/")
if after, ok := strings.CutPrefix(endpoint, "https://"); ok {
endpoint = after
} else if after, ok := strings.CutPrefix(endpoint, "http://"); ok {
endpoint = after
scheme = "http"
}
key = strings.TrimLeft(key, "/")
if s3.UsePathStyle {
return fmt.Sprintf("%s://%s/%s/%s", scheme, endpoint, s3.Bucket, key)
}
return fmt.Sprintf("%s://%s.%s/%s", scheme, s3.Bucket, endpoint, key)
}
// SignAndSend signs the provided request per AWS Signature v4 and sends it.
//
// It automatically normalizes all 40x/50x responses to ResponseError.
//
// Note: Don't forget to call resp.Body.Close() after done with the result.
func (s3 *S3) SignAndSend(req *http.Request) (*http.Response, error) {
s3.sign(req)
client := s3.Client
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
defer resp.Body.Close()
respErr := &ResponseError{
Status: resp.StatusCode,
}
respErr.Raw, err = io.ReadAll(resp.Body)
if err != nil && !errors.Is(err, io.EOF) {
return nil, errors.Join(err, respErr)
}
if len(respErr.Raw) > 0 {
err = xml.Unmarshal(respErr.Raw, respErr)
if err != nil {
return nil, errors.Join(err, respErr)
}
}
return nil, respErr
}
return resp, nil
}
// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-signed-request-steps
func (s3 *S3) sign(req *http.Request) {
// fallback to the Unsigned payload option
// (data integrity checks could be still applied via the content-md5 or x-amz-checksum-* headers)
if req.Header.Get("x-amz-content-sha256") == "" {
req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
}
reqDateTime, _ := time.Parse(dateTimeFormat, req.Header.Get("x-amz-date"))
if reqDateTime.IsZero() {
reqDateTime = time.Now().UTC()
req.Header.Set("x-amz-date", reqDateTime.Format(dateTimeFormat))
}
req.Header.Set("host", req.URL.Host)
date := reqDateTime.Format("20060102")
dateTime := reqDateTime.Format(dateTimeFormat)
// 1. Create canonical request
// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-canonical-request
// ---------------------------------------------------------------
canonicalHeaders, signedHeaders := canonicalAndSignedHeaders(req)
canonicalParts := []string{
req.Method,
req.URL.EscapedPath(),
encodeQuery(req),
canonicalHeaders,
signedHeaders,
req.Header.Get("x-amz-content-sha256"),
}
// 2. Create a hash of the canonical request
// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-canonical-request-hash
// ---------------------------------------------------------------
hashedCanonicalRequest := sha256Hex([]byte(strings.Join(canonicalParts, "\n")))
// 3. Create a string to sign
// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-string-to-sign
// ---------------------------------------------------------------
scope := strings.Join([]string{
date,
s3.Region,
awsS3ServiceCode,
awsTerminationString,
}, "/")
stringToSign := strings.Join([]string{
awsSignAlgorithm,
dateTime,
scope,
hashedCanonicalRequest,
}, "\n")
// 4. Derive a signing key for SigV4
// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#derive-signing-key
// ---------------------------------------------------------------
dateKey := hmacSHA256([]byte("AWS4"+s3.SecretKey), date)
dateRegionKey := hmacSHA256(dateKey, s3.Region)
dateRegionServiceKey := hmacSHA256(dateRegionKey, awsS3ServiceCode)
signingKey := hmacSHA256(dateRegionServiceKey, awsTerminationString)
signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign))
// 5. Add the signature to the request
// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#add-signature-to-request
authorization := fmt.Sprintf(
"%s Credential=%s/%s, SignedHeaders=%s, Signature=%s",
awsSignAlgorithm,
s3.AccessKey,
scope,
signedHeaders,
signature,
)
req.Header.Set("authorization", authorization)
}
// encodeQuery encodes the request query parameters according to the AWS requirements
// (see UriEncode description in https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html).
func encodeQuery(req *http.Request) string {
return strings.ReplaceAll(req.URL.Query().Encode(), "+", "%20")
}
func sha256Hex(content []byte) string {
h := sha256.New()
h.Write(content)
return hex.EncodeToString(h.Sum(nil))
}
func hmacSHA256(key []byte, content string) []byte {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(content))
return mac.Sum(nil)
}
func canonicalAndSignedHeaders(req *http.Request) (string, string) {
signed := []string{}
canonical := map[string]string{}
for key, values := range req.Header {
normalizedKey := strings.ToLower(key)
if normalizedKey != "host" &&
normalizedKey != "content-type" &&
!strings.HasPrefix(normalizedKey, "x-amz-") {
continue
}
signed = append(signed, normalizedKey)
// for each value:
// trim any leading or trailing spaces
// convert sequential spaces to a single space
normalizedValues := make([]string, len(values))
for i, v := range values {
normalizedValues[i] = strings.ReplaceAll(strings.TrimSpace(v), " ", " ")
}
canonical[normalizedKey] = strings.Join(normalizedValues, ",")
}
slices.Sort(signed)
var sortedCanonical strings.Builder
for _, key := range signed {
sortedCanonical.WriteString(key)
sortedCanonical.WriteString(":")
sortedCanonical.WriteString(canonical[key])
sortedCanonical.WriteString("\n")
}
return sortedCanonical.String(), strings.Join(signed, ";")
}
// extractMetadata parses and extracts and the metadata from the specified request headers.
//
// The metadata keys are all lowercased and without the "x-amz-meta-" prefix.
func extractMetadata(headers http.Header) map[string]string {
result := map[string]string{}
for k, v := range headers {
if len(v) == 0 {
continue
}
metadataKey, ok := strings.CutPrefix(strings.ToLower(k), metadataPrefix)
if !ok {
continue
}
result[metadataKey] = v[0]
}
return result
}
@@ -0,0 +1,225 @@
package s3_test
import (
"io"
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests"
)
func TestS3URL(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
s3Client *s3.S3
expected string
}{
{
"no schema",
&s3.S3{
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "example.com/",
AccessKey: "123",
SecretKey: "abc",
},
"https://test_bucket.example.com/test_key/a/b/c?q=1",
},
{
"with https schema",
&s3.S3{
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "https://example.com/",
AccessKey: "123",
SecretKey: "abc",
},
"https://test_bucket.example.com/test_key/a/b/c?q=1",
},
{
"with http schema",
&s3.S3{
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com/",
AccessKey: "123",
SecretKey: "abc",
},
"http://test_bucket.example.com/test_key/a/b/c?q=1",
},
{
"path style addressing (non-explicit schema)",
&s3.S3{
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "example.com/",
AccessKey: "123",
SecretKey: "abc",
UsePathStyle: true,
},
"https://example.com/test_bucket/test_key/a/b/c?q=1",
},
{
"path style addressing (explicit schema)",
&s3.S3{
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com/",
AccessKey: "123",
SecretKey: "abc",
UsePathStyle: true,
},
"http://example.com/test_bucket/test_key/a/b/c?q=1",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := s.s3Client.URL("/test_key/a/b/c?q=1")
if result != s.expected {
t.Fatalf("Expected URL\n%s\ngot\n%s", s.expected, result)
}
})
}
}
func TestS3SignAndSend(t *testing.T) {
t.Parallel()
testResponse := func() *http.Response {
return &http.Response{
Body: io.NopCloser(strings.NewReader("test_response")),
}
}
scenarios := []struct {
name string
reqFunc func(req *http.Request)
s3Client *s3.S3
}{
{
"minimal",
func(req *http.Request) {
req.Header.Set("x-amz-date", "20250102T150405Z")
},
&s3.S3{
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "https://example.com/",
AccessKey: "123",
SecretKey: "abc",
Client: tests.NewClient(&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/test",
Response: testResponse(),
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"Authorization": "AWS4-HMAC-SHA256 Credential=123/20250102/test_region/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=ea093662bc1deef08dfb4ac35453dfaad5ea89edf102e9dd3b7156c9a27e4c1f",
"Host": "test_bucket.example.com",
"X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD",
"X-Amz-Date": "20250102T150405Z",
})
},
}),
},
},
{
"minimal with different access and secret keys",
func(req *http.Request) {
req.Header.Set("x-amz-date", "20250102T150405Z")
},
&s3.S3{
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "https://example.com/",
AccessKey: "456",
SecretKey: "def",
Client: tests.NewClient(&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/test",
Response: testResponse(),
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"Authorization": "AWS4-HMAC-SHA256 Credential=456/20250102/test_region/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=17510fa1f724403dd0a563b61c9b31d1d718f877fcbd75455620d17a8afce5fb",
"Host": "test_bucket.example.com",
"X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD",
"X-Amz-Date": "20250102T150405Z",
})
},
}),
},
},
{
"with extra headers",
func(req *http.Request) {
req.Header.Set("x-amz-date", "20250102T150405Z")
req.Header.Set("x-amz-content-sha256", "test_sha256")
req.Header.Set("x-amz-example", "123")
req.Header.Set("x-amz-meta-a", "456")
req.Header.Set("content-type", "image/png")
req.Header.Set("x-test", "789") // shouldn't be included in the signing headers
},
&s3.S3{
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "https://example.com/",
AccessKey: "123",
SecretKey: "abc",
Client: tests.NewClient(&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/test",
Response: testResponse(),
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"authorization": "AWS4-HMAC-SHA256 Credential=123/20250102/test_region/s3/aws4_request, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-example;x-amz-meta-a, Signature=86dccbcd012c33073dc99e9d0a9e0b717a4d8c11c37848cfa9a4a02716bc0db3",
"host": "test_bucket.example.com",
"x-amz-date": "20250102T150405Z",
"x-amz-content-sha256": "test_sha256",
"x-amz-example": "123",
"x-amz-meta-a": "456",
"x-test": "789",
})
},
}),
},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, s.s3Client.URL("/test"), strings.NewReader("test_request"))
if err != nil {
t.Fatal(err)
}
if s.reqFunc != nil {
s.reqFunc(req)
}
resp, err := s.s3Client.SignAndSend(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
err = s.s3Client.Client.(*tests.Client).AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
expectedBody := "test_response"
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if str := string(body); str != expectedBody {
t.Fatalf("Expected body %q, got %q", expectedBody, str)
}
})
}
}
@@ -0,0 +1,111 @@
// Package tests contains various tests helpers and utilities to assist
// with the S3 client testing.
package tests
import (
"errors"
"fmt"
"io"
"net/http"
"regexp"
"slices"
"strings"
"sync"
)
// NewClient creates a new test Client loaded with the specified RequestStubs.
func NewClient(stubs ...*RequestStub) *Client {
return &Client{stubs: stubs}
}
type RequestStub struct {
Method string
URL string // plain string or regex pattern wrapped in "^pattern$"
Match func(req *http.Request) bool
Response *http.Response
}
type Client struct {
stubs []*RequestStub
mu sync.Mutex
}
// AssertNoRemaining asserts that current client has no unprocessed requests remaining.
func (c *Client) AssertNoRemaining() error {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.stubs) == 0 {
return nil
}
msgParts := make([]string, 0, len(c.stubs)+1)
msgParts = append(msgParts, "not all stub requests were processed:")
for _, stub := range c.stubs {
msgParts = append(msgParts, "- "+stub.Method+" "+stub.URL)
}
return errors.New(strings.Join(msgParts, "\n"))
}
// Do implements the [s3.HTTPClient] interface.
func (c *Client) Do(req *http.Request) (*http.Response, error) {
c.mu.Lock()
defer c.mu.Unlock()
for i, stub := range c.stubs {
if req.Method != stub.Method {
continue
}
urlPattern := stub.URL
if !strings.HasPrefix(urlPattern, "^") && !strings.HasSuffix(urlPattern, "$") {
urlPattern = "^" + regexp.QuoteMeta(urlPattern) + "$"
}
urlRegex, err := regexp.Compile(urlPattern)
if err != nil {
return nil, err
}
if !urlRegex.MatchString(req.URL.String()) {
continue
}
if stub.Match != nil && !stub.Match(req) {
continue
}
// remove from the remaining stubs
c.stubs = slices.Delete(c.stubs, i, i+1)
response := stub.Response
if response == nil {
response = &http.Response{}
}
if response.Header == nil {
response.Header = http.Header{}
}
if response.Body == nil {
response.Body = http.NoBody
}
response.Request = req
return response, nil
}
var body []byte
if req.Body != nil {
defer req.Body.Close()
body, _ = io.ReadAll(req.Body)
}
return nil, fmt.Errorf(
"the below request doesn't have a corresponding stub:\n%s %s\nHeaders: %v\nBody: %q",
req.Method,
req.URL.String(),
req.Header,
body,
)
}
@@ -0,0 +1,33 @@
package tests
import (
"net/http"
"regexp"
"strings"
)
// ExpectHeaders checks whether specified headers match the expectations.
// The expectations map entry key is the header name.
// The expectations map entry value is the first header value. If wrapped with `^...$`
// it is compared as regular expression.
func ExpectHeaders(headers http.Header, expectations map[string]string) bool {
for h, expected := range expectations {
v := headers.Get(h)
pattern := expected
if !strings.HasPrefix(pattern, "^") && !strings.HasSuffix(pattern, "$") {
pattern = "^" + regexp.QuoteMeta(pattern) + "$"
}
expectedRegex, err := regexp.Compile(pattern)
if err != nil {
return false
}
if !expectedRegex.MatchString(v) {
return false
}
}
return true
}
@@ -0,0 +1,414 @@
package s3
import (
"bytes"
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"golang.org/x/sync/errgroup"
)
var ErrUsedUploader = errors.New("the Uploader has been already used")
const (
defaultMaxConcurrency int = 5
defaultMinPartSize int = 6 << 20
)
// Uploader handles the upload of a single S3 object.
//
// If the Payload size is less than the configured MinPartSize it sends
// a single (PutObject) request, otherwise performs chunked/multipart upload.
type Uploader struct {
// S3 is the S3 client instance performing the upload object request (required).
S3 *S3
// Payload is the object content to upload (required).
Payload io.Reader
// Key is the destination key of the uploaded object (required).
Key string
// Metadata specifies the optional metadata to write with the object upload.
Metadata map[string]string
// MaxConcurrency specifies the max number of workers to use when
// performing chunked/multipart upload.
//
// If zero or negative, defaults to 5.
//
// This option is used only when the Payload size is > MinPartSize.
MaxConcurrency int
// MinPartSize specifies the min Payload size required to perform
// chunked/multipart upload.
//
// If zero or negative, defaults to ~6MB.
MinPartSize int
uploadId string
uploadedParts []*mpPart
lastPartNumber int
mu sync.Mutex // guards lastPartNumber and the uploadedParts slice
used bool
}
// Upload processes the current Uploader instance.
//
// Users can specify an optional optReqFuncs that will be passed down to all Upload internal requests
// (single upload, multipart init, multipart parts upload, multipart complete, multipart abort).
//
// Note that after this call the Uploader should be discarded (aka. no longer can be used).
func (u *Uploader) Upload(ctx context.Context, optReqFuncs ...func(*http.Request)) error {
if u.used {
return ErrUsedUploader
}
err := u.validateAndNormalize()
if err != nil {
return err
}
initPart, _, err := u.nextPart()
if err != nil && !errors.Is(err, io.EOF) {
return err
}
if len(initPart) < u.MinPartSize {
return u.singleUpload(ctx, initPart, optReqFuncs...)
}
err = u.multipartInit(ctx, optReqFuncs...)
if err != nil {
return fmt.Errorf("multipart init error: %w", err)
}
err = u.multipartUpload(ctx, initPart, optReqFuncs...)
if err != nil {
return errors.Join(
u.multipartAbort(ctx, optReqFuncs...),
fmt.Errorf("multipart upload error: %w", err),
)
}
err = u.multipartComplete(ctx, optReqFuncs...)
if err != nil {
return errors.Join(
u.multipartAbort(ctx, optReqFuncs...),
fmt.Errorf("multipart complete error: %w", err),
)
}
return nil
}
// -------------------------------------------------------------------
func (u *Uploader) validateAndNormalize() error {
if u.S3 == nil {
return errors.New("Uploader.S3 must be a non-empty and properly initialized S3 client instance")
}
if u.Key == "" {
return errors.New("Uploader.Key is required")
}
if u.Payload == nil {
return errors.New("Uploader.Payload must be non-nill")
}
if u.MaxConcurrency <= 0 {
u.MaxConcurrency = defaultMaxConcurrency
}
if u.MinPartSize <= 0 {
u.MinPartSize = defaultMinPartSize
}
return nil
}
func (u *Uploader) singleUpload(ctx context.Context, part []byte, optReqFuncs ...func(*http.Request)) error {
if u.used {
return ErrUsedUploader
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.S3.URL(u.Key), bytes.NewReader(part))
if err != nil {
return err
}
req.Header.Set("Content-Length", strconv.Itoa(len(part)))
for k, v := range u.Metadata {
req.Header.Set(metadataPrefix+k, v)
}
// apply optional request funcs
for _, fn := range optReqFuncs {
if fn != nil {
fn(req)
}
}
resp, err := u.S3.SignAndSend(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
// -------------------------------------------------------------------
type mpPart struct {
XMLName xml.Name `xml:"Part"`
ETag string `xml:"ETag"`
PartNumber int `xml:"PartNumber"`
}
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html
func (u *Uploader) multipartInit(ctx context.Context, optReqFuncs ...func(*http.Request)) error {
if u.used {
return ErrUsedUploader
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.S3.URL(u.Key+"?uploads"), nil)
if err != nil {
return err
}
for k, v := range u.Metadata {
req.Header.Set(metadataPrefix+k, v)
}
// apply optional request funcs
for _, fn := range optReqFuncs {
if fn != nil {
fn(req)
}
}
resp, err := u.S3.SignAndSend(req)
if err != nil {
return err
}
defer resp.Body.Close()
body := &struct {
XMLName xml.Name `xml:"InitiateMultipartUploadResult"`
UploadId string `xml:"UploadId"`
}{}
err = xml.NewDecoder(resp.Body).Decode(body)
if err != nil {
return err
}
u.uploadId = body.UploadId
return nil
}
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html
func (u *Uploader) multipartAbort(ctx context.Context, optReqFuncs ...func(*http.Request)) error {
u.mu.Lock()
defer u.mu.Unlock()
u.used = true
// ensure that the specified abort context is always valid to allow cleanup
var abortCtx = ctx
if abortCtx.Err() != nil {
abortCtx = context.Background()
}
query := url.Values{"uploadId": []string{u.uploadId}}
req, err := http.NewRequestWithContext(abortCtx, http.MethodDelete, u.S3.URL(u.Key+"?"+query.Encode()), nil)
if err != nil {
return err
}
// apply optional request funcs
for _, fn := range optReqFuncs {
if fn != nil {
fn(req)
}
}
resp, err := u.S3.SignAndSend(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html
func (u *Uploader) multipartComplete(ctx context.Context, optReqFuncs ...func(*http.Request)) error {
u.mu.Lock()
defer u.mu.Unlock()
u.used = true
// the list of parts must be sorted in ascending order
slices.SortFunc(u.uploadedParts, func(a, b *mpPart) int {
if a.PartNumber < b.PartNumber {
return -1
}
if a.PartNumber > b.PartNumber {
return 1
}
return 0
})
// build a request payload with the uploaded parts
xmlParts := &struct {
XMLName xml.Name `xml:"CompleteMultipartUpload"`
Parts []*mpPart
}{
Parts: u.uploadedParts,
}
rawXMLParts, err := xml.Marshal(xmlParts)
if err != nil {
return err
}
reqPayload := strings.NewReader(xml.Header + string(rawXMLParts))
query := url.Values{"uploadId": []string{u.uploadId}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.S3.URL(u.Key+"?"+query.Encode()), reqPayload)
if err != nil {
return err
}
// apply optional request funcs
for _, fn := range optReqFuncs {
if fn != nil {
fn(req)
}
}
resp, err := u.S3.SignAndSend(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
func (u *Uploader) nextPart() ([]byte, int, error) {
u.mu.Lock()
defer u.mu.Unlock()
part := make([]byte, u.MinPartSize)
n, err := io.ReadFull(u.Payload, part)
// normalize io.EOF errors and ensure that io.EOF is returned only when there were no read bytes
if err != nil && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
if n == 0 {
err = io.EOF
} else {
err = nil
}
}
u.lastPartNumber++
return part[0:n], u.lastPartNumber, err
}
func (u *Uploader) multipartUpload(ctx context.Context, initPart []byte, optReqFuncs ...func(*http.Request)) error {
var g errgroup.Group
g.SetLimit(u.MaxConcurrency)
totalParallel := u.MaxConcurrency
if len(initPart) != 0 {
totalParallel--
initPartNumber := u.lastPartNumber
g.Go(func() error {
mp, err := u.uploadPart(ctx, initPartNumber, initPart, optReqFuncs...)
if err != nil {
return err
}
u.mu.Lock()
u.uploadedParts = append(u.uploadedParts, mp)
u.mu.Unlock()
return nil
})
}
for i := 0; i < totalParallel; i++ {
g.Go(func() error {
for {
part, num, err := u.nextPart()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return err
}
mp, err := u.uploadPart(ctx, num, part, optReqFuncs...)
if err != nil {
return err
}
u.mu.Lock()
u.uploadedParts = append(u.uploadedParts, mp)
u.mu.Unlock()
}
return nil
})
}
return g.Wait()
}
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPart.html
func (u *Uploader) uploadPart(ctx context.Context, partNumber int, partData []byte, optReqFuncs ...func(*http.Request)) (*mpPart, error) {
query := url.Values{}
query.Set("uploadId", u.uploadId)
query.Set("partNumber", strconv.Itoa(partNumber))
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.S3.URL(u.Key+"?"+query.Encode()), bytes.NewReader(partData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Length", strconv.Itoa(len(partData)))
// apply optional request funcs
for _, fn := range optReqFuncs {
if fn != nil {
fn(req)
}
}
resp, err := u.S3.SignAndSend(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return &mpPart{
PartNumber: partNumber,
ETag: resp.Header.Get("ETag"),
}, nil
}
@@ -0,0 +1,463 @@
package s3_test
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests"
)
func TestUploaderRequiredFields(t *testing.T) {
t.Parallel()
s3Client := &s3.S3{
Client: tests.NewClient(&tests.RequestStub{Method: "PUT", URL: `^.+$`}), // match every upload
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com",
AccessKey: "123",
SecretKey: "abc",
}
payload := strings.NewReader("test")
scenarios := []struct {
name string
uploader *s3.Uploader
expectedError bool
}{
{
"blank",
&s3.Uploader{},
true,
},
{
"no Key",
&s3.Uploader{S3: s3Client, Payload: payload},
true,
},
{
"no S3",
&s3.Uploader{Key: "abc", Payload: payload},
true,
},
{
"no Payload",
&s3.Uploader{S3: s3Client, Key: "abc"},
true,
},
{
"with S3, Key and Payload",
&s3.Uploader{S3: s3Client, Key: "abc", Payload: payload},
false,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
err := s.uploader.Upload(context.Background())
hasErr := err != nil
if hasErr != s.expectedError {
t.Fatalf("Expected hasErr %v, got %v", s.expectedError, hasErr)
}
})
}
}
func TestUploaderSingleUpload(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodPut,
URL: "http://test_bucket.example.com/test_key",
Match: func(req *http.Request) bool {
body, err := io.ReadAll(req.Body)
if err != nil {
return false
}
return string(body) == "abcdefg" && tests.ExpectHeaders(req.Header, map[string]string{
"Content-Length": "7",
"x-amz-meta-a": "123",
"x-amz-meta-b": "456",
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
},
)
uploader := &s3.Uploader{
S3: &s3.S3{
Client: httpClient,
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com",
AccessKey: "123",
SecretKey: "abc",
},
Key: "test_key",
Payload: strings.NewReader("abcdefg"),
Metadata: map[string]string{"a": "123", "b": "456"},
MinPartSize: 8,
}
err := uploader.Upload(context.Background(), func(r *http.Request) {
r.Header.Set("test_header", "test")
})
if err != nil {
t.Fatal(err)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
func TestUploaderMultipartUploadSuccess(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodPost,
URL: "http://test_bucket.example.com/test_key?uploads",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"x-amz-meta-a": "123",
"x-amz-meta-b": "456",
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Body: io.NopCloser(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult>
<Bucket>test_bucket</Bucket>
<Key>test_key</Key>
<UploadId>test_id</UploadId>
</InitiateMultipartUploadResult>
`)),
},
},
&tests.RequestStub{
Method: http.MethodPut,
URL: "http://test_bucket.example.com/test_key?partNumber=1&uploadId=test_id",
Match: func(req *http.Request) bool {
body, err := io.ReadAll(req.Body)
if err != nil {
return false
}
return string(body) == "abc" && tests.ExpectHeaders(req.Header, map[string]string{
"Content-Length": "3",
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Header: http.Header{"Etag": []string{"etag1"}},
},
},
&tests.RequestStub{
Method: http.MethodPut,
URL: "http://test_bucket.example.com/test_key?partNumber=2&uploadId=test_id",
Match: func(req *http.Request) bool {
body, err := io.ReadAll(req.Body)
if err != nil {
return false
}
return string(body) == "def" && tests.ExpectHeaders(req.Header, map[string]string{
"Content-Length": "3",
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Header: http.Header{"Etag": []string{"etag2"}},
},
},
&tests.RequestStub{
Method: http.MethodPut,
URL: "http://test_bucket.example.com/test_key?partNumber=3&uploadId=test_id",
Match: func(req *http.Request) bool {
body, err := io.ReadAll(req.Body)
if err != nil {
return false
}
return string(body) == "g" && tests.ExpectHeaders(req.Header, map[string]string{
"Content-Length": "1",
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Header: http.Header{"Etag": []string{"etag3"}},
},
},
&tests.RequestStub{
Method: http.MethodPost,
URL: "http://test_bucket.example.com/test_key?uploadId=test_id",
Match: func(req *http.Request) bool {
body, err := io.ReadAll(req.Body)
if err != nil {
return false
}
expected := `<CompleteMultipartUpload><Part><ETag>etag1</ETag><PartNumber>1</PartNumber></Part><Part><ETag>etag2</ETag><PartNumber>2</PartNumber></Part><Part><ETag>etag3</ETag><PartNumber>3</PartNumber></Part></CompleteMultipartUpload>`
return strings.Contains(string(body), expected) && tests.ExpectHeaders(req.Header, map[string]string{
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
},
)
uploader := &s3.Uploader{
S3: &s3.S3{
Client: httpClient,
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com",
AccessKey: "123",
SecretKey: "abc",
},
Key: "test_key",
Payload: strings.NewReader("abcdefg"),
Metadata: map[string]string{"a": "123", "b": "456"},
MinPartSize: 3,
}
err := uploader.Upload(context.Background(), func(r *http.Request) {
r.Header.Set("test_header", "test")
})
if err != nil {
t.Fatal(err)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
func TestUploaderMultipartUploadPartFailure(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodPost,
URL: "http://test_bucket.example.com/test_key?uploads",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"x-amz-meta-a": "123",
"x-amz-meta-b": "456",
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Body: io.NopCloser(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult>
<Bucket>test_bucket</Bucket>
<Key>test_key</Key>
<UploadId>test_id</UploadId>
</InitiateMultipartUploadResult>
`)),
},
},
&tests.RequestStub{
Method: http.MethodPut,
URL: "http://test_bucket.example.com/test_key?partNumber=1&uploadId=test_id",
Match: func(req *http.Request) bool {
body, err := io.ReadAll(req.Body)
if err != nil {
return false
}
return string(body) == "abc" && tests.ExpectHeaders(req.Header, map[string]string{
"Content-Length": "3",
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Header: http.Header{"Etag": []string{"etag1"}},
},
},
&tests.RequestStub{
Method: http.MethodPut,
URL: "http://test_bucket.example.com/test_key?partNumber=2&uploadId=test_id",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
StatusCode: 400,
},
},
&tests.RequestStub{
Method: http.MethodDelete,
URL: "http://test_bucket.example.com/test_key?uploadId=test_id",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
},
)
uploader := &s3.Uploader{
S3: &s3.S3{
Client: httpClient,
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com",
AccessKey: "123",
SecretKey: "abc",
},
Key: "test_key",
Payload: strings.NewReader("abcdefg"),
Metadata: map[string]string{"a": "123", "b": "456"},
MinPartSize: 3,
}
err := uploader.Upload(context.Background(), func(r *http.Request) {
r.Header.Set("test_header", "test")
})
if err == nil {
t.Fatal("Expected non-nil error")
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
func TestUploaderMultipartUploadCompleteFailure(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodPost,
URL: "http://test_bucket.example.com/test_key?uploads",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"x-amz-meta-a": "123",
"x-amz-meta-b": "456",
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Body: io.NopCloser(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult>
<Bucket>test_bucket</Bucket>
<Key>test_key</Key>
<UploadId>test_id</UploadId>
</InitiateMultipartUploadResult>
`)),
},
},
&tests.RequestStub{
Method: http.MethodPut,
URL: "http://test_bucket.example.com/test_key?partNumber=1&uploadId=test_id",
Match: func(req *http.Request) bool {
body, err := io.ReadAll(req.Body)
if err != nil {
return false
}
return string(body) == "abc" && tests.ExpectHeaders(req.Header, map[string]string{
"Content-Length": "3",
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Header: http.Header{"Etag": []string{"etag1"}},
},
},
&tests.RequestStub{
Method: http.MethodPut,
URL: "http://test_bucket.example.com/test_key?partNumber=2&uploadId=test_id",
Match: func(req *http.Request) bool {
body, err := io.ReadAll(req.Body)
if err != nil {
return false
}
return string(body) == "def" && tests.ExpectHeaders(req.Header, map[string]string{
"Content-Length": "3",
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
Header: http.Header{"Etag": []string{"etag2"}},
},
},
&tests.RequestStub{
Method: http.MethodPost,
URL: "http://test_bucket.example.com/test_key?uploadId=test_id",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
Response: &http.Response{
StatusCode: 400,
},
},
&tests.RequestStub{
Method: http.MethodDelete,
URL: "http://test_bucket.example.com/test_key?uploadId=test_id",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"test_header": "test",
"Authorization": "^.+Credential=123/.+$",
})
},
},
)
uploader := &s3.Uploader{
S3: &s3.S3{
Client: httpClient,
Region: "test_region",
Bucket: "test_bucket",
Endpoint: "http://example.com",
AccessKey: "123",
SecretKey: "abc",
},
Key: "test_key",
Payload: strings.NewReader("abcdef"),
Metadata: map[string]string{"a": "123", "b": "456"},
MinPartSize: 3,
}
err := uploader.Upload(context.Background(), func(r *http.Request) {
r.Header.Set("test_header", "test")
})
if err == nil {
t.Fatal("Expected non-nil error")
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
+481
View File
@@ -0,0 +1,481 @@
// Package s3blob provides a blob.Bucket S3 driver implementation.
//
// NB! To minimize breaking changes with older PocketBase releases,
// the driver is based of the previously used gocloud.dev/blob/s3blob,
// hence many of the below doc comments, struct options and interface
// implementations are the same.
//
// The blob abstraction supports all UTF-8 strings; to make this work with services lacking
// full UTF-8 support, strings must be escaped (during writes) and unescaped
// (during reads). The following escapes are performed for s3blob:
// - Blob keys: ASCII characters 0-31 are escaped to "__0x<hex>__".
// Additionally, the "/" in "../" is escaped in the same way.
// - Metadata keys: Escaped using URL encoding, then additionally "@:=" are
// escaped using "__0x<hex>__". These characters were determined by
// experimentation.
// - Metadata values: Escaped using URL encoding.
//
// Example:
//
// drv, _ := s3blob.New(&s3.S3{
// Bucket: "bucketName",
// Region: "region",
// Endpoint: "endpoint",
// AccessKey: "accessKey",
// SecretKey: "secretKey",
// })
// bucket := blob.NewBucket(drv)
package s3blob
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
)
const defaultPageSize = 1000
// New creates a new instance of the S3 driver backed by the the internal S3 client.
func New(s3Client *s3.S3) (blob.Driver, error) {
if s3Client.Bucket == "" {
return nil, errors.New("s3blob.New: missing bucket name")
}
if s3Client.Endpoint == "" {
return nil, errors.New("s3blob.New: missing endpoint")
}
if s3Client.Region == "" {
return nil, errors.New("s3blob.New: missing region")
}
return &driver{s3: s3Client}, nil
}
type driver struct {
s3 *s3.S3
}
// Close implements [blob/Driver.Close].
func (drv *driver) Close() error {
return nil // nothing to close
}
// NormalizeError implements [blob/Driver.NormalizeError].
func (drv *driver) NormalizeError(err error) error {
// already normalized
if errors.Is(err, blob.ErrNotFound) {
return err
}
// normalize base on its S3 error code
var ae s3.ResponseError
if errors.As(err, &ae) {
switch ae.Code {
case "NoSuchBucket", "NoSuchKey", "NotFound":
return errors.Join(err, blob.ErrNotFound)
}
}
return err
}
// ListPaged implements [blob/Driver.ListPaged].
func (drv *driver) ListPaged(ctx context.Context, opts *blob.ListOptions) (*blob.ListPage, error) {
pageSize := opts.PageSize
if pageSize == 0 {
pageSize = defaultPageSize
}
listParams := s3.ListParams{
MaxKeys: pageSize,
}
if len(opts.PageToken) > 0 {
listParams.ContinuationToken = string(opts.PageToken)
}
if opts.Prefix != "" {
listParams.Prefix = escapeKey(opts.Prefix)
}
if opts.Delimiter != "" {
listParams.Delimiter = escapeKey(opts.Delimiter)
}
resp, err := drv.s3.ListObjects(ctx, listParams)
if err != nil {
return nil, err
}
page := blob.ListPage{}
if resp.NextContinuationToken != "" {
page.NextPageToken = []byte(resp.NextContinuationToken)
}
if n := len(resp.Contents) + len(resp.CommonPrefixes); n > 0 {
page.Objects = make([]*blob.ListObject, n)
for i, obj := range resp.Contents {
page.Objects[i] = &blob.ListObject{
Key: unescapeKey(obj.Key),
ModTime: obj.LastModified,
Size: obj.Size,
MD5: eTagToMD5(obj.ETag),
}
}
for i, prefix := range resp.CommonPrefixes {
page.Objects[i+len(resp.Contents)] = &blob.ListObject{
Key: unescapeKey(prefix.Prefix),
IsDir: true,
}
}
if len(resp.Contents) > 0 && len(resp.CommonPrefixes) > 0 {
// S3 gives us blobs and "directories" in separate lists; sort them.
sort.Slice(page.Objects, func(i, j int) bool {
return page.Objects[i].Key < page.Objects[j].Key
})
}
}
return &page, nil
}
// Attributes implements [blob/Driver.Attributes].
func (drv *driver) Attributes(ctx context.Context, key string) (*blob.Attributes, error) {
key = escapeKey(key)
resp, err := drv.s3.HeadObject(ctx, key)
if err != nil {
return nil, err
}
md := make(map[string]string, len(resp.Metadata))
for k, v := range resp.Metadata {
// See the package comments for more details on escaping of metadata keys & values.
md[blob.HexUnescape(urlUnescape(k))] = urlUnescape(v)
}
return &blob.Attributes{
CacheControl: resp.CacheControl,
ContentDisposition: resp.ContentDisposition,
ContentEncoding: resp.ContentEncoding,
ContentLanguage: resp.ContentLanguage,
ContentType: resp.ContentType,
Metadata: md,
// CreateTime not supported; left as the zero time.
ModTime: resp.LastModified,
Size: resp.ContentLength,
MD5: eTagToMD5(resp.ETag),
ETag: resp.ETag,
}, nil
}
// NewRangeReader implements [blob/Driver.NewRangeReader].
func (drv *driver) NewRangeReader(ctx context.Context, key string, offset, length int64) (blob.DriverReader, error) {
key = escapeKey(key)
var byteRange string
if offset > 0 && length < 0 {
byteRange = fmt.Sprintf("bytes=%d-", offset)
} else if length == 0 {
// AWS doesn't support a zero-length read; we'll read 1 byte and then
// ignore it in favor of http.NoBody below.
byteRange = fmt.Sprintf("bytes=%d-%d", offset, offset)
} else if length >= 0 {
byteRange = fmt.Sprintf("bytes=%d-%d", offset, offset+length-1)
}
reqOpt := func(req *http.Request) {
req.Header.Set("Range", byteRange)
}
resp, err := drv.s3.GetObject(ctx, key, reqOpt)
if err != nil {
return nil, err
}
body := resp.Body
if length == 0 {
body = http.NoBody
}
return &reader{
body: body,
attrs: &blob.ReaderAttributes{
ContentType: resp.ContentType,
ModTime: resp.LastModified,
Size: getSize(resp.ContentLength, resp.ContentRange),
},
}, nil
}
// NewTypedWriter implements [blob/Driver.NewTypedWriter].
func (drv *driver) NewTypedWriter(ctx context.Context, key string, contentType string, opts *blob.WriterOptions) (blob.DriverWriter, error) {
key = escapeKey(key)
u := &s3.Uploader{
S3: drv.s3,
Key: key,
}
if opts.BufferSize != 0 {
u.MinPartSize = opts.BufferSize
}
if opts.MaxConcurrency != 0 {
u.MaxConcurrency = opts.MaxConcurrency
}
md := make(map[string]string, len(opts.Metadata))
for k, v := range opts.Metadata {
// See the package comments for more details on escaping of metadata keys & values.
k = blob.HexEscape(url.PathEscape(k), func(runes []rune, i int) bool {
c := runes[i]
return c == '@' || c == ':' || c == '='
})
md[k] = url.PathEscape(v)
}
u.Metadata = md
var reqOptions []func(*http.Request)
reqOptions = append(reqOptions, func(r *http.Request) {
r.Header.Set("Content-Type", contentType)
if opts.CacheControl != "" {
r.Header.Set("Cache-Control", opts.CacheControl)
}
if opts.ContentDisposition != "" {
r.Header.Set("Content-Disposition", opts.ContentDisposition)
}
if opts.ContentEncoding != "" {
r.Header.Set("Content-Encoding", opts.ContentEncoding)
}
if opts.ContentLanguage != "" {
r.Header.Set("Content-Language", opts.ContentLanguage)
}
if len(opts.ContentMD5) > 0 {
r.Header.Set("Content-MD5", base64.StdEncoding.EncodeToString(opts.ContentMD5))
}
})
return &writer{
ctx: ctx,
uploader: u,
donec: make(chan struct{}),
reqOptions: reqOptions,
}, nil
}
// Copy implements [blob/Driver.Copy].
func (drv *driver) Copy(ctx context.Context, dstKey, srcKey string) error {
dstKey = escapeKey(dstKey)
srcKey = escapeKey(srcKey)
_, err := drv.s3.CopyObject(ctx, srcKey, dstKey)
return err
}
// Delete implements [blob/Driver.Delete].
func (drv *driver) Delete(ctx context.Context, key string) error {
key = escapeKey(key)
return drv.s3.DeleteObject(ctx, key)
}
// -------------------------------------------------------------------
// reader reads an S3 object. It implements io.ReadCloser.
type reader struct {
attrs *blob.ReaderAttributes
body io.ReadCloser
}
// Read implements [io/ReadCloser.Read].
func (r *reader) Read(p []byte) (int, error) {
return r.body.Read(p)
}
// Close closes the reader itself. It must be called when done reading.
func (r *reader) Close() error {
return r.body.Close()
}
// Attributes implements [blob/DriverReader.Attributes].
func (r *reader) Attributes() *blob.ReaderAttributes {
return r.attrs
}
// -------------------------------------------------------------------
// writer writes an S3 object, it implements io.WriteCloser.
type writer struct {
ctx context.Context
err error // written before donec closes
uploader *s3.Uploader
// Ends of an io.Pipe, created when the first byte is written.
pw *io.PipeWriter
pr *io.PipeReader
donec chan struct{} // closed when done writing
reqOptions []func(*http.Request)
}
// Write appends p to w.pw. User must call Close to close the w after done writing.
func (w *writer) Write(p []byte) (int, error) {
// Avoid opening the pipe for a zero-length write;
// the concrete can do these for empty blobs.
if len(p) == 0 {
return 0, nil
}
if w.pw == nil {
// We'll write into pw and use pr as an io.Reader for the
// Upload call to S3.
w.pr, w.pw = io.Pipe()
w.open(w.pr, true)
}
return w.pw.Write(p)
}
// r may be nil if we're Closing and no data was written.
// If closePipeOnError is true, w.pr will be closed if there's an
// error uploading to S3.
func (w *writer) open(r io.Reader, closePipeOnError bool) {
// This goroutine will keep running until Close, unless there's an error.
go func() {
defer func() {
close(w.donec)
}()
if r == nil {
// AWS doesn't like a nil Body.
r = http.NoBody
}
w.uploader.Payload = r
err := w.uploader.Upload(w.ctx, w.reqOptions...)
if err != nil {
if closePipeOnError {
w.pr.CloseWithError(err)
}
w.err = err
}
}()
}
// Close completes the writer and closes it. Any error occurring during write
// will be returned. If a writer is closed before any Write is called, Close
// will create an empty file at the given key.
func (w *writer) Close() error {
if w.pr != nil {
defer w.pr.Close()
}
if w.pw == nil {
// We never got any bytes written. We'll write an http.NoBody.
w.open(nil, false)
} else if err := w.pw.Close(); err != nil {
return err
}
<-w.donec
return w.err
}
// -------------------------------------------------------------------
// etagToMD5 processes an ETag header and returns an MD5 hash if possible.
// S3's ETag header is sometimes a quoted hexstring of the MD5. Other times,
// notably when the object was uploaded in multiple parts, it is not.
// We do the best we can.
// Some links about ETag:
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTCommonResponseHeaders.html
// https://github.com/aws/aws-sdk-net/issues/815
// https://teppen.io/2018/06/23/aws_s3_etags/
func eTagToMD5(etag string) []byte {
// No header at all.
if etag == "" {
return nil
}
// Strip the expected leading and trailing quotes.
if len(etag) < 2 || etag[0] != '"' || etag[len(etag)-1] != '"' {
return nil
}
unquoted := etag[1 : len(etag)-1]
// Un-hex; we return nil on error. In particular, we'll get an error here
// for multi-part uploaded blobs, whose ETag will contain a "-" and so will
// never be a legal hex encoding.
md5, err := hex.DecodeString(unquoted)
if err != nil {
return nil
}
return md5
}
func getSize(contentLength int64, contentRange string) int64 {
// Default size to ContentLength, but that's incorrect for partial-length reads,
// where ContentLength refers to the size of the returned Body, not the entire
// size of the blob. ContentRange has the full size.
size := contentLength
if contentRange != "" {
// Sample: bytes 10-14/27 (where 27 is the full size).
parts := strings.Split(contentRange, "/")
if len(parts) == 2 {
if i, err := strconv.ParseInt(parts[1], 10, 64); err == nil {
size = i
}
}
}
return size
}
// escapeKey does all required escaping for UTF-8 strings to work with S3.
func escapeKey(key string) string {
return blob.HexEscape(key, func(r []rune, i int) bool {
c := r[i]
// S3 doesn't handle these characters (determined via experimentation).
if c < 32 {
return true
}
// For "../", escape the trailing slash.
if i > 1 && c == '/' && r[i-1] == '.' && r[i-2] == '.' {
return true
}
return false
})
}
// unescapeKey reverses escapeKey.
func unescapeKey(key string) string {
return blob.HexUnescape(key)
}
// urlUnescape reverses URLEscape using url.PathUnescape. If the unescape
// returns an error, it returns s.
func urlUnescape(s string) string {
if u, err := url.PathUnescape(s); err == nil {
return u
}
return s
}
@@ -0,0 +1,595 @@
package s3blob_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3"
"github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests"
)
func TestNew(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
s3Client *s3.S3
expectError bool
}{
{
"blank",
&s3.S3{},
true,
},
{
"no bucket",
&s3.S3{Region: "b", Endpoint: "c"},
true,
},
{
"no endpoint",
&s3.S3{Bucket: "a", Region: "b"},
true,
},
{
"no region",
&s3.S3{Bucket: "a", Endpoint: "c"},
true,
},
{
"with bucket, endpoint and region",
&s3.S3{Bucket: "a", Region: "b", Endpoint: "c"},
false,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
drv, err := s3blob.New(s.s3Client)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
if err == nil && drv == nil {
t.Fatal("Expected non-nil driver instance")
}
})
}
}
func TestDriverClose(t *testing.T) {
t.Parallel()
drv, err := s3blob.New(&s3.S3{Bucket: "a", Region: "b", Endpoint: "c"})
if err != nil {
t.Fatal(err)
}
err = drv.Close()
if err != nil {
t.Fatalf("Expected nil, got error %v", err)
}
}
func TestDriverNormilizeError(t *testing.T) {
t.Parallel()
drv, err := s3blob.New(&s3.S3{Bucket: "a", Region: "b", Endpoint: "c"})
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
err error
expectErrNotFound bool
}{
{
"plain error",
errors.New("test"),
false,
},
{
"response error with custom code",
s3.ResponseError{Code: "test"},
false,
},
{
"response error with NoSuchBucket code",
s3.ResponseError{Code: "NoSuchBucket"},
true,
},
{
"response error with NoSuchKey code",
s3.ResponseError{Code: "NoSuchKey"},
true,
},
{
"response error with NotFound code",
s3.ResponseError{Code: "NotFound"},
true,
},
{
"wrapped response error with NotFound code", // ensures that the entire error's tree is checked
fmt.Errorf("test: %w", s3.ResponseError{Code: "NotFound"}),
true,
},
{
"already normalized error",
fmt.Errorf("test: %w", blob.ErrNotFound),
true,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
err := drv.NormalizeError(s.err)
if err == nil {
t.Fatal("Expected non-nil error")
}
isErrNotFound := errors.Is(err, blob.ErrNotFound)
if isErrNotFound != s.expectErrNotFound {
t.Fatalf("Expected isErrNotFound %v, got %v (%v)", s.expectErrNotFound, isErrNotFound, err)
}
})
}
}
func TestDriverDeleteEscaping(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(&tests.RequestStub{
Method: http.MethodDelete,
URL: "https://test_bucket.example.com/..__0x2f__abc/test/",
})
drv, err := s3blob.New(&s3.S3{
Bucket: "test_bucket",
Region: "test_region",
Endpoint: "https://example.com",
Client: httpClient,
})
if err != nil {
t.Fatal(err)
}
err = drv.Delete(context.Background(), "../abc/test/")
if err != nil {
t.Fatal(err)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
func TestDriverCopyEscaping(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(&tests.RequestStub{
Method: http.MethodPut,
URL: "https://test_bucket.example.com/..__0x2f__a/",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"x-amz-copy-source": "test_bucket%2F..__0x2f__b%2F",
})
},
Response: &http.Response{
Body: io.NopCloser(strings.NewReader(`<CopyObjectResult></CopyObjectResult>`)),
},
})
drv, err := s3blob.New(&s3.S3{
Bucket: "test_bucket",
Region: "test_region",
Endpoint: "https://example.com",
Client: httpClient,
})
if err != nil {
t.Fatal(err)
}
err = drv.Copy(context.Background(), "../a/", "../b/")
if err != nil {
t.Fatal(err)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
func TestDriverAttributes(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(&tests.RequestStub{
Method: http.MethodHead,
URL: "https://test_bucket.example.com/..__0x2f__a/",
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Cache-Control": []string{"test_cache"},
"Content-Disposition": []string{"test_disposition"},
"Content-Encoding": []string{"test_encoding"},
"Content-Language": []string{"test_language"},
"Content-Type": []string{"test_type"},
"Content-Range": []string{"test_range"},
"Etag": []string{`"ce5be8b6f53645c596306c4572ece521"`},
"Content-Length": []string{"100"},
"x-amz-meta-AbC%40": []string{"%40test_meta_a"},
"x-amz-meta-Def": []string{"test_meta_b"},
},
Body: http.NoBody,
},
})
drv, err := s3blob.New(&s3.S3{
Bucket: "test_bucket",
Region: "test_region",
Endpoint: "https://example.com",
Client: httpClient,
})
if err != nil {
t.Fatal(err)
}
attrs, err := drv.Attributes(context.Background(), "../a/")
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(attrs)
if err != nil {
t.Fatal(err)
}
expected := `{"cacheControl":"test_cache","contentDisposition":"test_disposition","contentEncoding":"test_encoding","contentLanguage":"test_language","contentType":"test_type","metadata":{"abc@":"@test_meta_a","def":"test_meta_b"},"createTime":"0001-01-01T00:00:00Z","modTime":"2025-02-01T03:04:05Z","size":100,"md5":"zlvotvU2RcWWMGxFcuzlIQ==","etag":"\"ce5be8b6f53645c596306c4572ece521\""}`
if str := string(raw); str != expected {
t.Fatalf("Expected attributes\n%s\ngot\n%s", expected, str)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
func TestDriverListPaged(t *testing.T) {
t.Parallel()
listResponse := func() *http.Response {
return &http.Response{
Body: io.NopCloser(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>example</Name>
<ContinuationToken>ct</ContinuationToken>
<NextContinuationToken>test_next</NextContinuationToken>
<StartAfter>example0.txt</StartAfter>
<KeyCount>1</KeyCount>
<MaxKeys>3</MaxKeys>
<Contents>
<Key>..__0x2f__prefixB/test/example.txt</Key>
<LastModified>2025-01-01T01:02:03.123Z</LastModified>
<ETag>"ce5be8b6f53645c596306c4572ece521"</ETag>
<Size>123</Size>
</Contents>
<Contents>
<Key>prefixA/..__0x2f__escape.txt</Key>
<LastModified>2025-01-02T01:02:03.123Z</LastModified>
<Size>456</Size>
</Contents>
<CommonPrefixes>
<Prefix>prefixA</Prefix>
</CommonPrefixes>
<CommonPrefixes>
<Prefix>..__0x2f__prefixB</Prefix>
</CommonPrefixes>
</ListBucketResult>
`)),
}
}
expectedPage := `{"objects":[{"key":"../prefixB","modTime":"0001-01-01T00:00:00Z","size":0,"md5":null,"isDir":true},{"key":"../prefixB/test/example.txt","modTime":"2025-01-01T01:02:03.123Z","size":123,"md5":"zlvotvU2RcWWMGxFcuzlIQ==","isDir":false},{"key":"prefixA","modTime":"0001-01-01T00:00:00Z","size":0,"md5":null,"isDir":true},{"key":"prefixA/../escape.txt","modTime":"2025-01-02T01:02:03.123Z","size":456,"md5":null,"isDir":false}],"nextPageToken":"dGVzdF9uZXh0"}`
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/?list-type=2&max-keys=1000",
Response: listResponse(),
},
&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/?continuation-token=test_token&delimiter=test_delimiter&list-type=2&max-keys=123&prefix=test_prefix",
Response: listResponse(),
},
)
drv, err := s3blob.New(&s3.S3{
Bucket: "test_bucket",
Region: "test_region",
Endpoint: "https://example.com",
Client: httpClient,
})
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
opts *blob.ListOptions
expected string
}{
{
"empty options",
&blob.ListOptions{},
expectedPage,
},
{
"filled options",
&blob.ListOptions{Prefix: "test_prefix", Delimiter: "test_delimiter", PageSize: 123, PageToken: []byte("test_token")},
expectedPage,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
page, err := drv.ListPaged(context.Background(), s.opts)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(page)
if err != nil {
t.Fatal(err)
}
if str := string(raw); s.expected != str {
t.Fatalf("Expected page result\n%s\ngot\n%s", s.expected, str)
}
})
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
func TestDriverNewRangeReader(t *testing.T) {
t.Parallel()
scenarios := []struct {
offset int64
length int64
httpClient *tests.Client
expectedAttrs string
}{
{
0,
0,
tests.NewClient(&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"Range": "bytes=0-0",
})
},
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Content-Type": []string{"test_ct"},
"Content-Length": []string{"123"},
},
Body: io.NopCloser(strings.NewReader("test")),
},
}),
`{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":123}`,
},
{
10,
-1,
tests.NewClient(&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"Range": "bytes=10-",
})
},
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Content-Type": []string{"test_ct"},
"Content-Range": []string{"bytes 1-1/456"}, // should take precedence over content-length
"Content-Length": []string{"123"},
},
Body: io.NopCloser(strings.NewReader("test")),
},
}),
`{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":456}`,
},
{
10,
0,
tests.NewClient(&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"Range": "bytes=10-10",
})
},
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Content-Type": []string{"test_ct"},
// no range and length headers
// "Content-Range": []string{"bytes 1-1/456"},
// "Content-Length": []string{"123"},
},
Body: io.NopCloser(strings.NewReader("test")),
},
}),
`{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":0}`,
},
{
10,
20,
tests.NewClient(&tests.RequestStub{
Method: http.MethodGet,
URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt",
Match: func(req *http.Request) bool {
return tests.ExpectHeaders(req.Header, map[string]string{
"Range": "bytes=10-29",
})
},
Response: &http.Response{
Header: http.Header{
"Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"},
"Content-Type": []string{"test_ct"},
// with range header but invalid format -> content-length takes precedence
"Content-Range": []string{"bytes invalid-456"},
"Content-Length": []string{"123"},
},
Body: io.NopCloser(strings.NewReader("test")),
},
}),
`{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":123}`,
},
}
for _, s := range scenarios {
t.Run(fmt.Sprintf("offset_%d_length_%d", s.offset, s.length), func(t *testing.T) {
drv, err := s3blob.New(&s3.S3{
Bucket: "test_bucket",
Region: "tesst_region",
Endpoint: "https://example.com",
Client: s.httpClient,
})
if err != nil {
t.Fatal(err)
}
r, err := drv.NewRangeReader(context.Background(), "../abc/test.txt", s.offset, s.length)
if err != nil {
t.Fatal(err)
}
defer r.Close()
// the response body should be always replaced with http.NoBody
if s.length == 0 {
body := make([]byte, 1)
n, err := r.Read(body)
if n != 0 || !errors.Is(err, io.EOF) {
t.Fatalf("Expected body to be http.NoBody, got %v (%v)", body, err)
}
}
rawAttrs, err := json.Marshal(r.Attributes())
if err != nil {
t.Fatal(err)
}
if str := string(rawAttrs); str != s.expectedAttrs {
t.Fatalf("Expected attributes\n%s\ngot\n%s", s.expectedAttrs, str)
}
err = s.httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
})
}
}
func TestDriverNewTypedWriter(t *testing.T) {
t.Parallel()
httpClient := tests.NewClient(
&tests.RequestStub{
Method: http.MethodPut,
URL: "https://test_bucket.example.com/..__0x2f__abc/test/",
Match: func(req *http.Request) bool {
body, err := io.ReadAll(req.Body)
if err != nil {
return false
}
return string(body) == "test" && tests.ExpectHeaders(req.Header, map[string]string{
"cache-control": "test_cache_control",
"content-disposition": "test_content_disposition",
"content-encoding": "test_content_encoding",
"content-language": "test_content_language",
"content-type": "test_ct",
"content-md5": "dGVzdA==",
})
},
},
)
drv, err := s3blob.New(&s3.S3{
Bucket: "test_bucket",
Region: "test_region",
Endpoint: "https://example.com",
Client: httpClient,
})
if err != nil {
t.Fatal(err)
}
options := &blob.WriterOptions{
CacheControl: "test_cache_control",
ContentDisposition: "test_content_disposition",
ContentEncoding: "test_content_encoding",
ContentLanguage: "test_content_language",
ContentType: "test_content_type", // should be ignored
ContentMD5: []byte("test"),
Metadata: map[string]string{"@test_meta_a": "@test"},
}
w, err := drv.NewTypedWriter(context.Background(), "../abc/test/", "test_ct", options)
if err != nil {
t.Fatal(err)
}
n, err := w.Write(nil)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("Expected nil write to result in %d written bytes, got %d", 0, n)
}
n, err = w.Write([]byte("test"))
if err != nil {
t.Fatal(err)
}
if n != 4 {
t.Fatalf("Expected nil write to result in %d written bytes, got %d", 4, n)
}
err = w.Close()
if err != nil {
t.Fatal(err)
}
err = httpClient.AssertNoRemaining()
if err != nil {
t.Fatal(err)
}
}
-917
View File
@@ -1,917 +0,0 @@
// This is a trimmed version of the original go-cloud/s3blob driver
// to avoid loading both aws-sdk-go and aws-sdk-go-v2 dependencies.
// It helps reducing the final binary with ~11MB.
//
// In the future this may get replaced entirely with an even slimmer
// version without relying on aws-sdk-go-v2.
//
//--------------------------------------------------------------------
//
// Copyright 2018 The Go Cloud Development Kit Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package s3blob provides a blob implementation that uses S3. Use OpenBucket
// to construct a *blob.Bucket.
//
// # URLs
//
// For blob.OpenBucket, s3blob registers for the scheme "s3".
// The default URL opener will use an AWS session with the default credentials
// and configuration; see https://docs.aws.amazon.com/sdk-for-go/api/aws/session/
// for more details.
// Use "awssdk=v1" or "awssdk=v2" to force a specific AWS SDK version.
// To customize the URL opener, or for more details on the URL format,
// see URLOpener.
// See https://gocloud.dev/concepts/urls/ for background information.
//
// # Escaping
//
// Go CDK supports all UTF-8 strings; to make this work with services lacking
// full UTF-8 support, strings must be escaped (during writes) and unescaped
// (during reads). The following escapes are performed for s3blob:
// - Blob keys: ASCII characters 0-31 are escaped to "__0x<hex>__".
// Additionally, the "/" in "../" is escaped in the same way.
// - Metadata keys: Escaped using URL encoding, then additionally "@:=" are
// escaped using "__0x<hex>__". These characters were determined by
// experimentation.
// - Metadata values: Escaped using URL encoding.
//
// # As
//
// s3blob exposes the following types for As:
// - Bucket: (V1) *s3.S3; (V2) *s3v2.Client
// - Error: (V1) awserr.Error; (V2) any error type returned by the service, notably smithy.APIError
// - ListObject: (V1) s3.Object for objects, s3.CommonPrefix for "directories"; (V2) typesv2.Object for objects, typesv2.CommonPrefix for "directories"
// - ListOptions.BeforeList: (V1) *s3.ListObjectsV2Input or *s3.ListObjectsInput
// when Options.UseLegacyList == true; (V2) *s3v2.ListObjectsV2Input or *[]func(*s3v2.Options), or *s3v2.ListObjectsInput
// when Options.UseLegacyList == true
// - Reader: (V1) s3.GetObjectOutput; (V2) s3v2.GetObjectInput
// - ReaderOptions.BeforeRead: (V1) *s3.GetObjectInput; (V2) *s3v2.GetObjectInput or *[]func(*s3v2.Options)
// - Attributes: (V1) s3.HeadObjectOutput; (V2)s3v2.HeadObjectOutput
// - CopyOptions.BeforeCopy: *(V1) s3.CopyObjectInput; (V2) s3v2.CopyObjectInput
// - WriterOptions.BeforeWrite: (V1) *s3manager.UploadInput, *s3manager.Uploader; (V2) *s3v2.PutObjectInput, *s3v2manager.Uploader
// - SignedURLOptions.BeforeSign:
// (V1) *s3.GetObjectInput; (V2) *s3v2.GetObjectInput, when Options.Method == http.MethodGet, or
// (V1) *s3.PutObjectInput; (V2) *s3v2.PutObjectInput, when Options.Method == http.MethodPut, or
// (V1) *s3.DeleteObjectInput; (V2) [not supported] when Options.Method == http.MethodDelete
package s3lite
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
s3managerv2 "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
s3v2 "github.com/aws/aws-sdk-go-v2/service/s3"
typesv2 "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
"gocloud.dev/blob"
"gocloud.dev/blob/driver"
"gocloud.dev/gcerrors"
)
// -------------------------------------------------------------------
// Copyright 2019 The Go Cloud Development Kit Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// HexEscape returns s, with all runes for which shouldEscape returns true
// escaped to "__0xXXX__", where XXX is the hex representation of the rune
// value. For example, " " would escape to "__0x20__".
//
// Non-UTF-8 strings will have their non-UTF-8 characters escaped to
// unicode.ReplacementChar; the original value is lost. Please file an
// issue if you need non-UTF8 support.
//
// Note: shouldEscape takes the whole string as a slice of runes and an
// index. Passing it a single byte or a single rune doesn't provide
// enough context for some escape decisions; for example, the caller might
// want to escape the second "/" in "//" but not the first one.
// We pass a slice of runes instead of the string or a slice of bytes
// because some decisions will be made on a rune basis (e.g., encode
// all non-ASCII runes).
func HexEscape(s string, shouldEscape func(s []rune, i int) bool) string {
// Do a first pass to see which runes (if any) need escaping.
runes := []rune(s)
var toEscape []int
for i := range runes {
if shouldEscape(runes, i) {
toEscape = append(toEscape, i)
}
}
if len(toEscape) == 0 {
return s
}
// Each escaped rune turns into at most 14 runes ("__0x7fffffff__"),
// so allocate an extra 13 for each. We'll reslice at the end
// if we didn't end up using them.
escaped := make([]rune, len(runes)+13*len(toEscape))
n := 0 // current index into toEscape
j := 0 // current index into escaped
for i, r := range runes {
if n < len(toEscape) && i == toEscape[n] {
// We were asked to escape this rune.
for _, x := range fmt.Sprintf("__%#x__", r) {
escaped[j] = x
j++
}
n++
} else {
escaped[j] = r
j++
}
}
return string(escaped[0:j])
}
// unescape tries to unescape starting at r[i].
// It returns a boolean indicating whether the unescaping was successful,
// and (if true) the unescaped rune and the last index of r that was used
// during unescaping.
func unescape(r []rune, i int) (bool, rune, int) {
// Look for "__0x".
if r[i] != '_' {
return false, 0, 0
}
i++
if i >= len(r) || r[i] != '_' {
return false, 0, 0
}
i++
if i >= len(r) || r[i] != '0' {
return false, 0, 0
}
i++
if i >= len(r) || r[i] != 'x' {
return false, 0, 0
}
i++
// Capture the digits until the next "_" (if any).
var hexdigits []rune
for ; i < len(r) && r[i] != '_'; i++ {
hexdigits = append(hexdigits, r[i])
}
// Look for the trailing "__".
if i >= len(r) || r[i] != '_' {
return false, 0, 0
}
i++
if i >= len(r) || r[i] != '_' {
return false, 0, 0
}
// Parse the hex digits into an int32.
retval, err := strconv.ParseInt(string(hexdigits), 16, 32)
if err != nil {
return false, 0, 0
}
return true, rune(retval), i
}
// HexUnescape reverses HexEscape.
func HexUnescape(s string) string {
var unescaped []rune
runes := []rune(s)
for i := 0; i < len(runes); i++ {
if ok, newR, newI := unescape(runes, i); ok {
// We unescaped some runes starting at i, resulting in the
// unescaped rune newR. The last rune used was newI.
if unescaped == nil {
// This is the first rune we've encountered that
// needed unescaping. Allocate a buffer and copy any
// previous runes.
unescaped = make([]rune, i)
copy(unescaped, runes)
}
unescaped = append(unescaped, newR)
i = newI
} else if unescaped != nil {
unescaped = append(unescaped, runes[i])
}
}
if unescaped == nil {
return s
}
return string(unescaped)
}
// URLEscape uses url.PathEscape to escape s.
func URLEscape(s string) string {
return url.PathEscape(s)
}
// URLUnescape reverses URLEscape using url.PathUnescape. If the unescape
// returns an error, it returns s.
func URLUnescape(s string) string {
if u, err := url.PathUnescape(s); err == nil {
return u
}
return s
}
// -------------------------------------------------------------------
const defaultPageSize = 1000
// Options sets options for constructing a *blob.Bucket backed by fileblob.
type Options struct {
// UseLegacyList forces the use of ListObjects instead of ListObjectsV2.
// Some S3-compatible services (like CEPH) do not currently support
// ListObjectsV2.
UseLegacyList bool
}
// openBucket returns an S3 Bucket.
func openBucket(ctx context.Context, useV2 bool, clientV2 *s3v2.Client, bucketName string, opts *Options) (*bucket, error) {
if bucketName == "" {
return nil, errors.New("s3blob.OpenBucket: bucketName is required")
}
if opts == nil {
opts = &Options{}
}
if clientV2 == nil {
return nil, errors.New("s3blob.OpenBucketV2: client is required")
}
return &bucket{
useV2: useV2,
name: bucketName,
clientV2: clientV2,
useLegacyList: opts.UseLegacyList,
}, nil
}
// OpenBucketV2 returns a *blob.Bucket backed by S3, using AWS SDK v2.
func OpenBucketV2(ctx context.Context, client *s3v2.Client, bucketName string, opts *Options) (*blob.Bucket, error) {
drv, err := openBucket(ctx, true, client, bucketName, opts)
if err != nil {
return nil, err
}
return blob.NewBucket(drv), nil
}
// reader reads an S3 object. It implements io.ReadCloser.
type reader struct {
useV2 bool
body io.ReadCloser
attrs driver.ReaderAttributes
rawV2 *s3v2.GetObjectOutput
}
func (r *reader) Read(p []byte) (int, error) {
return r.body.Read(p)
}
// Close closes the reader itself. It must be called when done reading.
func (r *reader) Close() error {
return r.body.Close()
}
func (r *reader) As(i interface{}) bool {
p, ok := i.(*s3v2.GetObjectOutput)
if !ok {
return false
}
*p = *r.rawV2
return true
}
func (r *reader) Attributes() *driver.ReaderAttributes {
return &r.attrs
}
// writer writes an S3 object, it implements io.WriteCloser.
type writer struct {
// Ends of an io.Pipe, created when the first byte is written.
pw *io.PipeWriter
pr *io.PipeReader
// Alternatively, upload is set to true when Upload was
// used to upload data.
upload bool
ctx context.Context
useV2 bool
// v2
uploaderV2 *s3managerv2.Uploader
reqV2 *s3v2.PutObjectInput
donec chan struct{} // closed when done writing
// The following fields will be written before donec closes:
err error
}
// Write appends p to w.pw. User must call Close to close the w after done writing.
func (w *writer) Write(p []byte) (int, error) {
// Avoid opening the pipe for a zero-length write;
// the concrete can do these for empty blobs.
if len(p) == 0 {
return 0, nil
}
if w.pw == nil {
// We'll write into pw and use pr as an io.Reader for the
// Upload call to S3.
w.pr, w.pw = io.Pipe()
w.open(w.pr, true)
}
return w.pw.Write(p)
}
// Upload reads from r. Per the driver, it is guaranteed to be the only
// write call for this writer.
func (w *writer) Upload(r io.Reader) error {
w.upload = true
w.open(r, false)
return nil
}
// r may be nil if we're Closing and no data was written.
// If closePipeOnError is true, w.pr will be closed if there's an
// error uploading to S3.
func (w *writer) open(r io.Reader, closePipeOnError bool) {
// This goroutine will keep running until Close, unless there's an error.
go func() {
defer close(w.donec)
if r == nil {
// AWS doesn't like a nil Body.
r = http.NoBody
}
var err error
w.reqV2.Body = r
_, err = w.uploaderV2.Upload(w.ctx, w.reqV2)
if err != nil {
if closePipeOnError {
w.pr.CloseWithError(err)
}
w.err = err
}
}()
}
// Close completes the writer and closes it. Any error occurring during write
// will be returned. If a writer is closed before any Write is called, Close
// will create an empty file at the given key.
func (w *writer) Close() error {
if !w.upload {
if w.pr != nil {
defer w.pr.Close()
}
if w.pw == nil {
// We never got any bytes written. We'll write an http.NoBody.
w.open(nil, false)
} else if err := w.pw.Close(); err != nil {
return err
}
}
<-w.donec
return w.err
}
// bucket represents an S3 bucket and handles read, write and delete operations.
type bucket struct {
name string
useV2 bool
clientV2 *s3v2.Client
useLegacyList bool
}
func (b *bucket) Close() error {
return nil
}
func (b *bucket) ErrorCode(err error) gcerrors.ErrorCode {
var code string
var ae smithy.APIError
var oe *smithy.OperationError
if errors.As(err, &oe) && strings.Contains(oe.Error(), "301") {
// V2 returns an OperationError with a missing redirect for invalid buckets.
code = "NoSuchBucket"
} else if errors.As(err, &ae) {
code = ae.ErrorCode()
} else {
return gcerrors.Unknown
}
switch {
case code == "NoSuchBucket" || code == "NoSuchKey" || code == "NotFound":
return gcerrors.NotFound
default:
return gcerrors.Unknown
}
}
// ListPaged implements driver.ListPaged.
func (b *bucket) ListPaged(ctx context.Context, opts *driver.ListOptions) (*driver.ListPage, error) {
pageSize := opts.PageSize
if pageSize == 0 {
pageSize = defaultPageSize
}
in := &s3v2.ListObjectsV2Input{
Bucket: awsv2.String(b.name),
MaxKeys: awsv2.Int32(int32(pageSize)),
}
if len(opts.PageToken) > 0 {
in.ContinuationToken = awsv2.String(string(opts.PageToken))
}
if opts.Prefix != "" {
in.Prefix = awsv2.String(escapeKey(opts.Prefix))
}
if opts.Delimiter != "" {
in.Delimiter = awsv2.String(escapeKey(opts.Delimiter))
}
resp, err := b.listObjectsV2(ctx, in, opts)
if err != nil {
return nil, err
}
page := driver.ListPage{}
if resp.NextContinuationToken != nil {
page.NextPageToken = []byte(*resp.NextContinuationToken)
}
if n := len(resp.Contents) + len(resp.CommonPrefixes); n > 0 {
page.Objects = make([]*driver.ListObject, n)
for i, obj := range resp.Contents {
obj := obj
page.Objects[i] = &driver.ListObject{
Key: unescapeKey(awsv2.ToString(obj.Key)),
ModTime: *obj.LastModified,
Size: awsv2.ToInt64(obj.Size),
MD5: eTagToMD5(obj.ETag),
AsFunc: func(i interface{}) bool {
p, ok := i.(*typesv2.Object)
if !ok {
return false
}
*p = obj
return true
},
}
}
for i, prefix := range resp.CommonPrefixes {
prefix := prefix
page.Objects[i+len(resp.Contents)] = &driver.ListObject{
Key: unescapeKey(awsv2.ToString(prefix.Prefix)),
IsDir: true,
AsFunc: func(i interface{}) bool {
p, ok := i.(*typesv2.CommonPrefix)
if !ok {
return false
}
*p = prefix
return true
},
}
}
if len(resp.Contents) > 0 && len(resp.CommonPrefixes) > 0 {
// S3 gives us blobs and "directories" in separate lists; sort them.
sort.Slice(page.Objects, func(i, j int) bool {
return page.Objects[i].Key < page.Objects[j].Key
})
}
}
return &page, nil
}
func (b *bucket) listObjectsV2(ctx context.Context, in *s3v2.ListObjectsV2Input, opts *driver.ListOptions) (*s3v2.ListObjectsV2Output, error) {
if !b.useLegacyList {
var varopt []func(*s3v2.Options)
if opts.BeforeList != nil {
asFunc := func(i interface{}) bool {
if p, ok := i.(**s3v2.ListObjectsV2Input); ok {
*p = in
return true
}
if p, ok := i.(**[]func(*s3v2.Options)); ok {
*p = &varopt
return true
}
return false
}
if err := opts.BeforeList(asFunc); err != nil {
return nil, err
}
}
return b.clientV2.ListObjectsV2(ctx, in, varopt...)
}
// Use the legacy ListObjects request.
legacyIn := &s3v2.ListObjectsInput{
Bucket: in.Bucket,
Delimiter: in.Delimiter,
EncodingType: in.EncodingType,
Marker: in.ContinuationToken,
MaxKeys: in.MaxKeys,
Prefix: in.Prefix,
RequestPayer: in.RequestPayer,
}
if opts.BeforeList != nil {
asFunc := func(i interface{}) bool {
p, ok := i.(**s3v2.ListObjectsInput)
if !ok {
return false
}
*p = legacyIn
return true
}
if err := opts.BeforeList(asFunc); err != nil {
return nil, err
}
}
legacyResp, err := b.clientV2.ListObjects(ctx, legacyIn)
if err != nil {
return nil, err
}
var nextContinuationToken *string
if legacyResp.NextMarker != nil {
nextContinuationToken = legacyResp.NextMarker
} else if awsv2.ToBool(legacyResp.IsTruncated) {
nextContinuationToken = awsv2.String(awsv2.ToString(legacyResp.Contents[len(legacyResp.Contents)-1].Key))
}
return &s3v2.ListObjectsV2Output{
CommonPrefixes: legacyResp.CommonPrefixes,
Contents: legacyResp.Contents,
NextContinuationToken: nextContinuationToken,
}, nil
}
// As implements driver.As.
func (b *bucket) As(i interface{}) bool {
p, ok := i.(**s3v2.Client)
if !ok {
return false
}
*p = b.clientV2
return true
}
// As implements driver.ErrorAs.
func (b *bucket) ErrorAs(err error, i interface{}) bool {
return errors.As(err, i)
}
// Attributes implements driver.Attributes.
func (b *bucket) Attributes(ctx context.Context, key string) (*driver.Attributes, error) {
key = escapeKey(key)
in := &s3v2.HeadObjectInput{
Bucket: awsv2.String(b.name),
Key: awsv2.String(key),
}
resp, err := b.clientV2.HeadObject(ctx, in)
if err != nil {
return nil, err
}
md := make(map[string]string, len(resp.Metadata))
for k, v := range resp.Metadata {
// See the package comments for more details on escaping of metadata
// keys & values.
md[HexUnescape(URLUnescape(k))] = URLUnescape(v)
}
return &driver.Attributes{
CacheControl: awsv2.ToString(resp.CacheControl),
ContentDisposition: awsv2.ToString(resp.ContentDisposition),
ContentEncoding: awsv2.ToString(resp.ContentEncoding),
ContentLanguage: awsv2.ToString(resp.ContentLanguage),
ContentType: awsv2.ToString(resp.ContentType),
Metadata: md,
// CreateTime not supported; left as the zero time.
ModTime: awsv2.ToTime(resp.LastModified),
Size: awsv2.ToInt64(resp.ContentLength),
MD5: eTagToMD5(resp.ETag),
ETag: awsv2.ToString(resp.ETag),
AsFunc: func(i interface{}) bool {
p, ok := i.(*s3v2.HeadObjectOutput)
if !ok {
return false
}
*p = *resp
return true
},
}, nil
}
// NewRangeReader implements driver.NewRangeReader.
func (b *bucket) NewRangeReader(ctx context.Context, key string, offset, length int64, opts *driver.ReaderOptions) (driver.Reader, error) {
key = escapeKey(key)
var byteRange *string
if offset > 0 && length < 0 {
byteRange = awsv2.String(fmt.Sprintf("bytes=%d-", offset))
} else if length == 0 {
// AWS doesn't support a zero-length read; we'll read 1 byte and then
// ignore it in favor of http.NoBody below.
byteRange = awsv2.String(fmt.Sprintf("bytes=%d-%d", offset, offset))
} else if length >= 0 {
byteRange = awsv2.String(fmt.Sprintf("bytes=%d-%d", offset, offset+length-1))
}
in := &s3v2.GetObjectInput{
Bucket: awsv2.String(b.name),
Key: awsv2.String(key),
Range: byteRange,
}
var varopt []func(*s3v2.Options)
if opts.BeforeRead != nil {
asFunc := func(i interface{}) bool {
if p, ok := i.(**s3v2.GetObjectInput); ok {
*p = in
return true
}
if p, ok := i.(**[]func(*s3v2.Options)); ok {
*p = &varopt
return true
}
return false
}
if err := opts.BeforeRead(asFunc); err != nil {
return nil, err
}
}
resp, err := b.clientV2.GetObject(ctx, in, varopt...)
if err != nil {
return nil, err
}
body := resp.Body
if length == 0 {
body = http.NoBody
}
return &reader{
useV2: true,
body: body,
attrs: driver.ReaderAttributes{
ContentType: awsv2.ToString(resp.ContentType),
ModTime: awsv2.ToTime(resp.LastModified),
Size: getSize(awsv2.ToInt64(resp.ContentLength), awsv2.ToString(resp.ContentRange)),
},
rawV2: resp,
}, nil
}
// etagToMD5 processes an ETag header and returns an MD5 hash if possible.
// S3's ETag header is sometimes a quoted hexstring of the MD5. Other times,
// notably when the object was uploaded in multiple parts, it is not.
// We do the best we can.
// Some links about ETag:
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTCommonResponseHeaders.html
// https://github.com/aws/aws-sdk-net/issues/815
// https://teppen.io/2018/06/23/aws_s3_etags/
func eTagToMD5(etag *string) []byte {
if etag == nil {
// No header at all.
return nil
}
// Strip the expected leading and trailing quotes.
quoted := *etag
if len(quoted) < 2 || quoted[0] != '"' || quoted[len(quoted)-1] != '"' {
return nil
}
unquoted := quoted[1 : len(quoted)-1]
// Un-hex; we return nil on error. In particular, we'll get an error here
// for multi-part uploaded blobs, whose ETag will contain a "-" and so will
// never be a legal hex encoding.
md5, err := hex.DecodeString(unquoted)
if err != nil {
return nil
}
return md5
}
func getSize(contentLength int64, contentRange string) int64 {
// Default size to ContentLength, but that's incorrect for partial-length reads,
// where ContentLength refers to the size of the returned Body, not the entire
// size of the blob. ContentRange has the full size.
size := contentLength
if contentRange != "" {
// Sample: bytes 10-14/27 (where 27 is the full size).
parts := strings.Split(contentRange, "/")
if len(parts) == 2 {
if i, err := strconv.ParseInt(parts[1], 10, 64); err == nil {
size = i
}
}
}
return size
}
// escapeKey does all required escaping for UTF-8 strings to work with S3.
func escapeKey(key string) string {
return HexEscape(key, func(r []rune, i int) bool {
c := r[i]
switch {
// S3 doesn't handle these characters (determined via experimentation).
case c < 32:
return true
// For "../", escape the trailing slash.
case i > 1 && c == '/' && r[i-1] == '.' && r[i-2] == '.':
return true
}
return false
})
}
// unescapeKey reverses escapeKey.
func unescapeKey(key string) string {
return HexUnescape(key)
}
// NewTypedWriter implements driver.NewTypedWriter.
func (b *bucket) NewTypedWriter(ctx context.Context, key string, contentType string, opts *driver.WriterOptions) (driver.Writer, error) {
key = escapeKey(key)
uploaderV2 := s3managerv2.NewUploader(b.clientV2, func(u *s3managerv2.Uploader) {
if opts.BufferSize != 0 {
u.PartSize = int64(opts.BufferSize)
}
if opts.MaxConcurrency != 0 {
u.Concurrency = opts.MaxConcurrency
}
})
md := make(map[string]string, len(opts.Metadata))
for k, v := range opts.Metadata {
// See the package comments for more details on escaping of metadata
// keys & values.
k = HexEscape(url.PathEscape(k), func(runes []rune, i int) bool {
c := runes[i]
return c == '@' || c == ':' || c == '='
})
md[k] = url.PathEscape(v)
}
reqV2 := &s3v2.PutObjectInput{
Bucket: awsv2.String(b.name),
ContentType: awsv2.String(contentType),
Key: awsv2.String(key),
Metadata: md,
}
if opts.CacheControl != "" {
reqV2.CacheControl = awsv2.String(opts.CacheControl)
}
if opts.ContentDisposition != "" {
reqV2.ContentDisposition = awsv2.String(opts.ContentDisposition)
}
if opts.ContentEncoding != "" {
reqV2.ContentEncoding = awsv2.String(opts.ContentEncoding)
}
if opts.ContentLanguage != "" {
reqV2.ContentLanguage = awsv2.String(opts.ContentLanguage)
}
if len(opts.ContentMD5) > 0 {
reqV2.ContentMD5 = awsv2.String(base64.StdEncoding.EncodeToString(opts.ContentMD5))
}
if opts.BeforeWrite != nil {
asFunc := func(i interface{}) bool {
// Note that since the Go CDK Blob
// abstraction does not expose AWS's
// Uploader concept, there does not
// appear to be any utility in
// exposing the options list to the v2
// Uploader's Upload() method.
// Instead, applications can
// manipulate the exposed *Uploader
// directly, including by setting
// ClientOptions if needed.
if p, ok := i.(**s3managerv2.Uploader); ok {
*p = uploaderV2
return true
}
if p, ok := i.(**s3v2.PutObjectInput); ok {
*p = reqV2
return true
}
return false
}
if err := opts.BeforeWrite(asFunc); err != nil {
return nil, err
}
}
return &writer{
ctx: ctx,
useV2: true,
uploaderV2: uploaderV2,
reqV2: reqV2,
donec: make(chan struct{}),
}, nil
}
// Copy implements driver.Copy.
func (b *bucket) Copy(ctx context.Context, dstKey, srcKey string, opts *driver.CopyOptions) error {
dstKey = escapeKey(dstKey)
srcKey = escapeKey(srcKey)
input := &s3v2.CopyObjectInput{
Bucket: awsv2.String(b.name),
CopySource: awsv2.String(b.name + "/" + srcKey),
Key: awsv2.String(dstKey),
}
if opts.BeforeCopy != nil {
asFunc := func(i interface{}) bool {
switch v := i.(type) {
case **s3v2.CopyObjectInput:
*v = input
return true
}
return false
}
if err := opts.BeforeCopy(asFunc); err != nil {
return err
}
}
_, err := b.clientV2.CopyObject(ctx, input)
return err
}
// Delete implements driver.Delete.
func (b *bucket) Delete(ctx context.Context, key string) error {
if _, err := b.Attributes(ctx, key); err != nil {
return err
}
key = escapeKey(key)
input := &s3v2.DeleteObjectInput{
Bucket: awsv2.String(b.name),
Key: awsv2.String(key),
}
_, err := b.clientV2.DeleteObject(ctx, input)
return err
}
func (b *bucket) SignedURL(ctx context.Context, key string, opts *driver.SignedURLOptions) (string, error) {
key = escapeKey(key)
switch opts.Method {
case http.MethodGet:
in := &s3v2.GetObjectInput{
Bucket: awsv2.String(b.name),
Key: awsv2.String(key),
}
if opts.BeforeSign != nil {
asFunc := func(i interface{}) bool {
v, ok := i.(**s3v2.GetObjectInput)
if ok {
*v = in
}
return ok
}
if err := opts.BeforeSign(asFunc); err != nil {
return "", err
}
}
p, err := s3v2.NewPresignClient(b.clientV2, s3v2.WithPresignExpires(opts.Expiry)).PresignGetObject(ctx, in)
if err != nil {
return "", err
}
return p.URL, nil
case http.MethodPut:
in := &s3v2.PutObjectInput{
Bucket: awsv2.String(b.name),
Key: awsv2.String(key),
}
if opts.EnforceAbsentContentType || opts.ContentType != "" {
// https://github.com/aws/aws-sdk-go-v2/issues/1475
return "", errors.New("s3blob: AWS SDK v2 does not supported enforcing ContentType in SignedURLs for PUT")
}
if opts.BeforeSign != nil {
asFunc := func(i interface{}) bool {
v, ok := i.(**s3v2.PutObjectInput)
if ok {
*v = in
}
return ok
}
if err := opts.BeforeSign(asFunc); err != nil {
return "", err
}
}
p, err := s3v2.NewPresignClient(b.clientV2, s3v2.WithPresignExpires(opts.Expiry)).PresignPutObject(ctx, in)
if err != nil {
return "", err
}
return p.URL, nil
}
return "", fmt.Errorf("unsupported Method %q", opts.Method)
}
+67 -19
View File
@@ -2,6 +2,7 @@ package logger
import (
"context"
"encoding/json"
"errors"
"log/slog"
"sync"
@@ -252,26 +253,73 @@ func (h *BatchHandler) resolveAttr(data map[string]any, attr slog.Attr) error {
data[attr.Key] = groupData
}
default:
switch v := attr.Value.Any().(type) {
case validation.Errors:
data[attr.Key] = map[string]any{
"data": v,
"raw": v.Error(),
}
case error:
var ve validation.Errors
if errors.As(v, &ve) {
data[attr.Key] = map[string]any{
"data": ve,
"raw": v.Error(),
}
} else {
data[attr.Key] = v.Error()
}
default:
data[attr.Key] = v
}
data[attr.Key] = normalizeLogAttrValue(attr.Value.Any())
}
return nil
}
func normalizeLogAttrValue(rawAttrValue any) any {
switch attrV := rawAttrValue.(type) {
case validation.Errors:
out := make(map[string]any, len(attrV))
for k, v := range attrV {
out[k] = serializeLogError(v)
}
return out
case map[string]validation.Error:
out := make(map[string]any, len(attrV))
for k, v := range attrV {
out[k] = serializeLogError(v)
}
return out
case map[string]error:
out := make(map[string]any, len(attrV))
for k, v := range attrV {
out[k] = serializeLogError(v)
}
return out
case map[string]any:
out := make(map[string]any, len(attrV))
for k, v := range attrV {
switch vv := v.(type) {
case error:
out[k] = serializeLogError(vv)
default:
out[k] = normalizeLogAttrValue(vv)
}
}
return out
case error:
// check for wrapped validation.Errors
var ve validation.Errors
if errors.As(attrV, &ve) {
out := make(map[string]any, len(ve))
for k, v := range ve {
out[k] = serializeLogError(v)
}
return map[string]any{
"data": out,
"raw": serializeLogError(attrV),
}
}
return serializeLogError(attrV)
default:
return attrV
}
}
func serializeLogError(err error) any {
if err == nil {
return nil
}
// prioritize a json structured format (e.g. validation.Errors)
jsonErr, ok := err.(json.Marshaler)
if ok {
return jsonErr
}
// fallback to its original string representation
return err.Error()
}
+34 -1
View File
@@ -7,6 +7,8 @@ import (
"log/slog"
"testing"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
)
func TestNewBatchHandlerPanic(t *testing.T) {
@@ -280,14 +282,45 @@ func TestBatchHandlerAttrsFormat(t *testing.T) {
h1.Handle(ctx, record)
h2.Handle(ctx, record)
// errors serialization checks
errorsRecord := slog.NewRecord(time.Now(), slog.LevelError, "details", 0)
errorsRecord.Add("validation.Errors", validation.Errors{
"a": validation.NewError("validation_code", "validation_message"),
"b": errors.New("plain"),
})
errorsRecord.Add("wrapped_validation.Errors", fmt.Errorf("wrapped: %w", validation.Errors{
"a": validation.NewError("validation_code", "validation_message"),
"b": errors.New("plain"),
}))
errorsRecord.Add("map[string]any", map[string]any{
"a": validation.NewError("validation_code", "validation_message"),
"b": errors.New("plain"),
"c": "test_any",
"d": map[string]any{
"nestedA": validation.NewError("nested_code", "nested_message"),
"nestedB": errors.New("nested_plain"),
},
})
errorsRecord.Add("map[string]error", map[string]error{
"a": validation.NewError("validation_code", "validation_message"),
"b": errors.New("plain"),
})
errorsRecord.Add("map[string]validation.Error", map[string]validation.Error{
"a": validation.NewError("validation_code", "validation_message"),
"b": nil,
})
errorsRecord.Add("plain_error", errors.New("plain"))
h0.Handle(ctx, errorsRecord)
expected := []string{
`{"name":"test"}`,
`{"a":1,"b":"123","name":"test"}`,
`{"a":1,"b":"123","sub":{"c":3,"d":{"d.1":1},"e":"example error","name":"test"}}`,
`{"map[string]any":{"a":"validation_message","b":"plain","c":"test_any","d":{"nestedA":"nested_message","nestedB":"nested_plain"}},"map[string]error":{"a":"validation_message","b":"plain"},"map[string]validation.Error":{"a":"validation_message","b":null},"plain_error":"plain","validation.Errors":{"a":"validation_message","b":"plain"},"wrapped_validation.Errors":{"data":{"a":"validation_message","b":"plain"},"raw":"wrapped: a: validation_message; b: plain."}}`,
}
if len(beforeLogs) != len(expected) {
t.Fatalf("Expected %d logs, got %d", len(beforeLogs), len(expected))
t.Fatalf("Expected %d logs, got %d", len(expected), len(beforeLogs))
}
for i, data := range expected {
+2
View File
@@ -182,6 +182,8 @@ const jsonFieldsParam = "fields"
// JSON writes a JSON response.
//
// It also provides a generic response data fields picker if the "fields" query parameter is set.
// For example, if you are requesting `?fields=a,b` for `e.JSON(200, map[string]int{ "a":1, "b":2, "c":3 })`,
// it should result in a JSON response like: `{"a":1, "b": 2}`.
func (e *Event) JSON(status int, data any) error {
e.setResponseHeaderIfEmpty(headerContentType, "application/json")
e.Response.WriteHeader(status)
+24 -1
View File
@@ -136,7 +136,7 @@ func (s *Store[K, T]) Values() []T {
return values
}
// Set sets (or overwrite if already exist) a new value for key.
// Set sets (or overwrite if already exists) a new value for key.
func (s *Store[K, T]) Set(key K, value T) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -148,6 +148,29 @@ func (s *Store[K, T]) Set(key K, value T) {
s.data[key] = value
}
// SetFunc sets (or overwrite if already exists) a new value resolved
// from the function callback for the provided key.
//
// The function callback receives as argument the old store element value (if exists).
// If there is no old store element, the argument will be the T zero value.
//
// Example:
//
// s := store.New[string, int](nil)
// s.SetFunc("count", func(old int) int {
// return old + 1
// })
func (s *Store[K, T]) SetFunc(key K, fn func(old T) T) {
s.mu.Lock()
defer s.mu.Unlock()
if s.data == nil {
s.data = make(map[K]T)
}
s.data[key] = fn(s.data[key])
}
// GetOrSet retrieves a single existing value for the provided key
// or stores a new one if it doesn't exist.
func (s *Store[K, T]) GetOrSet(key K, setFunc func() T) T {
+26
View File
@@ -249,6 +249,32 @@ func TestSet(t *testing.T) {
}
}
func TestSetFunc(t *testing.T) {
s := store.Store[string, int]{}
// non existing value
s.SetFunc("test", func(old int) int {
if old != 0 {
t.Fatalf("Expected old value %d, got %d", 0, old)
}
return old + 2
})
if v := s.Get("test"); v != 2 {
t.Fatalf("Expected the stored value to be %d, got %d", 2, v)
}
// increment existing value
s.SetFunc("test", func(old int) int {
if old != 2 {
t.Fatalf("Expected old value %d, got %d", 2, old)
}
return old + 1
})
if v := s.Get("test"); v != 3 {
t.Fatalf("Expected the stored value to be %d, got %d", 3, v)
}
}
func TestGetOrSet(t *testing.T) {
s := store.New(map[string]int{
"test1": 0,
-6
View File
@@ -13,12 +13,6 @@ import (
const optionsParam = "options"
// Message defines a client's channel data.
type Message struct {
Name string `json:"name"`
Data []byte `json:"data"`
}
// SubscriptionOptions defines the request options (query params, headers, etc.)
// for a single subscription topic.
type SubscriptionOptions struct {
+37
View File
@@ -0,0 +1,37 @@
package subscriptions
import (
"io"
)
// Message defines a client's channel data.
type Message struct {
Name string `json:"name"`
Data []byte `json:"data"`
}
// WriteSSE writes the current message in a SSE format into the provided writer.
//
// For example, writing to a router.Event:
//
// m := Message{Name: "users/create", Data: []byte{...}}
// m.Write(e.Response, "yourEventId")
// e.Flush()
func (m *Message) WriteSSE(w io.Writer, eventId string) error {
parts := [][]byte{
[]byte("id:" + eventId + "\n"),
[]byte("event:" + m.Name + "\n"),
[]byte("data:"),
m.Data,
[]byte("\n\n"),
}
for _, part := range parts {
_, err := w.Write(part)
if err != nil {
return err
}
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package subscriptions_test
import (
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
func TestMessageWrite(t *testing.T) {
m := subscriptions.Message{
Name: "test_name",
Data: []byte("test_data"),
}
var sb strings.Builder
m.WriteSSE(&sb, "test_id")
expected := "id:test_id\nevent:test_name\ndata:test_data\n\n"
if v := sb.String(); v != expected {
t.Fatalf("Expected writer content\n%q\ngot\n%q", expected, v)
}
}
+1 -1
View File
@@ -9,4 +9,4 @@ PB_DOCS_URL = "https://pocketbase.io/docs"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.25.2"
PB_VERSION = "v0.26.4"
@@ -1,4 +1,4 @@
import{S as Ce,i as Be,s as Te,V as Le,X as J,h as u,d as ae,t as Q,a as G,I as N,Z as we,_ as Se,C as De,$ as Re,D as Ue,l as d,n as a,m as ne,u as c,A as y,v as k,c as ie,w as h,p as oe,J as je,k as O,o as qe,W as Ee}from"./index-sszpfGrR.js";import{F as Fe}from"./FieldsQueryParam-Cs6hQAnX.js";function ye(n,s,l){const o=n.slice();return o[8]=s[l],o}function Me(n,s,l){const o=n.slice();return o[8]=s[l],o}function Ae(n,s){let l,o=s[8].code+"",p,b,i,f;function m(){return s[6](s[8])}return{key:n,first:null,c(){l=c("button"),p=y(o),b=k(),h(l,"class","tab-item"),O(l,"active",s[1]===s[8].code),this.first=l},m(v,$){d(v,l,$),a(l,p),a(l,b),i||(f=qe(l,"click",m),i=!0)},p(v,$){s=v,$&4&&o!==(o=s[8].code+"")&&N(p,o),$&6&&O(l,"active",s[1]===s[8].code)},d(v){v&&u(l),i=!1,f()}}}function Pe(n,s){let l,o,p,b;return o=new Ee({props:{content:s[8].body}}),{key:n,first:null,c(){l=c("div"),ie(o.$$.fragment),p=k(),h(l,"class","tab-item"),O(l,"active",s[1]===s[8].code),this.first=l},m(i,f){d(i,l,f),ne(o,l,null),a(l,p),b=!0},p(i,f){s=i;const m={};f&4&&(m.content=s[8].body),o.$set(m),(!b||f&6)&&O(l,"active",s[1]===s[8].code)},i(i){b||(G(o.$$.fragment,i),b=!0)},o(i){Q(o.$$.fragment,i),b=!1},d(i){i&&u(l),ae(o)}}}function He(n){var ke,ge;let s,l,o=n[0].name+"",p,b,i,f,m,v,$,g=n[0].name+"",V,ce,W,M,X,L,Z,A,E,re,F,S,ue,z,H=n[0].name+"",K,de,Y,D,x,P,ee,fe,te,T,le,R,se,C,U,w=[],me=new Map,pe,j,_=[],be=new Map,B;M=new Le({props:{js:`
import{S as Ce,i as Be,s as Te,V as Le,X as J,h as u,d as ae,t as Q,a as G,I as N,Z as we,_ as Se,C as De,$ as Re,D as Ue,l as d,n as a,m as ne,u as c,A as y,v as k,c as ie,w as h,p as oe,J as je,k as O,o as qe,W as Ee}from"./index-DA9lB3Zb.js";import{F as Fe}from"./FieldsQueryParam-NplIJIb9.js";function ye(n,s,l){const o=n.slice();return o[8]=s[l],o}function Me(n,s,l){const o=n.slice();return o[8]=s[l],o}function Ae(n,s){let l,o=s[8].code+"",p,b,i,f;function m(){return s[6](s[8])}return{key:n,first:null,c(){l=c("button"),p=y(o),b=k(),h(l,"class","tab-item"),O(l,"active",s[1]===s[8].code),this.first=l},m(v,$){d(v,l,$),a(l,p),a(l,b),i||(f=qe(l,"click",m),i=!0)},p(v,$){s=v,$&4&&o!==(o=s[8].code+"")&&N(p,o),$&6&&O(l,"active",s[1]===s[8].code)},d(v){v&&u(l),i=!1,f()}}}function Pe(n,s){let l,o,p,b;return o=new Ee({props:{content:s[8].body}}),{key:n,first:null,c(){l=c("div"),ie(o.$$.fragment),p=k(),h(l,"class","tab-item"),O(l,"active",s[1]===s[8].code),this.first=l},m(i,f){d(i,l,f),ne(o,l,null),a(l,p),b=!0},p(i,f){s=i;const m={};f&4&&(m.content=s[8].body),o.$set(m),(!b||f&6)&&O(l,"active",s[1]===s[8].code)},i(i){b||(G(o.$$.fragment,i),b=!0)},o(i){Q(o.$$.fragment,i),b=!1},d(i){i&&u(l),ae(o)}}}function He(n){var ke,ge;let s,l,o=n[0].name+"",p,b,i,f,m,v,$,g=n[0].name+"",V,ce,W,M,X,L,Z,A,E,re,F,S,ue,z,H=n[0].name+"",K,de,Y,D,x,P,ee,fe,te,T,le,R,se,C,U,w=[],me=new Map,pe,j,_=[],be=new Map,B;M=new Le({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${n[3]}');
@@ -1,4 +1,4 @@
import{S as je,i as xe,s as Ie,V as Ke,W as Ue,X as I,h as d,d as K,t as E,a as z,I as de,Z as Oe,_ as Qe,C as We,$ as Xe,D as Ze,l as u,n as o,m as Q,u as s,A as k,v as p,c as W,w as b,J as Ve,p as Ge,k as X,o as Ye}from"./index-sszpfGrR.js";import{F as et}from"./FieldsQueryParam-Cs6hQAnX.js";function Ee(r,a,l){const n=r.slice();return n[5]=a[l],n}function ze(r,a,l){const n=r.slice();return n[5]=a[l],n}function Je(r,a){let l,n=a[5].code+"",m,_,i,h;function g(){return a[4](a[5])}return{key:r,first:null,c(){l=s("button"),m=k(n),_=p(),b(l,"class","tab-item"),X(l,"active",a[1]===a[5].code),this.first=l},m(v,w){u(v,l,w),o(l,m),o(l,_),i||(h=Ye(l,"click",g),i=!0)},p(v,w){a=v,w&4&&n!==(n=a[5].code+"")&&de(m,n),w&6&&X(l,"active",a[1]===a[5].code)},d(v){v&&d(l),i=!1,h()}}}function Ne(r,a){let l,n,m,_;return n=new Ue({props:{content:a[5].body}}),{key:r,first:null,c(){l=s("div"),W(n.$$.fragment),m=p(),b(l,"class","tab-item"),X(l,"active",a[1]===a[5].code),this.first=l},m(i,h){u(i,l,h),Q(n,l,null),o(l,m),_=!0},p(i,h){a=i;const g={};h&4&&(g.content=a[5].body),n.$set(g),(!_||h&6)&&X(l,"active",a[1]===a[5].code)},i(i){_||(z(n.$$.fragment,i),_=!0)},o(i){E(n.$$.fragment,i),_=!1},d(i){i&&d(l),K(n)}}}function tt(r){var qe,Fe;let a,l,n=r[0].name+"",m,_,i,h,g,v,w,D,Z,S,J,ue,N,M,pe,G,U=r[0].name+"",Y,he,fe,j,ee,q,te,T,oe,be,F,C,ae,me,le,_e,f,ke,P,ge,ve,$e,se,ye,ne,Se,we,Te,re,Ce,Re,A,ie,H,ce,R,L,y=[],Pe=new Map,Ae,O,$=[],Be=new Map,B;v=new Ke({props:{js:`
import{S as je,i as xe,s as Ie,V as Ke,W as Ue,X as I,h as d,d as K,t as E,a as z,I as de,Z as Oe,_ as Qe,C as We,$ as Xe,D as Ze,l as u,n as o,m as Q,u as s,A as k,v as p,c as W,w as b,J as Ve,p as Ge,k as X,o as Ye}from"./index-DA9lB3Zb.js";import{F as et}from"./FieldsQueryParam-NplIJIb9.js";function Ee(r,a,l){const n=r.slice();return n[5]=a[l],n}function ze(r,a,l){const n=r.slice();return n[5]=a[l],n}function Je(r,a){let l,n=a[5].code+"",m,_,i,h;function g(){return a[4](a[5])}return{key:r,first:null,c(){l=s("button"),m=k(n),_=p(),b(l,"class","tab-item"),X(l,"active",a[1]===a[5].code),this.first=l},m(v,w){u(v,l,w),o(l,m),o(l,_),i||(h=Ye(l,"click",g),i=!0)},p(v,w){a=v,w&4&&n!==(n=a[5].code+"")&&de(m,n),w&6&&X(l,"active",a[1]===a[5].code)},d(v){v&&d(l),i=!1,h()}}}function Ne(r,a){let l,n,m,_;return n=new Ue({props:{content:a[5].body}}),{key:r,first:null,c(){l=s("div"),W(n.$$.fragment),m=p(),b(l,"class","tab-item"),X(l,"active",a[1]===a[5].code),this.first=l},m(i,h){u(i,l,h),Q(n,l,null),o(l,m),_=!0},p(i,h){a=i;const g={};h&4&&(g.content=a[5].body),n.$set(g),(!_||h&6)&&X(l,"active",a[1]===a[5].code)},i(i){_||(z(n.$$.fragment,i),_=!0)},o(i){E(n.$$.fragment,i),_=!1},d(i){i&&d(l),K(n)}}}function tt(r){var qe,Fe;let a,l,n=r[0].name+"",m,_,i,h,g,v,w,D,Z,S,J,ue,N,M,pe,G,U=r[0].name+"",Y,he,fe,j,ee,q,te,T,oe,be,F,C,ae,me,le,_e,f,ke,P,ge,ve,$e,se,ye,ne,Se,we,Te,re,Ce,Re,A,ie,H,ce,R,L,y=[],Pe=new Map,Ae,O,$=[],Be=new Map,B;v=new Ke({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${r[3]}');
@@ -1,4 +1,4 @@
import{S as Je,i as Ee,s as xe,V as Ie,W as je,X as Q,h as r,d as z,t as j,a as J,I as pe,Z as Ue,_ as Ne,C as Qe,$ as ze,D as Ke,l as c,n as a,m as K,u as o,A as _,v as h,c as X,w as p,J as Be,p as Xe,k as Z,o as Ze}from"./index-sszpfGrR.js";import{F as Ge}from"./FieldsQueryParam-Cs6hQAnX.js";function Fe(s,l,n){const i=s.slice();return i[5]=l[n],i}function Le(s,l,n){const i=s.slice();return i[5]=l[n],i}function He(s,l){let n,i=l[5].code+"",f,g,d,b;function k(){return l[4](l[5])}return{key:s,first:null,c(){n=o("button"),f=_(i),g=h(),p(n,"class","tab-item"),Z(n,"active",l[1]===l[5].code),this.first=n},m(v,O){c(v,n,O),a(n,f),a(n,g),d||(b=Ze(n,"click",k),d=!0)},p(v,O){l=v,O&4&&i!==(i=l[5].code+"")&&pe(f,i),O&6&&Z(n,"active",l[1]===l[5].code)},d(v){v&&r(n),d=!1,b()}}}function Ve(s,l){let n,i,f,g;return i=new je({props:{content:l[5].body}}),{key:s,first:null,c(){n=o("div"),X(i.$$.fragment),f=h(),p(n,"class","tab-item"),Z(n,"active",l[1]===l[5].code),this.first=n},m(d,b){c(d,n,b),K(i,n,null),a(n,f),g=!0},p(d,b){l=d;const k={};b&4&&(k.content=l[5].body),i.$set(k),(!g||b&6)&&Z(n,"active",l[1]===l[5].code)},i(d){g||(J(i.$$.fragment,d),g=!0)},o(d){j(i.$$.fragment,d),g=!1},d(d){d&&r(n),z(i)}}}function Ye(s){let l,n,i=s[0].name+"",f,g,d,b,k,v,O,R,G,A,E,be,x,P,me,Y,I=s[0].name+"",ee,fe,te,M,ae,W,le,U,ne,y,oe,ge,B,S,se,_e,ie,ke,m,ve,C,we,$e,Oe,re,Ae,ce,ye,Se,Te,de,Ce,qe,q,ue,F,he,T,L,$=[],De=new Map,Re,H,w=[],Pe=new Map,D;v=new Ie({props:{js:`
import{S as Je,i as xe,s as Ee,V as Ne,W as je,X as Q,h as r,d as Z,t as j,a as J,I as pe,Z as Ue,_ as Ie,C as Qe,$ as Ze,D as ze,l as c,n as a,m as z,u as o,A as _,v as h,c as K,w as p,J as Be,p as Ke,k as X,o as Xe}from"./index-DA9lB3Zb.js";import{F as Ge}from"./FieldsQueryParam-NplIJIb9.js";function Fe(s,l,n){const i=s.slice();return i[5]=l[n],i}function Le(s,l,n){const i=s.slice();return i[5]=l[n],i}function He(s,l){let n,i=l[5].code+"",f,g,d,b;function k(){return l[4](l[5])}return{key:s,first:null,c(){n=o("button"),f=_(i),g=h(),p(n,"class","tab-item"),X(n,"active",l[1]===l[5].code),this.first=n},m(v,O){c(v,n,O),a(n,f),a(n,g),d||(b=Xe(n,"click",k),d=!0)},p(v,O){l=v,O&4&&i!==(i=l[5].code+"")&&pe(f,i),O&6&&X(n,"active",l[1]===l[5].code)},d(v){v&&r(n),d=!1,b()}}}function Ve(s,l){let n,i,f,g;return i=new je({props:{content:l[5].body}}),{key:s,first:null,c(){n=o("div"),K(i.$$.fragment),f=h(),p(n,"class","tab-item"),X(n,"active",l[1]===l[5].code),this.first=n},m(d,b){c(d,n,b),z(i,n,null),a(n,f),g=!0},p(d,b){l=d;const k={};b&4&&(k.content=l[5].body),i.$set(k),(!g||b&6)&&X(n,"active",l[1]===l[5].code)},i(d){g||(J(i.$$.fragment,d),g=!0)},o(d){j(i.$$.fragment,d),g=!1},d(d){d&&r(n),Z(i)}}}function Ye(s){let l,n,i=s[0].name+"",f,g,d,b,k,v,O,R,G,A,x,be,E,P,me,Y,N=s[0].name+"",ee,fe,te,M,ae,W,le,U,ne,y,oe,ge,B,S,se,_e,ie,ke,m,ve,C,we,$e,Oe,re,Ae,ce,ye,Se,Te,de,Ce,qe,q,ue,F,he,T,L,$=[],De=new Map,Re,H,w=[],Pe=new Map,D;v=new Ne({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${s[3]}');
@@ -45,18 +45,18 @@ import{S as Je,i as Ee,s as xe,V as Ie,W as je,X as Q,h as r,d as z,t as j,a as
// "logout"
pb.authStore.clear();
`}}),C=new je({props:{content:"?expand=relField1,relField2.subRelField"}}),q=new Ge({props:{prefix:"record."}});let N=Q(s[2]);const Me=e=>e[5].code;for(let e=0;e<N.length;e+=1){let t=Le(s,N,e),u=Me(t);De.set(u,$[e]=He(u,t))}let V=Q(s[2]);const We=e=>e[5].code;for(let e=0;e<V.length;e+=1){let t=Fe(s,V,e),u=We(t);Pe.set(u,w[e]=Ve(u,t))}return{c(){l=o("h3"),n=_("Auth with OAuth2 ("),f=_(i),g=_(")"),d=h(),b=o("div"),b.innerHTML=`<p>Authenticate with an OAuth2 provider and returns a new auth token and record data.</p> <p>For more details please check the
`}}),C=new je({props:{content:"?expand=relField1,relField2.subRelField"}}),q=new Ge({props:{prefix:"record."}});let I=Q(s[2]);const Me=e=>e[5].code;for(let e=0;e<I.length;e+=1){let t=Le(s,I,e),u=Me(t);De.set(u,$[e]=He(u,t))}let V=Q(s[2]);const We=e=>e[5].code;for(let e=0;e<V.length;e+=1){let t=Fe(s,V,e),u=We(t);Pe.set(u,w[e]=Ve(u,t))}return{c(){l=o("h3"),n=_("Auth with OAuth2 ("),f=_(i),g=_(")"),d=h(),b=o("div"),b.innerHTML=`<p>Authenticate with an OAuth2 provider and returns a new auth token and record data.</p> <p>For more details please check the
<a href="https://pocketbase.io/docs/authentication#authenticate-with-oauth2" target="_blank" rel="noopener noreferrer">OAuth2 integration documentation
</a>.</p>`,k=h(),X(v.$$.fragment),O=h(),R=o("h6"),R.textContent="API details",G=h(),A=o("div"),E=o("strong"),E.textContent="POST",be=h(),x=o("div"),P=o("p"),me=_("/api/collections/"),Y=o("strong"),ee=_(I),fe=_("/auth-with-oauth2"),te=h(),M=o("div"),M.textContent="Body Parameters",ae=h(),W=o("table"),W.innerHTML=`<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>provider</span></div></td> <td><span class="label">String</span></td> <td>The name of the OAuth2 client provider (eg. &quot;google&quot;).</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>code</span></div></td> <td><span class="label">String</span></td> <td>The authorization code returned from the initial request.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>codeVerifier</span></div></td> <td><span class="label">String</span></td> <td>The code verifier sent with the initial request as part of the code_challenge.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>redirectURL</span></div></td> <td><span class="label">String</span></td> <td>The redirect url sent with the initial request.</td></tr> <tr><td><div class="inline-flex"><span class="label label-warning">Optional</span> <span>createData</span></div></td> <td><span class="label">Object</span></td> <td><p>Optional data that will be used when creating the auth record on OAuth2 sign-up.</p> <p>The created auth record must comply with the same requirements and validations in the
</a>.</p>`,k=h(),K(v.$$.fragment),O=h(),R=o("h6"),R.textContent="API details",G=h(),A=o("div"),x=o("strong"),x.textContent="POST",be=h(),E=o("div"),P=o("p"),me=_("/api/collections/"),Y=o("strong"),ee=_(N),fe=_("/auth-with-oauth2"),te=h(),M=o("div"),M.textContent="Body Parameters",ae=h(),W=o("table"),W.innerHTML=`<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>provider</span></div></td> <td><span class="label">String</span></td> <td>The name of the OAuth2 client provider (eg. &quot;google&quot;).</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>code</span></div></td> <td><span class="label">String</span></td> <td>The authorization code returned from the initial request.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>codeVerifier</span></div></td> <td><span class="label">String</span></td> <td>The code verifier sent with the initial request as part of the code_challenge.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>redirectURL</span></div></td> <td><span class="label">String</span></td> <td>The redirect url sent with the initial request.</td></tr> <tr><td><div class="inline-flex"><span class="label label-warning">Optional</span> <span>createData</span></div></td> <td><span class="label">Object</span></td> <td><p>Optional data that will be used when creating the auth record on OAuth2 sign-up.</p> <p>The created auth record must comply with the same requirements and validations in the
regular <strong>create</strong> action.
<br/> <em>The data can only be in <code>json</code>, aka. <code>multipart/form-data</code> and files
upload currently are not supported during OAuth2 sign-ups.</em></p></td></tr></tbody>`,le=h(),U=o("div"),U.textContent="Query parameters",ne=h(),y=o("table"),oe=o("thead"),oe.innerHTML='<tr><th>Param</th> <th>Type</th> <th width="60%">Description</th></tr>',ge=h(),B=o("tbody"),S=o("tr"),se=o("td"),se.textContent="expand",_e=h(),ie=o("td"),ie.innerHTML='<span class="label">String</span>',ke=h(),m=o("td"),ve=_(`Auto expand record relations. Ex.:
`),X(C.$$.fragment),we=_(`
`),K(C.$$.fragment),we=_(`
Supports up to 6-levels depth nested relations expansion. `),$e=o("br"),Oe=_(`
The expanded relations will be appended to the record under the
`),re=o("code"),re.textContent="expand",Ae=_(" property (eg. "),ce=o("code"),ce.textContent='"expand": {"relField1": {...}, ...}',ye=_(`).
`),Se=o("br"),Te=_(`
Only the relations to which the request user has permissions to `),de=o("strong"),de.textContent="view",Ce=_(" will be expanded."),qe=h(),X(q.$$.fragment),ue=h(),F=o("div"),F.textContent="Responses",he=h(),T=o("div"),L=o("div");for(let e=0;e<$.length;e+=1)$[e].c();Re=h(),H=o("div");for(let e=0;e<w.length;e+=1)w[e].c();p(l,"class","m-b-sm"),p(b,"class","content txt-lg m-b-sm"),p(R,"class","m-b-xs"),p(E,"class","label label-primary"),p(x,"class","content"),p(A,"class","alert alert-success"),p(M,"class","section-title"),p(W,"class","table-compact table-border m-b-base"),p(U,"class","section-title"),p(y,"class","table-compact table-border m-b-base"),p(F,"class","section-title"),p(L,"class","tabs-header compact combined left"),p(H,"class","tabs-content"),p(T,"class","tabs")},m(e,t){c(e,l,t),a(l,n),a(l,f),a(l,g),c(e,d,t),c(e,b,t),c(e,k,t),K(v,e,t),c(e,O,t),c(e,R,t),c(e,G,t),c(e,A,t),a(A,E),a(A,be),a(A,x),a(x,P),a(P,me),a(P,Y),a(Y,ee),a(P,fe),c(e,te,t),c(e,M,t),c(e,ae,t),c(e,W,t),c(e,le,t),c(e,U,t),c(e,ne,t),c(e,y,t),a(y,oe),a(y,ge),a(y,B),a(B,S),a(S,se),a(S,_e),a(S,ie),a(S,ke),a(S,m),a(m,ve),K(C,m,null),a(m,we),a(m,$e),a(m,Oe),a(m,re),a(m,Ae),a(m,ce),a(m,ye),a(m,Se),a(m,Te),a(m,de),a(m,Ce),a(B,qe),K(q,B,null),c(e,ue,t),c(e,F,t),c(e,he,t),c(e,T,t),a(T,L);for(let u=0;u<$.length;u+=1)$[u]&&$[u].m(L,null);a(T,Re),a(T,H);for(let u=0;u<w.length;u+=1)w[u]&&w[u].m(H,null);D=!0},p(e,[t]){(!D||t&1)&&i!==(i=e[0].name+"")&&pe(f,i);const u={};t&9&&(u.js=`
Only the relations to which the request user has permissions to `),de=o("strong"),de.textContent="view",Ce=_(" will be expanded."),qe=h(),K(q.$$.fragment),ue=h(),F=o("div"),F.textContent="Responses",he=h(),T=o("div"),L=o("div");for(let e=0;e<$.length;e+=1)$[e].c();Re=h(),H=o("div");for(let e=0;e<w.length;e+=1)w[e].c();p(l,"class","m-b-sm"),p(b,"class","content txt-lg m-b-sm"),p(R,"class","m-b-xs"),p(x,"class","label label-primary"),p(E,"class","content"),p(A,"class","alert alert-success"),p(M,"class","section-title"),p(W,"class","table-compact table-border m-b-base"),p(U,"class","section-title"),p(y,"class","table-compact table-border m-b-base"),p(F,"class","section-title"),p(L,"class","tabs-header compact combined left"),p(H,"class","tabs-content"),p(T,"class","tabs")},m(e,t){c(e,l,t),a(l,n),a(l,f),a(l,g),c(e,d,t),c(e,b,t),c(e,k,t),z(v,e,t),c(e,O,t),c(e,R,t),c(e,G,t),c(e,A,t),a(A,x),a(A,be),a(A,E),a(E,P),a(P,me),a(P,Y),a(Y,ee),a(P,fe),c(e,te,t),c(e,M,t),c(e,ae,t),c(e,W,t),c(e,le,t),c(e,U,t),c(e,ne,t),c(e,y,t),a(y,oe),a(y,ge),a(y,B),a(B,S),a(S,se),a(S,_e),a(S,ie),a(S,ke),a(S,m),a(m,ve),z(C,m,null),a(m,we),a(m,$e),a(m,Oe),a(m,re),a(m,Ae),a(m,ce),a(m,ye),a(m,Se),a(m,Te),a(m,de),a(m,Ce),a(B,qe),z(q,B,null),c(e,ue,t),c(e,F,t),c(e,he,t),c(e,T,t),a(T,L);for(let u=0;u<$.length;u+=1)$[u]&&$[u].m(L,null);a(T,Re),a(T,H);for(let u=0;u<w.length;u+=1)w[u]&&w[u].m(H,null);D=!0},p(e,[t]){(!D||t&1)&&i!==(i=e[0].name+"")&&pe(f,i);const u={};t&9&&(u.js=`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${e[3]}');
@@ -103,7 +103,7 @@ import{S as Je,i as Ee,s as xe,V as Ie,W as je,X as Q,h as r,d as z,t as j,a as
// "logout"
pb.authStore.clear();
`),v.$set(u),(!D||t&1)&&I!==(I=e[0].name+"")&&pe(ee,I),t&6&&(N=Q(e[2]),$=Ue($,t,Me,1,e,N,De,L,Ne,He,null,Le)),t&6&&(V=Q(e[2]),Qe(),w=Ue(w,t,We,1,e,V,Pe,H,ze,Ve,null,Fe),Ke())},i(e){if(!D){J(v.$$.fragment,e),J(C.$$.fragment,e),J(q.$$.fragment,e);for(let t=0;t<V.length;t+=1)J(w[t]);D=!0}},o(e){j(v.$$.fragment,e),j(C.$$.fragment,e),j(q.$$.fragment,e);for(let t=0;t<w.length;t+=1)j(w[t]);D=!1},d(e){e&&(r(l),r(d),r(b),r(k),r(O),r(R),r(G),r(A),r(te),r(M),r(ae),r(W),r(le),r(U),r(ne),r(y),r(ue),r(F),r(he),r(T)),z(v,e),z(C),z(q);for(let t=0;t<$.length;t+=1)$[t].d();for(let t=0;t<w.length;t+=1)w[t].d()}}}function et(s,l,n){let i,{collection:f}=l,g=200,d=[];const b=k=>n(1,g=k.code);return s.$$set=k=>{"collection"in k&&n(0,f=k.collection)},s.$$.update=()=>{s.$$.dirty&1&&n(2,d=[{code:200,body:JSON.stringify({token:"JWT_AUTH_TOKEN",record:Be.dummyCollectionRecord(f),meta:{id:"abc123",name:"John Doe",username:"john.doe",email:"test@example.com",avatarURL:"https://example.com/avatar.png",accessToken:"...",refreshToken:"...",rawUser:{}}},null,2)},{code:400,body:`
`),v.$set(u),(!D||t&1)&&N!==(N=e[0].name+"")&&pe(ee,N),t&6&&(I=Q(e[2]),$=Ue($,t,Me,1,e,I,De,L,Ie,He,null,Le)),t&6&&(V=Q(e[2]),Qe(),w=Ue(w,t,We,1,e,V,Pe,H,Ze,Ve,null,Fe),ze())},i(e){if(!D){J(v.$$.fragment,e),J(C.$$.fragment,e),J(q.$$.fragment,e);for(let t=0;t<V.length;t+=1)J(w[t]);D=!0}},o(e){j(v.$$.fragment,e),j(C.$$.fragment,e),j(q.$$.fragment,e);for(let t=0;t<w.length;t+=1)j(w[t]);D=!1},d(e){e&&(r(l),r(d),r(b),r(k),r(O),r(R),r(G),r(A),r(te),r(M),r(ae),r(W),r(le),r(U),r(ne),r(y),r(ue),r(F),r(he),r(T)),Z(v,e),Z(C),Z(q);for(let t=0;t<$.length;t+=1)$[t].d();for(let t=0;t<w.length;t+=1)w[t].d()}}}function et(s,l,n){let i,{collection:f}=l,g=200,d=[];const b=k=>n(1,g=k.code);return s.$$set=k=>{"collection"in k&&n(0,f=k.collection)},s.$$.update=()=>{s.$$.dirty&1&&n(2,d=[{code:200,body:JSON.stringify({token:"JWT_AUTH_TOKEN",record:Be.dummyCollectionRecord(f),meta:{id:"abc123",name:"John Doe",username:"john.doe",email:"test@example.com",avatarURL:"https://example.com/avatar.png",accessToken:"...",refreshToken:"...",expiry:"2022-01-01 10:00:00.123Z",isNew:!1,rawUser:{}}},null,2)},{code:400,body:`
{
"status": 400,
"message": "An error occurred while submitting the form.",
@@ -114,4 +114,4 @@ import{S as Je,i as Ee,s as xe,V as Ie,W as je,X as Q,h as r,d as z,t as j,a as
}
}
}
`}])},n(3,i=Be.getApiExampleUrl(Xe.baseURL)),[f,g,d,i,b]}class lt extends Je{constructor(l){super(),Ee(this,l,et,Ye,xe,{collection:0})}}export{lt as default};
`}])},n(3,i=Be.getApiExampleUrl(Ke.baseURL)),[f,g,d,i,b]}class lt extends Je{constructor(l){super(),xe(this,l,et,Ye,Ee,{collection:0})}}export{lt as default};
@@ -1,4 +1,4 @@
import{S as be,i as _e,s as ve,W as ge,X as V,h as b,d as x,t as j,a as J,I as ce,Z as de,_ as je,C as ue,$ as Qe,D as he,l as _,n as s,m as ee,u as d,v as T,A as R,c as te,w as g,J as ke,k as N,o as $e,V as Ke,Y as De,p as Xe,a0 as Me}from"./index-sszpfGrR.js";import{F as Ze}from"./FieldsQueryParam-Cs6hQAnX.js";function Be(a,t,e){const l=a.slice();return l[4]=t[e],l}function Ie(a,t,e){const l=a.slice();return l[4]=t[e],l}function We(a,t){let e,l=t[4].code+"",h,i,c,n;function m(){return t[3](t[4])}return{key:a,first:null,c(){e=d("button"),h=R(l),i=T(),g(e,"class","tab-item"),N(e,"active",t[1]===t[4].code),this.first=e},m(v,C){_(v,e,C),s(e,h),s(e,i),c||(n=$e(e,"click",m),c=!0)},p(v,C){t=v,C&4&&l!==(l=t[4].code+"")&&ce(h,l),C&6&&N(e,"active",t[1]===t[4].code)},d(v){v&&b(e),c=!1,n()}}}function Fe(a,t){let e,l,h,i;return l=new ge({props:{content:t[4].body}}),{key:a,first:null,c(){e=d("div"),te(l.$$.fragment),h=T(),g(e,"class","tab-item"),N(e,"active",t[1]===t[4].code),this.first=e},m(c,n){_(c,e,n),ee(l,e,null),s(e,h),i=!0},p(c,n){t=c;const m={};n&4&&(m.content=t[4].body),l.$set(m),(!i||n&6)&&N(e,"active",t[1]===t[4].code)},i(c){i||(J(l.$$.fragment,c),i=!0)},o(c){j(l.$$.fragment,c),i=!1},d(c){c&&b(e),x(l)}}}function ze(a){let t,e,l,h,i,c,n,m=a[0].name+"",v,C,F,B,I,D,Q,M,U,y,O,q,k,L,Y,A,X,E,o,$,P,z,u,p,S,w,Z,we,Te,Pe,pe,Oe,ye,le,fe,oe,me,G,ae,K=[],Se=new Map,qe,ne,H=[],Ce=new Map,se;P=new ge({props:{content:"?expand=relField1,relField2.subRelField"}}),le=new Ze({props:{prefix:"record."}});let re=V(a[2]);const Ae=r=>r[4].code;for(let r=0;r<re.length;r+=1){let f=Ie(a,re,r),W=Ae(f);Se.set(W,K[r]=We(W,f))}let ie=V(a[2]);const Re=r=>r[4].code;for(let r=0;r<ie.length;r+=1){let f=Be(a,ie,r),W=Re(f);Ce.set(W,H[r]=Fe(W,f))}return{c(){t=d("div"),e=d("strong"),e.textContent="POST",l=T(),h=d("div"),i=d("p"),c=R("/api/collections/"),n=d("strong"),v=R(m),C=R("/auth-with-otp"),F=T(),B=d("div"),B.textContent="Body Parameters",I=T(),D=d("table"),D.innerHTML='<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>otpId</span></div></td> <td><span class="label">String</span></td> <td>The id of the OTP request.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>password</span></div></td> <td><span class="label">String</span></td> <td>The one-time password.</td></tr></tbody>',Q=T(),M=d("div"),M.textContent="Query parameters",U=T(),y=d("table"),O=d("thead"),O.innerHTML='<tr><th>Param</th> <th>Type</th> <th width="60%">Description</th></tr>',q=T(),k=d("tbody"),L=d("tr"),Y=d("td"),Y.textContent="expand",A=T(),X=d("td"),X.innerHTML='<span class="label">String</span>',E=T(),o=d("td"),$=R(`Auto expand record relations. Ex.:
import{S as be,i as _e,s as ve,W as ge,X as V,h as b,d as x,t as j,a as J,I as ce,Z as de,_ as je,C as ue,$ as Qe,D as he,l as _,n as s,m as ee,u as d,v as T,A as R,c as te,w as g,J as ke,k as N,o as $e,V as Ke,Y as De,p as Xe,a0 as Me}from"./index-DA9lB3Zb.js";import{F as Ze}from"./FieldsQueryParam-NplIJIb9.js";function Be(a,t,e){const l=a.slice();return l[4]=t[e],l}function Ie(a,t,e){const l=a.slice();return l[4]=t[e],l}function We(a,t){let e,l=t[4].code+"",h,i,c,n;function m(){return t[3](t[4])}return{key:a,first:null,c(){e=d("button"),h=R(l),i=T(),g(e,"class","tab-item"),N(e,"active",t[1]===t[4].code),this.first=e},m(v,C){_(v,e,C),s(e,h),s(e,i),c||(n=$e(e,"click",m),c=!0)},p(v,C){t=v,C&4&&l!==(l=t[4].code+"")&&ce(h,l),C&6&&N(e,"active",t[1]===t[4].code)},d(v){v&&b(e),c=!1,n()}}}function Fe(a,t){let e,l,h,i;return l=new ge({props:{content:t[4].body}}),{key:a,first:null,c(){e=d("div"),te(l.$$.fragment),h=T(),g(e,"class","tab-item"),N(e,"active",t[1]===t[4].code),this.first=e},m(c,n){_(c,e,n),ee(l,e,null),s(e,h),i=!0},p(c,n){t=c;const m={};n&4&&(m.content=t[4].body),l.$set(m),(!i||n&6)&&N(e,"active",t[1]===t[4].code)},i(c){i||(J(l.$$.fragment,c),i=!0)},o(c){j(l.$$.fragment,c),i=!1},d(c){c&&b(e),x(l)}}}function ze(a){let t,e,l,h,i,c,n,m=a[0].name+"",v,C,F,B,I,D,Q,M,U,y,O,q,k,L,Y,A,X,E,o,$,P,z,u,p,S,w,Z,we,Te,Pe,pe,Oe,ye,le,fe,oe,me,G,ae,K=[],Se=new Map,qe,ne,H=[],Ce=new Map,se;P=new ge({props:{content:"?expand=relField1,relField2.subRelField"}}),le=new Ze({props:{prefix:"record."}});let re=V(a[2]);const Ae=r=>r[4].code;for(let r=0;r<re.length;r+=1){let f=Ie(a,re,r),W=Ae(f);Se.set(W,K[r]=We(W,f))}let ie=V(a[2]);const Re=r=>r[4].code;for(let r=0;r<ie.length;r+=1){let f=Be(a,ie,r),W=Re(f);Ce.set(W,H[r]=Fe(W,f))}return{c(){t=d("div"),e=d("strong"),e.textContent="POST",l=T(),h=d("div"),i=d("p"),c=R("/api/collections/"),n=d("strong"),v=R(m),C=R("/auth-with-otp"),F=T(),B=d("div"),B.textContent="Body Parameters",I=T(),D=d("table"),D.innerHTML='<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>otpId</span></div></td> <td><span class="label">String</span></td> <td>The id of the OTP request.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>password</span></div></td> <td><span class="label">String</span></td> <td>The one-time password.</td></tr></tbody>',Q=T(),M=d("div"),M.textContent="Query parameters",U=T(),y=d("table"),O=d("thead"),O.innerHTML='<tr><th>Param</th> <th>Type</th> <th width="60%">Description</th></tr>',q=T(),k=d("tbody"),L=d("tr"),Y=d("td"),Y.textContent="expand",A=T(),X=d("td"),X.innerHTML='<span class="label">String</span>',E=T(),o=d("td"),$=R(`Auto expand record relations. Ex.:
`),te(P.$$.fragment),z=R(`
Supports up to 6-levels depth nested relations expansion. `),u=d("br"),p=R(`
The expanded relations will be appended to the record under the
@@ -1,4 +1,4 @@
import{S as kt,i as gt,s as vt,V as St,X as L,W as _t,h as c,d as ae,Y as wt,t as X,a as Z,I as z,Z as ct,_ as yt,C as $t,$ as Pt,D as Ct,l as d,n as t,m as oe,u as s,A as f,v as u,c as se,w as k,J as dt,p as Rt,k as ne,o as Ot}from"./index-sszpfGrR.js";import{F as Tt}from"./FieldsQueryParam-Cs6hQAnX.js";function pt(i,o,a){const n=i.slice();return n[7]=o[a],n}function ut(i,o,a){const n=i.slice();return n[7]=o[a],n}function ht(i,o,a){const n=i.slice();return n[12]=o[a],n[14]=a,n}function At(i){let o;return{c(){o=f("or")},m(a,n){d(a,o,n)},d(a){a&&c(o)}}}function bt(i){let o,a,n=i[12]+"",m,b=i[14]>0&&At();return{c(){b&&b.c(),o=u(),a=s("strong"),m=f(n)},m(r,h){b&&b.m(r,h),d(r,o,h),d(r,a,h),t(a,m)},p(r,h){h&2&&n!==(n=r[12]+"")&&z(m,n)},d(r){r&&(c(o),c(a)),b&&b.d(r)}}}function ft(i,o){let a,n=o[7].code+"",m,b,r,h;function g(){return o[6](o[7])}return{key:i,first:null,c(){a=s("button"),m=f(n),b=u(),k(a,"class","tab-item"),ne(a,"active",o[2]===o[7].code),this.first=a},m($,_){d($,a,_),t(a,m),t(a,b),r||(h=Ot(a,"click",g),r=!0)},p($,_){o=$,_&8&&n!==(n=o[7].code+"")&&z(m,n),_&12&&ne(a,"active",o[2]===o[7].code)},d($){$&&c(a),r=!1,h()}}}function mt(i,o){let a,n,m,b;return n=new _t({props:{content:o[7].body}}),{key:i,first:null,c(){a=s("div"),se(n.$$.fragment),m=u(),k(a,"class","tab-item"),ne(a,"active",o[2]===o[7].code),this.first=a},m(r,h){d(r,a,h),oe(n,a,null),t(a,m),b=!0},p(r,h){o=r;const g={};h&8&&(g.content=o[7].body),n.$set(g),(!b||h&12)&&ne(a,"active",o[2]===o[7].code)},i(r){b||(Z(n.$$.fragment,r),b=!0)},o(r){X(n.$$.fragment,r),b=!1},d(r){r&&c(a),ae(n)}}}function Dt(i){var ot,st;let o,a,n=i[0].name+"",m,b,r,h,g,$,_,G=i[1].join("/")+"",ie,De,re,We,ce,C,de,q,pe,R,x,Fe,ee,H,Me,ue,te=i[0].name+"",he,Ue,be,Y,fe,O,me,Be,j,T,_e,Le,ke,qe,V,ge,He,ve,Se,E,we,A,ye,Ye,N,D,$e,je,Pe,Ve,v,Ee,M,Ne,Ie,Je,Ce,Qe,Re,Ke,Xe,Ze,Oe,ze,Ge,U,Te,I,Ae,W,J,P=[],xe=new Map,et,Q,w=[],tt=new Map,F;C=new St({props:{js:`
import{S as kt,i as gt,s as vt,V as St,X as L,W as _t,h as c,d as ae,Y as wt,t as X,a as Z,I as z,Z as ct,_ as yt,C as $t,$ as Pt,D as Ct,l as d,n as t,m as oe,u as s,A as f,v as u,c as se,w as k,J as dt,p as Rt,k as ne,o as Ot}from"./index-DA9lB3Zb.js";import{F as Tt}from"./FieldsQueryParam-NplIJIb9.js";function pt(i,o,a){const n=i.slice();return n[7]=o[a],n}function ut(i,o,a){const n=i.slice();return n[7]=o[a],n}function ht(i,o,a){const n=i.slice();return n[12]=o[a],n[14]=a,n}function At(i){let o;return{c(){o=f("or")},m(a,n){d(a,o,n)},d(a){a&&c(o)}}}function bt(i){let o,a,n=i[12]+"",m,b=i[14]>0&&At();return{c(){b&&b.c(),o=u(),a=s("strong"),m=f(n)},m(r,h){b&&b.m(r,h),d(r,o,h),d(r,a,h),t(a,m)},p(r,h){h&2&&n!==(n=r[12]+"")&&z(m,n)},d(r){r&&(c(o),c(a)),b&&b.d(r)}}}function ft(i,o){let a,n=o[7].code+"",m,b,r,h;function g(){return o[6](o[7])}return{key:i,first:null,c(){a=s("button"),m=f(n),b=u(),k(a,"class","tab-item"),ne(a,"active",o[2]===o[7].code),this.first=a},m($,_){d($,a,_),t(a,m),t(a,b),r||(h=Ot(a,"click",g),r=!0)},p($,_){o=$,_&8&&n!==(n=o[7].code+"")&&z(m,n),_&12&&ne(a,"active",o[2]===o[7].code)},d($){$&&c(a),r=!1,h()}}}function mt(i,o){let a,n,m,b;return n=new _t({props:{content:o[7].body}}),{key:i,first:null,c(){a=s("div"),se(n.$$.fragment),m=u(),k(a,"class","tab-item"),ne(a,"active",o[2]===o[7].code),this.first=a},m(r,h){d(r,a,h),oe(n,a,null),t(a,m),b=!0},p(r,h){o=r;const g={};h&8&&(g.content=o[7].body),n.$set(g),(!b||h&12)&&ne(a,"active",o[2]===o[7].code)},i(r){b||(Z(n.$$.fragment,r),b=!0)},o(r){X(n.$$.fragment,r),b=!1},d(r){r&&c(a),ae(n)}}}function Dt(i){var ot,st;let o,a,n=i[0].name+"",m,b,r,h,g,$,_,G=i[1].join("/")+"",ie,De,re,We,ce,C,de,q,pe,R,x,Fe,ee,H,Me,ue,te=i[0].name+"",he,Ue,be,Y,fe,O,me,Be,j,T,_e,Le,ke,qe,V,ge,He,ve,Se,E,we,A,ye,Ye,N,D,$e,je,Pe,Ve,v,Ee,M,Ne,Ie,Je,Ce,Qe,Re,Ke,Xe,Ze,Oe,ze,Ge,U,Te,I,Ae,W,J,P=[],xe=new Map,et,Q,w=[],tt=new Map,F;C=new St({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${i[5]}');
@@ -1,4 +1,4 @@
import{S as St,i as At,s as Lt,V as Mt,W as Ht,X as Q,h as d,d as Re,t as Y,a as x,I as jt,Z as Pt,_ as Nt,C as Ut,$ as Jt,D as zt,l as u,n as t,m as Te,E as Wt,G as Gt,u as o,A as _,v as i,c as Pe,w as b,J as Ft,p as Kt,k as ee,o as Vt}from"./index-sszpfGrR.js";function Bt(a,s,n){const c=a.slice();return c[6]=s[n],c}function Et(a,s,n){const c=a.slice();return c[6]=s[n],c}function Ot(a,s){let n,c,y;function f(){return s[5](s[6])}return{key:a,first:null,c(){n=o("button"),n.textContent=`${s[6].code} `,b(n,"class","tab-item"),ee(n,"active",s[1]===s[6].code),this.first=n},m(r,h){u(r,n,h),c||(y=Vt(n,"click",f),c=!0)},p(r,h){s=r,h&10&&ee(n,"active",s[1]===s[6].code)},d(r){r&&d(n),c=!1,y()}}}function It(a,s){let n,c,y,f;return c=new Ht({props:{content:s[6].body}}),{key:a,first:null,c(){n=o("div"),Pe(c.$$.fragment),y=i(),b(n,"class","tab-item"),ee(n,"active",s[1]===s[6].code),this.first=n},m(r,h){u(r,n,h),Te(c,n,null),t(n,y),f=!0},p(r,h){s=r,(!f||h&10)&&ee(n,"active",s[1]===s[6].code)},i(r){f||(x(c.$$.fragment,r),f=!0)},o(r){Y(c.$$.fragment,r),f=!1},d(r){r&&d(n),Re(c)}}}function Xt(a){var pt,mt,bt,ht,ft,_t,yt,kt;let s,n,c=a[0].name+"",y,f,r,h,F,g,U,Fe,P,B,Be,E,Ee,Oe,te,le,q,oe,O,ae,I,se,H,ne,J,ie,w,ce,Ie,re,S,z,He,k,W,Se,de,Ae,C,G,Le,ue,Me,K,je,pe,Ne,D,Ue,me,Je,ze,We,V,Ge,X,Ke,be,Ve,he,Xe,fe,Ze,p,_e,Qe,ye,Ye,ke,xe,$e,et,ge,tt,ve,lt,ot,at,Ce,st,R,De,A,qe,T,L,v=[],nt=new Map,it,M,$=[],ct=new Map,j,we,rt;q=new Mt({props:{js:`
import{S as St,i as At,s as Lt,V as Mt,W as Ht,X as Q,h as d,d as Re,t as Y,a as x,I as jt,Z as Pt,_ as Nt,C as Ut,$ as Jt,D as zt,l as u,n as t,m as Te,E as Wt,G as Gt,u as o,A as _,v as i,c as Pe,w as b,J as Ft,p as Kt,k as ee,o as Vt}from"./index-DA9lB3Zb.js";function Bt(a,s,n){const c=a.slice();return c[6]=s[n],c}function Et(a,s,n){const c=a.slice();return c[6]=s[n],c}function Ot(a,s){let n,c,y;function f(){return s[5](s[6])}return{key:a,first:null,c(){n=o("button"),n.textContent=`${s[6].code} `,b(n,"class","tab-item"),ee(n,"active",s[1]===s[6].code),this.first=n},m(r,h){u(r,n,h),c||(y=Vt(n,"click",f),c=!0)},p(r,h){s=r,h&10&&ee(n,"active",s[1]===s[6].code)},d(r){r&&d(n),c=!1,y()}}}function It(a,s){let n,c,y,f;return c=new Ht({props:{content:s[6].body}}),{key:a,first:null,c(){n=o("div"),Pe(c.$$.fragment),y=i(),b(n,"class","tab-item"),ee(n,"active",s[1]===s[6].code),this.first=n},m(r,h){u(r,n,h),Te(c,n,null),t(n,y),f=!0},p(r,h){s=r,(!f||h&10)&&ee(n,"active",s[1]===s[6].code)},i(r){f||(x(c.$$.fragment,r),f=!0)},o(r){Y(c.$$.fragment,r),f=!1},d(r){r&&d(n),Re(c)}}}function Xt(a){var pt,mt,bt,ht,ft,_t,yt,kt;let s,n,c=a[0].name+"",y,f,r,h,F,g,U,Fe,P,B,Be,E,Ee,Oe,te,le,q,oe,O,ae,I,se,H,ne,J,ie,w,ce,Ie,re,S,z,He,k,W,Se,de,Ae,C,G,Le,ue,Me,K,je,pe,Ne,D,Ue,me,Je,ze,We,V,Ge,X,Ke,be,Ve,he,Xe,fe,Ze,p,_e,Qe,ye,Ye,ke,xe,$e,et,ge,tt,ve,lt,ot,at,Ce,st,R,De,A,qe,T,L,v=[],nt=new Map,it,M,$=[],ct=new Map,j,we,rt;q=new Mt({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${a[2]}');
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{S as $t,i as qt,s as St,V as Tt,J as ee,X as ue,W as Ct,h as r,d as $e,t as ye,a as ve,I as oe,Z as Ve,_ as pt,C as Ot,$ as Mt,D as Lt,l as d,n as i,m as qe,u as a,A as _,v as p,c as Se,w,p as Pt,k as Te,o as Ft,L as Ht,H as we}from"./index-sszpfGrR.js";import{F as Rt}from"./FieldsQueryParam-Cs6hQAnX.js";function mt(s,e,t){const l=s.slice();return l[10]=e[t],l}function bt(s,e,t){const l=s.slice();return l[10]=e[t],l}function _t(s,e,t){const l=s.slice();return l[15]=e[t],l}function kt(s){let e;return{c(){e=a("p"),e.innerHTML="Requires superuser <code>Authorization:TOKEN</code> header",w(e,"class","txt-hint txt-sm txt-right")},m(t,l){d(t,e,l)},d(t){t&&r(e)}}}function ht(s){let e,t,l,u,c,f,b,m,$,h,g,B,T,O,R,M,I,J,S,W,L,q,k,P,te,K,U,re,X,Y,Z;function fe(y,C){var V,z,H;return C&1&&(f=null),f==null&&(f=!!((H=(z=(V=y[0])==null?void 0:V.fields)==null?void 0:z.find(Yt))!=null&&H.required)),f?Bt:At}let le=fe(s,-1),E=le(s);function G(y,C){var V,z,H;return C&1&&(I=null),I==null&&(I=!!((H=(z=(V=y[0])==null?void 0:V.fields)==null?void 0:z.find(Xt))!=null&&H.required)),I?Vt:jt}let x=G(s,-1),F=x(s);return{c(){e=a("tr"),e.innerHTML='<td colspan="3" class="txt-hint txt-bold">Auth specific fields</td>',t=p(),l=a("tr"),u=a("td"),c=a("div"),E.c(),b=p(),m=a("span"),m.textContent="email",$=p(),h=a("td"),h.innerHTML='<span class="label">String</span>',g=p(),B=a("td"),B.textContent="Auth record email address.",T=p(),O=a("tr"),R=a("td"),M=a("div"),F.c(),J=p(),S=a("span"),S.textContent="emailVisibility",W=p(),L=a("td"),L.innerHTML='<span class="label">Boolean</span>',q=p(),k=a("td"),k.textContent="Whether to show/hide the auth record email when fetching the record data.",P=p(),te=a("tr"),te.innerHTML='<td><div class="inline-flex"><span class="label label-success">Required</span> <span>password</span></div></td> <td><span class="label">String</span></td> <td>Auth record password.</td>',K=p(),U=a("tr"),U.innerHTML='<td><div class="inline-flex"><span class="label label-success">Required</span> <span>passwordConfirm</span></div></td> <td><span class="label">String</span></td> <td>Auth record password confirmation.</td>',re=p(),X=a("tr"),X.innerHTML=`<td><div class="inline-flex"><span class="label label-warning">Optional</span> <span>verified</span></div></td> <td><span class="label">Boolean</span></td> <td>Indicates whether the auth record is verified or not.
import{S as $t,i as qt,s as St,V as Tt,J as ee,X as ue,W as Ct,h as r,d as $e,t as ye,a as ve,I as oe,Z as Ve,_ as pt,C as Ot,$ as Mt,D as Lt,l as d,n as i,m as qe,u as a,A as _,v as p,c as Se,w,p as Pt,k as Te,o as Ft,L as Ht,H as we}from"./index-DA9lB3Zb.js";import{F as Rt}from"./FieldsQueryParam-NplIJIb9.js";function mt(s,e,t){const l=s.slice();return l[10]=e[t],l}function bt(s,e,t){const l=s.slice();return l[10]=e[t],l}function _t(s,e,t){const l=s.slice();return l[15]=e[t],l}function kt(s){let e;return{c(){e=a("p"),e.innerHTML="Requires superuser <code>Authorization:TOKEN</code> header",w(e,"class","txt-hint txt-sm txt-right")},m(t,l){d(t,e,l)},d(t){t&&r(e)}}}function ht(s){let e,t,l,u,c,f,b,m,$,h,g,B,T,O,R,M,I,J,S,W,L,q,k,P,te,K,U,re,X,Y,Z;function fe(y,C){var V,z,H;return C&1&&(f=null),f==null&&(f=!!((H=(z=(V=y[0])==null?void 0:V.fields)==null?void 0:z.find(Yt))!=null&&H.required)),f?Bt:At}let le=fe(s,-1),E=le(s);function G(y,C){var V,z,H;return C&1&&(I=null),I==null&&(I=!!((H=(z=(V=y[0])==null?void 0:V.fields)==null?void 0:z.find(Xt))!=null&&H.required)),I?Vt:jt}let x=G(s,-1),F=x(s);return{c(){e=a("tr"),e.innerHTML='<td colspan="3" class="txt-hint txt-bold">Auth specific fields</td>',t=p(),l=a("tr"),u=a("td"),c=a("div"),E.c(),b=p(),m=a("span"),m.textContent="email",$=p(),h=a("td"),h.innerHTML='<span class="label">String</span>',g=p(),B=a("td"),B.textContent="Auth record email address.",T=p(),O=a("tr"),R=a("td"),M=a("div"),F.c(),J=p(),S=a("span"),S.textContent="emailVisibility",W=p(),L=a("td"),L.innerHTML='<span class="label">Boolean</span>',q=p(),k=a("td"),k.textContent="Whether to show/hide the auth record email when fetching the record data.",P=p(),te=a("tr"),te.innerHTML='<td><div class="inline-flex"><span class="label label-success">Required</span> <span>password</span></div></td> <td><span class="label">String</span></td> <td>Auth record password.</td>',K=p(),U=a("tr"),U.innerHTML='<td><div class="inline-flex"><span class="label label-success">Required</span> <span>passwordConfirm</span></div></td> <td><span class="label">String</span></td> <td>Auth record password confirmation.</td>',re=p(),X=a("tr"),X.innerHTML=`<td><div class="inline-flex"><span class="label label-warning">Optional</span> <span>verified</span></div></td> <td><span class="label">Boolean</span></td> <td>Indicates whether the auth record is verified or not.
<br/>
This field can be set only by superusers or auth records with &quot;Manage&quot; access.</td>`,Y=p(),Z=a("tr"),Z.innerHTML='<td colspan="3" class="txt-hint txt-bold">Other fields</td>',w(c,"class","inline-flex"),w(M,"class","inline-flex")},m(y,C){d(y,e,C),d(y,t,C),d(y,l,C),i(l,u),i(u,c),E.m(c,null),i(c,b),i(c,m),i(l,$),i(l,h),i(l,g),i(l,B),d(y,T,C),d(y,O,C),i(O,R),i(R,M),F.m(M,null),i(M,J),i(M,S),i(O,W),i(O,L),i(O,q),i(O,k),d(y,P,C),d(y,te,C),d(y,K,C),d(y,U,C),d(y,re,C),d(y,X,C),d(y,Y,C),d(y,Z,C)},p(y,C){le!==(le=fe(y,C))&&(E.d(1),E=le(y),E&&(E.c(),E.m(c,b))),x!==(x=G(y,C))&&(F.d(1),F=x(y),F&&(F.c(),F.m(M,J)))},d(y){y&&(r(e),r(t),r(l),r(T),r(O),r(P),r(te),r(K),r(U),r(re),r(X),r(Y),r(Z)),E.d(),F.d()}}}function At(s){let e;return{c(){e=a("span"),e.textContent="Optional",w(e,"class","label label-warning")},m(t,l){d(t,e,l)},d(t){t&&r(e)}}}function Bt(s){let e;return{c(){e=a("span"),e.textContent="Required",w(e,"class","label label-success")},m(t,l){d(t,e,l)},d(t){t&&r(e)}}}function jt(s){let e;return{c(){e=a("span"),e.textContent="Optional",w(e,"class","label label-warning")},m(t,l){d(t,e,l)},d(t){t&&r(e)}}}function Vt(s){let e;return{c(){e=a("span"),e.textContent="Required",w(e,"class","label label-success")},m(t,l){d(t,e,l)},d(t){t&&r(e)}}}function Dt(s){let e;return{c(){e=a("span"),e.textContent="Required",w(e,"class","label label-success")},m(t,l){d(t,e,l)},d(t){t&&r(e)}}}function Nt(s){let e;return{c(){e=a("span"),e.textContent="Optional",w(e,"class","label label-warning")},m(t,l){d(t,e,l)},d(t){t&&r(e)}}}function Jt(s){let e,t=s[15].maxSelect===1?"id":"ids",l,u;return{c(){e=_("Relation record "),l=_(t),u=_(".")},m(c,f){d(c,e,f),d(c,l,f),d(c,u,f)},p(c,f){f&64&&t!==(t=c[15].maxSelect===1?"id":"ids")&&oe(l,t)},d(c){c&&(r(e),r(l),r(u))}}}function Et(s){let e,t,l,u,c,f,b,m,$;return{c(){e=_("File object."),t=a("br"),l=_(`
Set to empty value (`),u=a("code"),u.textContent="null",c=_(", "),f=a("code"),f.textContent='""',b=_(" or "),m=a("code"),m.textContent="[]",$=_(`) to delete
@@ -1,4 +1,4 @@
import{S as Re,i as Ee,s as Pe,V as Te,X as j,h as p,d as De,t as te,a as le,I as ee,Z as he,_ as Be,C as Ie,$ as Oe,D as Ae,l as f,n as i,m as Ce,u as c,A as $,v as k,c as we,w as m,J as Me,p as qe,k as z,o as Le,W as Se}from"./index-sszpfGrR.js";function ke(a,l,s){const n=a.slice();return n[6]=l[s],n}function ge(a,l,s){const n=a.slice();return n[6]=l[s],n}function ve(a){let l;return{c(){l=c("p"),l.innerHTML="Requires superuser <code>Authorization:TOKEN</code> header",m(l,"class","txt-hint txt-sm txt-right")},m(s,n){f(s,l,n)},d(s){s&&p(l)}}}function $e(a,l){let s,n,h;function r(){return l[5](l[6])}return{key:a,first:null,c(){s=c("button"),s.textContent=`${l[6].code} `,m(s,"class","tab-item"),z(s,"active",l[2]===l[6].code),this.first=s},m(o,d){f(o,s,d),n||(h=Le(s,"click",r),n=!0)},p(o,d){l=o,d&20&&z(s,"active",l[2]===l[6].code)},d(o){o&&p(s),n=!1,h()}}}function ye(a,l){let s,n,h,r;return n=new Se({props:{content:l[6].body}}),{key:a,first:null,c(){s=c("div"),we(n.$$.fragment),h=k(),m(s,"class","tab-item"),z(s,"active",l[2]===l[6].code),this.first=s},m(o,d){f(o,s,d),Ce(n,s,null),i(s,h),r=!0},p(o,d){l=o,(!r||d&20)&&z(s,"active",l[2]===l[6].code)},i(o){r||(le(n.$$.fragment,o),r=!0)},o(o){te(n.$$.fragment,o),r=!1},d(o){o&&p(s),De(n)}}}function He(a){var fe,me;let l,s,n=a[0].name+"",h,r,o,d,y,D,F,q=a[0].name+"",J,se,K,C,N,P,V,g,L,ae,S,E,ne,W,H=a[0].name+"",X,oe,Z,ie,G,T,Q,B,Y,I,x,w,O,v=[],ce=new Map,re,A,b=[],de=new Map,R;C=new Te({props:{js:`
import{S as Re,i as Ee,s as Pe,V as Te,X as j,h as p,d as De,t as te,a as le,I as ee,Z as he,_ as Be,C as Ie,$ as Oe,D as Ae,l as f,n as i,m as Ce,u as c,A as $,v as k,c as we,w as m,J as Me,p as qe,k as z,o as Le,W as Se}from"./index-DA9lB3Zb.js";function ke(a,l,s){const n=a.slice();return n[6]=l[s],n}function ge(a,l,s){const n=a.slice();return n[6]=l[s],n}function ve(a){let l;return{c(){l=c("p"),l.innerHTML="Requires superuser <code>Authorization:TOKEN</code> header",m(l,"class","txt-hint txt-sm txt-right")},m(s,n){f(s,l,n)},d(s){s&&p(l)}}}function $e(a,l){let s,n,h;function r(){return l[5](l[6])}return{key:a,first:null,c(){s=c("button"),s.textContent=`${l[6].code} `,m(s,"class","tab-item"),z(s,"active",l[2]===l[6].code),this.first=s},m(o,d){f(o,s,d),n||(h=Le(s,"click",r),n=!0)},p(o,d){l=o,d&20&&z(s,"active",l[2]===l[6].code)},d(o){o&&p(s),n=!1,h()}}}function ye(a,l){let s,n,h,r;return n=new Se({props:{content:l[6].body}}),{key:a,first:null,c(){s=c("div"),we(n.$$.fragment),h=k(),m(s,"class","tab-item"),z(s,"active",l[2]===l[6].code),this.first=s},m(o,d){f(o,s,d),Ce(n,s,null),i(s,h),r=!0},p(o,d){l=o,(!r||d&20)&&z(s,"active",l[2]===l[6].code)},i(o){r||(le(n.$$.fragment,o),r=!0)},o(o){te(n.$$.fragment,o),r=!1},d(o){o&&p(s),De(n)}}}function He(a){var fe,me;let l,s,n=a[0].name+"",h,r,o,d,y,D,F,q=a[0].name+"",J,se,K,C,N,P,V,g,L,ae,S,E,ne,W,H=a[0].name+"",X,oe,Z,ie,G,T,Q,B,Y,I,x,w,O,v=[],ce=new Map,re,A,b=[],de=new Map,R;C=new Te({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${a[3]}');
@@ -1,4 +1,4 @@
import{S as se,i as oe,s as ie,X as K,h as g,t as X,a as V,I as F,Z as le,_ as Re,C as ne,$ as Se,D as ae,l as v,n as u,u as p,v as y,A as U,w as b,k as Y,o as ce,W as Oe,d as x,m as ee,c as te,V as Me,Y as _e,J as Be,p as De,a0 as be}from"./index-sszpfGrR.js";function ge(n,e,t){const l=n.slice();return l[4]=e[t],l}function ve(n,e,t){const l=n.slice();return l[4]=e[t],l}function ke(n,e){let t,l=e[4].code+"",d,i,r,a;function m(){return e[3](e[4])}return{key:n,first:null,c(){t=p("button"),d=U(l),i=y(),b(t,"class","tab-item"),Y(t,"active",e[1]===e[4].code),this.first=t},m(k,q){v(k,t,q),u(t,d),u(t,i),r||(a=ce(t,"click",m),r=!0)},p(k,q){e=k,q&4&&l!==(l=e[4].code+"")&&F(d,l),q&6&&Y(t,"active",e[1]===e[4].code)},d(k){k&&g(t),r=!1,a()}}}function $e(n,e){let t,l,d,i;return l=new Oe({props:{content:e[4].body}}),{key:n,first:null,c(){t=p("div"),te(l.$$.fragment),d=y(),b(t,"class","tab-item"),Y(t,"active",e[1]===e[4].code),this.first=t},m(r,a){v(r,t,a),ee(l,t,null),u(t,d),i=!0},p(r,a){e=r;const m={};a&4&&(m.content=e[4].body),l.$set(m),(!i||a&6)&&Y(t,"active",e[1]===e[4].code)},i(r){i||(V(l.$$.fragment,r),i=!0)},o(r){X(l.$$.fragment,r),i=!1},d(r){r&&g(t),x(l)}}}function Ne(n){let e,t,l,d,i,r,a,m=n[0].name+"",k,q,G,H,J,L,z,B,D,S,N,A=[],O=new Map,P,j,T=[],W=new Map,w,E=K(n[2]);const M=c=>c[4].code;for(let c=0;c<E.length;c+=1){let f=ve(n,E,c),s=M(f);O.set(s,A[c]=ke(s,f))}let _=K(n[2]);const Z=c=>c[4].code;for(let c=0;c<_.length;c+=1){let f=ge(n,_,c),s=Z(f);W.set(s,T[c]=$e(s,f))}return{c(){e=p("div"),t=p("strong"),t.textContent="POST",l=y(),d=p("div"),i=p("p"),r=U("/api/collections/"),a=p("strong"),k=U(m),q=U("/confirm-email-change"),G=y(),H=p("div"),H.textContent="Body Parameters",J=y(),L=p("table"),L.innerHTML='<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>token</span></div></td> <td><span class="label">String</span></td> <td>The token from the change email request email.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>password</span></div></td> <td><span class="label">String</span></td> <td>The account password to confirm the email change.</td></tr></tbody>',z=y(),B=p("div"),B.textContent="Responses",D=y(),S=p("div"),N=p("div");for(let c=0;c<A.length;c+=1)A[c].c();P=y(),j=p("div");for(let c=0;c<T.length;c+=1)T[c].c();b(t,"class","label label-primary"),b(d,"class","content"),b(e,"class","alert alert-success"),b(H,"class","section-title"),b(L,"class","table-compact table-border m-b-base"),b(B,"class","section-title"),b(N,"class","tabs-header compact combined left"),b(j,"class","tabs-content"),b(S,"class","tabs")},m(c,f){v(c,e,f),u(e,t),u(e,l),u(e,d),u(d,i),u(i,r),u(i,a),u(a,k),u(i,q),v(c,G,f),v(c,H,f),v(c,J,f),v(c,L,f),v(c,z,f),v(c,B,f),v(c,D,f),v(c,S,f),u(S,N);for(let s=0;s<A.length;s+=1)A[s]&&A[s].m(N,null);u(S,P),u(S,j);for(let s=0;s<T.length;s+=1)T[s]&&T[s].m(j,null);w=!0},p(c,[f]){(!w||f&1)&&m!==(m=c[0].name+"")&&F(k,m),f&6&&(E=K(c[2]),A=le(A,f,M,1,c,E,O,N,Re,ke,null,ve)),f&6&&(_=K(c[2]),ne(),T=le(T,f,Z,1,c,_,W,j,Se,$e,null,ge),ae())},i(c){if(!w){for(let f=0;f<_.length;f+=1)V(T[f]);w=!0}},o(c){for(let f=0;f<T.length;f+=1)X(T[f]);w=!1},d(c){c&&(g(e),g(G),g(H),g(J),g(L),g(z),g(B),g(D),g(S));for(let f=0;f<A.length;f+=1)A[f].d();for(let f=0;f<T.length;f+=1)T[f].d()}}}function We(n,e,t){let{collection:l}=e,d=204,i=[];const r=a=>t(1,d=a.code);return n.$$set=a=>{"collection"in a&&t(0,l=a.collection)},t(2,i=[{code:204,body:"null"},{code:400,body:`
import{S as se,i as oe,s as ie,X as K,h as g,t as X,a as V,I as F,Z as le,_ as Re,C as ne,$ as Se,D as ae,l as v,n as u,u as p,v as y,A as U,w as b,k as Y,o as ce,W as Oe,d as x,m as ee,c as te,V as Me,Y as _e,J as Be,p as De,a0 as be}from"./index-DA9lB3Zb.js";function ge(n,e,t){const l=n.slice();return l[4]=e[t],l}function ve(n,e,t){const l=n.slice();return l[4]=e[t],l}function ke(n,e){let t,l=e[4].code+"",d,i,r,a;function m(){return e[3](e[4])}return{key:n,first:null,c(){t=p("button"),d=U(l),i=y(),b(t,"class","tab-item"),Y(t,"active",e[1]===e[4].code),this.first=t},m(k,q){v(k,t,q),u(t,d),u(t,i),r||(a=ce(t,"click",m),r=!0)},p(k,q){e=k,q&4&&l!==(l=e[4].code+"")&&F(d,l),q&6&&Y(t,"active",e[1]===e[4].code)},d(k){k&&g(t),r=!1,a()}}}function $e(n,e){let t,l,d,i;return l=new Oe({props:{content:e[4].body}}),{key:n,first:null,c(){t=p("div"),te(l.$$.fragment),d=y(),b(t,"class","tab-item"),Y(t,"active",e[1]===e[4].code),this.first=t},m(r,a){v(r,t,a),ee(l,t,null),u(t,d),i=!0},p(r,a){e=r;const m={};a&4&&(m.content=e[4].body),l.$set(m),(!i||a&6)&&Y(t,"active",e[1]===e[4].code)},i(r){i||(V(l.$$.fragment,r),i=!0)},o(r){X(l.$$.fragment,r),i=!1},d(r){r&&g(t),x(l)}}}function Ne(n){let e,t,l,d,i,r,a,m=n[0].name+"",k,q,G,H,J,L,z,B,D,S,N,A=[],O=new Map,P,j,T=[],W=new Map,w,E=K(n[2]);const M=c=>c[4].code;for(let c=0;c<E.length;c+=1){let f=ve(n,E,c),s=M(f);O.set(s,A[c]=ke(s,f))}let _=K(n[2]);const Z=c=>c[4].code;for(let c=0;c<_.length;c+=1){let f=ge(n,_,c),s=Z(f);W.set(s,T[c]=$e(s,f))}return{c(){e=p("div"),t=p("strong"),t.textContent="POST",l=y(),d=p("div"),i=p("p"),r=U("/api/collections/"),a=p("strong"),k=U(m),q=U("/confirm-email-change"),G=y(),H=p("div"),H.textContent="Body Parameters",J=y(),L=p("table"),L.innerHTML='<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>token</span></div></td> <td><span class="label">String</span></td> <td>The token from the change email request email.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>password</span></div></td> <td><span class="label">String</span></td> <td>The account password to confirm the email change.</td></tr></tbody>',z=y(),B=p("div"),B.textContent="Responses",D=y(),S=p("div"),N=p("div");for(let c=0;c<A.length;c+=1)A[c].c();P=y(),j=p("div");for(let c=0;c<T.length;c+=1)T[c].c();b(t,"class","label label-primary"),b(d,"class","content"),b(e,"class","alert alert-success"),b(H,"class","section-title"),b(L,"class","table-compact table-border m-b-base"),b(B,"class","section-title"),b(N,"class","tabs-header compact combined left"),b(j,"class","tabs-content"),b(S,"class","tabs")},m(c,f){v(c,e,f),u(e,t),u(e,l),u(e,d),u(d,i),u(i,r),u(i,a),u(a,k),u(i,q),v(c,G,f),v(c,H,f),v(c,J,f),v(c,L,f),v(c,z,f),v(c,B,f),v(c,D,f),v(c,S,f),u(S,N);for(let s=0;s<A.length;s+=1)A[s]&&A[s].m(N,null);u(S,P),u(S,j);for(let s=0;s<T.length;s+=1)T[s]&&T[s].m(j,null);w=!0},p(c,[f]){(!w||f&1)&&m!==(m=c[0].name+"")&&F(k,m),f&6&&(E=K(c[2]),A=le(A,f,M,1,c,E,O,N,Re,ke,null,ve)),f&6&&(_=K(c[2]),ne(),T=le(T,f,Z,1,c,_,W,j,Se,$e,null,ge),ae())},i(c){if(!w){for(let f=0;f<_.length;f+=1)V(T[f]);w=!0}},o(c){for(let f=0;f<T.length;f+=1)X(T[f]);w=!1},d(c){c&&(g(e),g(G),g(H),g(J),g(L),g(z),g(B),g(D),g(S));for(let f=0;f<A.length;f+=1)A[f].d();for(let f=0;f<T.length;f+=1)T[f].d()}}}function We(n,e,t){let{collection:l}=e,d=204,i=[];const r=a=>t(1,d=a.code);return n.$$set=a=>{"collection"in a&&t(0,l=a.collection)},t(2,i=[{code:204,body:"null"},{code:400,body:`
{
"status": 400,
"message": "An error occurred while validating the submitted data.",
@@ -1,4 +1,4 @@
import{S as J,i as N,s as O,W as P,h as Q,d as R,t as W,a as j,I as z,l as D,n as e,m as G,u as t,v as c,A as i,c as K,w as U}from"./index-sszpfGrR.js";function V(f){let n,o,u,d,k,s,p,w,g,y,r,F,_,S,b,E,C,a,$,L,q,H,I,M,m,T,v,A,x;return r=new P({props:{content:"?fields=*,"+f[0]+"expand.relField.name"}}),{c(){n=t("tr"),o=t("td"),o.textContent="fields",u=c(),d=t("td"),d.innerHTML='<span class="label">String</span>',k=c(),s=t("td"),p=t("p"),w=i(`Comma separated string of the fields to return in the JSON response
import{S as J,i as N,s as O,W as P,h as Q,d as R,t as W,a as j,I as z,l as D,n as e,m as G,u as t,v as c,A as i,c as K,w as U}from"./index-DA9lB3Zb.js";function V(f){let n,o,u,d,k,s,p,w,g,y,r,F,_,S,b,E,C,a,$,L,q,H,I,M,m,T,v,A,x;return r=new P({props:{content:"?fields=*,"+f[0]+"expand.relField.name"}}),{c(){n=t("tr"),o=t("td"),o.textContent="fields",u=c(),d=t("td"),d.innerHTML='<span class="label">String</span>',k=c(),s=t("td"),p=t("p"),w=i(`Comma separated string of the fields to return in the JSON response
`),g=t("em"),g.textContent="(by default returns all fields)",y=i(`. Ex.:
`),K(r.$$.fragment),F=c(),_=t("p"),_.innerHTML="<code>*</code> targets all keys from the specific depth level.",S=c(),b=t("p"),b.textContent="In addition, the following field modifiers are also supported:",E=c(),C=t("ul"),a=t("li"),$=t("code"),$.textContent=":excerpt(maxLength, withEllipsis?)",L=c(),q=t("br"),H=i(`
Returns a short plain text version of the field string value.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{S as el,i as ll,s as sl,H as ze,h as m,l as h,o as nl,u as e,v as s,L as ol,w as a,n as t,A as g,V as al,W as Le,X as ae,d as Kt,Y as il,t as Ct,a as kt,I as ve,Z as Je,_ as rl,C as cl,$ as dl,D as pl,m as Qt,c as Vt,J as Te,p as fl,k as Ae}from"./index-sszpfGrR.js";import{F as ul}from"./FieldsQueryParam-Cs6hQAnX.js";function ml(r){let n,o,i;return{c(){n=e("span"),n.textContent="Show details",o=s(),i=e("i"),a(n,"class","txt"),a(i,"class","ri-arrow-down-s-line")},m(f,b){h(f,n,b),h(f,o,b),h(f,i,b)},d(f){f&&(m(n),m(o),m(i))}}}function hl(r){let n,o,i;return{c(){n=e("span"),n.textContent="Hide details",o=s(),i=e("i"),a(n,"class","txt"),a(i,"class","ri-arrow-up-s-line")},m(f,b){h(f,n,b),h(f,o,b),h(f,i,b)},d(f){f&&(m(n),m(o),m(i))}}}function Ke(r){let n,o,i,f,b,p,u,C,_,x,d,Y,yt,Wt,E,Xt,D,it,P,Z,ie,j,U,re,rt,vt,tt,Ft,ce,ct,dt,et,N,Yt,Lt,k,lt,At,Zt,Tt,z,st,Pt,te,Rt,v,pt,Ot,de,ft,pe,H,St,nt,Et,F,ut,fe,J,Nt,ee,qt,le,Dt,ue,L,mt,me,ht,he,M,be,T,Ht,ot,Mt,K,bt,ge,I,It,y,Bt,at,Gt,_e,Q,gt,we,_t,xe,jt,$e,B,Ut,Ce,G,ke,wt,se,R,xt,V,W,O,zt,ne,X;return{c(){n=e("p"),n.innerHTML=`The syntax basically follows the format
import{S as el,i as ll,s as sl,H as ze,h as m,l as h,o as nl,u as e,v as s,L as ol,w as a,n as t,A as g,V as al,W as Le,X as ae,d as Kt,Y as il,t as Ct,a as kt,I as ve,Z as Je,_ as rl,C as cl,$ as dl,D as pl,m as Qt,c as Vt,J as Te,p as fl,k as Ae}from"./index-DA9lB3Zb.js";import{F as ul}from"./FieldsQueryParam-NplIJIb9.js";function ml(r){let n,o,i;return{c(){n=e("span"),n.textContent="Show details",o=s(),i=e("i"),a(n,"class","txt"),a(i,"class","ri-arrow-down-s-line")},m(f,b){h(f,n,b),h(f,o,b),h(f,i,b)},d(f){f&&(m(n),m(o),m(i))}}}function hl(r){let n,o,i;return{c(){n=e("span"),n.textContent="Hide details",o=s(),i=e("i"),a(n,"class","txt"),a(i,"class","ri-arrow-up-s-line")},m(f,b){h(f,n,b),h(f,o,b),h(f,i,b)},d(f){f&&(m(n),m(o),m(i))}}}function Ke(r){let n,o,i,f,b,p,u,C,_,x,d,Y,yt,Wt,E,Xt,D,it,P,Z,ie,j,U,re,rt,vt,tt,Ft,ce,ct,dt,et,N,Yt,Lt,k,lt,At,Zt,Tt,z,st,Pt,te,Rt,v,pt,Ot,de,ft,pe,H,St,nt,Et,F,ut,fe,J,Nt,ee,qt,le,Dt,ue,L,mt,me,ht,he,M,be,T,Ht,ot,Mt,K,bt,ge,I,It,y,Bt,at,Gt,_e,Q,gt,we,_t,xe,jt,$e,B,Ut,Ce,G,ke,wt,se,R,xt,V,W,O,zt,ne,X;return{c(){n=e("p"),n.innerHTML=`The syntax basically follows the format
<code><span class="txt-success">OPERAND</span> <span class="txt-danger">OPERATOR</span> <span class="txt-success">OPERAND</span></code>, where:`,o=s(),i=e("ul"),f=e("li"),f.innerHTML=`<code class="txt-success">OPERAND</code> - could be any of the above field literal, string (single
or double quoted), number, null, true, false`,b=s(),p=e("li"),u=e("code"),u.textContent="OPERATOR",C=g(` - is one of:
`),_=e("br"),x=s(),d=e("ul"),Y=e("li"),yt=e("code"),yt.textContent="=",Wt=s(),E=e("span"),E.textContent="Equal",Xt=s(),D=e("li"),it=e("code"),it.textContent="!=",P=s(),Z=e("span"),Z.textContent="NOT equal",ie=s(),j=e("li"),U=e("code"),U.textContent=">",re=s(),rt=e("span"),rt.textContent="Greater than",vt=s(),tt=e("li"),Ft=e("code"),Ft.textContent=">=",ce=s(),ct=e("span"),ct.textContent="Greater than or equal",dt=s(),et=e("li"),N=e("code"),N.textContent="<",Yt=s(),Lt=e("span"),Lt.textContent="Less than",k=s(),lt=e("li"),At=e("code"),At.textContent="<=",Zt=s(),Tt=e("span"),Tt.textContent="Less than or equal",z=s(),st=e("li"),Pt=e("code"),Pt.textContent="~",te=s(),Rt=e("span"),Rt.textContent=`Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{S as r,i as c,s as l,H as n,h as u,l as h,u as p,w as d,O as f,P as m,Q as g,R as o}from"./index-sszpfGrR.js";function _(s){let t;return{c(){t=p("div"),t.innerHTML='<h3 class="m-b-sm">Auth failed.</h3> <h5>You can close this window and go back to the app to try again.</h5>',d(t,"class","content txt-hint txt-center p-base")},m(e,a){h(e,t,a)},p:n,i:n,o:n,d(e){e&&u(t)}}}function b(s,t,e){let a;return f(s,o,i=>e(0,a=i)),m(o,a="OAuth2 auth failed",a),g(()=>{window.close()}),[]}class x extends r{constructor(t){super(),c(this,t,b,_,l,{})}}export{x as default};
import{S as r,i as c,s as l,H as n,h as u,l as h,u as p,w as d,O as f,P as m,Q as g,R as o}from"./index-DA9lB3Zb.js";function _(s){let t;return{c(){t=p("div"),t.innerHTML='<h3 class="m-b-sm">Auth failed.</h3> <h5>You can close this window and go back to the app to try again.</h5>',d(t,"class","content txt-hint txt-center p-base")},m(e,a){h(e,t,a)},p:n,i:n,o:n,d(e){e&&u(t)}}}function b(s,t,e){let a;return f(s,o,i=>e(0,a=i)),m(o,a="OAuth2 auth failed",a),g(()=>{window.close()}),[]}class x extends r{constructor(t){super(),c(this,t,b,_,l,{})}}export{x as default};
@@ -1 +1 @@
import{S as i,i as r,s as u,H as n,h as l,l as p,u as h,w as d,O as m,P as f,Q as _,R as o}from"./index-sszpfGrR.js";function b(a){let t;return{c(){t=h("div"),t.innerHTML='<h3 class="m-b-sm">Auth completed.</h3> <h5>You can close this window and go back to the app.</h5>',d(t,"class","content txt-hint txt-center p-base")},m(e,s){p(e,t,s)},p:n,i:n,o:n,d(e){e&&l(t)}}}function g(a,t,e){let s;return m(a,o,c=>e(0,s=c)),f(o,s="OAuth2 auth completed",s),_(()=>{window.close()}),[]}class x extends i{constructor(t){super(),r(this,t,g,b,u,{})}}export{x as default};
import{S as i,i as r,s as u,H as n,h as l,l as p,u as h,w as d,O as m,P as f,Q as _,R as o}from"./index-DA9lB3Zb.js";function b(a){let t;return{c(){t=h("div"),t.innerHTML='<h3 class="m-b-sm">Auth completed.</h3> <h5>You can close this window and go back to the app.</h5>',d(t,"class","content txt-hint txt-center p-base")},m(e,s){p(e,t,s)},p:n,i:n,o:n,d(e){e&&l(t)}}}function g(a,t,e){let s;return m(a,o,c=>e(0,s=c)),f(o,s="OAuth2 auth completed",s),_(()=>{window.close()}),[]}class x extends i{constructor(t){super(),r(this,t,g,b,u,{})}}export{x as default};
@@ -1,2 +1,2 @@
import{S as J,i as M,s as z,F as A,d as L,t as h,a as v,m as S,c as I,J as D,h as _,D as N,l as b,L as R,M as W,g as Y,p as j,H as P,o as q,u as m,v as y,w as p,f as B,k as T,n as g,q as G,A as C,I as K,z as F,C as O}from"./index-sszpfGrR.js";function Q(i){let e,t,n,l,s,o,f,a,r,u,k,$,d=i[3]&&H(i);return o=new B({props:{class:"form-field required",name:"password",$$slots:{default:[V,({uniqueId:c})=>({8:c}),({uniqueId:c})=>c?256:0]},$$scope:{ctx:i}}}),{c(){e=m("form"),t=m("div"),n=m("h5"),l=C(`Type your password to confirm changing your email address
import{S as J,i as M,s as z,F as A,d as L,t as h,a as v,m as S,c as I,J as D,h as _,D as N,l as b,L as R,M as W,g as Y,p as j,H as P,o as q,u as m,v as y,w as p,f as B,k as T,n as g,q as G,A as C,I as K,z as F,C as O}from"./index-DA9lB3Zb.js";function Q(i){let e,t,n,l,s,o,f,a,r,u,k,$,d=i[3]&&H(i);return o=new B({props:{class:"form-field required",name:"password",$$slots:{default:[V,({uniqueId:c})=>({8:c}),({uniqueId:c})=>c?256:0]},$$scope:{ctx:i}}}),{c(){e=m("form"),t=m("div"),n=m("h5"),l=C(`Type your password to confirm changing your email address
`),d&&d.c(),s=y(),I(o.$$.fragment),f=y(),a=m("button"),r=m("span"),r.textContent="Confirm new email",p(t,"class","content txt-center m-b-base"),p(r,"class","txt"),p(a,"type","submit"),p(a,"class","btn btn-lg btn-block"),a.disabled=i[1],T(a,"btn-loading",i[1])},m(c,w){b(c,e,w),g(e,t),g(t,n),g(n,l),d&&d.m(n,null),g(e,s),S(o,e,null),g(e,f),g(e,a),g(a,r),u=!0,k||($=q(e,"submit",G(i[4])),k=!0)},p(c,w){c[3]?d?d.p(c,w):(d=H(c),d.c(),d.m(n,null)):d&&(d.d(1),d=null);const E={};w&769&&(E.$$scope={dirty:w,ctx:c}),o.$set(E),(!u||w&2)&&(a.disabled=c[1]),(!u||w&2)&&T(a,"btn-loading",c[1])},i(c){u||(v(o.$$.fragment,c),u=!0)},o(c){h(o.$$.fragment,c),u=!1},d(c){c&&_(e),d&&d.d(),L(o),k=!1,$()}}}function U(i){let e,t,n,l,s;return{c(){e=m("div"),e.innerHTML='<div class="icon"><i class="ri-checkbox-circle-line"></i></div> <div class="content txt-bold"><p>Successfully changed the user email address.</p> <p>You can now sign in with your new email address.</p></div>',t=y(),n=m("button"),n.textContent="Close",p(e,"class","alert alert-success"),p(n,"type","button"),p(n,"class","btn btn-transparent btn-block")},m(o,f){b(o,e,f),b(o,t,f),b(o,n,f),l||(s=q(n,"click",i[6]),l=!0)},p:P,i:P,o:P,d(o){o&&(_(e),_(t),_(n)),l=!1,s()}}}function H(i){let e,t,n;return{c(){e=C("to "),t=m("strong"),n=C(i[3]),p(t,"class","txt-nowrap")},m(l,s){b(l,e,s),b(l,t,s),g(t,n)},p(l,s){s&8&&K(n,l[3])},d(l){l&&(_(e),_(t))}}}function V(i){let e,t,n,l,s,o,f,a;return{c(){e=m("label"),t=C("Password"),l=y(),s=m("input"),p(e,"for",n=i[8]),p(s,"type","password"),p(s,"id",o=i[8]),s.required=!0,s.autofocus=!0},m(r,u){b(r,e,u),g(e,t),b(r,l,u),b(r,s,u),F(s,i[0]),s.focus(),f||(a=q(s,"input",i[7]),f=!0)},p(r,u){u&256&&n!==(n=r[8])&&p(e,"for",n),u&256&&o!==(o=r[8])&&p(s,"id",o),u&1&&s.value!==r[0]&&F(s,r[0])},d(r){r&&(_(e),_(l),_(s)),f=!1,a()}}}function X(i){let e,t,n,l;const s=[U,Q],o=[];function f(a,r){return a[2]?0:1}return e=f(i),t=o[e]=s[e](i),{c(){t.c(),n=R()},m(a,r){o[e].m(a,r),b(a,n,r),l=!0},p(a,r){let u=e;e=f(a),e===u?o[e].p(a,r):(O(),h(o[u],1,1,()=>{o[u]=null}),N(),t=o[e],t?t.p(a,r):(t=o[e]=s[e](a),t.c()),v(t,1),t.m(n.parentNode,n))},i(a){l||(v(t),l=!0)},o(a){h(t),l=!1},d(a){a&&_(n),o[e].d(a)}}}function Z(i){let e,t;return e=new A({props:{nobranding:!0,$$slots:{default:[X]},$$scope:{ctx:i}}}),{c(){I(e.$$.fragment)},m(n,l){S(e,n,l),t=!0},p(n,[l]){const s={};l&527&&(s.$$scope={dirty:l,ctx:n}),e.$set(s)},i(n){t||(v(e.$$.fragment,n),t=!0)},o(n){h(e.$$.fragment,n),t=!1},d(n){L(e,n)}}}function x(i,e,t){let n,{params:l}=e,s="",o=!1,f=!1;async function a(){if(o)return;t(1,o=!0);const k=new W("../");try{const $=Y(l==null?void 0:l.token);await k.collection($.collectionId).confirmEmailChange(l==null?void 0:l.token,s),t(2,f=!0)}catch($){j.error($)}t(1,o=!1)}const r=()=>window.close();function u(){s=this.value,t(0,s)}return i.$$set=k=>{"params"in k&&t(5,l=k.params)},i.$$.update=()=>{i.$$.dirty&32&&t(3,n=D.getJWTPayload(l==null?void 0:l.token).newEmail||"")},[s,o,f,n,a,l,r,u]}class te extends J{constructor(e){super(),M(this,e,x,Z,z,{params:5})}}export{te as default};
@@ -1,2 +1,2 @@
import{S as A,i as D,s as W,F as Y,d as H,t as P,a as q,m as L,c as N,J as j,h as _,C as B,D as E,l as m,L as G,M as K,g as O,p as Q,H as F,o as S,u as b,v as C,w as p,f as J,k as M,n as w,q as U,A as y,I as V,z as R}from"./index-sszpfGrR.js";function X(a){let e,l,s,n,t,o,c,r,i,u,v,g,k,h,d=a[4]&&z(a);return o=new J({props:{class:"form-field required",name:"password",$$slots:{default:[x,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:a}}}),r=new J({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[ee,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:a}}}),{c(){e=b("form"),l=b("div"),s=b("h5"),n=y(`Reset your user password
import{S as A,i as D,s as W,F as Y,d as H,t as P,a as q,m as L,c as N,J as j,h as _,C as B,D as E,l as m,L as G,M as K,g as O,p as Q,H as F,o as S,u as b,v as C,w as p,f as J,k as M,n as w,q as U,A as y,I as V,z as R}from"./index-DA9lB3Zb.js";function X(a){let e,l,s,n,t,o,c,r,i,u,v,g,k,h,d=a[4]&&z(a);return o=new J({props:{class:"form-field required",name:"password",$$slots:{default:[x,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:a}}}),r=new J({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[ee,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:a}}}),{c(){e=b("form"),l=b("div"),s=b("h5"),n=y(`Reset your user password
`),d&&d.c(),t=C(),N(o.$$.fragment),c=C(),N(r.$$.fragment),i=C(),u=b("button"),v=b("span"),v.textContent="Set new password",p(l,"class","content txt-center m-b-base"),p(v,"class","txt"),p(u,"type","submit"),p(u,"class","btn btn-lg btn-block"),u.disabled=a[2],M(u,"btn-loading",a[2])},m(f,$){m(f,e,$),w(e,l),w(l,s),w(s,n),d&&d.m(s,null),w(e,t),L(o,e,null),w(e,c),L(r,e,null),w(e,i),w(e,u),w(u,v),g=!0,k||(h=S(e,"submit",U(a[5])),k=!0)},p(f,$){f[4]?d?d.p(f,$):(d=z(f),d.c(),d.m(s,null)):d&&(d.d(1),d=null);const T={};$&3073&&(T.$$scope={dirty:$,ctx:f}),o.$set(T);const I={};$&3074&&(I.$$scope={dirty:$,ctx:f}),r.$set(I),(!g||$&4)&&(u.disabled=f[2]),(!g||$&4)&&M(u,"btn-loading",f[2])},i(f){g||(q(o.$$.fragment,f),q(r.$$.fragment,f),g=!0)},o(f){P(o.$$.fragment,f),P(r.$$.fragment,f),g=!1},d(f){f&&_(e),d&&d.d(),H(o),H(r),k=!1,h()}}}function Z(a){let e,l,s,n,t;return{c(){e=b("div"),e.innerHTML='<div class="icon"><i class="ri-checkbox-circle-line"></i></div> <div class="content txt-bold"><p>Successfully changed the user password.</p> <p>You can now sign in with your new password.</p></div>',l=C(),s=b("button"),s.textContent="Close",p(e,"class","alert alert-success"),p(s,"type","button"),p(s,"class","btn btn-transparent btn-block")},m(o,c){m(o,e,c),m(o,l,c),m(o,s,c),n||(t=S(s,"click",a[7]),n=!0)},p:F,i:F,o:F,d(o){o&&(_(e),_(l),_(s)),n=!1,t()}}}function z(a){let e,l,s;return{c(){e=y("for "),l=b("strong"),s=y(a[4])},m(n,t){m(n,e,t),m(n,l,t),w(l,s)},p(n,t){t&16&&V(s,n[4])},d(n){n&&(_(e),_(l))}}}function x(a){let e,l,s,n,t,o,c,r;return{c(){e=b("label"),l=y("New password"),n=C(),t=b("input"),p(e,"for",s=a[10]),p(t,"type","password"),p(t,"id",o=a[10]),t.required=!0,t.autofocus=!0},m(i,u){m(i,e,u),w(e,l),m(i,n,u),m(i,t,u),R(t,a[0]),t.focus(),c||(r=S(t,"input",a[8]),c=!0)},p(i,u){u&1024&&s!==(s=i[10])&&p(e,"for",s),u&1024&&o!==(o=i[10])&&p(t,"id",o),u&1&&t.value!==i[0]&&R(t,i[0])},d(i){i&&(_(e),_(n),_(t)),c=!1,r()}}}function ee(a){let e,l,s,n,t,o,c,r;return{c(){e=b("label"),l=y("New password confirm"),n=C(),t=b("input"),p(e,"for",s=a[10]),p(t,"type","password"),p(t,"id",o=a[10]),t.required=!0},m(i,u){m(i,e,u),w(e,l),m(i,n,u),m(i,t,u),R(t,a[1]),c||(r=S(t,"input",a[9]),c=!0)},p(i,u){u&1024&&s!==(s=i[10])&&p(e,"for",s),u&1024&&o!==(o=i[10])&&p(t,"id",o),u&2&&t.value!==i[1]&&R(t,i[1])},d(i){i&&(_(e),_(n),_(t)),c=!1,r()}}}function te(a){let e,l,s,n;const t=[Z,X],o=[];function c(r,i){return r[3]?0:1}return e=c(a),l=o[e]=t[e](a),{c(){l.c(),s=G()},m(r,i){o[e].m(r,i),m(r,s,i),n=!0},p(r,i){let u=e;e=c(r),e===u?o[e].p(r,i):(B(),P(o[u],1,1,()=>{o[u]=null}),E(),l=o[e],l?l.p(r,i):(l=o[e]=t[e](r),l.c()),q(l,1),l.m(s.parentNode,s))},i(r){n||(q(l),n=!0)},o(r){P(l),n=!1},d(r){r&&_(s),o[e].d(r)}}}function se(a){let e,l;return e=new Y({props:{nobranding:!0,$$slots:{default:[te]},$$scope:{ctx:a}}}),{c(){N(e.$$.fragment)},m(s,n){L(e,s,n),l=!0},p(s,[n]){const t={};n&2079&&(t.$$scope={dirty:n,ctx:s}),e.$set(t)},i(s){l||(q(e.$$.fragment,s),l=!0)},o(s){P(e.$$.fragment,s),l=!1},d(s){H(e,s)}}}function le(a,e,l){let s,{params:n}=e,t="",o="",c=!1,r=!1;async function i(){if(c)return;l(2,c=!0);const k=new K("../");try{const h=O(n==null?void 0:n.token);await k.collection(h.collectionId).confirmPasswordReset(n==null?void 0:n.token,t,o),l(3,r=!0)}catch(h){Q.error(h)}l(2,c=!1)}const u=()=>window.close();function v(){t=this.value,l(0,t)}function g(){o=this.value,l(1,o)}return a.$$set=k=>{"params"in k&&l(6,n=k.params)},a.$$.update=()=>{a.$$.dirty&64&&l(4,s=j.getJWTPayload(n==null?void 0:n.token).email||"")},[t,o,c,r,s,i,n,u,v,g]}class oe extends A{constructor(e){super(),D(this,e,le,se,W,{params:6})}}export{oe as default};
@@ -1 +1 @@
import{S as M,i as P,s as R,F as I,d as N,t as S,a as V,m as q,c as F,M as w,g as y,N as E,h as r,l as a,L as g,p as j,H as k,u,w as d,o as m,v,k as C,n as z}from"./index-sszpfGrR.js";function A(o){let e,l,n;function t(i,f){return i[4]?K:J}let s=t(o),c=s(o);return{c(){e=u("div"),e.innerHTML='<div class="icon"><i class="ri-error-warning-line"></i></div> <div class="content txt-bold"><p>Invalid or expired verification token.</p></div>',l=v(),c.c(),n=g(),d(e,"class","alert alert-danger")},m(i,f){a(i,e,f),a(i,l,f),c.m(i,f),a(i,n,f)},p(i,f){s===(s=t(i))&&c?c.p(i,f):(c.d(1),c=s(i),c&&(c.c(),c.m(n.parentNode,n)))},d(i){i&&(r(e),r(l),r(n)),c.d(i)}}}function B(o){let e,l,n,t,s;return{c(){e=u("div"),e.innerHTML='<div class="icon"><i class="ri-checkbox-circle-line"></i></div> <div class="content txt-bold"><p>Please check your email for the new verification link.</p></div>',l=v(),n=u("button"),n.textContent="Close",d(e,"class","alert alert-success"),d(n,"type","button"),d(n,"class","btn btn-transparent btn-block")},m(c,i){a(c,e,i),a(c,l,i),a(c,n,i),t||(s=m(n,"click",o[8]),t=!0)},p:k,d(c){c&&(r(e),r(l),r(n)),t=!1,s()}}}function D(o){let e,l,n,t,s;return{c(){e=u("div"),e.innerHTML='<div class="icon"><i class="ri-checkbox-circle-line"></i></div> <div class="content txt-bold"><p>Successfully verified email address.</p></div>',l=v(),n=u("button"),n.textContent="Close",d(e,"class","alert alert-success"),d(n,"type","button"),d(n,"class","btn btn-transparent btn-block")},m(c,i){a(c,e,i),a(c,l,i),a(c,n,i),t||(s=m(n,"click",o[7]),t=!0)},p:k,d(c){c&&(r(e),r(l),r(n)),t=!1,s()}}}function G(o){let e;return{c(){e=u("div"),e.innerHTML='<div class="loader loader-lg"><em>Please wait...</em></div>',d(e,"class","txt-center")},m(l,n){a(l,e,n)},p:k,d(l){l&&r(e)}}}function J(o){let e,l,n;return{c(){e=u("button"),e.textContent="Close",d(e,"type","button"),d(e,"class","btn btn-transparent btn-block")},m(t,s){a(t,e,s),l||(n=m(e,"click",o[9]),l=!0)},p:k,d(t){t&&r(e),l=!1,n()}}}function K(o){let e,l,n,t;return{c(){e=u("button"),l=u("span"),l.textContent="Resend",d(l,"class","txt"),d(e,"type","button"),d(e,"class","btn btn-transparent btn-block"),e.disabled=o[3],C(e,"btn-loading",o[3])},m(s,c){a(s,e,c),z(e,l),n||(t=m(e,"click",o[5]),n=!0)},p(s,c){c&8&&(e.disabled=s[3]),c&8&&C(e,"btn-loading",s[3])},d(s){s&&r(e),n=!1,t()}}}function O(o){let e;function l(s,c){return s[1]?G:s[0]?D:s[2]?B:A}let n=l(o),t=n(o);return{c(){t.c(),e=g()},m(s,c){t.m(s,c),a(s,e,c)},p(s,c){n===(n=l(s))&&t?t.p(s,c):(t.d(1),t=n(s),t&&(t.c(),t.m(e.parentNode,e)))},d(s){s&&r(e),t.d(s)}}}function Q(o){let e,l;return e=new I({props:{nobranding:!0,$$slots:{default:[O]},$$scope:{ctx:o}}}),{c(){F(e.$$.fragment)},m(n,t){q(e,n,t),l=!0},p(n,[t]){const s={};t&2079&&(s.$$scope={dirty:t,ctx:n}),e.$set(s)},i(n){l||(V(e.$$.fragment,n),l=!0)},o(n){S(e.$$.fragment,n),l=!1},d(n){N(e,n)}}}function U(o,e,l){let n,{params:t}=e,s=!1,c=!1,i=!1,f=!1;x();async function x(){if(c)return;l(1,c=!0);const p=new w("../");try{const b=y(t==null?void 0:t.token);await p.collection(b.collectionId).confirmVerification(t==null?void 0:t.token),l(0,s=!0)}catch{l(0,s=!1)}l(1,c=!1)}async function T(){const p=y(t==null?void 0:t.token);if(f||!p.collectionId||!p.email)return;l(3,f=!0);const b=new w("../");try{const _=y(t==null?void 0:t.token);await b.collection(_.collectionId).requestVerification(_.email),l(2,i=!0)}catch(_){j.error(_),l(2,i=!1)}l(3,f=!1)}const h=()=>window.close(),H=()=>window.close(),L=()=>window.close();return o.$$set=p=>{"params"in p&&l(6,t=p.params)},o.$$.update=()=>{o.$$.dirty&64&&l(4,n=(t==null?void 0:t.token)&&E(t.token))},[s,c,i,f,n,T,t,h,H,L]}class X extends M{constructor(e){super(),P(this,e,U,Q,R,{params:6})}}export{X as default};
import{S as M,i as P,s as R,F as I,d as N,t as S,a as V,m as q,c as F,M as w,g as y,N as E,h as r,l as a,L as g,p as j,H as k,u,w as d,o as m,v,k as C,n as z}from"./index-DA9lB3Zb.js";function A(o){let e,l,n;function t(i,f){return i[4]?K:J}let s=t(o),c=s(o);return{c(){e=u("div"),e.innerHTML='<div class="icon"><i class="ri-error-warning-line"></i></div> <div class="content txt-bold"><p>Invalid or expired verification token.</p></div>',l=v(),c.c(),n=g(),d(e,"class","alert alert-danger")},m(i,f){a(i,e,f),a(i,l,f),c.m(i,f),a(i,n,f)},p(i,f){s===(s=t(i))&&c?c.p(i,f):(c.d(1),c=s(i),c&&(c.c(),c.m(n.parentNode,n)))},d(i){i&&(r(e),r(l),r(n)),c.d(i)}}}function B(o){let e,l,n,t,s;return{c(){e=u("div"),e.innerHTML='<div class="icon"><i class="ri-checkbox-circle-line"></i></div> <div class="content txt-bold"><p>Please check your email for the new verification link.</p></div>',l=v(),n=u("button"),n.textContent="Close",d(e,"class","alert alert-success"),d(n,"type","button"),d(n,"class","btn btn-transparent btn-block")},m(c,i){a(c,e,i),a(c,l,i),a(c,n,i),t||(s=m(n,"click",o[8]),t=!0)},p:k,d(c){c&&(r(e),r(l),r(n)),t=!1,s()}}}function D(o){let e,l,n,t,s;return{c(){e=u("div"),e.innerHTML='<div class="icon"><i class="ri-checkbox-circle-line"></i></div> <div class="content txt-bold"><p>Successfully verified email address.</p></div>',l=v(),n=u("button"),n.textContent="Close",d(e,"class","alert alert-success"),d(n,"type","button"),d(n,"class","btn btn-transparent btn-block")},m(c,i){a(c,e,i),a(c,l,i),a(c,n,i),t||(s=m(n,"click",o[7]),t=!0)},p:k,d(c){c&&(r(e),r(l),r(n)),t=!1,s()}}}function G(o){let e;return{c(){e=u("div"),e.innerHTML='<div class="loader loader-lg"><em>Please wait...</em></div>',d(e,"class","txt-center")},m(l,n){a(l,e,n)},p:k,d(l){l&&r(e)}}}function J(o){let e,l,n;return{c(){e=u("button"),e.textContent="Close",d(e,"type","button"),d(e,"class","btn btn-transparent btn-block")},m(t,s){a(t,e,s),l||(n=m(e,"click",o[9]),l=!0)},p:k,d(t){t&&r(e),l=!1,n()}}}function K(o){let e,l,n,t;return{c(){e=u("button"),l=u("span"),l.textContent="Resend",d(l,"class","txt"),d(e,"type","button"),d(e,"class","btn btn-transparent btn-block"),e.disabled=o[3],C(e,"btn-loading",o[3])},m(s,c){a(s,e,c),z(e,l),n||(t=m(e,"click",o[5]),n=!0)},p(s,c){c&8&&(e.disabled=s[3]),c&8&&C(e,"btn-loading",s[3])},d(s){s&&r(e),n=!1,t()}}}function O(o){let e;function l(s,c){return s[1]?G:s[0]?D:s[2]?B:A}let n=l(o),t=n(o);return{c(){t.c(),e=g()},m(s,c){t.m(s,c),a(s,e,c)},p(s,c){n===(n=l(s))&&t?t.p(s,c):(t.d(1),t=n(s),t&&(t.c(),t.m(e.parentNode,e)))},d(s){s&&r(e),t.d(s)}}}function Q(o){let e,l;return e=new I({props:{nobranding:!0,$$slots:{default:[O]},$$scope:{ctx:o}}}),{c(){F(e.$$.fragment)},m(n,t){q(e,n,t),l=!0},p(n,[t]){const s={};t&2079&&(s.$$scope={dirty:t,ctx:n}),e.$set(s)},i(n){l||(V(e.$$.fragment,n),l=!0)},o(n){S(e.$$.fragment,n),l=!1},d(n){N(e,n)}}}function U(o,e,l){let n,{params:t}=e,s=!1,c=!1,i=!1,f=!1;x();async function x(){if(c)return;l(1,c=!0);const p=new w("../");try{const b=y(t==null?void 0:t.token);await p.collection(b.collectionId).confirmVerification(t==null?void 0:t.token),l(0,s=!0)}catch{l(0,s=!1)}l(1,c=!1)}async function T(){const p=y(t==null?void 0:t.token);if(f||!p.collectionId||!p.email)return;l(3,f=!0);const b=new w("../");try{const _=y(t==null?void 0:t.token);await b.collection(_.collectionId).requestVerification(_.email),l(2,i=!0)}catch(_){j.error(_),l(2,i=!1)}l(3,f=!1)}const h=()=>window.close(),H=()=>window.close(),L=()=>window.close();return o.$$set=p=>{"params"in p&&l(6,t=p.params)},o.$$.update=()=>{o.$$.dirty&64&&l(4,n=(t==null?void 0:t.token)&&E(t.token))},[s,c,i,f,n,T,t,h,H,L]}class X extends M{constructor(e){super(),P(this,e,U,Q,R,{params:6})}}export{X as default};
@@ -1,2 +1,2 @@
import{S as L,i as W,s as y,F as D,d as R,t as J,a as N,m as T,c as j,J as M,f as G,h as b,j as O,k as H,l as w,n as c,o as z,E as Q,q as U,G as V,u as _,A as P,v as h,w as f,p as I,K as X,r as Y,I as Z,z as q}from"./index-sszpfGrR.js";function K(r){let e,n,s;return{c(){e=P("for "),n=_("strong"),s=P(r[3]),f(n,"class","txt-nowrap")},m(l,t){w(l,e,t),w(l,n,t),c(n,s)},p(l,t){t&8&&Z(s,l[3])},d(l){l&&(b(e),b(n))}}}function x(r){let e,n,s,l,t,i,p,d;return{c(){e=_("label"),n=P("New password"),l=h(),t=_("input"),f(e,"for",s=r[8]),f(t,"type","password"),f(t,"id",i=r[8]),t.required=!0,t.autofocus=!0},m(u,a){w(u,e,a),c(e,n),w(u,l,a),w(u,t,a),q(t,r[0]),t.focus(),p||(d=z(t,"input",r[6]),p=!0)},p(u,a){a&256&&s!==(s=u[8])&&f(e,"for",s),a&256&&i!==(i=u[8])&&f(t,"id",i),a&1&&t.value!==u[0]&&q(t,u[0])},d(u){u&&(b(e),b(l),b(t)),p=!1,d()}}}function ee(r){let e,n,s,l,t,i,p,d;return{c(){e=_("label"),n=P("New password confirm"),l=h(),t=_("input"),f(e,"for",s=r[8]),f(t,"type","password"),f(t,"id",i=r[8]),t.required=!0},m(u,a){w(u,e,a),c(e,n),w(u,l,a),w(u,t,a),q(t,r[1]),p||(d=z(t,"input",r[7]),p=!0)},p(u,a){a&256&&s!==(s=u[8])&&f(e,"for",s),a&256&&i!==(i=u[8])&&f(t,"id",i),a&2&&t.value!==u[1]&&q(t,u[1])},d(u){u&&(b(e),b(l),b(t)),p=!1,d()}}}function te(r){let e,n,s,l,t,i,p,d,u,a,g,S,C,v,k,F,A,m=r[3]&&K(r);return i=new G({props:{class:"form-field required",name:"password",$$slots:{default:[x,({uniqueId:o})=>({8:o}),({uniqueId:o})=>o?256:0]},$$scope:{ctx:r}}}),d=new G({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[ee,({uniqueId:o})=>({8:o}),({uniqueId:o})=>o?256:0]},$$scope:{ctx:r}}}),{c(){e=_("form"),n=_("div"),s=_("h4"),l=P(`Reset your superuser password
import{S as L,i as W,s as y,F as D,d as R,t as J,a as N,m as T,c as j,J as M,f as G,h as b,j as O,k as H,l as w,n as c,o as z,E as Q,q as U,G as V,u as _,A as P,v as h,w as f,p as I,K as X,r as Y,I as Z,z as q}from"./index-DA9lB3Zb.js";function K(r){let e,n,s;return{c(){e=P("for "),n=_("strong"),s=P(r[3]),f(n,"class","txt-nowrap")},m(l,t){w(l,e,t),w(l,n,t),c(n,s)},p(l,t){t&8&&Z(s,l[3])},d(l){l&&(b(e),b(n))}}}function x(r){let e,n,s,l,t,i,p,d;return{c(){e=_("label"),n=P("New password"),l=h(),t=_("input"),f(e,"for",s=r[8]),f(t,"type","password"),f(t,"id",i=r[8]),t.required=!0,t.autofocus=!0},m(u,a){w(u,e,a),c(e,n),w(u,l,a),w(u,t,a),q(t,r[0]),t.focus(),p||(d=z(t,"input",r[6]),p=!0)},p(u,a){a&256&&s!==(s=u[8])&&f(e,"for",s),a&256&&i!==(i=u[8])&&f(t,"id",i),a&1&&t.value!==u[0]&&q(t,u[0])},d(u){u&&(b(e),b(l),b(t)),p=!1,d()}}}function ee(r){let e,n,s,l,t,i,p,d;return{c(){e=_("label"),n=P("New password confirm"),l=h(),t=_("input"),f(e,"for",s=r[8]),f(t,"type","password"),f(t,"id",i=r[8]),t.required=!0},m(u,a){w(u,e,a),c(e,n),w(u,l,a),w(u,t,a),q(t,r[1]),p||(d=z(t,"input",r[7]),p=!0)},p(u,a){a&256&&s!==(s=u[8])&&f(e,"for",s),a&256&&i!==(i=u[8])&&f(t,"id",i),a&2&&t.value!==u[1]&&q(t,u[1])},d(u){u&&(b(e),b(l),b(t)),p=!1,d()}}}function te(r){let e,n,s,l,t,i,p,d,u,a,g,S,C,v,k,F,A,m=r[3]&&K(r);return i=new G({props:{class:"form-field required",name:"password",$$slots:{default:[x,({uniqueId:o})=>({8:o}),({uniqueId:o})=>o?256:0]},$$scope:{ctx:r}}}),d=new G({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[ee,({uniqueId:o})=>({8:o}),({uniqueId:o})=>o?256:0]},$$scope:{ctx:r}}}),{c(){e=_("form"),n=_("div"),s=_("h4"),l=P(`Reset your superuser password
`),m&&m.c(),t=h(),j(i.$$.fragment),p=h(),j(d.$$.fragment),u=h(),a=_("button"),g=_("span"),g.textContent="Set new password",S=h(),C=_("div"),v=_("a"),v.textContent="Back to login",f(s,"class","m-b-xs"),f(n,"class","content txt-center m-b-sm"),f(g,"class","txt"),f(a,"type","submit"),f(a,"class","btn btn-lg btn-block"),a.disabled=r[2],H(a,"btn-loading",r[2]),f(e,"class","m-b-base"),f(v,"href","/login"),f(v,"class","link-hint"),f(C,"class","content txt-center")},m(o,$){w(o,e,$),c(e,n),c(n,s),c(s,l),m&&m.m(s,null),c(e,t),T(i,e,null),c(e,p),T(d,e,null),c(e,u),c(e,a),c(a,g),w(o,S,$),w(o,C,$),c(C,v),k=!0,F||(A=[z(e,"submit",U(r[4])),Q(V.call(null,v))],F=!0)},p(o,$){o[3]?m?m.p(o,$):(m=K(o),m.c(),m.m(s,null)):m&&(m.d(1),m=null);const B={};$&769&&(B.$$scope={dirty:$,ctx:o}),i.$set(B);const E={};$&770&&(E.$$scope={dirty:$,ctx:o}),d.$set(E),(!k||$&4)&&(a.disabled=o[2]),(!k||$&4)&&H(a,"btn-loading",o[2])},i(o){k||(N(i.$$.fragment,o),N(d.$$.fragment,o),k=!0)},o(o){J(i.$$.fragment,o),J(d.$$.fragment,o),k=!1},d(o){o&&(b(e),b(S),b(C)),m&&m.d(),R(i),R(d),F=!1,O(A)}}}function se(r){let e,n;return e=new D({props:{$$slots:{default:[te]},$$scope:{ctx:r}}}),{c(){j(e.$$.fragment)},m(s,l){T(e,s,l),n=!0},p(s,[l]){const t={};l&527&&(t.$$scope={dirty:l,ctx:s}),e.$set(t)},i(s){n||(N(e.$$.fragment,s),n=!0)},o(s){J(e.$$.fragment,s),n=!1},d(s){R(e,s)}}}function le(r,e,n){let s,{params:l}=e,t="",i="",p=!1;async function d(){if(!p){n(2,p=!0);try{await I.collection("_superusers").confirmPasswordReset(l==null?void 0:l.token,t,i),X("Successfully set a new superuser password."),Y("/")}catch(g){I.error(g)}n(2,p=!1)}}function u(){t=this.value,n(0,t)}function a(){i=this.value,n(1,i)}return r.$$set=g=>{"params"in g&&n(5,l=g.params)},r.$$.update=()=>{r.$$.dirty&32&&n(3,s=M.getJWTPayload(l==null?void 0:l.token).email||"")},[t,i,p,s,d,l,u,a]}class ae extends L{constructor(e){super(),W(this,e,le,se,y,{params:5})}}export{ae as default};
@@ -1 +1 @@
import{S as M,i as T,s as z,F as A,d as E,t as w,a as y,m as H,c as L,h as g,C as B,D,l as k,n as d,E as G,G as I,v,u as m,w as p,p as C,H as F,I as N,A as h,f as j,k as P,o as R,q as J,z as S}from"./index-sszpfGrR.js";function K(u){let e,s,n,l,t,r,c,_,i,a,b,f;return l=new j({props:{class:"form-field required",name:"email",$$slots:{default:[Q,({uniqueId:o})=>({5:o}),({uniqueId:o})=>o?32:0]},$$scope:{ctx:u}}}),{c(){e=m("form"),s=m("div"),s.innerHTML='<h4 class="m-b-xs">Forgotten superuser password</h4> <p>Enter the email associated with your account and we’ll send you a recovery link:</p>',n=v(),L(l.$$.fragment),t=v(),r=m("button"),c=m("i"),_=v(),i=m("span"),i.textContent="Send recovery link",p(s,"class","content txt-center m-b-sm"),p(c,"class","ri-mail-send-line"),p(i,"class","txt"),p(r,"type","submit"),p(r,"class","btn btn-lg btn-block"),r.disabled=u[1],P(r,"btn-loading",u[1]),p(e,"class","m-b-base")},m(o,$){k(o,e,$),d(e,s),d(e,n),H(l,e,null),d(e,t),d(e,r),d(r,c),d(r,_),d(r,i),a=!0,b||(f=R(e,"submit",J(u[3])),b=!0)},p(o,$){const q={};$&97&&(q.$$scope={dirty:$,ctx:o}),l.$set(q),(!a||$&2)&&(r.disabled=o[1]),(!a||$&2)&&P(r,"btn-loading",o[1])},i(o){a||(y(l.$$.fragment,o),a=!0)},o(o){w(l.$$.fragment,o),a=!1},d(o){o&&g(e),E(l),b=!1,f()}}}function O(u){let e,s,n,l,t,r,c,_,i;return{c(){e=m("div"),s=m("div"),s.innerHTML='<i class="ri-checkbox-circle-line"></i>',n=v(),l=m("div"),t=m("p"),r=h("Check "),c=m("strong"),_=h(u[0]),i=h(" for the recovery link."),p(s,"class","icon"),p(c,"class","txt-nowrap"),p(l,"class","content"),p(e,"class","alert alert-success")},m(a,b){k(a,e,b),d(e,s),d(e,n),d(e,l),d(l,t),d(t,r),d(t,c),d(c,_),d(t,i)},p(a,b){b&1&&N(_,a[0])},i:F,o:F,d(a){a&&g(e)}}}function Q(u){let e,s,n,l,t,r,c,_;return{c(){e=m("label"),s=h("Email"),l=v(),t=m("input"),p(e,"for",n=u[5]),p(t,"type","email"),p(t,"id",r=u[5]),t.required=!0,t.autofocus=!0},m(i,a){k(i,e,a),d(e,s),k(i,l,a),k(i,t,a),S(t,u[0]),t.focus(),c||(_=R(t,"input",u[4]),c=!0)},p(i,a){a&32&&n!==(n=i[5])&&p(e,"for",n),a&32&&r!==(r=i[5])&&p(t,"id",r),a&1&&t.value!==i[0]&&S(t,i[0])},d(i){i&&(g(e),g(l),g(t)),c=!1,_()}}}function U(u){let e,s,n,l,t,r,c,_;const i=[O,K],a=[];function b(f,o){return f[2]?0:1}return e=b(u),s=a[e]=i[e](u),{c(){s.c(),n=v(),l=m("div"),t=m("a"),t.textContent="Back to login",p(t,"href","/login"),p(t,"class","link-hint"),p(l,"class","content txt-center")},m(f,o){a[e].m(f,o),k(f,n,o),k(f,l,o),d(l,t),r=!0,c||(_=G(I.call(null,t)),c=!0)},p(f,o){let $=e;e=b(f),e===$?a[e].p(f,o):(B(),w(a[$],1,1,()=>{a[$]=null}),D(),s=a[e],s?s.p(f,o):(s=a[e]=i[e](f),s.c()),y(s,1),s.m(n.parentNode,n))},i(f){r||(y(s),r=!0)},o(f){w(s),r=!1},d(f){f&&(g(n),g(l)),a[e].d(f),c=!1,_()}}}function V(u){let e,s;return e=new A({props:{$$slots:{default:[U]},$$scope:{ctx:u}}}),{c(){L(e.$$.fragment)},m(n,l){H(e,n,l),s=!0},p(n,[l]){const t={};l&71&&(t.$$scope={dirty:l,ctx:n}),e.$set(t)},i(n){s||(y(e.$$.fragment,n),s=!0)},o(n){w(e.$$.fragment,n),s=!1},d(n){E(e,n)}}}function W(u,e,s){let n="",l=!1,t=!1;async function r(){if(!l){s(1,l=!0);try{await C.collection("_superusers").requestPasswordReset(n),s(2,t=!0)}catch(_){C.error(_)}s(1,l=!1)}}function c(){n=this.value,s(0,n)}return[n,l,t,r,c]}class Y extends M{constructor(e){super(),T(this,e,W,V,z,{})}}export{Y as default};
import{S as M,i as T,s as z,F as A,d as E,t as w,a as y,m as H,c as L,h as g,C as B,D,l as k,n as d,E as G,G as I,v,u as m,w as p,p as C,H as F,I as N,A as h,f as j,k as P,o as R,q as J,z as S}from"./index-DA9lB3Zb.js";function K(u){let e,s,n,l,t,r,c,_,i,a,b,f;return l=new j({props:{class:"form-field required",name:"email",$$slots:{default:[Q,({uniqueId:o})=>({5:o}),({uniqueId:o})=>o?32:0]},$$scope:{ctx:u}}}),{c(){e=m("form"),s=m("div"),s.innerHTML='<h4 class="m-b-xs">Forgotten superuser password</h4> <p>Enter the email associated with your account and we’ll send you a recovery link:</p>',n=v(),L(l.$$.fragment),t=v(),r=m("button"),c=m("i"),_=v(),i=m("span"),i.textContent="Send recovery link",p(s,"class","content txt-center m-b-sm"),p(c,"class","ri-mail-send-line"),p(i,"class","txt"),p(r,"type","submit"),p(r,"class","btn btn-lg btn-block"),r.disabled=u[1],P(r,"btn-loading",u[1]),p(e,"class","m-b-base")},m(o,$){k(o,e,$),d(e,s),d(e,n),H(l,e,null),d(e,t),d(e,r),d(r,c),d(r,_),d(r,i),a=!0,b||(f=R(e,"submit",J(u[3])),b=!0)},p(o,$){const q={};$&97&&(q.$$scope={dirty:$,ctx:o}),l.$set(q),(!a||$&2)&&(r.disabled=o[1]),(!a||$&2)&&P(r,"btn-loading",o[1])},i(o){a||(y(l.$$.fragment,o),a=!0)},o(o){w(l.$$.fragment,o),a=!1},d(o){o&&g(e),E(l),b=!1,f()}}}function O(u){let e,s,n,l,t,r,c,_,i;return{c(){e=m("div"),s=m("div"),s.innerHTML='<i class="ri-checkbox-circle-line"></i>',n=v(),l=m("div"),t=m("p"),r=h("Check "),c=m("strong"),_=h(u[0]),i=h(" for the recovery link."),p(s,"class","icon"),p(c,"class","txt-nowrap"),p(l,"class","content"),p(e,"class","alert alert-success")},m(a,b){k(a,e,b),d(e,s),d(e,n),d(e,l),d(l,t),d(t,r),d(t,c),d(c,_),d(t,i)},p(a,b){b&1&&N(_,a[0])},i:F,o:F,d(a){a&&g(e)}}}function Q(u){let e,s,n,l,t,r,c,_;return{c(){e=m("label"),s=h("Email"),l=v(),t=m("input"),p(e,"for",n=u[5]),p(t,"type","email"),p(t,"id",r=u[5]),t.required=!0,t.autofocus=!0},m(i,a){k(i,e,a),d(e,s),k(i,l,a),k(i,t,a),S(t,u[0]),t.focus(),c||(_=R(t,"input",u[4]),c=!0)},p(i,a){a&32&&n!==(n=i[5])&&p(e,"for",n),a&32&&r!==(r=i[5])&&p(t,"id",r),a&1&&t.value!==i[0]&&S(t,i[0])},d(i){i&&(g(e),g(l),g(t)),c=!1,_()}}}function U(u){let e,s,n,l,t,r,c,_;const i=[O,K],a=[];function b(f,o){return f[2]?0:1}return e=b(u),s=a[e]=i[e](u),{c(){s.c(),n=v(),l=m("div"),t=m("a"),t.textContent="Back to login",p(t,"href","/login"),p(t,"class","link-hint"),p(l,"class","content txt-center")},m(f,o){a[e].m(f,o),k(f,n,o),k(f,l,o),d(l,t),r=!0,c||(_=G(I.call(null,t)),c=!0)},p(f,o){let $=e;e=b(f),e===$?a[e].p(f,o):(B(),w(a[$],1,1,()=>{a[$]=null}),D(),s=a[e],s?s.p(f,o):(s=a[e]=i[e](f),s.c()),y(s,1),s.m(n.parentNode,n))},i(f){r||(y(s),r=!0)},o(f){w(s),r=!1},d(f){f&&(g(n),g(l)),a[e].d(f),c=!1,_()}}}function V(u){let e,s;return e=new A({props:{$$slots:{default:[U]},$$scope:{ctx:u}}}),{c(){L(e.$$.fragment)},m(n,l){H(e,n,l),s=!0},p(n,[l]){const t={};l&71&&(t.$$scope={dirty:l,ctx:n}),e.$set(t)},i(n){s||(y(e.$$.fragment,n),s=!0)},o(n){w(e.$$.fragment,n),s=!1},d(n){E(e,n)}}}function W(u,e,s){let n="",l=!1,t=!1;async function r(){if(!l){s(1,l=!0);try{await C.collection("_superusers").requestPasswordReset(n),s(2,t=!0)}catch(_){C.error(_)}s(1,l=!1)}}function c(){n=this.value,s(0,n)}return[n,l,t,r,c]}class Y extends M{constructor(e){super(),T(this,e,W,V,z,{})}}export{Y as default};
@@ -1,4 +1,4 @@
import{S as se,i as ne,s as oe,X as H,h as b,t as X,a as V,I as Z,Z as ee,_ as ye,C as te,$ as Te,D as le,l as v,n as u,u as p,v as S,A as D,w as k,k as L,o as ae,W as Ee,d as G,m as Q,c as x,V as Ce,Y as fe,J as qe,p as Oe,a0 as pe}from"./index-sszpfGrR.js";function me(o,t,e){const n=o.slice();return n[4]=t[e],n}function _e(o,t,e){const n=o.slice();return n[4]=t[e],n}function he(o,t){let e,n=t[4].code+"",d,c,r,a;function f(){return t[3](t[4])}return{key:o,first:null,c(){e=p("button"),d=D(n),c=S(),k(e,"class","tab-item"),L(e,"active",t[1]===t[4].code),this.first=e},m(g,y){v(g,e,y),u(e,d),u(e,c),r||(a=ae(e,"click",f),r=!0)},p(g,y){t=g,y&4&&n!==(n=t[4].code+"")&&Z(d,n),y&6&&L(e,"active",t[1]===t[4].code)},d(g){g&&b(e),r=!1,a()}}}function be(o,t){let e,n,d,c;return n=new Ee({props:{content:t[4].body}}),{key:o,first:null,c(){e=p("div"),x(n.$$.fragment),d=S(),k(e,"class","tab-item"),L(e,"active",t[1]===t[4].code),this.first=e},m(r,a){v(r,e,a),Q(n,e,null),u(e,d),c=!0},p(r,a){t=r;const f={};a&4&&(f.content=t[4].body),n.$set(f),(!c||a&6)&&L(e,"active",t[1]===t[4].code)},i(r){c||(V(n.$$.fragment,r),c=!0)},o(r){X(n.$$.fragment,r),c=!1},d(r){r&&b(e),G(n)}}}function Ae(o){let t,e,n,d,c,r,a,f=o[0].name+"",g,y,F,q,J,W,U,O,A,T,C,R=[],M=new Map,j,N,h=[],K=new Map,E,P=H(o[2]);const B=l=>l[4].code;for(let l=0;l<P.length;l+=1){let s=_e(o,P,l),_=B(s);M.set(_,R[l]=he(_,s))}let m=H(o[2]);const Y=l=>l[4].code;for(let l=0;l<m.length;l+=1){let s=me(o,m,l),_=Y(s);K.set(_,h[l]=be(_,s))}return{c(){t=p("div"),e=p("strong"),e.textContent="POST",n=S(),d=p("div"),c=p("p"),r=D("/api/collections/"),a=p("strong"),g=D(f),y=D("/confirm-password-reset"),F=S(),q=p("div"),q.textContent="Body Parameters",J=S(),W=p("table"),W.innerHTML='<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>token</span></div></td> <td><span class="label">String</span></td> <td>The token from the password reset request email.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>password</span></div></td> <td><span class="label">String</span></td> <td>The new password to set.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>passwordConfirm</span></div></td> <td><span class="label">String</span></td> <td>The new password confirmation.</td></tr></tbody>',U=S(),O=p("div"),O.textContent="Responses",A=S(),T=p("div"),C=p("div");for(let l=0;l<R.length;l+=1)R[l].c();j=S(),N=p("div");for(let l=0;l<h.length;l+=1)h[l].c();k(e,"class","label label-primary"),k(d,"class","content"),k(t,"class","alert alert-success"),k(q,"class","section-title"),k(W,"class","table-compact table-border m-b-base"),k(O,"class","section-title"),k(C,"class","tabs-header compact combined left"),k(N,"class","tabs-content"),k(T,"class","tabs")},m(l,s){v(l,t,s),u(t,e),u(t,n),u(t,d),u(d,c),u(c,r),u(c,a),u(a,g),u(c,y),v(l,F,s),v(l,q,s),v(l,J,s),v(l,W,s),v(l,U,s),v(l,O,s),v(l,A,s),v(l,T,s),u(T,C);for(let _=0;_<R.length;_+=1)R[_]&&R[_].m(C,null);u(T,j),u(T,N);for(let _=0;_<h.length;_+=1)h[_]&&h[_].m(N,null);E=!0},p(l,[s]){(!E||s&1)&&f!==(f=l[0].name+"")&&Z(g,f),s&6&&(P=H(l[2]),R=ee(R,s,B,1,l,P,M,C,ye,he,null,_e)),s&6&&(m=H(l[2]),te(),h=ee(h,s,Y,1,l,m,K,N,Te,be,null,me),le())},i(l){if(!E){for(let s=0;s<m.length;s+=1)V(h[s]);E=!0}},o(l){for(let s=0;s<h.length;s+=1)X(h[s]);E=!1},d(l){l&&(b(t),b(F),b(q),b(J),b(W),b(U),b(O),b(A),b(T));for(let s=0;s<R.length;s+=1)R[s].d();for(let s=0;s<h.length;s+=1)h[s].d()}}}function We(o,t,e){let{collection:n}=t,d=204,c=[];const r=a=>e(1,d=a.code);return o.$$set=a=>{"collection"in a&&e(0,n=a.collection)},e(2,c=[{code:204,body:"null"},{code:400,body:`
import{S as se,i as ne,s as oe,X as H,h as b,t as X,a as V,I as Z,Z as ee,_ as ye,C as te,$ as Te,D as le,l as v,n as u,u as p,v as S,A as D,w as k,k as L,o as ae,W as Ee,d as G,m as Q,c as x,V as Ce,Y as fe,J as qe,p as Oe,a0 as pe}from"./index-DA9lB3Zb.js";function me(o,t,e){const n=o.slice();return n[4]=t[e],n}function _e(o,t,e){const n=o.slice();return n[4]=t[e],n}function he(o,t){let e,n=t[4].code+"",d,c,r,a;function f(){return t[3](t[4])}return{key:o,first:null,c(){e=p("button"),d=D(n),c=S(),k(e,"class","tab-item"),L(e,"active",t[1]===t[4].code),this.first=e},m(g,y){v(g,e,y),u(e,d),u(e,c),r||(a=ae(e,"click",f),r=!0)},p(g,y){t=g,y&4&&n!==(n=t[4].code+"")&&Z(d,n),y&6&&L(e,"active",t[1]===t[4].code)},d(g){g&&b(e),r=!1,a()}}}function be(o,t){let e,n,d,c;return n=new Ee({props:{content:t[4].body}}),{key:o,first:null,c(){e=p("div"),x(n.$$.fragment),d=S(),k(e,"class","tab-item"),L(e,"active",t[1]===t[4].code),this.first=e},m(r,a){v(r,e,a),Q(n,e,null),u(e,d),c=!0},p(r,a){t=r;const f={};a&4&&(f.content=t[4].body),n.$set(f),(!c||a&6)&&L(e,"active",t[1]===t[4].code)},i(r){c||(V(n.$$.fragment,r),c=!0)},o(r){X(n.$$.fragment,r),c=!1},d(r){r&&b(e),G(n)}}}function Ae(o){let t,e,n,d,c,r,a,f=o[0].name+"",g,y,F,q,J,W,U,O,A,T,C,R=[],M=new Map,j,N,h=[],K=new Map,E,P=H(o[2]);const B=l=>l[4].code;for(let l=0;l<P.length;l+=1){let s=_e(o,P,l),_=B(s);M.set(_,R[l]=he(_,s))}let m=H(o[2]);const Y=l=>l[4].code;for(let l=0;l<m.length;l+=1){let s=me(o,m,l),_=Y(s);K.set(_,h[l]=be(_,s))}return{c(){t=p("div"),e=p("strong"),e.textContent="POST",n=S(),d=p("div"),c=p("p"),r=D("/api/collections/"),a=p("strong"),g=D(f),y=D("/confirm-password-reset"),F=S(),q=p("div"),q.textContent="Body Parameters",J=S(),W=p("table"),W.innerHTML='<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>token</span></div></td> <td><span class="label">String</span></td> <td>The token from the password reset request email.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>password</span></div></td> <td><span class="label">String</span></td> <td>The new password to set.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>passwordConfirm</span></div></td> <td><span class="label">String</span></td> <td>The new password confirmation.</td></tr></tbody>',U=S(),O=p("div"),O.textContent="Responses",A=S(),T=p("div"),C=p("div");for(let l=0;l<R.length;l+=1)R[l].c();j=S(),N=p("div");for(let l=0;l<h.length;l+=1)h[l].c();k(e,"class","label label-primary"),k(d,"class","content"),k(t,"class","alert alert-success"),k(q,"class","section-title"),k(W,"class","table-compact table-border m-b-base"),k(O,"class","section-title"),k(C,"class","tabs-header compact combined left"),k(N,"class","tabs-content"),k(T,"class","tabs")},m(l,s){v(l,t,s),u(t,e),u(t,n),u(t,d),u(d,c),u(c,r),u(c,a),u(a,g),u(c,y),v(l,F,s),v(l,q,s),v(l,J,s),v(l,W,s),v(l,U,s),v(l,O,s),v(l,A,s),v(l,T,s),u(T,C);for(let _=0;_<R.length;_+=1)R[_]&&R[_].m(C,null);u(T,j),u(T,N);for(let _=0;_<h.length;_+=1)h[_]&&h[_].m(N,null);E=!0},p(l,[s]){(!E||s&1)&&f!==(f=l[0].name+"")&&Z(g,f),s&6&&(P=H(l[2]),R=ee(R,s,B,1,l,P,M,C,ye,he,null,_e)),s&6&&(m=H(l[2]),te(),h=ee(h,s,Y,1,l,m,K,N,Te,be,null,me),le())},i(l){if(!E){for(let s=0;s<m.length;s+=1)V(h[s]);E=!0}},o(l){for(let s=0;s<h.length;s+=1)X(h[s]);E=!1},d(l){l&&(b(t),b(F),b(q),b(J),b(W),b(U),b(O),b(A),b(T));for(let s=0;s<R.length;s+=1)R[s].d();for(let s=0;s<h.length;s+=1)h[s].d()}}}function We(o,t,e){let{collection:n}=t,d=204,c=[];const r=a=>e(1,d=a.code);return o.$$set=a=>{"collection"in a&&e(0,n=a.collection)},e(2,c=[{code:204,body:"null"},{code:400,body:`
{
"status": 400,
"message": "An error occurred while validating the submitted data.",
@@ -1,4 +1,4 @@
import{S as re,i as ae,s as be,V as pe,W as ue,J as P,h as s,d as se,t as ne,a as ie,I as me,l as n,n as y,m as ce,u as p,A as I,v as a,c as le,w as u,p as de}from"./index-sszpfGrR.js";function he(o){var B,U,W,A,L,H,T,q,J,M,j,N;let i,m,c=o[0].name+"",b,d,k,h,D,f,_,l,S,$,w,g,C,v,E,r,R;return l=new pe({props:{js:`
import{S as re,i as ae,s as be,V as pe,W as ue,J as P,h as s,d as se,t as ne,a as ie,I as me,l as n,n as y,m as ce,u as p,A as I,v as a,c as le,w as u,p as de}from"./index-DA9lB3Zb.js";function he(o){var B,U,W,A,L,H,T,q,J,M,j,N;let i,m,c=o[0].name+"",b,d,k,h,D,f,_,l,S,$,w,g,C,v,E,r,R;return l=new pe({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${o[1]}');
@@ -1,4 +1,4 @@
import{S as Ot,i as St,s as $t,V as Mt,J as x,X as ie,W as Tt,h as o,d as ve,t as he,a as ye,I as te,Z as Je,_ as bt,C as qt,$ as Rt,D as Dt,l as r,n,m as we,u as i,A as h,v as f,c as Ce,w as k,p as Ht,k as Te,o as Lt,H as de}from"./index-sszpfGrR.js";import{F as Pt}from"./FieldsQueryParam-Cs6hQAnX.js";function mt(d,e,t){const a=d.slice();return a[10]=e[t],a}function _t(d,e,t){const a=d.slice();return a[10]=e[t],a}function ht(d,e,t){const a=d.slice();return a[15]=e[t],a}function yt(d){let e;return{c(){e=i("p"),e.innerHTML=`<em>Note that in case of a password change all previously issued tokens for the current record
import{S as Ot,i as St,s as $t,V as Mt,J as x,X as ie,W as Tt,h as o,d as ve,t as he,a as ye,I as te,Z as Je,_ as bt,C as qt,$ as Rt,D as Dt,l as r,n,m as we,u as i,A as h,v as f,c as Ce,w as k,p as Ht,k as Te,o as Lt,H as de}from"./index-DA9lB3Zb.js";import{F as Pt}from"./FieldsQueryParam-NplIJIb9.js";function mt(d,e,t){const a=d.slice();return a[10]=e[t],a}function _t(d,e,t){const a=d.slice();return a[10]=e[t],a}function ht(d,e,t){const a=d.slice();return a[15]=e[t],a}function yt(d){let e;return{c(){e=i("p"),e.innerHTML=`<em>Note that in case of a password change all previously issued tokens for the current record
will be automatically invalidated and if you want your user to remain signed in you need to
reauthenticate manually after the update call.</em>`},m(t,a){r(t,e,a)},d(t){t&&o(e)}}}function kt(d){let e;return{c(){e=i("p"),e.innerHTML="Requires superuser <code>Authorization:TOKEN</code> header",k(e,"class","txt-hint txt-sm txt-right")},m(t,a){r(t,e,a)},d(t){t&&o(e)}}}function gt(d){let e,t,a,m,p,c,u,b,O,T,$,D,S,E,q,H,J,I,M,R,L,g,v,w;function Q(_,C){var le,z,ne;return C&1&&(b=null),b==null&&(b=!!((ne=(z=(le=_[0])==null?void 0:le.fields)==null?void 0:z.find(Wt))!=null&&ne.required)),b?Bt:Ft}let W=Q(d,-1),F=W(d);return{c(){e=i("tr"),e.innerHTML='<td colspan="3" class="txt-hint txt-bold">Auth specific fields</td>',t=f(),a=i("tr"),a.innerHTML=`<td><div class="inline-flex"><span class="label label-warning">Optional</span> <span>email</span></div></td> <td><span class="label">String</span></td> <td>The auth record email address.
<br/>
@@ -1,4 +1,4 @@
import{S as le,i as ne,s as ie,X as F,h as b,t as j,a as U,I as Y,Z as x,_ as Te,C as ee,$ as Ce,D as te,l as h,n as u,u as m,v as y,A as M,w as v,k as K,o as oe,W as qe,d as z,m as G,c as Q,V as Ve,Y as fe,J as Ae,p as Ie,a0 as ue}from"./index-sszpfGrR.js";function de(s,t,e){const o=s.slice();return o[4]=t[e],o}function me(s,t,e){const o=s.slice();return o[4]=t[e],o}function pe(s,t){let e,o=t[4].code+"",f,c,r,a;function d(){return t[3](t[4])}return{key:s,first:null,c(){e=m("button"),f=M(o),c=y(),v(e,"class","tab-item"),K(e,"active",t[1]===t[4].code),this.first=e},m(g,C){h(g,e,C),u(e,f),u(e,c),r||(a=oe(e,"click",d),r=!0)},p(g,C){t=g,C&4&&o!==(o=t[4].code+"")&&Y(f,o),C&6&&K(e,"active",t[1]===t[4].code)},d(g){g&&b(e),r=!1,a()}}}function _e(s,t){let e,o,f,c;return o=new qe({props:{content:t[4].body}}),{key:s,first:null,c(){e=m("div"),Q(o.$$.fragment),f=y(),v(e,"class","tab-item"),K(e,"active",t[1]===t[4].code),this.first=e},m(r,a){h(r,e,a),G(o,e,null),u(e,f),c=!0},p(r,a){t=r;const d={};a&4&&(d.content=t[4].body),o.$set(d),(!c||a&6)&&K(e,"active",t[1]===t[4].code)},i(r){c||(U(o.$$.fragment,r),c=!0)},o(r){j(o.$$.fragment,r),c=!1},d(r){r&&b(e),z(o)}}}function Pe(s){let t,e,o,f,c,r,a,d=s[0].name+"",g,C,D,P,L,R,B,O,N,q,V,$=[],J=new Map,H,I,p=[],T=new Map,A,_=F(s[2]);const X=l=>l[4].code;for(let l=0;l<_.length;l+=1){let i=me(s,_,l),n=X(i);J.set(n,$[l]=pe(n,i))}let E=F(s[2]);const W=l=>l[4].code;for(let l=0;l<E.length;l+=1){let i=de(s,E,l),n=W(i);T.set(n,p[l]=_e(n,i))}return{c(){t=m("div"),e=m("strong"),e.textContent="POST",o=y(),f=m("div"),c=m("p"),r=M("/api/collections/"),a=m("strong"),g=M(d),C=M("/confirm-verification"),D=y(),P=m("div"),P.textContent="Body Parameters",L=y(),R=m("table"),R.innerHTML='<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>token</span></div></td> <td><span class="label">String</span></td> <td>The token from the verification request email.</td></tr></tbody>',B=y(),O=m("div"),O.textContent="Responses",N=y(),q=m("div"),V=m("div");for(let l=0;l<$.length;l+=1)$[l].c();H=y(),I=m("div");for(let l=0;l<p.length;l+=1)p[l].c();v(e,"class","label label-primary"),v(f,"class","content"),v(t,"class","alert alert-success"),v(P,"class","section-title"),v(R,"class","table-compact table-border m-b-base"),v(O,"class","section-title"),v(V,"class","tabs-header compact combined left"),v(I,"class","tabs-content"),v(q,"class","tabs")},m(l,i){h(l,t,i),u(t,e),u(t,o),u(t,f),u(f,c),u(c,r),u(c,a),u(a,g),u(c,C),h(l,D,i),h(l,P,i),h(l,L,i),h(l,R,i),h(l,B,i),h(l,O,i),h(l,N,i),h(l,q,i),u(q,V);for(let n=0;n<$.length;n+=1)$[n]&&$[n].m(V,null);u(q,H),u(q,I);for(let n=0;n<p.length;n+=1)p[n]&&p[n].m(I,null);A=!0},p(l,[i]){(!A||i&1)&&d!==(d=l[0].name+"")&&Y(g,d),i&6&&(_=F(l[2]),$=x($,i,X,1,l,_,J,V,Te,pe,null,me)),i&6&&(E=F(l[2]),ee(),p=x(p,i,W,1,l,E,T,I,Ce,_e,null,de),te())},i(l){if(!A){for(let i=0;i<E.length;i+=1)U(p[i]);A=!0}},o(l){for(let i=0;i<p.length;i+=1)j(p[i]);A=!1},d(l){l&&(b(t),b(D),b(P),b(L),b(R),b(B),b(O),b(N),b(q));for(let i=0;i<$.length;i+=1)$[i].d();for(let i=0;i<p.length;i+=1)p[i].d()}}}function Re(s,t,e){let{collection:o}=t,f=204,c=[];const r=a=>e(1,f=a.code);return s.$$set=a=>{"collection"in a&&e(0,o=a.collection)},e(2,c=[{code:204,body:"null"},{code:400,body:`
import{S as le,i as ne,s as ie,X as F,h as b,t as j,a as U,I as Y,Z as x,_ as Te,C as ee,$ as Ce,D as te,l as h,n as u,u as m,v as y,A as M,w as v,k as K,o as oe,W as qe,d as z,m as G,c as Q,V as Ve,Y as fe,J as Ae,p as Ie,a0 as ue}from"./index-DA9lB3Zb.js";function de(s,t,e){const o=s.slice();return o[4]=t[e],o}function me(s,t,e){const o=s.slice();return o[4]=t[e],o}function pe(s,t){let e,o=t[4].code+"",f,c,r,a;function d(){return t[3](t[4])}return{key:s,first:null,c(){e=m("button"),f=M(o),c=y(),v(e,"class","tab-item"),K(e,"active",t[1]===t[4].code),this.first=e},m(g,C){h(g,e,C),u(e,f),u(e,c),r||(a=oe(e,"click",d),r=!0)},p(g,C){t=g,C&4&&o!==(o=t[4].code+"")&&Y(f,o),C&6&&K(e,"active",t[1]===t[4].code)},d(g){g&&b(e),r=!1,a()}}}function _e(s,t){let e,o,f,c;return o=new qe({props:{content:t[4].body}}),{key:s,first:null,c(){e=m("div"),Q(o.$$.fragment),f=y(),v(e,"class","tab-item"),K(e,"active",t[1]===t[4].code),this.first=e},m(r,a){h(r,e,a),G(o,e,null),u(e,f),c=!0},p(r,a){t=r;const d={};a&4&&(d.content=t[4].body),o.$set(d),(!c||a&6)&&K(e,"active",t[1]===t[4].code)},i(r){c||(U(o.$$.fragment,r),c=!0)},o(r){j(o.$$.fragment,r),c=!1},d(r){r&&b(e),z(o)}}}function Pe(s){let t,e,o,f,c,r,a,d=s[0].name+"",g,C,D,P,L,R,B,O,N,q,V,$=[],J=new Map,H,I,p=[],T=new Map,A,_=F(s[2]);const X=l=>l[4].code;for(let l=0;l<_.length;l+=1){let i=me(s,_,l),n=X(i);J.set(n,$[l]=pe(n,i))}let E=F(s[2]);const W=l=>l[4].code;for(let l=0;l<E.length;l+=1){let i=de(s,E,l),n=W(i);T.set(n,p[l]=_e(n,i))}return{c(){t=m("div"),e=m("strong"),e.textContent="POST",o=y(),f=m("div"),c=m("p"),r=M("/api/collections/"),a=m("strong"),g=M(d),C=M("/confirm-verification"),D=y(),P=m("div"),P.textContent="Body Parameters",L=y(),R=m("table"),R.innerHTML='<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>token</span></div></td> <td><span class="label">String</span></td> <td>The token from the verification request email.</td></tr></tbody>',B=y(),O=m("div"),O.textContent="Responses",N=y(),q=m("div"),V=m("div");for(let l=0;l<$.length;l+=1)$[l].c();H=y(),I=m("div");for(let l=0;l<p.length;l+=1)p[l].c();v(e,"class","label label-primary"),v(f,"class","content"),v(t,"class","alert alert-success"),v(P,"class","section-title"),v(R,"class","table-compact table-border m-b-base"),v(O,"class","section-title"),v(V,"class","tabs-header compact combined left"),v(I,"class","tabs-content"),v(q,"class","tabs")},m(l,i){h(l,t,i),u(t,e),u(t,o),u(t,f),u(f,c),u(c,r),u(c,a),u(a,g),u(c,C),h(l,D,i),h(l,P,i),h(l,L,i),h(l,R,i),h(l,B,i),h(l,O,i),h(l,N,i),h(l,q,i),u(q,V);for(let n=0;n<$.length;n+=1)$[n]&&$[n].m(V,null);u(q,H),u(q,I);for(let n=0;n<p.length;n+=1)p[n]&&p[n].m(I,null);A=!0},p(l,[i]){(!A||i&1)&&d!==(d=l[0].name+"")&&Y(g,d),i&6&&(_=F(l[2]),$=x($,i,X,1,l,_,J,V,Te,pe,null,me)),i&6&&(E=F(l[2]),ee(),p=x(p,i,W,1,l,E,T,I,Ce,_e,null,de),te())},i(l){if(!A){for(let i=0;i<E.length;i+=1)U(p[i]);A=!0}},o(l){for(let i=0;i<p.length;i+=1)j(p[i]);A=!1},d(l){l&&(b(t),b(D),b(P),b(L),b(R),b(B),b(O),b(N),b(q));for(let i=0;i<$.length;i+=1)$[i].d();for(let i=0;i<p.length;i+=1)p[i].d()}}}function Re(s,t,e){let{collection:o}=t,f=204,c=[];const r=a=>e(1,f=a.code);return s.$$set=a=>{"collection"in a&&e(0,o=a.collection)},e(2,c=[{code:204,body:"null"},{code:400,body:`
{
"status": 400,
"message": "An error occurred while validating the submitted data.",
@@ -1,4 +1,4 @@
import{S as lt,i as st,s as nt,V as at,W as tt,X as K,h as r,d as W,t as V,a as j,I as ve,Z as Ge,_ as ot,C as it,$ as rt,D as dt,l as d,n as l,m as X,u as a,A as _,v as b,c as Z,w as m,J as Ke,p as ct,k as Y,o as pt}from"./index-sszpfGrR.js";import{F as ut}from"./FieldsQueryParam-Cs6hQAnX.js";function We(o,s,n){const i=o.slice();return i[6]=s[n],i}function Xe(o,s,n){const i=o.slice();return i[6]=s[n],i}function Ze(o){let s;return{c(){s=a("p"),s.innerHTML="Requires superuser <code>Authorization:TOKEN</code> header",m(s,"class","txt-hint txt-sm txt-right")},m(n,i){d(n,s,i)},d(n){n&&r(s)}}}function Ye(o,s){let n,i,v;function p(){return s[5](s[6])}return{key:o,first:null,c(){n=a("button"),n.textContent=`${s[6].code} `,m(n,"class","tab-item"),Y(n,"active",s[2]===s[6].code),this.first=n},m(c,f){d(c,n,f),i||(v=pt(n,"click",p),i=!0)},p(c,f){s=c,f&20&&Y(n,"active",s[2]===s[6].code)},d(c){c&&r(n),i=!1,v()}}}function et(o,s){let n,i,v,p;return i=new tt({props:{content:s[6].body}}),{key:o,first:null,c(){n=a("div"),Z(i.$$.fragment),v=b(),m(n,"class","tab-item"),Y(n,"active",s[2]===s[6].code),this.first=n},m(c,f){d(c,n,f),X(i,n,null),l(n,v),p=!0},p(c,f){s=c,(!p||f&20)&&Y(n,"active",s[2]===s[6].code)},i(c){p||(j(i.$$.fragment,c),p=!0)},o(c){V(i.$$.fragment,c),p=!1},d(c){c&&r(n),W(i)}}}function ft(o){var Je,Ne;let s,n,i=o[0].name+"",v,p,c,f,w,C,ee,J=o[0].name+"",te,$e,le,F,se,B,ne,$,N,ye,Q,T,we,ae,z=o[0].name+"",oe,Ce,ie,Fe,re,I,de,S,ce,x,pe,R,ue,Re,M,D,fe,De,be,Oe,h,Pe,E,Te,Ee,Ae,me,Be,_e,Ie,Se,xe,he,Me,qe,A,ke,q,ge,O,H,y=[],He=new Map,Le,L,k=[],Ue=new Map,P;F=new at({props:{js:`
import{S as lt,i as st,s as nt,V as at,W as tt,X as K,h as r,d as W,t as V,a as j,I as ve,Z as Ge,_ as ot,C as it,$ as rt,D as dt,l as d,n as l,m as X,u as a,A as _,v as b,c as Z,w as m,J as Ke,p as ct,k as Y,o as pt}from"./index-DA9lB3Zb.js";import{F as ut}from"./FieldsQueryParam-NplIJIb9.js";function We(o,s,n){const i=o.slice();return i[6]=s[n],i}function Xe(o,s,n){const i=o.slice();return i[6]=s[n],i}function Ze(o){let s;return{c(){s=a("p"),s.innerHTML="Requires superuser <code>Authorization:TOKEN</code> header",m(s,"class","txt-hint txt-sm txt-right")},m(n,i){d(n,s,i)},d(n){n&&r(s)}}}function Ye(o,s){let n,i,v;function p(){return s[5](s[6])}return{key:o,first:null,c(){n=a("button"),n.textContent=`${s[6].code} `,m(n,"class","tab-item"),Y(n,"active",s[2]===s[6].code),this.first=n},m(c,f){d(c,n,f),i||(v=pt(n,"click",p),i=!0)},p(c,f){s=c,f&20&&Y(n,"active",s[2]===s[6].code)},d(c){c&&r(n),i=!1,v()}}}function et(o,s){let n,i,v,p;return i=new tt({props:{content:s[6].body}}),{key:o,first:null,c(){n=a("div"),Z(i.$$.fragment),v=b(),m(n,"class","tab-item"),Y(n,"active",s[2]===s[6].code),this.first=n},m(c,f){d(c,n,f),X(i,n,null),l(n,v),p=!0},p(c,f){s=c,(!p||f&20)&&Y(n,"active",s[2]===s[6].code)},i(c){p||(j(i.$$.fragment,c),p=!0)},o(c){V(i.$$.fragment,c),p=!1},d(c){c&&r(n),W(i)}}}function ft(o){var Je,Ne;let s,n,i=o[0].name+"",v,p,c,f,w,C,ee,J=o[0].name+"",te,$e,le,F,se,B,ne,$,N,ye,Q,T,we,ae,z=o[0].name+"",oe,Ce,ie,Fe,re,I,de,S,ce,x,pe,R,ue,Re,M,D,fe,De,be,Oe,h,Pe,E,Te,Ee,Ae,me,Be,_e,Ie,Se,xe,he,Me,qe,A,ke,q,ge,O,H,y=[],He=new Map,Le,L,k=[],Ue=new Map,P;F=new at({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${o[3]}');
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More