mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc1bf6fa7 | ||
|
|
d3cc87abee | ||
|
|
19ad827302 | ||
|
|
40830b6c43 | ||
|
|
f234132629 | ||
|
|
9b880f5ab4 | ||
|
|
d5775ff657 | ||
|
|
41ba7e12e1 | ||
|
|
5ef6b3a8be | ||
|
|
c673d9d314 | ||
|
|
a7aa3da67e | ||
|
|
0439af458f | ||
|
|
6f3241399c | ||
|
|
19ccc70fe5 | ||
|
|
9033cd109e | ||
|
|
e1773eead0 | ||
|
|
f6fff85d07 | ||
|
|
166d2eafc7 | ||
|
|
d710446c71 | ||
|
|
8f6cb5ad2d | ||
|
|
775417ac2b | ||
|
|
079616ee8e | ||
|
|
26b794aa08 | ||
|
|
d55610511d | ||
|
|
a00606d969 | ||
|
|
4abc8ae021 | ||
|
|
f91f009fce | ||
|
|
7fc1d979dd | ||
|
|
ede7804a80 | ||
|
|
cea287a2c1 | ||
|
|
9e03ac4dc4 | ||
|
|
e713de1e44 | ||
|
|
233ab62f8e | ||
|
|
3449084e54 | ||
|
|
15c05b9679 | ||
|
|
fb57c8091d | ||
|
|
ca528cef03 | ||
|
|
4ceab4e7ed | ||
|
|
e8fa51526a | ||
|
|
a43713ce14 | ||
|
|
7d7d20744e | ||
|
|
bd16680548 | ||
|
|
84ba89d5af | ||
|
|
bf5b1db672 | ||
|
|
71d3f8f4c7 | ||
|
|
687a79b450 | ||
|
|
738f71f244 | ||
|
|
64f3c5a604 | ||
|
|
a8c996c93d | ||
|
|
9ae8536515 | ||
|
|
5f6b7f6cc0 | ||
|
|
89de29fc84 | ||
|
|
c25e67e13d | ||
|
|
8e582acbee | ||
|
|
b9e257d2b1 | ||
|
|
e964b019c2 | ||
|
|
4cbb7f58cd | ||
|
|
8815f6060c | ||
|
|
5183280c39 | ||
|
|
1f45b858a6 | ||
|
|
6d46cefd1f | ||
|
|
b1a63bb98d | ||
|
|
1248421703 | ||
|
|
be3dd42eac | ||
|
|
0eeae9de80 | ||
|
|
55b439cb1c | ||
|
|
0696a252cc | ||
|
|
54c52f696c | ||
|
|
21f442293f | ||
|
|
18d6a1c529 | ||
|
|
ad321c01e0 | ||
|
|
29c99319dc | ||
|
|
18285e7505 | ||
|
|
846b56d393 | ||
|
|
f30c9f263f | ||
|
|
b63268559f | ||
|
|
5c899a4cf0 | ||
|
|
506bfca8b2 | ||
|
|
007fcd36b8 | ||
|
|
707f35f461 | ||
|
|
972b06c708 | ||
|
|
37bac5cc50 | ||
|
|
aa6eaa7319 | ||
|
|
68a9782c03 | ||
|
|
869d1cbcf7 | ||
|
|
451611776e | ||
|
|
9c7f48a66e | ||
|
|
59b41c8202 | ||
|
|
94658712c6 | ||
|
|
2c4ac070a3 | ||
|
|
e206e303ca | ||
|
|
e60f470188 | ||
|
|
9cf5e28700 | ||
|
|
7aefcd9bf6 | ||
|
|
693954cdcd | ||
|
|
355f7053fd | ||
|
|
808f5054d0 | ||
|
|
f1d546c845 | ||
|
|
dba66d4da1 | ||
|
|
4070a11660 | ||
|
|
45b72dd6b3 | ||
|
|
599c542c5a | ||
|
|
b8cd686b32 | ||
|
|
604009bd10 | ||
|
|
02f72638b8 | ||
|
|
23fbfab63a | ||
|
|
98cc8e2aee | ||
|
|
04018f931b | ||
|
|
d2028143df | ||
|
|
6400924d29 | ||
|
|
44a69eb4ba | ||
|
|
0fa5edb0b1 | ||
|
|
799e1d96f8 | ||
|
|
9ba710cdc5 | ||
|
|
a4953cbb4e | ||
|
|
647158f62d | ||
|
|
2deca759fa | ||
|
|
bd65125744 | ||
|
|
328b99a690 | ||
|
|
33539452de | ||
|
|
c6f03cda43 | ||
|
|
2d3531dd66 | ||
|
|
7ac3a74440 | ||
|
|
3bce173748 | ||
|
|
b024737ec8 | ||
|
|
675d459137 | ||
|
|
8c9b657132 | ||
|
|
d8963c6fc3 | ||
|
|
3e1a19685b | ||
|
|
c4a660d2d2 | ||
|
|
c12c873a65 |
@@ -0,0 +1,5 @@
|
||||
# Security
|
||||
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
|
||||
|
||||
All reports will be promptly addressed, and you'll be credited accordingly.
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '>=1.19.3'
|
||||
go-version: '>=1.19.4'
|
||||
|
||||
# This step usually is not needed because the /ui/dist is pregenerated locally
|
||||
# but its here to ensure that each release embeds the latest admin ui artifacts.
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
# goreleaser builds folder
|
||||
/.builds/
|
||||
|
||||
# examples app directories
|
||||
pb_data
|
||||
pb_public
|
||||
|
||||
# tests coverage
|
||||
coverage.out
|
||||
|
||||
|
||||
+334
-2
@@ -1,3 +1,335 @@
|
||||
## v0.11.0
|
||||
|
||||
- Added `+` and `-` body field modifiers for `number`, `files`, `select` and `relation` fields.
|
||||
```js
|
||||
{
|
||||
// oldValue + 2
|
||||
"someNumber+": 2,
|
||||
|
||||
// oldValue + ["id1", "id2"] - ["id3"]
|
||||
"someRelation+": ["id1", "id2"],
|
||||
"someRelation-": ["id3"],
|
||||
|
||||
// delete single file by its name (file fields supports only the "-" modifier!)
|
||||
"someFile-": "filename.png",
|
||||
}
|
||||
```
|
||||
_Note1: `@request.data.someField` will contain the final resolved value._
|
||||
|
||||
_Note2: The old index (`"field.0":null`) and filename (`"field.filename.png":null`) based suffixed syntax for deleting files is still supported._
|
||||
|
||||
- ! Added support for multi-match/match-all request data and collection multi-valued fields (`select`, `relation`) conditions.
|
||||
If you want a "at least one of" type of condition, you can prefix the operator with `?`.
|
||||
```js
|
||||
// for each someRelA.someRelB record require the "status" field to be "active"
|
||||
someRelA.someRelB.status = "active"
|
||||
|
||||
// OR for "at least one of" condition
|
||||
someRelA.someRelB.status ?= "active"
|
||||
```
|
||||
_**Note: Previously the behavior for multi-valued fields was as the "at least one of" type.
|
||||
The release comes with system db migration that will update your existing API rules (if needed) to preserve the compatibility.
|
||||
If you have multi-select or multi-relation filter checks in your client-side code and want to preserve the old behavior, you'll have to prefix with `?` your operators.**_
|
||||
|
||||
- Added support for querying `@request.data.someRelField.*` relation fields.
|
||||
```js
|
||||
// example submitted data: {"someRel": "REL_RECORD_ID"}
|
||||
@request.data.someRel.status = "active"
|
||||
```
|
||||
|
||||
- Added `:isset` modifier for the static request data fields.
|
||||
```js
|
||||
// prevent changing the "role" field
|
||||
@request.data.role:isset = false
|
||||
```
|
||||
|
||||
- Added `:length` modifier for the arrayable request data and collection fields (`select`, `file`, `relation`).
|
||||
```js
|
||||
// example submitted data: {"someSelectField": ["val1", "val2"]}
|
||||
@request.data.someSelectField:length = 2
|
||||
|
||||
// check existing record field length
|
||||
someSelectField:length = 2
|
||||
```
|
||||
|
||||
- Added `:each` modifier support for the multi-`select` request data and collection field.
|
||||
```js
|
||||
// check if all selected rows has "pb_" prefix
|
||||
roles:each ~ 'pb_%'
|
||||
```
|
||||
|
||||
- Improved the Admin UI filters autocomplete.
|
||||
|
||||
- Added `@random` sort key for `RANDOM()` sorted list results.
|
||||
|
||||
- Added Strava OAuth2 provider ([#1443](https://github.com/pocketbase/pocketbase/pull/1443); thanks @szsascha).
|
||||
|
||||
- Added Gitee OAuth2 provider ([#1448](https://github.com/pocketbase/pocketbase/pull/1448); thanks @yuxiang-gao).
|
||||
|
||||
- Added IME status check to the textarea keydown handler ([#1370](https://github.com/pocketbase/pocketbase/pull/1370); thanks @tenthree).
|
||||
|
||||
- Added `filesystem.NewFileFromBytes()` helper ([#1420](https://github.com/pocketbase/pocketbase/pull/1420); thanks @dschissler).
|
||||
|
||||
- Added support for reordering uploaded multiple files.
|
||||
|
||||
- Added `webp` to the default images mime type presets list ([#1469](https://github.com/pocketbase/pocketbase/pull/1469); thanks @khairulhaaziq).
|
||||
|
||||
- Added the OAuth2 refresh token to the auth meta response ([#1487](https://github.com/pocketbase/pocketbase/issues/1487)).
|
||||
|
||||
- Fixed the text wrapping in the Admin UI listing searchbar ([#1416](https://github.com/pocketbase/pocketbase/issues/1416)).
|
||||
|
||||
- Fixed number field value output in the records listing ([#1447](https://github.com/pocketbase/pocketbase/issues/1447)).
|
||||
|
||||
- Fixed duplicated settings view pages caused by uncompleted transitions ([#1498](https://github.com/pocketbase/pocketbase/issues/1498)).
|
||||
|
||||
- Allowed sending `Authorization` header with the `/auth-with-password` record and admin login requests ([#1494](https://github.com/pocketbase/pocketbase/discussions/1494)).
|
||||
|
||||
- `migrate down` now reverts migrations in the applied order.
|
||||
|
||||
- Added additional list-bucket check in the S3 config test API.
|
||||
|
||||
- Other minor improvements.
|
||||
|
||||
|
||||
## v0.10.4
|
||||
|
||||
- Fixed `Record.MergeExpand` panic when the main model expand map is not initialized ([#1365](https://github.com/pocketbase/pocketbase/issues/1365)).
|
||||
|
||||
|
||||
## v0.10.3
|
||||
|
||||
- ! Renamed the metadata key `original_filename` to `original-filename` due to an S3 file upload error caused by the underscore character ([#1343](https://github.com/pocketbase/pocketbase/pull/1343); thanks @yuxiang-gao).
|
||||
|
||||
- Fixed request verification docs api url ([#1332](https://github.com/pocketbase/pocketbase/pull/1332); thanks @JoyMajumdar2001)
|
||||
|
||||
- Excluded `collectionId` and `collectionName` from the displayable relation props list ([1322](https://github.com/pocketbase/pocketbase/issues/1322); thanks @dhall2).
|
||||
|
||||
|
||||
## v0.10.2
|
||||
|
||||
- Fixed nested multiple expands with shared path ([#586](https://github.com/pocketbase/pocketbase/issues/586#issuecomment-1357784227)).
|
||||
A new helper method `models.Record.MergeExpand(map[string]any)` was also added to simplify the expand handling and unit testing.
|
||||
|
||||
|
||||
## v0.10.1
|
||||
|
||||
- Fixed nested transactions deadlock when authenticating with OAuth2 ([#1291](https://github.com/pocketbase/pocketbase/issues/1291)).
|
||||
|
||||
|
||||
## v0.10.0
|
||||
|
||||
- Added `/api/health` endpoint (thanks @MarvinJWendt).
|
||||
|
||||
- Added support for SMTP `LOGIN` auth for Microsoft/Outlook and other providers that don't support the `PLAIN` auth method ([#1217](https://github.com/pocketbase/pocketbase/discussions/1217#discussioncomment-4387970)).
|
||||
|
||||
- Reduced memory consumption (you can expect ~20% less allocated memory).
|
||||
|
||||
- Added support for split (concurrent and nonconcurrent) DB connections pool increasing even further the concurrent throughput without blocking reads on heavy write load.
|
||||
|
||||
- Improved record references delete performance.
|
||||
|
||||
- Removed the unnecessary parenthesis in the generated filter SQL query, reducing the "_parse stack overflow_" errors.
|
||||
|
||||
- Fixed `~` expressions backslash literal escaping ([#1231](https://github.com/pocketbase/pocketbase/discussions/1231)).
|
||||
|
||||
- Refactored the `core.app.Bootstrap()` to be called before starting the cobra commands ([#1267](https://github.com/pocketbase/pocketbase/discussions/1267)).
|
||||
|
||||
- ! Changed `pocketbase.NewWithConfig(config Config)` to `pocketbase.NewWithConfig(config *Config)` and added 4 new config settings:
|
||||
```go
|
||||
DataMaxOpenConns int // default to core.DefaultDataMaxOpenConns
|
||||
DataMaxIdleConns int // default to core.DefaultDataMaxIdleConns
|
||||
LogsMaxOpenConns int // default to core.DefaultLogsMaxOpenConns
|
||||
LogsMaxIdleConns int // default to core.DefaultLogsMaxIdleConns
|
||||
```
|
||||
|
||||
- Added new helper method `core.App.IsBootstrapped()` to check the current app bootstrap state.
|
||||
|
||||
- ! Changed `core.NewBaseApp(dir, encryptionEnv, isDebug)` to `NewBaseApp(config *BaseAppConfig)`.
|
||||
|
||||
- ! Removed `rest.UploadedFile` struct (see below `filesystem.File`).
|
||||
|
||||
- Added generic file resource struct that allows loading and uploading file content from
|
||||
different sources (at the moment multipart/form-data requests and from the local filesystem).
|
||||
```
|
||||
filesystem.File{}
|
||||
filesystem.NewFileFromPath(path)
|
||||
filesystem.NewFileFromMultipart(multipartHeader)
|
||||
filesystem/System.UploadFile(file)
|
||||
```
|
||||
|
||||
- Refactored `forms.RecordUpsert` to allow more easily loading and removing files programmatically.
|
||||
```
|
||||
forms.RecordUpsert.AddFiles(key, filesystem.File...) // add new filesystem.File to the form for upload
|
||||
forms.RecordUpsert.RemoveFiles(key, filenames...) // marks the filenames for deletion
|
||||
```
|
||||
|
||||
- Trigger the `password` validators if any of the others password change fields is set.
|
||||
|
||||
|
||||
## v0.9.2
|
||||
|
||||
- Fixed field column name conflict on record deletion ([#1220](https://github.com/pocketbase/pocketbase/discussions/1220)).
|
||||
|
||||
|
||||
## v0.9.1
|
||||
|
||||
- Moved the record file upload and delete out of the db transaction to minimize the locking times.
|
||||
|
||||
- Added `Dao` query semaphore and base fail/retry handling to improve the concurrent writes throughput ([#1187](https://github.com/pocketbase/pocketbase/issues/1187)).
|
||||
|
||||
- Fixed records cascade deletion when there are "A<->B" relation references.
|
||||
|
||||
- Replaced `c.QueryString()` with `c.QueryParams().Encode()` to allow loading middleware modified query parameters in the default crud actions ([#1210](https://github.com/pocketbase/pocketbase/discussions/1210)).
|
||||
|
||||
- Fixed the datetime field not triggering the `onChange` event on manual field edit and added a "Clear" button ([#1219](https://github.com/pocketbase/pocketbase/issues/1219)).
|
||||
|
||||
- Updated the GitHub goreleaser action to use go 1.19.4 since it comes with [some security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.19.4+label%3ACherryPickApproved).
|
||||
|
||||
|
||||
## v0.9.0
|
||||
|
||||
- Fixed concurrent multi-relation cascade update/delete ([#1138](https://github.com/pocketbase/pocketbase/issues/1138)).
|
||||
|
||||
- Added the raw OAuth2 user data (`meta.rawUser`) and OAuth2 access token (`meta.accessToken`) to the auth response ([#654](https://github.com/pocketbase/pocketbase/discussions/654)).
|
||||
|
||||
- `BaseModel.UnmarkAsNew()` method was renamed to `BaseModel.MarkAsNotNew()`.
|
||||
Additionally, to simplify the insert model queries with custom IDs, it is no longer required to call `MarkAsNew()` for manually initialized models with set ID since now this is the default state.
|
||||
When the model is populated with values from the database (eg. after row `Scan`) it will be marked automatically as "not new".
|
||||
|
||||
- Added `Record.OriginalCopy()` method that returns a new `Record` copy populated with the initially loaded record data (useful if you want to compare old and new field values).
|
||||
|
||||
- Added new event hooks:
|
||||
```go
|
||||
app.OnBeforeBootstrap()
|
||||
app.OnAfterBootstrap()
|
||||
app.OnBeforeApiError()
|
||||
app.OnAfterApiError()
|
||||
app.OnRealtimeDisconnectRequest()
|
||||
app.OnRealtimeBeforeMessageSend()
|
||||
app.OnRealtimeAfterMessageSend()
|
||||
app.OnRecordBeforeRequestPasswordResetRequest()
|
||||
app.OnRecordAfterRequestPasswordResetRequest()
|
||||
app.OnRecordBeforeConfirmPasswordResetRequest()
|
||||
app.OnRecordAfterConfirmPasswordResetRequest()
|
||||
app.OnRecordBeforeRequestVerificationRequest()
|
||||
app.OnRecordAfterRequestVerificationRequest()
|
||||
app.OnRecordBeforeConfirmVerificationRequest()
|
||||
app.OnRecordAfterConfirmVerificationRequest()
|
||||
app.OnRecordBeforeRequestEmailChangeRequest()
|
||||
app.OnRecordAfterRequestEmailChangeRequest()
|
||||
app.OnRecordBeforeConfirmEmailChangeRequest()
|
||||
app.OnRecordAfterConfirmEmailChangeRequest()
|
||||
```
|
||||
|
||||
- The original uploaded file name is now stored as metadata under the `original_filename` key. It could be accessed via:
|
||||
```go
|
||||
fs, _ := app.NewFilesystem()
|
||||
defer fs.Close()
|
||||
|
||||
attrs, _ := fs.Attributes(fikeKey)
|
||||
attrs.Metadata["original_name"]
|
||||
```
|
||||
|
||||
- Added support for `Partial/Range` file requests ([#1125](https://github.com/pocketbase/pocketbase/issues/1125)).
|
||||
This is a minor breaking change if you are using `filesystem.Serve` (eg. as part of a custom `OnFileDownloadRequest` hook):
|
||||
```go
|
||||
// old
|
||||
filesystem.Serve(res, e.ServedPath, e.ServedName)
|
||||
|
||||
// new
|
||||
filesystem.Serve(res, req, e.ServedPath, e.ServedName)
|
||||
```
|
||||
|
||||
- Refactored the `migrate` command to support **external JavaScript migration files** using an embedded JS interpreter ([goja](https://github.com/dop251/goja)).
|
||||
This allow writing custom migration scripts such as programmatically creating collections,
|
||||
initializing default settings, running data imports, etc., with a JavaScript API very similar to the Go one (_more documentation will be available soon_).
|
||||
|
||||
The `migrate` command is available by default for the prebuilt executable,
|
||||
but if you use PocketBase as framework you need register it manually:
|
||||
```go
|
||||
migrationsDir := "" // default to "pb_migrations" (for js) and "migrations" (for go)
|
||||
|
||||
// load js files if you want to allow loading external JavaScript migrations
|
||||
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
// register the `migrate` command
|
||||
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
|
||||
TemplateLang: migratecmd.TemplateLangJS, // or migratecmd.TemplateLangGo (default)
|
||||
Dir: migrationsDir,
|
||||
Automigrate: true,
|
||||
})
|
||||
```
|
||||
|
||||
**The refactoring also comes with automigrations support.**
|
||||
|
||||
If `Automigrate` is enabled (`true` by default for the prebuilt executable; can be disabled with `--automigrate=0`),
|
||||
PocketBase will generate seamlessly in the background JS (or Go) migration file with your collection changes.
|
||||
**The directory with the JS migrations can be committed to your git repo.**
|
||||
All migrations (Go and JS) are automatically executed on server start.
|
||||
Also note that the auto generated migrations are granural (in contrast to the `migrate collections` snapshot command)
|
||||
and allow multiple developers to do changes on the collections independently (even editing the same collection) miniziming the eventual merge conflicts.
|
||||
Here is a sample JS migration file that will be generated if you for example edit a single collection name:
|
||||
```js
|
||||
// pb_migrations/1669663597_updated_posts_old.js
|
||||
migrate((db) => {
|
||||
// up
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("lngf8rb3dqu86r3")
|
||||
collection.name = "posts_new"
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
// down
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("lngf8rb3dqu86r3")
|
||||
collection.name = "posts_old"
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
```
|
||||
|
||||
- Added new `Dao` helpers to make it easier fetching and updating the app settings from a migration:
|
||||
```go
|
||||
dao.FindSettings([optEncryptionKey])
|
||||
dao.SaveSettings(newSettings, [optEncryptionKey])
|
||||
```
|
||||
|
||||
- Moved `core.Settings` to `models/settings.Settings`:
|
||||
```
|
||||
core.Settings{} -> settings.Settings{}
|
||||
core.NewSettings() -> settings.New()
|
||||
core.MetaConfig{} -> settings.MetaConfig{}
|
||||
core.LogsConfig{} -> settings.LogsConfig{}
|
||||
core.SmtpConfig{} -> settings.SmtpConfig{}
|
||||
core.S3Config{} -> settings.S3Config{}
|
||||
core.TokenConfig{} -> settings.TokenConfig{}
|
||||
core.AuthProviderConfig{} -> settings.AuthProviderConfig{}
|
||||
```
|
||||
|
||||
- Changed the `mailer.Mailer` interface (**minor breaking if you are sending custom emails**):
|
||||
```go
|
||||
// Old:
|
||||
app.NewMailClient().Send(from, to, subject, html, attachments?)
|
||||
|
||||
// New:
|
||||
app.NewMailClient().Send(&mailer.Message{
|
||||
From: from,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
HTML: html,
|
||||
Attachments: attachments,
|
||||
// new configurable fields
|
||||
Bcc: []string{"bcc1@example.com", "bcc2@example.com"},
|
||||
Cc: []string{"cc1@example.com", "cc2@example.com"},
|
||||
Headers: map[string]string{"Custom-Header": "test"},
|
||||
Text: "custom plain text version",
|
||||
})
|
||||
```
|
||||
The new `*mailer.Message` struct is also now a member of the `MailerRecordEvent` and `MailerAdminEvent` events.
|
||||
|
||||
- Other minor UI fixes and improvements
|
||||
|
||||
|
||||
## v0.8.0
|
||||
|
||||
**⚠️ This release contains breaking changes and requires some manual migration steps!**
|
||||
@@ -12,7 +344,7 @@ In addition to the `Users` and `profiles` merge, this release comes with several
|
||||
|
||||
- The `json` field type now supports filtering and sorting [#423](https://github.com/pocketbase/pocketbase/issues/423#issuecomment-1258302125).
|
||||
|
||||
- The `relation` field now allows unlimitted `maxSelect` (aka. without upper limit).
|
||||
- The `relation` field now allows unlimited `maxSelect` (aka. without upper limit).
|
||||
|
||||
- Added support for combined email/username + password authentication (see below `authWithPassword()`).
|
||||
|
||||
@@ -648,7 +980,7 @@ Please check the individual SDK package changelog and apply the necessary change
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- Marked as "Deprecated" and will be removed in v0.9:
|
||||
- Marked as "Deprecated" and will be removed in v0.9+:
|
||||
```
|
||||
core.Settings.EmailAuth{}
|
||||
core.EmailAuthConfig{}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2022, Gani Georgiev
|
||||
Copyright (c) 2022 - present, Gani Georgiev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
|
||||
@@ -110,7 +110,7 @@ Check also the [Testing guide](http://pocketbase.io/docs/testing) to learn how t
|
||||
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
|
||||
|
||||
All reports will be promptly addressed and you'll be credited accordingly.
|
||||
All reports will be promptly addressed, and you'll be credited accordingly.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ func bindAdminApi(app core.App, rg *echo.Group) {
|
||||
api := adminApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/admins", ActivityLogger(app))
|
||||
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
|
||||
subGroup.POST("/auth-with-password", api.authWithPassword)
|
||||
subGroup.POST("/request-password-reset", api.requestPasswordReset)
|
||||
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
|
||||
subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth())
|
||||
@@ -120,7 +120,7 @@ func (api *adminApi) list(c echo.Context) error {
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.Dao().AdminQuery()).
|
||||
ParseAndExec(c.QueryString(), &admins)
|
||||
ParseAndExec(c.QueryParams().Encode(), &admins)
|
||||
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
|
||||
+14
-8
@@ -48,6 +48,20 @@ func TestAdminAuthWithEmail(t *testing.T) {
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid email/password (guest)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"admin":{"id":"sywbhecnh46rhm0"`,
|
||||
`"token":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email/password (already authorized)",
|
||||
Method: http.MethodPost,
|
||||
@@ -56,14 +70,6 @@ func TestAdminAuthWithEmail(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid email/password (guest)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"admin":{"id":"sywbhecnh46rhm0"`,
|
||||
|
||||
+18
-9
@@ -64,19 +64,27 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
apiErr = NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
// Send response
|
||||
var cErr error
|
||||
if c.Request().Method == http.MethodHead {
|
||||
// @see https://github.com/labstack/echo/issues/608
|
||||
cErr = c.NoContent(apiErr.Code)
|
||||
} else {
|
||||
cErr = c.JSON(apiErr.Code, apiErr)
|
||||
event := &core.ApiErrorEvent{
|
||||
HttpContext: c,
|
||||
Error: apiErr,
|
||||
}
|
||||
|
||||
// send error response
|
||||
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
|
||||
// @see https://github.com/labstack/echo/issues/608
|
||||
if e.HttpContext.Request().Method == http.MethodHead {
|
||||
return e.HttpContext.NoContent(apiErr.Code)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(apiErr.Code, apiErr)
|
||||
})
|
||||
|
||||
// truly rare case; eg. client already disconnected
|
||||
if cErr != nil && app.IsDebug() {
|
||||
log.Println(cErr)
|
||||
if hookErr != nil && app.IsDebug() {
|
||||
log.Println(hookErr)
|
||||
}
|
||||
|
||||
app.OnAfterApiError().Trigger(event)
|
||||
}
|
||||
|
||||
// admin ui routes
|
||||
@@ -92,6 +100,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
bindFileApi(app, api)
|
||||
bindRealtimeApi(app, api)
|
||||
bindLogsApi(app, api)
|
||||
bindHealthApi(app, api)
|
||||
|
||||
// trigger the custom BeforeServe hook for the created api router
|
||||
// allowing users to further adjust its options or register new routes
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ func (api *collectionApi) list(c echo.Context) error {
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.Dao().CollectionQuery()).
|
||||
ParseAndExec(c.QueryString(), &collections)
|
||||
ParseAndExec(c.QueryParams().Encode(), &collections)
|
||||
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
|
||||
+12
-10
@@ -42,7 +42,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":7`,
|
||||
`"totalItems":8`,
|
||||
`"items":[{`,
|
||||
`"id":"_pb_users_auth_"`,
|
||||
`"id":"v851q4r790rhknl"`,
|
||||
@@ -51,6 +51,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
`"id":"sz5l5z67tg7gku0"`,
|
||||
`"id":"wzlqyes4orhoygb"`,
|
||||
`"id":"4d1blo5cuycfaca"`,
|
||||
`"id":"9n89pl5vkct6330"`,
|
||||
`"type":"auth"`,
|
||||
`"type":"base"`,
|
||||
},
|
||||
@@ -69,10 +70,10 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":2`,
|
||||
`"perPage":2`,
|
||||
`"totalItems":7`,
|
||||
`"totalItems":8`,
|
||||
`"items":[{`,
|
||||
`"id":"v851q4r790rhknl"`,
|
||||
`"id":"4d1blo5cuycfaca"`,
|
||||
`"id":"wzlqyes4orhoygb"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsListRequest": 1,
|
||||
@@ -99,12 +100,13 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":4`,
|
||||
`"totalItems":5`,
|
||||
`"items":[{`,
|
||||
`"id":"wsmn24bux7wo113"`,
|
||||
`"id":"sz5l5z67tg7gku0"`,
|
||||
`"id":"wzlqyes4orhoygb"`,
|
||||
`"id":"4d1blo5cuycfaca"`,
|
||||
`"id":"9n89pl5vkct6330"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsListRequest": 1,
|
||||
@@ -786,7 +788,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 7
|
||||
expected := 8
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
@@ -814,7 +816,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 7
|
||||
expected := 8
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
@@ -856,7 +858,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 7
|
||||
expected := 8
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
@@ -909,7 +911,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 10
|
||||
expected := 11
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
@@ -996,8 +998,8 @@ func TestCollectionImport(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsAfterImportRequest": 1,
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeDelete": 5,
|
||||
"OnModelAfterDelete": 5,
|
||||
"OnModelBeforeDelete": 6,
|
||||
"OnModelAfterDelete": 6,
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
|
||||
+3
-1
@@ -94,7 +94,9 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
}
|
||||
|
||||
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
|
||||
if err := fs.Serve(e.HttpContext.Response(), e.ServedPath, e.ServedName); err != nil {
|
||||
res := e.HttpContext.Response()
|
||||
req := e.HttpContext.Request()
|
||||
if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// bindHealthApi registers the health api endpoint.
|
||||
func bindHealthApi(app core.App, rg *echo.Group) {
|
||||
api := healthApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/health")
|
||||
subGroup.GET("", api.healthCheck)
|
||||
}
|
||||
|
||||
type healthApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
// healthCheck returns a 200 OK response if the server is healthy.
|
||||
func (api *healthApi) healthCheck(c echo.Context) error {
|
||||
payload := map[string]any{
|
||||
"code": http.StatusOK,
|
||||
"message": "API is healthy.",
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestHealthAPI(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "health status returns 200",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/health",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"code":200`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -35,7 +35,7 @@ func (api *logsApi) requestsList(c echo.Context) error {
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.LogsDao().RequestQuery()).
|
||||
ParseAndExec(c.QueryString(), &[]*models.Request{})
|
||||
ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{})
|
||||
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
|
||||
+2
-13
@@ -333,19 +333,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
model.RefreshUpdated()
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
attempts := 1
|
||||
|
||||
BeginSave:
|
||||
logErr := app.LogsDao().SaveRequest(model)
|
||||
if logErr != nil {
|
||||
// try one more time after 10s in case of SQLITE_BUSY or "database is locked" error
|
||||
if attempts <= 2 {
|
||||
attempts++
|
||||
time.Sleep(10 * time.Second)
|
||||
goto BeginSave
|
||||
} else if app.IsDebug() {
|
||||
log.Println("Log save failed:", logErr)
|
||||
}
|
||||
if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
|
||||
log.Println("Log save failed:", err)
|
||||
}
|
||||
|
||||
// Delete old request logs
|
||||
|
||||
+58
-12
@@ -42,7 +42,14 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
// register new subscription client
|
||||
client := subscriptions.NewDefaultClient()
|
||||
api.app.SubscriptionsBroker().Register(client)
|
||||
defer api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
defer func() {
|
||||
api.app.OnRealtimeDisconnectRequest().Trigger(&core.RealtimeDisconnectEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
})
|
||||
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
}()
|
||||
|
||||
c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8")
|
||||
c.Response().Header().Set("Cache-Control", "no-store")
|
||||
@@ -51,12 +58,12 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
|
||||
c.Response().Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
event := &core.RealtimeConnectEvent{
|
||||
connectEvent := &core.RealtimeConnectEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeConnectRequest().Trigger(event); err != nil {
|
||||
if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -65,10 +72,31 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
}
|
||||
|
||||
// signalize established connection (aka. fire "connect" message)
|
||||
fmt.Fprint(c.Response(), "id:"+client.Id()+"\n")
|
||||
fmt.Fprint(c.Response(), "event:PB_CONNECT\n")
|
||||
fmt.Fprint(c.Response(), "data:{\"clientId\":\""+client.Id()+"\"}\n\n")
|
||||
c.Response().Flush()
|
||||
connectMsgEvent := &core.RealtimeMessageEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
Message: &subscriptions.Message{
|
||||
Name: "PB_CONNECT",
|
||||
Data: `{"clientId":"` + client.Id() + `"}`,
|
||||
},
|
||||
}
|
||||
connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Flush()
|
||||
return nil
|
||||
})
|
||||
if connectMsgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(connectMsgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend PB_CONNECT error:", err)
|
||||
}
|
||||
|
||||
// start an idle timer to keep track of inactive/forgotten connections
|
||||
idleDuration := 5 * time.Minute
|
||||
@@ -88,11 +116,29 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
w := c.Response()
|
||||
fmt.Fprint(w, "id:"+client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+msg.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+msg.Data+"\n\n")
|
||||
w.Flush()
|
||||
msgEvent := &core.RealtimeMessageEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
Message: &msg,
|
||||
}
|
||||
msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+e.Client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Flush()
|
||||
return nil
|
||||
})
|
||||
if msgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend error:", err)
|
||||
}
|
||||
|
||||
idleTimer.Stop()
|
||||
idleTimer.Reset(idleDuration)
|
||||
|
||||
+52
-1
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
)
|
||||
|
||||
@@ -25,7 +27,56 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
`data:{"clientId":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeAfterMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "PB_CONNECT interrupt",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
|
||||
if e.Message.Name == "PB_CONNECT" {
|
||||
return errors.New("PB_CONNECT error")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Skipping/ignoring messages",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeAfterMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
|
||||
return hook.StopPropagation
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
|
||||
+146
-30
@@ -35,8 +35,8 @@ func bindRecordAuthApi(app core.App, rg *echo.Group) {
|
||||
|
||||
subGroup.GET("/auth-methods", api.authMethods)
|
||||
subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
|
||||
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2) // allow anyone so that we can link the OAuth2 profile with the authenticated record
|
||||
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
|
||||
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2)
|
||||
subGroup.POST("/auth-with-password", api.authWithPassword)
|
||||
subGroup.POST("/request-password-reset", api.requestPasswordReset)
|
||||
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
|
||||
subGroup.POST("/request-verification", api.requestVerification)
|
||||
@@ -231,7 +231,7 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
}
|
||||
|
||||
if _, err := txDao.FindRecordById(collection.Id, createForm.Id, createRuleFunc); err != nil {
|
||||
return fmt.Errorf("Failed create rule constraint: %v", err)
|
||||
return fmt.Errorf("Failed create rule constraint: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -283,15 +283,39 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
// run in background because we don't need to show
|
||||
// the result to the user (prevents users enumeration)
|
||||
routine.FireAndForget(func() {
|
||||
if err := form.Submit(); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
event := &core.RecordRequestPasswordResetEvent{
|
||||
HttpContext: c,
|
||||
}
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event)
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
@@ -305,12 +329,29 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return NewBadRequestError("Failed to set new password.", submitErr)
|
||||
event := &core.RecordConfirmPasswordResetEvent{
|
||||
HttpContext: c,
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) requestVerification(c echo.Context) error {
|
||||
@@ -328,15 +369,39 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
// run in background because we don't need to show
|
||||
// the result to the user (prevents users enumeration)
|
||||
routine.FireAndForget(func() {
|
||||
if err := form.Submit(); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
event := &core.RecordRequestVerificationEvent{
|
||||
HttpContext: c,
|
||||
}
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterRequestVerificationRequest().Trigger(event)
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
@@ -350,12 +415,29 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", submitErr)
|
||||
event := &core.RecordConfirmVerificationEvent{
|
||||
HttpContext: c,
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
|
||||
@@ -369,11 +451,28 @@ func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Submit(); err != nil {
|
||||
return NewBadRequestError("Failed to request email change.", err)
|
||||
event := &core.RecordRequestEmailChangeEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to request email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
@@ -387,12 +486,29 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return NewBadRequestError("Failed to confirm email change.", submitErr)
|
||||
event := &core.RecordConfirmEmailChangeEvent{
|
||||
HttpContext: c,
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to confirm email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
|
||||
|
||||
+90
-39
@@ -66,26 +66,6 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
|
||||
func TestRecordAuthWithPassword(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "authenticated record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authenticated admin",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "invalid body format",
|
||||
Method: http.MethodPost,
|
||||
@@ -226,6 +206,52 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// with already authenticated record or admin
|
||||
{
|
||||
Name: "authenticated record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated admin",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -346,10 +372,12 @@ func TestRecordAuthRequestPasswordReset(t *testing.T) {
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeRecordResetPasswordSend": 1,
|
||||
"OnMailerAfterRecordResetPasswordSend": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnRecordBeforeRequestPasswordResetRequest": 1,
|
||||
"OnRecordAfterRequestPasswordResetRequest": 1,
|
||||
"OnMailerBeforeRecordResetPasswordSend": 1,
|
||||
"OnMailerAfterRecordResetPasswordSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -466,8 +494,10 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnRecordAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -518,6 +548,10 @@ func TestRecordAuthRequestVerification(t *testing.T) {
|
||||
Body: strings.NewReader(`{"email":"test2@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeRequestVerificationRequest": 1,
|
||||
"OnRecordAfterRequestVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing auth record",
|
||||
@@ -527,10 +561,12 @@ func TestRecordAuthRequestVerification(t *testing.T) {
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeRecordVerificationSend": 1,
|
||||
"OnMailerAfterRecordVerificationSend": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnRecordBeforeRequestVerificationRequest": 1,
|
||||
"OnRecordAfterRequestVerificationRequest": 1,
|
||||
"OnMailerBeforeRecordVerificationSend": 1,
|
||||
"OnMailerAfterRecordVerificationSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -540,6 +576,10 @@ func TestRecordAuthRequestVerification(t *testing.T) {
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
// "OnRecordBeforeRequestVerificationRequest": 1,
|
||||
// "OnRecordAfterRequestVerificationRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// simulate recent verification sent
|
||||
authRecord, err := app.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
@@ -627,8 +667,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmVerificationRequest": 1,
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -639,7 +681,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg"
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeConfirmVerificationRequest": 1,
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid verification token from a collection without allowed login",
|
||||
@@ -651,8 +696,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
ExpectedStatus: 204,
|
||||
ExpectedContent: []string{},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmVerificationRequest": 1,
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -751,8 +798,10 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnMailerBeforeRecordChangeEmailSend": 1,
|
||||
"OnMailerAfterRecordChangeEmailSend": 1,
|
||||
"OnMailerBeforeRecordChangeEmailSend": 1,
|
||||
"OnMailerAfterRecordChangeEmailSend": 1,
|
||||
"OnRecordBeforeRequestEmailChangeRequest": 1,
|
||||
"OnRecordAfterRequestEmailChangeRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -833,8 +882,10 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmEmailChangeRequest": 1,
|
||||
"OnRecordAfterConfirmEmailChangeRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+26
-9
@@ -74,7 +74,7 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
}
|
||||
|
||||
var rawRecords = []dbx.NullStringMap{}
|
||||
result, err := searchProvider.ParseAndExec(c.QueryString(), &rawRecords)
|
||||
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &rawRecords)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Invalid filter parameters.", err)
|
||||
}
|
||||
@@ -166,6 +166,20 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
|
||||
// temporary save the record and check it against the create rule
|
||||
if requestData.Admin == nil && collection.CreateRule != nil {
|
||||
testRecord := models.NewRecord(collection)
|
||||
|
||||
// replace modifiers fields so that the resolved value is always
|
||||
// available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
requestData.Data = testRecord.ReplaceModifers(requestData.Data)
|
||||
}
|
||||
|
||||
testForm := forms.NewRecordUpsert(api.app, testRecord)
|
||||
testForm.SetFullManageAccess(true)
|
||||
if err := testForm.LoadRequest(c.Request(), ""); err != nil {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *collection.CreateRule == "" {
|
||||
return nil // no create rule to resolve
|
||||
@@ -181,17 +195,10 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
testRecord := models.NewRecord(collection)
|
||||
testForm := forms.NewRecordUpsert(api.app, testRecord)
|
||||
testForm.SetFullManageAccess(true)
|
||||
if err := testForm.LoadRequest(c.Request(), ""); err != nil {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DrySubmit create rule failure: %v", err)
|
||||
return fmt.Errorf("DrySubmit create rule failure: %w", err)
|
||||
}
|
||||
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
|
||||
return nil
|
||||
@@ -258,6 +265,16 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
// eager fetch the record so that the modifier field values are replaced
|
||||
// and available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
|
||||
if err != nil || record == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
requestData.Data = record.ReplaceModifers(requestData.Data)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
|
||||
+163
-5
@@ -2,6 +2,7 @@ package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -209,6 +210,50 @@ func TestRecordCrudList(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: ":rule modifer",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo5/records",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":1`,
|
||||
`"items":[{`,
|
||||
`"id":"qjeql998mtp1azp"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "multi-match - at least one of",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":1`,
|
||||
`"items":[{`,
|
||||
`"id":"qzaqccwrmva4o1n"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "multi-match - all",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":0`,
|
||||
`"totalItems":0`,
|
||||
`"items":[]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
|
||||
// auth collection checks
|
||||
// -----------------------------------------------------------
|
||||
@@ -684,7 +729,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated record that does match the collection delete rule",
|
||||
Name: "authenticated record that match the collection delete rule",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/users/records/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
@@ -693,9 +738,9 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnModelAfterDelete": 3, // +2 because of the external auths
|
||||
"OnModelBeforeDelete": 3, // +2 because of the external auths
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
@@ -716,6 +761,25 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "@request :isset (rule failure check)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "@request :isset (rule pass check)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/demo5/records/la4y2w4o98acwuj?test=1",
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// cascade delete checks
|
||||
// -----------------------------------------------------------
|
||||
@@ -730,8 +794,8 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
"OnModelBeforeUpdate": 1, // self_rel_many update of test1 record
|
||||
"OnModelBeforeDelete": 1, // rel_one_cascade of test1 record
|
||||
"OnModelBeforeUpdate": 2, // self_rel_many update of test1 record + rel_one_cascade demo4 cascaded in demo5
|
||||
"OnModelBeforeDelete": 2, // the record itself + rel_one_cascade of test1 record
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1092,6 +1156,63 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
|
||||
// fields modifier checks
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "trying to delete a record while being part of a non-cascade required relation",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/demo3/records/7nwo8tuiatetxdm",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
"OnModelBeforeUpdate": 2, // self_rel_many update of test1 record + rel_one_cascade demo4 cascaded in demo5
|
||||
"OnModelBeforeDelete": 2, // the record itself + rel_one_cascade of test1 record
|
||||
},
|
||||
},
|
||||
|
||||
// check whether if @request.data modifer fields are properly resolved
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "@request.data.field with compute modifers (rule failure check)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo5/records",
|
||||
Body: strings.NewReader(`{
|
||||
"total":1,
|
||||
"total+":4,
|
||||
"total-":1
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "@request.data.field with compute modifers (rule pass check)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo5/records",
|
||||
Body: strings.NewReader(`{
|
||||
"total":1,
|
||||
"total+":3,
|
||||
"total-":1
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"`,
|
||||
`"collectionName":"demo5"`,
|
||||
`"total":3`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnRecordAfterCreateRequest": 1,
|
||||
"OnRecordBeforeCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// auth records
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
@@ -1501,6 +1622,43 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
|
||||
// check whether if @request.data modifer fields are properly resolved
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "@request.data.field with compute modifers (rule failure check)",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
|
||||
Body: strings.NewReader(`{
|
||||
"total+":3,
|
||||
"total-":1
|
||||
}`),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "@request.data.field with compute modifers (rule pass check)",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
|
||||
Body: strings.NewReader(`{
|
||||
"total+":2,
|
||||
"total-":1
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"la4y2w4o98acwuj"`,
|
||||
`"collectionName":"demo5"`,
|
||||
`"total":3`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// auth records
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
|
||||
@@ -15,11 +15,6 @@ import (
|
||||
|
||||
const ContextRequestDataKey = "requestData"
|
||||
|
||||
// Deprecated: Will be removed after v0.9. Use apis.RequestData(c) instead.
|
||||
func GetRequestData(c echo.Context) *models.RequestData {
|
||||
return RequestData(c)
|
||||
}
|
||||
|
||||
// RequestData exports cached common request data fields
|
||||
// (query, body, logged auth state, etc.) from the provided context.
|
||||
func RequestData(c echo.Context) *models.RequestData {
|
||||
@@ -62,7 +57,7 @@ func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defa
|
||||
requestData := RequestData(c)
|
||||
|
||||
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil {
|
||||
return fmt.Errorf("Failed to resolve email visibility: %v", err)
|
||||
return fmt.Errorf("Failed to resolve email visibility: %w", err)
|
||||
}
|
||||
|
||||
expands := defaultExpands
|
||||
@@ -138,10 +133,10 @@ func autoIgnoreAuthRecordsEmailVisibility(
|
||||
collection := records[0].Collection()
|
||||
|
||||
mappedRecords := make(map[string]*models.Record, len(records))
|
||||
recordIds := make([]any, 0, len(records))
|
||||
for _, rec := range records {
|
||||
recordIds := make([]any, len(records))
|
||||
for i, rec := range records {
|
||||
mappedRecords[rec.Id] = rec
|
||||
recordIds = append(recordIds, rec.Id)
|
||||
recordIds[i] = rec.Id
|
||||
}
|
||||
|
||||
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
|
||||
|
||||
+7
-3
@@ -1,6 +1,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -91,14 +92,17 @@ func (api *settingsApi) testS3(c echo.Context) error {
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
testFileKey := "pb_test_" + security.PseudorandomString(5) + "/test.txt"
|
||||
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
|
||||
testFileKey := testPrefix + "/test.txt"
|
||||
|
||||
// try to upload a test file
|
||||
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
|
||||
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
if err := fs.Delete(testFileKey); err != nil {
|
||||
return NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
|
||||
// test prefix deletion (ensures that both bucket list and delete works)
|
||||
if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
|
||||
return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
|
||||
+19
-13
@@ -58,6 +58,8 @@ func TestSettingsList(t *testing.T) {
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -125,9 +127,11 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"Acme"`,
|
||||
`"appName":"acme_test"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
@@ -181,6 +185,8 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
@@ -303,12 +309,12 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastToAddress.Address != "test@example.com" {
|
||||
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address)
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastHtmlBody, "Verify") {
|
||||
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
|
||||
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
@@ -334,12 +340,12 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastToAddress.Address != "test@example.com" {
|
||||
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address)
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastHtmlBody, "Reset password") {
|
||||
t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
|
||||
t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
@@ -365,12 +371,12 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastToAddress.Address != "test@example.com" {
|
||||
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address)
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastHtmlBody, "Confirm new email") {
|
||||
t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") {
|
||||
t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
|
||||
-241
@@ -1,241 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewMigrateCommand creates and returns new command for handling DB migrations.
|
||||
func NewMigrateCommand(app core.App) *cobra.Command {
|
||||
desc := `
|
||||
Supported arguments are:
|
||||
- up - runs all available migrations.
|
||||
- down [number] - reverts the last [number] applied migrations.
|
||||
- create name [folder] - creates new migration template file.
|
||||
- collections [folder] - (Experimental) creates new migration file with the most recent local collections configuration.
|
||||
`
|
||||
var databaseFlag string
|
||||
|
||||
command := &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Executes DB migration scripts",
|
||||
ValidArgs: []string{"up", "down", "create", "collections"},
|
||||
Long: desc,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
cmd := ""
|
||||
if len(args) > 0 {
|
||||
cmd = args[0]
|
||||
}
|
||||
|
||||
// additional commands
|
||||
// ---
|
||||
if cmd == "create" {
|
||||
if err := migrateCreateHandler(defaultMigrateCreateTemplate, args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if cmd == "collections" {
|
||||
if err := migrateCollectionsHandler(app, args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// ---
|
||||
|
||||
// normalize
|
||||
if databaseFlag != "logs" {
|
||||
databaseFlag = "db"
|
||||
}
|
||||
|
||||
connections := migrationsConnectionsMap(app)
|
||||
|
||||
runner, err := migrate.NewRunner(
|
||||
connections[databaseFlag].DB,
|
||||
connections[databaseFlag].MigrationsList,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := runner.Run(args...); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
command.PersistentFlags().StringVar(
|
||||
&databaseFlag,
|
||||
"database",
|
||||
"db",
|
||||
"specify the database connection to use (db or logs)",
|
||||
)
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
type migrationsConnection struct {
|
||||
DB *dbx.DB
|
||||
MigrationsList migrate.MigrationsList
|
||||
}
|
||||
|
||||
func migrationsConnectionsMap(app core.App) map[string]migrationsConnection {
|
||||
return map[string]migrationsConnection{
|
||||
"db": {
|
||||
DB: app.DB(),
|
||||
MigrationsList: migrations.AppMigrations,
|
||||
},
|
||||
"logs": {
|
||||
DB: app.LogsDB(),
|
||||
MigrationsList: logs.LogsMigrations,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// migrate create
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const defaultMigrateCreateTemplate = `package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(db dbx.Builder) error {
|
||||
// add up queries...
|
||||
|
||||
return nil
|
||||
}, func(db dbx.Builder) error {
|
||||
// add down queries...
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
func migrateCreateHandler(template string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("Missing migration file name")
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
|
||||
var dir string
|
||||
if len(args) == 2 {
|
||||
dir = args[1]
|
||||
}
|
||||
if dir == "" {
|
||||
// If not specified, auto point to the default migrations folder.
|
||||
//
|
||||
// NB!
|
||||
// Since the create command makes sense only during development,
|
||||
// it is expected the user to be in the app working directory
|
||||
// and to be using `go run`
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir = path.Join(wd, "migrations")
|
||||
}
|
||||
|
||||
resultFilePath := path.Join(
|
||||
dir,
|
||||
fmt.Sprintf("%d_%s.go", time.Now().Unix(), inflector.Snakecase(name)),
|
||||
)
|
||||
|
||||
confirm := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
|
||||
}
|
||||
survey.AskOne(prompt, &confirm)
|
||||
if !confirm {
|
||||
fmt.Println("The command has been cancelled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensure that migrations dir exist
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil {
|
||||
return fmt.Errorf("Failed to save migration file %q\n", resultFilePath)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully created file %q\n", resultFilePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// migrate collections
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const collectionsMigrateCreateTemplate = `package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// Auto generated migration with the most recent collections configuration.
|
||||
func init() {
|
||||
m.Register(func(db dbx.Builder) error {
|
||||
jsonData := ` + "`" + `%s` + "`" + `
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return daos.New(db).ImportCollections(collections, true, nil)
|
||||
}, func(db dbx.Builder) error {
|
||||
// no revert since the configuration on the environment, on which
|
||||
// the migration was executed, could have changed via the UI/API
|
||||
return nil
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
func migrateCollectionsHandler(app core.App, args []string) error {
|
||||
createArgs := []string{"collections_snapshot"}
|
||||
createArgs = append(createArgs, args...)
|
||||
|
||||
dao := daos.New(app.DB())
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
|
||||
return fmt.Errorf("Failed to fetch migrations list: %v", err)
|
||||
}
|
||||
|
||||
serialized, err := json.MarshalIndent(collections, "\t\t", "\t")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to serialize collections list: %v", err)
|
||||
}
|
||||
|
||||
return migrateCreateHandler(
|
||||
fmt.Sprintf(collectionsMigrateCreateTemplate, string(serialized)),
|
||||
createArgs,
|
||||
)
|
||||
}
|
||||
+21
-3
@@ -10,8 +10,11 @@ import (
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/acme"
|
||||
@@ -38,7 +41,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
// (or if this is the first time the init migration was executed)
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
color.Yellow("=====================================")
|
||||
color.Yellow("WARNING - Settings load error! \n%v", err)
|
||||
color.Yellow("WARNING: Settings load error! \n%v", err)
|
||||
color.Yellow("Fallback to the application defaults.")
|
||||
color.Yellow("=====================================")
|
||||
}
|
||||
@@ -75,7 +78,8 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
GetCertificate: certManager.GetCertificate,
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
},
|
||||
ReadTimeout: 60 * time.Second,
|
||||
ReadTimeout: 5 * time.Minute,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
// WriteTimeout: 60 * time.Second, // breaks sse!
|
||||
Handler: router,
|
||||
Addr: mainAddr,
|
||||
@@ -137,8 +141,22 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
return command
|
||||
}
|
||||
|
||||
type migrationsConnection struct {
|
||||
DB *dbx.DB
|
||||
MigrationsList migrate.MigrationsList
|
||||
}
|
||||
|
||||
func runMigrations(app core.App) error {
|
||||
connections := migrationsConnectionsMap(app)
|
||||
connections := []migrationsConnection{
|
||||
{
|
||||
DB: app.DB(),
|
||||
MigrationsList: migrations.AppMigrations,
|
||||
},
|
||||
{
|
||||
DB: app.LogsDB(),
|
||||
MigrationsList: logs.LogsMigrations,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range connections {
|
||||
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
// Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes.
|
||||
//
|
||||
// NB! It will be removed in v0.9.0!
|
||||
// NB! It will be removed in v0.9+
|
||||
func NewTempUpgradeCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
|
||||
+120
-4
@@ -6,6 +6,7 @@ package core
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
@@ -15,6 +16,11 @@ import (
|
||||
|
||||
// App defines the main PocketBase app interface.
|
||||
type App interface {
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the app db instance from app.Dao().DB() or
|
||||
// if you want more flexibility - app.Dao().ConcurrentDB() and app.Dao().NonconcurrentDB().
|
||||
//
|
||||
// DB returns the default app database instance.
|
||||
DB() *dbx.DB
|
||||
|
||||
@@ -25,6 +31,11 @@ type App interface {
|
||||
// trying to access the request logs table will result in error.
|
||||
Dao() *daos.Dao
|
||||
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the logs db instance from app.LogsDao().DB() or
|
||||
// if you want more flexibility - app.LogsDao().ConcurrentDB() and app.LogsDao().NonconcurrentDB().
|
||||
//
|
||||
// LogsDB returns the app logs database instance.
|
||||
LogsDB() *dbx.DB
|
||||
|
||||
@@ -47,7 +58,7 @@ type App interface {
|
||||
IsDebug() bool
|
||||
|
||||
// Settings returns the loaded app settings.
|
||||
Settings() *Settings
|
||||
Settings() *settings.Settings
|
||||
|
||||
// Cache returns the app internal cache store.
|
||||
Cache() *store.Store[any]
|
||||
@@ -67,8 +78,14 @@ type App interface {
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
RefreshSettings() error
|
||||
|
||||
// IsBootstrapped checks if the application was initialized
|
||||
// (aka. whether Bootstrap() was called).
|
||||
IsBootstrapped() bool
|
||||
|
||||
// Bootstrap takes care for initializing the application
|
||||
// (open db connections, load settings, etc.)
|
||||
// (open db connections, load settings, etc.).
|
||||
//
|
||||
// It will call ResetBootstrapState() if the application was already bootstrapped.
|
||||
Bootstrap() error
|
||||
|
||||
// ResetBootstrapState takes care for releasing initialized app resources
|
||||
@@ -79,10 +96,28 @@ type App interface {
|
||||
// App event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnBeforeBootstrap hook is triggered before initializing the base
|
||||
// application resources (eg. before db open and initial settings load).
|
||||
OnBeforeBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnAfterBootstrap hook is triggered after initializing the base
|
||||
// application resources (eg. after db open and initial settings load).
|
||||
OnAfterBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnBeforeServe hook is triggered before serving the internal router (echo),
|
||||
// allowing you to adjust its options and attach new routes.
|
||||
OnBeforeServe() *hook.Hook[*ServeEvent]
|
||||
|
||||
// OnBeforeApiError hook is triggered right before sending an error API
|
||||
// response to the client, allowing you to further modify the error data
|
||||
// or to return a completely different API response (using [hook.StopPropagation]).
|
||||
OnBeforeApiError() *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// OnAfterApiError hook is triggered right after sending an error API
|
||||
// response to the client.
|
||||
// It could be used to log the final API error in external services.
|
||||
OnAfterApiError() *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Dao event hooks
|
||||
// ---------------------------------------------------------------
|
||||
@@ -167,6 +202,21 @@ type App interface {
|
||||
// the SSE client connection.
|
||||
OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent]
|
||||
|
||||
// OnRealtimeDisconnectRequest hook is triggered on disconnected/interrupted
|
||||
// SSE client connection.
|
||||
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
|
||||
|
||||
// OnRealtimeBeforeMessage hook is triggered right before sending
|
||||
// an SSE message to a client.
|
||||
//
|
||||
// Returning [hook.StopPropagation] will prevent sending the message.
|
||||
// Returning any other non-nil error will close the realtime connection.
|
||||
OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
|
||||
|
||||
// OnRealtimeBeforeMessage hook is triggered right after sending
|
||||
// an SSE message to a client.
|
||||
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
|
||||
|
||||
// OnRealtimeBeforeSubscribeRequest hook is triggered before changing
|
||||
// the client subscriptions, allowing you to further validate and
|
||||
// modify the submitted change.
|
||||
@@ -264,7 +314,7 @@ type App interface {
|
||||
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Auth Record API event hooks
|
||||
// Record Auth API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnRecordAuthRequest hook is triggered on each successful API
|
||||
@@ -274,6 +324,72 @@ type App interface {
|
||||
// record data and token.
|
||||
OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
|
||||
|
||||
// OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record
|
||||
// request password reset API request (after request data load and before sending the reset email).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different password reset behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
|
||||
|
||||
// OnRecordAfterRequestPasswordResetRequest hook is triggered after each
|
||||
// successful request password reset API request.
|
||||
OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
|
||||
|
||||
// OnRecordBeforeConfirmPasswordResetRequest hook is triggered before each Record
|
||||
// confirm password reset API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent]
|
||||
|
||||
// OnRecordAfterConfirmPasswordResetRequest hook is triggered after each
|
||||
// successful confirm password reset API request.
|
||||
OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent]
|
||||
|
||||
// OnRecordBeforeRequestVerificationRequest hook is triggered before each Record
|
||||
// request verification API request (after request data load and before sending the verification email).
|
||||
//
|
||||
// Could be used to additionally validate the loaded request data or implement
|
||||
// completely different verification behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
|
||||
|
||||
// OnRecordAfterRequestVerificationRequest hook is triggered after each
|
||||
// successful request verification API request.
|
||||
OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
|
||||
|
||||
// OnRecordBeforeConfirmVerificationRequest hook is triggered before each Record
|
||||
// confirm verification API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent]
|
||||
|
||||
// OnRecordAfterConfirmVerificationRequest hook is triggered after each
|
||||
// successful confirm verification API request.
|
||||
OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent]
|
||||
|
||||
// OnRecordBeforeRequestEmailChangeRequest hook is triggered before each Record request email change API request
|
||||
// (after request data load and before sending the email link to confirm the change).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different request email change behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
|
||||
|
||||
// OnRecordAfterRequestEmailChangeRequest hook is triggered after each
|
||||
// successful request email change API request.
|
||||
OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
|
||||
|
||||
// OnRecordBeforeConfirmEmailChangeRequest hook is triggered before each Record
|
||||
// confirm email change API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent]
|
||||
|
||||
// OnRecordAfterConfirmEmailChangeRequest hook is triggered after each
|
||||
// successful confirm email change API request.
|
||||
OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent]
|
||||
|
||||
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
@@ -291,7 +407,7 @@ type App interface {
|
||||
OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record API event hooks
|
||||
// Record CRUD API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnRecordsListRequest hook is triggered on each API Records list request.
|
||||
|
||||
+311
-118
@@ -1,10 +1,8 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
@@ -15,34 +13,47 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDataMaxOpenConns int = 100
|
||||
DefaultDataMaxIdleConns int = 20
|
||||
DefaultLogsMaxOpenConns int = 10
|
||||
DefaultLogsMaxIdleConns int = 2
|
||||
)
|
||||
|
||||
var _ App = (*BaseApp)(nil)
|
||||
|
||||
// BaseApp implements core.App and defines the base PocketBase app structure.
|
||||
type BaseApp struct {
|
||||
// configurable parameters
|
||||
isDebug bool
|
||||
dataDir string
|
||||
encryptionEnv string
|
||||
isDebug bool
|
||||
dataDir string
|
||||
encryptionEnv string
|
||||
dataMaxOpenConns int
|
||||
dataMaxIdleConns int
|
||||
logsMaxOpenConns int
|
||||
logsMaxIdleConns int
|
||||
|
||||
// internals
|
||||
cache *store.Store[any]
|
||||
settings *Settings
|
||||
db *dbx.DB
|
||||
settings *settings.Settings
|
||||
dao *daos.Dao
|
||||
logsDB *dbx.DB
|
||||
logsDao *daos.Dao
|
||||
subscriptionsBroker *subscriptions.Broker
|
||||
|
||||
// serve event hooks
|
||||
onBeforeServe *hook.Hook[*ServeEvent]
|
||||
// app event hooks
|
||||
onBeforeBootstrap *hook.Hook[*BootstrapEvent]
|
||||
onAfterBootstrap *hook.Hook[*BootstrapEvent]
|
||||
onBeforeServe *hook.Hook[*ServeEvent]
|
||||
onBeforeApiError *hook.Hook[*ApiErrorEvent]
|
||||
onAfterApiError *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// dao event hooks
|
||||
onModelBeforeCreate *hook.Hook[*ModelEvent]
|
||||
@@ -64,6 +75,9 @@ type BaseApp struct {
|
||||
|
||||
// realtime api event hooks
|
||||
onRealtimeConnectRequest *hook.Hook[*RealtimeConnectEvent]
|
||||
onRealtimeDisconnectRequest *hook.Hook[*RealtimeDisconnectEvent]
|
||||
onRealtimeBeforeMessageSend *hook.Hook[*RealtimeMessageEvent]
|
||||
onRealtimeAfterMessageSend *hook.Hook[*RealtimeMessageEvent]
|
||||
onRealtimeBeforeSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent]
|
||||
onRealtimeAfterSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent]
|
||||
|
||||
@@ -86,13 +100,25 @@ type BaseApp struct {
|
||||
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
|
||||
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
|
||||
|
||||
// user api event hooks
|
||||
onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
|
||||
onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent]
|
||||
onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
// record auth API event hooks
|
||||
onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
|
||||
onRecordBeforeRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
|
||||
onRecordAfterRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
|
||||
onRecordBeforeConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
|
||||
onRecordAfterConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
|
||||
onRecordBeforeRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent]
|
||||
onRecordAfterRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent]
|
||||
onRecordBeforeConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent]
|
||||
onRecordAfterConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent]
|
||||
onRecordBeforeRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent]
|
||||
onRecordAfterRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent]
|
||||
onRecordBeforeConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent]
|
||||
onRecordAfterConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent]
|
||||
onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent]
|
||||
onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// record api event hooks
|
||||
// record crud API event hooks
|
||||
onRecordsListRequest *hook.Hook[*RecordsListEvent]
|
||||
onRecordViewRequest *hook.Hook[*RecordViewEvent]
|
||||
onRecordBeforeCreateRequest *hook.Hook[*RecordCreateEvent]
|
||||
@@ -102,7 +128,7 @@ type BaseApp struct {
|
||||
onRecordBeforeDeleteRequest *hook.Hook[*RecordDeleteEvent]
|
||||
onRecordAfterDeleteRequest *hook.Hook[*RecordDeleteEvent]
|
||||
|
||||
// collection api event hooks
|
||||
// collection API event hooks
|
||||
onCollectionsListRequest *hook.Hook[*CollectionsListEvent]
|
||||
onCollectionViewRequest *hook.Hook[*CollectionViewEvent]
|
||||
onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent]
|
||||
@@ -115,21 +141,40 @@ type BaseApp struct {
|
||||
onCollectionsAfterImportRequest *hook.Hook[*CollectionsImportEvent]
|
||||
}
|
||||
|
||||
// BaseAppConfig defines a BaseApp configuration option
|
||||
type BaseAppConfig struct {
|
||||
DataDir string
|
||||
EncryptionEnv string
|
||||
IsDebug bool
|
||||
DataMaxOpenConns int // default to 500
|
||||
DataMaxIdleConns int // default 20
|
||||
LogsMaxOpenConns int // default to 100
|
||||
LogsMaxIdleConns int // default to 5
|
||||
}
|
||||
|
||||
// NewBaseApp creates and returns a new BaseApp instance
|
||||
// configured with the provided arguments.
|
||||
//
|
||||
// To initialize the app, you need to call `app.Bootstrap()`.
|
||||
func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||
func NewBaseApp(config *BaseAppConfig) *BaseApp {
|
||||
app := &BaseApp{
|
||||
dataDir: dataDir,
|
||||
isDebug: isDebug,
|
||||
encryptionEnv: encryptionEnv,
|
||||
dataDir: config.DataDir,
|
||||
isDebug: config.IsDebug,
|
||||
encryptionEnv: config.EncryptionEnv,
|
||||
dataMaxOpenConns: config.DataMaxOpenConns,
|
||||
dataMaxIdleConns: config.DataMaxIdleConns,
|
||||
logsMaxOpenConns: config.LogsMaxOpenConns,
|
||||
logsMaxIdleConns: config.LogsMaxIdleConns,
|
||||
cache: store.New[any](nil),
|
||||
settings: NewSettings(),
|
||||
settings: settings.New(),
|
||||
subscriptionsBroker: subscriptions.NewBroker(),
|
||||
|
||||
// serve event hooks
|
||||
onBeforeServe: &hook.Hook[*ServeEvent]{},
|
||||
// app event hooks
|
||||
onBeforeBootstrap: &hook.Hook[*BootstrapEvent]{},
|
||||
onAfterBootstrap: &hook.Hook[*BootstrapEvent]{},
|
||||
onBeforeServe: &hook.Hook[*ServeEvent]{},
|
||||
onBeforeApiError: &hook.Hook[*ApiErrorEvent]{},
|
||||
onAfterApiError: &hook.Hook[*ApiErrorEvent]{},
|
||||
|
||||
// dao event hooks
|
||||
onModelBeforeCreate: &hook.Hook[*ModelEvent]{},
|
||||
@@ -151,6 +196,9 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||
|
||||
// realtime API event hooks
|
||||
onRealtimeConnectRequest: &hook.Hook[*RealtimeConnectEvent]{},
|
||||
onRealtimeDisconnectRequest: &hook.Hook[*RealtimeDisconnectEvent]{},
|
||||
onRealtimeBeforeMessageSend: &hook.Hook[*RealtimeMessageEvent]{},
|
||||
onRealtimeAfterMessageSend: &hook.Hook[*RealtimeMessageEvent]{},
|
||||
onRealtimeBeforeSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{},
|
||||
onRealtimeAfterSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{},
|
||||
|
||||
@@ -173,13 +221,25 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||
onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
|
||||
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
|
||||
|
||||
// user API event hooks
|
||||
onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
|
||||
onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{},
|
||||
onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
|
||||
onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
|
||||
// record auth API event hooks
|
||||
onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
|
||||
onRecordBeforeRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
|
||||
onRecordAfterRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
|
||||
onRecordBeforeConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{},
|
||||
onRecordAfterConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{},
|
||||
onRecordBeforeRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{},
|
||||
onRecordAfterRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{},
|
||||
onRecordBeforeConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{},
|
||||
onRecordAfterConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{},
|
||||
onRecordBeforeRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{},
|
||||
onRecordAfterRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{},
|
||||
onRecordBeforeConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{},
|
||||
onRecordAfterConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{},
|
||||
onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{},
|
||||
onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
|
||||
onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
|
||||
|
||||
// record API event hooks
|
||||
// record crud API event hooks
|
||||
onRecordsListRequest: &hook.Hook[*RecordsListEvent]{},
|
||||
onRecordViewRequest: &hook.Hook[*RecordViewEvent]{},
|
||||
onRecordBeforeCreateRequest: &hook.Hook[*RecordCreateEvent]{},
|
||||
@@ -207,9 +267,23 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||
return app
|
||||
}
|
||||
|
||||
// IsBootstrapped checks if the application was initialized
|
||||
// (aka. whether Bootstrap() was called).
|
||||
func (app *BaseApp) IsBootstrapped() bool {
|
||||
return app.dao != nil && app.logsDao != nil && app.settings != nil
|
||||
}
|
||||
|
||||
// Bootstrap initializes the application
|
||||
// (aka. create data dir, open db connections, load settings, etc.)
|
||||
// (aka. create data dir, open db connections, load settings, etc.).
|
||||
//
|
||||
// It will call ResetBootstrapState() if the application was already bootstrapped.
|
||||
func (app *BaseApp) Bootstrap() error {
|
||||
event := &BootstrapEvent{app}
|
||||
|
||||
if err := app.OnBeforeBootstrap().Trigger(event); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// clear resources of previous core state (if any)
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
return err
|
||||
@@ -228,24 +302,33 @@ func (app *BaseApp) Bootstrap() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// we don't check for an error because the db migrations may
|
||||
// have not been executed yet.
|
||||
// we don't check for an error because the db migrations may have not been executed yet
|
||||
app.RefreshSettings()
|
||||
|
||||
if err := app.OnAfterBootstrap().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetBootstrapState takes care for releasing initialized app resources
|
||||
// (eg. closing db connections).
|
||||
func (app *BaseApp) ResetBootstrapState() error {
|
||||
if app.db != nil {
|
||||
if err := app.db.Close(); err != nil {
|
||||
if app.Dao() != nil {
|
||||
if err := app.Dao().ConcurrentDB().(*dbx.DB).Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Dao().NonconcurrentDB().(*dbx.DB).Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if app.logsDB != nil {
|
||||
if err := app.logsDB.Close(); err != nil {
|
||||
if app.LogsDao() != nil {
|
||||
if err := app.LogsDao().ConcurrentDB().(*dbx.DB).Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.LogsDao().NonconcurrentDB().(*dbx.DB).Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -257,9 +340,23 @@ func (app *BaseApp) ResetBootstrapState() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the db instance from app.Dao().DB() or
|
||||
// if you want more flexibility - app.Dao().ConcurrentDB() and app.Dao().NonconcurrentDB().
|
||||
//
|
||||
// DB returns the default app database instance.
|
||||
func (app *BaseApp) DB() *dbx.DB {
|
||||
return app.db
|
||||
if app.Dao() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
db, ok := app.Dao().DB().(*dbx.DB)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// Dao returns the default app Dao instance.
|
||||
@@ -267,9 +364,23 @@ func (app *BaseApp) Dao() *daos.Dao {
|
||||
return app.dao
|
||||
}
|
||||
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the logs db instance from app.LogsDao().DB() or
|
||||
// if you want more flexibility - app.LogsDao().ConcurrentDB() and app.LogsDao().NonconcurrentDB().
|
||||
//
|
||||
// LogsDB returns the app logs database instance.
|
||||
func (app *BaseApp) LogsDB() *dbx.DB {
|
||||
return app.logsDB
|
||||
if app.LogsDao() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
db, ok := app.LogsDao().DB().(*dbx.DB)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// LogsDao returns the app logs Dao instance.
|
||||
@@ -295,7 +406,7 @@ func (app *BaseApp) IsDebug() bool {
|
||||
}
|
||||
|
||||
// Settings returns the loaded app settings.
|
||||
func (app *BaseApp) Settings() *Settings {
|
||||
func (app *BaseApp) Settings() *settings.Settings {
|
||||
return app.settings
|
||||
}
|
||||
|
||||
@@ -313,13 +424,14 @@ func (app *BaseApp) SubscriptionsBroker() *subscriptions.Broker {
|
||||
// based on the current app settings.
|
||||
func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
if app.Settings().Smtp.Enabled {
|
||||
return mailer.NewSmtpClient(
|
||||
app.Settings().Smtp.Host,
|
||||
app.Settings().Smtp.Port,
|
||||
app.Settings().Smtp.Username,
|
||||
app.Settings().Smtp.Password,
|
||||
app.Settings().Smtp.Tls,
|
||||
)
|
||||
return &mailer.SmtpClient{
|
||||
Host: app.Settings().Smtp.Host,
|
||||
Port: app.Settings().Smtp.Port,
|
||||
Username: app.Settings().Smtp.Username,
|
||||
Password: app.Settings().Smtp.Password,
|
||||
Tls: app.Settings().Smtp.Tls,
|
||||
AuthMethod: app.Settings().Smtp.AuthMethod,
|
||||
}
|
||||
}
|
||||
|
||||
return &mailer.Sendmail{}
|
||||
@@ -349,79 +461,53 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
func (app *BaseApp) RefreshSettings() error {
|
||||
if app.settings == nil {
|
||||
app.settings = NewSettings()
|
||||
app.settings = settings.New()
|
||||
}
|
||||
|
||||
encryptionKey := os.Getenv(app.EncryptionEnv())
|
||||
|
||||
param, err := app.Dao().FindParamByKey(models.ParamAppSettings)
|
||||
storedSettings, err := app.Dao().FindSettings(encryptionKey)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
// no settings were previously stored
|
||||
if param == nil {
|
||||
return app.Dao().SaveParam(models.ParamAppSettings, app.settings, encryptionKey)
|
||||
if storedSettings == nil {
|
||||
return app.Dao().SaveSettings(app.settings, encryptionKey)
|
||||
}
|
||||
|
||||
// load the settings from the stored param into the app ones
|
||||
// ---
|
||||
newSettings := NewSettings()
|
||||
|
||||
// try first without decryption
|
||||
plainDecodeErr := json.Unmarshal(param.Value, newSettings)
|
||||
|
||||
// failed, try to decrypt
|
||||
if plainDecodeErr != nil {
|
||||
// load without decrypt has failed and there is no encryption key to use for decrypt
|
||||
if encryptionKey == "" {
|
||||
return errors.New("Failed to load the stored app settings (missing or invalid encryption key).")
|
||||
}
|
||||
|
||||
// decrypt
|
||||
decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey)
|
||||
if decryptErr != nil {
|
||||
return decryptErr
|
||||
}
|
||||
|
||||
// decode again
|
||||
decryptedDecodeErr := json.Unmarshal(decrypted, newSettings)
|
||||
if decryptedDecodeErr != nil {
|
||||
return decryptedDecodeErr
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.settings.Merge(newSettings); err != nil {
|
||||
if err := app.settings.Merge(storedSettings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
afterMergeRaw, err := json.Marshal(app.settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if
|
||||
// save because previously the settings weren't stored encrypted
|
||||
(plainDecodeErr == nil && encryptionKey != "") ||
|
||||
// or save because there are new fields after the merge
|
||||
!bytes.Equal(param.Value, afterMergeRaw) {
|
||||
saveErr := app.Dao().SaveParam(models.ParamAppSettings, app.settings, encryptionKey)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Serve event hooks
|
||||
// App event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnBeforeBootstrap() *hook.Hook[*BootstrapEvent] {
|
||||
return app.onBeforeBootstrap
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnAfterBootstrap() *hook.Hook[*BootstrapEvent] {
|
||||
return app.onAfterBootstrap
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnBeforeServe() *hook.Hook[*ServeEvent] {
|
||||
return app.onBeforeServe
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnBeforeApiError() *hook.Hook[*ApiErrorEvent] {
|
||||
return app.onBeforeApiError
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
|
||||
return app.onAfterApiError
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Dao event hooks
|
||||
// -------------------------------------------------------------------
|
||||
@@ -494,6 +580,18 @@ func (app *BaseApp) OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent]
|
||||
return app.onRealtimeConnectRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent] {
|
||||
return app.onRealtimeDisconnectRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent] {
|
||||
return app.onRealtimeBeforeMessageSend
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent] {
|
||||
return app.onRealtimeAfterMessageSend
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRealtimeBeforeSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] {
|
||||
return app.onRealtimeBeforeSubscribeRequest
|
||||
}
|
||||
@@ -567,13 +665,61 @@ func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Auth Record API event hooks
|
||||
// Record auth API event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] {
|
||||
return app.onRecordAuthRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
|
||||
return app.onRecordBeforeRequestPasswordResetRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
|
||||
return app.onRecordAfterRequestPasswordResetRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
|
||||
return app.onRecordBeforeConfirmPasswordResetRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
|
||||
return app.onRecordAfterConfirmPasswordResetRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
|
||||
return app.onRecordBeforeRequestVerificationRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
|
||||
return app.onRecordAfterRequestVerificationRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
|
||||
return app.onRecordBeforeConfirmVerificationRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
|
||||
return app.onRecordAfterConfirmVerificationRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
|
||||
return app.onRecordBeforeRequestEmailChangeRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
|
||||
return app.onRecordAfterRequestEmailChangeRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
|
||||
return app.onRecordBeforeConfirmEmailChangeRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
|
||||
return app.onRecordAfterConfirmEmailChangeRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] {
|
||||
return app.onRecordListExternalAuthsRequest
|
||||
}
|
||||
@@ -587,7 +733,7 @@ func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordU
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Record API event hooks
|
||||
// Record CRUD API event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnRecordsListRequest() *hook.Hook[*RecordsListEvent] {
|
||||
@@ -671,50 +817,91 @@ func (app *BaseApp) OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImp
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) initLogsDB() error {
|
||||
var connectErr error
|
||||
app.logsDB, connectErr = connectDB(filepath.Join(app.DataDir(), "logs.db"))
|
||||
if connectErr != nil {
|
||||
return connectErr
|
||||
maxOpenConns := DefaultLogsMaxOpenConns
|
||||
maxIdleConns := DefaultLogsMaxIdleConns
|
||||
if app.logsMaxOpenConns > 0 {
|
||||
maxOpenConns = app.logsMaxOpenConns
|
||||
}
|
||||
if app.logsMaxIdleConns > 0 {
|
||||
maxIdleConns = app.logsMaxIdleConns
|
||||
}
|
||||
|
||||
app.logsDao = daos.New(app.logsDB)
|
||||
concurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
|
||||
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonconcurrentDB.DB().SetMaxOpenConns(1)
|
||||
nonconcurrentDB.DB().SetMaxIdleConns(1)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
app.logsDao = daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *BaseApp) initDataDB() error {
|
||||
var connectErr error
|
||||
app.db, connectErr = connectDB(filepath.Join(app.DataDir(), "data.db"))
|
||||
if connectErr != nil {
|
||||
return connectErr
|
||||
maxOpenConns := DefaultDataMaxOpenConns
|
||||
maxIdleConns := DefaultDataMaxIdleConns
|
||||
if app.dataMaxOpenConns > 0 {
|
||||
maxOpenConns = app.dataMaxOpenConns
|
||||
}
|
||||
if app.dataMaxIdleConns > 0 {
|
||||
maxIdleConns = app.dataMaxIdleConns
|
||||
}
|
||||
|
||||
app.db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
if app.IsDebug() {
|
||||
concurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
|
||||
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonconcurrentDB.DB().SetMaxOpenConns(1)
|
||||
nonconcurrentDB.DB().SetMaxIdleConns(1)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
if app.IsDebug() {
|
||||
nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
|
||||
}
|
||||
}
|
||||
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
|
||||
|
||||
app.db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
if app.IsDebug() {
|
||||
nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
|
||||
}
|
||||
concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
|
||||
}
|
||||
|
||||
app.dao = app.createDaoWithHooks(app.db)
|
||||
app.dao = app.createDaoWithHooks(concurrentDB, nonconcurrentDB)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *BaseApp) createDaoWithHooks(db dbx.Builder) *daos.Dao {
|
||||
dao := daos.New(db)
|
||||
func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder) *daos.Dao {
|
||||
dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
return app.OnModelBeforeCreate().Trigger(&ModelEvent{eventDao, m})
|
||||
}
|
||||
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
|
||||
err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
|
||||
if err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
@@ -722,7 +909,10 @@ func (app *BaseApp) createDaoWithHooks(db dbx.Builder) *daos.Dao {
|
||||
}
|
||||
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
|
||||
err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
|
||||
if err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
@@ -730,7 +920,10 @@ func (app *BaseApp) createDaoWithHooks(db dbx.Builder) *daos.Dao {
|
||||
}
|
||||
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
|
||||
err := app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
|
||||
if err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return dao
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -32,7 +31,7 @@ func TestBaseAppRefreshSettings(t *testing.T) {
|
||||
t.Fatalf("Expected new settings to be persisted, got %v", err)
|
||||
}
|
||||
|
||||
// change the db entry and refresh the app settings
|
||||
// change the db entry and refresh the app settings (ensure that there was no db update)
|
||||
param.Value = types.JsonRaw([]byte(`{"example": 123}`))
|
||||
if err := app.Dao().SaveParam(param.Key, param.Value); err != nil {
|
||||
t.Fatalf("Failed to update the test settings: %v", err)
|
||||
@@ -41,21 +40,9 @@ func TestBaseAppRefreshSettings(t *testing.T) {
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatalf("Failed to refresh the app settings: %v", err)
|
||||
}
|
||||
testEventCalls(t, app, map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
})
|
||||
testEventCalls(t, app, nil)
|
||||
|
||||
// make sure that the newly merged settings were actually saved
|
||||
newParam, err := app.Dao().FindParamByKey(models.ParamAppSettings)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch new settings param: %v", err)
|
||||
}
|
||||
if bytes.Equal(param.Value, newParam.Value) {
|
||||
t.Fatalf("Expected the new refreshed settings to be different, got: \n%v", string(newParam.Value))
|
||||
}
|
||||
|
||||
// try to refresh again and ensure that there was no db update
|
||||
// try to refresh again without doing any changes
|
||||
app.ResetEventCalls()
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatalf("Failed to refresh the app settings without change: %v", err)
|
||||
+39
-11
@@ -11,7 +11,11 @@ func TestNewBaseApp(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "test_env", true)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "test_env",
|
||||
IsDebug: true,
|
||||
})
|
||||
|
||||
if app.dataDir != testDataDir {
|
||||
t.Fatalf("expected dataDir %q, got %q", testDataDir, app.dataDir)
|
||||
@@ -42,14 +46,26 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
if app.IsBootstrapped() {
|
||||
t.Fatal("Didn't expect the application to be bootstrapped.")
|
||||
}
|
||||
|
||||
// bootstrap
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !app.IsBootstrapped() {
|
||||
t.Fatal("Expected the application to be bootstrapped.")
|
||||
}
|
||||
|
||||
if stat, err := os.Stat(testDataDir); err != nil || !stat.IsDir() {
|
||||
t.Fatal("Expected test data directory to be created.")
|
||||
}
|
||||
@@ -112,29 +128,33 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if app.db != app.DB() {
|
||||
t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.db)
|
||||
}
|
||||
|
||||
if app.dao != app.Dao() {
|
||||
t.Fatalf("Expected app.Dao %v, got %v", app.Dao(), app.dao)
|
||||
}
|
||||
|
||||
if app.logsDB != app.LogsDB() {
|
||||
t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDB)
|
||||
if app.dao.ConcurrentDB() != app.DB() {
|
||||
t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.dao.ConcurrentDB())
|
||||
}
|
||||
|
||||
if app.logsDao != app.LogsDao() {
|
||||
t.Fatalf("Expected app.LogsDao %v, got %v", app.LogsDao(), app.logsDao)
|
||||
}
|
||||
|
||||
if app.logsDao.ConcurrentDB() != app.LogsDB() {
|
||||
t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDao.ConcurrentDB())
|
||||
}
|
||||
|
||||
if app.dataDir != app.DataDir() {
|
||||
t.Fatalf("Expected app.DataDir %v, got %v", app.DataDir(), app.dataDir)
|
||||
}
|
||||
@@ -400,7 +420,11 @@ func TestBaseAppNewMailClient(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
|
||||
client1 := app.NewMailClient()
|
||||
if val, ok := client1.(*mailer.Sendmail); !ok {
|
||||
@@ -419,7 +443,11 @@ func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
|
||||
// local
|
||||
local, localErr := app.NewFilesystem()
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
func initPragmas(db *dbx.DB) error {
|
||||
// note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection
|
||||
_, err := db.NewQuery(`
|
||||
PRAGMA busy_timeout = 10000;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA journal_size_limit = 100000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = TRUE;
|
||||
`).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
+8
-23
@@ -3,35 +3,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
// note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection
|
||||
pragmas := "_busy_timeout=10000&_journal_mode=WAL&_foreign_keys=1&_synchronous=NORMAL"
|
||||
|
||||
db, openErr := dbx.MustOpen("sqlite3", fmt.Sprintf("%s?%s", dbPath, pragmas))
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
db, err := dbx.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// use a fixed connection pool to limit the SQLITE_BUSY errors
|
||||
// and reduce the open file descriptors
|
||||
// (the limits are arbitrary and may change in the future)
|
||||
db.DB().SetMaxOpenConns(1000)
|
||||
db.DB().SetMaxIdleConns(30)
|
||||
db.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// additional pragmas not supported through the dsn string
|
||||
_, err := db.NewQuery(`
|
||||
pragma journal_size_limit = 100000000;
|
||||
`).Execute()
|
||||
|
||||
return db, err
|
||||
return db, nil
|
||||
}
|
||||
|
||||
+5
-17
@@ -3,32 +3,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
// note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection
|
||||
pragmas := "_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_pragma=journal_size_limit(100000000)"
|
||||
|
||||
db, err := dbx.MustOpen("sqlite", fmt.Sprintf("%s?%s", dbPath, pragmas))
|
||||
db, err := dbx.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// use a fixed connection pool to limit the SQLITE_BUSY errors and
|
||||
// reduce the open file descriptors
|
||||
// (the limits are arbitrary and may change in the future)
|
||||
//
|
||||
// @see https://gitlab.com/cznic/sqlite/-/issues/115
|
||||
db.DB().SetMaxOpenConns(1000)
|
||||
db.DB().SetMaxIdleConns(30)
|
||||
db.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
+80
-27
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
@@ -15,11 +16,20 @@ import (
|
||||
// Serve events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type BootstrapEvent struct {
|
||||
App App
|
||||
}
|
||||
|
||||
type ServeEvent struct {
|
||||
App App
|
||||
Router *echo.Echo
|
||||
}
|
||||
|
||||
type ApiErrorEvent struct {
|
||||
HttpContext echo.Context
|
||||
Error error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Model DAO events data
|
||||
// -------------------------------------------------------------------
|
||||
@@ -35,12 +45,14 @@ type ModelEvent struct {
|
||||
|
||||
type MailerRecordEvent struct {
|
||||
MailClient mailer.Mailer
|
||||
Message *mailer.Message
|
||||
Record *models.Record
|
||||
Meta map[string]any
|
||||
}
|
||||
|
||||
type MailerAdminEvent struct {
|
||||
MailClient mailer.Mailer
|
||||
Message *mailer.Message
|
||||
Admin *models.Admin
|
||||
Meta map[string]any
|
||||
}
|
||||
@@ -54,6 +66,17 @@ type RealtimeConnectEvent struct {
|
||||
Client subscriptions.Client
|
||||
}
|
||||
|
||||
type RealtimeDisconnectEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
}
|
||||
|
||||
type RealtimeMessageEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
Message *subscriptions.Message
|
||||
}
|
||||
|
||||
type RealtimeSubscribeEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
@@ -66,17 +89,17 @@ type RealtimeSubscribeEvent struct {
|
||||
|
||||
type SettingsListEvent struct {
|
||||
HttpContext echo.Context
|
||||
RedactedSettings *Settings
|
||||
RedactedSettings *settings.Settings
|
||||
}
|
||||
|
||||
type SettingsUpdateEvent struct {
|
||||
HttpContext echo.Context
|
||||
OldSettings *Settings
|
||||
NewSettings *Settings
|
||||
OldSettings *settings.Settings
|
||||
NewSettings *settings.Settings
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Record API events data
|
||||
// Record CRUD API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type RecordsListEvent struct {
|
||||
@@ -106,6 +129,59 @@ type RecordDeleteEvent struct {
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Auth Record API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type RecordAuthEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
Token string
|
||||
Meta any
|
||||
}
|
||||
|
||||
type RecordRequestPasswordResetEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmPasswordResetEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordRequestVerificationEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmVerificationEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordRequestEmailChangeEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmEmailChangeEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordListExternalAuthsEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuths []*models.ExternalAuth
|
||||
}
|
||||
|
||||
type RecordUnlinkExternalAuthEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuth *models.ExternalAuth
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Admin API events data
|
||||
// -------------------------------------------------------------------
|
||||
@@ -142,29 +218,6 @@ type AdminAuthEvent struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Auth Record API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type RecordAuthEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
Token string
|
||||
Meta any
|
||||
}
|
||||
|
||||
type RecordListExternalAuthsEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuths []*models.ExternalAuth
|
||||
}
|
||||
|
||||
type RecordUnlinkExternalAuthEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuth *models.ExternalAuth
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Collection API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
+2
-2
@@ -97,8 +97,8 @@ func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool {
|
||||
AndWhere(dbx.HashExp{"email": email}).
|
||||
Limit(1)
|
||||
|
||||
if len(excludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(excludeIds)...))
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
+161
-32
@@ -4,24 +4,44 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
// New creates a new Dao instance with the provided db builder.
|
||||
const DefaultMaxFailRetries = 5
|
||||
|
||||
// New creates a new Dao instance with the provided db builder
|
||||
// (for both async and sync db operations).
|
||||
func New(db dbx.Builder) *Dao {
|
||||
return NewMultiDB(db, db)
|
||||
}
|
||||
|
||||
// New creates a new Dao instance with the provided dedicated
|
||||
// async and sync db builders.
|
||||
func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
|
||||
return &Dao{
|
||||
db: db,
|
||||
concurrentDB: concurrentDB,
|
||||
nonconcurrentDB: nonconcurrentDB,
|
||||
}
|
||||
}
|
||||
|
||||
// Dao handles various db operations.
|
||||
// Think of Dao as a repository and service layer in one.
|
||||
type Dao struct {
|
||||
db dbx.Builder
|
||||
// in a transaction both refer to the same *dbx.TX instance
|
||||
concurrentDB dbx.Builder
|
||||
nonconcurrentDB dbx.Builder
|
||||
|
||||
// @todo delete after removing Block and Continue
|
||||
sem *semaphore.Weighted
|
||||
mux sync.RWMutex
|
||||
|
||||
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterCreateFunc func(eventDao *Dao, m models.Model)
|
||||
@@ -31,16 +51,78 @@ type Dao struct {
|
||||
AfterDeleteFunc func(eventDao *Dao, m models.Model)
|
||||
}
|
||||
|
||||
// DB returns the internal db builder (*dbx.DB or *dbx.TX).
|
||||
// DB returns the default dao db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// Currently the default db builder is dao.concurrentDB but that may change in the future.
|
||||
func (dao *Dao) DB() dbx.Builder {
|
||||
return dao.db
|
||||
return dao.ConcurrentDB()
|
||||
}
|
||||
|
||||
// ConcurrentDB returns the dao concurrent (aka. multiple open connections)
|
||||
// db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
|
||||
func (dao *Dao) ConcurrentDB() dbx.Builder {
|
||||
return dao.concurrentDB
|
||||
}
|
||||
|
||||
// NonconcurrentDB returns the dao nonconcurrent (aka. single open connection)
|
||||
// db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
|
||||
func (dao *Dao) NonconcurrentDB() dbx.Builder {
|
||||
return dao.nonconcurrentDB
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
|
||||
//
|
||||
// Block acquires a lock and blocks all other go routines that uses
|
||||
// the Dao instance until dao.Continue() is called, effectively making
|
||||
// the concurrent requests to perform synchronous db operations.
|
||||
//
|
||||
// This method should be used only as a last resort and as a workaround
|
||||
// for the SQLITE_BUSY error when mixing read&write in a transaction.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func someLongRunningTransaction() error {
|
||||
// ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
// defer cancel()
|
||||
// if err := app.Dao().Block(ctx); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// defer app.Dao().Continue()
|
||||
//
|
||||
// return app.Dao().RunInTransaction(func (txDao *daos.Dao) error {
|
||||
// // some long running read&write transaction...
|
||||
// })
|
||||
// }
|
||||
func (dao *Dao) Block(ctx context.Context) error {
|
||||
if dao.sem == nil {
|
||||
dao.mux.Lock()
|
||||
dao.sem = semaphore.NewWeighted(1)
|
||||
dao.mux.Unlock()
|
||||
}
|
||||
|
||||
return dao.sem.Acquire(ctx, 1)
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
|
||||
//
|
||||
// Continue releases the previously acquired Block() lock.
|
||||
func (dao *Dao) Continue() {
|
||||
if dao.sem == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dao.sem.Release(1)
|
||||
}
|
||||
|
||||
// ModelQuery creates a new query with preset Select and From fields
|
||||
// based on the provided model argument.
|
||||
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
|
||||
tableName := m.TableName()
|
||||
return dao.db.Select(fmt.Sprintf("{{%s}}.*", tableName)).From(tableName)
|
||||
return dao.DB().Select("{{" + tableName + "}}.*").From(tableName)
|
||||
}
|
||||
|
||||
// FindById finds a single db record with the specified id and
|
||||
@@ -57,13 +139,23 @@ type afterCallGroup struct {
|
||||
|
||||
// RunInTransaction wraps fn into a transaction.
|
||||
//
|
||||
// It is safe to nest RunInTransaction calls.
|
||||
// It is safe to nest RunInTransaction calls as long as you use the txDao.
|
||||
func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
switch txOrDB := dao.db.(type) {
|
||||
switch txOrDB := dao.NonconcurrentDB().(type) {
|
||||
case *dbx.Tx:
|
||||
// nested transactions are not supported by default
|
||||
// so execute the function within the current transaction
|
||||
return fn(dao)
|
||||
// ---
|
||||
// create a new dao with the same hooks to avoid semaphore deadlock when nesting
|
||||
txDao := New(txOrDB)
|
||||
txDao.BeforeCreateFunc = dao.BeforeCreateFunc
|
||||
txDao.BeforeUpdateFunc = dao.BeforeUpdateFunc
|
||||
txDao.BeforeDeleteFunc = dao.BeforeDeleteFunc
|
||||
txDao.AfterCreateFunc = dao.AfterCreateFunc
|
||||
txDao.AfterUpdateFunc = dao.AfterUpdateFunc
|
||||
txDao.AfterDeleteFunc = dao.AfterDeleteFunc
|
||||
|
||||
return fn(txDao)
|
||||
case *dbx.DB:
|
||||
afterCalls := []afterCallGroup{}
|
||||
|
||||
@@ -107,14 +199,15 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
|
||||
if txError == nil {
|
||||
// execute after event calls on successful transaction
|
||||
// (note: using the non-transaction dao to allow following queries in the after hooks)
|
||||
for _, call := range afterCalls {
|
||||
switch call.Action {
|
||||
case "create":
|
||||
dao.AfterCreateFunc(call.EventDao, call.Model)
|
||||
dao.AfterCreateFunc(dao, call.Model)
|
||||
case "update":
|
||||
dao.AfterUpdateFunc(call.EventDao, call.Model)
|
||||
dao.AfterUpdateFunc(dao, call.Model)
|
||||
case "delete":
|
||||
dao.AfterDeleteFunc(call.EventDao, call.Model)
|
||||
dao.AfterDeleteFunc(dao, call.Model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +215,7 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
return txError
|
||||
}
|
||||
|
||||
return errors.New("Failed to start transaction (unknown dao.db)")
|
||||
return errors.New("failed to start transaction (unknown dao.NonconcurrentDB() instance)")
|
||||
}
|
||||
|
||||
// Delete deletes the provided model.
|
||||
@@ -131,30 +224,36 @@ func (dao *Dao) Delete(m models.Model) error {
|
||||
return errors.New("ID is not set")
|
||||
}
|
||||
|
||||
if dao.BeforeDeleteFunc != nil {
|
||||
if err := dao.BeforeDeleteFunc(dao, m); err != nil {
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
if retryDao.BeforeDeleteFunc != nil {
|
||||
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := dao.db.Model(m).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
if retryDao.AfterDeleteFunc != nil {
|
||||
retryDao.AfterDeleteFunc(retryDao, m)
|
||||
}
|
||||
|
||||
if dao.AfterDeleteFunc != nil {
|
||||
dao.AfterDeleteFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return nil
|
||||
}, DefaultMaxFailRetries)
|
||||
}
|
||||
|
||||
// Save upserts (update or create if primary key is not set) the provided model.
|
||||
func (dao *Dao) Save(m models.Model) error {
|
||||
if m.IsNew() {
|
||||
return dao.create(m)
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
return retryDao.create(m)
|
||||
}, DefaultMaxFailRetries)
|
||||
}
|
||||
|
||||
return dao.update(m)
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
return retryDao.update(m)
|
||||
}, DefaultMaxFailRetries)
|
||||
}
|
||||
|
||||
func (dao *Dao) update(m models.Model) error {
|
||||
@@ -177,7 +276,7 @@ func (dao *Dao) update(m models.Model) error {
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
|
||||
_, err := dao.db.Update(
|
||||
_, err := dao.NonconcurrentDB().Update(
|
||||
m.TableName(),
|
||||
dataMap,
|
||||
dbx.HashExp{"id": m.GetId()},
|
||||
@@ -187,7 +286,7 @@ func (dao *Dao) update(m models.Model) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.db.Model(m).Update(); err != nil {
|
||||
if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -228,18 +327,18 @@ func (dao *Dao) create(m models.Model) error {
|
||||
dataMap["id"] = m.GetId()
|
||||
}
|
||||
|
||||
_, err := dao.db.Insert(m.TableName(), dataMap).Execute()
|
||||
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.db.Model(m).Insert(); err != nil {
|
||||
if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// clears the "new" model flag
|
||||
m.UnmarkAsNew()
|
||||
m.MarkAsNotNew()
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
dao.AfterCreateFunc(dao, m)
|
||||
@@ -247,3 +346,33 @@ func (dao *Dao) create(m models.Model) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dao *Dao) failRetry(op func(retryDao *Dao) error, maxRetries int) error {
|
||||
retryDao := dao
|
||||
attempts := 1
|
||||
|
||||
Retry:
|
||||
if attempts == 2 {
|
||||
// assign new Dao without the before hooks to avoid triggering
|
||||
// the already fired before callbacks multiple times
|
||||
retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
|
||||
retryDao.AfterCreateFunc = dao.AfterCreateFunc
|
||||
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
|
||||
retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
|
||||
}
|
||||
|
||||
// execute
|
||||
err := op(retryDao)
|
||||
|
||||
if err != nil &&
|
||||
attempts < maxRetries &&
|
||||
// note: we are checking the err message so that we can handle both the cgo and noncgo errors
|
||||
strings.Contains(err.Error(), "database is locked") {
|
||||
// wait and retry
|
||||
time.Sleep(time.Duration(200*attempts) * time.Millisecond)
|
||||
attempts++
|
||||
goto Retry
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,25 @@ func TestNew(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMultiDB(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
|
||||
|
||||
if dao.DB() != testApp.Dao().ConcurrentDB() {
|
||||
t.Fatal("[db-concurrentDB] The 2 db instances are different")
|
||||
}
|
||||
|
||||
if dao.ConcurrentDB() != testApp.Dao().ConcurrentDB() {
|
||||
t.Fatal("[concurrentDB-concurrentDB] The 2 db instances are different")
|
||||
}
|
||||
|
||||
if dao.NonconcurrentDB() != testApp.Dao().NonconcurrentDB() {
|
||||
t.Fatal("[nonconcurrentDB-nonconcurrentDB] The 2 db instances are different")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoModelQuery(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
@@ -289,6 +308,170 @@ func TestDaoDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoRetryCreate(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init mock retry dao
|
||||
retryBeforeCreateHookCalls := 0
|
||||
retryAfterCreateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryAfterCreateHookCalls++
|
||||
}
|
||||
|
||||
model := &models.Admin{Email: "new@example.com"}
|
||||
if err := retryDao.Save(model); err != nil {
|
||||
t.Fatalf("Expected nil after retry, got error: %v", err)
|
||||
}
|
||||
|
||||
// the before hook is expected to be called only once because
|
||||
// it is ignored after the first "database is locked" error
|
||||
if retryBeforeCreateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterCreateHookCalls != 1 {
|
||||
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterCreateHookCalls)
|
||||
}
|
||||
|
||||
// with non-locking error
|
||||
retryBeforeCreateHookCalls = 0
|
||||
retryAfterCreateHookCalls = 0
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
|
||||
dummy := &models.Admin{Email: "test@example.com"}
|
||||
if err := retryDao.Save(dummy); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if retryBeforeCreateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterCreateHookCalls != 0 {
|
||||
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterCreateHookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoRetryUpdate(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
model, err := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// init mock retry dao
|
||||
retryBeforeUpdateHookCalls := 0
|
||||
retryAfterUpdateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryAfterUpdateHookCalls++
|
||||
}
|
||||
|
||||
if err := retryDao.Save(model); err != nil {
|
||||
t.Fatalf("Expected nil after retry, got error: %v", err)
|
||||
}
|
||||
|
||||
// the before hook is expected to be called only once because
|
||||
// it is ignored after the first "database is locked" error
|
||||
if retryBeforeUpdateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterUpdateHookCalls != 1 {
|
||||
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterUpdateHookCalls)
|
||||
}
|
||||
|
||||
// with non-locking error
|
||||
retryBeforeUpdateHookCalls = 0
|
||||
retryAfterUpdateHookCalls = 0
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
|
||||
if err := retryDao.Save(model); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if retryBeforeUpdateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterUpdateHookCalls != 0 {
|
||||
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterUpdateHookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoRetryDelete(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init mock retry dao
|
||||
retryBeforeDeleteHookCalls := 0
|
||||
retryAfterDeleteHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryAfterDeleteHookCalls++
|
||||
}
|
||||
|
||||
model, _ := retryDao.FindAdminByEmail("test@example.com")
|
||||
if err := retryDao.Delete(model); err != nil {
|
||||
t.Fatalf("Expected nil after retry, got error: %v", err)
|
||||
}
|
||||
|
||||
// the before hook is expected to be called only once because
|
||||
// it is ignored after the first "database is locked" error
|
||||
if retryBeforeDeleteHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterDeleteHookCalls != 1 {
|
||||
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterDeleteHookCalls)
|
||||
}
|
||||
|
||||
// with non-locking error
|
||||
retryBeforeDeleteHookCalls = 0
|
||||
retryAfterDeleteHookCalls = 0
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
|
||||
dummy := &models.Admin{}
|
||||
dummy.RefreshId()
|
||||
dummy.MarkAsNotNew()
|
||||
if err := retryDao.Delete(dummy); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if retryBeforeDeleteHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterDeleteHookCalls != 0 {
|
||||
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterDeleteHookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoBeforeHooksError(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
+7
-4
@@ -65,8 +65,7 @@ func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
|
||||
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
|
||||
Limit(1)
|
||||
|
||||
if len(excludeIds) > 0 {
|
||||
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
@@ -85,15 +84,17 @@ func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeI
|
||||
collections := []*models.Collection{}
|
||||
|
||||
query := dao.CollectionQuery()
|
||||
if len(excludeIds) > 0 {
|
||||
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
if err := query.All(&collections); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := map[*models.Collection][]*schema.SchemaField{}
|
||||
|
||||
for _, c := range collections {
|
||||
for _, f := range c.Schema.Fields() {
|
||||
if f.Type != schema.FieldTypeRelation {
|
||||
@@ -212,6 +213,8 @@ func (dao *Dao) ImportCollections(
|
||||
}
|
||||
|
||||
if existing, ok := mappedExisting[imported.GetId()]; ok {
|
||||
imported.MarkAsNotNew()
|
||||
|
||||
// preserve original created date
|
||||
if !existing.Created.IsZero() {
|
||||
imported.Created = existing.Created
|
||||
|
||||
+15
-8
@@ -37,7 +37,7 @@ func TestFindCollectionsByType(t *testing.T) {
|
||||
{"", false, 0},
|
||||
{"unknown", false, 0},
|
||||
{models.CollectionTypeAuth, false, 3},
|
||||
{models.CollectionTypeBase, false, 4},
|
||||
{models.CollectionTypeBase, false, 5},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -122,7 +122,13 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := app.Dao().FindCollectionReferences(collection, collection.Id)
|
||||
result, err := app.Dao().FindCollectionReferences(
|
||||
collection,
|
||||
collection.Id,
|
||||
// test whether "nonempty" exclude ids condition will be skipped
|
||||
"",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -296,7 +302,7 @@ func TestImportCollections(t *testing.T) {
|
||||
name: "empty collections",
|
||||
jsonData: `[]`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import",
|
||||
@@ -306,7 +312,7 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 9,
|
||||
expectCollectionsCount: 10,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + failed beforeRecordsSync",
|
||||
@@ -318,7 +324,7 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + successful beforeRecordsSync",
|
||||
@@ -330,7 +336,7 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: 9,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete system collection",
|
||||
@@ -366,7 +372,7 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: true,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete non-system collection",
|
||||
@@ -495,7 +501,7 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: 9,
|
||||
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
|
||||
expectedCollectionFields := map[string]int{
|
||||
"nologin": 1,
|
||||
@@ -503,6 +509,7 @@ func TestImportCollections(t *testing.T) {
|
||||
"demo2": 2,
|
||||
"demo3": 2,
|
||||
"demo4": 11,
|
||||
"demo5": 5,
|
||||
"new_import": 1,
|
||||
}
|
||||
for name, expectedCount := range expectedCollectionFields {
|
||||
|
||||
+143
-190
@@ -3,6 +3,7 @@ package daos
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -83,7 +84,7 @@ func (dao *Dao) FindRecordsByIds(
|
||||
}
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
rows := make([]dbx.NullStringMap, 0, len(recordIds))
|
||||
if err := query.All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -191,8 +192,7 @@ func (dao *Dao) IsRecordValueUnique(
|
||||
AndWhere(expr).
|
||||
Limit(1)
|
||||
|
||||
if len(excludeIds) > 0 {
|
||||
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*model
|
||||
id, _ := unverifiedClaims["id"].(string)
|
||||
collectionId, _ := unverifiedClaims["collectionId"].(string)
|
||||
if id == "" || collectionId == "" {
|
||||
return nil, errors.New("Missing or invalid token claims.")
|
||||
return nil, errors.New("missing or invalid token claims")
|
||||
}
|
||||
|
||||
record, err := dao.FindRecordById(collectionId, id)
|
||||
@@ -241,8 +241,11 @@ func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*model
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil || !collection.IsAuth() {
|
||||
return nil, errors.New("Missing or not an auth collection.")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
|
||||
}
|
||||
if !collection.IsAuth() {
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
@@ -264,8 +267,11 @@ func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username string) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil || !collection.IsAuth() {
|
||||
return nil, errors.New("Missing or not an auth collection.")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
|
||||
}
|
||||
if !collection.IsAuth() {
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
@@ -316,14 +322,14 @@ func (dao *Dao) SuggestUniqueAuthRecordUsername(
|
||||
func (dao *Dao) SaveRecord(record *models.Record) error {
|
||||
if record.Collection().IsAuth() {
|
||||
if record.Username() == "" {
|
||||
return errors.New("Unable to save auth record without username.")
|
||||
return errors.New("unable to save auth record without username")
|
||||
}
|
||||
|
||||
// Cross-check that the auth record id is unique for all auth collections.
|
||||
// This is to make sure that the filter `@request.auth.id` always returns a unique id.
|
||||
authCollections, err := dao.FindCollectionsByType(models.CollectionTypeAuth)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to fetch the auth collections for cross-id unique check: %v", err)
|
||||
return fmt.Errorf("unable to fetch the auth collections for cross-id unique check: %w", err)
|
||||
}
|
||||
for _, collection := range authCollections {
|
||||
if record.Collection().Id == collection.Id {
|
||||
@@ -331,7 +337,7 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
|
||||
}
|
||||
isUnique := dao.IsRecordValueUnique(collection.Id, schema.FieldNameId, record.Id)
|
||||
if !isUnique {
|
||||
return errors.New("The auth record ID must be unique across all auth collections.")
|
||||
return errors.New("the auth record ID must be unique across all auth collections")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,222 +348,169 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
|
||||
// DeleteRecord deletes the provided Record model.
|
||||
//
|
||||
// This method will also cascade the delete operation to all linked
|
||||
// relational records (delete or set to NULL, depending on the rel settings).
|
||||
// relational records (delete or unset, depending on the rel settings).
|
||||
//
|
||||
// The delete operation may fail if the record is part of a required
|
||||
// reference in another record (aka. cannot be deleted or set to NULL).
|
||||
// reference in another record (aka. cannot be deleted or unset).
|
||||
func (dao *Dao) DeleteRecord(record *models.Record) error {
|
||||
// check for references
|
||||
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction.
|
||||
// fetch rel references (if any)
|
||||
//
|
||||
// note: the select is outside of the transaction to minimize
|
||||
// SQLITE_BUSY errors when mixing read&write in a single transaction
|
||||
refs, err := dao.FindCollectionReferences(record.Collection())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if related records has to be deleted (if `CascadeDelete` is set)
|
||||
// OR
|
||||
// just unset the record id from any relation field values (if they are not required)
|
||||
// -----------------------------------------------------------
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// delete/update references
|
||||
for refCollection, fields := range refs {
|
||||
for _, field := range fields {
|
||||
options, _ := field.Options.(*schema.RelationOptions)
|
||||
// manually trigger delete on any linked external auth to ensure
|
||||
// that the `OnModel*` hooks are triggered
|
||||
if record.Collection().IsAuth() {
|
||||
// note: the select is outside of the transaction to minimize
|
||||
// SQLITE_BUSY errors when mixing read&write in a single transaction
|
||||
externalAuths, err := dao.FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, auth := range externalAuths {
|
||||
if err := txDao.DeleteExternalAuth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
// delete the record before the relation references to ensure that there
|
||||
// will be no "A<->B" relations to prevent deadlock when calling DeleteRecord recursively
|
||||
if err := txDao.Delete(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// note: the select is not using the transaction dao to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
|
||||
err := dao.RecordQuery(refCollection).
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": record.Id})).
|
||||
AndWhere(dbx.Like(field.Name, record.Id).Match(true, true)).
|
||||
All(&rows)
|
||||
if err != nil {
|
||||
return txDao.cascadeRecordDelete(record, refs)
|
||||
})
|
||||
}
|
||||
|
||||
// cascadeRecordDelete triggers cascade deletion for the provided references
|
||||
// and split the work to a batched set of go routines.
|
||||
//
|
||||
// NB! This method is expected to be called inside a transaction.
|
||||
func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
|
||||
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
|
||||
|
||||
for refCollection, fields := range refs {
|
||||
for _, field := range fields {
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
|
||||
query := dao.RecordQuery(refCollection).
|
||||
Distinct(true).
|
||||
LeftJoin(fmt.Sprintf(
|
||||
// note: the case is used to normalize value access for single and multiple relations.
|
||||
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
|
||||
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
|
||||
), nil).
|
||||
AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id})).
|
||||
AndWhere(dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
|
||||
|
||||
// trigger cascade for each 1000 rel items until there is none
|
||||
batchSize := 1000
|
||||
for {
|
||||
rows := make([]dbx.NullStringMap, 0, batchSize)
|
||||
if err := query.Limit(int64(batchSize)).All(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows)
|
||||
for _, refRecord := range refRecords {
|
||||
ids := refRecord.GetStringSlice(field.Name)
|
||||
total := len(rows)
|
||||
if total == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// unset the record id
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
if ids[i] == record.Id {
|
||||
ids = append(ids[:i], ids[i+1:]...)
|
||||
break
|
||||
perWorker := 50
|
||||
workers := int(math.Ceil(float64(total) / float64(perWorker)))
|
||||
|
||||
batchErr := func() error {
|
||||
ch := make(chan error)
|
||||
defer close(ch)
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
var chunks []dbx.NullStringMap
|
||||
if len(rows) <= perWorker {
|
||||
chunks = rows
|
||||
rows = nil
|
||||
} else {
|
||||
chunks = rows[:perWorker]
|
||||
rows = rows[perWorker:]
|
||||
}
|
||||
|
||||
go func() {
|
||||
refRecords := models.NewRecordsFromNullStringMaps(refCollection, chunks)
|
||||
ch <- dao.deleteRefRecords(mainRecord, refRecords, field)
|
||||
}()
|
||||
}
|
||||
|
||||
// cascade delete the reference
|
||||
// (only if there are no other active references in case of multiple select)
|
||||
if options.CascadeDelete && len(ids) == 0 {
|
||||
if err := txDao.DeleteRecord(refRecord); err != nil {
|
||||
for i := 0; i < workers; i++ {
|
||||
if err := <-ch; err != nil {
|
||||
return err
|
||||
}
|
||||
// no further action are needed (the reference is deleted)
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Required && len(ids) == 0 {
|
||||
return fmt.Errorf("The record cannot be deleted because it is part of a required reference in record %s (%s collection).", refRecord.Id, refCollection.Name)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
// save the reference changes
|
||||
refRecord.Set(field.Name, field.PrepareValue(ids))
|
||||
if err := txDao.SaveRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
if batchErr != nil {
|
||||
return batchErr
|
||||
}
|
||||
|
||||
if total < batchSize {
|
||||
break // no more items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delete linked external auths
|
||||
if record.Collection().IsAuth() {
|
||||
_, err = txDao.DB().Delete((&models.ExternalAuth{}).TableName(), dbx.HashExp{
|
||||
"collectionId": record.Collection().Id,
|
||||
"recordId": record.Id,
|
||||
}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return txDao.Delete(record)
|
||||
})
|
||||
}
|
||||
|
||||
// SyncRecordTableSchema compares the two provided collections
|
||||
// and applies the necessary related record table changes.
|
||||
//
|
||||
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
|
||||
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
|
||||
// create
|
||||
if oldCollection == nil {
|
||||
cols := map[string]string{
|
||||
schema.FieldNameId: "TEXT PRIMARY KEY",
|
||||
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
|
||||
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
|
||||
}
|
||||
|
||||
if newCollection.IsAuth() {
|
||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
|
||||
// ensure that the new collection has an id
|
||||
if !newCollection.HasId() {
|
||||
newCollection.RefreshId()
|
||||
newCollection.MarkAsNew()
|
||||
}
|
||||
|
||||
tableName := newCollection.Name
|
||||
|
||||
// add schema field definitions
|
||||
for _, field := range newCollection.Schema.Fields() {
|
||||
cols[field.Name] = field.ColDefinition()
|
||||
}
|
||||
|
||||
// create table
|
||||
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named index on the base `created` column
|
||||
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named unique index on the email and tokenKey columns
|
||||
if newCollection.IsAuth() {
|
||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||
`
|
||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||
`,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// update
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
oldTableName := oldCollection.Name
|
||||
newTableName := newCollection.Name
|
||||
oldSchema := oldCollection.Schema
|
||||
newSchema := newCollection.Schema
|
||||
|
||||
// check for renamed table
|
||||
if !strings.EqualFold(oldTableName, newTableName) {
|
||||
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRefRecords checks if related records has to be deleted (if `CascadeDelete` is set)
|
||||
// OR
|
||||
// just unset the record id from any relation field values (if they are not required).
|
||||
//
|
||||
// NB! This method is expected to be called inside a transaction.
|
||||
func (dao *Dao) deleteRefRecords(mainRecord *models.Record, refRecords []*models.Record, field *schema.SchemaField) error {
|
||||
options, _ := field.Options.(*schema.RelationOptions)
|
||||
if options == nil {
|
||||
return errors.New("relation field options are not initialized")
|
||||
}
|
||||
|
||||
for _, refRecord := range refRecords {
|
||||
ids := refRecord.GetStringSlice(field.Name)
|
||||
|
||||
// unset the record id
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
if ids[i] == mainRecord.Id {
|
||||
ids = append(ids[:i], ids[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// check for deleted columns
|
||||
for _, oldField := range oldSchema.Fields() {
|
||||
if f := newSchema.GetFieldById(oldField.Id); f != nil {
|
||||
continue // exist
|
||||
}
|
||||
|
||||
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
|
||||
if err != nil {
|
||||
// cascade delete the reference
|
||||
// (only if there are no other active references in case of multiple select)
|
||||
if options.CascadeDelete && len(ids) == 0 {
|
||||
if err := dao.DeleteRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
// no further actions are needed (the reference is deleted)
|
||||
continue
|
||||
}
|
||||
|
||||
// check for new or renamed columns
|
||||
toRename := map[string]string{}
|
||||
for _, field := range newSchema.Fields() {
|
||||
oldField := oldSchema.GetFieldById(field.Id)
|
||||
// Note:
|
||||
// We are using a temporary column name when adding or renaming columns
|
||||
// to ensure that there are no name collisions in case there is
|
||||
// names switch/reuse of existing columns (eg. name, title -> title, name).
|
||||
// This way we are always doing 1 more rename operation but it provides better dev experience.
|
||||
|
||||
if oldField == nil {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if oldField.Name != field.Name {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if field.Required && len(ids) == 0 {
|
||||
return fmt.Errorf("the record cannot be deleted because it is part of a required reference in record %s (%s collection)", refRecord.Id, refRecord.Collection().Name)
|
||||
}
|
||||
|
||||
// set the actual columns name
|
||||
for tempName, actualName := range toRename {
|
||||
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// save the reference changes
|
||||
refRecord.Set(field.Name, field.PrepareValue(ids))
|
||||
if err := dao.SaveRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,8 +93,8 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
}
|
||||
|
||||
recordIds := make([]any, len(records))
|
||||
for _, record := range records {
|
||||
recordIds = append(recordIds, record.Id)
|
||||
for i, record := range records {
|
||||
recordIds[i] = record.Id
|
||||
}
|
||||
|
||||
indirectRecords, err := dao.FindRecordsByExpr(
|
||||
@@ -176,7 +176,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
}
|
||||
|
||||
// reindex with the rel id
|
||||
indexedRels := map[string]*models.Record{}
|
||||
indexedRels := make(map[string]*models.Record, len(rels))
|
||||
for _, rel := range rels {
|
||||
indexedRels[rel.GetId()] = rel
|
||||
}
|
||||
@@ -216,12 +216,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
continue
|
||||
}
|
||||
|
||||
oldRelExpand := oldExpandedRel.Expand()
|
||||
newRelExpand := rel.Expand()
|
||||
for k, v := range oldRelExpand {
|
||||
newRelExpand[k] = v
|
||||
}
|
||||
rel.SetExpand(newRelExpand)
|
||||
rel.MergeExpand(oldExpandedRel.Expand())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,22 @@ func TestExpandRecords(t *testing.T) {
|
||||
5,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"expand multiple relations sharing a common path",
|
||||
"demo4",
|
||||
[]string{"qzaqccwrmva4o1n"},
|
||||
[]string{
|
||||
"rel_one_no_cascade",
|
||||
"rel_many_no_cascade",
|
||||
"self_rel_many.self_rel_one.rel_many_cascade",
|
||||
"self_rel_many.self_rel_one.rel_many_no_cascade_required",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
5,
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// SyncRecordTableSchema compares the two provided collections
|
||||
// and applies the necessary related record table changes.
|
||||
//
|
||||
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
|
||||
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
|
||||
// create
|
||||
if oldCollection == nil {
|
||||
cols := map[string]string{
|
||||
schema.FieldNameId: "TEXT PRIMARY KEY NOT NULL",
|
||||
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
|
||||
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
|
||||
}
|
||||
|
||||
if newCollection.IsAuth() {
|
||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
|
||||
// ensure that the new collection has an id
|
||||
if !newCollection.HasId() {
|
||||
newCollection.RefreshId()
|
||||
newCollection.MarkAsNew()
|
||||
}
|
||||
|
||||
tableName := newCollection.Name
|
||||
|
||||
// add schema field definitions
|
||||
for _, field := range newCollection.Schema.Fields() {
|
||||
cols[field.Name] = field.ColDefinition()
|
||||
}
|
||||
|
||||
// create table
|
||||
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named index on the base `created` column
|
||||
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named unique index on the email and tokenKey columns
|
||||
if newCollection.IsAuth() {
|
||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||
`
|
||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||
`,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// update
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
oldTableName := oldCollection.Name
|
||||
newTableName := newCollection.Name
|
||||
oldSchema := oldCollection.Schema
|
||||
newSchema := newCollection.Schema
|
||||
|
||||
// check for renamed table
|
||||
if !strings.EqualFold(oldTableName, newTableName) {
|
||||
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// check for deleted columns
|
||||
for _, oldField := range oldSchema.Fields() {
|
||||
if f := newSchema.GetFieldById(oldField.Id); f != nil {
|
||||
continue // exist
|
||||
}
|
||||
|
||||
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// check for new or renamed columns
|
||||
toRename := map[string]string{}
|
||||
for _, field := range newSchema.Fields() {
|
||||
oldField := oldSchema.GetFieldById(field.Id)
|
||||
// Note:
|
||||
// We are using a temporary column name when adding or renaming columns
|
||||
// to ensure that there are no name collisions in case there is
|
||||
// names switch/reuse of existing columns (eg. name, title -> title, name).
|
||||
// This way we are always doing 1 more rename operation but it provides better dev experience.
|
||||
|
||||
if oldField == nil {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if oldField.Name != field.Name {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// set the actual columns name
|
||||
for tempName, actualName := range toRename {
|
||||
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestSyncRecordTableSchema(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection.Name = "demo_renamed"
|
||||
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Name: "new_field",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Id: updatedCollection.Schema.GetFieldByName("title").Id,
|
||||
Name: "title_renamed",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
|
||||
scenarios := []struct {
|
||||
newCollection *models.Collection
|
||||
oldCollection *models.Collection
|
||||
expectedTableName string
|
||||
expectedColumns []string
|
||||
}{
|
||||
// new base collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table",
|
||||
[]string{"id", "created", "updated", "test"},
|
||||
},
|
||||
// new auth collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table_auth",
|
||||
Type: models.CollectionTypeAuth,
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table_auth",
|
||||
[]string{
|
||||
"id", "created", "updated", "test",
|
||||
"username", "email", "verified", "emailVisibility",
|
||||
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
|
||||
},
|
||||
},
|
||||
// no changes
|
||||
{
|
||||
oldCollection,
|
||||
oldCollection,
|
||||
"demo3",
|
||||
[]string{"id", "created", "updated", "title", "active"},
|
||||
},
|
||||
// renamed table, deleted column, renamed columnd and new column
|
||||
{
|
||||
updatedCollection,
|
||||
oldCollection,
|
||||
"demo_renamed",
|
||||
[]string{"id", "created", "updated", "title_renamed", "new_field"},
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !app.Dao().HasTable(scenario.newCollection.Name) {
|
||||
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
|
||||
}
|
||||
|
||||
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
|
||||
if len(cols) != len(scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
|
||||
}
|
||||
|
||||
for _, c := range cols {
|
||||
if !list.ExistInSlice(c, scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
-94
@@ -1,17 +1,22 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRecordQuery(t *testing.T) {
|
||||
@@ -628,7 +633,21 @@ func TestDeleteRecord(t *testing.T) {
|
||||
|
||||
// delete existing record + cascade
|
||||
// ---
|
||||
calledQueries := []string{}
|
||||
app.Dao().NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
rec3, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
|
||||
// delete
|
||||
if err := app.Dao().DeleteRecord(rec3); err != nil {
|
||||
t.Fatalf("(rec3) Expected nil, got error %v", err)
|
||||
}
|
||||
@@ -642,113 +661,137 @@ func TestDeleteRecord(t *testing.T) {
|
||||
if rel != nil {
|
||||
t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel)
|
||||
}
|
||||
// ensure that the json rel fields were prefixed
|
||||
joinedQueries := strings.Join(calledQueries, " ")
|
||||
expectedRelManyJoin := "`demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
|
||||
if !strings.Contains(joinedQueries, expectedRelManyJoin) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyJoin, calledQueries)
|
||||
}
|
||||
expectedRelOneJoin := "`demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
|
||||
if !strings.Contains(joinedQueries, expectedRelOneJoin) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOneJoin, calledQueries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRecordTableSchema(t *testing.T) {
|
||||
func TestDeleteRecordBatchProcessing(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
if err := createMockBatchProcessingData(app.Dao()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
|
||||
// find and delete the first c1 record to trigger cascade
|
||||
mainRecord, _ := app.Dao().FindRecordById("c1", "a")
|
||||
if err := app.Dao().DeleteRecord(mainRecord); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection.Name = "demo_renamed"
|
||||
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Name: "new_field",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Id: updatedCollection.Schema.GetFieldByName("title").Id,
|
||||
Name: "title_renamed",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
|
||||
scenarios := []struct {
|
||||
newCollection *models.Collection
|
||||
oldCollection *models.Collection
|
||||
expectedTableName string
|
||||
expectedColumns []string
|
||||
}{
|
||||
// new base collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table",
|
||||
[]string{"id", "created", "updated", "test"},
|
||||
},
|
||||
// new auth collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table_auth",
|
||||
Type: models.CollectionTypeAuth,
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table_auth",
|
||||
[]string{
|
||||
"id", "created", "updated", "test",
|
||||
"username", "email", "verified", "emailVisibility",
|
||||
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
|
||||
},
|
||||
},
|
||||
// no changes
|
||||
{
|
||||
oldCollection,
|
||||
oldCollection,
|
||||
"demo3",
|
||||
[]string{"id", "created", "updated", "title", "active"},
|
||||
},
|
||||
// renamed table, deleted column, renamed columnd and new column
|
||||
{
|
||||
updatedCollection,
|
||||
oldCollection,
|
||||
"demo_renamed",
|
||||
[]string{"id", "created", "updated", "title_renamed", "new_field"},
|
||||
},
|
||||
// check if the main record was deleted
|
||||
_, err := app.Dao().FindRecordById(mainRecord.Collection().Id, mainRecord.Id)
|
||||
if err == nil {
|
||||
t.Fatal("The main record wasn't deleted")
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) %v", i, err)
|
||||
continue
|
||||
// check if the c2 rel fields were updated
|
||||
c2Records, err := app.Dao().FindRecordsByExpr("c2", nil)
|
||||
if err != nil || len(c2Records) == 0 {
|
||||
t.Fatalf("Failed to fetch c2 records: %v", err)
|
||||
}
|
||||
for _, r := range c2Records {
|
||||
ids := r.GetStringSlice("rel")
|
||||
if len(ids) != 1 || ids[0] != "b" {
|
||||
t.Fatalf("Expected only 'b' rel id, got %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
if !app.Dao().HasTable(scenario.newCollection.Name) {
|
||||
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
|
||||
}
|
||||
|
||||
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
|
||||
if len(cols) != len(scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
|
||||
}
|
||||
|
||||
for _, c := range cols {
|
||||
if !list.ExistInSlice(c, scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
|
||||
}
|
||||
}
|
||||
// check if all c3 relations were deleted
|
||||
c3Records, err := app.Dao().FindRecordsByExpr("c3", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch c3 records: %v", err)
|
||||
}
|
||||
if total := len(c3Records); total != 0 {
|
||||
t.Fatalf("Expected c3 records to be deleted, found %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func createMockBatchProcessingData(dao *daos.Dao) error {
|
||||
// create mock collection without relation
|
||||
c1 := &models.Collection{}
|
||||
c1.Id = "c1"
|
||||
c1.Name = c1.Id
|
||||
c1.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "text",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create mock collection with a multi-rel field
|
||||
c2 := &models.Collection{}
|
||||
c2.Id = "c2"
|
||||
c2.Name = c2.Id
|
||||
c2.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "rel",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(10),
|
||||
CollectionId: "c1",
|
||||
CascadeDelete: false, // should unset all rel fields
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create mock collection with a single-rel field
|
||||
c3 := &models.Collection{}
|
||||
c3.Id = "c3"
|
||||
c3.Name = c3.Id
|
||||
c3.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "rel",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(1),
|
||||
CollectionId: "c1",
|
||||
CascadeDelete: true, // should delete all c3 records
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c3); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// insert mock records
|
||||
c1RecordA := models.NewRecord(c1)
|
||||
c1RecordA.Id = "a"
|
||||
if err := dao.Save(c1RecordA); err != nil {
|
||||
return err
|
||||
}
|
||||
c1RecordB := models.NewRecord(c1)
|
||||
c1RecordB.Id = "b"
|
||||
if err := dao.Save(c1RecordB); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < 2400; i++ {
|
||||
c2Record := models.NewRecord(c2)
|
||||
c2Record.Set("rel", []string{c1RecordA.Id, c1RecordB.Id})
|
||||
if err := dao.Save(c2Record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c3Record := models.NewRecord(c3)
|
||||
c3Record.Set("rel", c1RecordA.Id)
|
||||
if err := dao.Save(c3Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// FindSettings returns and decode the serialized app settings param value.
|
||||
//
|
||||
// The method will first try to decode the param value without decryption.
|
||||
// If it fails and optEncryptionKey is set, it will try again by first
|
||||
// decrypting the value and then decode it again.
|
||||
//
|
||||
// Returns an error if it fails to decode the stored serialized param value.
|
||||
func (dao *Dao) FindSettings(optEncryptionKey ...string) (*settings.Settings, error) {
|
||||
param, err := dao.FindParamByKey(models.ParamAppSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := settings.New()
|
||||
|
||||
// try first without decryption
|
||||
plainDecodeErr := json.Unmarshal(param.Value, result)
|
||||
|
||||
// failed, try to decrypt
|
||||
if plainDecodeErr != nil {
|
||||
var encryptionKey string
|
||||
if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" {
|
||||
encryptionKey = optEncryptionKey[0]
|
||||
}
|
||||
|
||||
// load without decrypt has failed and there is no encryption key to use for decrypt
|
||||
if encryptionKey == "" {
|
||||
return nil, errors.New("failed to load the stored app settings - missing or invalid encryption key")
|
||||
}
|
||||
|
||||
// decrypt
|
||||
decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey)
|
||||
if decryptErr != nil {
|
||||
return nil, decryptErr
|
||||
}
|
||||
|
||||
// decode again
|
||||
decryptedDecodeErr := json.Unmarshal(decrypted, result)
|
||||
if decryptedDecodeErr != nil {
|
||||
return nil, decryptedDecodeErr
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveSettings persists the specified settings configuration.
|
||||
//
|
||||
// If optEncryptionKey is set, then the stored serialized value will be encrypted with it.
|
||||
func (dao *Dao) SaveSettings(newSettings *settings.Settings, optEncryptionKey ...string) error {
|
||||
return dao.SaveParam(models.ParamAppSettings, newSettings, optEncryptionKey...)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestSaveAndFindSettings(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
encryptionKey := security.PseudorandomString(32)
|
||||
|
||||
// change unencrypted app settings
|
||||
app.Settings().Meta.AppName = "save_unencrypted"
|
||||
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// check if the change was persisted
|
||||
s1, err := app.Dao().FindSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch settings: %v", err)
|
||||
}
|
||||
if s1.Meta.AppName != "save_unencrypted" {
|
||||
t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_unencrypted", s1)
|
||||
}
|
||||
|
||||
// make another change but this time provide an encryption key
|
||||
app.Settings().Meta.AppName = "save_encrypted"
|
||||
if err := app.Dao().SaveSettings(app.Settings(), encryptionKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// try to fetch the settings without encryption key (should fail)
|
||||
if s2, err := app.Dao().FindSettings(); err == nil {
|
||||
t.Fatalf("Expected FindSettings to fail without an encryption key, got \n%v", s2)
|
||||
}
|
||||
|
||||
// try again but this time with an encryption key
|
||||
s3, err := app.Dao().FindSettings(encryptionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch settings with an encryption key %s: %v", encryptionKey, err)
|
||||
}
|
||||
if s3.Meta.AppName != "save_encrypted" {
|
||||
t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_encrypted", s3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# ignore everything
|
||||
/*
|
||||
|
||||
# exclude from the ignore filter
|
||||
!.gitignore
|
||||
!main.go
|
||||
+59
-14
@@ -9,34 +9,70 @@ import (
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
)
|
||||
|
||||
func defaultPublicDir() string {
|
||||
if strings.HasPrefix(os.Args[0], os.TempDir()) {
|
||||
// most likely ran with go run
|
||||
return "./pb_public"
|
||||
}
|
||||
|
||||
return filepath.Join(os.Args[0], "../pb_public")
|
||||
}
|
||||
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
var publicDirFlag string
|
||||
// ---------------------------------------------------------------
|
||||
// Optional plugin flags:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// add "--publicDir" option flag
|
||||
var migrationsDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&publicDirFlag,
|
||||
&migrationsDir,
|
||||
"migrationsDir",
|
||||
"",
|
||||
"the directory with the user defined migrations",
|
||||
)
|
||||
|
||||
var automigrate bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(
|
||||
&automigrate,
|
||||
"automigrate",
|
||||
true,
|
||||
"enable/disable auto migrations",
|
||||
)
|
||||
|
||||
var publicDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&publicDir,
|
||||
"publicDir",
|
||||
defaultPublicDir(),
|
||||
"the directory to serve static files",
|
||||
)
|
||||
|
||||
var indexFallback bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(
|
||||
&indexFallback,
|
||||
"indexFallback",
|
||||
true,
|
||||
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
|
||||
)
|
||||
|
||||
app.RootCmd.ParseFlags(os.Args[1:])
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Plugins and hooks:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// load js pb_migrations
|
||||
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
// migrate command (with js templates)
|
||||
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
|
||||
TemplateLang: migratecmd.TemplateLangJS,
|
||||
Automigrate: automigrate,
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
// serves static files from the provided public dir (if exists)
|
||||
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDirFlag), true))
|
||||
|
||||
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -44,3 +80,12 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// the default pb_public dir location is relative to the executable
|
||||
func defaultPublicDir() string {
|
||||
if strings.HasPrefix(os.Args[0], os.TempDir()) {
|
||||
// most likely ran with go run
|
||||
return "./pb_public"
|
||||
}
|
||||
return filepath.Join(os.Args[0], "../pb_public")
|
||||
}
|
||||
|
||||
+20
-2
@@ -4,6 +4,8 @@ package forms
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// base ID value regex pattern
|
||||
@@ -13,7 +15,8 @@ var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
|
||||
// Usually used in combination with InterceptorFunc.
|
||||
type InterceptorNextFunc = func() error
|
||||
|
||||
// InterceptorFunc defines a single interceptor function that will execute the provided next func handler.
|
||||
// InterceptorFunc defines a single interceptor function that
|
||||
// will execute the provided next func handler.
|
||||
type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc
|
||||
|
||||
// runInterceptors executes the provided list of interceptors.
|
||||
@@ -21,6 +24,21 @@ func runInterceptors(next InterceptorNextFunc, interceptors ...InterceptorFunc)
|
||||
for i := len(interceptors) - 1; i >= 0; i-- {
|
||||
next = interceptors[i](next)
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
// InterceptorWithRecordNextFunc is a Record interceptor handler function.
|
||||
// Usually used in combination with InterceptorWithRecordFunc.
|
||||
type InterceptorWithRecordNextFunc = func(record *models.Record) error
|
||||
|
||||
// InterceptorWithRecordFunc defines a single Record interceptor function
|
||||
// that will execute the provided next func handler.
|
||||
type InterceptorWithRecordFunc func(next InterceptorWithRecordNextFunc) InterceptorWithRecordNextFunc
|
||||
|
||||
// runInterceptorsWithRecord executes the provided list of Record interceptors.
|
||||
func runInterceptorsWithRecord(record *models.Record, next InterceptorWithRecordNextFunc, interceptors ...InterceptorWithRecordFunc) error {
|
||||
for i := len(interceptors) - 1; i >= 0; i-- {
|
||||
next = interceptors[i](next)
|
||||
}
|
||||
return next(record)
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := form.dao.FindCollectionByNameOrId(options.CollectionId); err != nil {
|
||||
if err := form.dao.FindById(&models.Collection{}, options.CollectionId); err != nil {
|
||||
return validation.Errors{fmt.Sprint(i): validation.NewError(
|
||||
"validation_field_invalid_relation",
|
||||
"The relation collection doesn't exist.",
|
||||
|
||||
@@ -367,12 +367,12 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCall := 1
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCall = 0
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCall {
|
||||
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCall, interceptorCalls)
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
|
||||
@@ -103,6 +103,7 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
|
||||
if upsertModel == nil {
|
||||
upsertModel = collection
|
||||
}
|
||||
upsertModel.MarkAsNotNew()
|
||||
|
||||
upsertForm := NewCollectionUpsert(form.app, upsertModel)
|
||||
upsertForm.SetDao(txDao)
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"collections": []
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: nil,
|
||||
},
|
||||
{
|
||||
@@ -82,7 +82,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
@@ -101,7 +101,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
@@ -137,7 +137,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 10,
|
||||
expectCollectionsCount: 11,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 3,
|
||||
"OnModelAfterCreate": 3,
|
||||
@@ -160,7 +160,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
},
|
||||
@@ -202,7 +202,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 5,
|
||||
},
|
||||
@@ -253,7 +253,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 9,
|
||||
expectCollectionsCount: 10,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
@@ -341,8 +341,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnModelBeforeDelete": 5,
|
||||
"OnModelAfterDelete": 5,
|
||||
"OnModelBeforeDelete": 6,
|
||||
"OnModelAfterDelete": 6,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -113,7 +113,10 @@ func (form *RecordEmailChangeConfirm) parseToken(token string) (*models.Record,
|
||||
|
||||
// Submit validates and submits the auth record email change confirmation form.
|
||||
// On success returns the updated auth record associated to `form.Token`.
|
||||
func (form *RecordEmailChangeConfirm) Submit() (*models.Record, error) {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -127,8 +130,12 @@ func (form *RecordEmailChangeConfirm) Submit() (*models.Record, error) {
|
||||
authRecord.SetVerified(true)
|
||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
||||
|
||||
if err := form.dao.SaveRecord(authRecord); err != nil {
|
||||
return nil, err
|
||||
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return authRecord, nil
|
||||
|
||||
@@ -2,10 +2,12 @@ package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -82,7 +84,24 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
record, err := form.Submit()
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
record, err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
@@ -124,3 +143,58 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
|
||||
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY"
|
||||
form.Password = "1234567890"
|
||||
interceptorEmail := authRecord.Email()
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptorEmail = record.Email()
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorEmail == authRecord.Email() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +61,15 @@ func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
|
||||
}
|
||||
|
||||
// Submit validates and sends the change email request.
|
||||
func (form *RecordEmailChangeRequest) Submit() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mails.SendRecordChangeEmail(form.app, form.record, form.NewEmail)
|
||||
return runInterceptorsWithRecord(form.record, func(m *models.Record) error {
|
||||
return mails.SendRecordChangeEmail(form.app, m, form.NewEmail)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
@@ -57,7 +59,24 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
@@ -85,3 +104,46 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordEmailChangeRequest(testApp, authRecord)
|
||||
form.NewEmail = "test_new@example.com"
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,10 @@ func (form *RecordPasswordResetConfirm) checkToken(value any) error {
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the updated auth record associated to `form.Token`.
|
||||
func (form *RecordPasswordResetConfirm) Submit() (*models.Record, error) {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,8 +91,12 @@ func (form *RecordPasswordResetConfirm) Submit() (*models.Record, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := form.dao.SaveRecord(authRecord); err != nil {
|
||||
return nil, err
|
||||
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return authRecord, nil
|
||||
|
||||
@@ -2,10 +2,12 @@ package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -76,7 +78,15 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
record, submitErr := form.Submit()
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
record, submitErr := form.Submit(interceptor)
|
||||
|
||||
// parse errors
|
||||
errs, ok := submitErr.(validation.Errors)
|
||||
@@ -85,6 +95,15 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
@@ -115,3 +134,59 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
|
||||
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
|
||||
form.Password = "1234567890"
|
||||
form.PasswordConfirm = "1234567890"
|
||||
interceptorTokenKey := authRecord.TokenKey()
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptorTokenKey = record.TokenKey()
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorTokenKey == authRecord.TokenKey() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,12 +59,15 @@ func (form *RecordPasswordResetRequest) Validate() error {
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success, sends a password reset email to the `form.Email` auth record.
|
||||
func (form *RecordPasswordResetRequest) Submit() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authRecord, err := form.dao.FindFirstRecordByData(form.collection.Id, schema.FieldNameEmail, form.Email)
|
||||
authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -75,12 +78,14 @@ func (form *RecordPasswordResetRequest) Submit() error {
|
||||
return errors.New("You've already requested a password reset.")
|
||||
}
|
||||
|
||||
if err := mails.SendRecordPasswordReset(form.app, authRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
|
||||
|
||||
return form.dao.SaveRecord(authRecord)
|
||||
return runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
|
||||
if err := mails.SendRecordPasswordReset(form.app, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
@@ -64,7 +66,24 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if s.expectError {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
@@ -95,3 +114,57 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordResetRequest(testApp, authCollection)
|
||||
form.Email = authRecord.Email()
|
||||
interceptorLastResetSentAt := authRecord.LastResetSentAt()
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptorLastResetSentAt = record.LastResetSentAt()
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorLastResetSentAt.String() == authRecord.LastResetSentAt().String() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
+290
-183
@@ -7,7 +7,6 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -18,6 +17,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
@@ -34,8 +34,8 @@ type RecordUpsert struct {
|
||||
manageAccess bool
|
||||
record *models.Record
|
||||
|
||||
filesToUpload map[string][]*filesystem.File
|
||||
filesToDelete []string // names list
|
||||
filesToUpload map[string][]*rest.UploadedFile
|
||||
|
||||
// base model fields
|
||||
Id string `json:"id"`
|
||||
@@ -51,7 +51,7 @@ type RecordUpsert struct {
|
||||
OldPassword string `json:"oldPassword"`
|
||||
// ---
|
||||
|
||||
Data map[string]any `json:"data"`
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
// NewRecordUpsert creates a new [RecordUpsert] form with initializer
|
||||
@@ -66,7 +66,7 @@ func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
|
||||
dao: app.Dao(),
|
||||
record: record,
|
||||
filesToDelete: []string{},
|
||||
filesToUpload: map[string][]*rest.UploadedFile{},
|
||||
filesToUpload: map[string][]*filesystem.File{},
|
||||
}
|
||||
|
||||
form.loadFormDefaults()
|
||||
@@ -74,6 +74,11 @@ func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
|
||||
return form
|
||||
}
|
||||
|
||||
// Data returns the loaded form's data.
|
||||
func (form *RecordUpsert) Data() map[string]any {
|
||||
return form.data
|
||||
}
|
||||
|
||||
// SetFullManageAccess sets the manageAccess bool flag of the current
|
||||
// form to enable/disable directly changing some system record fields
|
||||
// (often used with auth collection records).
|
||||
@@ -96,9 +101,9 @@ func (form *RecordUpsert) loadFormDefaults() {
|
||||
form.Verified = form.record.Verified()
|
||||
}
|
||||
|
||||
form.Data = map[string]any{}
|
||||
form.data = map[string]any{}
|
||||
for _, field := range form.record.Collection().Schema.Fields() {
|
||||
form.Data[field.Name] = form.record.Get(field.Name)
|
||||
form.data[field.Name] = form.record.Get(field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,49 +117,56 @@ func (form *RecordUpsert) getContentType(r *http.Request) string {
|
||||
return t
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractRequestData(r *http.Request, keyPrefix string) (map[string]any, error) {
|
||||
func (form *RecordUpsert) extractRequestData(
|
||||
r *http.Request,
|
||||
keyPrefix string,
|
||||
) (map[string]any, map[string][]*filesystem.File, error) {
|
||||
switch form.getContentType(r) {
|
||||
case "application/json":
|
||||
return form.extractJsonData(r, keyPrefix)
|
||||
case "multipart/form-data":
|
||||
return form.extractMultipartFormData(r, keyPrefix)
|
||||
default:
|
||||
return nil, errors.New("Unsupported request Content-Type.")
|
||||
return nil, nil, errors.New("unsupported request content-type")
|
||||
}
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractJsonData(r *http.Request, keyPrefix string) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
func (form *RecordUpsert) extractJsonData(
|
||||
r *http.Request,
|
||||
keyPrefix string,
|
||||
) (map[string]any, map[string][]*filesystem.File, error) {
|
||||
data := map[string]any{}
|
||||
|
||||
err := rest.CopyJsonBody(r, &result)
|
||||
err := rest.CopyJsonBody(r, &data)
|
||||
|
||||
if keyPrefix != "" {
|
||||
parts := strings.Split(keyPrefix, ".")
|
||||
for _, part := range parts {
|
||||
if result[part] == nil {
|
||||
if data[part] == nil {
|
||||
break
|
||||
}
|
||||
if v, ok := result[part].(map[string]any); ok {
|
||||
result = v
|
||||
if v, ok := data[part].(map[string]any); ok {
|
||||
data = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, err
|
||||
return data, nil, err
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix string) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
|
||||
func (form *RecordUpsert) extractMultipartFormData(
|
||||
r *http.Request,
|
||||
keyPrefix string,
|
||||
) (map[string]any, map[string][]*filesystem.File, error) {
|
||||
// parse form data (if not already)
|
||||
if err := r.ParseMultipartForm(rest.DefaultMaxMemory); err != nil {
|
||||
return result, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
data := map[string]any{}
|
||||
filesToUpload := map[string][]*filesystem.File{}
|
||||
arrayValueSupportTypes := schema.ArraybleFieldTypes()
|
||||
|
||||
form.filesToUpload = map[string][]*rest.UploadedFile{}
|
||||
|
||||
for fullKey, values := range r.PostForm {
|
||||
key := fullKey
|
||||
if keyPrefix != "" {
|
||||
@@ -162,15 +174,15 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix st
|
||||
}
|
||||
|
||||
if len(values) == 0 {
|
||||
result[key] = nil
|
||||
data[key] = nil
|
||||
continue
|
||||
}
|
||||
|
||||
field := form.record.Collection().Schema.GetFieldByName(key)
|
||||
if field != nil && list.ExistInSlice(field.Type, arrayValueSupportTypes) {
|
||||
result[key] = values
|
||||
data[key] = values
|
||||
} else {
|
||||
result[key] = values[0]
|
||||
data[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,76 +208,155 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix st
|
||||
continue
|
||||
}
|
||||
|
||||
options, ok := field.Options.(*schema.FileOptions)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if form.filesToUpload[key] == nil {
|
||||
form.filesToUpload[key] = []*rest.UploadedFile{}
|
||||
}
|
||||
|
||||
if options.MaxSelect == 1 {
|
||||
form.filesToUpload[key] = append(form.filesToUpload[key], files[0])
|
||||
} else if options.MaxSelect > 1 {
|
||||
form.filesToUpload[key] = append(form.filesToUpload[key], files...)
|
||||
}
|
||||
filesToUpload[key] = append(filesToUpload[key], files...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) normalizeData() error {
|
||||
for _, field := range form.record.Collection().Schema.Fields() {
|
||||
if v, ok := form.Data[field.Name]; ok {
|
||||
form.Data[field.Name] = field.PrepareValue(v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return data, filesToUpload, nil
|
||||
}
|
||||
|
||||
// LoadRequest extracts the json or multipart/form-data request data
|
||||
// and lods it into the form.
|
||||
//
|
||||
// File upload is supported only via multipart/form-data.
|
||||
//
|
||||
// To DELETE previously uploaded file(s) you can suffix the field name
|
||||
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
|
||||
// For single file upload fields, you can skip the index and directly
|
||||
// reset the field using its field name (eg. `myfile = null`).
|
||||
func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
|
||||
requestData, err := form.extractRequestData(r, keyPrefix)
|
||||
requestData, uploadedFiles, err := form.extractRequestData(r, keyPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return form.LoadData(requestData)
|
||||
if err := form.LoadData(requestData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for key, files := range uploadedFiles {
|
||||
form.AddFiles(key, files...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadData loads and normalizes the provided data into the form.
|
||||
// AddFiles adds the provided file(s) to the specified file field.
|
||||
//
|
||||
// To DELETE previously uploaded file(s) you can suffix the field name
|
||||
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
|
||||
// For single file upload fields, you can skip the index and directly
|
||||
// reset the field using its field name (eg. `myfile = null`).
|
||||
// If the file field is a SINGLE-value file field (aka. "Max Select = 1"),
|
||||
// then the newly added file will REPLACE the existing one.
|
||||
// In this case if you pass more than 1 files only the first one will be assigned.
|
||||
//
|
||||
// If the file field is a MULTI-value file field (aka. "Max Select > 1"),
|
||||
// then the newly added file(s) will be APPENDED to the existing one(s).
|
||||
//
|
||||
// Example
|
||||
//
|
||||
// f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
|
||||
// f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
|
||||
// form.AddFiles("documents", f1, f2)
|
||||
func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error {
|
||||
field := form.record.Collection().Schema.GetFieldByName(key)
|
||||
if field == nil || field.Type != schema.FieldTypeFile {
|
||||
return errors.New("invalid field key")
|
||||
}
|
||||
|
||||
options, ok := field.Options.(*schema.FileOptions)
|
||||
if !ok {
|
||||
return errors.New("failed to initilize field options")
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return nil // nothing to upload
|
||||
}
|
||||
|
||||
if form.filesToUpload == nil {
|
||||
form.filesToUpload = map[string][]*filesystem.File{}
|
||||
}
|
||||
|
||||
oldNames := list.ToUniqueStringSlice(form.data[key])
|
||||
|
||||
if options.MaxSelect == 1 {
|
||||
// mark previous file(s) for deletion before replacing
|
||||
if len(oldNames) > 0 {
|
||||
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
|
||||
}
|
||||
|
||||
// replace
|
||||
form.filesToUpload[key] = []*filesystem.File{files[0]}
|
||||
form.data[key] = field.PrepareValue(files[0].Name)
|
||||
} else {
|
||||
// append
|
||||
form.filesToUpload[key] = append(form.filesToUpload[key], files...)
|
||||
for _, f := range files {
|
||||
oldNames = append(oldNames, f.Name)
|
||||
}
|
||||
form.data[key] = field.PrepareValue(oldNames)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveFiles removes a single or multiple file from the specified file field.
|
||||
//
|
||||
// NB! If filesToDelete is not set it will remove all existing files
|
||||
// assigned to the file field (including those assigned with AddFiles)!
|
||||
//
|
||||
// Example
|
||||
//
|
||||
// // mark only only 2 files for removal
|
||||
// form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
|
||||
//
|
||||
// // mark all "documents" files for removal
|
||||
// form.AddFiles("documents")
|
||||
func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
|
||||
field := form.record.Collection().Schema.GetFieldByName(key)
|
||||
if field == nil || field.Type != schema.FieldTypeFile {
|
||||
return errors.New("invalid field key")
|
||||
}
|
||||
|
||||
existing := list.ToUniqueStringSlice(form.data[key])
|
||||
|
||||
// mark all files for deletion
|
||||
if len(toDelete) == 0 {
|
||||
toDelete = make([]string, len(existing))
|
||||
copy(toDelete, existing)
|
||||
}
|
||||
|
||||
// check for existing files
|
||||
for i := len(existing) - 1; i >= 0; i-- {
|
||||
if list.ExistInSlice(existing[i], toDelete) {
|
||||
form.filesToDelete = append(form.filesToDelete, existing[i])
|
||||
existing = append(existing[:i], existing[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
// check for newly uploaded files
|
||||
for i := len(form.filesToUpload[key]) - 1; i >= 0; i-- {
|
||||
f := form.filesToUpload[key][i]
|
||||
if list.ExistInSlice(f.Name, toDelete) {
|
||||
form.filesToUpload[key] = append(form.filesToUpload[key][:i], form.filesToUpload[key][i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
form.data[key] = field.PrepareValue(existing)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadData loads and normalizes the provided regular record data fields into the form.
|
||||
func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||
// load base system fields
|
||||
if v, ok := requestData["id"]; ok {
|
||||
if v, ok := requestData[schema.FieldNameId]; ok {
|
||||
form.Id = cast.ToString(v)
|
||||
}
|
||||
|
||||
// load auth system fields
|
||||
if form.record.Collection().IsAuth() {
|
||||
if v, ok := requestData["username"]; ok {
|
||||
if v, ok := requestData[schema.FieldNameUsername]; ok {
|
||||
form.Username = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData["email"]; ok {
|
||||
if v, ok := requestData[schema.FieldNameEmail]; ok {
|
||||
form.Email = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData["emailVisibility"]; ok {
|
||||
if v, ok := requestData[schema.FieldNameEmailVisibility]; ok {
|
||||
form.EmailVisibility = cast.ToBool(v)
|
||||
}
|
||||
if v, ok := requestData["verified"]; ok {
|
||||
if v, ok := requestData[schema.FieldNameVerified]; ok {
|
||||
form.Verified = cast.ToBool(v)
|
||||
}
|
||||
if v, ok := requestData["password"]; ok {
|
||||
@@ -279,8 +370,16 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||
}
|
||||
}
|
||||
|
||||
// extend the record schema data with the request data
|
||||
extendedData := form.record.SchemaData()
|
||||
// replace modifiers (if any)
|
||||
requestData = form.record.ReplaceModifers(requestData)
|
||||
|
||||
// create a shallow copy of form.data
|
||||
var extendedData = make(map[string]any, len(form.data))
|
||||
for k, v := range form.data {
|
||||
extendedData[k] = v
|
||||
}
|
||||
|
||||
// extend form.data with the request data
|
||||
rawData, err := json.Marshal(requestData)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -291,89 +390,59 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||
|
||||
for _, field := range form.record.Collection().Schema.Fields() {
|
||||
key := field.Name
|
||||
value := extendedData[key]
|
||||
value = field.PrepareValue(value)
|
||||
value := field.PrepareValue(extendedData[key])
|
||||
|
||||
if field.Type != schema.FieldTypeFile {
|
||||
form.Data[key] = value
|
||||
form.data[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.FileOptions)
|
||||
oldNames := list.ToUniqueStringSlice(form.Data[key])
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Delete previously uploaded file(s)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
oldNames := form.record.GetStringSlice(key)
|
||||
submittedNames := list.ToUniqueStringSlice(value)
|
||||
|
||||
// ensure that all submitted names are existing to prevent accidental files deletions
|
||||
if len(submittedNames) > len(oldNames) || len(list.SubtractSlice(submittedNames, oldNames)) != 0 {
|
||||
return validation.Errors{
|
||||
key: validation.NewError(
|
||||
"validation_unknown_filenames",
|
||||
"The field contains unknown filenames.",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// if empty value was set, mark all previously uploaded files for deletion
|
||||
if len(list.ToUniqueStringSlice(value)) == 0 && len(oldNames) > 0 {
|
||||
form.filesToDelete = append(form.filesToDelete, oldNames...)
|
||||
form.Data[key] = []string{}
|
||||
// otherwise check for "deleted" (aka. unsubmitted) file names
|
||||
if len(submittedNames) == 0 && len(oldNames) > 0 {
|
||||
form.RemoveFiles(key)
|
||||
} else if len(oldNames) > 0 {
|
||||
indexesToDelete := make([]int, 0, len(extendedData))
|
||||
|
||||
// search for individual file name to delete (eg. "file.test.png = null")
|
||||
for i, name := range oldNames {
|
||||
if v, ok := extendedData[key+"."+name]; ok && cast.ToString(v) == "" {
|
||||
indexesToDelete = append(indexesToDelete, i)
|
||||
}
|
||||
}
|
||||
toDelete := []string{}
|
||||
|
||||
// search for individual file index to delete (eg. "file.0 = null")
|
||||
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
|
||||
for indexedKey := range extendedData {
|
||||
if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
|
||||
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
|
||||
if indexErr != nil || index >= len(oldNames) {
|
||||
continue
|
||||
}
|
||||
indexesToDelete = append(indexesToDelete, index)
|
||||
}
|
||||
}
|
||||
|
||||
// slice to fill only with the non-deleted indexes
|
||||
nonDeleted := make([]string, 0, len(oldNames))
|
||||
for i, name := range oldNames {
|
||||
// not marked for deletion
|
||||
if !list.ExistInSlice(i, indexesToDelete) {
|
||||
nonDeleted = append(nonDeleted, name)
|
||||
for _, name := range oldNames {
|
||||
// submitted as a modifier or a new array
|
||||
if !list.ExistInSlice(name, submittedNames) {
|
||||
toDelete = append(toDelete, name)
|
||||
continue
|
||||
}
|
||||
|
||||
// store the id to actually delete the file later
|
||||
form.filesToDelete = append(form.filesToDelete, name)
|
||||
}
|
||||
form.Data[key] = nonDeleted
|
||||
|
||||
if len(toDelete) > 0 {
|
||||
form.RemoveFiles(key, toDelete...)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Check for new uploaded file
|
||||
// -----------------------------------------------------------
|
||||
|
||||
if len(form.filesToUpload[key]) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// refresh oldNames list
|
||||
oldNames = list.ToUniqueStringSlice(form.Data[key])
|
||||
|
||||
if options.MaxSelect == 1 {
|
||||
// delete previous file(s) before replacing
|
||||
if len(oldNames) > 0 {
|
||||
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
|
||||
}
|
||||
form.Data[key] = form.filesToUpload[key][0].Name()
|
||||
} else if options.MaxSelect > 1 {
|
||||
// append the id of each uploaded file instance
|
||||
for _, file := range form.filesToUpload[key] {
|
||||
oldNames = append(oldNames, file.Name())
|
||||
}
|
||||
form.Data[key] = oldNames
|
||||
// allow file key reasignments for file names sorting
|
||||
// (only if all submitted values already exists)
|
||||
if len(submittedNames) > 0 && len(list.SubtractSlice(submittedNames, oldNames)) == 0 {
|
||||
form.data[key] = submittedNames
|
||||
}
|
||||
}
|
||||
|
||||
return form.normalizeData()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
@@ -429,13 +498,16 @@ func (form *RecordUpsert) Validate() error {
|
||||
),
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.When(form.record.IsNew(), validation.Required),
|
||||
validation.When(
|
||||
(form.record.IsNew() || form.PasswordConfirm != "" || form.OldPassword != ""),
|
||||
validation.Required,
|
||||
),
|
||||
validation.Length(form.record.Collection().AuthOptions().MinPasswordLength, 72),
|
||||
),
|
||||
validation.Field(
|
||||
&form.PasswordConfirm,
|
||||
validation.When(
|
||||
(form.record.IsNew() || form.Password != ""),
|
||||
(form.record.IsNew() || form.Password != "" || form.OldPassword != ""),
|
||||
validation.Required,
|
||||
),
|
||||
validation.By(validators.Compare(form.Password)),
|
||||
@@ -446,7 +518,7 @@ func (form *RecordUpsert) Validate() error {
|
||||
// - form.manageAccess is not set
|
||||
// - changing the existing password
|
||||
validation.When(
|
||||
!form.record.IsNew() && !form.manageAccess && form.Password != "",
|
||||
!form.record.IsNew() && !form.manageAccess && (form.Password != "" || form.PasswordConfirm != ""),
|
||||
validation.Required,
|
||||
validation.By(form.checkOldPassword),
|
||||
),
|
||||
@@ -463,7 +535,7 @@ func (form *RecordUpsert) Validate() error {
|
||||
form.dao,
|
||||
form.record,
|
||||
form.filesToUpload,
|
||||
).Validate(form.Data)
|
||||
).Validate(form.data)
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) checkUniqueUsername(value any) error {
|
||||
@@ -583,7 +655,7 @@ func (form *RecordUpsert) ValidateAndFill() error {
|
||||
}
|
||||
}
|
||||
|
||||
if form.Password != "" {
|
||||
if form.Password != "" && form.Password == form.PasswordConfirm {
|
||||
if err := form.record.SetPassword(form.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -591,7 +663,7 @@ func (form *RecordUpsert) ValidateAndFill() error {
|
||||
}
|
||||
|
||||
// bulk load the remaining form data
|
||||
form.record.Load(form.Data)
|
||||
form.record.Load(form.data)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -607,20 +679,23 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
|
||||
return err
|
||||
}
|
||||
|
||||
// use the default app.Dao to prevent changing the transaction form.Dao
|
||||
// and causing "transaction has already been committed or rolled back" error
|
||||
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
var dryDao *daos.Dao
|
||||
if form.dao.ConcurrentDB() == form.dao.NonconcurrentDB() {
|
||||
// it is already in a transaction and therefore use the app concurrent db pool
|
||||
// to prevent "transaction has already been committed or rolled back" error
|
||||
dryDao = daos.New(form.app.Dao().ConcurrentDB())
|
||||
} else {
|
||||
// otherwise use the form noncurrent dao db pool
|
||||
dryDao = daos.New(form.dao.NonconcurrentDB())
|
||||
}
|
||||
|
||||
return dryDao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
tx, ok := txDao.DB().(*dbx.Tx)
|
||||
if !ok {
|
||||
return errors.New("failed to get transaction db")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
txDao.BeforeCreateFunc = nil
|
||||
txDao.AfterCreateFunc = nil
|
||||
txDao.BeforeUpdateFunc = nil
|
||||
txDao.AfterUpdateFunc = nil
|
||||
|
||||
if err := txDao.SaveRecord(form.record); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -648,35 +723,57 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
}
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
// persist record model
|
||||
if err := txDao.SaveRecord(form.record); err != nil {
|
||||
return fmt.Errorf("Failed to save the record: %v", err)
|
||||
if !form.record.HasId() {
|
||||
form.record.RefreshId()
|
||||
form.record.MarkAsNew()
|
||||
}
|
||||
|
||||
// upload new files (if any)
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return fmt.Errorf("failed to process the uploaded files: %w", err)
|
||||
}
|
||||
|
||||
// persist the record model
|
||||
if saveErr := form.dao.SaveRecord(form.record); saveErr != nil {
|
||||
// try to cleanup the successfully uploaded files
|
||||
if _, err := form.deleteFilesByNamesList(form.getFilesToUploadNames()); err != nil && form.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
// upload new files (if any)
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return fmt.Errorf("Failed to process the upload files: %v", err)
|
||||
}
|
||||
return fmt.Errorf("failed to save the record: %w", saveErr)
|
||||
}
|
||||
|
||||
// delete old files (if any)
|
||||
if err := form.processFilesToDelete(); err != nil { //nolint:staticcheck
|
||||
// for now fail silently to avoid reupload when `form.Submit()`
|
||||
// is called manually (aka. not from an api request)...
|
||||
}
|
||||
// delete old files (if any)
|
||||
//
|
||||
// for now fail silently to avoid reupload when `form.Submit()`
|
||||
// is called manually (aka. not from an api request)...
|
||||
if err := form.processFilesToDelete(); err != nil && form.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) getFilesToUploadNames() []string {
|
||||
names := []string{}
|
||||
|
||||
for fieldKey := range form.filesToUpload {
|
||||
for _, file := range form.filesToUpload[fieldKey] {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) processFilesToUpload() error {
|
||||
if len(form.filesToUpload) == 0 {
|
||||
return nil // no parsed file fields
|
||||
}
|
||||
|
||||
if !form.record.HasId() {
|
||||
return errors.New("The record is not persisted yet.")
|
||||
return errors.New("the record is not persisted yet")
|
||||
}
|
||||
|
||||
fs, err := form.app.NewFilesystem()
|
||||
@@ -685,65 +782,75 @@ func (form *RecordUpsert) processFilesToUpload() error {
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
var uploadErrors []error
|
||||
var uploadErrors []error // list of upload errors
|
||||
var uploaded []string // list of uploaded file paths
|
||||
|
||||
for fieldKey := range form.filesToUpload {
|
||||
for i := len(form.filesToUpload[fieldKey]) - 1; i >= 0; i-- {
|
||||
file := form.filesToUpload[fieldKey][i]
|
||||
path := form.record.BaseFilesPath() + "/" + file.Name()
|
||||
|
||||
if err := fs.UploadMultipart(file.Header(), path); err == nil {
|
||||
// remove the uploaded file from the list
|
||||
form.filesToUpload[fieldKey] = append(form.filesToUpload[fieldKey][:i], form.filesToUpload[fieldKey][i+1:]...)
|
||||
for i, file := range form.filesToUpload[fieldKey] {
|
||||
path := form.record.BaseFilesPath() + "/" + file.Name
|
||||
if err := fs.UploadFile(file, path); err == nil {
|
||||
// keep track of the already uploaded file
|
||||
uploaded = append(uploaded, path)
|
||||
} else {
|
||||
// store the upload error
|
||||
uploadErrors = append(uploadErrors, fmt.Errorf("File %d: %v", i, err))
|
||||
uploadErrors = append(uploadErrors, fmt.Errorf("file %d: %v", i, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(uploadErrors) > 0 {
|
||||
return fmt.Errorf("Failed to upload all files: %v", uploadErrors)
|
||||
// cleanup - try to delete the successfully uploaded files (if any)
|
||||
form.deleteFilesByNamesList(uploaded)
|
||||
|
||||
return fmt.Errorf("failed to upload all files: %v", uploadErrors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) processFilesToDelete() error {
|
||||
if len(form.filesToDelete) == 0 {
|
||||
return nil // nothing to delete
|
||||
func (form *RecordUpsert) processFilesToDelete() (err error) {
|
||||
form.filesToDelete, err = form.deleteFilesByNamesList(form.filesToDelete)
|
||||
return
|
||||
}
|
||||
|
||||
// deleteFiles deletes a list of record files by their names.
|
||||
// Returns the failed/remaining files.
|
||||
func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string, error) {
|
||||
if len(filenames) == 0 {
|
||||
return filenames, nil // nothing to delete
|
||||
}
|
||||
|
||||
if !form.record.HasId() {
|
||||
return errors.New("The record is not persisted yet.")
|
||||
return filenames, errors.New("the record doesn't have a unique ID")
|
||||
}
|
||||
|
||||
fs, err := form.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
return filenames, err
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
var deleteErrors []error
|
||||
for i := len(form.filesToDelete) - 1; i >= 0; i-- {
|
||||
filename := form.filesToDelete[i]
|
||||
|
||||
for i := len(filenames) - 1; i >= 0; i-- {
|
||||
filename := filenames[i]
|
||||
path := form.record.BaseFilesPath() + "/" + filename
|
||||
|
||||
if err := fs.Delete(path); err == nil {
|
||||
// remove the deleted file from the list
|
||||
form.filesToDelete = append(form.filesToDelete[:i], form.filesToDelete[i+1:]...)
|
||||
filenames = append(filenames[:i], filenames[i+1:]...)
|
||||
|
||||
// try to delete the related file thumbs (if any)
|
||||
fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
|
||||
} else {
|
||||
// store the delete error
|
||||
deleteErrors = append(deleteErrors, fmt.Errorf("File %d: %v", i, err))
|
||||
deleteErrors = append(deleteErrors, fmt.Errorf("file %d: %v", i, err))
|
||||
}
|
||||
|
||||
// try to delete the related file thumbs (if any)
|
||||
fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
|
||||
}
|
||||
|
||||
if len(deleteErrors) > 0 {
|
||||
return fmt.Errorf("Failed to delete all files: %v", deleteErrors)
|
||||
return filenames, fmt.Errorf("failed to delete all files: %v", deleteErrors)
|
||||
}
|
||||
|
||||
return nil
|
||||
return filenames, nil
|
||||
}
|
||||
|
||||
+180
-28
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
@@ -44,9 +46,9 @@ func TestNewRecordUpsert(t *testing.T) {
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
|
||||
val := form.Data["title"]
|
||||
val := form.Data()["title"]
|
||||
if val != "test_value" {
|
||||
t.Errorf("Expected record data to be loaded, got %v", form.Data)
|
||||
t.Errorf("Expected record data to be loaded, got %v", form.Data())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +89,10 @@ func TestRecordUpsertLoadRequestJson(t *testing.T) {
|
||||
"unknown": "test456",
|
||||
// file fields unset/delete
|
||||
"file_one": nil,
|
||||
"file_many.0": "", // delete by index
|
||||
"file_many.1": "test.png", // should be ignored
|
||||
"file_many.300_WlbFWSGmW9.png": nil, // delete by filename
|
||||
"file_many.0": "", // delete by index
|
||||
"file_many-": []string{"test_MaWC6mWyrP.txt", "test_tC1Yc87DfC.txt"}, // multiple delete with modifier
|
||||
"file_many.300_WlbFWSGmW9.png": nil, // delete by filename
|
||||
"file_many.2": "test.png", // should be ignored
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -107,15 +110,15 @@ func TestRecordUpsertLoadRequestJson(t *testing.T) {
|
||||
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["text"]; !ok || v != "test123" {
|
||||
if v, ok := form.Data()["text"]; !ok || v != "test123" {
|
||||
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["unknown"]; ok {
|
||||
if v, ok := form.Data()["unknown"]; ok {
|
||||
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
|
||||
}
|
||||
|
||||
fileOne, ok := form.Data["file_one"]
|
||||
fileOne, ok := form.Data()["file_one"]
|
||||
if !ok {
|
||||
t.Fatal("Expect file_one field to be set")
|
||||
}
|
||||
@@ -123,7 +126,7 @@ func TestRecordUpsertLoadRequestJson(t *testing.T) {
|
||||
t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
|
||||
}
|
||||
|
||||
fileMany, ok := form.Data["file_many"]
|
||||
fileMany, ok := form.Data()["file_many"]
|
||||
if !ok || fileMany == nil {
|
||||
t.Fatal("Expect file_many field to be set")
|
||||
}
|
||||
@@ -147,11 +150,12 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
|
||||
"a.b.text": "test123",
|
||||
"a.b.unknown": "test456",
|
||||
// file fields unset/delete
|
||||
"a.b.file_one": "",
|
||||
"a.b.file_many.0": "",
|
||||
"a.b.file_many.300_WlbFWSGmW9.png": "test.png", // delete by name
|
||||
"a.b.file_many.1": "test.png", // should be ignored
|
||||
}, "file_many")
|
||||
"a.b.file_one-": "test_d61b33QdDU.txt", // delete with modifier
|
||||
"a.b.file_many.0": "", // delete by index
|
||||
"a.b.file_many-": "test_tC1Yc87DfC.txt", // delete with modifier
|
||||
"a.b.file_many.300_WlbFWSGmW9.png": "", // delete by filename
|
||||
"a.b.file_many.2": "test.png", // should be ignored
|
||||
}, "a.b.file_many")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -168,15 +172,15 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
|
||||
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["text"]; !ok || v != "test123" {
|
||||
if v, ok := form.Data()["text"]; !ok || v != "test123" {
|
||||
t.Fatalf("Expect text field to be %q, got %q", "test123", v)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["unknown"]; ok {
|
||||
if v, ok := form.Data()["unknown"]; ok {
|
||||
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
|
||||
}
|
||||
|
||||
fileOne, ok := form.Data["file_one"]
|
||||
fileOne, ok := form.Data()["file_one"]
|
||||
if !ok {
|
||||
t.Fatal("Expect file_one field to be set")
|
||||
}
|
||||
@@ -184,12 +188,12 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
|
||||
t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
|
||||
}
|
||||
|
||||
fileMany, ok := form.Data["file_many"]
|
||||
fileMany, ok := form.Data()["file_many"]
|
||||
if !ok || fileMany == nil {
|
||||
t.Fatal("Expect file_many field to be set")
|
||||
}
|
||||
manyfilesRemains := len(list.ToUniqueStringSlice(fileMany))
|
||||
expectedRemains := 2 // -2 from 3 removed + 1 new upload
|
||||
expectedRemains := 3 // 5 old; 3 deleted and 1 new uploaded
|
||||
if manyfilesRemains != expectedRemains {
|
||||
t.Fatalf("Expect file_many to be %d, got %d (%v)", expectedRemains, manyfilesRemains, fileMany)
|
||||
}
|
||||
@@ -214,11 +218,11 @@ func TestRecordUpsertLoadData(t *testing.T) {
|
||||
t.Fatal(loadErr)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["title"]; !ok || v != "test_new" {
|
||||
if v, ok := form.Data()["title"]; !ok || v != "test_new" {
|
||||
t.Fatalf("Expect title field to be %v, got %v", "test_new", v)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["active"]; !ok || v != true {
|
||||
if v, ok := form.Data()["active"]; !ok || v != true {
|
||||
t.Fatalf("Expect active field to be %v, got %v", true, v)
|
||||
}
|
||||
}
|
||||
@@ -316,12 +320,10 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// ensure that the record changes weren't persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if recordAfter.GetString("title") == "dry_test" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetString("title"), "dry_test")
|
||||
}
|
||||
@@ -335,6 +337,66 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertDrySubmitWithNestedTx(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "dry_test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
txErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
form.SetDao(txDao)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadRequest(req, "")
|
||||
|
||||
callbackCalls := 0
|
||||
|
||||
result := form.DrySubmit(func(innerTxDao *daos.Dao) error {
|
||||
callbackCalls++
|
||||
return nil
|
||||
})
|
||||
if result != nil {
|
||||
t.Fatalf("Expected nil, got error %v", result)
|
||||
}
|
||||
|
||||
// ensure callback was called
|
||||
if callbackCalls != 1 {
|
||||
t.Fatalf("Expected callbackCalls to be 1, got %d", callbackCalls)
|
||||
}
|
||||
|
||||
// ensure that the original txDao can still be used after the DrySubmit rollback
|
||||
if _, err := txDao.FindRecordById(collection.Id, recordBefore.Id); err != nil {
|
||||
t.Fatalf("Expected the dry submit rollback to not affect the outer tx context, got %v", err)
|
||||
}
|
||||
|
||||
// ensure that the record changes weren't persisted
|
||||
recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recordAfter.GetString("title") == "dry_test" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordBefore.GetString("title"), "dry_test")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
t.Fatalf("Nested transactions failure: %v", txErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertSubmitFailure(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -405,7 +467,7 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
|
||||
if v := recordAfter.Get("email"); v == "invalid" {
|
||||
t.Fatalf("Expected record.email not to change, got %v", v)
|
||||
}
|
||||
if v := recordAfter.GetStringSlice("file_many"); len(v) != 3 {
|
||||
if v := recordAfter.GetStringSlice("file_many"); len(v) != 5 {
|
||||
t.Fatalf("Expected record.file_many not to change, got %v", v)
|
||||
}
|
||||
|
||||
@@ -477,8 +539,8 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
fileMany := (recordAfter.GetStringSlice("file_many"))
|
||||
if len(fileMany) != 4 { // 1 replace + 1 new
|
||||
t.Fatalf("Expected 4 record.file_many, got %d (%v)", len(fileMany), fileMany)
|
||||
if len(fileMany) != 6 { // 1 replace + 1 new
|
||||
t.Fatalf("Expected 6 record.file_many, got %d (%v)", len(fileMany), fileMany)
|
||||
}
|
||||
for _, f := range fileMany {
|
||||
if !hasRecordFile(app, recordAfter, f) {
|
||||
@@ -498,7 +560,7 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
form.Data["title"] = "test_new"
|
||||
form.Data()["title"] = "test_new"
|
||||
|
||||
testErr := errors.New("test_error")
|
||||
interceptorRecordTitle := ""
|
||||
@@ -533,7 +595,7 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorRecordTitle != form.Data["title"].(string) {
|
||||
if interceptorRecordTitle != form.Data()["title"].(string) {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
@@ -792,6 +854,24 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
},
|
||||
|
||||
// password
|
||||
{
|
||||
"trigger the password validations if only oldPassword is set",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{
|
||||
"oldPassword": "1234567890",
|
||||
},
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"trigger the password validations if only passwordConfirm is set",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{
|
||||
"passwordConfirm": "1234567890",
|
||||
},
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"try to update password without managed access",
|
||||
"4q1xlclmfloku33",
|
||||
@@ -863,3 +943,75 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
recordBefore, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// create test temp files
|
||||
tempDir := filepath.Join(app.DataDir(), "temp")
|
||||
if err := os.MkdirAll(app.DataDir(), os.ModePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
tmpFile, _ := os.CreateTemp(os.TempDir(), "tmpfile1-*.txt")
|
||||
tmpFile.Close()
|
||||
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
|
||||
f1, err := filesystem.NewFileFromPath(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f2, err := filesystem.NewFileFromPath(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f3, err := filesystem.NewFileFromPath(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form.AddFiles("file_one", f1) // should replace the existin file
|
||||
|
||||
form.AddFiles("file_many", f2, f3) // should append
|
||||
|
||||
form.RemoveFiles("file_many", "300_WlbFWSGmW9.png", "logo_vcfJJG5TAh.svg") // should remove
|
||||
|
||||
if err := form.Submit(); err != nil {
|
||||
t.Fatalf("Failed to submit the RecordUpsert form, got %v", err)
|
||||
}
|
||||
|
||||
recordAfter, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ensure files deletion
|
||||
if hasRecordFile(app, recordAfter, "test_d61b33QdDU.txt") {
|
||||
t.Fatalf("Expected the old file_one file to be deleted")
|
||||
}
|
||||
if hasRecordFile(app, recordAfter, "300_WlbFWSGmW9.png") {
|
||||
t.Fatalf("Expected 300_WlbFWSGmW9.png to be deleted")
|
||||
}
|
||||
if hasRecordFile(app, recordAfter, "logo_vcfJJG5TAh.svg") {
|
||||
t.Fatalf("Expected logo_vcfJJG5TAh.svg to be deleted")
|
||||
}
|
||||
|
||||
fileOne := recordAfter.GetStringSlice("file_one")
|
||||
if len(fileOne) == 0 {
|
||||
t.Fatalf("Expected new file_one file to be uploaded")
|
||||
}
|
||||
|
||||
fileMany := recordAfter.GetStringSlice("file_many")
|
||||
if len(fileMany) != 5 {
|
||||
t.Fatalf("Expected file_many to be 5, got %v", fileMany)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,10 @@ func (form *RecordVerificationConfirm) checkToken(value any) error {
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the verified auth record associated to `form.Token`.
|
||||
func (form *RecordVerificationConfirm) Submit() (*models.Record, error) {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,14 +92,22 @@ func (form *RecordVerificationConfirm) Submit() (*models.Record, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if record.Verified() {
|
||||
return record, nil // already verified
|
||||
wasVerified := record.Verified()
|
||||
|
||||
if !wasVerified {
|
||||
record.SetVerified(true)
|
||||
}
|
||||
|
||||
record.SetVerified(true)
|
||||
interceptorsErr := runInterceptorsWithRecord(record, func(m *models.Record) error {
|
||||
if wasVerified {
|
||||
return nil // already verified
|
||||
}
|
||||
|
||||
if err := form.dao.SaveRecord(record); err != nil {
|
||||
return nil, err
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return record, nil
|
||||
|
||||
@@ -2,9 +2,11 @@ package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -54,7 +56,24 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
record, err := form.Submit()
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
record, err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if s.expectError {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
@@ -77,3 +96,57 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordVerificationConfirmInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordVerificationConfirm(testApp, authCollection)
|
||||
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
|
||||
interceptorVerified := authRecord.Verified()
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptorVerified = record.Verified()
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorVerified == authRecord.Verified() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,10 @@ func (form *RecordVerificationRequest) Validate() error {
|
||||
|
||||
// Submit validates and sends a verification request email
|
||||
// to the `form.Email` auth record.
|
||||
func (form *RecordVerificationRequest) Submit() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -73,22 +76,26 @@ func (form *RecordVerificationRequest) Submit() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if record.GetBool(schema.FieldNameVerified) {
|
||||
return nil // already verified
|
||||
if !record.Verified() {
|
||||
now := time.Now().UTC()
|
||||
lastVerificationSentAt := record.LastVerificationSentAt().Time()
|
||||
if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
|
||||
return errors.New("A verification email was already sent.")
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
record.SetLastVerificationSentAt(types.NowDateTime())
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastVerificationSentAt := record.LastVerificationSentAt().Time()
|
||||
if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
|
||||
return errors.New("A verification email was already sent.")
|
||||
}
|
||||
return runInterceptorsWithRecord(record, func(m *models.Record) error {
|
||||
if m.Verified() {
|
||||
return nil // already verified
|
||||
}
|
||||
|
||||
if err := mails.SendRecordVerification(form.app, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mails.SendRecordVerification(form.app, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
record.Set(schema.FieldNameLastVerificationSentAt, types.NowDateTime())
|
||||
|
||||
return form.dao.SaveRecord(record)
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
@@ -78,15 +80,32 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
t.Errorf("[%d] Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if s.expectError {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
expectedMails := 0
|
||||
@@ -94,7 +113,7 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
|
||||
expectedMails = 1
|
||||
}
|
||||
if testApp.TestMailer.TotalSend != expectedMails {
|
||||
t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
|
||||
t.Errorf("[%d] Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
@@ -103,13 +122,67 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
|
||||
|
||||
user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email)
|
||||
t.Errorf("[%d] Expected user with email %q to exist, got nil", i, form.Email)
|
||||
continue
|
||||
}
|
||||
|
||||
// check whether LastVerificationSentAt was updated
|
||||
if !user.Verified() && user.LastVerificationSentAt().Time().Sub(now.Time()) < 0 {
|
||||
t.Errorf("(%d) Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt())
|
||||
t.Errorf("[%d] Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordVerificationRequestInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordVerificationRequest(testApp, authCollection)
|
||||
form.Email = authRecord.Email()
|
||||
interceptorLastVerificationSentAt := authRecord.LastVerificationSentAt()
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
return func(record *models.Record) error {
|
||||
interceptorLastVerificationSentAt = record.LastVerificationSentAt()
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorLastVerificationSentAt.String() == authRecord.LastVerificationSentAt().String() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
)
|
||||
|
||||
// SettingsUpsert is a [core.Settings] upsert (create/update) form.
|
||||
// SettingsUpsert is a [settings.Settings] upsert (create/update) form.
|
||||
type SettingsUpsert struct {
|
||||
*core.Settings
|
||||
*settings.Settings
|
||||
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
@@ -55,16 +55,10 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
return err
|
||||
}
|
||||
|
||||
encryptionKey := os.Getenv(form.app.EncryptionEnv())
|
||||
|
||||
return runInterceptors(func() error {
|
||||
saveErr := form.dao.SaveParam(
|
||||
models.ParamAppSettings,
|
||||
form.Settings,
|
||||
encryptionKey,
|
||||
)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
encryptionKey := os.Getenv(form.app.EncryptionEnv())
|
||||
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// explicitly trigger old logs deletion
|
||||
@@ -73,7 +67,7 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
)
|
||||
|
||||
if form.Settings.Logs.MaxDays == 0 {
|
||||
// reclaim deleted logs disk space
|
||||
// no logs are allowed -> reclaim preserved disk space after the previous delete operation
|
||||
form.app.LogsDao().Vacuum()
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +72,8 @@ func TestEmailSendValidateAndSubmit(t *testing.T) {
|
||||
expectedContent = "Confirm new email"
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastHtmlBody, expectedContent) {
|
||||
t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) {
|
||||
t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
)
|
||||
|
||||
// UploadedFileSize checks whether the validated `rest.UploadedFile`
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
|
||||
func UploadedFileSize(maxBytes int) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
v, _ := value.(*rest.UploadedFile)
|
||||
v, _ := value.(*filesystem.File)
|
||||
if v == nil {
|
||||
return nil // nothing to validate
|
||||
}
|
||||
|
||||
if int(v.Header().Size) > maxBytes {
|
||||
if int(v.Size) > maxBytes {
|
||||
return validation.NewError("validation_file_size_limit", fmt.Sprintf("Maximum allowed file size is %v bytes.", maxBytes))
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func UploadedFileSize(maxBytes int) validation.RuleFunc {
|
||||
// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
|
||||
func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
v, _ := value.(*rest.UploadedFile)
|
||||
v, _ := value.(*filesystem.File)
|
||||
if v == nil {
|
||||
return nil // nothing to validate
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
|
||||
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
|
||||
}
|
||||
|
||||
f, err := v.Header().Open()
|
||||
f, err := v.Reader.Open()
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ func TestUploadedFileSize(t *testing.T) {
|
||||
|
||||
scenarios := []struct {
|
||||
maxBytes int
|
||||
file *rest.UploadedFile
|
||||
file *filesystem.File
|
||||
expectError bool
|
||||
}{
|
||||
{0, nil, false},
|
||||
@@ -70,7 +71,7 @@ func TestUploadedFileMimeType(t *testing.T) {
|
||||
|
||||
scenarios := []struct {
|
||||
types []string
|
||||
file *rest.UploadedFile
|
||||
file *filesystem.File
|
||||
expectError bool
|
||||
}{
|
||||
{nil, nil, false},
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ var requiredErr = validation.NewError("validation_required", "Missing required v
|
||||
func NewRecordDataValidator(
|
||||
dao *daos.Dao,
|
||||
record *models.Record,
|
||||
uploadedFiles map[string][]*rest.UploadedFile,
|
||||
uploadedFiles map[string][]*filesystem.File,
|
||||
) *RecordDataValidator {
|
||||
return &RecordDataValidator{
|
||||
dao: dao,
|
||||
@@ -42,7 +42,7 @@ func NewRecordDataValidator(
|
||||
type RecordDataValidator struct {
|
||||
dao *daos.Dao
|
||||
record *models.Record
|
||||
uploadedFiles map[string][]*rest.UploadedFile
|
||||
uploadedFiles map[string][]*filesystem.File
|
||||
}
|
||||
|
||||
// Validate validates the provided `data` by checking it against
|
||||
@@ -314,9 +314,9 @@ func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField,
|
||||
}
|
||||
|
||||
// extract the uploaded files
|
||||
files := make([]*rest.UploadedFile, 0, len(validator.uploadedFiles[field.Name]))
|
||||
files := make([]*filesystem.File, 0, len(validator.uploadedFiles[field.Name]))
|
||||
for _, file := range validator.uploadedFiles[field.Name] {
|
||||
if list.ExistInSlice(file.Name(), names) {
|
||||
if list.ExistInSlice(file.Name, names) {
|
||||
files = append(files, file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
type testDataFieldScenario struct {
|
||||
name string
|
||||
data map[string]any
|
||||
files map[string][]*rest.UploadedFile
|
||||
files map[string][]*filesystem.File
|
||||
expectedErrors []string
|
||||
}
|
||||
|
||||
@@ -1086,10 +1087,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||
"check MaxSelect constraint",
|
||||
map[string]any{
|
||||
"field1": "test1",
|
||||
"field2": []string{"test1", testFiles[0].Name(), testFiles[3].Name()},
|
||||
"field2": []string{"test1", testFiles[0].Name, testFiles[3].Name},
|
||||
"field3": []string{"test1", "test2", "test3", "test4"},
|
||||
},
|
||||
map[string][]*rest.UploadedFile{
|
||||
map[string][]*filesystem.File{
|
||||
"field2": {testFiles[0], testFiles[3]},
|
||||
},
|
||||
[]string{"field2", "field3"},
|
||||
@@ -1097,11 +1098,11 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||
{
|
||||
"check MaxSize constraint",
|
||||
map[string]any{
|
||||
"field1": testFiles[0].Name(),
|
||||
"field2": []string{"test1", testFiles[0].Name()},
|
||||
"field1": testFiles[0].Name,
|
||||
"field2": []string{"test1", testFiles[0].Name},
|
||||
"field3": []string{"test1", "test2", "test3"},
|
||||
},
|
||||
map[string][]*rest.UploadedFile{
|
||||
map[string][]*filesystem.File{
|
||||
"field1": {testFiles[0]},
|
||||
"field2": {testFiles[0]},
|
||||
},
|
||||
@@ -1111,10 +1112,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||
"check MimeTypes constraint",
|
||||
map[string]any{
|
||||
"field1": "test1",
|
||||
"field2": []string{"test1", testFiles[0].Name()},
|
||||
"field3": []string{testFiles[1].Name(), testFiles[2].Name()},
|
||||
"field2": []string{"test1", testFiles[0].Name},
|
||||
"field3": []string{testFiles[1].Name, testFiles[2].Name},
|
||||
},
|
||||
map[string][]*rest.UploadedFile{
|
||||
map[string][]*filesystem.File{
|
||||
"field2": {testFiles[0], testFiles[1], testFiles[2]},
|
||||
"field3": {testFiles[1], testFiles[2]},
|
||||
},
|
||||
@@ -1134,10 +1135,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||
"valid data - just new files",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": []string{testFiles[0].Name(), testFiles[1].Name()},
|
||||
"field2": []string{testFiles[0].Name, testFiles[1].Name},
|
||||
"field3": nil,
|
||||
},
|
||||
map[string][]*rest.UploadedFile{
|
||||
map[string][]*filesystem.File{
|
||||
"field2": {testFiles[0], testFiles[1]},
|
||||
},
|
||||
[]string{},
|
||||
@@ -1146,10 +1147,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||
"valid data - mixed existing and new files",
|
||||
map[string]any{
|
||||
"field1": "test1",
|
||||
"field2": []string{"test1", testFiles[0].Name()},
|
||||
"field2": []string{"test1", testFiles[0].Name},
|
||||
"field3": "test1", // will be casted
|
||||
},
|
||||
map[string][]*rest.UploadedFile{
|
||||
map[string][]*filesystem.File{
|
||||
"field2": {testFiles[0], testFiles[1], testFiles[2]},
|
||||
},
|
||||
[]string{},
|
||||
|
||||
@@ -4,82 +4,88 @@ go 1.18
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.6
|
||||
github.com/aws/aws-sdk-go v1.44.141
|
||||
github.com/aws/aws-sdk-go v1.44.175
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/domodwyer/mailyak/v3 v3.3.4
|
||||
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86
|
||||
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6
|
||||
github.com/fatih/color v1.13.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.1
|
||||
github.com/ganigeorgiev/fexpr v0.1.1
|
||||
github.com/ganigeorgiev/fexpr v0.3.0
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3
|
||||
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
|
||||
github.com/mattn/go-sqlite3 v1.14.16
|
||||
github.com/pocketbase/dbx v1.7.0
|
||||
github.com/pocketbase/dbx v1.8.0
|
||||
github.com/spf13/cast v1.5.0
|
||||
github.com/spf13/cobra v1.6.1
|
||||
gocloud.dev v0.27.0
|
||||
golang.org/x/crypto v0.3.0
|
||||
golang.org/x/net v0.2.0
|
||||
golang.org/x/oauth2 v0.2.0
|
||||
modernc.org/sqlite v1.19.4
|
||||
golang.org/x/crypto v0.5.0
|
||||
golang.org/x/net v0.5.0
|
||||
golang.org/x/oauth2 v0.4.0
|
||||
golang.org/x/sync v0.1.0
|
||||
modernc.org/sqlite v1.20.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.41 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.11.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.17.4 // indirect
|
||||
github.com/aws/smithy-go v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.47 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.0 // indirect
|
||||
github.com/aws/smithy-go v1.13.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/wire v0.5.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/image v0.1.0 // indirect
|
||||
golang.org/x/image v0.3.0 // indirect
|
||||
golang.org/x/mod v0.7.0 // indirect
|
||||
golang.org/x/sys v0.2.0 // indirect
|
||||
golang.org/x/term v0.2.0 // indirect
|
||||
golang.org/x/text v0.4.0 // indirect
|
||||
golang.org/x/time v0.2.0 // indirect
|
||||
golang.org/x/tools v0.3.0 // indirect
|
||||
golang.org/x/sys v0.4.0 // indirect
|
||||
golang.org/x/term v0.4.0 // indirect
|
||||
golang.org/x/text v0.6.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/tools v0.5.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
google.golang.org/api v0.103.0 // indirect
|
||||
google.golang.org/api v0.106.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230106154932-a12b697841d9 // indirect
|
||||
google.golang.org/grpc v1.51.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.21.4 // indirect
|
||||
modernc.org/libc v1.22.2 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.4.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
|
||||
@@ -47,18 +47,16 @@ cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6m
|
||||
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
|
||||
cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
|
||||
cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U=
|
||||
cloud.google.com/go/compute v1.10.0 h1:aoLIYaA1fX3ywihqpBk2APQKOo20nXsp1GEZQbx5Jk4=
|
||||
cloud.google.com/go/compute v1.12.1 h1:gKVJMEyqV5c/UnpzjjQbo3Rjvvqpr9B1DFSbJC4OXr0=
|
||||
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
|
||||
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
||||
cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c=
|
||||
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
|
||||
cloud.google.com/go/iam v0.6.0 h1:nsqQC88kT5Iwlm4MeNGTpfMWddp6NB/UOLFTH6m1QfQ=
|
||||
cloud.google.com/go/iam v0.7.0 h1:k4MuwOsS7zGJJ+QfZ5vBK8SgHBAvYN/23BWsiihJ1vs=
|
||||
cloud.google.com/go/iam v0.8.0 h1:E2osAkZzxI/+8pZcxVLcDtAQx/u+hZXVryUaYQ5O0Kk=
|
||||
cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA=
|
||||
cloud.google.com/go/longrunning v0.1.1 h1:y50CXG4j0+qvEukslYFBCrzaXX0qpFbBzc3PchSu/LE=
|
||||
cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs=
|
||||
cloud.google.com/go/monitoring v1.1.0/go.mod h1:L81pzz7HKn14QCMaCs6NTQkdBnE87TElyanS95vIcl4=
|
||||
cloud.google.com/go/monitoring v1.5.0/go.mod h1:/o9y8NYX5j91JjD/JvGLYbi86kL11OjyJXq2XziLJu4=
|
||||
@@ -208,86 +206,70 @@ github.com/aws/aws-sdk-go v1.43.11/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4
|
||||
github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.44.45/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.44.68/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.44.126 h1:7HQJw2DNiwpxqMe2H7odGNT2rhO4SRrUe5/8dYXl0Jk=
|
||||
github.com/aws/aws-sdk-go v1.44.126/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.44.127 h1:IoO2VfuIQg1aMXnl8l6OpNUKT4Qq5CnJMOyIWoTYXj0=
|
||||
github.com/aws/aws-sdk-go v1.44.127/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.44.130 h1:a/qwOxmYJF47xTZvTjECSJXnfRbjegb3YxvCXfETtnY=
|
||||
github.com/aws/aws-sdk-go v1.44.130/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.44.141 h1:kT/YIH9GUaKZa3yi8TEsZmVgvphwEzINJu19hYUzfGY=
|
||||
github.com/aws/aws-sdk-go v1.44.141/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
|
||||
github.com/aws/aws-sdk-go v1.44.175 h1:c0NzHHnPXV5kJoTUFQxFN5cUPpX1SxO635XnwL5/oIY=
|
||||
github.com/aws/aws-sdk-go v1.44.175/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
|
||||
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
|
||||
github.com/aws/aws-sdk-go-v2 v1.16.8/go.mod h1:6CpKuLXg2w7If3ABZCl/qZ6rEgwtjZTn4eAf4RcEyuw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.1 h1:02c72fDJr87N8RAC2s3Qu0YuvMRZKNZJ9F+lAehCazk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.1/go.mod h1:JLnGeGONAyi2lWXI1p0PCIOIy333JMVK1U7Hf0aRFLw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.3 h1:shN7NlnVzvDUgPQ+1rLMSxY8OWRNDRYtiqe0p/PgrhY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.3/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3/go.mod h1:gNsR5CaXKmQSSzrmGxmwmct/r+ZBfbxorAuXYsj/M5Y=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.9 h1:RKci2D7tMwpvGpDNZnGQw9wk6v7o/xSwFcUAuNPoB8k=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.9/go.mod h1:vCmV1q1VK8eoQJ5+aYE7PkK1K6v41qJ5pJdK3ggCDvg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.15.15/go.mod h1:A1Lzyy/o21I5/s2FbyX5AevQfSVXpvvIDCoVFD0BC4E=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.17.10 h1:zBy5QQ/mkvHElM1rygHPAzuH+sl8nsdSaxSWj0+rpdE=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.17.10/go.mod h1:/4np+UiJJKpWHN7Q+LZvqXYgyjgeXm5+lLfDI6TPZao=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.2 h1:tRhTb3xMZsB0gW0sXWpqs9FeIP8iQp5SvnvwiPXzHwo=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.2/go.mod h1:9XVoZTdD8ICjrgI5ddb8j918q6lEZkFYpb7uohgvU6c=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.8 h1:lDpy0WM8AHsywOnVrOHaSMfpaiV2igOw8D7svkFkXVA=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.8/go.mod h1:5XCmmyutmzzgkpk/6NYTjeWb6lgo9N170m1j6pQkIBs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.12.10/go.mod h1:g5eIM5XRs/OzIIK81QMBl+dAuDyoLN0VYaLP+tBqEOk=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.12.23 h1:LctvcJMIb8pxvk5hQhChpCu0WlU6oKQmcYb1HA4IZSA=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.12.23/go.mod h1:0awX9iRr/+UO7OwRQFpV1hNtXxOVuehpjVEzrIAYNcA=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.2 h1:F/v1w0XcFDZjL0bCdi9XWJenoPKjGbzljBhDKcryzEQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.2/go.mod h1:eAT5aj/WJ2UDIA0IVNFc2byQLeD89SDEi4cjzH/MKoQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.8 h1:vTrwTvv5qAwjWIGhZDSBH/oQHuIQjGmD232k01FUh6A=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.8/go.mod h1:lVa4OHbvgjVot4gmh1uouF1ubgexSCN92P6CJQpT0t8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.9/go.mod h1:KDCCm4ONIdHtUloDcFvK2+vshZvx4Zmj7UMDfusuz5s=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19 h1:E3PXZSI3F2bzyj6XxUXdTIfvp425HHhwKsFvmzBwHgs=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19/go.mod h1:VihW95zQpeKQWVPGkwT+2+WJNQV8UXFfMTWdU6VErL8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21 h1:j9wi1kQ8b+e0FBVHxCqCGo4kxDU175hoDHcWAi0sauU=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21/go.mod h1:ugwW57Z5Z48bpvUyZuaPy4Kv+vEfJWnIrky7RmkBvJg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.21/go.mod h1:iIYPrQ2rYfZiB/iADYlhj9HHZ9TTi6PqKQPAqygohbE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.37 h1:e1VtTBo+cLNjres0wTlMkmwCGGRjDEkkrz3frxxcaCs=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.37/go.mod h1:kdAV1UMnCkyG6tZJUC4mHbPoRjPA3dIK0L8mnsHERiM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.41 h1:ssgdsNm11dvFtO7F/AeiW4dAO3eGsDeg5fwpag/JP/I=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.41/go.mod h1:CS+AbDFAaPU9TQOo7U6mVV23YvqCOElnqmh0XQjgJ1g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.47 h1:E884ndKWVGt8IhtUuGhXbEsmaCvdAAkTTUDu7uAok1g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.47/go.mod h1:KybsEsmXLO0u75FyS3F0sY4OQ97syDe8z+ISq8oEczA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.15/go.mod h1:pWrr2OoHlT7M/Pd2y4HV3gJyPb3qj5qMmnPkKSNPYK4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25 h1:nBO/RFxeq/IS5G9Of+ZrgucRciie2qpLy++3UGZ+q2E=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25/go.mod h1:Zb29PYkf42vVYQY6pvSyJCJcFHlPIiY+YKdPtwnvMkY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27 h1:I3cakv2Uy1vNmmhRQmFptYDxOvBnwCdNwyw63N0RaRU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27/go.mod h1:a1/UpzeyBBerajpnP5nGZa9mGzsBn5cOKxm6NWQsvoI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.9/go.mod h1:08tUpeSGN33QKSO7fwxXczNfiwCpbj+GxK6XKwqWVv0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19 h1:oRHDrwCTVT8ZXi4sr9Ld+EXk7N/KGssOr2ygNeojEhw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19/go.mod h1:6Q0546uHDp421okhmmGfbxzq2hBqbXFNpi4k+Q1JnQA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21 h1:5NbbMrIzmUn/TXFqAle6mgrH5m9cOvMLRGL7pnG8tRE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21/go.mod h1:+Gxn8jYn5k9ebfHEqlhrMirFjSW0v0C9fI+KN5vk2kE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.16/go.mod h1:CYmI+7x03jjJih8kBEEFKRQc40UjUokT0k7GbvrhhTc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26 h1:Mza+vlnZr+fPKFKRq/lKGVvM6B/8ZZmNdEopOwSQLms=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26/go.mod h1:Y2OJ+P+MC1u1VKnavT+PshiEuGPyh/7DqxoDNij4/bg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28 h1:KeTxcGdNnQudb46oOl4d90f2I33DF/c6q3RnZAmvQdQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28/go.mod h1:yRZVr/iT0AqyHeep00SZ4YfBAKojXz08w3XMBscdi0c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.6/go.mod h1:O7Oc4peGZDEKlddivslfYFvAbgzvl/GH3J8j3JIGBXc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.16 h1:2EXB7dtGwRYIN3XQ9qwIW504DVbKIw3r89xQnonGdsQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.16/go.mod h1:XH+3h395e3WVdd6T2Z3mPxuI+x/HVtdqVOREkTiyubs=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18 h1:H/mF2LNWwX00lD6FlYfKpLLZgUW7oIzCBkig78x4Xok=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18/go.mod h1:T2Ku+STrYQ1zIkL1wMvj8P3wWQaaCMKNdz70MT2FLfE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3/go.mod h1:gkb2qADY+OHaGLKNTYxMaQNacfeyQpZ4csDTQMeFmcw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10 h1:dpiPHgmFstgkLG07KaYAewvuptq5kvo52xn7tVSrtrQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10/go.mod h1:9cBNUHI2aW4ho0A5T87O294iPDuuUOSIEDjnd1Lq/z0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.10/go.mod h1:Qks+dxK3O+Z2deAhNo6cJ8ls1bam3tUGUAcgxQP1c70=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.20 h1:KSvtm1+fPXE0swe9GPjc6msyrdTT0LB/BP8eLugL1FI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.20/go.mod h1:Mp4XI/CkWGD79AQxZ5lIFlgvC0A+gl+4BmyG1F+SfNc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22 h1:kv5vRAl00tozRxSnI0IszPWGXsJOyA7hmEUHFYqsyvw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22/go.mod h1:Od+GU5+Yx41gryN/ZGZzAJMZ9R1yn6lgA0fD5Lo5SkQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.9/go.mod h1:yQowTpvdZkFVuHrLBXmczat4W+WJKg/PafBZnGBLga0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19 h1:GE25AWCdNUPh9AOJzI9KIJnja7IwUc1WyUqz/JTyJ/I=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19/go.mod h1:02CP6iuYP+IVnBX5HULVdSAku/85eHB2Y9EsFhrkEwU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21 h1:5C6XgTViSb0bunmU57b3CT+MhxULqHH2721FVA+/kDM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21/go.mod h1:lRToEJsn+DRA9lW4O9L9+/3hjTkUzlzyzHqn8MTds5k=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.9/go.mod h1:Rc5+wn2k8gFSi3V1Ch4mhxOzjMh+bYSXVFfVaqowQOY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19 h1:piDBAaWkaxkkVV3xJJbTehXCZRXYs49kvpi/LG6LR2o=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19/go.mod h1:BmQWRVkLTmyNzYPFAZgon53qKLWBNSvonugD1MrSWUs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21 h1:vY5siRXvW5TrOKm2qKEf9tliBfdLxdfy0i02LOcmqUo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21/go.mod h1:WZvNXT1XuH8dnJM0HvOlvk+RNn7NbAPvA/ACO0QarSc=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.18.1/go.mod h1:4PZMUkc9rXHWGVB5J9vKaZy3D7Nai79ORworQ3ASMiM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.27.2/go.mod h1:u+566cosFI+d+motIz3USXEh6sN8Nq4GrNXSg2RXVMo=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.1 h1:/EMdFPW/Ppieh0WUtQf1+qCGNLdsq5UWUyevBQ6vMVc=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.1/go.mod h1:/NHbqPRiwxSPVOB2Xr+StDEH+GWV/64WwnUjv4KYzV0=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.3 h1:F6wgg8aHGNyhaAy2ONnWBThiPdLa386qNA0j33FIuSM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.3/go.mod h1:/NHbqPRiwxSPVOB2Xr+StDEH+GWV/64WwnUjv4KYzV0=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.0 h1:wddsyuESfviaiXk3w9N6/4iRwTg/a3gktjODY6jYQBo=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.0/go.mod h1:L2l2/q76teehcW7YEsgsDjqdsDTERJeX3nOMIFlgGUE=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.14/go.mod h1:xakbH8KMsQQKqzX87uyyzTHshc/0/Df8bsTneTS5pFU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sns v1.17.10/go.mod h1:uITsRNVMeCB3MkWpXxXw0eDz8pW4TYLzj+eyQtbhSxM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.19.1/go.mod h1:A94o564Gj+Yn+7QO1eLFeI7UVv3riy/YBFOfICVqFvU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.27.6/go.mod h1:fiFzQgj4xNOg4/wqmAiPvzgDMXPD+cUEplX/CYn+0j0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.11.13/go.mod h1:d7ptRksDDgvXaUvxyHZ9SYh+iMDymm94JbVcgvSYSzU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.11.25 h1:GFZitO48N/7EsFDt8fMa5iYdmWqkUDDB3Eje6z3kbG0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.11.25/go.mod h1:IARHuzTXmj1C0KS35vboR0FeJ89OkEy1M9mWbK2ifCI=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8 h1:jcw6kKZrtNfBPJkaHrscDOZoe5gvi9wjudnxvozYFJo=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8/go.mod h1:er2JHN+kBY6FcMfcBBKNGCT3CarImmdFzishsqBmSRI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.0 h1:/2gzjhQowRLarkkBOGPXSRnb8sQ2RVsjdG1C/UliK/c=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.0/go.mod h1:wo/B7uUm/7zw/dWhBJ4FXuw1sySU5lyIhVg1Bu2yL9A=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.0 h1:Jfly6mRxk2ZOSlbCvZfKNS7TukSx1mIzhSsqZ/IGSZI=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.0/go.mod h1:TZSH7xLO7+phDtViY/KUp9WGCJMQkLJ/VpgkTFd5gh8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.16.10/go.mod h1:cftkHYN6tCDNfkSasAmclSfl4l7cySoay8vz7p/ce0E=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.17.1 h1:KRAix/KHvjGODaHAMXnxRk9t0D+4IJVUuS/uwXxngXk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.17.1/go.mod h1:bXcN3koeVYiJcdDU89n3kCYILob7Y34AeLopUbZgLT4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.17.4 h1:YNncBj5dVYd05i4ZQ+YicOotSXo0ufc9P8kTioi13EM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.17.4/go.mod h1:bXcN3koeVYiJcdDU89n3kCYILob7Y34AeLopUbZgLT4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.0 h1:kOO++CYo50RcTFISESluhWEi5Prhg+gaSs4whWabiZU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.0/go.mod h1:+lGbb3+1ugwKrNTWcf2RT05Xmp543B06zDFTwiTLp7I=
|
||||
github.com/aws/smithy-go v1.12.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
|
||||
github.com/aws/smithy-go v1.13.4 h1:/RN2z1txIJWeXeOkzX+Hk/4Uuvv7dWtCjbmVJcrskyk=
|
||||
github.com/aws/smithy-go v1.13.4/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
|
||||
github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8=
|
||||
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
|
||||
github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
@@ -498,6 +480,9 @@ github.com/digitalocean/godo v1.81.0/go.mod h1:BPCqvwbjbGqxuUnIKB4EvS/AX7IDnNmt5
|
||||
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
|
||||
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.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
|
||||
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
@@ -521,6 +506,14 @@ github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZ
|
||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
||||
github.com/domodwyer/mailyak/v3 v3.3.4 h1:AG/pvcz2/ocFqZkPEG7lPAa0MhCq1warfUEKJt6Fagk=
|
||||
github.com/domodwyer/mailyak/v3 v3.3.4/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
|
||||
github.com/dop251/goja v0.0.0-20220815083517-0c74f9139fd6/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs=
|
||||
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86 h1:E2wycakfddWJ26v+ZyEY91Lb/HEZyaiZhbMX+KQcdmc=
|
||||
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6 h1:p3QZwRRfCN7Qr3GNBTMKBkLFjEm3DHR4MaJABvsiqgk=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6/go.mod h1:+CJy9V5cGycP5qwp6RM5jLg+TFEMyGtD7A9xUbU/BOQ=
|
||||
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
@@ -571,8 +564,8 @@ github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmV
|
||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
|
||||
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
|
||||
github.com/ganigeorgiev/fexpr v0.1.1 h1:La9kYEgTcIutvOnqNZ8pOUD0O0Q/Gn15sTVEX+IeBE8=
|
||||
github.com/ganigeorgiev/fexpr v0.1.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||
github.com/ganigeorgiev/fexpr v0.3.0 h1:RwSyJBME+g/XdzlUW0paH/4VXhLHPg+rErtLeC7K8Ew=
|
||||
github.com/ganigeorgiev/fexpr v0.3.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||
github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
|
||||
github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg=
|
||||
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
|
||||
@@ -640,6 +633,8 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
|
||||
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
|
||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||
github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
@@ -698,8 +693,9 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
|
||||
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
|
||||
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
@@ -796,6 +792,7 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe
|
||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20220318212150-b2ab0324ddda/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||
github.com/google/pprof v0.0.0-20220608213341-c488b8fa1db3/go.mod h1:gSuNB+gJaOiQKLEZ+q+PK9Mq3SOzhRcw2GsGS/FhYDk=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -808,7 +805,7 @@ github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8=
|
||||
github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.2.0 h1:y8Yozv7SZtlU//QXbezB6QkpuE6jMD2/gfzk4AftXjs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.2.1 h1:RY7tHKZcRlk788d5WSo/e83gOyyy742E8GSs771ySpg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
|
||||
@@ -816,8 +813,6 @@ github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0
|
||||
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
|
||||
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
|
||||
github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
|
||||
github.com/googleapis/gax-go/v2 v2.6.0 h1:SXk3ABtQYDT/OH8jAyvEOQ58mgawq5C4o/4/89qN2ZU=
|
||||
github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY=
|
||||
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
|
||||
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
|
||||
github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
|
||||
@@ -915,8 +910,9 @@ github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH
|
||||
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
|
||||
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
|
||||
github.com/intel/goresctrl v0.2.0/go.mod h1:+CZdzouYFn5EsxgqAQTEzMfwKwuc0fVdMrT9FCCAVRQ=
|
||||
github.com/ionos-cloud/sdk-go/v6 v6.1.0/go.mod h1:Ox3W0iiEz0GHnfY9e5LmAxwklsxguuNFEUSu0gVRTME=
|
||||
@@ -1063,8 +1059,9 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME
|
||||
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
|
||||
github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
|
||||
@@ -1224,8 +1221,8 @@ github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6J
|
||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.7.0 h1:MY6/up//aIeH6WA8VqYt3EeQt082bEdKcUDcEF4UrWw=
|
||||
github.com/pocketbase/dbx v1.7.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/dbx v1.8.0 h1:kjf3mgmmE12t8IG48kJOeIyBmRi0A1sl6Hsezv4PoiA=
|
||||
github.com/pocketbase/dbx v1.8.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
|
||||
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
|
||||
@@ -1379,7 +1376,6 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
@@ -1464,7 +1460,6 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
@@ -1567,10 +1562,8 @@ golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
|
||||
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
|
||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -1584,8 +1577,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
|
||||
golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
|
||||
golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg=
|
||||
golang.org/x/image v0.3.0/go.mod h1:fXd9211C/0VTlYuAcOhW8dY/RtEJqODXOWBDpmYBf+A=
|
||||
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-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -1613,8 +1606,6 @@ golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
|
||||
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
|
||||
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
|
||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1694,10 +1685,9 @@ golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1723,10 +1713,8 @@ golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7Lm
|
||||
golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
|
||||
golang.org/x/oauth2 v0.0.0-20220628200809-02e64fa58f26/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
|
||||
golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
|
||||
golang.org/x/oauth2 v0.1.0 h1:isLCZuhj4v+tYv7eskaN4v/TM+A1begWWgyVJDdl1+Y=
|
||||
golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A=
|
||||
golang.org/x/oauth2 v0.2.0 h1:GtQkldQ9m7yvzCL1V+LrYow3Khe0eJH0w7RbX/VbaIU=
|
||||
golang.org/x/oauth2 v0.2.0/go.mod h1:Cwn6afJ8jrQwYMxQDTpISoXmXW9I6qF6vDeuuoX3Ibs=
|
||||
golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M=
|
||||
golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec=
|
||||
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-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1741,6 +1729,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1883,20 +1873,18 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
|
||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -1906,8 +1894,9 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1920,10 +1909,8 @@ golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxb
|
||||
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA=
|
||||
golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.2.0 h1:52I/1L54xyEQAYdtcSuxtiT84KGYTBGXwayxmIpNJhE=
|
||||
golang.org/x/time v0.2.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -2007,10 +1994,8 @@ golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
|
||||
golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE=
|
||||
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
|
||||
golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM=
|
||||
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
|
||||
golang.org/x/tools v0.5.0 h1:+bSpV5HIeWkuvgaMfI3UmKRThoTA5ODJTUd8T17NO+4=
|
||||
golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -2070,12 +2055,8 @@ google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6F
|
||||
google.golang.org/api v0.86.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
|
||||
google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
|
||||
google.golang.org/api v0.91.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
|
||||
google.golang.org/api v0.101.0 h1:lJPPeEBIRxGpGLwnBTam1NPEM8Z2BmmXEd3z812pjwM=
|
||||
google.golang.org/api v0.101.0/go.mod h1:CjxAAWWt3A3VrUE2IGDY2bgK5qhoG/OkyWVlYcP05MY=
|
||||
google.golang.org/api v0.102.0 h1:JxJl2qQ85fRMPNvlZY/enexbxpCjLwGhZUtgfGeQ51I=
|
||||
google.golang.org/api v0.102.0/go.mod h1:3VFl6/fzoA+qNuS1N1/VfXY4LjoXN/wzeIp7TweWwGo=
|
||||
google.golang.org/api v0.103.0 h1:9yuVqlu2JCvcLg9p8S3fcFLZij8EPSyvODIY1rkMizQ=
|
||||
google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0=
|
||||
google.golang.org/api v0.106.0 h1:ffmW0faWCwKkpbbtvlY/K/8fUl+JKvNS5CVzRoyfCv8=
|
||||
google.golang.org/api v0.106.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
@@ -2184,10 +2165,8 @@ google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljW
|
||||
google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
|
||||
google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
|
||||
google.golang.org/genproto v0.0.0-20220802133213-ce4fa296bf78/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
|
||||
google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c h1:QgY/XxIAIeccR+Ca/rDdKubLIU9rcJ3xfy1DC/Wd2Oo=
|
||||
google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo=
|
||||
google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 h1:a2S6M0+660BgMNl++4JPlcAO/CjkqYItDEZwkoDQK7c=
|
||||
google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
|
||||
google.golang.org/genproto v0.0.0-20230106154932-a12b697841d9 h1:3wPBShTLWQnEkZ9VW/HZZ8zT/9LLtleBtq7l8SKtJIA=
|
||||
google.golang.org/genproto v0.0.0-20230106154932-a12b697841d9/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
|
||||
google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
@@ -2229,8 +2208,6 @@ google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACu
|
||||
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.50.1 h1:DS/BukOZWp8s6p4Dt/tOaJaTQyPyOoCcrjroHuCeLzY=
|
||||
google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
|
||||
google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U=
|
||||
google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||
@@ -2365,29 +2342,23 @@ lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8 h1:0+dsXf0zeLx9ixj4nilg6jKe5Bg1ilzBwSFq4kJmIUc=
|
||||
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/libc v1.21.4 h1:CzTlumWeIbPV5/HVIMzYHNPCRP8uiU/CWiN2gtd/Qu8=
|
||||
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
|
||||
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
|
||||
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
|
||||
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.19.3 h1:dIoagx6yIQT3V/zOSeAyZ8OqQyEr17YTgETOXTZNJMA=
|
||||
modernc.org/sqlite v1.19.3/go.mod h1:xiyJD7FY8mTZXnQwE/gEL1STtFrrnDx03V8KhVQmcr8=
|
||||
modernc.org/sqlite v1.19.4 h1:nlPIDqumn6/mSvs7T5C8MNYEuN73sISzPdKtMdURpUI=
|
||||
modernc.org/sqlite v1.19.4/go.mod h1:x/yZNb3h5+I3zGQSlwIv4REL5eJhiRkUH5MReogAeIc=
|
||||
modernc.org/sqlite v1.20.2 h1:9AaVzJH1Yf0u9iOZRjjuvqxLoGqybqVFbAUC5rvi9u8=
|
||||
modernc.org/sqlite v1.20.2/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.15.0 h1:oY+JeD11qVVSgVvodMJsu7Edf8tr5E/7tuhF5cNYz34=
|
||||
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
|
||||
|
||||
@@ -7,10 +7,8 @@ linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- asciicheck
|
||||
- deadcode
|
||||
- depguard
|
||||
- exportloopref
|
||||
- gocritic
|
||||
- gofmt
|
||||
- goimports
|
||||
- gomodguard
|
||||
@@ -26,5 +24,4 @@ linters:
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- varcheck
|
||||
- whitespace
|
||||
|
||||
+23
-17
@@ -2,12 +2,14 @@ package mails
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/mail"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails/templates"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
)
|
||||
|
||||
@@ -43,33 +45,37 @@ func SendAdminPasswordReset(app core.App, admin *models.Admin) error {
|
||||
|
||||
mailClient := app.NewMailClient()
|
||||
|
||||
// resolve body template
|
||||
body, renderErr := resolveTemplateContent(params, templates.Layout, templates.AdminPasswordResetBody)
|
||||
if renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
|
||||
message := &mailer.Message{
|
||||
From: mail.Address{
|
||||
Name: app.Settings().Meta.SenderName,
|
||||
Address: app.Settings().Meta.SenderAddress,
|
||||
},
|
||||
To: mail.Address{Address: admin.Email},
|
||||
Subject: "Reset admin password",
|
||||
HTML: body,
|
||||
}
|
||||
|
||||
event := &core.MailerAdminEvent{
|
||||
MailClient: mailClient,
|
||||
Message: message,
|
||||
Admin: admin,
|
||||
Meta: map[string]any{"token": token},
|
||||
}
|
||||
|
||||
sendErr := app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error {
|
||||
// resolve body template
|
||||
body, renderErr := resolveTemplateContent(params, templates.Layout, templates.AdminPasswordResetBody)
|
||||
if renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
|
||||
return e.MailClient.Send(
|
||||
mail.Address{
|
||||
Name: app.Settings().Meta.SenderName,
|
||||
Address: app.Settings().Meta.SenderAddress,
|
||||
},
|
||||
mail.Address{Address: e.Admin.Email},
|
||||
"Reset admin password",
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
app.OnMailerAfterAdminResetPasswordSend().Trigger(event)
|
||||
if err := app.OnMailerAfterAdminResetPasswordSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ func TestSendAdminPasswordReset(t *testing.T) {
|
||||
"http://localhost:8090/_/#/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
||||
}
|
||||
for _, part := range expectedParts {
|
||||
if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) {
|
||||
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
|
||||
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+66
-60
@@ -2,13 +2,15 @@ package mails
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/mail"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails/templates"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
)
|
||||
|
||||
// SendRecordPasswordReset sends a password reset request email to the specified user.
|
||||
@@ -20,34 +22,36 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
|
||||
|
||||
mailClient := app.NewMailClient()
|
||||
|
||||
subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.ResetPasswordTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message := &mailer.Message{
|
||||
From: mail.Address{
|
||||
Name: app.Settings().Meta.SenderName,
|
||||
Address: app.Settings().Meta.SenderAddress,
|
||||
},
|
||||
To: mail.Address{Address: authRecord.Email()},
|
||||
Subject: subject,
|
||||
HTML: body,
|
||||
}
|
||||
|
||||
event := &core.MailerRecordEvent{
|
||||
MailClient: mailClient,
|
||||
Message: message,
|
||||
Record: authRecord,
|
||||
Meta: map[string]any{"token": token},
|
||||
}
|
||||
|
||||
sendErr := app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
settings := app.Settings()
|
||||
|
||||
subject, body, err := resolveEmailTemplate(app, token, settings.Meta.ResetPasswordTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.MailClient.Send(
|
||||
mail.Address{
|
||||
Name: settings.Meta.SenderName,
|
||||
Address: settings.Meta.SenderAddress,
|
||||
},
|
||||
mail.Address{Address: e.Record.GetString(schema.FieldNameEmail)},
|
||||
subject,
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
app.OnMailerAfterRecordResetPasswordSend().Trigger(event)
|
||||
if err := app.OnMailerAfterRecordResetPasswordSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
@@ -62,34 +66,36 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
|
||||
|
||||
mailClient := app.NewMailClient()
|
||||
|
||||
subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.VerificationTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message := &mailer.Message{
|
||||
From: mail.Address{
|
||||
Name: app.Settings().Meta.SenderName,
|
||||
Address: app.Settings().Meta.SenderAddress,
|
||||
},
|
||||
To: mail.Address{Address: authRecord.Email()},
|
||||
Subject: subject,
|
||||
HTML: body,
|
||||
}
|
||||
|
||||
event := &core.MailerRecordEvent{
|
||||
MailClient: mailClient,
|
||||
Message: message,
|
||||
Record: authRecord,
|
||||
Meta: map[string]any{"token": token},
|
||||
}
|
||||
|
||||
sendErr := app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
settings := app.Settings()
|
||||
|
||||
subject, body, err := resolveEmailTemplate(app, token, settings.Meta.VerificationTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.MailClient.Send(
|
||||
mail.Address{
|
||||
Name: settings.Meta.SenderName,
|
||||
Address: settings.Meta.SenderAddress,
|
||||
},
|
||||
mail.Address{Address: e.Record.GetString(schema.FieldNameEmail)},
|
||||
subject,
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
app.OnMailerAfterRecordVerificationSend().Trigger(event)
|
||||
if err := app.OnMailerAfterRecordVerificationSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
@@ -104,8 +110,24 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
|
||||
|
||||
mailClient := app.NewMailClient()
|
||||
|
||||
subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.ConfirmEmailChangeTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message := &mailer.Message{
|
||||
From: mail.Address{
|
||||
Name: app.Settings().Meta.SenderName,
|
||||
Address: app.Settings().Meta.SenderAddress,
|
||||
},
|
||||
To: mail.Address{Address: newEmail},
|
||||
Subject: subject,
|
||||
HTML: body,
|
||||
}
|
||||
|
||||
event := &core.MailerRecordEvent{
|
||||
MailClient: mailClient,
|
||||
Message: message,
|
||||
Record: record,
|
||||
Meta: map[string]any{
|
||||
"token": token,
|
||||
@@ -114,27 +136,13 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
|
||||
}
|
||||
|
||||
sendErr := app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
settings := app.Settings()
|
||||
|
||||
subject, body, err := resolveEmailTemplate(app, token, settings.Meta.ConfirmEmailChangeTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.MailClient.Send(
|
||||
mail.Address{
|
||||
Name: settings.Meta.SenderName,
|
||||
Address: settings.Meta.SenderAddress,
|
||||
},
|
||||
mail.Address{Address: newEmail},
|
||||
subject,
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
app.OnMailerAfterRecordChangeEmailSend().Trigger(event)
|
||||
if err := app.OnMailerAfterRecordChangeEmailSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
@@ -143,13 +151,11 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
|
||||
func resolveEmailTemplate(
|
||||
app core.App,
|
||||
token string,
|
||||
emailTemplate core.EmailTemplate,
|
||||
emailTemplate settings.EmailTemplate,
|
||||
) (subject string, body string, err error) {
|
||||
settings := app.Settings()
|
||||
|
||||
subject, rawBody, _ := emailTemplate.Resolve(
|
||||
settings.Meta.AppName,
|
||||
settings.Meta.AppUrl,
|
||||
app.Settings().Meta.AppName,
|
||||
app.Settings().Meta.AppUrl,
|
||||
token,
|
||||
)
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ func TestSendRecordPasswordReset(t *testing.T) {
|
||||
"http://localhost:8090/_/#/auth/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
||||
}
|
||||
for _, part := range expectedParts {
|
||||
if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) {
|
||||
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
|
||||
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,8 @@ func TestSendRecordVerification(t *testing.T) {
|
||||
"http://localhost:8090/_/#/auth/confirm-verification/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
||||
}
|
||||
for _, part := range expectedParts {
|
||||
if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) {
|
||||
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
|
||||
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,8 @@ func TestSendRecordChangeEmail(t *testing.T) {
|
||||
"http://localhost:8090/_/#/auth/confirm-email-change/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
||||
}
|
||||
for _, part := range expectedParts {
|
||||
if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) {
|
||||
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
|
||||
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,10 @@ package templates
|
||||
const AdminPasswordResetBody = `
|
||||
{{define "content"}}
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>Follow this link to reset your admin password for {{.AppName}}.</p>
|
||||
|
||||
<p>
|
||||
<a class="btn" href="{{.ActionUrl}}" target="_blank" rel="noopener">Reset password</a>
|
||||
</p>
|
||||
|
||||
<p><i>If you did not request to reset your password, please ignore this email and the link will expire on its own.</i></p>
|
||||
{{end}}
|
||||
`
|
||||
|
||||
@@ -36,7 +36,7 @@ func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
_, tablesErr := db.NewQuery(`
|
||||
CREATE TABLE {{_admins}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[id]] TEXT PRIMARY KEY NOT NULL,
|
||||
[[avatar]] INTEGER DEFAULT 0 NOT NULL,
|
||||
[[email]] TEXT UNIQUE NOT NULL,
|
||||
[[tokenKey]] TEXT UNIQUE NOT NULL,
|
||||
@@ -47,7 +47,7 @@ func init() {
|
||||
);
|
||||
|
||||
CREATE TABLE {{_collections}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[id]] TEXT PRIMARY KEY NOT NULL,
|
||||
[[system]] BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
[[type]] TEXT DEFAULT "base" NOT NULL,
|
||||
[[name]] TEXT UNIQUE NOT NULL,
|
||||
@@ -63,7 +63,7 @@ func init() {
|
||||
);
|
||||
|
||||
CREATE TABLE {{_params}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[id]] TEXT PRIMARY KEY NOT NULL,
|
||||
[[key]] TEXT UNIQUE NOT NULL,
|
||||
[[value]] JSON DEFAULT NULL,
|
||||
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||
@@ -71,7 +71,7 @@ func init() {
|
||||
);
|
||||
|
||||
CREATE TABLE {{_externalAuths}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[id]] TEXT PRIMARY KEY NOT NULL,
|
||||
[[collectionId]] TEXT NOT NULL,
|
||||
[[recordId]] TEXT NOT NULL,
|
||||
[[provider]] TEXT NOT NULL,
|
||||
@@ -133,6 +133,7 @@ func init() {
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// This migration replaces for backward compatibility the default operators
|
||||
// (=, !=, >, etc.) with their any/opt equivalent (?=, ?=, ?>, etc.)
|
||||
// in any muli-rel expression collection rule.
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
exprRegex := regexp.MustCompile(`([\@\'\"\w\.]+)\s*(=|!=|~|!~|>|>=|<|<=)\s*([\@\'\"\w\.]+)`)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
findCollection := func(nameOrId string) *models.Collection {
|
||||
for _, c := range collections {
|
||||
if c.Id == nameOrId || c.Name == nameOrId {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var isMultiRelLiteral func(mainCollection *models.Collection, literal string) bool
|
||||
isMultiRelLiteral = func(mainCollection *models.Collection, literal string) bool {
|
||||
if strings.HasPrefix(literal, "@collection.") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(literal, `"`) ||
|
||||
strings.HasPrefix(literal, `'`) ||
|
||||
strings.HasPrefix(literal, "@request.method") ||
|
||||
strings.HasPrefix(literal, "@request.data") ||
|
||||
strings.HasPrefix(literal, "@request.query") {
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Split(literal, ".")
|
||||
if len(parts) <= 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(literal, "@request.auth") && len(parts) >= 4 {
|
||||
// check each auth collection
|
||||
for _, c := range collections {
|
||||
if c.IsAuth() && isMultiRelLiteral(c, strings.Join(parts[2:], ".")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
activeCollection := mainCollection
|
||||
|
||||
for i, p := range parts {
|
||||
f := activeCollection.Schema.GetFieldByName(p)
|
||||
if f == nil || f.Type != schema.FieldTypeRelation {
|
||||
return false // not a relation field
|
||||
}
|
||||
|
||||
// is multi-relation and not the last prop
|
||||
opt, ok := f.Options.(*schema.RelationOptions)
|
||||
if ok && (opt.MaxSelect == nil || *opt.MaxSelect != 1) && i != len(parts)-1 {
|
||||
return true
|
||||
}
|
||||
|
||||
activeCollection = findCollection(opt.CollectionId)
|
||||
if activeCollection == nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// replace all multi-match operators to their any/opt equivalent, eg. "=" => "?="
|
||||
migrateRule := func(collection *models.Collection, rule *string) (*string, error) {
|
||||
if rule == nil || *rule == "" {
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
newRule := *rule
|
||||
parts := exprRegex.FindAllStringSubmatch(newRule, -1)
|
||||
|
||||
for _, p := range parts {
|
||||
if isMultiRelLiteral(collection, p[1]) || isMultiRelLiteral(collection, p[3]) {
|
||||
newRule = strings.ReplaceAll(newRule, p[0], p[1]+" ?"+p[2]+" "+p[3])
|
||||
}
|
||||
}
|
||||
|
||||
return &newRule, nil
|
||||
}
|
||||
|
||||
var ruleErr error
|
||||
for _, c := range collections {
|
||||
c.ListRule, ruleErr = migrateRule(c, c.ListRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.ViewRule, ruleErr = migrateRule(c, c.ViewRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.CreateRule, ruleErr = migrateRule(c, c.CreateRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.UpdateRule, ruleErr = migrateRule(c, c.UpdateRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.DeleteRule, ruleErr = migrateRule(c, c.DeleteRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
if c.IsAuth() {
|
||||
opt := c.AuthOptions()
|
||||
opt.ManageRule, ruleErr = migrateRule(c, opt.ManageRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
c.SetOptions(opt)
|
||||
}
|
||||
|
||||
if err := dao.Save(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
anyOpRegex := regexp.MustCompile(`\?(=|!=|~|!~|>|>=|<|<=)`)
|
||||
|
||||
// replace any/opt operators to their old versions, eg. "?=" => "="
|
||||
revertRule := func(rule *string) (*string, error) {
|
||||
if rule == nil || *rule == "" {
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
newRule := *rule
|
||||
newRule = anyOpRegex.ReplaceAllString(newRule, "${1}")
|
||||
|
||||
return &newRule, nil
|
||||
}
|
||||
|
||||
var ruleErr error
|
||||
for _, c := range collections {
|
||||
c.ListRule, ruleErr = revertRule(c.ListRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.ViewRule, ruleErr = revertRule(c.ViewRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.CreateRule, ruleErr = revertRule(c.CreateRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.UpdateRule, ruleErr = revertRule(c.UpdateRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.DeleteRule, ruleErr = revertRule(c.DeleteRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
if c.IsAuth() {
|
||||
opt := c.AuthOptions()
|
||||
opt.ManageRule, ruleErr = revertRule(opt.ManageRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
c.SetOptions(opt)
|
||||
}
|
||||
|
||||
if err := dao.Save(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -11,7 +11,7 @@ func init() {
|
||||
LogsMigrations.Register(func(db dbx.Builder) (err error) {
|
||||
_, err = db.NewQuery(`
|
||||
CREATE TABLE {{_requests}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[id]] TEXT PRIMARY KEY NOT NULL,
|
||||
[[url]] TEXT DEFAULT "" NOT NULL,
|
||||
[[method]] TEXT DEFAULT "get" NOT NULL,
|
||||
[[status]] INTEGER DEFAULT 200 NOT NULL,
|
||||
|
||||
+16
-11
@@ -31,7 +31,7 @@ type Model interface {
|
||||
TableName() string
|
||||
IsNew() bool
|
||||
MarkAsNew()
|
||||
UnmarkAsNew()
|
||||
MarkAsNotNew()
|
||||
HasId() bool
|
||||
GetId() string
|
||||
SetId(id string)
|
||||
@@ -48,7 +48,7 @@ type Model interface {
|
||||
|
||||
// BaseModel defines common fields and methods used by all other models.
|
||||
type BaseModel struct {
|
||||
isNewFlag bool
|
||||
isNotNew bool
|
||||
|
||||
Id string `db:"id" json:"id"`
|
||||
Created types.DateTime `db:"created" json:"created"`
|
||||
@@ -70,20 +70,20 @@ func (m *BaseModel) SetId(id string) {
|
||||
m.Id = id
|
||||
}
|
||||
|
||||
// MarkAsNew sets the model isNewFlag enforcing [m.IsNew()] to be true.
|
||||
// MarkAsNew marks the model as "new" (aka. enforces m.IsNew() to be true).
|
||||
func (m *BaseModel) MarkAsNew() {
|
||||
m.isNewFlag = true
|
||||
m.isNotNew = false
|
||||
}
|
||||
|
||||
// UnmarkAsNew resets the model isNewFlag.
|
||||
func (m *BaseModel) UnmarkAsNew() {
|
||||
m.isNewFlag = false
|
||||
// MarkAsNotNew marks the model as "not new" (aka. enforces m.IsNew() to be false)
|
||||
func (m *BaseModel) MarkAsNotNew() {
|
||||
m.isNotNew = true
|
||||
}
|
||||
|
||||
// IsNew indicates what type of db query (insert or update)
|
||||
// should be used with the model instance.
|
||||
func (m *BaseModel) IsNew() bool {
|
||||
return m.isNewFlag || !m.HasId()
|
||||
return !m.isNotNew
|
||||
}
|
||||
|
||||
// GetCreated returns the model Created datetime.
|
||||
@@ -100,9 +100,6 @@ func (m *BaseModel) GetUpdated() types.DateTime {
|
||||
//
|
||||
// The generated id is a cryptographically random 15 characters length string.
|
||||
func (m *BaseModel) RefreshId() {
|
||||
if m.Id == "" { // no previous id
|
||||
m.MarkAsNew()
|
||||
}
|
||||
m.Id = security.RandomStringWithAlphabet(DefaultIdLength, DefaultIdAlphabet)
|
||||
}
|
||||
|
||||
@@ -115,3 +112,11 @@ func (m *BaseModel) RefreshCreated() {
|
||||
func (m *BaseModel) RefreshUpdated() {
|
||||
m.Updated = types.NowDateTime()
|
||||
}
|
||||
|
||||
// PostScan implements the [dbx.PostScanner] interface.
|
||||
//
|
||||
// It is executed right after the model was populated with the db row values.
|
||||
func (m *BaseModel) PostScan() error {
|
||||
m.MarkAsNotNew()
|
||||
return nil
|
||||
}
|
||||
|
||||
+13
-7
@@ -58,15 +58,19 @@ func TestBaseModelIsNew(t *testing.T) {
|
||||
m1 := models.BaseModel{Id: ""}
|
||||
m2 := models.BaseModel{Id: "test"}
|
||||
m3 := models.BaseModel{}
|
||||
m3.MarkAsNew()
|
||||
m3.MarkAsNotNew()
|
||||
m4 := models.BaseModel{Id: "test"}
|
||||
m4.MarkAsNew()
|
||||
m4.MarkAsNotNew()
|
||||
m5 := models.BaseModel{Id: "test"}
|
||||
m5.MarkAsNew()
|
||||
m5.UnmarkAsNew()
|
||||
// check if MarkAsNew will be called on initial RefreshId()
|
||||
m5.MarkAsNotNew()
|
||||
m6 := models.BaseModel{}
|
||||
m6.RefreshId()
|
||||
m7 := models.BaseModel{}
|
||||
m7.MarkAsNotNew()
|
||||
m7.RefreshId()
|
||||
m8 := models.BaseModel{}
|
||||
m8.PostScan()
|
||||
|
||||
scenarios := []struct {
|
||||
model models.BaseModel
|
||||
@@ -74,11 +78,13 @@ func TestBaseModelIsNew(t *testing.T) {
|
||||
}{
|
||||
{m0, true},
|
||||
{m1, true},
|
||||
{m2, false},
|
||||
{m3, true},
|
||||
{m4, true},
|
||||
{m2, true},
|
||||
{m3, false},
|
||||
{m4, false},
|
||||
{m5, false},
|
||||
{m6, true},
|
||||
{m7, false},
|
||||
{m8, false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
|
||||
+195
-5
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -30,6 +32,8 @@ type Record struct {
|
||||
ignoreEmailVisibility bool // whether to ignore the emailVisibility flag for auth collections
|
||||
data map[string]any // any custom data in addition to the base model fields
|
||||
expand map[string]any // expanded relations
|
||||
loaded bool
|
||||
originalData map[string]any // the original (aka. first loaded) model data
|
||||
}
|
||||
|
||||
// NewRecord initializes a new empty Record model.
|
||||
@@ -55,7 +59,7 @@ func nullStringMapValue(data dbx.NullStringMap, key string) any {
|
||||
// NewRecordFromNullStringMap initializes a single new Record model
|
||||
// with data loaded from the provided NullStringMap.
|
||||
func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap) *Record {
|
||||
resultMap := map[string]any{}
|
||||
resultMap := make(map[string]any, len(data))
|
||||
|
||||
// load schema fields
|
||||
for _, field := range collection.Schema.Fields() {
|
||||
@@ -77,6 +81,7 @@ func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap)
|
||||
record := NewRecord(collection)
|
||||
|
||||
record.Load(resultMap)
|
||||
record.PostScan()
|
||||
|
||||
return record
|
||||
}
|
||||
@@ -103,6 +108,21 @@ func (m *Record) Collection() *Collection {
|
||||
return m.collection
|
||||
}
|
||||
|
||||
// OriginalCopy returns a copy of the current record model populated
|
||||
// with its original (aka. the initially loaded) data state.
|
||||
func (m *Record) OriginalCopy() *Record {
|
||||
newRecord := NewRecord(m.collection)
|
||||
newRecord.Load(m.originalData)
|
||||
|
||||
if m.IsNew() {
|
||||
newRecord.MarkAsNew()
|
||||
} else {
|
||||
newRecord.MarkAsNotNew()
|
||||
}
|
||||
|
||||
return newRecord
|
||||
}
|
||||
|
||||
// Expand returns a shallow copy of the record.expand data
|
||||
// attached to the current Record model.
|
||||
func (m *Record) Expand() map[string]any {
|
||||
@@ -114,9 +134,79 @@ func (m *Record) SetExpand(expand map[string]any) {
|
||||
m.expand = shallowCopy(expand)
|
||||
}
|
||||
|
||||
// MergeExpand merges recursively the provided expand data into
|
||||
// the current model's expand (if any).
|
||||
//
|
||||
// Note that if an expanded prop with the same key is a slice (old or new expand)
|
||||
// then both old and new records will be merged into a new slice (aka. a :merge: [b,c] => [a,b,c]).
|
||||
// Otherwise the "old" expanded record will be replace with the "new" one (aka. a :merge: aNew => aNew).
|
||||
func (m *Record) MergeExpand(expand map[string]any) {
|
||||
if m.expand == nil && len(expand) > 0 {
|
||||
m.expand = make(map[string]any, len(expand))
|
||||
}
|
||||
|
||||
for key, new := range expand {
|
||||
old, ok := m.expand[key]
|
||||
if !ok {
|
||||
m.expand[key] = new
|
||||
continue
|
||||
}
|
||||
|
||||
var wasOldSlice bool
|
||||
var oldSlice []*Record
|
||||
switch v := old.(type) {
|
||||
case *Record:
|
||||
oldSlice = []*Record{v}
|
||||
case []*Record:
|
||||
wasOldSlice = true
|
||||
oldSlice = v
|
||||
default:
|
||||
// invalid old expand data -> assign directly the new
|
||||
// (no matter whether new is valid or not)
|
||||
m.expand[key] = new
|
||||
continue
|
||||
}
|
||||
|
||||
var wasNewSlice bool
|
||||
var newSlice []*Record
|
||||
switch v := new.(type) {
|
||||
case *Record:
|
||||
newSlice = []*Record{v}
|
||||
case []*Record:
|
||||
wasNewSlice = true
|
||||
newSlice = v
|
||||
default:
|
||||
// invalid new expand data -> skip
|
||||
continue
|
||||
}
|
||||
|
||||
oldIndexed := make(map[string]*Record, len(oldSlice))
|
||||
for _, oldRecord := range oldSlice {
|
||||
oldIndexed[oldRecord.Id] = oldRecord
|
||||
}
|
||||
|
||||
for _, newRecord := range newSlice {
|
||||
oldRecord := oldIndexed[newRecord.Id]
|
||||
if oldRecord != nil {
|
||||
// note: there is no need to update oldSlice since oldRecord is a reference
|
||||
oldRecord.MergeExpand(newRecord.Expand())
|
||||
} else {
|
||||
// missing new entry
|
||||
oldSlice = append(oldSlice, newRecord)
|
||||
}
|
||||
}
|
||||
|
||||
if wasOldSlice || wasNewSlice || len(oldSlice) == 0 {
|
||||
m.expand[key] = oldSlice
|
||||
} else {
|
||||
m.expand[key] = oldSlice[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SchemaData returns a shallow copy ONLY of the defined record schema fields data.
|
||||
func (m *Record) SchemaData() map[string]any {
|
||||
result := map[string]any{}
|
||||
result := make(map[string]any, len(m.collection.Schema.Fields()))
|
||||
|
||||
for _, field := range m.collection.Schema.Fields() {
|
||||
if v, ok := m.data[field.Name]; ok {
|
||||
@@ -270,6 +360,11 @@ func (m *Record) FindFileFieldByFile(filename string) *schema.SchemaField {
|
||||
|
||||
// Load bulk loads the provided data into the current Record model.
|
||||
func (m *Record) Load(data map[string]any) {
|
||||
if !m.loaded {
|
||||
m.loaded = true
|
||||
m.originalData = data
|
||||
}
|
||||
|
||||
for k, v := range data {
|
||||
m.Set(k, v)
|
||||
}
|
||||
@@ -277,7 +372,7 @@ func (m *Record) Load(data map[string]any) {
|
||||
|
||||
// ColumnValueMap implements [ColumnValueMapper] interface.
|
||||
func (m *Record) ColumnValueMap() map[string]any {
|
||||
result := map[string]any{}
|
||||
result := make(map[string]any, len(m.collection.Schema.Fields())+3)
|
||||
|
||||
// export schema field values
|
||||
for _, field := range m.collection.Schema.Fields() {
|
||||
@@ -303,7 +398,7 @@ func (m *Record) ColumnValueMap() map[string]any {
|
||||
//
|
||||
// Fields marked as hidden will be exported only if `m.IgnoreEmailVisibility(true)` is set.
|
||||
func (m *Record) PublicExport() map[string]any {
|
||||
result := map[string]any{}
|
||||
result := make(map[string]any, len(m.collection.Schema.Fields())+5)
|
||||
|
||||
// export unknown data fields if allowed
|
||||
if m.exportUnknown {
|
||||
@@ -364,6 +459,101 @@ func (m *Record) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceModifers returns a new map with applied modifier
|
||||
// values based on the current record and the specified data.
|
||||
//
|
||||
// The resolved modifier keys will be removed.
|
||||
//
|
||||
// Multiple modifiers will be applied one after another,
|
||||
// while reusing the previous base key value result (eg. 1; -5; +2 => -2).
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// newData := record.ReplaceModifers(data)
|
||||
// // record: {"field": 10}
|
||||
// // data: {"field+": 5}
|
||||
// // newData: {"field": 15}
|
||||
func (m *Record) ReplaceModifers(data map[string]any) map[string]any {
|
||||
var clone = shallowCopy(data)
|
||||
if len(clone) == 0 {
|
||||
return clone
|
||||
}
|
||||
|
||||
var recordDataCache map[string]any
|
||||
|
||||
// export recordData lazily
|
||||
recordData := func() map[string]any {
|
||||
if recordDataCache == nil {
|
||||
recordDataCache = m.SchemaData()
|
||||
}
|
||||
return recordDataCache
|
||||
}
|
||||
|
||||
modifiers := schema.FieldValueModifiers()
|
||||
|
||||
for _, field := range m.Collection().Schema.Fields() {
|
||||
key := field.Name
|
||||
|
||||
for _, m := range modifiers {
|
||||
if mv, mOk := clone[key+m]; mOk {
|
||||
if _, ok := clone[key]; !ok {
|
||||
// get base value from the merged data
|
||||
clone[key] = recordData()[key]
|
||||
}
|
||||
|
||||
clone[key] = field.PrepareValueWithModifier(clone[key], m, mv)
|
||||
delete(clone, key+m)
|
||||
}
|
||||
}
|
||||
|
||||
if field.Type != schema.FieldTypeFile {
|
||||
continue
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// legacy file field modifiers (kept for backward compatability)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
var oldNames []string
|
||||
var toDelete []string
|
||||
if _, ok := clone[key]; ok {
|
||||
oldNames = list.ToUniqueStringSlice(clone[key])
|
||||
} else {
|
||||
// get oldNames from the model
|
||||
oldNames = list.ToUniqueStringSlice(recordData()[key])
|
||||
}
|
||||
|
||||
// search for individual file name to delete (eg. "file.test.png = null")
|
||||
for _, name := range oldNames {
|
||||
suffixedKey := key + "." + name
|
||||
if v, ok := clone[suffixedKey]; ok && cast.ToString(v) == "" {
|
||||
toDelete = append(toDelete, name)
|
||||
delete(clone, suffixedKey)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// search for individual file index to delete (eg. "file.0 = null")
|
||||
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
|
||||
for indexedKey := range clone {
|
||||
if keyExp.MatchString(indexedKey) && cast.ToString(clone[indexedKey]) == "" {
|
||||
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
|
||||
if indexErr != nil || index < 0 || index >= len(oldNames) {
|
||||
continue
|
||||
}
|
||||
toDelete = append(toDelete, oldNames[index])
|
||||
delete(clone, indexedKey)
|
||||
}
|
||||
}
|
||||
|
||||
if toDelete != nil {
|
||||
clone[key] = field.PrepareValue(list.SubtractSlice(oldNames, toDelete))
|
||||
}
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// getNormalizeDataValueForDB returns the "key" data value formatted for db storage.
|
||||
func (m *Record) getNormalizeDataValueForDB(key string) any {
|
||||
var val any
|
||||
@@ -403,7 +593,7 @@ func (m *Record) getNormalizeDataValueForDB(key string) any {
|
||||
|
||||
// shallowCopy shallow copy data into a new map.
|
||||
func shallowCopy(data map[string]any) map[string]any {
|
||||
result := map[string]any{}
|
||||
result := make(map[string]any, len(data))
|
||||
|
||||
for k, v := range data {
|
||||
result[k] = v
|
||||
|
||||
+267
-4
@@ -138,7 +138,7 @@ func TestNewRecordFromNullStringMap(t *testing.T) {
|
||||
Valid: true,
|
||||
},
|
||||
"field5": sql.NullString{
|
||||
String: `["test1","test2"]`, // will select only the first elem
|
||||
String: `["test1","test2"]`, // will select only the last elem
|
||||
Valid: true,
|
||||
},
|
||||
"field6": sql.NullString{
|
||||
@@ -157,11 +157,11 @@ func TestNewRecordFromNullStringMap(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
models.CollectionTypeBase,
|
||||
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z"}`,
|
||||
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test2","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z"}`,
|
||||
},
|
||||
{
|
||||
models.CollectionTypeAuth,
|
||||
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z","username":"test_username","verified":false}`,
|
||||
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test2","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z","username":"test_username","verified":false}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -331,7 +331,34 @@ func TestRecordCollection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordExpand(t *testing.T) {
|
||||
func TestRecordOriginalCopy(t *testing.T) {
|
||||
m := models.NewRecord(&models.Collection{})
|
||||
m.Load(map[string]any{"f": "123"})
|
||||
|
||||
// change the field
|
||||
m.Set("f", "456")
|
||||
|
||||
if v := m.GetString("f"); v != "456" {
|
||||
t.Fatalf("Expected f to be %q, got %q", "456", v)
|
||||
}
|
||||
|
||||
if v := m.OriginalCopy().GetString("f"); v != "123" {
|
||||
t.Fatalf("Expected the initial/original f to be %q, got %q", "123", v)
|
||||
}
|
||||
|
||||
// Loading new data shouldn't affect the original state
|
||||
m.Load(map[string]any{"f": "789"})
|
||||
|
||||
if v := m.GetString("f"); v != "789" {
|
||||
t.Fatalf("Expected f to be %q, got %q", "789", v)
|
||||
}
|
||||
|
||||
if v := m.OriginalCopy().GetString("f"); v != "123" {
|
||||
t.Fatalf("Expected the initial/original f still to be %q, got %q", "123", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSetAndGetExpand(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
m := models.NewRecord(collection)
|
||||
|
||||
@@ -348,6 +375,139 @@ func TestRecordExpand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordMergeExpand(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
m := models.NewRecord(collection)
|
||||
m.Id = "m"
|
||||
|
||||
// a
|
||||
a := models.NewRecord(collection)
|
||||
a.Id = "a"
|
||||
a1 := models.NewRecord(collection)
|
||||
a1.Id = "a1"
|
||||
a2 := models.NewRecord(collection)
|
||||
a2.Id = "a2"
|
||||
a3 := models.NewRecord(collection)
|
||||
a3.Id = "a3"
|
||||
a31 := models.NewRecord(collection)
|
||||
a31.Id = "a31"
|
||||
a32 := models.NewRecord(collection)
|
||||
a32.Id = "a32"
|
||||
a.SetExpand(map[string]any{
|
||||
"a1": a1,
|
||||
"a23": []*models.Record{a2, a3},
|
||||
})
|
||||
a3.SetExpand(map[string]any{
|
||||
"a31": a31,
|
||||
"a32": []*models.Record{a32},
|
||||
})
|
||||
|
||||
// b
|
||||
b := models.NewRecord(collection)
|
||||
b.Id = "b"
|
||||
b1 := models.NewRecord(collection)
|
||||
b1.Id = "b1"
|
||||
b.SetExpand(map[string]any{
|
||||
"b1": b1,
|
||||
})
|
||||
|
||||
// c
|
||||
c := models.NewRecord(collection)
|
||||
c.Id = "c"
|
||||
|
||||
// load initial expand
|
||||
m.SetExpand(map[string]any{
|
||||
"a": a,
|
||||
"b": b,
|
||||
"c": []*models.Record{c},
|
||||
})
|
||||
|
||||
// a (new)
|
||||
aNew := models.NewRecord(collection)
|
||||
aNew.Id = a.Id
|
||||
a3New := models.NewRecord(collection)
|
||||
a3New.Id = a3.Id
|
||||
a32New := models.NewRecord(collection)
|
||||
a32New.Id = "a32New"
|
||||
a33New := models.NewRecord(collection)
|
||||
a33New.Id = "a33New"
|
||||
a3New.SetExpand(map[string]any{
|
||||
"a32": []*models.Record{a32New},
|
||||
"a33New": a33New,
|
||||
})
|
||||
aNew.SetExpand(map[string]any{
|
||||
"a23": []*models.Record{a2, a3New},
|
||||
})
|
||||
|
||||
// b (new)
|
||||
bNew := models.NewRecord(collection)
|
||||
bNew.Id = "bNew"
|
||||
dNew := models.NewRecord(collection)
|
||||
dNew.Id = "dNew"
|
||||
|
||||
// merge expands
|
||||
m.MergeExpand(map[string]any{
|
||||
"a": aNew,
|
||||
"b": []*models.Record{bNew},
|
||||
"dNew": dNew,
|
||||
})
|
||||
|
||||
result := m.Expand()
|
||||
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawStr := string(raw)
|
||||
|
||||
expected := `{"a":{"collectionId":"","collectionName":"","created":"","expand":{"a1":{"collectionId":"","collectionName":"","created":"","id":"a1","updated":""},"a23":[{"collectionId":"","collectionName":"","created":"","id":"a2","updated":""},{"collectionId":"","collectionName":"","created":"","expand":{"a31":{"collectionId":"","collectionName":"","created":"","id":"a31","updated":""},"a32":[{"collectionId":"","collectionName":"","created":"","id":"a32","updated":""},{"collectionId":"","collectionName":"","created":"","id":"a32New","updated":""}],"a33New":{"collectionId":"","collectionName":"","created":"","id":"a33New","updated":""}},"id":"a3","updated":""}]},"id":"a","updated":""},"b":[{"collectionId":"","collectionName":"","created":"","expand":{"b1":{"collectionId":"","collectionName":"","created":"","id":"b1","updated":""}},"id":"b","updated":""},{"collectionId":"","collectionName":"","created":"","id":"bNew","updated":""}],"c":[{"collectionId":"","collectionName":"","created":"","id":"c","updated":""}],"dNew":{"collectionId":"","collectionName":"","created":"","id":"dNew","updated":""}}`
|
||||
|
||||
if expected != rawStr {
|
||||
t.Fatalf("Expected \n%v, \ngot \n%v", expected, rawStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordMergeExpandNilCheck(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
expand map[string]any
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"nil expand",
|
||||
nil,
|
||||
`{"collectionId":"","collectionName":"","created":"","id":"","updated":""}`,
|
||||
},
|
||||
{
|
||||
"empty expand",
|
||||
map[string]any{},
|
||||
`{"collectionId":"","collectionName":"","created":"","id":"","updated":""}`,
|
||||
},
|
||||
{
|
||||
"non-empty expand",
|
||||
map[string]any{"test": models.NewRecord(collection)},
|
||||
`{"collectionId":"","collectionName":"","created":"","expand":{"test":{"collectionId":"","collectionName":"","created":"","id":"","updated":""}},"id":"","updated":""}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
m := models.NewRecord(collection)
|
||||
m.MergeExpand(s.expand)
|
||||
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawStr := string(raw)
|
||||
|
||||
if rawStr != s.expected {
|
||||
t.Fatalf("[%s] Expected \n%v, \ngot \n%v", s.name, s.expected, rawStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSchemaData(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Type: models.CollectionTypeAuth,
|
||||
@@ -1265,6 +1425,109 @@ func TestRecordUnmarshalJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordReplaceModifers(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "text",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "number",
|
||||
Type: schema.FieldTypeNumber,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "rel_one",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "rel_many",
|
||||
Type: schema.FieldTypeRelation,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "select_one",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{MaxSelect: 1},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "select_many",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{MaxSelect: 10},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "file_one",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{MaxSelect: 1},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "file_one_index",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{MaxSelect: 1},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "file_one_name",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{MaxSelect: 1},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "file_many",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{MaxSelect: 10},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
record := models.NewRecord(collection)
|
||||
|
||||
record.Load(map[string]any{
|
||||
"text": "test",
|
||||
"number": 10,
|
||||
"rel_one": "a",
|
||||
"rel_many": []string{"a", "b"},
|
||||
"select_one": "a",
|
||||
"select_many": []string{"a", "b", "c"},
|
||||
"file_one": "a",
|
||||
"file_one_index": "b",
|
||||
"file_one_name": "c",
|
||||
"file_many": []string{"a", "b", "c", "d", "e", "f"},
|
||||
})
|
||||
|
||||
result := record.ReplaceModifers(map[string]any{
|
||||
"text-": "m-",
|
||||
"text+": "m+",
|
||||
"number-": 3,
|
||||
"number+": 5,
|
||||
"rel_one-": "a",
|
||||
"rel_one+": "b",
|
||||
"rel_many-": []string{"a"},
|
||||
"rel_many+": []string{"c", "d", "e"},
|
||||
"select_one-": "a",
|
||||
"select_one+": "c",
|
||||
"select_many-": []string{"b", "c"},
|
||||
"select_many+": []string{"d", "e"},
|
||||
"file_one+": "skip", // should be ignored
|
||||
"file_one-": "a",
|
||||
"file_one_index.0": "",
|
||||
"file_one_name.c": "",
|
||||
"file_many+": []string{"e", "f"}, // should be ignored
|
||||
"file_many-": []string{"c", "d"},
|
||||
"file_many.f": nil,
|
||||
"file_many.0": nil,
|
||||
})
|
||||
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"file_many":["b","e"],"file_one":"","file_one_index":"","file_one_name":"","number":12,"rel_many":["b","c","d","e"],"rel_one":"b","select_many":["a","d","e"],"select_one":"c","text":"test"}`
|
||||
|
||||
if v := string(raw); v != expected {
|
||||
t.Fatalf("Expected \n%s, \ngot \n%s", expected, v)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Auth helpers:
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// RequestData defines a HTTP request data struct, usually used
|
||||
// as part of the `@request.*` filter resolver.
|
||||
type RequestData struct {
|
||||
@@ -9,3 +15,18 @@ type RequestData struct {
|
||||
AuthRecord *Record `json:"authRecord"`
|
||||
Admin *Admin `json:"admin"`
|
||||
}
|
||||
|
||||
// HasModifierDataKeys loosely checks if the current struct has any modifier Data keys.
|
||||
func (r *RequestData) HasModifierDataKeys() bool {
|
||||
allModifiers := schema.FieldValueModifiers()
|
||||
|
||||
for key := range r.Data {
|
||||
for _, m := range allModifiers {
|
||||
if strings.HasSuffix(key, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
func TestRequestDataHasModifierDataKeys(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
requestData *models.RequestData
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
&models.RequestData{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Data with regular fields",
|
||||
&models.RequestData{
|
||||
Query: map[string]any{"data+": "demo"}, // should be ignored
|
||||
Data: map[string]any{"a": 123, "b": "test", "c.d": false},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Data with +modifier fields",
|
||||
&models.RequestData{
|
||||
Data: map[string]any{"a+": 123, "b": "test", "c.d": false},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Data with -modifier fields",
|
||||
&models.RequestData{
|
||||
Data: map[string]any{"a": 123, "b-": "test", "c.d": false},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Data with mixed modifier fields",
|
||||
&models.RequestData{
|
||||
Data: map[string]any{"a": 123, "b-": "test", "c.d+": false},
|
||||
},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result := s.requestData.HasModifierDataKeys()
|
||||
|
||||
if result != s.expected {
|
||||
t.Fatalf("[%s] Expected %v, got %v", s.name, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,14 @@ func (s *Schema) UnmarshalJSON(data []byte) error {
|
||||
s.AddField(f)
|
||||
}
|
||||
|
||||
return s.InitFieldsOptions()
|
||||
for _, field := range s.fields {
|
||||
if err := field.InitOptions(); err != nil {
|
||||
// ignore the error and remove the invalid field
|
||||
s.RemoveField(field.Id)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements the [driver.Valuer] interface.
|
||||
|
||||
@@ -15,22 +15,36 @@ import (
|
||||
|
||||
var schemaFieldNameRegex = regexp.MustCompile(`^\w+$`)
|
||||
|
||||
// field value modifiers
|
||||
const (
|
||||
FieldValueModifierAdd string = "+"
|
||||
FieldValueModifierSubtract string = "-"
|
||||
)
|
||||
|
||||
// FieldValueModifiers returns a list with all available field modifier tokens.
|
||||
func FieldValueModifiers() []string {
|
||||
return []string{
|
||||
FieldValueModifierAdd,
|
||||
FieldValueModifierSubtract,
|
||||
}
|
||||
}
|
||||
|
||||
// commonly used field names
|
||||
const (
|
||||
FieldNameId = "id"
|
||||
FieldNameCreated = "created"
|
||||
FieldNameUpdated = "updated"
|
||||
FieldNameCollectionId = "collectionId"
|
||||
FieldNameCollectionName = "collectionName"
|
||||
FieldNameExpand = "expand"
|
||||
FieldNameUsername = "username"
|
||||
FieldNameEmail = "email"
|
||||
FieldNameEmailVisibility = "emailVisibility"
|
||||
FieldNameVerified = "verified"
|
||||
FieldNameTokenKey = "tokenKey"
|
||||
FieldNamePasswordHash = "passwordHash"
|
||||
FieldNameLastResetSentAt = "lastResetSentAt"
|
||||
FieldNameLastVerificationSentAt = "lastVerificationSentAt"
|
||||
FieldNameId string = "id"
|
||||
FieldNameCreated string = "created"
|
||||
FieldNameUpdated string = "updated"
|
||||
FieldNameCollectionId string = "collectionId"
|
||||
FieldNameCollectionName string = "collectionName"
|
||||
FieldNameExpand string = "expand"
|
||||
FieldNameUsername string = "username"
|
||||
FieldNameEmail string = "email"
|
||||
FieldNameEmailVisibility string = "emailVisibility"
|
||||
FieldNameVerified string = "verified"
|
||||
FieldNameTokenKey string = "tokenKey"
|
||||
FieldNamePasswordHash string = "passwordHash"
|
||||
FieldNameLastResetSentAt string = "lastResetSentAt"
|
||||
FieldNameLastVerificationSentAt string = "lastVerificationSentAt"
|
||||
)
|
||||
|
||||
// BaseModelFieldNames returns the field names that all models have (id, created, updated).
|
||||
@@ -78,7 +92,7 @@ const (
|
||||
FieldTypeFile string = "file"
|
||||
FieldTypeRelation string = "relation"
|
||||
|
||||
// Deprecated: Will be removed in v0.9!
|
||||
// Deprecated: Will be removed in v0.9+
|
||||
FieldTypeUser string = "user"
|
||||
)
|
||||
|
||||
@@ -168,8 +182,8 @@ func (f SchemaField) Validate() error {
|
||||
f.InitOptions()
|
||||
|
||||
excludeNames := BaseModelFieldNames()
|
||||
// exclude filter literals
|
||||
excludeNames = append(excludeNames, "null", "true", "false")
|
||||
// exclude special filter literals
|
||||
excludeNames = append(excludeNames, "null", "true", "false", "isset")
|
||||
// exclude system literals
|
||||
excludeNames = append(excludeNames, SystemFieldNames()...)
|
||||
|
||||
@@ -235,7 +249,7 @@ func (f *SchemaField) InitOptions() error {
|
||||
case FieldTypeRelation:
|
||||
options = &RelationOptions{}
|
||||
|
||||
// Deprecated: Will be removed in v0.9!
|
||||
// Deprecated: Will be removed in v0.9+
|
||||
case FieldTypeUser:
|
||||
options = &UserOptions{}
|
||||
|
||||
@@ -276,7 +290,7 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
options, _ := f.Options.(*SelectOptions)
|
||||
if options.MaxSelect <= 1 {
|
||||
if len(val) > 0 {
|
||||
return val[0]
|
||||
return val[len(val)-1] // the last selected
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -288,7 +302,7 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
options, _ := f.Options.(*FileOptions)
|
||||
if options.MaxSelect <= 1 {
|
||||
if len(val) > 0 {
|
||||
return val[0]
|
||||
return val[len(val)-1] // the last selected
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -300,7 +314,7 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
options, _ := f.Options.(*RelationOptions)
|
||||
if options.MaxSelect != nil && *options.MaxSelect <= 1 {
|
||||
if len(ids) > 0 {
|
||||
return ids[0]
|
||||
return ids[len(ids)-1] // the last selected
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -311,6 +325,46 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareValueWithModifier returns normalized and properly formatted field value
|
||||
// by "merging" baseValue with the modifierValue based on the specified modifier (+ or -).
|
||||
func (f *SchemaField) PrepareValueWithModifier(baseValue any, modifier string, modifierValue any) any {
|
||||
resolvedValue := baseValue
|
||||
|
||||
switch f.Type {
|
||||
case FieldTypeNumber:
|
||||
switch modifier {
|
||||
case FieldValueModifierAdd:
|
||||
resolvedValue = cast.ToFloat64(baseValue) + cast.ToFloat64(modifierValue)
|
||||
case FieldValueModifierSubtract:
|
||||
resolvedValue = cast.ToFloat64(baseValue) - cast.ToFloat64(modifierValue)
|
||||
}
|
||||
case FieldTypeSelect, FieldTypeRelation:
|
||||
switch modifier {
|
||||
case FieldValueModifierAdd:
|
||||
resolvedValue = append(
|
||||
list.ToUniqueStringSlice(baseValue),
|
||||
list.ToUniqueStringSlice(modifierValue)...,
|
||||
)
|
||||
case FieldValueModifierSubtract:
|
||||
resolvedValue = list.SubtractSlice(
|
||||
list.ToUniqueStringSlice(baseValue),
|
||||
list.ToUniqueStringSlice(modifierValue),
|
||||
)
|
||||
}
|
||||
case FieldTypeFile:
|
||||
// note: file for now supports only the subtract modifier
|
||||
switch modifier {
|
||||
case FieldValueModifierSubtract:
|
||||
resolvedValue = list.SubtractSlice(
|
||||
list.ToUniqueStringSlice(baseValue),
|
||||
list.ToUniqueStringSlice(modifierValue),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return f.PrepareValue(resolvedValue)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// FieldOptions interfaces that defines common methods that every field options struct has.
|
||||
@@ -515,13 +569,13 @@ func (o RelationOptions) Validate() error {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Deprecated: Will be removed in v0.9!
|
||||
// Deprecated: Will be removed in v0.9+
|
||||
type UserOptions struct {
|
||||
MaxSelect int `form:"maxSelect" json:"maxSelect"`
|
||||
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in v0.9!
|
||||
// Deprecated: Will be removed in v0.9+
|
||||
func (o UserOptions) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -603,7 +603,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, 123, `"123"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, []string{"test1", "test2"}, `"test1"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, []string{"test1", "test2"}, `"test2"`},
|
||||
{
|
||||
// no values validation/filtering
|
||||
schema.SchemaField{
|
||||
@@ -680,7 +680,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, 123, `"123"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, []string{"test1", "test2"}, `"test1"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, []string{"test1", "test2"}, `"test2"`},
|
||||
// file (multiple)
|
||||
{
|
||||
schema.SchemaField{
|
||||
@@ -785,7 +785,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
|
||||
`"1ba88b4f-e9da-42f0-9764-9a55c953e724"`,
|
||||
`"2ba88b4f-e9da-42f0-9764-9a55c953e724"`,
|
||||
},
|
||||
// relation (multiple)
|
||||
{
|
||||
@@ -863,6 +863,696 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaFieldPrepareValueWithModifier(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
field schema.SchemaField
|
||||
baseValue any
|
||||
modifier string
|
||||
modifierValue any
|
||||
expectJson string
|
||||
}{
|
||||
// text
|
||||
{
|
||||
"text with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeText},
|
||||
"base",
|
||||
"+",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"text with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeText},
|
||||
"base",
|
||||
"-",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"text with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeText},
|
||||
"base",
|
||||
"?",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"text cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeText},
|
||||
123,
|
||||
"?",
|
||||
"new",
|
||||
`"123"`,
|
||||
},
|
||||
|
||||
// number
|
||||
{
|
||||
"number with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeNumber},
|
||||
1,
|
||||
"+",
|
||||
4,
|
||||
`5`,
|
||||
},
|
||||
{
|
||||
"number with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeNumber},
|
||||
1,
|
||||
"-",
|
||||
4,
|
||||
`-3`,
|
||||
},
|
||||
{
|
||||
"number with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeNumber},
|
||||
"1",
|
||||
"?",
|
||||
4,
|
||||
`1`,
|
||||
},
|
||||
{
|
||||
"number cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeNumber},
|
||||
"test",
|
||||
"+",
|
||||
"4",
|
||||
`4`,
|
||||
},
|
||||
|
||||
// bool
|
||||
{
|
||||
"bool with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeBool},
|
||||
true,
|
||||
"+",
|
||||
false,
|
||||
`true`,
|
||||
},
|
||||
{
|
||||
"bool with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeBool},
|
||||
true,
|
||||
"-",
|
||||
false,
|
||||
`true`,
|
||||
},
|
||||
{
|
||||
"bool with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeBool},
|
||||
true,
|
||||
"?",
|
||||
false,
|
||||
`true`,
|
||||
},
|
||||
{
|
||||
"bool cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeBool},
|
||||
"true",
|
||||
"?",
|
||||
false,
|
||||
`true`,
|
||||
},
|
||||
|
||||
// email
|
||||
{
|
||||
"email with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeEmail},
|
||||
"base",
|
||||
"+",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"email with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeEmail},
|
||||
"base",
|
||||
"-",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"email with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeEmail},
|
||||
"base",
|
||||
"?",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"email cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeEmail},
|
||||
123,
|
||||
"?",
|
||||
"new",
|
||||
`"123"`,
|
||||
},
|
||||
|
||||
// url
|
||||
{
|
||||
"url with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeUrl},
|
||||
"base",
|
||||
"+",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"url with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeUrl},
|
||||
"base",
|
||||
"-",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"url with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeUrl},
|
||||
"base",
|
||||
"?",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"url cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeUrl},
|
||||
123,
|
||||
"-",
|
||||
"new",
|
||||
`"123"`,
|
||||
},
|
||||
|
||||
// date
|
||||
{
|
||||
"date with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeDate},
|
||||
"2023-01-01 00:00:00.123",
|
||||
"+",
|
||||
"2023-02-01 00:00:00.456",
|
||||
`"2023-01-01 00:00:00.123Z"`,
|
||||
},
|
||||
{
|
||||
"date with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeDate},
|
||||
"2023-01-01 00:00:00.123Z",
|
||||
"-",
|
||||
"2023-02-01 00:00:00.456Z",
|
||||
`"2023-01-01 00:00:00.123Z"`,
|
||||
},
|
||||
{
|
||||
"date with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeDate},
|
||||
"2023-01-01 00:00:00.123",
|
||||
"?",
|
||||
"2023-01-01 00:00:00.456",
|
||||
`"2023-01-01 00:00:00.123Z"`,
|
||||
},
|
||||
{
|
||||
"date cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeDate},
|
||||
1672524000, // 2022-12-31 22:00:00.000Z
|
||||
"+",
|
||||
100,
|
||||
`"2022-12-31 22:00:00.000Z"`,
|
||||
},
|
||||
|
||||
// json
|
||||
{
|
||||
"json with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeJson},
|
||||
10,
|
||||
"+",
|
||||
5,
|
||||
`10`,
|
||||
},
|
||||
{
|
||||
"json with '+' modifier (slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeJson},
|
||||
[]string{"a", "b"},
|
||||
"+",
|
||||
"c",
|
||||
`["a","b"]`,
|
||||
},
|
||||
{
|
||||
"json with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeJson},
|
||||
10,
|
||||
"-",
|
||||
5,
|
||||
`10`,
|
||||
},
|
||||
{
|
||||
"json with '-' modifier (slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeJson},
|
||||
`["a","b"]`,
|
||||
"-",
|
||||
"c",
|
||||
`["a","b"]`,
|
||||
},
|
||||
{
|
||||
"json with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeJson},
|
||||
`"base"`,
|
||||
"?",
|
||||
`"new"`,
|
||||
`"base"`,
|
||||
},
|
||||
|
||||
// single select
|
||||
{
|
||||
"single select with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"+",
|
||||
"b",
|
||||
`"b"`,
|
||||
},
|
||||
{
|
||||
"single select with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"+",
|
||||
"b",
|
||||
`"b"`,
|
||||
},
|
||||
{
|
||||
"single select with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single select with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single select with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single select with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single select with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
[]string{"b", "a", "c", "123"},
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single select with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"?",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
|
||||
// multi select
|
||||
{
|
||||
"multi select with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
nil,
|
||||
"+",
|
||||
"b",
|
||||
`["b"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
[]string{"a"},
|
||||
"+",
|
||||
[]string{"b", "c"},
|
||||
`["a","b","c"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '+' modifier (nonempty base; already existing value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b"},
|
||||
"+",
|
||||
"b",
|
||||
`["a","b"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
nil,
|
||||
"-",
|
||||
[]string{"a"},
|
||||
`[]`,
|
||||
},
|
||||
{
|
||||
"multi select with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
"c",
|
||||
`["a","b","d"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
[]string{"b", "a", "123"},
|
||||
`["c","d"]`,
|
||||
},
|
||||
{
|
||||
"multi select with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b"},
|
||||
"?",
|
||||
"a",
|
||||
`["a","b"]`,
|
||||
},
|
||||
|
||||
// single relation
|
||||
{
|
||||
"single relation with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"",
|
||||
"+",
|
||||
"b",
|
||||
`"b"`,
|
||||
},
|
||||
{
|
||||
"single relation with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"a",
|
||||
"+",
|
||||
"b",
|
||||
`"b"`,
|
||||
},
|
||||
{
|
||||
"single relation with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single relation with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single relation with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single relation with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"a",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single relation with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"a",
|
||||
"-",
|
||||
[]string{"b", "a", "c", "123"},
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single relation with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"",
|
||||
"?",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
|
||||
// multi relation
|
||||
{
|
||||
"multi relation with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
nil,
|
||||
"+",
|
||||
"b",
|
||||
`["b"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
[]string{"a"},
|
||||
"+",
|
||||
[]string{"b", "c"},
|
||||
`["a","b","c"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '+' modifier (nonempty base; already existing value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
[]string{"a", "b"},
|
||||
"+",
|
||||
"b",
|
||||
`["a","b"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
nil,
|
||||
"-",
|
||||
[]string{"a"},
|
||||
`[]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
"c",
|
||||
`["a","b","d"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
[]string{"b", "a", "123"},
|
||||
`["c","d"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
[]string{"a", "b"},
|
||||
"?",
|
||||
"a",
|
||||
`["a","b"]`,
|
||||
},
|
||||
|
||||
// single file
|
||||
{
|
||||
"single file with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"+",
|
||||
"b",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single file with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"+",
|
||||
"b",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single file with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single file with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single file with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single file with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single file with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
[]string{"b", "a", "c", "123"},
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single file with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"?",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
|
||||
// multi file
|
||||
{
|
||||
"multi file with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
nil,
|
||||
"+",
|
||||
"b",
|
||||
`[]`,
|
||||
},
|
||||
{
|
||||
"multi file with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
[]string{"a"},
|
||||
"+",
|
||||
[]string{"b", "c"},
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi file with '+' modifier (nonempty base; already existing value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b"},
|
||||
"+",
|
||||
"b",
|
||||
`["a","b"]`,
|
||||
},
|
||||
{
|
||||
"multi file with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
nil,
|
||||
"-",
|
||||
[]string{"a"},
|
||||
`[]`,
|
||||
},
|
||||
{
|
||||
"multi file with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi file with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi file with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
"c",
|
||||
`["a","b","d"]`,
|
||||
},
|
||||
{
|
||||
"multi file with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
[]string{"b", "a", "123"},
|
||||
`["c","d"]`,
|
||||
},
|
||||
{
|
||||
"multi file with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b"},
|
||||
"?",
|
||||
"a",
|
||||
`["a","b"]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result := s.field.PrepareValueWithModifier(s.baseValue, s.modifier, s.modifierValue)
|
||||
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] %v", s.name, err)
|
||||
}
|
||||
|
||||
if string(encoded) != s.expectJson {
|
||||
t.Fatalf("[%s], Expected %v, got %v", s.name, s.expectJson, string(encoded))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type fieldOptionsScenario struct {
|
||||
@@ -1306,7 +1996,7 @@ func TestRelationOptionsValidate(t *testing.T) {
|
||||
[]string{"maxSelect"},
|
||||
},
|
||||
{
|
||||
"MaxSelect > 0 && non-empty CollectionId",
|
||||
"MaxSelect > 0 && nonempty CollectionId",
|
||||
schema.RelationOptions{
|
||||
CollectionId: "abc",
|
||||
MaxSelect: types.Pointer(1),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -30,7 +31,7 @@ type Settings struct {
|
||||
RecordEmailChangeToken TokenConfig `form:"recordEmailChangeToken" json:"recordEmailChangeToken"`
|
||||
RecordVerificationToken TokenConfig `form:"recordVerificationToken" json:"recordVerificationToken"`
|
||||
|
||||
// Deprecated: Will be removed in v0.9!
|
||||
// Deprecated: Will be removed in v0.9+
|
||||
EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
|
||||
|
||||
GoogleAuth AuthProviderConfig `form:"googleAuth" json:"googleAuth"`
|
||||
@@ -43,10 +44,12 @@ type Settings struct {
|
||||
SpotifyAuth AuthProviderConfig `form:"spotifyAuth" json:"spotifyAuth"`
|
||||
KakaoAuth AuthProviderConfig `form:"kakaoAuth" json:"kakaoAuth"`
|
||||
TwitchAuth AuthProviderConfig `form:"twitchAuth" json:"twitchAuth"`
|
||||
StravaAuth AuthProviderConfig `form:"stravaAuth" json:"stravaAuth"`
|
||||
GiteeAuth AuthProviderConfig `form:"giteeAuth" json:"giteeAuth"`
|
||||
}
|
||||
|
||||
// NewSettings creates and returns a new default Settings instance.
|
||||
func NewSettings() *Settings {
|
||||
// New creates and returns a new default Settings instance.
|
||||
func New() *Settings {
|
||||
return &Settings{
|
||||
Meta: MetaConfig{
|
||||
AppName: "Acme",
|
||||
@@ -123,6 +126,12 @@ func NewSettings() *Settings {
|
||||
TwitchAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
StravaAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
GiteeAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +161,8 @@ func (s *Settings) Validate() error {
|
||||
validation.Field(&s.SpotifyAuth),
|
||||
validation.Field(&s.KakaoAuth),
|
||||
validation.Field(&s.TwitchAuth),
|
||||
validation.Field(&s.StravaAuth),
|
||||
validation.Field(&s.GiteeAuth),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -170,11 +181,11 @@ func (s *Settings) Merge(other *Settings) error {
|
||||
|
||||
// Clone creates a new deep copy of the current settings.
|
||||
func (s *Settings) Clone() (*Settings, error) {
|
||||
settings := &Settings{}
|
||||
if err := settings.Merge(s); err != nil {
|
||||
clone := &Settings{}
|
||||
if err := clone.Merge(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return settings, nil
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
// RedactClone creates a new deep copy of the current settings,
|
||||
@@ -206,6 +217,8 @@ func (s *Settings) RedactClone() (*Settings, error) {
|
||||
&clone.SpotifyAuth.ClientSecret,
|
||||
&clone.KakaoAuth.ClientSecret,
|
||||
&clone.TwitchAuth.ClientSecret,
|
||||
&clone.StravaAuth.ClientSecret,
|
||||
&clone.GiteeAuth.ClientSecret,
|
||||
}
|
||||
|
||||
// mask all sensitive fields
|
||||
@@ -235,6 +248,8 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
|
||||
auth.NameSpotify: s.SpotifyAuth,
|
||||
auth.NameKakao: s.KakaoAuth,
|
||||
auth.NameTwitch: s.TwitchAuth,
|
||||
auth.NameStrava: s.StravaAuth,
|
||||
auth.NameGitee: s.GiteeAuth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +277,9 @@ type SmtpConfig struct {
|
||||
Username string `form:"username" json:"username"`
|
||||
Password string `form:"password" json:"password"`
|
||||
|
||||
// SMTP AUTH - PLAIN (default) or LOGIN
|
||||
AuthMethod string `form:"authMethod" json:"authMethod"`
|
||||
|
||||
// Whether to enforce TLS encryption for the mail server connection.
|
||||
//
|
||||
// When set to false StartTLS command is send, leaving the server
|
||||
@@ -272,8 +290,23 @@ type SmtpConfig struct {
|
||||
// Validate makes SmtpConfig validatable by implementing [validation.Validatable] interface.
|
||||
func (c SmtpConfig) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(&c.Host, is.Host, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.Port, validation.When(c.Enabled, validation.Required), validation.Min(0)),
|
||||
validation.Field(
|
||||
&c.Host,
|
||||
validation.When(c.Enabled, validation.Required),
|
||||
is.Host,
|
||||
),
|
||||
validation.Field(
|
||||
&c.Port,
|
||||
validation.When(c.Enabled, validation.Required),
|
||||
validation.Min(0),
|
||||
),
|
||||
validation.Field(
|
||||
&c.AuthMethod,
|
||||
// don't require it for backward compatibility
|
||||
// (fallback internally to PLAIN)
|
||||
// validation.When(c.Enabled, validation.Required),
|
||||
validation.In(mailer.SmtpAuthLogin, mailer.SmtpAuthPlain),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -476,7 +509,7 @@ func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Deprecated: Will be removed in v0.9!
|
||||
// Deprecated: Will be removed in v0.9+
|
||||
type EmailAuthConfig struct {
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
|
||||
@@ -484,7 +517,7 @@ type EmailAuthConfig struct {
|
||||
MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in v0.9!
|
||||
// Deprecated: Will be removed in v0.9+
|
||||
func (c EmailAuthConfig) Validate() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package settings
|
||||
|
||||
// Common settings placeholder tokens
|
||||
const (
|
||||
@@ -1,4 +1,4 @@
|
||||
package core_test
|
||||
package settings_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -7,12 +7,13 @@ import (
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
)
|
||||
|
||||
func TestSettingsValidate(t *testing.T) {
|
||||
s := core.NewSettings()
|
||||
s := settings.New()
|
||||
|
||||
// set invalid settings data
|
||||
s.Meta.AppName = ""
|
||||
@@ -47,6 +48,10 @@ func TestSettingsValidate(t *testing.T) {
|
||||
s.KakaoAuth.ClientId = ""
|
||||
s.TwitchAuth.Enabled = true
|
||||
s.TwitchAuth.ClientId = ""
|
||||
s.StravaAuth.Enabled = true
|
||||
s.StravaAuth.ClientId = ""
|
||||
s.GiteeAuth.Enabled = true
|
||||
s.GiteeAuth.ClientId = ""
|
||||
|
||||
// check if Validate() is triggering the members validate methods.
|
||||
err := s.Validate()
|
||||
@@ -75,6 +80,8 @@ func TestSettingsValidate(t *testing.T) {
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
}
|
||||
|
||||
errBytes, _ := json.Marshal(err)
|
||||
@@ -87,10 +94,10 @@ func TestSettingsValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsMerge(t *testing.T) {
|
||||
s1 := core.NewSettings()
|
||||
s1 := settings.New()
|
||||
s1.Meta.AppUrl = "old_app_url"
|
||||
|
||||
s2 := core.NewSettings()
|
||||
s2 := settings.New()
|
||||
s2.Meta.AppName = "test"
|
||||
s2.Logs.MaxDays = 123
|
||||
s2.Smtp.Host = "test"
|
||||
@@ -123,6 +130,10 @@ func TestSettingsMerge(t *testing.T) {
|
||||
s2.KakaoAuth.ClientId = "kakao_test"
|
||||
s2.TwitchAuth.Enabled = true
|
||||
s2.TwitchAuth.ClientId = "twitch_test"
|
||||
s2.StravaAuth.Enabled = true
|
||||
s2.StravaAuth.ClientId = "strava_test"
|
||||
s2.GiteeAuth.Enabled = true
|
||||
s2.GiteeAuth.ClientId = "gitee_test"
|
||||
|
||||
if err := s1.Merge(s2); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -144,7 +155,7 @@ func TestSettingsMerge(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsClone(t *testing.T) {
|
||||
s1 := core.NewSettings()
|
||||
s1 := settings.New()
|
||||
|
||||
s2, err := s1.Clone()
|
||||
if err != nil {
|
||||
@@ -173,7 +184,7 @@ func TestSettingsClone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsRedactClone(t *testing.T) {
|
||||
s1 := core.NewSettings()
|
||||
s1 := settings.New()
|
||||
s1.Meta.AppName = "test123" // control field
|
||||
s1.Smtp.Password = "test123"
|
||||
s1.Smtp.Tls = true
|
||||
@@ -194,6 +205,8 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
s1.SpotifyAuth.ClientSecret = "test123"
|
||||
s1.KakaoAuth.ClientSecret = "test123"
|
||||
s1.TwitchAuth.ClientSecret = "test123"
|
||||
s1.StravaAuth.ClientSecret = "test123"
|
||||
s1.GiteeAuth.ClientSecret = "test123"
|
||||
|
||||
s2, err := s1.RedactClone()
|
||||
if err != nil {
|
||||
@@ -205,7 +218,7 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"},"microsoftAuth":{"enabled":false,"clientSecret":"******"},"spotifyAuth":{"enabled":false,"clientSecret":"******"},"kakaoAuth":{"enabled":false,"clientSecret":"******"},"twitchAuth":{"enabled":false,"clientSecret":"******"}}`
|
||||
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","authMethod":"","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"},"microsoftAuth":{"enabled":false,"clientSecret":"******"},"spotifyAuth":{"enabled":false,"clientSecret":"******"},"kakaoAuth":{"enabled":false,"clientSecret":"******"},"twitchAuth":{"enabled":false,"clientSecret":"******"},"stravaAuth":{"enabled":false,"clientSecret":"******"},"giteeAuth":{"enabled":false,"clientSecret":"******"}}`
|
||||
|
||||
if encodedStr := string(encoded); encodedStr != expected {
|
||||
t.Fatalf("Expected\n%v\ngot\n%v", expected, encodedStr)
|
||||
@@ -213,7 +226,7 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
s := core.NewSettings()
|
||||
s := settings.New()
|
||||
|
||||
s.GoogleAuth.ClientId = "google_test"
|
||||
s.FacebookAuth.ClientId = "facebook_test"
|
||||
@@ -226,6 +239,8 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
s.SpotifyAuth.ClientId = "spotify_test"
|
||||
s.KakaoAuth.ClientId = "kakao_test"
|
||||
s.TwitchAuth.ClientId = "twitch_test"
|
||||
s.StravaAuth.ClientId = "strava_test"
|
||||
s.GiteeAuth.ClientId = "gitee_test"
|
||||
|
||||
result := s.NamedAuthProviderConfigs()
|
||||
|
||||
@@ -246,6 +261,8 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
`"twitter":{"enabled":false,"clientId":"twitter_test"}`,
|
||||
`"kakao":{"enabled":false,"clientId":"kakao_test"}`,
|
||||
`"twitch":{"enabled":false,"clientId":"twitch_test"}`,
|
||||
`"strava":{"enabled":false,"clientId":"strava_test"}`,
|
||||
`"gitee":{"enabled":false,"clientId":"gitee_test"}`,
|
||||
}
|
||||
for _, p := range expectedParts {
|
||||
if !strings.Contains(encodedStr, p) {
|
||||
@@ -256,17 +273,17 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
|
||||
func TestTokenConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.TokenConfig
|
||||
config settings.TokenConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values
|
||||
{
|
||||
core.TokenConfig{},
|
||||
settings.TokenConfig{},
|
||||
true,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.TokenConfig{
|
||||
settings.TokenConfig{
|
||||
Secret: strings.Repeat("a", 5),
|
||||
Duration: 4,
|
||||
},
|
||||
@@ -274,7 +291,7 @@ func TestTokenConfigValidate(t *testing.T) {
|
||||
},
|
||||
// valid secret but invalid duration
|
||||
{
|
||||
core.TokenConfig{
|
||||
settings.TokenConfig{
|
||||
Secret: strings.Repeat("a", 30),
|
||||
Duration: 63072000 + 1,
|
||||
},
|
||||
@@ -282,7 +299,7 @@ func TestTokenConfigValidate(t *testing.T) {
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
core.TokenConfig{
|
||||
settings.TokenConfig{
|
||||
Secret: strings.Repeat("a", 30),
|
||||
Duration: 100,
|
||||
},
|
||||
@@ -305,31 +322,41 @@ func TestTokenConfigValidate(t *testing.T) {
|
||||
|
||||
func TestSmtpConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.SmtpConfig
|
||||
config settings.SmtpConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values (disabled)
|
||||
{
|
||||
core.SmtpConfig{},
|
||||
settings.SmtpConfig{},
|
||||
false,
|
||||
},
|
||||
// zero values (enabled)
|
||||
{
|
||||
core.SmtpConfig{Enabled: true},
|
||||
settings.SmtpConfig{Enabled: true},
|
||||
true,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.SmtpConfig{
|
||||
settings.SmtpConfig{
|
||||
Enabled: true,
|
||||
Host: "test:test:test",
|
||||
Port: -10,
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data
|
||||
// invalid auth method
|
||||
{
|
||||
core.SmtpConfig{
|
||||
settings.SmtpConfig{
|
||||
Enabled: true,
|
||||
Host: "example.com",
|
||||
Port: 100,
|
||||
AuthMethod: "example",
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data (no explicit auth method)
|
||||
{
|
||||
settings.SmtpConfig{
|
||||
Enabled: true,
|
||||
Host: "example.com",
|
||||
Port: 100,
|
||||
@@ -337,6 +364,16 @@ func TestSmtpConfigValidate(t *testing.T) {
|
||||
},
|
||||
false,
|
||||
},
|
||||
// valid data (explicit auth method - login)
|
||||
{
|
||||
settings.SmtpConfig{
|
||||
Enabled: true,
|
||||
Host: "example.com",
|
||||
Port: 100,
|
||||
AuthMethod: mailer.SmtpAuthLogin,
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -354,22 +391,22 @@ func TestSmtpConfigValidate(t *testing.T) {
|
||||
|
||||
func TestS3ConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.S3Config
|
||||
config settings.S3Config
|
||||
expectError bool
|
||||
}{
|
||||
// zero values (disabled)
|
||||
{
|
||||
core.S3Config{},
|
||||
settings.S3Config{},
|
||||
false,
|
||||
},
|
||||
// zero values (enabled)
|
||||
{
|
||||
core.S3Config{Enabled: true},
|
||||
settings.S3Config{Enabled: true},
|
||||
true,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.S3Config{
|
||||
settings.S3Config{
|
||||
Enabled: true,
|
||||
Endpoint: "test:test:test",
|
||||
},
|
||||
@@ -377,7 +414,7 @@ func TestS3ConfigValidate(t *testing.T) {
|
||||
},
|
||||
// valid data (url endpoint)
|
||||
{
|
||||
core.S3Config{
|
||||
settings.S3Config{
|
||||
Enabled: true,
|
||||
Endpoint: "https://localhost:8090",
|
||||
Bucket: "test",
|
||||
@@ -389,7 +426,7 @@ func TestS3ConfigValidate(t *testing.T) {
|
||||
},
|
||||
// valid data (hostname endpoint)
|
||||
{
|
||||
core.S3Config{
|
||||
settings.S3Config{
|
||||
Enabled: true,
|
||||
Endpoint: "example.com",
|
||||
Bucket: "test",
|
||||
@@ -415,36 +452,36 @@ func TestS3ConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMetaConfigValidate(t *testing.T) {
|
||||
invalidTemplate := core.EmailTemplate{
|
||||
invalidTemplate := settings.EmailTemplate{
|
||||
Subject: "test",
|
||||
ActionUrl: "test",
|
||||
Body: "test",
|
||||
}
|
||||
|
||||
noPlaceholdersTemplate := core.EmailTemplate{
|
||||
noPlaceholdersTemplate := settings.EmailTemplate{
|
||||
Subject: "test",
|
||||
ActionUrl: "http://example.com",
|
||||
Body: "test",
|
||||
}
|
||||
|
||||
withPlaceholdersTemplate := core.EmailTemplate{
|
||||
withPlaceholdersTemplate := settings.EmailTemplate{
|
||||
Subject: "test",
|
||||
ActionUrl: "http://example.com" + core.EmailPlaceholderToken,
|
||||
Body: "test" + core.EmailPlaceholderActionUrl,
|
||||
ActionUrl: "http://example.com" + settings.EmailPlaceholderToken,
|
||||
Body: "test" + settings.EmailPlaceholderActionUrl,
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
config core.MetaConfig
|
||||
config settings.MetaConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values
|
||||
{
|
||||
core.MetaConfig{},
|
||||
settings.MetaConfig{},
|
||||
true,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.MetaConfig{
|
||||
settings.MetaConfig{
|
||||
AppName: strings.Repeat("a", 300),
|
||||
AppUrl: "test",
|
||||
SenderName: strings.Repeat("a", 300),
|
||||
@@ -457,7 +494,7 @@ func TestMetaConfigValidate(t *testing.T) {
|
||||
},
|
||||
// invalid data (missing required placeholders)
|
||||
{
|
||||
core.MetaConfig{
|
||||
settings.MetaConfig{
|
||||
AppName: "test",
|
||||
AppUrl: "https://example.com",
|
||||
SenderName: "test",
|
||||
@@ -470,7 +507,7 @@ func TestMetaConfigValidate(t *testing.T) {
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
core.MetaConfig{
|
||||
settings.MetaConfig{
|
||||
AppName: "test",
|
||||
AppUrl: "https://example.com",
|
||||
SenderName: "test",
|
||||
@@ -498,17 +535,17 @@ func TestMetaConfigValidate(t *testing.T) {
|
||||
|
||||
func TestEmailTemplateValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
emailTemplate core.EmailTemplate
|
||||
emailTemplate settings.EmailTemplate
|
||||
expectedErrors []string
|
||||
}{
|
||||
// require values
|
||||
{
|
||||
core.EmailTemplate{},
|
||||
settings.EmailTemplate{},
|
||||
[]string{"subject", "actionUrl", "body"},
|
||||
},
|
||||
// missing placeholders
|
||||
{
|
||||
core.EmailTemplate{
|
||||
settings.EmailTemplate{
|
||||
Subject: "test",
|
||||
ActionUrl: "test",
|
||||
Body: "test",
|
||||
@@ -517,10 +554,10 @@ func TestEmailTemplateValidate(t *testing.T) {
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
core.EmailTemplate{
|
||||
settings.EmailTemplate{
|
||||
Subject: "test",
|
||||
ActionUrl: "test" + core.EmailPlaceholderToken,
|
||||
Body: "test" + core.EmailPlaceholderActionUrl,
|
||||
ActionUrl: "test" + settings.EmailPlaceholderToken,
|
||||
Body: "test" + settings.EmailPlaceholderActionUrl,
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
@@ -549,17 +586,17 @@ func TestEmailTemplateValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEmailTemplateResolve(t *testing.T) {
|
||||
allPlaceholders := core.EmailPlaceholderActionUrl + core.EmailPlaceholderToken + core.EmailPlaceholderAppName + core.EmailPlaceholderAppUrl
|
||||
allPlaceholders := settings.EmailPlaceholderActionUrl + settings.EmailPlaceholderToken + settings.EmailPlaceholderAppName + settings.EmailPlaceholderAppUrl
|
||||
|
||||
scenarios := []struct {
|
||||
emailTemplate core.EmailTemplate
|
||||
emailTemplate settings.EmailTemplate
|
||||
expectedSubject string
|
||||
expectedBody string
|
||||
expectedActionUrl string
|
||||
}{
|
||||
// no placeholders
|
||||
{
|
||||
emailTemplate: core.EmailTemplate{
|
||||
emailTemplate: settings.EmailTemplate{
|
||||
Subject: "subject:",
|
||||
Body: "body:",
|
||||
ActionUrl: "/actionUrl////",
|
||||
@@ -570,7 +607,7 @@ func TestEmailTemplateResolve(t *testing.T) {
|
||||
},
|
||||
// with placeholders
|
||||
{
|
||||
emailTemplate: core.EmailTemplate{
|
||||
emailTemplate: settings.EmailTemplate{
|
||||
ActionUrl: "/actionUrl////" + allPlaceholders,
|
||||
Subject: "subject:" + allPlaceholders,
|
||||
Body: "body:" + allPlaceholders,
|
||||
@@ -583,8 +620,8 @@ func TestEmailTemplateResolve(t *testing.T) {
|
||||
),
|
||||
expectedSubject: fmt.Sprintf(
|
||||
"subject:%s%s%s%s",
|
||||
core.EmailPlaceholderActionUrl,
|
||||
core.EmailPlaceholderToken,
|
||||
settings.EmailPlaceholderActionUrl,
|
||||
settings.EmailPlaceholderToken,
|
||||
"name_test",
|
||||
"url_test",
|
||||
),
|
||||
@@ -622,22 +659,22 @@ func TestEmailTemplateResolve(t *testing.T) {
|
||||
|
||||
func TestLogsConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.LogsConfig
|
||||
config settings.LogsConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values
|
||||
{
|
||||
core.LogsConfig{},
|
||||
settings.LogsConfig{},
|
||||
false,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.LogsConfig{MaxDays: -10},
|
||||
settings.LogsConfig{MaxDays: -10},
|
||||
true,
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
core.LogsConfig{MaxDays: 1},
|
||||
settings.LogsConfig{MaxDays: 1},
|
||||
false,
|
||||
},
|
||||
}
|
||||
@@ -657,22 +694,22 @@ func TestLogsConfigValidate(t *testing.T) {
|
||||
|
||||
func TestAuthProviderConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.AuthProviderConfig
|
||||
config settings.AuthProviderConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values (disabled)
|
||||
{
|
||||
core.AuthProviderConfig{},
|
||||
settings.AuthProviderConfig{},
|
||||
false,
|
||||
},
|
||||
// zero values (enabled)
|
||||
{
|
||||
core.AuthProviderConfig{Enabled: true},
|
||||
settings.AuthProviderConfig{Enabled: true},
|
||||
true,
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
core.AuthProviderConfig{
|
||||
settings.AuthProviderConfig{
|
||||
Enabled: true,
|
||||
ClientId: "",
|
||||
ClientSecret: "",
|
||||
@@ -684,7 +721,7 @@ func TestAuthProviderConfigValidate(t *testing.T) {
|
||||
},
|
||||
// valid data (only the required)
|
||||
{
|
||||
core.AuthProviderConfig{
|
||||
settings.AuthProviderConfig{
|
||||
Enabled: true,
|
||||
ClientId: "test",
|
||||
ClientSecret: "test",
|
||||
@@ -693,7 +730,7 @@ func TestAuthProviderConfigValidate(t *testing.T) {
|
||||
},
|
||||
// valid data (fill all fields)
|
||||
{
|
||||
core.AuthProviderConfig{
|
||||
settings.AuthProviderConfig{
|
||||
Enabled: true,
|
||||
ClientId: "test",
|
||||
ClientSecret: "test",
|
||||
@@ -722,12 +759,12 @@ func TestAuthProviderConfigSetupProvider(t *testing.T) {
|
||||
provider := auth.NewGithubProvider()
|
||||
|
||||
// disabled config
|
||||
c1 := core.AuthProviderConfig{Enabled: false}
|
||||
c1 := settings.AuthProviderConfig{Enabled: false}
|
||||
if err := c1.SetupProvider(provider); err == nil {
|
||||
t.Errorf("Expected error, got nil")
|
||||
}
|
||||
|
||||
c2 := core.AuthProviderConfig{
|
||||
c2 := settings.AuthProviderConfig{
|
||||
Enabled: true,
|
||||
ClientId: "test_ClientId",
|
||||
ClientSecret: "test_ClientSecret",
|
||||
@@ -0,0 +1,113 @@
|
||||
package jsvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja_nodejs/console"
|
||||
"github.com/dop251/goja_nodejs/require"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// MigrationsOptions defines optional struct to customize the default migrations loader behavior.
|
||||
type MigrationsOptions struct {
|
||||
// Dir specifies the directory with the JS migrations.
|
||||
//
|
||||
// If not set it fallbacks to a relative "pb_data/../pb_migrations" directory.
|
||||
Dir string
|
||||
}
|
||||
|
||||
// migrations is the migrations loader plugin definition.
|
||||
// Usually it is instantiated via RegisterMigrations or MustRegisterMigrations.
|
||||
type migrations struct {
|
||||
app core.App
|
||||
options *MigrationsOptions
|
||||
}
|
||||
|
||||
//
|
||||
// MustRegisterMigrations registers the migrations loader plugin to
|
||||
// the provided app instance and panics if it fails.
|
||||
//
|
||||
// Internally it calls RegisterMigrations(app, options).
|
||||
//
|
||||
// If options is nil, by default the js files from pb_data/migrations are loaded.
|
||||
// Set custom options.Dir if you want to change it to some other directory.
|
||||
func MustRegisterMigrations(app core.App, options *MigrationsOptions) {
|
||||
if err := RegisterMigrations(app, options); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterMigrations registers the plugin to the provided app instance.
|
||||
//
|
||||
// If options is nil, by default the js files from pb_data/migrations are loaded.
|
||||
// Set custom options.Dir if you want to change it to some other directory.
|
||||
func RegisterMigrations(app core.App, options *MigrationsOptions) error {
|
||||
l := &migrations{app: app}
|
||||
|
||||
if options != nil {
|
||||
l.options = options
|
||||
} else {
|
||||
l.options = &MigrationsOptions{}
|
||||
}
|
||||
|
||||
if l.options.Dir == "" {
|
||||
l.options.Dir = filepath.Join(app.DataDir(), "../pb_migrations")
|
||||
}
|
||||
|
||||
files, err := readDirFiles(l.options.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry := new(require.Registry) // this can be shared by multiple runtimes
|
||||
|
||||
for file, content := range files {
|
||||
vm := NewBaseVM()
|
||||
registry.Enable(vm)
|
||||
console.Enable(vm)
|
||||
|
||||
vm.Set("migrate", func(up, down func(db dbx.Builder) error) {
|
||||
m.AppMigrations.Register(up, down, file)
|
||||
})
|
||||
|
||||
_, err := vm.RunString(string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run migration %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readDirFiles returns a map with all directory files and their content.
|
||||
//
|
||||
// If directory with dirPath is missing, it returns an empty map and no error.
|
||||
func readDirFiles(dirPath string) (map[string][]byte, error) {
|
||||
files, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string][]byte{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := map[string][]byte{}
|
||||
|
||||
for _, f := range files {
|
||||
if f.IsDir() || !strings.HasSuffix(f.Name(), ".js") {
|
||||
continue // not a .js file
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dirPath, f.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[f.Name()] = raw
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user