mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3d2608d03 | ||
|
|
650a4255cc | ||
|
|
de70af2584 | ||
|
|
ba7ed78b73 | ||
|
|
cea149cb6e | ||
|
|
70d8d1ee9d | ||
|
|
29c2e209f4 | ||
|
|
ef465957ff | ||
|
|
ba8b51af58 | ||
|
|
93e3eb3a35 | ||
|
|
cf77c0e7eb | ||
|
|
d4578c3b8c | ||
|
|
c2f3fe6a52 | ||
|
|
bb18799a0b | ||
|
|
4a40c1b897 | ||
|
|
e9d4b1fe77 | ||
|
|
9cefd0128c | ||
|
|
d695e9d180 | ||
|
|
faf96896e7 | ||
|
|
72364bf4e0 | ||
|
|
969d4e2bfb | ||
|
|
430d892e64 | ||
|
|
fb8af7a8cf | ||
|
|
6ecb412849 | ||
|
|
bc7080337e | ||
|
|
c157331721 | ||
|
|
27ab2d45e8 | ||
|
|
d5d8decf6e | ||
|
|
eb28f898d0 | ||
|
|
ab0edb80df | ||
|
|
233dd2ac67 | ||
|
|
21d58d389f | ||
|
|
7f21e31145 | ||
|
|
19ad2f3b04 | ||
|
|
f6675702ea | ||
|
|
3b9f2141fe | ||
|
|
2b39757fbc | ||
|
|
15091999e3 | ||
|
|
fcc680794c | ||
|
|
bc72525013 | ||
|
|
23ca5a77e1 | ||
|
|
266b56ecbb | ||
|
|
ac8a4583cb | ||
|
|
1d72c8487e | ||
|
|
97eb9b300b | ||
|
|
5b2cae8509 | ||
|
|
5715e11e52 | ||
|
|
90e9fa705d | ||
|
|
d4101be9f6 | ||
|
|
6cec679f02 | ||
|
|
0e0b862bd7 | ||
|
|
b2bf26122f | ||
|
|
adc5c3cf18 | ||
|
|
4a1e3aed6e | ||
|
|
55e24344ff | ||
|
|
17086722f4 | ||
|
|
9b036fb10f | ||
|
|
8e3f0f2e32 | ||
|
|
65750bca8d | ||
|
|
adb991eb02 | ||
|
|
d87a887673 | ||
|
|
f2eb295fa2 | ||
|
|
d11a9f9d99 | ||
|
|
bbd7f4e4ae | ||
|
|
b0a5bce4c4 | ||
|
|
9234cbf0d1 | ||
|
|
6bf5eccfa7 | ||
|
|
8bbd0c2d77 | ||
|
|
2af60a85c5 | ||
|
|
56e53e885c | ||
|
|
3115f08d4a | ||
|
|
0b9a646d41 | ||
|
|
5f8cce3558 | ||
|
|
3da2c00f32 | ||
|
|
352ee72740 | ||
|
|
98510103c3 | ||
|
|
25c044a3a2 | ||
|
|
0536c779d5 | ||
|
|
b1da83e516 | ||
|
|
6b5d7b216b | ||
|
|
26d6a98355 | ||
|
|
4abc018d7c | ||
|
|
f0a9cbf31b | ||
|
|
d08da7594a | ||
|
|
c2d6530065 | ||
|
|
94c4d4ec65 | ||
|
|
be7ec34516 | ||
|
|
27cb36ffd7 | ||
|
|
e7af58efac | ||
|
|
ab51b3c038 | ||
|
|
5773f46fea | ||
|
|
4d5b7cc1d0 | ||
|
|
7286f34104 | ||
|
|
4399a6c1ab | ||
|
|
e89603497f | ||
|
|
abb6bcd6de | ||
|
|
5604fe672e | ||
|
|
7825baab13 | ||
|
|
68b9d0e403 | ||
|
|
85232ed6e4 | ||
|
|
d76d4089cf | ||
|
|
2e5f8bff63 | ||
|
|
90d896e1cc | ||
|
|
c13d83adb1 | ||
|
|
9b73295a7c | ||
|
|
3c6ce2de74 | ||
|
|
63b89533a9 | ||
|
|
779059eca3 | ||
|
|
9abdadf0dc | ||
|
|
6500b8c518 | ||
|
|
91f1ca273d | ||
|
|
6e739fd33d | ||
|
|
2525f29c1c | ||
|
|
1dc5e061b8 | ||
|
|
09d7f6a7c3 | ||
|
|
1775585b68 | ||
|
|
f4e6c5edee | ||
|
|
94b11bf2c3 | ||
|
|
ddb8c88a37 | ||
|
|
0f5411d81c | ||
|
|
423d234da1 | ||
|
|
6184b31d82 | ||
|
|
63a9d045a1 | ||
|
|
501ab0e6be | ||
|
|
7ad08ef6bf | ||
|
|
6210f361b0 | ||
|
|
fcb5b5dd67 | ||
|
|
41607679a0 | ||
|
|
ca7e5b7f7b | ||
|
|
593721dcea | ||
|
|
153ad12e64 | ||
|
|
482fe2bce0 | ||
|
|
48489b6a07 | ||
|
|
67ee431585 | ||
|
|
d5dcd01551 | ||
|
|
749bf7815c | ||
|
|
ceae5e005f | ||
|
|
7b6b71e18d | ||
|
|
885d907beb | ||
|
|
afb942bc41 | ||
|
|
83a26d436e | ||
|
|
7b52d0b56a | ||
|
|
5a8eae7089 | ||
|
|
0bd712752f | ||
|
|
0b6157e1cc | ||
|
|
c8980edf85 | ||
|
|
a7ebb98e20 | ||
|
|
69be986132 | ||
|
|
58da159641 | ||
|
|
8acb48b884 | ||
|
|
91b521595a | ||
|
|
52a53b5b91 | ||
|
|
1137a35ded | ||
|
|
280005e35c | ||
|
|
6656d9820a | ||
|
|
0d8b426b0c | ||
|
|
acd12ce9dd | ||
|
|
47d3da28d5 | ||
|
|
6f8524961f | ||
|
|
fda6ad8d5d | ||
|
|
a8321498fd | ||
|
|
ca4902a808 | ||
|
|
348ccfc580 | ||
|
|
77c05dbd2a | ||
|
|
44289a93a2 | ||
|
|
6b6d3b36d3 | ||
|
|
e26905f8e2 | ||
|
|
6ad42bde29 | ||
|
|
54fb4293c5 | ||
|
|
a8dd8be524 | ||
|
|
76a6b9834b | ||
|
|
68ab174f69 |
+86
-2
@@ -1,5 +1,89 @@
|
||||
# Security
|
||||
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
|
||||
**Keep in mind that PocketBase is a non-commercial open source project, maintained entirely on volunteer basis (there is no company or dedicated team behind it), and there are no bounties!**
|
||||
|
||||
All reports will be promptly addressed and you'll be credited in the fix release notes.
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io** or submit a private [GitHub Security advisory](https://github.com/pocketbase/pocketbase/security/advisories).
|
||||
|
||||
I try to be as responsive as possible and usually address security reports within a day or two, but if you didn't receive a reply from me for more than 5 days it is very likely that your email was flagged and in that case please open a GitHub issue or discussion just mentioning that you found a vulnerability and want to report it so that I can see the notification and will try to contact you for more details.
|
||||
|
||||
In case the vulnerability is confirmed, within another couple days I'll try to submit a fix, GitHub security advisory and CVE with remediation steps and **minimal details** regarding the found exploit to minimize giving too much hints to malicious actors (you'll be credited both in the fix release notes and in the public report).
|
||||
|
||||
### Please:
|
||||
|
||||
- DO NOT use LLM as part of your report or email communication - it is extremely frustrating to spend an hour or more reading a wall of generated text, writing an elaborate reply and in the end to just receive another generic LLM prompt response in return.
|
||||
|
||||
- DO NOT reserve and publish MITRE CVE number on your own _(I prefer to do it through the GitHub Security advisory)_ and try to communicate first privately the details to better understand how the code is being used and whether the supposed vulnerability can be actually exploited in any real practical scenarios. Otherwise you are risking needlessly causing scaremongering and annoyance for users that rely on security scanners as part of their CI/CD pipeline.
|
||||
|
||||
- Wait before publicly disclosing and sharing details about the found vulnerability, **ideally at least 5 days after the fix**, to make it harder to exploit and give enough time for users to patch their instances _(you are free to provide a PoC and as much details as you want in your own blog/gist/etc.)_.
|
||||
|
||||
### Below is a list of common vulnerabilities that were previously reported but are NOT considered a security issue:
|
||||
|
||||
<details>
|
||||
<summary><strong>Stored XSS</strong></summary>
|
||||
|
||||
This was discussed several times, both privately and [publicly](https://github.com/pocketbase/pocketbase/discussions/6694), but I remain on the opinion that it should be handled primarily on the client-side.
|
||||
|
||||
Modern browsers recently introduced a basic [`Sanitizer` interface](https://developer.mozilla.org/en-US/docs/Web/API/Sanitizer) that could help filtering HTML strings without external libraries.
|
||||
|
||||
Having also a default [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) either as meta tag or response header is always a good idea to minimize the risk of XSS.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>SQL injection in low level DB methods like <code>app.DeleteTable(dangerousName)</code></strong></summary>
|
||||
|
||||
This is working correctly and it is not an issue but it is a common report most likely found by LLM or some other automated tools that may have stumbled on the [NB! code comments](https://pkg.go.dev/github.com/pocketbase/pocketbase@master/core#BaseApp.DeleteTable).
|
||||
|
||||
Raw SQL statements, table and column names are not parameterized and they are vulnerable to SQL injection if used with untrusted input. The documentation as seen above already warns against it. In recent PocketBase releases, many of the arguments of these methods were also prefixed with `dangerous*` to make it even more clear that they should be used with caution.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Race conditions</strong></summary>
|
||||
|
||||
To avoid DB locks PocketBase deliberately tries to minimize the use of DB transactions.
|
||||
This means that operations like record update don't wrap out of the box for example the `SELECT` and `UPDATE` SQL statements in a single transaction, and this can technically lead to a race condition if multiple users edit the same record.
|
||||
|
||||
This is an accepted tradeoff and for the majority of cases it has no security implications.
|
||||
|
||||
This also apply for the read and delete of MFA and OTP records but for those cases, since they operate in a security sensitive context, they have an extra short-lived duration that is configurable from the collection settings _(there are also system cron jobs that takes care for deleting forgotten/expired entries to prevent accumulation of invalid records)_.
|
||||
|
||||
For the cases where transactions are really needed, users can utilize the [Batch Web API](https://pocketbase.io/docs/api-records/#batch-createupdateupsertdelete-records) or [create a transaction programmatically](https://pocketbase.io/docs/go-records/#transaction) _(with PocketBase v0.23+ it is also possible to wrap an entire hook chain in a single transaction)_.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>List/Search side-channel attacks</strong></summary>
|
||||
|
||||
Over the years we've implemented several extra checks to minimize the risk of List/Search side-channel attacks (see especially [v0.32.0](https://github.com/pocketbase/pocketbase/blob/master/CHANGELOG.md#v0320)) but users need to be aware that all client-side filtered fields are technically subject to timing attacks _(whether they are practical or not is a different topic)_.
|
||||
|
||||
This is by design and it is accepted tradeoff between performance, security and usability.
|
||||
|
||||
If you are concerned about timing attacks and have security sensitive collection data such as `secret`, `code`, `token`, etc. then the general recommendation is to mark their related fields as "Hidden" in order to disallow use in client-side filters.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Connecting to a vulnerable OAuth2 provider</strong></summary>
|
||||
|
||||
Because PocketBase v0.23+ supports automatically uploading the OAuth2 avatar on user create _(need to be specified from the auth collection OAuth2 fields mapping)_ some security researchers raised a concern regarding a Blind SSRF but this implies that an attacker controls the OAuth2 vendor and this is a very serious assumption in the first place.
|
||||
|
||||
The entire OAuth2 flow relies that the application server (PocketBase) trusts the configured OAuth2 vendor.
|
||||
If you suspect that an OAuth2 vendor is malicious and cannot be trusted then you MUST NOT use that OAuth2 vendor at all and you should report it.
|
||||
|
||||
If someone is able to tamper with the OAuth2 responses then the entire OAuth2 flow can be thrown out of the window because they will be practically able to authenticate as any of your existing users and the eventual avatar url probing request is the least of your problem.
|
||||
|
||||
_Nonetheless, in future PocketBase releases there will be [extra `localhost` domain like checks](https://github.com/orgs/pocketbase/projects/2/views/1?pane=issue&itemId=159545722) when assigning the OAuth2 avatar url to a `file` field that will further minimize the risk of internal network probing requests in case of a vulnerable OAuth2 provider._
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong><code>disintegration/imaging</code> CVE-2023-36308</strong></summary>
|
||||
|
||||
Just for the past month, due to some corporate security scanners 5 different people raised concerns over [CVE-2023-36308](https://nvd.nist.gov/vuln/detail/CVE-2023-36308) but this is not really a vulnerability, especially not in PocketBase.
|
||||
|
||||
[`disintegration/imaging`](https://github.com/disintegration/imaging) is a direct PocketBase dependency responsible for the thumbs generation.
|
||||
|
||||
First, a panic (similar to exception in other languages) is NOT a security issue and Go programs usually have to be written defensively with that in mind. In PocketBase specifically all routes have auto panic-recover handling, no matter what the source of the panic is, so the worst case scenario would be an HTTP error response when attempting to access the thumb.
|
||||
|
||||
Second, the related issue that the CVE describes is probably caused by a bug in an outdated `golang.org/x/image` dependency listed in the `go.mod` of that package but PocketBase uses a newer patched version of it that is expected to take precedence.
|
||||
|
||||
Third, even if that issue is still available, with PocketBase it would have been triggerable ONLY if we supported TIFF thumbs generation but we don't. The supported thumbs formats at the moment are JPG, PNG, GIF (its first frame) and partially WebP (stored as PNG). All other images are served as it is, without any transformation.
|
||||
|
||||
In the future I may consider eventually replacing the library because it is no longer actively maintained but as of now it is working correctly and as expected for our use case and you can safely flag the security warning as false-positive.
|
||||
</details>
|
||||
|
||||
@@ -26,9 +26,9 @@ jobs:
|
||||
node-version: 20.17.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '>=1.24.7'
|
||||
go-version: '>=1.26.1'
|
||||
|
||||
# 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.
|
||||
|
||||
+246
-1
@@ -1,3 +1,248 @@
|
||||
## v0.36.7
|
||||
|
||||
- Fixed high memory usage with large file uploads ([#7572](https://github.com/pocketbase/pocketbase/discussions/7572)).
|
||||
|
||||
- Updated the rate limiter reset rules to follow a more traditional fixed window strategy _(aka. to be more close to how it is presented in the UI - allow max X user requests under Ys)_ since several users complained that the older algorithm was not intuitive and not suitable for large intervals.
|
||||
_Approximated sliding window strategy was also suggested as a better compromise option to help minimize traffic spikes right after reset but the additional tracking could introduce some overhead and for now it is left aside until we have more tests._
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3.
|
||||
_⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._
|
||||
|
||||
- Updated other minor Go and npm deps.
|
||||
_The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._
|
||||
|
||||
|
||||
## v0.36.6
|
||||
|
||||
- Set `NumberField.OnlyInt:true` for the generated View collection schema fields when a view column expression is known to return int-only values ([#7538](https://github.com/pocketbase/pocketbase/issues/7538)).
|
||||
|
||||
- Documented the `unmarshal` JSVM helper ([#7543](https://github.com/pocketbase/pocketbase/issues/7543)).
|
||||
|
||||
- Added extra read check after the `Store.GetOrSet` write lock to prevent races overwriting an already existing value.
|
||||
|
||||
- Added empty records check for the additional client-side filter's ListRule constraint that was introduced in v0.32.0 ([presentator#206](https://github.com/presentator/presentator/issues/206)).
|
||||
|
||||
- Set a fixed `routine.FireAndForget()` debug stack trace limit to 2KB.
|
||||
|
||||
- Bumped min Go GitHub action version to 1.26.1 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1).
|
||||
|
||||
- Typos and other minor doc fixes.
|
||||
|
||||
|
||||
## v0.36.5
|
||||
|
||||
- Disabled collection and fields name normalization while in IME mode ([#7532](https://github.com/pocketbase/pocketbase/pull/7532); thanks @miaopan607).
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.46.1 _(resets connection state on Tx.Commit failure)_.
|
||||
|
||||
|
||||
## v0.36.4
|
||||
|
||||
- Made the optional `Bearer` token prefix case-insensitive ([#7525](https://github.com/pocketbase/pocketbase/pull/7525); thanks @benjamesfleming).
|
||||
|
||||
- Enabled `$filesystem.s3(...)` and `$filesystem.local(...)` JSVM bindings ([#7526](https://github.com/pocketbase/pocketbase/issues/7526)).
|
||||
|
||||
|
||||
## v0.36.3
|
||||
|
||||
- Added `Accept-Encoding: identity` to the S3 requests per the suggestion in [#7523](https://github.com/pocketbase/pocketbase/issues/7523).
|
||||
_This should help fixing the 0-bytes file response when S3 API compression is enabled._
|
||||
|
||||
- Bumped min Go GitHub action version to 1.26.0 _(it comes with minor [GC performance improvements](https://go.dev/doc/go1.26#runtime))_.
|
||||
|
||||
- Other minor fixes _(updated `modernc.org/sqlite` to v1.45.0, updated `goja_nodejs` adding `Buffer.concat`, updated the arguments of `app.DeleteTable(...)`, `app.DeleteView(...)` and other similar methods to make it more clear that they are dangerous and shouldn't be used with untrusted input, etc.)_.
|
||||
|
||||
|
||||
## v0.36.2
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.44.3 _(race check fix)_, `goja` _(circular references fix)_ and other go deps.
|
||||
|
||||
- Other minor fixes _(updated tests to silence some of the race detector errors, updated `FindFirstRecordByData` with more clear error message when missing or invalid key is used, etc.)_.
|
||||
|
||||
|
||||
## v0.36.1
|
||||
|
||||
- Reverted the `DISTINCT` with `GROUP BY` replacement optimization from v0.36.0 as it was reported to negatively impact the indexes utilization for some queries
|
||||
and the minor performance boost that you may get when used on large records is not enough to justify the more common use ([#7461](https://github.com/pocketbase/pocketbase/discussions/7461)).
|
||||
_A better generic deduplication optimization for large records (aka. records with large `text`/`json` fields or many small ones) will be researched but there are no ETAs._
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.44.2 _(SQLite 3.51.2)_.
|
||||
|
||||
- Fixed code comment typos.
|
||||
|
||||
|
||||
## v0.36.0
|
||||
|
||||
- List query and API rules optimizations:
|
||||
- Removed unnecessary correlated subquery expression when using back-relations via single `relation` field.
|
||||
- Replaced `DISTINCT` with `GROUP BY id` when rows deduplication is needed and when deemed safe.
|
||||
_This should help with having a more stable and predictable performance even if the collection records are on the larger side._
|
||||
|
||||
For some queries and data sets the above 2 optimizations have shown significant improvements but if you notice a performance degradation after upgrading,
|
||||
please open a Q&A discussion with export of your collections structure and the problematic request so that it can be analyzed.
|
||||
|
||||
- Added [`strftime(format, timevalue, modifiers...)`](https://pocketbase.io/docs/api-rules-and-filters/#strftimeformat-time-value-modifiers-) date formatting filter and API rules function.
|
||||
It works similarly to the [SQLite `strftime` builtin function](https://sqlite.org/lang_datefunc.html)
|
||||
with the main difference that NULL results will be normalized for consistency with the non-nullable PocketBase `text` and `date` fields.
|
||||
Multi-match expressions are also supported and works the same as if the collection field is referenced, for example:
|
||||
```js
|
||||
// requires ANY/AT-LEAST-ONE-OF multiRel records to have "created" date matching the formatted string "2026-01"
|
||||
strftime('%Y-%m', multiRel.created) ?= '2026-01'
|
||||
|
||||
// requires ALL multiRel records to have "created" date matching the formatted string "2026-01"
|
||||
strftime('%Y-%m', multiRel.created) = '2026-01'
|
||||
```
|
||||
|
||||
- ⚠️ Minor changes to the `search.ResolverResult` struct _(mostly used internally)_:
|
||||
- Replaced `NoCoalesce` field with the more explicit `NullFallback` _(`NullFallbackDisabled` is the same as `NoCoalesce:true`)_.
|
||||
- Replaced the expression interface of the `MultiMatchSubQuery` field with the concrete struct type `search.MultiMatchSubquery` to avoid excessive type assertions and allow direct mutations of the field.
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.44.1 _(SQLite 3.51.1)_.
|
||||
|
||||
- Bumped min Go GitHub action version to 1.25.6 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.6).
|
||||
|
||||
|
||||
## v0.35.1
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.43.0 _(query cancellation race fix)_.
|
||||
|
||||
- Other minor UI fixes (normalized relations picker selection and confirmation message when `maxSelect=0/1`, updated node deps).
|
||||
|
||||
|
||||
## v0.35.0
|
||||
|
||||
- Added `nullString()`, `nullInt()`, `nullFloat()`, `nullBool`, `nullArray()`, `nullObject()` JSVM helpers for scanning nullable columns ([#7396](https://github.com/pocketbase/pocketbase/issues/7396)).
|
||||
|
||||
- Store the correct `image/png` as attrs content type when generating a thumb fallback _(e.g. for `webp`)_.
|
||||
|
||||
- Trimmed custom uploaded file name and extension from leftover `.` characters after `filesystem.File` normalization.
|
||||
_This was done to prevent issues with external files sync programs that may have special handling for "invisible" files._
|
||||
|
||||
- Updated `modernc.org/sqlite` _(v1.41.0 includes prepared statements optimization)_ and other minor Go deps.
|
||||
|
||||
|
||||
## v0.34.2
|
||||
|
||||
- Bumped JS SDK to v0.26.5 to fix Safari AbortError detection introduced with the previous release ([#7369](https://github.com/pocketbase/pocketbase/issues/7369)).
|
||||
|
||||
|
||||
## v0.34.1
|
||||
|
||||
- Added missing `:` char to the autocomplete regex ([#7353](https://github.com/pocketbase/pocketbase/pull/7353); thanks @ouvreboite).
|
||||
|
||||
- Added "Copy raw JSON" collection dropdown option ([#7357](https://github.com/pocketbase/pocketbase/issues/7357)).
|
||||
|
||||
- Updated Go deps and JS SDK.
|
||||
|
||||
- Bumped min Go GitHub action version to 1.25.5 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.5).
|
||||
_The runner action was also updated to `actions/setup-go@v6` since the previous v5 Go source seems [no longer accessible](https://github.com/actions/setup-go/pull/665#issuecomment-3416693714)._
|
||||
|
||||
|
||||
## v0.34.0
|
||||
|
||||
- Added `@request.body.someField:changed` modifier.
|
||||
It could be used when you want to ensure that a body field either wasn't submitted or was submitted with the same value.
|
||||
Or in other words, if you want to disallow a field change the below 2 expressions would be equivalent:
|
||||
```js
|
||||
// (old)
|
||||
(@request.body.someField:isset = false || @request.body.someField = someField)
|
||||
|
||||
// (new)
|
||||
@request.body.someField:changed = false
|
||||
```
|
||||
|
||||
- Added `MailerRecordEvent.Meta["info"]` property for the `OnMailerRecordAuthAlertSend` hook.
|
||||
|
||||
- Updated the backup restore popup with a short info about the performed restore steps.
|
||||
|
||||
- Updated Go deps.
|
||||
|
||||
|
||||
## v0.33.0
|
||||
|
||||
- Added extra `id` characters validation in addition to the user specified regex pattern ([#7312](https://github.com/pocketbase/pocketbase/issues/7312)).
|
||||
_The following special characters are always forbidden: `./\|"'``<>:?*%$\n\r\t\0 `. Common reserved Windows file names such as `aux`, `prn`, `con`, `nul`, `com1-9`, `lpt1-9` are also not allowed._
|
||||
_The list is not exhaustive but it should help minimizing eventual filesystem compatibility issues in case of wildcards or other loose regex patterns._
|
||||
|
||||
- Added `{ALERT_INFO}` placeholder to the auth alert mail template ([#7314](https://github.com/pocketbase/pocketbase/issues/7314)).
|
||||
_⚠️ `mails.SendRecordAuthAlert(app, authRecord, info)` also now accepts a 3rd `info` string argument._
|
||||
|
||||
- Updated Go deps.
|
||||
|
||||
|
||||
## v0.32.0
|
||||
|
||||
- ⚠️ Added extra List/Search API rules checks for the client-side `filter`/`sort` relations.
|
||||
|
||||
This is continuation of the effort to eliminate the risk of information disclosure _(and eventually the side-channel attacks that may originate from that)_.
|
||||
|
||||
So far this was accepted tradeoff between performance, usability and correctness since the solutions at the time weren't really practical _(especially with the back-relations as mentioned in ["Security and performance" section in #4417](https://github.com/pocketbase/pocketbase/discussions/4417))_, but with v0.23+ changes we can implement the extra checks without littering the code too much, with very little impact on the performance and at the same time ensuring better out of the box security _(especially for the cases where users operate with sensitive fields like "code", "token", "secret", etc.)_.
|
||||
|
||||
Similar to the previous release, probably for most users with already configured API rules this change won't be breaking, but if you have an _intermediate/junction collection_ that is "locked" (superusers-only) we no longer will allow the client-side relation filter to pass through it and you'll have to set its List/Search API rule to enable the current user to search in it.
|
||||
|
||||
For example, if you have a client-side filter that targets `rel1.rel2.token`, the client must have not only List/Search API rule access to the main collection BUT also to the collections referenced by "rel1" and "rel2" relation fields.
|
||||
|
||||
Note that this change is only for the **client-side** `filter`/`sort` and doesn't affect the execution of superuser requests, API rules and `expand` - they continue to work the same as it is.
|
||||
|
||||
An optional environment variable to toggle this behavior was considered but for now I think having 2 ways of resolving client-side filters would introduce maintenance burden and can even cause confusion (this change should actually make things more intuitive and clear because we can simply say something like _"you can search by a collection X field only if you have List/Search API rule access to it"_ no matter whether the targeted collection is the request's main collection, the first or last relation from the filter chain, etc.).
|
||||
|
||||
If you stumble on an error or extreme query performance degradation as a result of the extra checks, please open a Q&A discussion with the failing request and export of your collections configuration as JSON (_Settings > Export collections_) and I'll try to investigate it.
|
||||
|
||||
- Increased the default SQLite `PRAGMA cache_size` to ~32MB.
|
||||
|
||||
- Fixed deadlock when manually triggering the `OnTerminate` hook ([#7305](https://github.com/pocketbase/pocketbase/pull/7305); thanks @yerTools).
|
||||
|
||||
- Fixed some code comment typos, regenerated the JSVM types and updated npm dependencies.
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.40.0.
|
||||
|
||||
|
||||
## v0.31.0
|
||||
|
||||
- Visualize presentable multiple `relation` fields ([#7260](https://github.com/pocketbase/pocketbase/issues/7260)).
|
||||
|
||||
- Support Ed25519 in the optional OIDC `id_token` signature validation ([#7252](https://github.com/pocketbase/pocketbase/issues/7252); thanks @shynome).
|
||||
|
||||
- Added `ApiScenario.DisableTestAppCleanup` optional field to skip the auto test app cleanup and leave it up to the developers to do the cleanup manually ([#7267](https://github.com/pocketbase/pocketbase/discussions/7267)).
|
||||
|
||||
- Added `FileDownloadRequestEvent.ThumbError` field that is populated in case of a thumb generation failure (e.g. unsupported format, timing out, etc.), allowing developers to reject the thumb fallback and/or supply their own custom thumb generation ([#7268](https://github.com/pocketbase/pocketbase/discussions/7268)).
|
||||
|
||||
- ⚠️ Disallow client-side filtering and sorting of relations where the collection of the last targeted relation field has superusers-only List/Search API rule to further minimize the risk of eventual side-channel attack.
|
||||
_This should be a non-breaking change for most users, but if you want the old behavior, please open a new Q&A discussion with details about your use case to evaluate making it configurable._
|
||||
_Note also that as mentioned in the "Security and performance" section of [#4417](https://github.com/pocketbase/pocketbase/discussions/4417) and [#5863](https://github.com/pocketbase/pocketbase/discussions/5863), the easiest and recommended solution to protect security sensitive fields (tokens, codes, passwords, etc.) is to mark them as "Hidden" (aka. make them non-API filterable)._
|
||||
|
||||
- Regenerated JSVM types and updated npm and Go deps.
|
||||
|
||||
|
||||
## v0.30.4
|
||||
|
||||
- Fixed `json` field CSS regression introduced with the overflow workaround in v0.30.3 ([#7259](https://github.com/pocketbase/pocketbase/issues/7259)).
|
||||
|
||||
|
||||
## v0.30.3
|
||||
|
||||
- Fixed legacy identitity field priority check when a username is a valid email address ([#7256](https://github.com/pocketbase/pocketbase/issues/7256)).
|
||||
|
||||
- Workaround autocomplete overflow issue with Firefox 144 ([#7223](https://github.com/pocketbase/pocketbase/issues/7223)).
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.39.1 (SQLite 3.50.4).
|
||||
|
||||
|
||||
## v0.30.2
|
||||
|
||||
- Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved).
|
||||
|
||||
|
||||
## v0.30.1
|
||||
|
||||
- ⚠️ Excluded the `lost+found` directory from the backups ([#7208](https://github.com/pocketbase/pocketbase/pull/7208); thanks @lbndev).
|
||||
_If for some reason you want to keep it, you can restore it by editing the `e.Exclude` list of the `OnBackupCreate` and `OnBackupRestore` hooks._
|
||||
|
||||
- Minor tests improvements (disabled initial superuser creation for the test app to avoid cluttering the std output, added more tests for the `s3.Uploader.MaxConcurrency`, etc.).
|
||||
|
||||
- Updated `modernc.org/sqlite` and other Go dependencies.
|
||||
|
||||
|
||||
## v0.30.0
|
||||
|
||||
- Eagerly escape the S3 request path following the same rules as in the S3 signing header ([#7153](https://github.com/pocketbase/pocketbase/issues/7153)).
|
||||
@@ -157,7 +402,7 @@
|
||||
If you are having difficulties adjusting your code, feel free to open a [Q&A discussion](https://github.com/pocketbase/pocketbase/discussions) with the failing/problematic code sample.
|
||||
|
||||
- Added [new `geoPoint` field](https://pocketbase.io/docs/collections/#geopoint) for storing `{"lon":x,"lat":y}` geographic coordinates.
|
||||
In addition, a new [`geoDistance(lonA, lotA, lonB, lotB)` function](htts://pocketbase.io/docs/api-rules-and-filters/#geodistancelona-lata-lonb-latb) was also implemented that could be used to apply an API rule or filter constraint based on the distance (in km) between 2 geo points.
|
||||
In addition, a new [`geoDistance(lonA, lotA, lonB, lotB)` function](https://pocketbase.io/docs/api-rules-and-filters/#geodistancelona-lata-lonb-latb) was also implemented that could be used to apply an API rule or filter constraint based on the distance (in km) between 2 geo points.
|
||||
|
||||
- Updated the `select` field UI to accommodate better larger lists and RTL languages ([#4674](https://github.com/pocketbase/pocketbase/issues/4674)).
|
||||
|
||||
|
||||
@@ -2,6 +2,38 @@
|
||||
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
|
||||
---
|
||||
|
||||
## v0.22.40
|
||||
|
||||
- (_Backported from v0.36.7_) Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3.
|
||||
_⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._
|
||||
|
||||
- (_Backported from v0.36.7_) Updated other minor Go and npm deps.
|
||||
_The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._
|
||||
|
||||
|
||||
## v0.22.39
|
||||
|
||||
- (_Backported from v0.36.6_) Bumped min Go GitHub action version to 1.26.1 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1).
|
||||
|
||||
|
||||
## v0.22.38
|
||||
|
||||
- (_Backported from v0.36.0_) Bumped min Go GitHub action version to 1.25.6 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.6).
|
||||
|
||||
|
||||
## v0.22.37
|
||||
|
||||
- (_Backported from v0.34.1_) - Added missing `:` char to the autocomplete regex ([#7353](https://github.com/pocketbase/pocketbase/pull/7353)).
|
||||
|
||||
- (_Backported from v0.34.1_) Bumped min Go GitHub action version to 1.25.5 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.5).
|
||||
_The runner action was also updated to `actions/setup-go@v6` since the previous v5 Go source seems [no longer accessible](https://github.com/actions/setup-go/pull/665#issuecomment-3416693714)._
|
||||
|
||||
|
||||
## v0.22.36
|
||||
|
||||
- (_Backported from v0.30.2_) Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved).
|
||||
|
||||
|
||||
## v0.22.35
|
||||
|
||||
- (_Backported from v0.29.2_) Bumped min Go GitHub action version to 1.23.12 since it comes with some [minor fixes for the runtime and `database/sql` package](https://github.com/golang/go/issues?q=milestone%3AGo1.23.12+label%3ACherryPickApproved).
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ This document describes how to prepare a PR for a change in the main repository.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.23+ (for making changes in the Go code)
|
||||
- Go 1.25+ (for making changes in the Go code)
|
||||
- Node 18+ (for making changes in the Admin UI)
|
||||
|
||||
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
|
||||
|
||||
@@ -49,7 +49,7 @@ your own custom app specific business logic and still have a single portable exe
|
||||
|
||||
Here is a minimal example:
|
||||
|
||||
0. [Install Go 1.23+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
0. [Install Go 1.25+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
|
||||
1. Create a new project directory with the following `main.go` file inside it:
|
||||
```go
|
||||
@@ -92,7 +92,7 @@ _For more details please refer to [Extend with Go](https://pocketbase.io/docs/go
|
||||
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
|
||||
|
||||
0. [Install Go 1.23+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
0. [Install Go 1.25+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
1. Clone/download the repo
|
||||
2. Navigate to `examples/base`
|
||||
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
|
||||
@@ -110,9 +110,11 @@ linux 386
|
||||
linux amd64
|
||||
linux arm
|
||||
linux arm64
|
||||
linux loong64
|
||||
linux ppc64le
|
||||
linux riscv64
|
||||
linux s390x
|
||||
windows 386
|
||||
windows amd64
|
||||
windows arm64
|
||||
```
|
||||
|
||||
+2
-1
@@ -88,7 +88,7 @@ func (brs batchRequestsForm) validate() error {
|
||||
}
|
||||
|
||||
// NB! When the request is submitted as multipart/form-data,
|
||||
// the regular fields data is expected to be submitted as serailized
|
||||
// the regular fields data is expected to be submitted as serialized
|
||||
// json under the @jsonPayload field and file keys need to follow the
|
||||
// pattern "requests.N.fileField" or requests[N].fileField.
|
||||
func batchTransaction(e *core.RequestEvent) error {
|
||||
@@ -364,6 +364,7 @@ func processInternalRequest(
|
||||
// assign request
|
||||
event.Request = r
|
||||
event.Request.Body = &router.RereadableReadCloser{ReadCloser: r.Body} // enables multiple reads
|
||||
defer event.Request.Body.Close()
|
||||
|
||||
// assign response
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
+20
-17
@@ -142,8 +142,14 @@ func (api *fileApi) download(e *core.RequestEvent) error {
|
||||
defer fsys.Close()
|
||||
|
||||
originalPath := baseFilesPath + "/" + filename
|
||||
servedPath := originalPath
|
||||
servedName := filename
|
||||
|
||||
event := new(core.FileDownloadRequestEvent)
|
||||
event.RequestEvent = e
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.FileField = fileField
|
||||
event.ServedPath = originalPath
|
||||
event.ServedName = filename
|
||||
|
||||
// check for valid thumb size param
|
||||
thumbSize := e.Request.URL.Query().Get("thumb")
|
||||
@@ -157,34 +163,31 @@ func (api *fileApi) download(e *core.RequestEvent) error {
|
||||
// check if it is an image
|
||||
if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) {
|
||||
// add thumb size as file suffix
|
||||
servedName = thumbSize + "_" + filename
|
||||
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
|
||||
event.ServedName = thumbSize + "_" + filename
|
||||
event.ServedPath = baseFilesPath + "/thumbs_" + filename + "/" + event.ServedName
|
||||
|
||||
// create a new thumb if it doesn't exist
|
||||
if exists, _ := fsys.Exists(servedPath); !exists {
|
||||
if err := api.createThumb(e, fsys, originalPath, servedPath, thumbSize); err != nil {
|
||||
if exists, _ := fsys.Exists(event.ServedPath); !exists {
|
||||
if err := api.createThumb(e, fsys, originalPath, event.ServedPath, thumbSize); err != nil {
|
||||
e.App.Logger().Warn(
|
||||
"Fallback to original - failed to create thumb "+servedName,
|
||||
"Fallback to original - failed to create thumb "+event.ServedName,
|
||||
slog.Any("error", err),
|
||||
slog.String("original", originalPath),
|
||||
slog.String("thumb", servedPath),
|
||||
slog.String("thumb", event.ServedPath),
|
||||
)
|
||||
|
||||
// fallback to the original
|
||||
servedName = filename
|
||||
servedPath = originalPath
|
||||
event.ThumbError = err
|
||||
event.ServedName = filename
|
||||
event.ServedPath = originalPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event := new(core.FileDownloadRequestEvent)
|
||||
event.RequestEvent = e
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.FileField = fileField
|
||||
event.ServedPath = servedPath
|
||||
event.ServedName = servedName
|
||||
if thumbSize != "" && event.ThumbError == nil && event.ServedPath == originalPath {
|
||||
event.ThumbError = fmt.Errorf("the thumb size %q or the original file format are not supported", thumbSize)
|
||||
}
|
||||
|
||||
// clickjacking shouldn't be a concern when serving uploaded files,
|
||||
// so it safe to unset the global X-Frame-Options to allow files embedding
|
||||
|
||||
+88
-24
@@ -181,9 +181,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - missing thumb (should fallback to the original)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
|
||||
Name: "existing image - missing thumb (should fallback to the original)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError == nil {
|
||||
t.Fatal("Expected thumb error, got nil")
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testImg)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -192,9 +200,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (crop center)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
|
||||
Name: "existing image - existing thumb (crop center)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbCropCenter)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -203,9 +219,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (crop top)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
|
||||
Name: "existing image - existing thumb (crop top)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbCropTop)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -214,9 +238,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (crop bottom)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
|
||||
Name: "existing image - existing thumb (crop bottom)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbCropBottom)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -225,9 +257,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (fit)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
|
||||
Name: "existing image - existing thumb (fit)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbFit)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -236,9 +276,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (zero width)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
|
||||
Name: "existing image - existing thumb (zero width)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbZeroWidth)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -247,9 +295,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (zero height)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
|
||||
Name: "existing image - existing thumb (zero height)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbZeroHeight)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -258,9 +314,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing non image file - thumb parameter should be ignored",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
|
||||
Name: "existing non image file - thumb parameter should be ignored",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError == nil {
|
||||
t.Fatal("Expected thumb error, got nil")
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testFile)},
|
||||
ExpectedEvents: map[string]int{
|
||||
|
||||
+6
-4
@@ -207,11 +207,13 @@ func loadAuthToken() *hook.Handler[*core.RequestEvent] {
|
||||
|
||||
func getAuthTokenFromRequest(e *core.RequestEvent) string {
|
||||
token := e.Request.Header.Get("Authorization")
|
||||
if token != "" {
|
||||
// the schema prefix is not required and it is only for
|
||||
// compatibility with the defaults of some HTTP clients
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
|
||||
// the "Bearer" schema prefix is not required by PocketBase and it is
|
||||
// supported only for compatibility with the defaults of some HTTP clients
|
||||
if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
|
||||
return token[7:]
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
|
||||
@@ -112,9 +112,21 @@ func (r *limitedReader) Read(b []byte) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// explicit casts to ensure that the main struct methods will be invoked
|
||||
// (extra precautions in case of nested interface wrapping erasure)
|
||||
// ---
|
||||
|
||||
func (r *limitedReader) Reread() {
|
||||
rr, ok := r.ReadCloser.(router.Rereader)
|
||||
rereader, ok := r.ReadCloser.(router.Rereader)
|
||||
if ok {
|
||||
rr.Reread()
|
||||
rereader.Reread()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *limitedReader) Close() error {
|
||||
closer, ok := r.ReadCloser.(io.Closer)
|
||||
if ok {
|
||||
return closer.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -108,28 +108,6 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// @todo consider exporting as helper?
|
||||
//
|
||||
//nolint:unused
|
||||
func isClientRateLimited(e *core.RequestEvent, rtId string) bool {
|
||||
rateLimiters, ok := e.App.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
|
||||
if !ok || rateLimiters == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rt, ok := rateLimiters.GetOk(rtId)
|
||||
if !ok || rt == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client, ok := rt.getClient(e.RealIP())
|
||||
if !ok || client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return client.available <= 0 && time.Now().Unix()-client.lastConsume < client.interval
|
||||
}
|
||||
|
||||
// @todo consider exporting as helper?
|
||||
func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule) error {
|
||||
switch rule.Audience {
|
||||
@@ -154,7 +132,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
|
||||
}
|
||||
|
||||
rt := rateLimiters.GetOrSet(rtId, func() *rateLimiter {
|
||||
return newRateLimiter(rule.MaxRequests, rule.Duration, rule.Duration+1800)
|
||||
return newRateLimiter(rule.MaxRequests, rule.Duration, 1800)
|
||||
})
|
||||
if rt == nil {
|
||||
e.App.Logger().Warn("Failed to retrieve app rate limiter", "id", rtId)
|
||||
@@ -234,12 +212,12 @@ func newRateLimiter(maxAllowed int, intervalInSec int64, minDeleteIntervalInSec
|
||||
maxAllowed: maxAllowed,
|
||||
interval: intervalInSec,
|
||||
minDeleteInterval: minDeleteIntervalInSec,
|
||||
clients: map[string]*fixedWindow{},
|
||||
clients: map[string]*rateClient{},
|
||||
}
|
||||
}
|
||||
|
||||
type rateLimiter struct {
|
||||
clients map[string]*fixedWindow
|
||||
clients map[string]*rateClient
|
||||
|
||||
maxAllowed int
|
||||
interval int64
|
||||
@@ -250,7 +228,7 @@ type rateLimiter struct {
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func (rt *rateLimiter) getClient(key string) (*fixedWindow, bool) {
|
||||
func (rt *rateLimiter) getClient(key string) (*rateClient, bool) {
|
||||
rt.RLock()
|
||||
client, ok := rt.clients[key]
|
||||
rt.RUnlock()
|
||||
@@ -269,7 +247,7 @@ func (rt *rateLimiter) isAllowed(key string) bool {
|
||||
// check again in case the client was added by another request
|
||||
client, ok = rt.clients[key]
|
||||
if !ok {
|
||||
client = newFixedWindow(rt.maxAllowed, rt.interval)
|
||||
client = newRateClient(rt.maxAllowed, rt.interval)
|
||||
rt.clients[key] = client
|
||||
}
|
||||
rt.Unlock()
|
||||
@@ -295,7 +273,7 @@ func (rt *rateLimiter) clean() {
|
||||
//
|
||||
// @todo remove after https://github.com/golang/go/issues/20135
|
||||
if rt.totalDeleted >= 300 {
|
||||
shrunk := make(map[string]*fixedWindow, len(rt.clients))
|
||||
shrunk := make(map[string]*rateClient, len(rt.clients))
|
||||
for k, v := range rt.clients {
|
||||
shrunk[k] = v
|
||||
}
|
||||
@@ -304,52 +282,54 @@ func (rt *rateLimiter) clean() {
|
||||
}
|
||||
}
|
||||
|
||||
func newFixedWindow(maxAllowed int, intervalInSec int64) *fixedWindow {
|
||||
return &fixedWindow{
|
||||
func newRateClient(maxAllowed int, intervalInSec int64) *rateClient {
|
||||
return &rateClient{
|
||||
maxAllowed: maxAllowed,
|
||||
interval: intervalInSec,
|
||||
}
|
||||
}
|
||||
|
||||
type fixedWindow struct {
|
||||
// @todo evaluate swiching to sliding window with approximation counter similar to Cloudflare.
|
||||
//
|
||||
// rateClient implements fixed window rate limit strategy.
|
||||
type rateClient struct {
|
||||
// use plain Mutex instead of RWMutex since the operations are expected
|
||||
// to be mostly writes (e.g. consume()) and it should perform better
|
||||
sync.Mutex
|
||||
|
||||
maxAllowed int // the max allowed tokens per interval
|
||||
available int // the total available tokens
|
||||
interval int64 // in seconds
|
||||
lastConsume int64 // the time of the last consume
|
||||
maxAllowed int // the max allowed tokens per interval
|
||||
available int // the total available tokens
|
||||
start int64 // the start time of the current window
|
||||
interval int64 // in seconds
|
||||
}
|
||||
|
||||
// hasExpired checks whether it has been at least minElapsed seconds since the lastConsume time.
|
||||
// hasExpired checks whether it has been at least minElapsed seconds after the last active window.
|
||||
// (usually used to perform periodic cleanup of staled instances).
|
||||
func (l *fixedWindow) hasExpired(relativeNow int64, minElapsed int64) bool {
|
||||
func (l *rateClient) hasExpired(relativeNow int64, minElapsed int64) bool {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
return relativeNow-l.lastConsume > minElapsed
|
||||
return relativeNow-(l.start+l.interval) > minElapsed
|
||||
}
|
||||
|
||||
// consume decrease the current window allowance with 1 (if not exhausted already).
|
||||
// consume decreases the current allowance with 1 (if not exhausted already).
|
||||
//
|
||||
// It returns false if the allowance has been already exhausted and the user
|
||||
// has to wait until it resets back to its maxAllowed value.
|
||||
func (l *fixedWindow) consume() bool {
|
||||
func (l *rateClient) consume() bool {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
nowUnix := time.Now().Unix()
|
||||
|
||||
// reset consumed counter
|
||||
if nowUnix-l.lastConsume >= l.interval {
|
||||
// reset
|
||||
if nowUnix-l.start >= l.interval {
|
||||
l.available = l.maxAllowed
|
||||
l.start = nowUnix
|
||||
}
|
||||
|
||||
if l.available > 0 {
|
||||
l.available--
|
||||
l.lastConsume = nowUnix
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
|
||||
scenarios := []struct {
|
||||
url string
|
||||
wait float64
|
||||
wait float64 // ms
|
||||
authenticated bool
|
||||
expectedStatus int
|
||||
}{
|
||||
@@ -85,10 +85,13 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
{"/norate", 0, false, 200},
|
||||
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 800, false, 200}, // (fixed window check) wait enough to ensure that it can't fit 2 requests in 1s
|
||||
{"/rate/a", 800, false, 200},
|
||||
{"/rate/a", 800, false, 200},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 0, false, 429},
|
||||
{"/rate/a", 0, false, 429},
|
||||
{"/rate/a", 1.1, false, 200},
|
||||
{"/rate/a", 1000, false, 200},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 0, false, 429},
|
||||
|
||||
@@ -96,7 +99,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 429},
|
||||
{"/rate/b", 1.1, false, 200},
|
||||
{"/rate/b", 1000, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 429},
|
||||
@@ -118,7 +121,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
{"/rate/guest", 0, false, 429},
|
||||
|
||||
// "guest" rule with regular user (should fallback to the /rate/ rule)
|
||||
{"/rate/guest", 1.1, true, 200},
|
||||
{"/rate/guest", 1000, true, 200},
|
||||
{"/rate/guest", 0, true, 200},
|
||||
{"/rate/guest", 0, true, 429},
|
||||
{"/rate/guest", 0, true, 429},
|
||||
@@ -126,10 +129,6 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.url, func(t *testing.T) {
|
||||
if s.wait > 0 {
|
||||
time.Sleep(time.Duration(s.wait) * time.Second)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", s.url, nil)
|
||||
|
||||
@@ -147,6 +146,10 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
req.Header.Add("Authorization", token)
|
||||
}
|
||||
|
||||
if s.wait > 0 {
|
||||
time.Sleep(time.Duration(s.wait) * time.Millisecond)
|
||||
}
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
|
||||
@@ -224,6 +224,22 @@ func TestRequireAuth(t *testing.T) {
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid record auth token with Bearer case-insensitive prefix",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
// regular user
|
||||
"Authorization": "BeArEr eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "test123")
|
||||
}).Bind(apis.RequireAuth())
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+5
-1
@@ -769,7 +769,11 @@ func realtimeCanAccessRecord(
|
||||
}
|
||||
|
||||
q.AndWhere(expr)
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
err = resolver.UpdateQuery(q)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
err = q.Limit(1).Row(&exists)
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("Invalid or expired OTP", fmt.Errorf("missing auth record: %w", err))
|
||||
}
|
||||
|
||||
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumaration protection
|
||||
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumeration protection
|
||||
err = checkRateLimit(e, "@pb_otp_"+event.Record.Id, core.RateLimitRule{MaxRequests: 5, Duration: 180})
|
||||
if err != nil {
|
||||
return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil)
|
||||
|
||||
@@ -40,23 +40,32 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
|
||||
if form.IdentityField != "" {
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, form.IdentityField, form.Identity)
|
||||
} else {
|
||||
// prioritize email lookup
|
||||
isEmail := is.EmailFormat.Validate(form.Identity) == nil
|
||||
if isEmail && list.ExistInSlice(core.FieldNameEmail, collection.PasswordAuth.IdentityFields) {
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, core.FieldNameEmail, form.Identity)
|
||||
identityFields := collection.PasswordAuth.IdentityFields
|
||||
|
||||
// @todo consider removing with the stable release or moving it in the collection save
|
||||
//
|
||||
// prioritize email lookup to minimize breaking changes with earlier versions
|
||||
if len(identityFields) > 1 && identityFields[0] != core.FieldNameEmail {
|
||||
identityFields = slices.Clone(identityFields)
|
||||
slices.SortStableFunc(identityFields, func(a, b string) int {
|
||||
if a == "email" {
|
||||
return -1
|
||||
}
|
||||
if b == "email" {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
// search by the other identity fields
|
||||
if !isEmail || foundErr != nil {
|
||||
for _, name := range collection.PasswordAuth.IdentityFields {
|
||||
if !isEmail && name == core.FieldNameEmail {
|
||||
continue // no need to search by the email field if it is not an email
|
||||
}
|
||||
for _, name := range identityFields {
|
||||
if name == core.FieldNameEmail && is.EmailFormat.Validate(form.Identity) != nil {
|
||||
continue // no need to query the database if we know that the submitted value is not an email
|
||||
}
|
||||
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
|
||||
if foundErr == nil {
|
||||
break
|
||||
}
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
|
||||
if foundErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,57 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
"OnMailerRecordAuthAlertSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
// https://github.com/pocketbase/pocketbase/issues/7256
|
||||
Name: "valid non-email identity field with a value that is a properly formatted email",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"username_as_email@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
record, err := app.FindAuthRecordByEmail("clients", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record.Set("username", "username_as_email@example.com")
|
||||
|
||||
err = app.SaveNoValidate(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test@example.com"`,
|
||||
`"username":"username_as_email@example.com"`,
|
||||
`"token":`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
// authOrigin track
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordValidate": 1,
|
||||
"OnMailerSend": 1,
|
||||
"OnMailerRecordAuthAlertSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unknown explicit identityField",
|
||||
Method: http.MethodPost,
|
||||
|
||||
+30
-6
@@ -118,7 +118,7 @@ func recordsList(e *core.RequestEvent) error {
|
||||
len(e.Records) == 0 &&
|
||||
checkRateLimit(e.RequestEvent, "@pb_list_timing_check_"+collection.Id, listTimingRateLimitRule) != nil {
|
||||
e.App.Logger().Debug("Randomized throttle because of too many failed searches", "collectionId", collection.Id)
|
||||
randomizedThrottle(150)
|
||||
randomizedThrottle(500)
|
||||
}
|
||||
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
@@ -169,12 +169,18 @@ func recordView(e *core.RequestEvent) error {
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if !requestInfo.HasSuperuserAuth() && collection.ViewRule != nil && *collection.ViewRule != "" {
|
||||
resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
q.AndWhere(expr)
|
||||
|
||||
err = resolver.UpdateQuery(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -310,7 +316,10 @@ func recordCreate(responseWriteAfterTx bool, optFinalizer func(data any) error)
|
||||
}
|
||||
ruleQuery.AndWhere(expr)
|
||||
|
||||
resolver.UpdateQuery(ruleQuery)
|
||||
err = resolver.UpdateQuery(ruleQuery)
|
||||
if err != nil {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule update query failure: %w", err))
|
||||
}
|
||||
|
||||
var exists int
|
||||
err = ruleQuery.Limit(1).Row(&exists)
|
||||
@@ -430,12 +439,18 @@ func recordUpdate(responseWriteAfterTx bool, optFinalizer func(data any) error)
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if !hasSuperuserAuth && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
q.AndWhere(expr)
|
||||
|
||||
err = resolver.UpdateQuery(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -546,12 +561,18 @@ func recordDelete(responseWriteAfterTx bool, optFinalizer func(data any) error)
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if !requestInfo.HasSuperuserAuth() && collection.DeleteRule != nil && *collection.DeleteRule != "" {
|
||||
resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
q.AndWhere(expr)
|
||||
|
||||
err = resolver.UpdateQuery(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -732,7 +753,10 @@ func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, collection
|
||||
}
|
||||
query.AndWhere(expr)
|
||||
|
||||
resolver.UpdateQuery(query)
|
||||
err = resolver.UpdateQuery(query)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists int
|
||||
|
||||
|
||||
@@ -382,10 +382,32 @@ func TestRecordCrudList(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "multi-match - at least one of",
|
||||
Name: "multi-match - at least one of (guest - non-satisfied relation filter API rule)",
|
||||
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{
|
||||
"*": 0,
|
||||
"OnRecordsListRequest": 1,
|
||||
"OnRecordEnrich": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "multi-match - at least one of (clients)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
|
||||
Headers: map[string]string{
|
||||
// clients, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
@@ -401,9 +423,13 @@ func TestRecordCrudList(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "multi-match - all",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
|
||||
Name: "multi-match - all (clients)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
|
||||
Headers: map[string]string{
|
||||
// clients, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
|
||||
+31
-10
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -160,7 +161,11 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
|
||||
err = resolver.UpdateQuery(query)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
err = query.AndWhere(expr).Limit(1).Row(&exists)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -379,12 +384,18 @@ func expandFetch(app core.App, originalRequestInfo *core.RequestInfo) core.Expan
|
||||
|
||||
if *relCollection.ViewRule != "" {
|
||||
resolver := core.NewRecordFieldResolver(app, relCollection, requestInfoPtr, true)
|
||||
|
||||
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
q.AndWhere(expr)
|
||||
|
||||
err = resolver.UpdateQuery(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -465,10 +476,16 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
|
||||
query.AndWhere(expr)
|
||||
|
||||
if err := query.Column(&managedIds); err != nil {
|
||||
err = resolver.UpdateQuery(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = query.Column(&managedIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// ---
|
||||
@@ -537,7 +554,7 @@ func firstApiError(errs ...error) *router.ApiError {
|
||||
return router.NewInternalServerError("", errors.Join(errs...))
|
||||
}
|
||||
|
||||
// execAfterSuccessTx ensures that fn is executed only after a succesul transaction.
|
||||
// execAfterSuccessTx ensures that fn is executed only after a successful transaction.
|
||||
//
|
||||
// If the current app instance is not a transactional or checkTx is false,
|
||||
// then fn is directly executed.
|
||||
@@ -563,13 +580,17 @@ func execAfterSuccessTx(checkTx bool, app core.App, fn func() error) error {
|
||||
const maxAuthOrigins = 5
|
||||
|
||||
func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
|
||||
// generating fingerprint
|
||||
// generate fingerprint
|
||||
// ---
|
||||
ip := e.RealIP()
|
||||
|
||||
userAgent := e.Request.UserAgent()
|
||||
if len(userAgent) > 300 {
|
||||
userAgent = userAgent[:300]
|
||||
if len(userAgent) > 200 {
|
||||
userAgent = userAgent[:200] + "..."
|
||||
}
|
||||
fingerprint := security.MD5(e.RealIP() + userAgent)
|
||||
|
||||
fingerprint := security.MD5(ip + userAgent)
|
||||
alertInfo := fmt.Sprintf("%s - %s %s", types.NowDateTime().String(), ip, userAgent)
|
||||
// ---
|
||||
|
||||
origins, err := e.App.FindAllAuthOriginsByRecord(authRecord)
|
||||
@@ -609,7 +630,7 @@ func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
|
||||
})
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
err := mails.SendRecordAuthAlert(e.App, authRecord)
|
||||
err := mails.SendRecordAuthAlert(e.App, authRecord, alertInfo)
|
||||
timer.Stop()
|
||||
mailSent <- err
|
||||
})
|
||||
|
||||
+12
-9
@@ -240,29 +240,32 @@ type App interface {
|
||||
// This method is a no-op if a table with the provided name doesn't exist.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "tableName" argument must come only from trusted input!
|
||||
DeleteTable(tableName string) error
|
||||
// "dangerousTableName" argument must come only from trusted input!
|
||||
DeleteTable(dangerousTableName string) error
|
||||
|
||||
// DeleteView drops the specified view name.
|
||||
//
|
||||
// This method is a no-op if a view with the provided name doesn't exist.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "name" argument must come only from trusted input!
|
||||
DeleteView(name string) error
|
||||
// "dangerousViewName" argument must come only from trusted input!
|
||||
DeleteView(dangerousViewName string) error
|
||||
|
||||
// SaveView creates (or updates already existing) persistent SQL view.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "selectQuery" argument must come only from trusted input!
|
||||
SaveView(name string, selectQuery string) error
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and
|
||||
// its arguments must come only from trusted input!
|
||||
SaveView(dangerousViewName string, dangerousSelectQuery string) error
|
||||
|
||||
// CreateViewFields creates a new FieldsList from the provided select query.
|
||||
//
|
||||
// There are some caveats:
|
||||
// - The select query must have an "id" column.
|
||||
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
|
||||
CreateViewFields(selectQuery string) (FieldsList, error)
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "dangerousSelectQuery" argument must come only from trusted input!
|
||||
CreateViewFields(dangerousSelectQuery string) (FieldsList, error)
|
||||
|
||||
// FindRecordByViewFile returns the original Record of the provided view collection file.
|
||||
FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
|
||||
@@ -1195,7 +1198,7 @@ type App interface {
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnMailerSend hook is triggered every time when a new email is
|
||||
// being send using the [App.NewMailClient()] instance.
|
||||
// being sent using the [App.NewMailClient()] instance.
|
||||
//
|
||||
// It allows intercepting the email message or to use a custom mailer client.
|
||||
OnMailerSend() *hook.Hook[*MailerEvent]
|
||||
|
||||
+10
-3
@@ -40,6 +40,9 @@ const (
|
||||
LocalBackupsDirName string = "backups"
|
||||
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
|
||||
LocalAutocertCacheDirName string = ".autocert_cache"
|
||||
|
||||
// @todo consider removing after backups refactoring
|
||||
lostFoundDirName string = "lost+found"
|
||||
)
|
||||
|
||||
// FilesManager defines an interface with common methods that files manager models should implement.
|
||||
@@ -1217,7 +1220,7 @@ var sqlLogReplacements = []struct {
|
||||
{regexp.MustCompile(`<nil>`), "NULL"},
|
||||
}
|
||||
|
||||
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
|
||||
// normalizeSQLLog replaces common query builder characters with their plain SQL version for easier debugging.
|
||||
// The query is still not suitable for execution and should be used only for log and debug purposes
|
||||
// (the normalization is done here to avoid breaking changes in dbx).
|
||||
func normalizeSQLLog(sql string) string {
|
||||
@@ -1405,7 +1408,7 @@ func getLoggerMinLevel(app App) slog.Level {
|
||||
func (app *BaseApp) initLogger() error {
|
||||
duration := 3 * time.Second
|
||||
ticker := time.NewTicker(duration)
|
||||
done := make(chan bool)
|
||||
done := make(chan bool, 1)
|
||||
|
||||
handler := logger.NewBatchHandler(logger.BatchOptions{
|
||||
Level: getLoggerMinLevel(app),
|
||||
@@ -1476,7 +1479,11 @@ func (app *BaseApp) initLogger() error {
|
||||
|
||||
ticker.Stop()
|
||||
|
||||
done <- true
|
||||
// don't block in case OnTerminate is triggered more than once
|
||||
select {
|
||||
case done <- true:
|
||||
default:
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
|
||||
+5
-5
@@ -54,7 +54,7 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
event.Context = ctx
|
||||
event.Name = name
|
||||
// default root dir entries to exclude from the backup generation
|
||||
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName}
|
||||
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName, lostFoundDirName}
|
||||
|
||||
return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
|
||||
// generate a default name if missing
|
||||
@@ -69,7 +69,7 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
// archive pb_data in a temp directory, exluding the "backups" and the temp dirs
|
||||
// archive pb_data in a temp directory, excluding the "backups" and the temp dirs
|
||||
//
|
||||
// run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection)
|
||||
// ---
|
||||
@@ -138,9 +138,9 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
//
|
||||
// 4. Move the extracted dir content to the app "pb_data".
|
||||
//
|
||||
// 5. Restart the app (on successful app bootstap it will also remove the old pb_data).
|
||||
// 5. Restart the app (on successful app bootstrap it will also remove the old pb_data).
|
||||
//
|
||||
// If a failure occure during the restore process the dir changes are reverted.
|
||||
// If a failure occur during the restore process the dir changes are reverted.
|
||||
// If for whatever reason the revert is not possible, it panics.
|
||||
//
|
||||
// Note that if your pb_data has custom network mounts as subdirectories, then
|
||||
@@ -159,7 +159,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
event.Context = ctx
|
||||
event.Name = name
|
||||
// default root dir entries to exclude from the backup restore
|
||||
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName}
|
||||
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName, lostFoundDirName}
|
||||
|
||||
return app.OnBackupRestore().Trigger(event, func(e *BackupEvent) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
|
||||
@@ -552,3 +552,19 @@ func TestBaseAppAuxDBDualBuilder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppTriggerOnTerminate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
event := new(core.TerminateEvent)
|
||||
event.App = app
|
||||
|
||||
// trigger OnTerminate multiple times to ensure that it doesn't deadlock
|
||||
// https://github.com/pocketbase/pocketbase/pull/7305
|
||||
app.OnTerminate().Trigger(event)
|
||||
app.OnTerminate().Trigger(event)
|
||||
app.OnTerminate().Trigger(event)
|
||||
}
|
||||
|
||||
@@ -545,7 +545,7 @@ func (m *Collection) UnmarshalJSON(b []byte) error {
|
||||
// MarshalJSON implements the [json.Marshaler] interface.
|
||||
//
|
||||
// Note that non-type related fields are ignored from the serialization
|
||||
// (ex. for "view" colections the "auth" fields are skipped).
|
||||
// (ex. for "view" collections the "auth" fields are skipped).
|
||||
func (m Collection) MarshalJSON() ([]byte, error) {
|
||||
switch m.Type {
|
||||
case CollectionTypeView:
|
||||
|
||||
@@ -2,11 +2,12 @@ package core
|
||||
|
||||
// Common settings placeholder tokens
|
||||
const (
|
||||
EmailPlaceholderAppName string = "{APP_NAME}"
|
||||
EmailPlaceholderAppURL string = "{APP_URL}"
|
||||
EmailPlaceholderToken string = "{TOKEN}"
|
||||
EmailPlaceholderOTP string = "{OTP}"
|
||||
EmailPlaceholderOTPId string = "{OTP_ID}"
|
||||
EmailPlaceholderAppName string = "{APP_NAME}"
|
||||
EmailPlaceholderAppURL string = "{APP_URL}"
|
||||
EmailPlaceholderToken string = "{TOKEN}"
|
||||
EmailPlaceholderOTP string = "{OTP}"
|
||||
EmailPlaceholderOTPId string = "{OTP_ID}"
|
||||
EmailPlaceholderAlertInfo string = "{ALERT_INFO}"
|
||||
)
|
||||
|
||||
var defaultVerificationTemplate = EmailTemplate{
|
||||
@@ -65,9 +66,10 @@ var defaultOTPTemplate = EmailTemplate{
|
||||
var defaultAuthAlertTemplate = EmailTemplate{
|
||||
Subject: "Login from a new location",
|
||||
Body: `<p>Hello,</p>
|
||||
<p>We noticed a login to your ` + EmailPlaceholderAppName + ` account from a new location.</p>
|
||||
<p>If this was you, you may disregard this email.</p>
|
||||
<p>We noticed a login to your ` + EmailPlaceholderAppName + ` account from a new location:</p>
|
||||
<p><em>` + EmailPlaceholderAlertInfo + `</em></p>
|
||||
<p><strong>If this wasn't you, you should immediately change your ` + EmailPlaceholderAppName + ` account password to revoke access from all other locations.</strong></p>
|
||||
<p>If this was you, you may disregard this email.</p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
` + EmailPlaceholderAppName + ` team
|
||||
|
||||
@@ -489,7 +489,7 @@ func (validator *collectionValidator) checkRule(value any) error {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
r := NewRecordFieldResolver(validator.app, validator.new, nil, true)
|
||||
r := NewRecordFieldResolver(validator.app, validator.new, &RequestInfo{}, true)
|
||||
_, err := search.FilterData(vStr).BuildExpr(r)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_rule", "Invalid rule. Raw error: "+err.Error())
|
||||
|
||||
@@ -319,8 +319,8 @@ func TestCollectionValidate(t *testing.T) {
|
||||
c.ListRule = types.Pointer("!invalid")
|
||||
c.ViewRule = types.Pointer("missing = 123")
|
||||
c.CreateRule = types.Pointer("id = 123 && missing = 456")
|
||||
c.UpdateRule = types.Pointer("(id = 123")
|
||||
c.DeleteRule = types.Pointer("missing = 123")
|
||||
c.UpdateRule = types.Pointer("@request.body.missing:changed = false")
|
||||
c.DeleteRule = types.Pointer("(id=123")
|
||||
return c, nil
|
||||
},
|
||||
expectedErrors: []string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
@@ -333,7 +333,7 @@ func TestCollectionValidate(t *testing.T) {
|
||||
c.ListRule = types.Pointer("")
|
||||
c.ViewRule = types.Pointer("f1 = 123")
|
||||
c.CreateRule = types.Pointer("id = 123 && f1 = 456")
|
||||
c.UpdateRule = types.Pointer("(id = 123)")
|
||||
c.UpdateRule = types.Pointer("(id = 123 && @request.body.f1:changed = false)")
|
||||
c.DeleteRule = types.Pointer("f1 = 123")
|
||||
return c, nil
|
||||
},
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ func DefaultDBConnect(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=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-16000)"
|
||||
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)"
|
||||
|
||||
db, err := dbx.Open("sqlite", dbPath+pragmas)
|
||||
if err != nil {
|
||||
|
||||
+3
-3
@@ -85,11 +85,11 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
|
||||
// This method is a no-op if a table with the provided name doesn't exist.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "tableName" argument must come only from trusted input!
|
||||
func (app *BaseApp) DeleteTable(tableName string) error {
|
||||
// "dangerousTableName" argument must come only from trusted input!
|
||||
func (app *BaseApp) DeleteTable(dangerousTableName string) error {
|
||||
_, err := app.NonconcurrentDB().NewQuery(fmt.Sprintf(
|
||||
"DROP TABLE IF EXISTS {{%s}}",
|
||||
tableName,
|
||||
dangerousTableName,
|
||||
)).Execute()
|
||||
|
||||
return err
|
||||
|
||||
@@ -384,6 +384,13 @@ type FileDownloadRequestEvent struct {
|
||||
FileField *FileField
|
||||
ServedPath string
|
||||
ServedName string
|
||||
|
||||
// ThumbError indicates the a thumb wasn't able to be generated
|
||||
// (e.g. because it didn't satisfy the support image formats or it timed out).
|
||||
//
|
||||
// Note that PocketBase fallbacks to the original file in case of a thumb error,
|
||||
// but developers can check the field and provide their own custom thumb generation if necessary.
|
||||
ThumbError error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ var (
|
||||
// FileField defines "file" type field for managing record file(s).
|
||||
//
|
||||
// Only the file name is stored as part of the record value.
|
||||
// New files (aka. files to upload) are expected to be of *filesytem.File.
|
||||
// New files (aka. files to upload) are expected to be of *filesystem.File.
|
||||
//
|
||||
// If MaxSelect is not set or <= 1, then the field value is expected to be a single record id.
|
||||
//
|
||||
|
||||
+39
-14
@@ -31,6 +31,22 @@ var (
|
||||
_ RecordInterceptor = (*TextField)(nil)
|
||||
)
|
||||
|
||||
var forbiddenPKCharacters = []string{
|
||||
".", "/", `\`, "|", `"`, "'", "`",
|
||||
"<", ">", ":", "?", "*", "%", "$",
|
||||
"\000", "\t", "\n", "\r", " ",
|
||||
}
|
||||
|
||||
// (see largestReservedPKLength)
|
||||
var caseInsensitiveReservedPKs = []string{
|
||||
// reserved Windows files names
|
||||
"CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
}
|
||||
|
||||
const largestReservedPKLength = 4
|
||||
|
||||
// TextField defines "text" type field for storing any string value.
|
||||
//
|
||||
// The respective zero record field value is empty string.
|
||||
@@ -155,8 +171,6 @@ func (f *TextField) PrepareValue(record *Record, raw any) (any, error) {
|
||||
return cast.ToString(raw), nil
|
||||
}
|
||||
|
||||
var forbiddenPKChars = []string{"/", "\\"}
|
||||
|
||||
// ValidateValue implements [Field.ValidateValue] interface method.
|
||||
func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record) error {
|
||||
newVal, ok := record.GetRaw(f.Name).(string)
|
||||
@@ -178,15 +192,6 @@ func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// disallow PK special characters no matter of the Pattern validator to minimize
|
||||
// side-effects when the primary key is used for example in a directory path
|
||||
for _, c := range forbiddenPKChars {
|
||||
if strings.Contains(newVal, c) {
|
||||
return validation.NewError("validation_pk_forbidden", "The record primary key contains forbidden characters.").
|
||||
SetParams(map[string]any{"forbidden": c})
|
||||
}
|
||||
}
|
||||
|
||||
// this technically shouldn't be necessarily but again to
|
||||
// minimize misuse of the Pattern validator that could cause
|
||||
// side-effects on some platforms check for duplicates in a case-insensitive manner
|
||||
@@ -226,7 +231,7 @@ func (f *TextField) ValidatePlainValue(value string) error {
|
||||
length := len([]rune(value))
|
||||
|
||||
if f.Min > 0 && length < f.Min {
|
||||
return validation.NewError("validation_min_text_constraint", "Must be at least {{.min}} character(s)").
|
||||
return validation.NewError("validation_min_text_constraint", "Must be at least {{.min}} character(s).").
|
||||
SetParams(map[string]any{"min": f.Min})
|
||||
}
|
||||
|
||||
@@ -236,14 +241,34 @@ func (f *TextField) ValidatePlainValue(value string) error {
|
||||
}
|
||||
|
||||
if max > 0 && length > max {
|
||||
return validation.NewError("validation_max_text_constraint", "Must be no more than {{.max}} character(s)").
|
||||
return validation.NewError("validation_max_text_constraint", "Must be no more than {{.max}} character(s).").
|
||||
SetParams(map[string]any{"max": max})
|
||||
}
|
||||
|
||||
if f.Pattern != "" {
|
||||
match, _ := regexp.MatchString(f.Pattern, value)
|
||||
if !match {
|
||||
return validation.NewError("validation_invalid_format", "Invalid value format")
|
||||
return validation.NewError("validation_invalid_format", "Invalid value format.")
|
||||
}
|
||||
}
|
||||
|
||||
// additional primary key checks to minimize eventual filesystem compatibility issues
|
||||
// because the primary key is often used as a file/directory name
|
||||
if f.PrimaryKey && f.Pattern != defaultLowercaseRecordIdPattern {
|
||||
for _, ch := range forbiddenPKCharacters {
|
||||
if strings.Contains(value, ch) {
|
||||
return validation.NewError("validation_forbidden_pk_character", "'{{.ch}}' is not a valid primary key character.").
|
||||
SetParams(map[string]any{"ch": ch})
|
||||
}
|
||||
}
|
||||
|
||||
if largestReservedPKLength >= length {
|
||||
for _, reserved := range caseInsensitiveReservedPKs {
|
||||
if strings.EqualFold(value, reserved) {
|
||||
return validation.NewError("validation_reserved_pk", "The primary key '{{.reserved}}' is reserved and cannot be used.").
|
||||
SetParams(map[string]any{"reserved": reserved})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+94
-4
@@ -129,7 +129,7 @@ func TestTextFieldValidateValue(t *testing.T) {
|
||||
&core.TextField{Name: "test", PrimaryKey: false},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "/")
|
||||
record.SetRaw("test", "abc/")
|
||||
return record
|
||||
},
|
||||
false,
|
||||
@@ -139,7 +139,37 @@ func TestTextFieldValidateValue(t *testing.T) {
|
||||
&core.TextField{Name: "test", PrimaryKey: false},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "\\")
|
||||
record.SetRaw("test", "abc\\")
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"special forbidden character . (non-primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: false},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "abc.")
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"special forbidden character ' ' (non-primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: false},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "ab c")
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"special forbidden character * (non-primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: false},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "abc*")
|
||||
return record
|
||||
},
|
||||
false,
|
||||
@@ -149,7 +179,7 @@ func TestTextFieldValidateValue(t *testing.T) {
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "/")
|
||||
record.SetRaw("test", "abc/")
|
||||
return record
|
||||
},
|
||||
true,
|
||||
@@ -159,11 +189,71 @@ func TestTextFieldValidateValue(t *testing.T) {
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "\\")
|
||||
record.SetRaw("test", "abc\\")
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"special forbidden character . (primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "abc.")
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"special forbidden character ' ' (primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "ab c")
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"special forbidden character * (primaryKey; used in the realtime events too)",
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "abc*")
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"reserved pk literal (non-primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: false},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "aUx")
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"reserved pk literal (primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "aUx")
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"reserved pk literal (non-exact match, primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "aUx-")
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"zero field value (primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ func marshaledJSONtoFieldsList(rawJSON []byte) (FieldsList, error) {
|
||||
return extractedFields, nil
|
||||
}
|
||||
|
||||
// try to unmarshal first into a new fieds list
|
||||
// try to unmarshal first into a new fields list
|
||||
// (assuming that rawJSON is array of objects)
|
||||
err := json.Unmarshal(rawJSON, &extractedFields)
|
||||
if err != nil {
|
||||
|
||||
+210
-22
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
@@ -17,12 +18,18 @@ import (
|
||||
|
||||
// filter modifiers
|
||||
const (
|
||||
eachModifier string = "each"
|
||||
issetModifier string = "isset"
|
||||
lengthModifier string = "length"
|
||||
lowerModifier string = "lower"
|
||||
eachModifier string = "each"
|
||||
issetModifier string = "isset"
|
||||
lengthModifier string = "length"
|
||||
lowerModifier string = "lower"
|
||||
changedModifier string = "changed"
|
||||
)
|
||||
|
||||
type ruleJoin struct {
|
||||
collection *Collection
|
||||
tableAlias string
|
||||
}
|
||||
|
||||
// ensure that `search.FieldResolver` interface is implemented
|
||||
var _ search.FieldResolver = (*RecordFieldResolver)(nil)
|
||||
|
||||
@@ -46,8 +53,12 @@ type RecordFieldResolver struct {
|
||||
requestInfo *RequestInfo
|
||||
staticRequestInfo map[string]any
|
||||
allowedFields []string
|
||||
joins []*join
|
||||
joins []*search.Join
|
||||
allowHiddenFields bool
|
||||
// ---
|
||||
listRuleJoins []ruleJoin
|
||||
joinAliasSuffix string // used for uniqueness in the flatten collection list rule join
|
||||
baseCollectionAlias string
|
||||
}
|
||||
|
||||
// AllowedFields returns a copy of the resolver's allowed fields.
|
||||
@@ -82,7 +93,7 @@ func NewRecordFieldResolver(
|
||||
baseCollection: baseCollection,
|
||||
requestInfo: requestInfo,
|
||||
allowHiddenFields: allowHiddenFields, // note: it is not based only on the requestInfo.auth since it could be used by a non-request internal method
|
||||
joins: []*join{},
|
||||
joins: []*search.Join{},
|
||||
allowedFields: []string{
|
||||
`^\w+[\w\.\:]*$`,
|
||||
`^\@request\.context$`,
|
||||
@@ -115,18 +126,80 @@ func NewRecordFieldResolver(
|
||||
return r
|
||||
}
|
||||
|
||||
// @todo think of a better a way how to call it automatically after BuildExpr
|
||||
//
|
||||
// UpdateQuery implements `search.FieldResolver` interface.
|
||||
//
|
||||
// Conditionally updates the provided search query based on the
|
||||
// resolved fields (eg. dynamically joining relations).
|
||||
func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
|
||||
if len(r.joins) > 0 {
|
||||
query.Distinct(true)
|
||||
r.updateQueryWithDeduplicateConstraint(query)
|
||||
|
||||
for _, join := range r.joins {
|
||||
query.LeftJoin(
|
||||
(join.tableName + " " + join.tableAlias),
|
||||
join.on,
|
||||
(join.TableName + " " + join.TableAlias),
|
||||
join.On,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// note: for now the joins are not applied for multi-match conditions to avoid excessive checks
|
||||
if len(r.listRuleJoins) > 0 {
|
||||
for _, join := range r.listRuleJoins {
|
||||
err := r.updateQueryWithCollectionListRule(join.collection, join.tableAlias, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RecordFieldResolver) updateQueryWithCollectionListRule(c *Collection, tableAlias string, query *dbx.SelectQuery) error {
|
||||
if r.allowHiddenFields || c == nil || c.ListRule == nil || *c.ListRule == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloneR := *r
|
||||
cloneR.joins = []*search.Join{}
|
||||
cloneR.baseCollection = c
|
||||
cloneR.baseCollectionAlias = tableAlias
|
||||
cloneR.allowHiddenFields = true
|
||||
cloneR.joinAliasSuffix = security.PseudorandomString(8)
|
||||
|
||||
// The extra "id='' || (\nRULE\n)" concatenated part on its own
|
||||
// doesn't make much sense because all records are required to have an id,
|
||||
// but it is necessary to properly resolve client-side filters when
|
||||
// referencing missing relations (the "\n" is for leading and trailing comments).
|
||||
//
|
||||
// Consider the client-side filter: "a.name != '' || b.name != ''",
|
||||
// where both "a" and "b" ref collections have non-empty ListRule.
|
||||
// Without the empty check the query will always evaluate to FALSE
|
||||
// when one of the "a" or "b" relation fields are empty,
|
||||
// even if for example "a.name != ''" is true.
|
||||
expr, err := search.FilterData("id='' || (\n" + *c.ListRule + "\n)").BuildExpr(&cloneR)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build %q ListRule join subquery filter expression: %w", c.Name, err)
|
||||
}
|
||||
|
||||
// Bind the extra rule expression at the top query level for performance and security reasons
|
||||
// (it is more strict and minimizes the risk of data disclosure from a side-channel attack).
|
||||
//
|
||||
// @todo Investigate with the refactoring if there is a way to group it
|
||||
// together with the client-side constraint that invoked it and benchmark
|
||||
// it with many (tag.name="1"||...) like statements to evaluate the impact of applying the check many times.
|
||||
// If not feasible - document it as caveat and maybe add --dev log.
|
||||
query.AndWhere(expr)
|
||||
|
||||
if len(cloneR.joins) > 0 {
|
||||
r.updateQueryWithDeduplicateConstraint(query)
|
||||
|
||||
for _, j := range cloneR.joins {
|
||||
query.LeftJoin(
|
||||
(j.TableName + " " + j.TableAlias),
|
||||
j.On,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -134,6 +207,79 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RecordFieldResolver) updateQueryWithDeduplicateConstraint(query *dbx.SelectQuery) {
|
||||
query.Distinct(true)
|
||||
|
||||
// @todo Research better options for generic rows deduplication.
|
||||
//
|
||||
// Disable the GROUP BY conditional checks for now since it prevents
|
||||
// proper utilization of ORDER BY indexes (and maybe others)
|
||||
// (https://github.com/pocketbase/pocketbase/discussions/7461)
|
||||
|
||||
// info := query.Info()
|
||||
// if info.Distinct {
|
||||
// return
|
||||
// }
|
||||
|
||||
// // already has the group by registered
|
||||
// var groupByCol = r.baseCollection.Name
|
||||
// if r.baseCollectionAlias != "" {
|
||||
// groupByCol = r.baseCollectionAlias
|
||||
// }
|
||||
// groupByCol += ".id"
|
||||
// if len(info.GroupBy) > 0 && info.GroupBy[0] == groupByCol {
|
||||
// return
|
||||
// }
|
||||
|
||||
// // when deemed safe (GROUP BY could have different execution order compared to DISTINCT),
|
||||
// // prefer GROUP BY to deduplicate only on the id field instead of all columns
|
||||
// // so that the size of a single row wouldn't matter that much
|
||||
// if preferGroupBy(info, groupByCol) {
|
||||
// query.GroupBy(groupByCol)
|
||||
// } else {
|
||||
// query.Distinct(true)
|
||||
// }
|
||||
}
|
||||
|
||||
func preferGroupBy(info *dbx.QueryInfo, fullUnquotedGroupByCol string) bool {
|
||||
if len(info.GroupBy) != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if info.Having != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// dbx fallbacks to * if not set
|
||||
if len(info.Selects) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(info.Selects) != 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
identifier := info.Selects[0]
|
||||
|
||||
if identifier == "*" || identifier == fullUnquotedGroupByCol {
|
||||
return true
|
||||
}
|
||||
|
||||
// try again as direct col match in an unquoted column format
|
||||
identifier = inflector.Columnify(identifier)
|
||||
if identifier == fullUnquotedGroupByCol {
|
||||
return true
|
||||
}
|
||||
|
||||
// remains table.* to check
|
||||
// (aliased columns for now are ignored as they could be represented by expressions)
|
||||
if !strings.HasSuffix(identifier, ".*") {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(fullUnquotedGroupByCol, strings.TrimSuffix(identifier, "*"))
|
||||
}
|
||||
|
||||
// Resolve implements `search.FieldResolver` interface.
|
||||
//
|
||||
// Example of some resolvable fieldName formats:
|
||||
@@ -186,7 +332,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
|
||||
|
||||
switch v := resultVal.(type) {
|
||||
case nil:
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
// no further processing is needed...
|
||||
case string:
|
||||
// check if it is a number field and explicitly try to cast to
|
||||
// float in case of a numeric string value was used
|
||||
@@ -202,7 +348,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
|
||||
// no further processing is needed...
|
||||
default:
|
||||
// non-plain value
|
||||
// try casting to string (in case for exampe fmt.Stringer is implemented)
|
||||
// try casting to string (in case for example fmt.Stringer is implemented)
|
||||
val, castErr := cast.ToStringE(v)
|
||||
|
||||
// if that doesn't work, try encoding it
|
||||
@@ -216,8 +362,20 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
|
||||
resultVal = val
|
||||
}
|
||||
|
||||
placeholder := "f" + security.PseudorandomString(8)
|
||||
// unsupported modifier
|
||||
// @todo consider deprecating with the introduction of filter functions
|
||||
if modifier != "" && modifier != lowerModifier {
|
||||
return nil, fmt.Errorf("invalid modifier sequence %s:%s", lastProp, modifier)
|
||||
}
|
||||
|
||||
// no need to wrap as placeholder if we already know that it is null
|
||||
if resultVal == nil {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
}
|
||||
|
||||
placeholder := "f" + security.PseudorandomString(10)
|
||||
|
||||
// @todo consider deprecating with the introduction of filter functions
|
||||
if modifier == lowerModifier {
|
||||
return &search.ResolverResult{
|
||||
Identifier: "LOWER({:" + placeholder + "})",
|
||||
@@ -239,23 +397,52 @@ func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*Collec
|
||||
return getCollectionByModelOrIdentifier(r.app, collectionNameOrId)
|
||||
}
|
||||
|
||||
func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) {
|
||||
join := &join{
|
||||
tableName: tableName,
|
||||
tableAlias: tableAlias,
|
||||
on: on,
|
||||
func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) error {
|
||||
newJoin := &search.Join{
|
||||
TableName: tableName,
|
||||
TableAlias: tableAlias,
|
||||
On: on,
|
||||
}
|
||||
|
||||
// (see updateQueryWithCollectionListRule)
|
||||
if !r.allowHiddenFields {
|
||||
c, _ := r.loadCollection(tableName)
|
||||
|
||||
// ignore non-collections since the table name could be an expression (e.g. json) or some other subquery
|
||||
if c != nil {
|
||||
// treat all fields as if they are hidden
|
||||
if c.ListRule == nil {
|
||||
return fmt.Errorf("%q fields can be accessed only when allowHiddenFields is enabled or by superusers", c.Name)
|
||||
}
|
||||
|
||||
r.registerRuleJoin(c, newJoin.TableAlias)
|
||||
}
|
||||
}
|
||||
|
||||
// replace existing join
|
||||
for i, j := range r.joins {
|
||||
if j.tableAlias == join.tableAlias {
|
||||
r.joins[i] = join
|
||||
return
|
||||
if j.TableAlias == newJoin.TableAlias {
|
||||
r.joins[i] = newJoin
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// register new join
|
||||
r.joins = append(r.joins, join)
|
||||
r.joins = append(r.joins, newJoin)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RecordFieldResolver) registerRuleJoin(collection *Collection, tableAlias string) {
|
||||
// replace existing
|
||||
for i, j := range r.listRuleJoins {
|
||||
if j.tableAlias == tableAlias {
|
||||
r.listRuleJoins[i].collection = collection
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// register new
|
||||
r.listRuleJoins = append(r.listRuleJoins, ruleJoin{collection, tableAlias})
|
||||
}
|
||||
|
||||
type mapExtractor interface {
|
||||
@@ -395,7 +582,8 @@ func splitModifier(combined string) (string, string, error) {
|
||||
case issetModifier,
|
||||
eachModifier,
|
||||
lengthModifier,
|
||||
lowerModifier:
|
||||
lowerModifier,
|
||||
changedModifier:
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ dbx.Expression = (*multiMatchSubquery)(nil)
|
||||
|
||||
// join defines the specification for a single SQL JOIN clause.
|
||||
type join struct {
|
||||
tableName string
|
||||
tableAlias string
|
||||
on dbx.Expression
|
||||
}
|
||||
|
||||
// multiMatchSubquery defines a record multi-match subquery expression.
|
||||
type multiMatchSubquery struct {
|
||||
baseTableAlias string
|
||||
fromTableName string
|
||||
fromTableAlias string
|
||||
valueIdentifier string
|
||||
joins []*join
|
||||
params dbx.Params
|
||||
}
|
||||
|
||||
// Build converts the expression into a SQL fragment.
|
||||
//
|
||||
// Implements [dbx.Expression] interface.
|
||||
func (m *multiMatchSubquery) Build(db *dbx.DB, params dbx.Params) string {
|
||||
if m.baseTableAlias == "" || m.fromTableName == "" || m.fromTableAlias == "" {
|
||||
return "0=1"
|
||||
}
|
||||
|
||||
if params == nil {
|
||||
params = m.params
|
||||
} else {
|
||||
// merge by updating the parent params
|
||||
for k, v := range m.params {
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
var mergedJoins strings.Builder
|
||||
for i, j := range m.joins {
|
||||
if i > 0 {
|
||||
mergedJoins.WriteString(" ")
|
||||
}
|
||||
mergedJoins.WriteString("LEFT JOIN ")
|
||||
mergedJoins.WriteString(db.QuoteTableName(j.tableName))
|
||||
mergedJoins.WriteString(" ")
|
||||
mergedJoins.WriteString(db.QuoteTableName(j.tableAlias))
|
||||
if j.on != nil {
|
||||
mergedJoins.WriteString(" ON ")
|
||||
mergedJoins.WriteString(j.on.Build(db, params))
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
`SELECT %s as [[multiMatchValue]] FROM %s %s %s WHERE %s = %s`,
|
||||
db.QuoteColumnName(m.valueIdentifier),
|
||||
db.QuoteTableName(m.fromTableName),
|
||||
db.QuoteTableName(m.fromTableAlias),
|
||||
mergedJoins.String(),
|
||||
db.QuoteColumnName(m.fromTableAlias+".id"),
|
||||
db.QuoteColumnName(m.baseTableAlias+".id"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ dbx.Expression = (*replaceWithExpression)(nil)
|
||||
|
||||
// replaceWithExpression defines a custom expression that will replace
|
||||
// a placeholder identifier found in "old" with the result of "new".
|
||||
type replaceWithExpression struct {
|
||||
placeholder string
|
||||
old dbx.Expression
|
||||
new dbx.Expression
|
||||
}
|
||||
|
||||
// Build converts the expression into a SQL fragment.
|
||||
//
|
||||
// Implements [dbx.Expression] interface.
|
||||
func (e *replaceWithExpression) Build(db *dbx.DB, params dbx.Params) string {
|
||||
if e.placeholder == "" || e.old == nil || e.new == nil {
|
||||
return "0=1"
|
||||
}
|
||||
|
||||
oldResult := e.old.Build(db, params)
|
||||
newResult := e.new.Build(db, params)
|
||||
|
||||
return strings.ReplaceAll(oldResult, e.placeholder, newResult)
|
||||
}
|
||||
@@ -49,14 +49,13 @@ type runner struct {
|
||||
|
||||
// shared processing state
|
||||
// ---------------------------------------------------------------
|
||||
activeProps []string // holds the active props that remains to be processed
|
||||
activeCollectionName string // the last used collection name
|
||||
activeTableAlias string // the last used table alias
|
||||
allowHiddenFields bool // indicates whether hidden fields (eg. email) should be allowed without extra conditions
|
||||
nullifyMisingField bool // indicating whether to return null on missing field or return an error
|
||||
withMultiMatch bool // indicates whether to attach a multiMatchSubquery condition to the ResolverResult
|
||||
multiMatchActiveTableAlias string // the last used multi-match table alias
|
||||
multiMatch *multiMatchSubquery // the multi-match subquery expression generated from the fieldName
|
||||
activeProps []string // holds the active props that remains to be processed
|
||||
activeCollectionName string // the last used collection name
|
||||
activeTableAlias string // the last used table alias
|
||||
nullifyMisingField bool // indicating whether to return null on missing field or return an error
|
||||
withMultiMatch bool // indicates whether to attach a MultiMatchSubquery condition to the ResolverResult
|
||||
multiMatchActiveTableAlias string // the last used multi-match table alias
|
||||
multiMatch *search.MultiMatchSubquery // the multi-match subquery expression generated from the fieldName
|
||||
}
|
||||
|
||||
func (r *runner) run() (*search.ResolverResult, error) {
|
||||
@@ -81,6 +80,7 @@ func (r *runner) run() (*search.ResolverResult, error) {
|
||||
}
|
||||
|
||||
if r.activeProps[0] == "@request" {
|
||||
// @todo consider returning an error instead?
|
||||
if r.resolver.requestInfo == nil {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
}
|
||||
@@ -89,7 +89,9 @@ func (r *runner) run() (*search.ResolverResult, error) {
|
||||
return r.processRequestAuthField()
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.fieldName, "@request.body.") && len(r.activeProps) > 2 {
|
||||
totalProps := len(r.activeProps)
|
||||
|
||||
if strings.HasPrefix(r.fieldName, "@request.body.") && totalProps > 2 {
|
||||
name, modifier, err := splitModifier(r.activeProps[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -101,23 +103,21 @@ func (r *runner) run() (*search.ResolverResult, error) {
|
||||
}
|
||||
|
||||
// check for body relation field
|
||||
if bodyField.Type() == FieldTypeRelation && len(r.activeProps) > 3 {
|
||||
return r.processRequestInfoRelationField(bodyField)
|
||||
if bodyField.Type() == FieldTypeRelation && totalProps > 3 {
|
||||
return r.processRequestBodyRelationField(bodyField)
|
||||
}
|
||||
|
||||
// check for body arrayble fields ":each" modifier
|
||||
if modifier == eachModifier && len(r.activeProps) == 3 {
|
||||
return r.processRequestInfoEachModifier(bodyField)
|
||||
}
|
||||
|
||||
// check for body arrayble fields ":length" modifier
|
||||
if modifier == lengthModifier && len(r.activeProps) == 3 {
|
||||
return r.processRequestInfoLengthModifier(bodyField)
|
||||
}
|
||||
|
||||
// check for body arrayble fields ":lower" modifier
|
||||
if modifier == lowerModifier && len(r.activeProps) == 3 {
|
||||
return r.processRequestInfoLowerModifier(bodyField)
|
||||
if totalProps == 3 { // aka. last prop
|
||||
switch modifier {
|
||||
case eachModifier:
|
||||
return r.processRequestBodyEachModifier(bodyField)
|
||||
case lengthModifier:
|
||||
return r.processRequestBodyLengthModifier(bodyField)
|
||||
case lowerModifier:
|
||||
return r.processRequestBodyLowerModifier(bodyField)
|
||||
case changedModifier:
|
||||
return r.processRequestBodyChangedModifier(bodyField)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,12 +133,10 @@ func (r *runner) prepare() {
|
||||
r.activeProps = strings.Split(r.fieldName, ".")
|
||||
|
||||
r.activeCollectionName = r.resolver.baseCollection.Name
|
||||
r.activeTableAlias = inflector.Columnify(r.activeCollectionName)
|
||||
|
||||
r.allowHiddenFields = r.resolver.allowHiddenFields
|
||||
// always allow hidden fields since the @.* filter is a system one
|
||||
if r.activeProps[0] == "@collection" || r.activeProps[0] == "@request" {
|
||||
r.allowHiddenFields = true
|
||||
if r.resolver.baseCollectionAlias == "" {
|
||||
r.activeTableAlias = inflector.Columnify(r.activeCollectionName)
|
||||
} else {
|
||||
r.activeTableAlias = r.resolver.baseCollectionAlias
|
||||
}
|
||||
|
||||
// enable the ignore flag for missing @request.* fields for backward
|
||||
@@ -146,13 +144,13 @@ func (r *runner) prepare() {
|
||||
r.nullifyMisingField = r.activeProps[0] == "@request"
|
||||
|
||||
// prepare a multi-match subquery
|
||||
r.multiMatch = &multiMatchSubquery{
|
||||
baseTableAlias: r.activeTableAlias,
|
||||
params: dbx.Params{},
|
||||
r.multiMatch = &search.MultiMatchSubquery{
|
||||
TargetTableAlias: r.activeTableAlias,
|
||||
Params: dbx.Params{},
|
||||
}
|
||||
r.multiMatch.fromTableName = inflector.Columnify(r.activeCollectionName)
|
||||
r.multiMatch.fromTableAlias = "__mm_" + r.activeTableAlias
|
||||
r.multiMatchActiveTableAlias = r.multiMatch.fromTableAlias
|
||||
r.multiMatch.FromTableName = inflector.Columnify(r.activeCollectionName)
|
||||
r.multiMatch.FromTableAlias = "__mm_" + r.activeTableAlias
|
||||
r.multiMatchActiveTableAlias = r.multiMatch.FromTableAlias
|
||||
r.withMultiMatch = false
|
||||
}
|
||||
|
||||
@@ -172,21 +170,24 @@ func (r *runner) processCollectionField() (*search.ResolverResult, error) {
|
||||
r.activeCollectionName = collection.Name
|
||||
|
||||
if len(collectionParts) == 2 && collectionParts[1] != "" {
|
||||
r.activeTableAlias = inflector.Columnify("__collection_alias_" + collectionParts[1])
|
||||
r.activeTableAlias = inflector.Columnify("__collection_alias_"+collectionParts[1]) + r.resolver.joinAliasSuffix
|
||||
} else {
|
||||
r.activeTableAlias = inflector.Columnify("__collection_" + r.activeCollectionName)
|
||||
r.activeTableAlias = inflector.Columnify("__collection_"+r.activeCollectionName) + r.resolver.joinAliasSuffix
|
||||
}
|
||||
|
||||
r.withMultiMatch = true
|
||||
|
||||
// join the collection to the main query
|
||||
r.resolver.registerJoin(inflector.Columnify(collection.Name), r.activeTableAlias, nil)
|
||||
err = r.resolver.registerJoin(inflector.Columnify(collection.Name), r.activeTableAlias, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// join the collection to the multi-match subquery
|
||||
r.multiMatchActiveTableAlias = "__mm" + r.activeTableAlias
|
||||
r.multiMatch.joins = append(r.multiMatch.joins, &join{
|
||||
tableName: inflector.Columnify(collection.Name),
|
||||
tableAlias: r.multiMatchActiveTableAlias,
|
||||
r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
|
||||
r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
|
||||
TableName: inflector.Columnify(collection.Name),
|
||||
TableAlias: r.multiMatchActiveTableAlias,
|
||||
})
|
||||
|
||||
// leave only the collection fields
|
||||
@@ -212,10 +213,10 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
||||
collection := r.resolver.requestInfo.Auth.Collection()
|
||||
|
||||
r.activeCollectionName = collection.Name
|
||||
r.activeTableAlias = "__auth_" + inflector.Columnify(r.activeCollectionName)
|
||||
r.activeTableAlias = "__auth_" + inflector.Columnify(r.activeCollectionName) + r.resolver.joinAliasSuffix
|
||||
|
||||
// join the auth collection to the main query
|
||||
r.resolver.registerJoin(
|
||||
err := r.resolver.registerJoin(
|
||||
inflector.Columnify(r.activeCollectionName),
|
||||
r.activeTableAlias,
|
||||
dbx.HashExp{
|
||||
@@ -223,15 +224,18 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
||||
(r.activeTableAlias + ".id"): r.resolver.requestInfo.Auth.Id,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// join the auth collection to the multi-match subquery
|
||||
r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
|
||||
r.multiMatch.joins = append(
|
||||
r.multiMatch.joins,
|
||||
&join{
|
||||
tableName: inflector.Columnify(r.activeCollectionName),
|
||||
tableAlias: r.multiMatchActiveTableAlias,
|
||||
on: dbx.HashExp{
|
||||
r.multiMatch.Joins = append(
|
||||
r.multiMatch.Joins,
|
||||
&search.Join{
|
||||
TableName: inflector.Columnify(r.activeCollectionName),
|
||||
TableAlias: r.multiMatchActiveTableAlias,
|
||||
On: dbx.HashExp{
|
||||
(r.multiMatchActiveTableAlias + ".id"): r.resolver.requestInfo.Auth.Id,
|
||||
},
|
||||
},
|
||||
@@ -268,10 +272,37 @@ func toSlice(value any) []any {
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *runner) processRequestInfoLowerModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||
func (r *runner) processRequestBodyChangedModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||
name := bodyField.GetName()
|
||||
|
||||
alias := search.FilterData(fmt.Sprintf("@request.body.%s:isset = true && @request.body.%s != %s", name, name, name))
|
||||
|
||||
aliasExpr, err := alias.BuildExpr(r.resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
placeholder := "@changed@" + name + security.PseudorandomString(8)
|
||||
|
||||
result := &search.ResolverResult{
|
||||
Identifier: placeholder,
|
||||
NullFallback: search.NullFallbackDisabled,
|
||||
AfterBuild: func(expr dbx.Expression) dbx.Expression {
|
||||
return &replaceWithExpression{
|
||||
placeholder: placeholder,
|
||||
old: expr,
|
||||
new: aliasExpr,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *runner) processRequestBodyLowerModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||
rawValue := cast.ToString(r.resolver.requestInfo.Body[bodyField.GetName()])
|
||||
|
||||
placeholder := "infoLower" + bodyField.GetName() + security.PseudorandomString(6)
|
||||
placeholder := "infoLower" + bodyField.GetName() + security.PseudorandomString(8)
|
||||
|
||||
result := &search.ResolverResult{
|
||||
Identifier: "LOWER({:" + placeholder + "})",
|
||||
@@ -281,7 +312,7 @@ func (r *runner) processRequestInfoLowerModifier(bodyField Field) (*search.Resol
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *runner) processRequestInfoLengthModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||
func (r *runner) processRequestBodyLengthModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||
if _, ok := bodyField.(MultiValuer); !ok {
|
||||
return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName())
|
||||
}
|
||||
@@ -295,7 +326,7 @@ func (r *runner) processRequestInfoLengthModifier(bodyField Field) (*search.Reso
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *runner) processRequestInfoEachModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||
func (r *runner) processRequestBodyEachModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||
multiValuer, ok := bodyField.(MultiValuer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName())
|
||||
@@ -307,11 +338,15 @@ func (r *runner) processRequestInfoEachModifier(bodyField Field) (*search.Resolv
|
||||
return nil, fmt.Errorf("cannot serialize the data for field %q", r.activeProps[2])
|
||||
}
|
||||
|
||||
placeholder := "dataEach" + security.PseudorandomString(6)
|
||||
placeholder := "dataEach" + security.PseudorandomString(8)
|
||||
cleanFieldName := inflector.Columnify(bodyField.GetName())
|
||||
jeTable := fmt.Sprintf("json_each({:%s})", placeholder)
|
||||
jeAlias := "__dataEach_" + cleanFieldName + "_je"
|
||||
r.resolver.registerJoin(jeTable, jeAlias, nil)
|
||||
jeAlias := "__dataEach_je_" + cleanFieldName + r.resolver.joinAliasSuffix
|
||||
|
||||
err = r.resolver.registerJoin(jeTable, jeAlias, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &search.ResolverResult{
|
||||
Identifier: fmt.Sprintf("[[%s.value]]", jeAlias),
|
||||
@@ -325,14 +360,14 @@ func (r *runner) processRequestInfoEachModifier(bodyField Field) (*search.Resolv
|
||||
if r.withMultiMatch {
|
||||
placeholder2 := "mm" + placeholder
|
||||
jeTable2 := fmt.Sprintf("json_each({:%s})", placeholder2)
|
||||
jeAlias2 := "__mm" + jeAlias
|
||||
jeAlias2 := "__mm_" + jeAlias
|
||||
|
||||
r.multiMatch.joins = append(r.multiMatch.joins, &join{
|
||||
tableName: jeTable2,
|
||||
tableAlias: jeAlias2,
|
||||
r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
|
||||
TableName: jeTable2,
|
||||
TableAlias: jeAlias2,
|
||||
})
|
||||
r.multiMatch.params[placeholder2] = bodyItemsRaw
|
||||
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
|
||||
r.multiMatch.Params[placeholder2] = bodyItemsRaw
|
||||
r.multiMatch.ValueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
|
||||
|
||||
result.MultiMatchSubQuery = r.multiMatch
|
||||
}
|
||||
@@ -340,7 +375,7 @@ func (r *runner) processRequestInfoEachModifier(bodyField Field) (*search.Resolv
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *runner) processRequestInfoRelationField(bodyField Field) (*search.ResolverResult, error) {
|
||||
func (r *runner) processRequestBodyRelationField(bodyField Field) (*search.ResolverResult, error) {
|
||||
relField, ok := bodyField.(*RelationField)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to initialize data relation field %q", bodyField.GetName())
|
||||
@@ -360,10 +395,10 @@ func (r *runner) processRequestInfoRelationField(bodyField Field) (*search.Resol
|
||||
}
|
||||
|
||||
r.activeCollectionName = dataRelCollection.Name
|
||||
r.activeTableAlias = inflector.Columnify("__data_" + dataRelCollection.Name + "_" + relField.Name)
|
||||
r.activeTableAlias = inflector.Columnify("__data_"+dataRelCollection.Name+"_"+relField.Name) + r.resolver.joinAliasSuffix
|
||||
|
||||
// join the data rel collection to the main collection
|
||||
r.resolver.registerJoin(
|
||||
err = r.resolver.registerJoin(
|
||||
r.activeCollectionName,
|
||||
r.activeTableAlias,
|
||||
dbx.In(
|
||||
@@ -371,19 +406,22 @@ func (r *runner) processRequestInfoRelationField(bodyField Field) (*search.Resol
|
||||
list.ToInterfaceSlice(dataRelIds)...,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if relField.IsMultiple() {
|
||||
r.withMultiMatch = true
|
||||
}
|
||||
|
||||
// join the data rel collection to the multi-match subquery
|
||||
r.multiMatchActiveTableAlias = inflector.Columnify("__data_mm_" + dataRelCollection.Name + "_" + relField.Name)
|
||||
r.multiMatch.joins = append(
|
||||
r.multiMatch.joins,
|
||||
&join{
|
||||
tableName: r.activeCollectionName,
|
||||
tableAlias: r.multiMatchActiveTableAlias,
|
||||
on: dbx.In(
|
||||
r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
|
||||
r.multiMatch.Joins = append(
|
||||
r.multiMatch.Joins,
|
||||
&search.Join{
|
||||
TableName: r.activeCollectionName,
|
||||
TableAlias: r.multiMatchActiveTableAlias,
|
||||
On: dbx.In(
|
||||
fmt.Sprintf("[[%s.id]]", r.multiMatchActiveTableAlias),
|
||||
list.ToInterfaceSlice(dataRelIds)...,
|
||||
),
|
||||
@@ -399,6 +437,7 @@ func (r *runner) processRequestInfoRelationField(bodyField Field) (*search.Resol
|
||||
|
||||
var viaRegex = regexp.MustCompile(`^(\w+)_via_(\w+)$`)
|
||||
|
||||
// @todo refactor and abstract lastProp processing with the support of field plugins
|
||||
func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
totalProps := len(r.activeProps)
|
||||
|
||||
@@ -410,17 +449,17 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
|
||||
// last prop
|
||||
if i == totalProps-1 {
|
||||
return r.processLastProp(collection, prop)
|
||||
return r.finalizeActivePropsProcessing(collection, prop, i)
|
||||
}
|
||||
|
||||
field := collection.Fields.GetByName(prop)
|
||||
|
||||
if field != nil && field.GetHidden() && !r.allowHiddenFields {
|
||||
if field != nil && field.GetHidden() && !r.resolver.allowHiddenFields {
|
||||
return nil, fmt.Errorf("non-filterable field %q", prop)
|
||||
}
|
||||
|
||||
// @todo consider moving to the finalizer and converting to "JSONExtractable" interface with optional extra validation for the remaining props?
|
||||
// json or geoPoint field -> treat the rest of the props as json path
|
||||
// @todo consider converting to "JSONExtractable" interface with optional extra validation for the remaining props?
|
||||
if field != nil && (field.Type() == FieldTypeJSON || field.Type() == FieldTypeGeoPoint) {
|
||||
var jsonPath strings.Builder
|
||||
for j, p := range r.activeProps[i+1:] {
|
||||
@@ -438,12 +477,12 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
jsonPathStr := jsonPath.String()
|
||||
|
||||
result := &search.ResolverResult{
|
||||
NoCoalesce: true,
|
||||
Identifier: dbutils.JSONExtract(r.activeTableAlias+"."+inflector.Columnify(prop), jsonPathStr),
|
||||
NullFallback: search.NullFallbackDisabled,
|
||||
Identifier: dbutils.JSONExtract(r.activeTableAlias+"."+inflector.Columnify(prop), jsonPathStr),
|
||||
}
|
||||
|
||||
if r.withMultiMatch {
|
||||
r.multiMatch.valueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+inflector.Columnify(prop), jsonPathStr)
|
||||
r.multiMatch.ValueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+inflector.Columnify(prop), jsonPathStr)
|
||||
result.MultiMatchSubQuery = r.multiMatch
|
||||
}
|
||||
|
||||
@@ -488,7 +527,7 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
return nil, fmt.Errorf("invalid back relation field %q", parts[2])
|
||||
}
|
||||
|
||||
if backField.GetHidden() && !r.allowHiddenFields {
|
||||
if backField.GetHidden() && !r.resolver.allowHiddenFields {
|
||||
return nil, fmt.Errorf("non-filterable back relation field %q", backField.GetName())
|
||||
}
|
||||
|
||||
@@ -508,25 +547,24 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
// ---
|
||||
cleanProp := inflector.Columnify(prop)
|
||||
cleanBackFieldName := inflector.Columnify(backRelField.Name)
|
||||
newTableAlias := r.activeTableAlias + "_" + cleanProp
|
||||
|
||||
newTableAlias := r.activeTableAlias + "_" + cleanProp + r.resolver.joinAliasSuffix
|
||||
newCollectionName := inflector.Columnify(backCollection.Name)
|
||||
|
||||
isBackRelMultiple := backRelField.IsMultiple()
|
||||
if !isBackRelMultiple {
|
||||
// additionally check if the rel field has a single column unique index
|
||||
_, hasUniqueIndex := dbutils.FindSingleColumnUniqueIndex(backCollection.Indexes, backRelField.Name)
|
||||
isBackRelMultiple = !hasUniqueIndex
|
||||
}
|
||||
|
||||
if !isBackRelMultiple {
|
||||
r.resolver.registerJoin(
|
||||
err := r.resolver.registerJoin(
|
||||
newCollectionName,
|
||||
newTableAlias,
|
||||
dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias, cleanBackFieldName, r.activeTableAlias)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
jeAlias := r.activeTableAlias + "_" + cleanProp + "_je"
|
||||
r.resolver.registerJoin(
|
||||
jeAlias := "__je_" + newTableAlias
|
||||
err := r.resolver.registerJoin(
|
||||
newCollectionName,
|
||||
newTableAlias,
|
||||
dbx.NewExp(fmt.Sprintf(
|
||||
@@ -537,6 +575,9 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
jeAlias,
|
||||
)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
r.activeCollectionName = newCollectionName
|
||||
@@ -547,27 +588,32 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
// ---
|
||||
if isBackRelMultiple {
|
||||
r.withMultiMatch = true // enable multimatch if not already
|
||||
} else if !r.withMultiMatch {
|
||||
// additionally check if the rel field has a single column unique index;
|
||||
// if not - apply a multi-match check
|
||||
_, hasUniqueIndex := dbutils.FindSingleColumnUniqueIndex(backCollection.Indexes, backRelField.Name)
|
||||
r.withMultiMatch = !hasUniqueIndex
|
||||
}
|
||||
|
||||
newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp
|
||||
newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp + r.resolver.joinAliasSuffix
|
||||
|
||||
if !isBackRelMultiple {
|
||||
r.multiMatch.joins = append(
|
||||
r.multiMatch.joins,
|
||||
&join{
|
||||
tableName: newCollectionName,
|
||||
tableAlias: newTableAlias2,
|
||||
on: dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias2, cleanBackFieldName, r.multiMatchActiveTableAlias)),
|
||||
r.multiMatch.Joins = append(
|
||||
r.multiMatch.Joins,
|
||||
&search.Join{
|
||||
TableName: newCollectionName,
|
||||
TableAlias: newTableAlias2,
|
||||
On: dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias2, cleanBackFieldName, r.multiMatchActiveTableAlias)),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp + "_je"
|
||||
r.multiMatch.joins = append(
|
||||
r.multiMatch.joins,
|
||||
&join{
|
||||
tableName: newCollectionName,
|
||||
tableAlias: newTableAlias2,
|
||||
on: dbx.NewExp(fmt.Sprintf(
|
||||
jeAlias2 := "__je_" + newTableAlias2
|
||||
r.multiMatch.Joins = append(
|
||||
r.multiMatch.Joins,
|
||||
&search.Join{
|
||||
TableName: newCollectionName,
|
||||
TableAlias: newTableAlias2,
|
||||
On: dbx.NewExp(fmt.Sprintf(
|
||||
"[[%s.id]] IN (SELECT [[%s.value]] FROM %s {{%s}})",
|
||||
r.multiMatchActiveTableAlias,
|
||||
jeAlias2,
|
||||
@@ -607,28 +653,39 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
if !relField.IsMultiple() &&
|
||||
// the penultimate prop is "id"
|
||||
i == totalProps-2 && r.activeProps[i+1] == FieldNameId {
|
||||
return r.processLastProp(collection, relField.Name)
|
||||
return r.finalizeActivePropsProcessing(collection, relField.Name, i)
|
||||
}
|
||||
|
||||
cleanFieldName := inflector.Columnify(relField.Name)
|
||||
prefixedFieldName := r.activeTableAlias + "." + cleanFieldName
|
||||
newTableAlias := r.activeTableAlias + "_" + cleanFieldName
|
||||
newTableAlias := r.activeTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix
|
||||
newCollectionName := relCollection.Name
|
||||
|
||||
if !relField.IsMultiple() {
|
||||
r.resolver.registerJoin(
|
||||
err := r.resolver.registerJoin(
|
||||
inflector.Columnify(newCollectionName),
|
||||
newTableAlias,
|
||||
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias, prefixedFieldName)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
|
||||
r.resolver.registerJoin(dbutils.JSONEach(prefixedFieldName), jeAlias, nil)
|
||||
r.resolver.registerJoin(
|
||||
jeAlias := "__je_" + newTableAlias
|
||||
|
||||
err := r.resolver.registerJoin(dbutils.JSONEach(prefixedFieldName), jeAlias, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = r.resolver.registerJoin(
|
||||
inflector.Columnify(newCollectionName),
|
||||
newTableAlias,
|
||||
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias, jeAlias)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
r.activeCollectionName = newCollectionName
|
||||
@@ -645,26 +702,26 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
prefixedFieldName2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
|
||||
|
||||
if !relField.IsMultiple() {
|
||||
r.multiMatch.joins = append(
|
||||
r.multiMatch.joins,
|
||||
&join{
|
||||
tableName: inflector.Columnify(newCollectionName),
|
||||
tableAlias: newTableAlias2,
|
||||
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias2, prefixedFieldName2)),
|
||||
r.multiMatch.Joins = append(
|
||||
r.multiMatch.Joins,
|
||||
&search.Join{
|
||||
TableName: inflector.Columnify(newCollectionName),
|
||||
TableAlias: newTableAlias2,
|
||||
On: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias2, prefixedFieldName2)),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
|
||||
r.multiMatch.joins = append(
|
||||
r.multiMatch.joins,
|
||||
&join{
|
||||
tableName: dbutils.JSONEach(prefixedFieldName2),
|
||||
tableAlias: jeAlias2,
|
||||
r.multiMatch.Joins = append(
|
||||
r.multiMatch.Joins,
|
||||
&search.Join{
|
||||
TableName: dbutils.JSONEach(prefixedFieldName2),
|
||||
TableAlias: jeAlias2,
|
||||
},
|
||||
&join{
|
||||
tableName: inflector.Columnify(newCollectionName),
|
||||
tableAlias: newTableAlias2,
|
||||
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
|
||||
&search.Join{
|
||||
TableName: inflector.Columnify(newCollectionName),
|
||||
TableAlias: newTableAlias2,
|
||||
On: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -676,7 +733,7 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
return nil, fmt.Errorf("failed to resolve field %q", r.fieldName)
|
||||
}
|
||||
|
||||
func (r *runner) processLastProp(collection *Collection, prop string) (*search.ResolverResult, error) {
|
||||
func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop string, propDepth int) (*search.ResolverResult, error) {
|
||||
name, modifier, err := splitModifier(prop)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -690,7 +747,7 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
|
||||
return nil, fmt.Errorf("unknown field %q", name)
|
||||
}
|
||||
|
||||
if field.GetHidden() && !r.allowHiddenFields {
|
||||
if field.GetHidden() && !r.resolver.allowHiddenFields {
|
||||
return nil, fmt.Errorf("non-filterable field %q", name)
|
||||
}
|
||||
|
||||
@@ -709,7 +766,7 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
|
||||
|
||||
if r.withMultiMatch {
|
||||
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
|
||||
r.multiMatch.valueIdentifier = dbutils.JSONArrayLength(jePair2)
|
||||
r.multiMatch.ValueIdentifier = dbutils.JSONArrayLength(jePair2)
|
||||
result.MultiMatchSubQuery = r.multiMatch
|
||||
}
|
||||
|
||||
@@ -720,8 +777,12 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
|
||||
// -------------------------------------------------------
|
||||
if modifier == eachModifier && isMultivaluer {
|
||||
jePair := r.activeTableAlias + "." + cleanFieldName
|
||||
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
|
||||
r.resolver.registerJoin(dbutils.JSONEach(jePair), jeAlias, nil)
|
||||
jeAlias := "__je_" + r.activeTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix
|
||||
|
||||
err := r.resolver.registerJoin(dbutils.JSONEach(jePair), jeAlias, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &search.ResolverResult{
|
||||
Identifier: fmt.Sprintf("[[%s.value]]", jeAlias),
|
||||
@@ -733,13 +794,13 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
|
||||
|
||||
if r.withMultiMatch {
|
||||
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
|
||||
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
|
||||
jeAlias2 := "__je_" + r.multiMatchActiveTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix
|
||||
|
||||
r.multiMatch.joins = append(r.multiMatch.joins, &join{
|
||||
tableName: dbutils.JSONEach(jePair2),
|
||||
tableAlias: jeAlias2,
|
||||
r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
|
||||
TableName: dbutils.JSONEach(jePair2),
|
||||
TableAlias: jeAlias2,
|
||||
})
|
||||
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
|
||||
r.multiMatch.ValueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
|
||||
|
||||
result.MultiMatchSubQuery = r.multiMatch
|
||||
}
|
||||
@@ -754,12 +815,12 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
|
||||
}
|
||||
|
||||
if r.withMultiMatch {
|
||||
r.multiMatch.valueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
|
||||
r.multiMatch.ValueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
|
||||
result.MultiMatchSubQuery = r.multiMatch
|
||||
}
|
||||
|
||||
// allow querying only auth records with emails marked as public
|
||||
if field.GetName() == FieldNameEmail && !r.allowHiddenFields && collection.IsAuth() {
|
||||
if field.GetName() == FieldNameEmail && !r.resolver.allowHiddenFields && collection.IsAuth() {
|
||||
result.AfterBuild = func(expr dbx.Expression) dbx.Expression {
|
||||
return dbx.Enclose(dbx.And(expr, dbx.NewExp(fmt.Sprintf(
|
||||
"[[%s.%s]] = TRUE",
|
||||
@@ -773,10 +834,10 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
|
||||
// stored as json work correctly when compared to their SQL equivalent
|
||||
// (https://github.com/pocketbase/pocketbase/issues/4068)
|
||||
if field.Type() == FieldTypeJSON {
|
||||
result.NoCoalesce = true
|
||||
result.NullFallback = search.NullFallbackDisabled
|
||||
result.Identifier = dbutils.JSONExtract(r.activeTableAlias+"."+cleanFieldName, "")
|
||||
if r.withMultiMatch {
|
||||
r.multiMatch.valueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "")
|
||||
r.multiMatch.ValueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,7 +845,7 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
|
||||
if modifier == lowerModifier {
|
||||
result.Identifier = "LOWER(" + result.Identifier + ")"
|
||||
if r.withMultiMatch {
|
||||
r.multiMatch.valueIdentifier = "LOWER(" + r.multiMatch.valueIdentifier + ")"
|
||||
r.multiMatch.ValueIdentifier = "LOWER(" + r.multiMatch.ValueIdentifier + ")"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -983,7 +983,7 @@ func (m *Record) GetStringSlice(key string) []string {
|
||||
}
|
||||
|
||||
// GetUnsavedFiles returns the uploaded files for the provided "file" field key,
|
||||
// (aka. the current [*filesytem.File] values) so that you can apply further
|
||||
// (aka. the current [*filesystem.File] values) so that you can apply further
|
||||
// validations or modifications (including changing the file name or content before persisting).
|
||||
//
|
||||
// Example:
|
||||
|
||||
+20
-3
@@ -314,9 +314,19 @@ func (app *BaseApp) FindAllRecords(collectionModelOrIdentifier any, exprs ...dbx
|
||||
// FindFirstRecordByData returns the first found record matching
|
||||
// the provided key-value pair.
|
||||
func (app *BaseApp) FindFirstRecordByData(collectionModelOrIdentifier any, key string, value any) (*Record, error) {
|
||||
collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
field := collection.Fields.GetByName(key)
|
||||
if field == nil {
|
||||
return nil, errors.New("invalid or missing field " + key)
|
||||
}
|
||||
|
||||
record := &Record{}
|
||||
|
||||
err := app.RecordQuery(collectionModelOrIdentifier).
|
||||
err = app.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
|
||||
Limit(1).
|
||||
One(record)
|
||||
@@ -397,7 +407,10 @@ func (app *BaseApp) FindRecordsByFilter(
|
||||
}
|
||||
}
|
||||
|
||||
resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
|
||||
err = resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ---
|
||||
|
||||
if offset > 0 {
|
||||
@@ -611,7 +624,11 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
|
||||
err = resolver.UpdateQuery(query)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = query.AndWhere(expr).Limit(1).Row(&exists)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -525,6 +525,13 @@ func TestFindFirstRecordByData(t *testing.T) {
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
"invalid_or_missing",
|
||||
"llvuca81nly1qls",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
"id",
|
||||
|
||||
@@ -423,7 +423,7 @@ type BatchConfig struct {
|
||||
// MaxRequests is the maximum allowed batch request to execute.
|
||||
MaxRequests int `form:"maxRequests" json:"maxRequests"`
|
||||
|
||||
// Timeout is the the max duration in seconds to wait before cancelling the batch transaction.
|
||||
// Timeout is the max duration in seconds to wait before cancelling the batch transaction.
|
||||
Timeout int64 `form:"timeout" json:"timeout"`
|
||||
|
||||
// MaxBodySize is the maximum allowed batch request body size in bytes.
|
||||
@@ -449,7 +449,7 @@ type BackupsConfig struct {
|
||||
// Leave it empty to disable the auto backups functionality.
|
||||
Cron string `form:"cron" json:"cron"`
|
||||
|
||||
// CronMaxKeep is the the max number of cron generated backups to
|
||||
// CronMaxKeep is the max number of cron generated backups to
|
||||
// keep before removing older entries.
|
||||
//
|
||||
// This field works only when the cron config has valid cron expression.
|
||||
|
||||
+39
-19
@@ -19,11 +19,11 @@ import (
|
||||
// This method is a no-op if a view with the provided name doesn't exist.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "name" argument must come only from trusted input!
|
||||
func (app *BaseApp) DeleteView(name string) error {
|
||||
// "dangerousViewName" argument must come only from trusted input!
|
||||
func (app *BaseApp) DeleteView(dangerousViewName string) error {
|
||||
_, err := app.DB().NewQuery(fmt.Sprintf(
|
||||
"DROP VIEW IF EXISTS {{%s}}",
|
||||
name,
|
||||
dangerousViewName,
|
||||
)).Execute()
|
||||
|
||||
return err
|
||||
@@ -31,19 +31,19 @@ func (app *BaseApp) DeleteView(name string) error {
|
||||
|
||||
// SaveView creates (or updates already existing) persistent SQL view.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "selectQuery" argument must come only from trusted input!
|
||||
func (app *BaseApp) SaveView(name string, selectQuery string) error {
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and
|
||||
// its arguments must come only from trusted input!
|
||||
func (app *BaseApp) SaveView(dangerousViewName string, dangerousSelectQuery string) error {
|
||||
return app.RunInTransaction(func(txApp App) error {
|
||||
// delete old view (if exists)
|
||||
if err := txApp.DeleteView(name); err != nil {
|
||||
if err := txApp.DeleteView(dangerousViewName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";")
|
||||
dangerousSelectQuery = strings.Trim(strings.TrimSpace(dangerousSelectQuery), ";")
|
||||
|
||||
// try to loosely detect multiple inline statements
|
||||
tk := tokenizer.NewFromString(selectQuery)
|
||||
tk := tokenizer.NewFromString(dangerousSelectQuery)
|
||||
tk.Separators(';')
|
||||
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
|
||||
return errors.New("multiple statements are not supported")
|
||||
@@ -53,17 +53,17 @@ func (app *BaseApp) SaveView(name string, selectQuery string) error {
|
||||
//
|
||||
// note: the query is wrapped in a secondary SELECT as a rudimentary
|
||||
// measure to discourage multiple inline sql statements execution
|
||||
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, selectQuery)
|
||||
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", dangerousViewName, dangerousSelectQuery)
|
||||
if _, err := txApp.DB().NewQuery(viewQuery).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// fetch the view table info to ensure that the view was created
|
||||
// because missing tables or columns won't return an error
|
||||
if _, err := txApp.TableInfo(name); err != nil {
|
||||
if _, err := txApp.TableInfo(dangerousViewName); err != nil {
|
||||
// manually cleanup previously created view in case the func
|
||||
// is called in a nested transaction and the error is discarded
|
||||
txApp.DeleteView(name)
|
||||
txApp.DeleteView(dangerousViewName)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -77,18 +77,21 @@ func (app *BaseApp) SaveView(name string, selectQuery string) error {
|
||||
// There are some caveats:
|
||||
// - The select query must have an "id" column.
|
||||
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
|
||||
func (app *BaseApp) CreateViewFields(selectQuery string) (FieldsList, error) {
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "dangerousSelectQuery" argument must come only from trusted input!
|
||||
func (app *BaseApp) CreateViewFields(dangerousSelectQuery string) (FieldsList, error) {
|
||||
result := NewFieldsList()
|
||||
|
||||
suggestedFields, err := parseQueryToFields(app, selectQuery)
|
||||
suggestedFields, err := parseQueryToFields(app, dangerousSelectQuery)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// note wrap in a transaction in case the selectQuery contains
|
||||
// note wrap in a transaction in case the dangerousSelectQuery contains
|
||||
// multiple statements allowing us to rollback on any error
|
||||
txErr := app.RunInTransaction(func(txApp App) error {
|
||||
info, err := getQueryTableInfo(txApp, selectQuery)
|
||||
info, err := getQueryTableInfo(txApp, dangerousSelectQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -254,7 +257,16 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
|
||||
}
|
||||
|
||||
// numeric aggregations
|
||||
if strings.HasPrefix(colLower, "count(") || strings.HasPrefix(colLower, "total(") {
|
||||
if strings.HasPrefix(colLower, "count(") {
|
||||
result[col.alias] = &queryField{
|
||||
field: &NumberField{
|
||||
Name: col.alias,
|
||||
OnlyInt: true,
|
||||
},
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(colLower, "total(") {
|
||||
result[col.alias] = &queryField{
|
||||
field: &NumberField{
|
||||
Name: col.alias,
|
||||
@@ -265,16 +277,24 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
|
||||
|
||||
castMatch := castRegex.FindStringSubmatch(colLower)
|
||||
|
||||
// numeric casts
|
||||
// casts
|
||||
if len(castMatch) == 2 {
|
||||
switch castMatch[1] {
|
||||
case "real", "integer", "int", "decimal", "numeric":
|
||||
case "real", "decimal", "numeric":
|
||||
result[col.alias] = &queryField{
|
||||
field: &NumberField{
|
||||
Name: col.alias,
|
||||
},
|
||||
}
|
||||
continue
|
||||
case "int", "integer":
|
||||
result[col.alias] = &queryField{
|
||||
field: &NumberField{
|
||||
Name: col.alias,
|
||||
OnlyInt: true,
|
||||
},
|
||||
}
|
||||
continue
|
||||
case "text":
|
||||
result[col.alias] = &queryField{
|
||||
field: &TextField{
|
||||
|
||||
+56
-1
@@ -326,7 +326,7 @@ func TestCreateViewFields(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with multiple froms, joins and style of aliasses",
|
||||
"query with multiple froms, joins and style of aliases",
|
||||
`
|
||||
select
|
||||
a.id as id,
|
||||
@@ -545,6 +545,61 @@ func TestCreateViewFields(t *testing.T) {
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestCreateViewFieldsWithNumberOnlyInt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
sql := `select
|
||||
a.id,
|
||||
count(a.id) count,
|
||||
total(a.id) total,
|
||||
cast(a.id as int) cast_int,
|
||||
cast(a.id as integer) cast_integer,
|
||||
cast(a.id as real) cast_real,
|
||||
cast(a.id as decimal) cast_decimal,
|
||||
cast(a.id as numeric) cast_numeric
|
||||
from demo1 a`
|
||||
|
||||
result, err := app.CreateViewFields(sql)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
onlyInts := map[string]bool{
|
||||
"count": true,
|
||||
"total": false,
|
||||
"cast_int": true,
|
||||
"cast_integer": true,
|
||||
"cast_real": false,
|
||||
"cast_decimal": false,
|
||||
"cast_numeric": false,
|
||||
}
|
||||
|
||||
totalExpected := len(onlyInts) + 1
|
||||
if total := len(result); total != totalExpected {
|
||||
t.Fatalf("Expected %d, got %d", totalExpected, total)
|
||||
}
|
||||
|
||||
for _, f := range result {
|
||||
if f.GetName() == "id" {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(f.GetName(), func(t *testing.T) {
|
||||
nf, ok := f.(*core.NumberField)
|
||||
if !ok {
|
||||
t.Fatalf("Expected *core.NumberField, got %v", f)
|
||||
}
|
||||
|
||||
if nf.OnlyInt != onlyInts[nf.Name] {
|
||||
t.Fatalf("Expected OnlyInt %v, got %v", onlyInts[nf.Name], nf.OnlyInt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRecordByViewFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -534,7 +534,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
|
||||
expectedErrors: []string{"password", "passwordConfirm"},
|
||||
},
|
||||
{
|
||||
name: "new auth collection record with invalid record and invalid form data (without manager acess)",
|
||||
name: "new auth collection record with invalid record and invalid form data (without manager access)",
|
||||
record: core.NewRecord(usersCol),
|
||||
data: map[string]any{
|
||||
"verified": true,
|
||||
@@ -552,7 +552,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
|
||||
expectedErrors: []string{"verified", "passwordConfirm"},
|
||||
},
|
||||
{
|
||||
name: "new auth collection record with invalid record and valid form data (without manager acess)",
|
||||
name: "new auth collection record with invalid record and valid form data (without manager access)",
|
||||
record: core.NewRecord(usersCol),
|
||||
data: map[string]any{
|
||||
"verified": false,
|
||||
@@ -570,7 +570,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
|
||||
expectedErrors: []string{"password", "username"},
|
||||
},
|
||||
{
|
||||
name: "new auth collection record with invalid record and invalid form data (with manager acess)",
|
||||
name: "new auth collection record with invalid record and invalid form data (with manager access)",
|
||||
record: core.NewRecord(usersCol),
|
||||
managerAccess: true,
|
||||
data: map[string]any{
|
||||
@@ -589,7 +589,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
|
||||
expectedErrors: []string{"passwordConfirm"},
|
||||
},
|
||||
{
|
||||
name: "new auth collection record with invalid record and valid form data (with manager acess)",
|
||||
name: "new auth collection record with invalid record and valid form data (with manager access)",
|
||||
record: core.NewRecord(usersCol),
|
||||
managerAccess: true,
|
||||
data: map[string]any{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -109,7 +110,8 @@ func (form *TestEmailSend) Submit() error {
|
||||
case TestTemplateOTP:
|
||||
return mails.SendRecordOTP(form.app, record, "_PB_TEST_OTP_ID_", "123456")
|
||||
case TestTemplateAuthAlert:
|
||||
return mails.SendRecordAuthAlert(form.app, record)
|
||||
testEvent := types.NowDateTime().String() + " - TEST_IP TEST_USER_AGENT"
|
||||
return mails.SendRecordAuthAlert(form.app, record, testEvent)
|
||||
default:
|
||||
return errors.New("unknown template " + form.Template)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
module github.com/pocketbase/pocketbase
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2
|
||||
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994
|
||||
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.10
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/ganigeorgiev/fexpr v0.5.0
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f
|
||||
github.com/spf13/cast v1.9.2
|
||||
github.com/spf13/cobra v1.10.1
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/image v0.30.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sync v0.16.0
|
||||
modernc.org/sqlite v1.38.2
|
||||
github.com/spf13/cast v1.10.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.20.0
|
||||
modernc.org/sqlite v1.46.2
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -31,20 +31,19 @@ require (
|
||||
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -14,10 +14,10 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
|
||||
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
|
||||
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM=
|
||||
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 h1:fuHXpEVTTk7TilRdfGRLHpiTD6tnT0ihEowCfWjlFvw=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 h1:3U8dTgyNBhEQ/GVw0jZW5q+93Zw2gAZPRWhJ9TwV3rM=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
@@ -26,8 +26,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||
@@ -36,15 +36,17 @@ github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TC
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -55,12 +57,12 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f h1:ahrn66FNJYsFkO0EOTStYs+jdBKBop/anp9hoQSzZjI=
|
||||
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -68,63 +70,63 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -133,8 +135,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
|
||||
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
version: "2"
|
||||
run:
|
||||
go: 1.23
|
||||
concurrency: 4
|
||||
timeout: 10m
|
||||
linters:
|
||||
|
||||
+9
-2
@@ -12,10 +12,14 @@ import (
|
||||
)
|
||||
|
||||
// SendRecordAuthAlert sends a new device login alert to the specified auth record.
|
||||
func SendRecordAuthAlert(app core.App, authRecord *core.Record) error {
|
||||
func SendRecordAuthAlert(app core.App, authRecord *core.Record, info string) error {
|
||||
mailClient := app.NewMailClient()
|
||||
|
||||
subject, body, err := resolveEmailTemplate(app, authRecord, authRecord.Collection().AuthAlert.EmailTemplate, nil)
|
||||
info = html.EscapeString(info)
|
||||
|
||||
subject, body, err := resolveEmailTemplate(app, authRecord, authRecord.Collection().AuthAlert.EmailTemplate, map[string]any{
|
||||
core.EmailPlaceholderAlertInfo: info,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -35,6 +39,9 @@ func SendRecordAuthAlert(app core.App, authRecord *core.Record) error {
|
||||
event.Mailer = mailClient
|
||||
event.Message = message
|
||||
event.Record = authRecord
|
||||
event.Meta = map[string]any{
|
||||
"info": info,
|
||||
}
|
||||
|
||||
return app.OnMailerRecordAuthAlertSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
return e.Mailer.Send(e.Message)
|
||||
|
||||
@@ -15,12 +15,14 @@ func TestSendRecordAuthAlert(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
info := "<p>test_info</p>"
|
||||
|
||||
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
||||
|
||||
// to test that it is escaped
|
||||
user.Set("name", "<p>"+user.GetString("name")+"</p>")
|
||||
|
||||
err := mails.SendRecordAuthAlert(testApp, user)
|
||||
err := mails.SendRecordAuthAlert(testApp, user, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -34,6 +36,7 @@ func TestSendRecordAuthAlert(t *testing.T) {
|
||||
"login to your " + testApp.Settings().Meta.AppName + " account from a new location",
|
||||
"If this was you",
|
||||
"If this wasn't you",
|
||||
html.EscapeString(info),
|
||||
}
|
||||
for _, part := range expectedParts {
|
||||
if !strings.Contains(testApp.TestMailer.LastMessage().HTML, part) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
const oldAuthAlertTemplate = `<p>Hello,</p>
|
||||
<p>We noticed a login to your {APP_NAME} account from a new location.</p>
|
||||
<p>If this was you, you may disregard this email.</p>
|
||||
<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
{APP_NAME} team
|
||||
</p>`
|
||||
|
||||
func init() {
|
||||
core.SystemMigrations.Register(func(txApp core.App) error {
|
||||
collections, err := txApp.FindAllCollections(core.CollectionTypeAuth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newTemplate := core.NewAuthCollection("up").AuthAlert.EmailTemplate.Body
|
||||
|
||||
for _, c := range collections {
|
||||
if c.AuthAlert.EmailTemplate.Body != oldAuthAlertTemplate {
|
||||
continue
|
||||
}
|
||||
|
||||
c.AuthAlert.EmailTemplate.Body = newTemplate
|
||||
|
||||
err = txApp.Save(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, func(txApp core.App) error {
|
||||
collections, err := txApp.FindAllCollections(core.CollectionTypeAuth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newTemplate := core.NewAuthCollection("down").AuthAlert.EmailTemplate.Body
|
||||
|
||||
for _, c := range collections {
|
||||
if c.AuthAlert.EmailTemplate.Body != newTemplate {
|
||||
continue
|
||||
}
|
||||
|
||||
c.AuthAlert.EmailTemplate.Body = oldAuthAlertTemplate
|
||||
|
||||
err = txApp.Save(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -10,15 +10,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
expectedDriverVersion = "v1.38.2"
|
||||
expectedLibcVersion = "v1.66.3"
|
||||
expectedDriverVersion = "v1.46.2"
|
||||
expectedLibcVersion = "v1.70.0"
|
||||
|
||||
// ModerncDepsCheckHookId is the id of the hook that performs the modernc.org/* deps checks.
|
||||
// It could be used for removing/unbinding the hook if you don't want the checks.
|
||||
ModerncDepsCheckHookId = "pbModerncDepsCheck"
|
||||
)
|
||||
|
||||
// checkModerncDeps checks whether the current binary was buit with the
|
||||
// checkModerncDeps checks whether the current binary was built with the
|
||||
// expected and tested modernc driver dependencies.
|
||||
//
|
||||
// This is needed because modernc.org/libc doesn't follow semantic versioning
|
||||
|
||||
+48
-6
@@ -431,6 +431,32 @@ func baseBinds(vm *goja.Runtime) {
|
||||
return instanceValue
|
||||
})
|
||||
|
||||
// nullable helpers usually used as DynamicModel shape values
|
||||
vm.Set("nullString", func() *string {
|
||||
var v string
|
||||
return &v
|
||||
})
|
||||
vm.Set("nullFloat", func() *float64 {
|
||||
var v float64
|
||||
return &v
|
||||
})
|
||||
vm.Set("nullInt", func() *int64 {
|
||||
var v int64
|
||||
return &v
|
||||
})
|
||||
vm.Set("nullBool", func() *bool {
|
||||
var v bool
|
||||
return &v
|
||||
})
|
||||
vm.Set("nullArray", func() *types.JSONArray[any] {
|
||||
var v types.JSONArray[any]
|
||||
return &v
|
||||
})
|
||||
vm.Set("nullObject", func() *types.JSONMap[any] {
|
||||
var v types.JSONMap[any]
|
||||
return &v
|
||||
})
|
||||
|
||||
vm.Set("Record", func(call goja.ConstructorCall) *goja.Object {
|
||||
var instance *core.Record
|
||||
|
||||
@@ -663,6 +689,7 @@ func mailsBinds(vm *goja.Runtime) {
|
||||
obj.Set("sendRecordVerification", mails.SendRecordVerification)
|
||||
obj.Set("sendRecordChangeEmail", mails.SendRecordChangeEmail)
|
||||
obj.Set("sendRecordOTP", mails.SendRecordOTP)
|
||||
obj.Set("sendRecordAuthAlert", mails.SendRecordAuthAlert)
|
||||
}
|
||||
|
||||
func securityBinds(vm *goja.Runtime) {
|
||||
@@ -712,6 +739,8 @@ func filesystemBinds(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$filesystem", obj)
|
||||
|
||||
obj.Set("s3", filesystem.NewS3)
|
||||
obj.Set("local", filesystem.NewLocal)
|
||||
obj.Set("fileFromPath", filesystem.NewFileFromPath)
|
||||
obj.Set("fileFromBytes", filesystem.NewFileFromBytes)
|
||||
obj.Set("fileFromMultipart", filesystem.NewFileFromMultipart)
|
||||
@@ -1099,12 +1128,19 @@ var cachedDynamicModelStructs = store.New[string, reflect.Type](nil)
|
||||
// on the specified "shape".
|
||||
//
|
||||
// The "shape" values are used as defaults and could be of type:
|
||||
// - int (ex. 0)
|
||||
// - float (ex. -0)
|
||||
// - string (ex. "")
|
||||
// - bool (ex. false)
|
||||
// - slice (ex. [])
|
||||
// - map (ex. map[string]any{})
|
||||
//
|
||||
// - int64 (ex.: 0)
|
||||
// - *int64 (ex.: nullInt())
|
||||
// - float64 (ex.: -0)
|
||||
// - *float64 (ex.: nullFloat())
|
||||
// - string (ex.: "")
|
||||
// - *string (ex.: nullString())
|
||||
// - bool (ex.: false)
|
||||
// - *bool (ex.: nullBool())
|
||||
// - slice/arr (ex.: [])
|
||||
// - *slice/arr (ex.: nullArray())
|
||||
// - map (ex.: {})
|
||||
// - *map (ex.: nullObject())
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
@@ -1140,6 +1176,9 @@ func newDynamicModel(shape map[string]any) any {
|
||||
newV.Scan(raw)
|
||||
v = newV
|
||||
vt = reflect.TypeOf(newV)
|
||||
case reflect.Pointer:
|
||||
// for pointers always fallback to nil as their default value
|
||||
v = nil
|
||||
}
|
||||
|
||||
hash.WriteString(k)
|
||||
@@ -1168,6 +1207,9 @@ func newDynamicModel(shape map[string]any) any {
|
||||
|
||||
// load default values into the new model
|
||||
for i, item := range info {
|
||||
if item.value == nil {
|
||||
continue
|
||||
}
|
||||
elem.Field(i).Set(reflect.ValueOf(item.value))
|
||||
}
|
||||
|
||||
|
||||
+118
-34
@@ -9,6 +9,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -46,7 +47,7 @@ func TestBaseBindsCount(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
|
||||
testBindsCount(vm, "this", 35, t)
|
||||
testBindsCount(vm, "this", 41, t)
|
||||
}
|
||||
|
||||
func TestBaseBindsSleep(t *testing.T) {
|
||||
@@ -795,7 +796,7 @@ func TestMailsBindsCount(t *testing.T) {
|
||||
vm := goja.New()
|
||||
mailsBinds(vm)
|
||||
|
||||
testBindsCount(vm, "$mails", 4, t)
|
||||
testBindsCount(vm, "$mails", 5, t)
|
||||
}
|
||||
|
||||
func TestMailsBinds(t *testing.T) {
|
||||
@@ -833,6 +834,11 @@ func TestMailsBinds(t *testing.T) {
|
||||
if (!$app.testMailer.lastMessage().html.includes("test_otp_pass")) {
|
||||
throw new Error("Expected record OTP email, got:" + JSON.stringify($app.testMailer.lastMessage()))
|
||||
}
|
||||
|
||||
$mails.sendRecordAuthAlert($app, record, "test_alert_info");
|
||||
if (!$app.testMailer.lastMessage().html.includes("test_alert_info")) {
|
||||
throw new Error("Expected record OTP email, got:" + JSON.stringify($app.testMailer.lastMessage()))
|
||||
}
|
||||
`)
|
||||
if vmErr != nil {
|
||||
t.Fatal(vmErr)
|
||||
@@ -1003,14 +1009,47 @@ func TestFilesystemBinds(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "jsvm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
vm := goja.New()
|
||||
vm.Set("mh", &multipart.FileHeader{Filename: "test"})
|
||||
vm.Set("tmpDir", tmpDir)
|
||||
vm.Set("testFile", filepath.Join(app.DataDir(), "data.db"))
|
||||
vm.Set("baseURL", srv.URL)
|
||||
baseBinds(vm)
|
||||
filesystemBinds(vm)
|
||||
|
||||
testBindsCount(vm, "$filesystem", 4, t)
|
||||
testBindsCount(vm, "$filesystem", 6, t)
|
||||
|
||||
// s3
|
||||
{
|
||||
v, err := vm.RunString(`$filesystem.s3("bucketName", "region", "endpoint", "accessKey", "secretKey", true)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fsys, ok := v.Export().(*filesystem.System)
|
||||
if !ok {
|
||||
t.Fatalf("[s3] Expected System instance got %v", fsys)
|
||||
}
|
||||
}
|
||||
|
||||
// local
|
||||
{
|
||||
v, err := vm.RunString(`$filesystem.local(tmpDir)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fsys, ok := v.Export().(*filesystem.System)
|
||||
if !ok {
|
||||
t.Fatalf("[s3] Expected System instance got %v", fsys)
|
||||
}
|
||||
}
|
||||
|
||||
// fileFromPath
|
||||
{
|
||||
@@ -1157,44 +1196,89 @@ func TestLoadingDynamicModel(t *testing.T) {
|
||||
|
||||
_, err := vm.RunString(`
|
||||
let result = new DynamicModel({
|
||||
text: "",
|
||||
bool: false,
|
||||
number: 0,
|
||||
select_many: [],
|
||||
json: [],
|
||||
// custom map-like field
|
||||
obj: {},
|
||||
string: "",
|
||||
nullString: nullString(),
|
||||
nullStringEmpty: nullString(),
|
||||
|
||||
bool: false,
|
||||
nullBool: nullBool(),
|
||||
nullBoolEmpty: nullBool(),
|
||||
|
||||
int: 0,
|
||||
nullInt: nullInt(),
|
||||
nullIntEmpty: nullInt(),
|
||||
|
||||
float: -0,
|
||||
nullFloat: nullFloat(),
|
||||
nullFloatEmpty: nullFloat(),
|
||||
|
||||
array: [],
|
||||
nullArray: nullArray(),
|
||||
nullArrayEmpty: nullArray(),
|
||||
|
||||
object: {},
|
||||
nullObject: nullObject(),
|
||||
nullObjectEmpty: nullObject(),
|
||||
})
|
||||
|
||||
const expectations = {
|
||||
"string": "a",
|
||||
"nullString": "b",
|
||||
"nullStringEmpty": null,
|
||||
|
||||
"bool": false,
|
||||
"nullBool": true,
|
||||
"nullBoolEmpty": null,
|
||||
|
||||
"int": 1,
|
||||
"nullInt": 2,
|
||||
"nullIntEmpty": null,
|
||||
|
||||
"float": 1.1,
|
||||
"nullFloat": 1.2,
|
||||
"nullFloatEmpty": null,
|
||||
|
||||
"array": [1,2],
|
||||
"nullArray": [3,4],
|
||||
"nullArrayEmpty": null,
|
||||
|
||||
"object": {a:1},
|
||||
"nullObject": {a:2},
|
||||
"nullObjectEmpty": null,
|
||||
};
|
||||
|
||||
// construct dummy SELECT column value literals based on the expectations
|
||||
const selectColumns = [];
|
||||
for (const col in expectations) {
|
||||
const val = expectations[col]
|
||||
|
||||
if (val === null) {
|
||||
selectColumns.push("null as [[" + col + "]]")
|
||||
} else if (typeof val === "string") {
|
||||
selectColumns.push("'" + val + "' as [[" + col + "]]")
|
||||
} else if (typeof val === "object") {
|
||||
selectColumns.push("'" + JSON.stringify(val) + "' as [[" + col + "]]")
|
||||
} else {
|
||||
selectColumns.push(val + " as [[" + col + "]]")
|
||||
}
|
||||
}
|
||||
|
||||
$app.db()
|
||||
.select("text", "bool", "number", "select_many", "json", "('{\"test\": 1}') as obj")
|
||||
.from("demo1")
|
||||
.where($dbx.hashExp({"id": "84nmscqy84lsi1t"}))
|
||||
.limit(1)
|
||||
.newQuery("SELECT " + selectColumns.join(", "))
|
||||
.one(result)
|
||||
|
||||
if (result.text != "test") {
|
||||
throw new Error('Expected text "test", got ' + result.text);
|
||||
}
|
||||
for (const col in expectations) {
|
||||
let expVal = expectations[col];
|
||||
let resVal = result[col];
|
||||
|
||||
if (result.bool != true) {
|
||||
throw new Error('Expected bool true, got ' + result.bool);
|
||||
}
|
||||
if (expVal !== null && typeof expVal === "object") {
|
||||
expVal = JSON.stringify(expVal)
|
||||
resVal = JSON.stringify(resVal)
|
||||
}
|
||||
|
||||
if (result.number != 123456) {
|
||||
throw new Error('Expected number 123456, got ' + result.number);
|
||||
}
|
||||
|
||||
if (result.select_many.length != 2 || result.select_many[0] != "optionB" || result.select_many[1] != "optionC") {
|
||||
throw new Error('Expected select_many ["optionB", "optionC"], got ' + result.select_many);
|
||||
}
|
||||
|
||||
if (result.json.length != 3 || result.json[0] != 1 || result.json[1] != 2 || result.json[2] != 3) {
|
||||
throw new Error('Expected json [1, 2, 3], got ' + result.json);
|
||||
}
|
||||
|
||||
if (result.obj.get("test") != 1) {
|
||||
throw new Error('Expected obj.get("test") 1, got ' + JSON.stringify(result.obj));
|
||||
if (expVal != resVal) {
|
||||
throw new Error("Expected '" + col + "' value " + expVal + ", got " + resVal);
|
||||
}
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
|
||||
+4851
-4647
File diff suppressed because it is too large
Load Diff
@@ -254,32 +254,106 @@ declare function sleep(milliseconds: number): void;
|
||||
*/
|
||||
declare function arrayOf<T>(model: T): Array<T>;
|
||||
|
||||
/**
|
||||
* unmarshal clones and merges the data argument on top of dst.
|
||||
*
|
||||
* This method is rarely used directly by the users and it is most
|
||||
* commonly used in the autogenerated migrations.
|
||||
*
|
||||
* To an extent it is similar to the JS native ` + "`" + `Object.assign` + "`" + `
|
||||
* but the arguments are reversed and it invokes the Go standard
|
||||
* ` + "`" + `json.Marshal/Unmarshal` + "`" + ` methods under the hood.
|
||||
*
|
||||
* The data argument could be anything serializable, usually a plain object (map).
|
||||
* The dst argument could be any pointer value, usually a model instance.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ` + "```" + `js
|
||||
* unmarshal({ authAlert: { enabled: true } }, collection)
|
||||
* ` + "```" + `
|
||||
*
|
||||
* @group PocketBase
|
||||
*/
|
||||
declare function unmarshal(data: any, dst: any): void;
|
||||
|
||||
/**
|
||||
* DynamicModel creates a new dynamic model with fields from the provided data shape.
|
||||
*
|
||||
* Caveats:
|
||||
* - In order to use 0 as double/float initialization number you have to negate it (` + "`-0`" + `).
|
||||
* - You need to use lowerCamelCase when accessing the model fields (e.g. ` + "`model.roles`" + ` and not ` + "`model.Roles`" + `).
|
||||
* - You need to use lowerCamelCase when accessing the model fields (e.g. ` + "`model.roles`" + ` and not ` + "`model.Roles`" + ` even if in the model shape and in the DB table the column is capitalized).
|
||||
* - Objects are loaded into types.JSONMap, meaning that they need to be accessed with ` + "`get(key)`" + ` (e.g. ` + "`model.meta.get('something')`" + `).
|
||||
* - For describing nullable types you can use the ` + "`null*()`" + ` helpers - ` + "`nullString()`" + `, ` + "`nullInt()`" + `, ` + "`nullFloat()`" + `, ` + "`nullBool()`" + `, ` + "`nullArray()`" + `, ` + "`nullObject()`" + `.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ` + "```" + `js
|
||||
* const model = new DynamicModel({
|
||||
* name: ""
|
||||
* age: 0, // int64
|
||||
* totalSpent: -0, // float64
|
||||
* active: false,
|
||||
* Roles: [], // maps to "Roles" in the DB/JSON but the prop would be accessible via "model.roles"
|
||||
* meta: {}
|
||||
* name: "" // or nullString() if nullable
|
||||
* age: 0, // or nullInt() if nullable
|
||||
* totalSpent: -0, // or nullFloat() if nullable
|
||||
* active: false, // or nullBool() if nullable
|
||||
* Roles: [], // or nullArray() if nullable; maps to "Roles" in the DB/JSON but the prop would be accessible via "model.roles"
|
||||
* meta: {}, // or nullObject() if nullable
|
||||
* })
|
||||
* ` + "```" + `
|
||||
*
|
||||
* @group PocketBase
|
||||
*/
|
||||
declare class DynamicModel {
|
||||
[key: string]: any;
|
||||
constructor(shape?: { [key:string]: any })
|
||||
}
|
||||
|
||||
/**
|
||||
* nullString creates an empty Go string pointer usually used for
|
||||
* describing a **nullable** ` + "`DynamicModel`" + ` string value.
|
||||
*
|
||||
* @group PocketBase
|
||||
*/
|
||||
declare function nullString(): string;
|
||||
|
||||
/**
|
||||
* nullInt creates an empty Go int64 pointer usually used for
|
||||
* describing a **nullable** ` + "`DynamicModel`" + ` int value.
|
||||
*
|
||||
* @group PocketBase
|
||||
*/
|
||||
declare function nullInt(): number;
|
||||
|
||||
/**
|
||||
* nullFloat creates an empty Go float64 pointer usually used for
|
||||
* describing a **nullable** ` + "`DynamicModel`" + ` float value.
|
||||
*
|
||||
* @group PocketBase
|
||||
*/
|
||||
declare function nullFloat(): number;
|
||||
|
||||
/**
|
||||
* nullBool creates an empty Go bool pointer usually used for
|
||||
* describing a **nullable** ` + "`DynamicModel`" + ` bool value.
|
||||
*
|
||||
* @group PocketBase
|
||||
*/
|
||||
declare function nullBool(): boolean;
|
||||
|
||||
/**
|
||||
* nullArray creates an empty Go types.JSONArray pointer usually used for
|
||||
* describing a **nullable** ` + "`DynamicModel`" + ` JSON array value.
|
||||
*
|
||||
* @group PocketBase
|
||||
*/
|
||||
declare function nullArray(): Array<any>;
|
||||
|
||||
/**
|
||||
* nullObject creates an empty Go types.JSONMap pointer usually used for
|
||||
* describing a **nullable** ` + "`DynamicModel`" + ` JSON object value.
|
||||
*
|
||||
* @group PocketBase
|
||||
*/
|
||||
declare function nullObject(): { get(key:string):any; set(key:string,value:any):void };
|
||||
|
||||
interface Context extends context.Context{} // merge
|
||||
/**
|
||||
* Context creates a new empty Go context.Context.
|
||||
@@ -788,6 +862,7 @@ declare namespace $mails {
|
||||
let sendRecordVerification: mails.sendRecordVerification
|
||||
let sendRecordChangeEmail: mails.sendRecordChangeEmail
|
||||
let sendRecordOTP: mails.sendRecordOTP
|
||||
let sendRecordAuthAlert: mails.sendRecordAuthAlert
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -818,17 +893,17 @@ declare namespace $security {
|
||||
/**
|
||||
* {@inheritDoc security.newJWT}
|
||||
*/
|
||||
export function createJWT(payload: { [key:string]: any }, signingKey: string, secDuration: number): string
|
||||
function createJWT(payload: { [key:string]: any }, signingKey: string, secDuration: number): string
|
||||
|
||||
/**
|
||||
* {@inheritDoc security.parseUnverifiedJWT}
|
||||
*/
|
||||
export function parseUnverifiedJWT(token: string): _TygojaDict
|
||||
function parseUnverifiedJWT(token: string): _TygojaDict
|
||||
|
||||
/**
|
||||
* {@inheritDoc security.parseJWT}
|
||||
*/
|
||||
export function parseJWT(token: string, verificationKey: string): _TygojaDict
|
||||
function parseJWT(token: string, verificationKey: string): _TygojaDict
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -846,6 +921,24 @@ declare namespace $filesystem {
|
||||
let fileFromBytes: filesystem.newFileFromBytes
|
||||
let fileFromMultipart: filesystem.newFileFromMultipart
|
||||
|
||||
/**
|
||||
* Initializes a new S3-only filesystem instance
|
||||
* (make sure to call ` + "`" + `close()` + "`" + ` after you are done working with it).
|
||||
*
|
||||
* Most users should prefer ` + "`" + `$app.newFilesystem()` + "`" + ` which will
|
||||
* construct a local or S3 filesystem based on the configured application settings.
|
||||
*/
|
||||
let s3: filesystem.newS3
|
||||
|
||||
/**
|
||||
* Initializes a new local-only filesystem instance
|
||||
* (make sure to call ` + "`" + `close()` + "`" + ` after you are done working with it).
|
||||
*
|
||||
* Most users should prefer ` + "`" + `$app.newFilesystem()` + "`" + ` which will
|
||||
* construct a local or S3 filesystem based on the configured application settings.
|
||||
*/
|
||||
let local: filesystem.newLocal
|
||||
|
||||
/**
|
||||
* fileFromURL creates a new File from the provided url by
|
||||
* downloading the resource and creating a BytesReader.
|
||||
@@ -860,7 +953,7 @@ declare namespace $filesystem {
|
||||
* const file2 = $filesystem.fileFromURL("https://...", 15)
|
||||
* ` + "```" + `
|
||||
*/
|
||||
export function fileFromURL(url: string, secTimeout?: number): filesystem.File
|
||||
function fileFromURL(url: string, secTimeout?: number): filesystem.File
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -874,21 +967,21 @@ declare namespace $filesystem {
|
||||
* @group PocketBase
|
||||
*/
|
||||
declare namespace $filepath {
|
||||
export let base: filepath.base
|
||||
export let clean: filepath.clean
|
||||
export let dir: filepath.dir
|
||||
export let ext: filepath.ext
|
||||
export let fromSlash: filepath.fromSlash
|
||||
export let glob: filepath.glob
|
||||
export let isAbs: filepath.isAbs
|
||||
export let join: filepath.join
|
||||
export let match: filepath.match
|
||||
export let rel: filepath.rel
|
||||
export let split: filepath.split
|
||||
export let splitList: filepath.splitList
|
||||
export let toSlash: filepath.toSlash
|
||||
export let walk: filepath.walk
|
||||
export let walkDir: filepath.walkDir
|
||||
let base: filepath.base
|
||||
let clean: filepath.clean
|
||||
let dir: filepath.dir
|
||||
let ext: filepath.ext
|
||||
let fromSlash: filepath.fromSlash
|
||||
let glob: filepath.glob
|
||||
let isAbs: filepath.isAbs
|
||||
let join: filepath.join
|
||||
let match: filepath.match
|
||||
let rel: filepath.rel
|
||||
let split: filepath.split
|
||||
let splitList: filepath.splitList
|
||||
let toSlash: filepath.toSlash
|
||||
let walk: filepath.walk
|
||||
let walkDir: filepath.walkDir
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -905,7 +998,7 @@ declare namespace $os {
|
||||
/**
|
||||
* Legacy alias for $os.cmd().
|
||||
*/
|
||||
export let exec: exec.command
|
||||
let exec: exec.command
|
||||
|
||||
/**
|
||||
* Prepares an external OS command.
|
||||
@@ -920,30 +1013,30 @@ declare namespace $os {
|
||||
* const output = toString(cmd.output());
|
||||
* ` + "```" + `
|
||||
*/
|
||||
export let cmd: exec.command
|
||||
let cmd: exec.command
|
||||
|
||||
/**
|
||||
* Args hold the command-line arguments, starting with the program name.
|
||||
*/
|
||||
export let args: Array<string>
|
||||
let args: Array<string>
|
||||
|
||||
export let exit: os.exit
|
||||
export let getenv: os.getenv
|
||||
export let dirFS: os.dirFS
|
||||
export let readFile: os.readFile
|
||||
export let writeFile: os.writeFile
|
||||
export let stat: os.stat
|
||||
export let readDir: os.readDir
|
||||
export let tempDir: os.tempDir
|
||||
export let truncate: os.truncate
|
||||
export let getwd: os.getwd
|
||||
export let mkdir: os.mkdir
|
||||
export let mkdirAll: os.mkdirAll
|
||||
export let rename: os.rename
|
||||
export let remove: os.remove
|
||||
export let removeAll: os.removeAll
|
||||
export let openRoot: os.openRoot
|
||||
export let openInRoot: os.openInRoot
|
||||
let exit: os.exit
|
||||
let getenv: os.getenv
|
||||
let dirFS: os.dirFS
|
||||
let readFile: os.readFile
|
||||
let writeFile: os.writeFile
|
||||
let stat: os.stat
|
||||
let readDir: os.readDir
|
||||
let tempDir: os.tempDir
|
||||
let truncate: os.truncate
|
||||
let getwd: os.getwd
|
||||
let mkdir: os.mkdir
|
||||
let mkdirAll: os.mkdirAll
|
||||
let rename: os.rename
|
||||
let remove: os.remove
|
||||
let removeAll: os.removeAll
|
||||
let openRoot: os.openRoot
|
||||
let openInRoot: os.openInRoot
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -1072,7 +1165,7 @@ declare namespace $apis {
|
||||
* If a file resource is missing and indexFallback is set, the request
|
||||
* will be forwarded to the base index.html (useful for SPA).
|
||||
*/
|
||||
export function static(dir: string, indexFallback: boolean): (e: core.RequestEvent) => void
|
||||
function static(dir: string, indexFallback: boolean): (e: core.RequestEvent) => void
|
||||
|
||||
let requireGuestOnly: apis.requireGuestOnly
|
||||
let requireAuth: apis.requireAuth
|
||||
@@ -1094,7 +1187,7 @@ declare namespace $apis {
|
||||
* Set authMethod to empty string if you want to ignore the MFA checks and the login alerts
|
||||
* (can be also adjusted additionally via the onRecordAuthRequest hook).
|
||||
*/
|
||||
export function recordAuthResponse(e: core.RequestEvent, authRecord: core.Record, authMethod: string, meta?: any): void
|
||||
function recordAuthResponse(e: core.RequestEvent, authRecord: core.Record, authMethod: string, meta?: any): void
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -377,7 +377,7 @@ func (p *plugin) watchHooks() error {
|
||||
if hooksDirInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
watchDir, err = filepath.EvalSymlinks(p.config.HooksDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve hooksDir symink: %w", err)
|
||||
return fmt.Errorf("failed to resolve hooksDir symlink: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"authAlert": {
|
||||
"emailTemplate": {
|
||||
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location.</p>\n<p>If this was you, you may disregard this email.</p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
|
||||
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location:</p>\n<p><em>{ALERT_INFO}</em></p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>If this was you, you may disregard this email.</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
|
||||
"subject": "Login from a new location"
|
||||
},
|
||||
"enabled": true
|
||||
@@ -205,7 +205,7 @@ func init() {
|
||||
jsonData := ` + "`" + `{
|
||||
"authAlert": {
|
||||
"emailTemplate": {
|
||||
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location.</p>\n<p>If this was you, you may disregard this email.</p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
|
||||
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location:</p>\n<p><em>{ALERT_INFO}</em></p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>If this was you, you may disregard this email.</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
|
||||
"subject": "Login from a new location"
|
||||
},
|
||||
"enabled": true
|
||||
@@ -470,7 +470,7 @@ migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"authAlert": {
|
||||
"emailTemplate": {
|
||||
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location.</p>\n<p>If this was you, you may disregard this email.</p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
|
||||
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location:</p>\n<p><em>{ALERT_INFO}</em></p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>If this was you, you may disregard this email.</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
|
||||
"subject": "Login from a new location"
|
||||
},
|
||||
"enabled": true
|
||||
@@ -649,7 +649,7 @@ func init() {
|
||||
jsonData := ` + "`" + `{
|
||||
"authAlert": {
|
||||
"emailTemplate": {
|
||||
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location.</p>\n<p>If this was you, you may disregard this email.</p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
|
||||
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location:</p>\n<p><em>{ALERT_INFO}</em></p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>If this was you, you may disregard this email.</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
|
||||
"subject": "Login from a new location"
|
||||
},
|
||||
"enabled": true
|
||||
|
||||
@@ -205,6 +205,8 @@ func (pb *PocketBase) Execute() error {
|
||||
<-done
|
||||
|
||||
// trigger cleanups
|
||||
//
|
||||
// @todo consider skipping and just call the finalizer in case OnTerminate was already invoked manually?
|
||||
event := new(core.TerminateEvent)
|
||||
event.App = pb
|
||||
return pb.OnTerminate().Trigger(event, func(e *core.TerminateEvent) error {
|
||||
|
||||
+15
-2
@@ -48,6 +48,14 @@ type ApiScenario struct {
|
||||
// A zero or negative value means that there will be no timeout.
|
||||
Timeout time.Duration
|
||||
|
||||
// DisableTestAppCleanup disables the builtin TestApp cleanup at
|
||||
// the end of the ApiScenario execution.
|
||||
//
|
||||
// This option works only when explicit TestAppFactory is specified
|
||||
// and means that the developer is responsible to do the necessary
|
||||
// after test cleanup on their own (e.g. by manually calling testApp.Cleanup()).
|
||||
DisableTestAppCleanup bool
|
||||
|
||||
// expectations
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -172,7 +180,11 @@ func (scenario *ApiScenario) test(t testing.TB) {
|
||||
t.Fatalf("Failed to initialize the test app instance: %v", testAppErr)
|
||||
}
|
||||
}
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// https://github.com/pocketbase/pocketbase/discussions/7267
|
||||
if scenario.TestAppFactory == nil || !scenario.DisableTestAppCleanup {
|
||||
defer testApp.Cleanup()
|
||||
}
|
||||
|
||||
baseRouter, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
@@ -220,7 +232,8 @@ func (scenario *ApiScenario) test(t testing.TB) {
|
||||
|
||||
// set scenario headers
|
||||
for k, v := range scenario.Headers {
|
||||
req.Header.Set(k, v)
|
||||
// trim whitespaces for consistency with the net/http request parsing
|
||||
req.Header.Set(k, strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
// execute request
|
||||
|
||||
@@ -147,6 +147,7 @@ func NewTestAppWithConfig(config core.BaseAppConfig) (*TestApp, error) {
|
||||
t.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
||||
Func: func(e *core.ServeEvent) error {
|
||||
t.registerEventCall("OnServe")
|
||||
e.InstallerFunc = nil // https://github.com/pocketbase/pocketbase/discussions/7202
|
||||
return e.Next()
|
||||
},
|
||||
Priority: -99999,
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
test
|
||||
+6
-5
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pocketbase/pocketbase/tools/auth/internal/jwk"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -108,10 +110,10 @@ func (p *Apple) parseAndVerifyIdToken(idToken string) (jwt.MapClaims, error) {
|
||||
return nil, errors.New("empty id_token")
|
||||
}
|
||||
|
||||
// extract the token header params and claims
|
||||
// extract the token claims
|
||||
// ---
|
||||
claims := jwt.MapClaims{}
|
||||
t, _, err := jwt.NewParser().ParseUnverified(idToken, claims)
|
||||
_, _, err := jwt.NewParser().ParseUnverified(idToken, claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -136,10 +138,9 @@ func (p *Apple) parseAndVerifyIdToken(idToken string) (jwt.MapClaims, error) {
|
||||
// the token which is a result of direct TLS communication with the provider
|
||||
// (see also https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation)
|
||||
// ---
|
||||
kid, _ := t.Header["kid"].(string)
|
||||
err = validateIdTokenSignature(p.ctx, idToken, p.jwksURL, kid)
|
||||
err = jwk.ValidateTokenSignature(p.ctx, idToken, p.jwksURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("id_token validation failed: %w", err)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Package jwk implements some common utilities for interacting with JWKs
|
||||
// (mostly used with OIDC providers).
|
||||
package jwk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type JWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Kid string `json:"kid"`
|
||||
Use string `json:"use"`
|
||||
Alg string `json:"alg"`
|
||||
// RS256 (RSA)
|
||||
E string `json:"e"`
|
||||
N string `json:"n"`
|
||||
// Ed25519 (OKP)
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
}
|
||||
|
||||
// PublicKey reconstructs and returns the public key from the current JWK.
|
||||
func (key *JWK) PublicKey() (any, error) {
|
||||
switch key.Kty {
|
||||
case "RSA":
|
||||
// RFC 7518
|
||||
// https://datatracker.ietf.org/doc/html/rfc7518#section-6.3
|
||||
// https://datatracker.ietf.org/doc/html/rfc7517#appendix-A.1
|
||||
exponent, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.E, "="))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
modulus, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.N, "="))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &rsa.PublicKey{
|
||||
E: int(big.NewInt(0).SetBytes(exponent).Uint64()),
|
||||
N: big.NewInt(0).SetBytes(modulus),
|
||||
}, nil
|
||||
case "OKP":
|
||||
// RFC 8037
|
||||
// https://datatracker.ietf.org/doc/html/rfc8037#section-2
|
||||
// https://datatracker.ietf.org/doc/html/rfc8037#appendix-A
|
||||
if key.Crv != "Ed25519" {
|
||||
return nil, fmt.Errorf("unsupported OKP curve (must be Ed25519): %q", key.Crv)
|
||||
}
|
||||
|
||||
x, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.X, "="))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if l := len(x); l != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("invalid Ed25519 key length: %d", l)
|
||||
}
|
||||
|
||||
return ed25519.PublicKey(x), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported kty (must be RSA or OKP): %q", key.Kty)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch retrieves the JSON Web Key Set located at jwksURL and returns
|
||||
// the first key that matches the specified kid.
|
||||
func Fetch(ctx context.Context, jwksURL string, kid string) (*JWK, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
rawBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// http.Client.Get doesn't treat non 2xx responses as error
|
||||
if res.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to fetch JSON Web Key Set from %s (%d):\n%s",
|
||||
jwksURL,
|
||||
res.StatusCode,
|
||||
string(rawBody),
|
||||
)
|
||||
}
|
||||
|
||||
jwks := struct {
|
||||
Keys []*JWK
|
||||
}{}
|
||||
|
||||
err = json.Unmarshal(rawBody, &jwks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, key := range jwks.Keys {
|
||||
if key.Kid == kid {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("JWK with kid %q was not found", kid)
|
||||
}
|
||||
|
||||
// ValidateTokenSignature validates the signature of a token with the
|
||||
// public key retrieved from a remote JWKS.
|
||||
func ValidateTokenSignature(ctx context.Context, token string, jwksURL string) error {
|
||||
// extract the kid token header
|
||||
// ---
|
||||
t, _, err := jwt.NewParser().ParseUnverified(token, jwt.MapClaims{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kid, _ := t.Header["kid"].(string)
|
||||
if kid == "" {
|
||||
return errors.New("missing kid header value")
|
||||
}
|
||||
|
||||
// fetch the public key set
|
||||
// ---
|
||||
key, err := Fetch(ctx, jwksURL, kid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// verify the signature
|
||||
// ---
|
||||
parser := jwt.NewParser(jwt.WithValidMethods([]string{key.Alg}))
|
||||
|
||||
parsedToken, err := parser.Parse(token, func(t *jwt.Token) (any, error) {
|
||||
return key.PublicKey()
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !parsedToken.Valid {
|
||||
return errors.New("the parsed token is invalid")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package jwk_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pocketbase/pocketbase/tools/auth/internal/jwk"
|
||||
)
|
||||
|
||||
type publicKey interface {
|
||||
Equal(x crypto.PublicKey) bool
|
||||
}
|
||||
|
||||
func TestJWK_PublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rsaPrivate, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
key *jwk.JWK
|
||||
expectError bool
|
||||
expectKey crypto.PublicKey
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
&jwk.JWK{},
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"invalid kty",
|
||||
&jwk.JWK{
|
||||
Kty: "invalid",
|
||||
Alg: "RS256",
|
||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaPrivate.E)).Bytes()),
|
||||
N: base64.RawURLEncoding.EncodeToString(rsaPrivate.N.Bytes()),
|
||||
},
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"RSA",
|
||||
&jwk.JWK{
|
||||
Kty: "RSA",
|
||||
Alg: "RS256",
|
||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaPrivate.E)).Bytes()),
|
||||
N: base64.RawURLEncoding.EncodeToString(rsaPrivate.N.Bytes()),
|
||||
},
|
||||
false,
|
||||
&rsaPrivate.PublicKey,
|
||||
},
|
||||
{
|
||||
"OKP with unsupported curve",
|
||||
&jwk.JWK{
|
||||
Kty: "OKP",
|
||||
Crv: "invalid",
|
||||
X: base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("a", ed25519.PublicKeySize))),
|
||||
},
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"OKP with invalid public key length",
|
||||
&jwk.JWK{
|
||||
Kty: "OKP",
|
||||
Crv: "Ed25519",
|
||||
X: base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("a", ed25519.PublicKeySize-1))),
|
||||
},
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"valid OKP",
|
||||
&jwk.JWK{
|
||||
Kty: "OKP",
|
||||
Crv: "Ed25519",
|
||||
X: base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("a", ed25519.PublicKeySize))),
|
||||
},
|
||||
false,
|
||||
ed25519.PublicKey([]byte(strings.Repeat("a", ed25519.PublicKeySize))),
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result, err := s.key.PublicKey()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr && result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
k, ok := result.(publicKey)
|
||||
if !ok {
|
||||
t.Fatalf("The returned public key %T doesn't satisfy the expected common interface", k)
|
||||
}
|
||||
|
||||
if !k.Equal(s.expectKey) {
|
||||
t.Fatalf("The returned public key doesn't match with the expected one:\n%v\n%v", k, s.expectKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Query().Has("error") {
|
||||
res.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
fmt.Fprintf(res, `{
|
||||
"keys": [
|
||||
{
|
||||
"kid": "abc",
|
||||
"kty": "OKP",
|
||||
"crv": "Ed25519",
|
||||
"x": "test_x"
|
||||
},
|
||||
{
|
||||
"kid": "def",
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"n": "test_n",
|
||||
"e": "test_e"
|
||||
}
|
||||
]
|
||||
}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
kid string
|
||||
expectError bool
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
"error response",
|
||||
"def",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"non-matching kid",
|
||||
"missing",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"matching kid",
|
||||
"def",
|
||||
false,
|
||||
[]string{
|
||||
`"kid":"def"`,
|
||||
`"kty":"RSA"`,
|
||||
`"alg":"RS256"`,
|
||||
`"n":"test_n"`,
|
||||
`"e":"test_e"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
url := server.URL
|
||||
if s.expectError {
|
||||
url += "?error"
|
||||
}
|
||||
|
||||
key, err := jwk.Fetch(context.Background(), url, s.kid)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawStr := string(raw)
|
||||
|
||||
for _, substr := range s.contains {
|
||||
if !strings.Contains(rawStr, substr) {
|
||||
t.Fatalf("Missing expected substring\n%s\nin\n%s", substr, rawStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rsaPrivate, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ed25519Public, ed25519Private, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
nonmatchingKidToken := jwt.New(&jwt.SigningMethodEd25519{})
|
||||
nonmatchingKidToken.Header["kid"] = "missing"
|
||||
nonmatchingKidTokenStr, err := nonmatchingKidToken.SignedString(ed25519Private)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key1Token := jwt.New(&jwt.SigningMethodEd25519{})
|
||||
key1Token.Header["kid"] = "key1"
|
||||
key1TokenStr, err := key1Token.SignedString(ed25519Private)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key2Token := jwt.New(jwt.SigningMethodRS256)
|
||||
key2Token.Header["kid"] = "key2"
|
||||
key2TokenStr, err := key2Token.SignedString(rsaPrivate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||
_ = json.NewEncoder(res).Encode(map[string]any{"keys": []*jwk.JWK{
|
||||
{
|
||||
Kid: "key1",
|
||||
Kty: "OKP",
|
||||
Alg: "EdDSA",
|
||||
Crv: "Ed25519",
|
||||
X: base64.RawURLEncoding.EncodeToString(ed25519Public),
|
||||
},
|
||||
{
|
||||
Kid: "key2",
|
||||
Kty: "RSA",
|
||||
Alg: "RS256",
|
||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaPrivate.E)).Bytes()),
|
||||
N: base64.RawURLEncoding.EncodeToString(rsaPrivate.N.Bytes()),
|
||||
},
|
||||
}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
token string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty token",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid token",
|
||||
"abc",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"no matching kid",
|
||||
nonmatchingKidTokenStr,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid Ed25519 token",
|
||||
key1TokenStr,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"valid RSA token",
|
||||
key2TokenStr,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
err := jwk.ValidateTokenSignature(
|
||||
context.Background(),
|
||||
s.token,
|
||||
server.URL,
|
||||
)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+5
-111
@@ -2,20 +2,15 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pocketbase/pocketbase/tools/auth/internal/jwk"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
@@ -53,7 +48,7 @@ const NameOIDC string = "oidc"
|
||||
//
|
||||
// The provider support the following Extra config options:
|
||||
// - "jwksURL" - url to the keys to validate the id_token signature (optional and used only when reading the user data from the id_token)
|
||||
// - "issuers" - list of valid issuers for the iss id_token claim (optioanl and used only when reading the user data from the id_token)
|
||||
// - "issuers" - list of valid issuers for the iss id_token claim (optional and used only when reading the user data from the id_token)
|
||||
type OIDC struct {
|
||||
BaseProvider
|
||||
}
|
||||
@@ -140,7 +135,7 @@ func (p *OIDC) parseIdToken(token *oauth2.Token) (jwt.MapClaims, error) {
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
t, _, err := jwt.NewParser().ParseUnverified(idToken, claims)
|
||||
_, _, err := jwt.NewParser().ParseUnverified(idToken, claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -181,112 +176,11 @@ func (p *OIDC) parseIdToken(token *oauth2.Token) (jwt.MapClaims, error) {
|
||||
// (see also https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation)
|
||||
jwksURL := cast.ToString(p.Extra()["jwksURL"])
|
||||
if jwksURL != "" {
|
||||
kid, _ := t.Header["kid"].(string)
|
||||
err = validateIdTokenSignature(p.ctx, idToken, jwksURL, kid)
|
||||
err = jwk.ValidateTokenSignature(p.ctx, idToken, jwksURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("id_token validation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func validateIdTokenSignature(ctx context.Context, idToken string, jwksURL string, kid string) error {
|
||||
// fetch the public key set
|
||||
// ---
|
||||
if kid == "" {
|
||||
return errors.New("missing kid header value")
|
||||
}
|
||||
|
||||
key, err := fetchJWK(ctx, jwksURL, kid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// decode the key params per RFC 7518 (https://tools.ietf.org/html/rfc7518#section-6.3)
|
||||
// and construct a valid publicKey from them
|
||||
// ---
|
||||
exponent, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.E, "="))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
modulus, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.N, "="))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publicKey := &rsa.PublicKey{
|
||||
// https://tools.ietf.org/html/rfc7517#appendix-A.1
|
||||
E: int(big.NewInt(0).SetBytes(exponent).Uint64()),
|
||||
N: big.NewInt(0).SetBytes(modulus),
|
||||
}
|
||||
|
||||
// verify the signiture
|
||||
// ---
|
||||
parser := jwt.NewParser(jwt.WithValidMethods([]string{key.Alg}))
|
||||
|
||||
parsedToken, err := parser.Parse(idToken, func(t *jwt.Token) (any, error) {
|
||||
return publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !parsedToken.Valid {
|
||||
return errors.New("the parsed id_token is invalid")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type jwk struct {
|
||||
Kty string
|
||||
Kid string
|
||||
Use string
|
||||
Alg string
|
||||
N string
|
||||
E string
|
||||
}
|
||||
|
||||
func fetchJWK(ctx context.Context, jwksURL string, kid string) (*jwk, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
rawBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// http.Client.Get doesn't treat non 2xx responses as error
|
||||
if res.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to verify the provided id_token (%d):\n%s",
|
||||
res.StatusCode,
|
||||
string(rawBody),
|
||||
)
|
||||
}
|
||||
|
||||
jwks := struct {
|
||||
Keys []*jwk
|
||||
}{}
|
||||
if err := json.Unmarshal(rawBody, &jwks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, key := range jwks.Keys {
|
||||
if key.Kid == kid {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("jwk with kid %q was not found", kid)
|
||||
}
|
||||
|
||||
+17
-6
@@ -3,6 +3,7 @@ package cron
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -253,53 +254,63 @@ func TestCronJobs(t *testing.T) {
|
||||
func TestCronStartStop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mu sync.Mutex
|
||||
|
||||
test1 := 0
|
||||
test2 := 0
|
||||
|
||||
c := New()
|
||||
|
||||
c.SetInterval(500 * time.Millisecond)
|
||||
c.SetInterval(250 * time.Millisecond)
|
||||
|
||||
c.Add("test1", "* * * * *", func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
test1++
|
||||
})
|
||||
|
||||
c.Add("test2", "* * * * *", func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
test2++
|
||||
})
|
||||
|
||||
expectedCalls := 2
|
||||
|
||||
// call twice Start to check if the previous ticker will be reseted
|
||||
c.Start()
|
||||
c.Start()
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
time.Sleep(505 * time.Millisecond) // slightly larger to minimize flakiness
|
||||
|
||||
// call twice Stop to ensure that the second stop is no-op
|
||||
c.Stop()
|
||||
c.Stop()
|
||||
|
||||
expectedCalls := 2
|
||||
|
||||
mu.Lock()
|
||||
if test1 != expectedCalls {
|
||||
t.Fatalf("Expected %d test1, got %d", expectedCalls, test1)
|
||||
}
|
||||
if test2 != expectedCalls {
|
||||
t.Fatalf("Expected %d test2, got %d", expectedCalls, test2)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// resume for 2 seconds
|
||||
// resume for 1 seconds
|
||||
c.Start()
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
time.Sleep(1005 * time.Millisecond) // slightly larger to minimize flakiness
|
||||
|
||||
c.Stop()
|
||||
|
||||
expectedCalls += 4
|
||||
|
||||
mu.Lock()
|
||||
if test1 != expectedCalls {
|
||||
t.Fatalf("Expected %d test1, got %d", expectedCalls, test1)
|
||||
}
|
||||
if test2 != expectedCalls {
|
||||
t.Fatalf("Expected %d test2, got %d", expectedCalls, test2)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func parseCronSegment(segment string, min int, max int) (map[int]struct{}, error
|
||||
switch len(rangeParts) {
|
||||
case 1:
|
||||
if step != 1 {
|
||||
return nil, errors.New("invalid segement step - step > 1 could be used only with the wildcard or range format")
|
||||
return nil, errors.New("invalid segment step - step > 1 could be used only with the wildcard or range format")
|
||||
}
|
||||
parsed, err := strconv.Atoi(rangeParts[0])
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package dbutils
|
||||
|
||||
import "regexp"
|
||||
|
||||
// Regexp for columns and tables (the same as the one in dbx).
|
||||
var selectRegex = regexp.MustCompile(`(?i:\s+as\s+|\s+)([\w\-_\.]+)$`)
|
||||
|
||||
// AliasOrIdentifier returns the alias from a column or table identifier.
|
||||
// Returns the identifier unmodified if no alias was found.
|
||||
func AliasOrIdentifier(columnOrTableIdentifier string) string {
|
||||
matches := selectRegex.FindStringSubmatch(columnOrTableIdentifier)
|
||||
|
||||
if len(matches) > 0 && matches[1] != "" {
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
return columnOrTableIdentifier
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package dbutils_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
)
|
||||
|
||||
func TestAliasOrIdentifier(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
value string
|
||||
expected string
|
||||
}{
|
||||
{"", ""},
|
||||
{"abc", "abc"},
|
||||
{"abc ", "abc "}, // return unmodified
|
||||
{"abc.def", "abc.def"},
|
||||
{"abc.123 def", "def"},
|
||||
{"abc.123 as def.456", "def.456"},
|
||||
{"(abc) def", "def"},
|
||||
{"(abc) as def", "def"},
|
||||
{"abc def", "def"},
|
||||
{"abc as def", "def"},
|
||||
// technically invalid identifier but consistent with the dbx regex matching
|
||||
{"a b c d", "d"},
|
||||
{"a b c as d", "d"},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.value, func(t *testing.T) {
|
||||
result := dbutils.AliasOrIdentifier(s.value)
|
||||
|
||||
if result != s.expected {
|
||||
t.Fatalf("Expected\n%v\ngot\n%v", s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -193,22 +193,32 @@ var extInvalidCharsRegex = regexp.MustCompile(`[^\w\.\*\-\+\=\#]+`)
|
||||
const randomAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
func normalizeName(fr FileReader, name string) string {
|
||||
// cut the name even if it is not multibyte safe to avoid operating on too large strings
|
||||
// ---
|
||||
originalLength := len(name)
|
||||
if originalLength > 300 {
|
||||
name = name[originalLength-300:]
|
||||
}
|
||||
|
||||
// extension
|
||||
// ---
|
||||
originalExt := extractExtension(name)
|
||||
cleanExt := extInvalidCharsRegex.ReplaceAllString(originalExt, "")
|
||||
if cleanExt == "" {
|
||||
cleanExt := "." + strings.Trim(extInvalidCharsRegex.ReplaceAllString(originalExt, ""), ".")
|
||||
if cleanExt == "." {
|
||||
// try to detect the extension from the file content
|
||||
cleanExt, _ = detectExtension(fr)
|
||||
}
|
||||
if extLength := len(cleanExt); extLength > 20 {
|
||||
// keep only the last 20 characters (it is multibyte safe after the regex replace)
|
||||
cleanExt = "." + cleanExt[extLength-20:]
|
||||
cleanExt = "." + strings.Trim(cleanExt[extLength-20:], ".")
|
||||
}
|
||||
|
||||
// name
|
||||
//
|
||||
// note: leading dot is trimmed to prevent various subtle issues with files
|
||||
// sync programs as they sometimes have special handling for "invisible" files
|
||||
// ---
|
||||
cleanName := inflector.Snakecase(strings.TrimSuffix(name, originalExt))
|
||||
cleanName := inflector.Snakecase(strings.Trim(strings.TrimSuffix(name, originalExt), "."))
|
||||
if length := len(cleanName); length < 3 {
|
||||
// the name is too short so we concatenate an additional random part
|
||||
cleanName += security.RandomStringWithAlphabet(10, randomAlphabet)
|
||||
|
||||
@@ -52,8 +52,8 @@ func TestNewFileFromPath(t *testing.T) {
|
||||
}
|
||||
|
||||
// existing file
|
||||
originalName := "image_! noext"
|
||||
normalizedNamePattern := regexp.QuoteMeta("image_noext_") + `\w{10}` + regexp.QuoteMeta(".png")
|
||||
originalName := "image_!@ special"
|
||||
normalizedNamePattern := regexp.QuoteMeta("image_special_") + `\w{10}` + regexp.QuoteMeta(".png")
|
||||
f, err := filesystem.NewFileFromPath(filepath.Join(testDir, originalName))
|
||||
if err != nil {
|
||||
t.Fatalf("Expected nil error, got %v", err)
|
||||
@@ -83,8 +83,8 @@ func TestNewFileFromBytes(t *testing.T) {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
originalName := "image_! noext"
|
||||
normalizedNamePattern := regexp.QuoteMeta("image_noext_") + `\w{10}` + regexp.QuoteMeta(".txt")
|
||||
originalName := "image_!@ special"
|
||||
normalizedNamePattern := regexp.QuoteMeta("image_special_") + `\w{10}` + regexp.QuoteMeta(".txt")
|
||||
f, err := filesystem.NewFileFromBytes([]byte("text\n"), originalName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -175,8 +175,8 @@ func TestNewFileFromURLTimeout(t *testing.T) {
|
||||
|
||||
// valid response
|
||||
{
|
||||
originalName := "image_! noext"
|
||||
normalizedNamePattern := regexp.QuoteMeta("image_noext_") + `\w{10}` + regexp.QuoteMeta(".txt")
|
||||
originalName := "image_!@ special"
|
||||
normalizedNamePattern := regexp.QuoteMeta("image_special_") + `\w{10}` + regexp.QuoteMeta(".txt")
|
||||
|
||||
f, err := filesystem.NewFileFromURL(context.Background(), srv.URL+"/"+originalName)
|
||||
if err != nil {
|
||||
@@ -211,10 +211,14 @@ func TestFileNameNormalizations(t *testing.T) {
|
||||
{".png", `^\w{10}_\w{10}\.png$`},
|
||||
{".tar.gz", `^\w{10}_\w{10}\.tar\.gz$`},
|
||||
{"a.tar.gz", `^a\w{10}_\w{10}\.tar\.gz$`},
|
||||
{"....abc", `^\w{10}_\w{10}\.abc$`},
|
||||
{"a.b.c.?.?.?.2", `^a_b_c_\w{10}\.2$`},
|
||||
{"a.b.c.d.tar.gz", `^a_b_c_d_\w{10}\.tar\.gz$`},
|
||||
{"abcd", `^abcd_\w{10}\.txt$`},
|
||||
{"a b! c d . 456", `^a_b_c_d_\w{10}\.456$`}, // normalize spaces
|
||||
{strings.Repeat("a", 101) + "." + strings.Repeat("b", 21), `^a{100}_\w{10}\.b{20}$`}, // name and extension length trim
|
||||
{".abcd.123.", `^abcd_\w{10}\.123$`},
|
||||
{"a b! c d . 456", `^a_b_c_d_\w{10}\.456$`}, // normalize spaces
|
||||
{strings.Repeat("a", 101) + "." + strings.Repeat("b", 21), `^a{100}_\w{10}\.b{20}$`}, // name and extension length cut
|
||||
{"abc" + strings.Repeat("d", 290) + "." + strings.Repeat("b", 9), `^d{100}_\w{10}\.b{9}$`}, // initial total length cut
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
@@ -223,7 +227,8 @@ func TestFileNameNormalizations(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if match, err := regexp.Match(s.pattern, []byte(f.Name)); !match {
|
||||
match, err := regexp.Match(s.pattern, []byte(f.Name))
|
||||
if !match {
|
||||
t.Fatalf("Expected Name to match %v, got %q (%v)", s.pattern, f.Name, err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -38,7 +38,7 @@ type System struct {
|
||||
bucket *blob.Bucket
|
||||
}
|
||||
|
||||
// NewS3 initializes an S3 filesystem instance.
|
||||
// NewS3 initializes a new S3 filesystem instance.
|
||||
//
|
||||
// NB! Make sure to call `Close()` after you are done working with it.
|
||||
func NewS3(
|
||||
@@ -536,25 +536,38 @@ func (s *System) CreateThumb(originalKey string, thumbKey, thumbSize string) err
|
||||
}
|
||||
}
|
||||
|
||||
originalContentType := r.ContentType()
|
||||
|
||||
opts := &blob.WriterOptions{
|
||||
ContentType: r.ContentType(),
|
||||
ContentType: originalContentType,
|
||||
}
|
||||
|
||||
// open a thumb storage writer (aka. prepare for upload)
|
||||
w, writerErr := s.bucket.NewWriter(s.ctx, thumbKey, opts)
|
||||
if writerErr != nil {
|
||||
return writerErr
|
||||
}
|
||||
var format imaging.Format
|
||||
|
||||
// try to detect the thumb format based on the original file name
|
||||
// (fallbacks to png on error)
|
||||
format, err := imaging.FormatFromFilename(thumbKey)
|
||||
if err != nil {
|
||||
switch originalContentType {
|
||||
case "image/jpeg":
|
||||
format = imaging.JPEG
|
||||
case "image/gif":
|
||||
format = imaging.GIF
|
||||
case "image/tiff":
|
||||
format = imaging.TIFF
|
||||
case "image/bmp":
|
||||
format = imaging.BMP
|
||||
default:
|
||||
// fallback to PNG (this includes webp!)
|
||||
opts.ContentType = "image/png"
|
||||
format = imaging.PNG
|
||||
}
|
||||
|
||||
// open a thumb storage writer (aka. prepare for upload)
|
||||
w, err := s.bucket.NewWriter(s.ctx, thumbKey, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// thumb encode (aka. upload)
|
||||
if err := imaging.Encode(w, thumbImg, format); err != nil {
|
||||
err = imaging.Encode(w, thumbImg, format)
|
||||
if err != nil {
|
||||
w.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -600,6 +600,7 @@ func TestFileSystemGetReuploadableFile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
raw, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
@@ -677,12 +678,13 @@ func TestFileSystemCopy(t *testing.T) {
|
||||
if err := fsys.Copy(src, dst); err != nil {
|
||||
t.Fatalf("Failed to copy %q to %q: %v", src, dst, err)
|
||||
}
|
||||
|
||||
f, err := fsys.GetReader(dst)
|
||||
//nolint
|
||||
defer f.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("Missing copied file %q: %v", dst, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if f.Size() != 73 {
|
||||
t.Fatalf("Expected file size %d, got %d", 73, f.Size())
|
||||
}
|
||||
@@ -709,7 +711,8 @@ func TestFileSystemList(t *testing.T) {
|
||||
"image.jpg",
|
||||
"image.svg",
|
||||
"image.webp",
|
||||
"image_! noext",
|
||||
"image_!@ special",
|
||||
"image_noext",
|
||||
"style.css",
|
||||
"main.js",
|
||||
"main.mjs",
|
||||
@@ -861,6 +864,8 @@ func TestFileSystemCreateThumb(t *testing.T) {
|
||||
{"image.jpg", "thumb.jpg", "100x100", "image/jpeg"},
|
||||
// webp (should produce png)
|
||||
{"image.webp", "thumb.webp", "100x100", "image/png"},
|
||||
// without extension (should extract the mimetype from its stored ContentType)
|
||||
{"image_noext", "image_noext.jpeg", "100x100", "image/jpeg"},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
@@ -884,13 +889,20 @@ func TestFileSystemCreateThumb(t *testing.T) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
attrsMimeType := f.ContentType()
|
||||
|
||||
mt, err := mimetype.DetectReader(f)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to detect thumb %s mimetype (%v)", s.thumb, err)
|
||||
}
|
||||
fileMimeType := mt.String()
|
||||
|
||||
if mtStr := mt.String(); mtStr != s.expectedMimeType {
|
||||
t.Fatalf("Expected thumb %s MimeType %q, got %q", s.thumb, s.expectedMimeType, mtStr)
|
||||
if fileMimeType != s.expectedMimeType {
|
||||
t.Fatalf("Expected thumb file %s MimeType %q, got %q", s.thumb, s.expectedMimeType, fileMimeType)
|
||||
}
|
||||
|
||||
if attrsMimeType != s.expectedMimeType {
|
||||
t.Fatalf("Expected thumb attrs %s MimeType %q, got %q", s.thumb, s.expectedMimeType, attrsMimeType)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -975,16 +987,33 @@ func createTestDir(t *testing.T) string {
|
||||
}
|
||||
}
|
||||
|
||||
// no extension and invalid characters
|
||||
// invalid/special characters
|
||||
{
|
||||
file, err := os.OpenFile(filepath.Join(dir, "image_! noext"), os.O_WRONLY|os.O_CREATE, 0644)
|
||||
file, err := os.OpenFile(filepath.Join(dir, "image_!@ special"), os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = png.Encode(file, image.Rect(0, 0, 1, 1)) // tiny 1x1 png
|
||||
imgRect := image.Rect(0, 0, 1, 1) // tiny 1x1 png
|
||||
_ = png.Encode(file, imgRect)
|
||||
file.Close()
|
||||
}
|
||||
|
||||
// no extension
|
||||
{
|
||||
fullPath := filepath.Join(dir, "image_noext")
|
||||
file, err := os.OpenFile(fullPath, os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
imgRect := image.Rect(0, 0, 1, 1) // tiny 1x1 jpg
|
||||
_ = jpeg.Encode(file, imgRect, nil)
|
||||
file.Close()
|
||||
err = os.WriteFile(fullPath+".attrs", []byte(`{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/jpeg","user.metadata":null}`), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// css
|
||||
{
|
||||
file, err := os.OpenFile(filepath.Join(dir, "style.css"), os.O_WRONLY|os.O_CREATE, 0644)
|
||||
|
||||
@@ -87,7 +87,7 @@ func (s3 *S3) URL(path string) string {
|
||||
path = escapePath(parsed.Path)
|
||||
|
||||
// the rest is usually not expected to be part of the S3 path but it is kept to avoid surprises
|
||||
// (it will be further escaped if necessery by the Go HTTP client)
|
||||
// (it will be further escaped if necessary by the Go HTTP client)
|
||||
if parsed.RawQuery != "" {
|
||||
path += "?" + parsed.RawQuery
|
||||
}
|
||||
@@ -150,6 +150,12 @@ func (s3 *S3) SignAndSend(req *http.Request) (*http.Response, error) {
|
||||
|
||||
// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-signed-request-steps
|
||||
func (s3 *S3) sign(req *http.Request) {
|
||||
// explicitly set Accept-Encoding to avoid transparent decompression
|
||||
// and Content-Length zeroing (https://github.com/pocketbase/pocketbase/issues/7523)
|
||||
if req.Header.Get("Accept-Encoding") == "" {
|
||||
req.Header.Set("Accept-Encoding", "identity")
|
||||
}
|
||||
|
||||
// fallback to the Unsigned payload option
|
||||
// (data integrity checks could be still applied via the content-md5 or x-amz-checksum-* headers)
|
||||
if req.Header.Get("x-amz-content-sha256") == "" {
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestS3URL(t *testing.T) {
|
||||
path := "/test_key/a/b c@d?a=@1&b=!2#@a b c"
|
||||
|
||||
// note: query params and fragments are kept as it is
|
||||
// since they are later escaped if necessery by the Go HTTP client
|
||||
// since they are later escaped if necessary by the Go HTTP client
|
||||
expectedPath := "/test_key/a/b%20c%40d?a=@1&b=!2#@a b c"
|
||||
|
||||
scenarios := []struct {
|
||||
@@ -128,6 +128,7 @@ func TestS3SignAndSend(t *testing.T) {
|
||||
return tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"Authorization": "AWS4-HMAC-SHA256 Credential=123/20250102/test_region/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=ea093662bc1deef08dfb4ac35453dfaad5ea89edf102e9dd3b7156c9a27e4c1f",
|
||||
"Host": "test_bucket.example.com",
|
||||
"Accept-Encoding": "identity",
|
||||
"X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD",
|
||||
"X-Amz-Date": "20250102T150405Z",
|
||||
})
|
||||
@@ -155,6 +156,7 @@ func TestS3SignAndSend(t *testing.T) {
|
||||
return tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"Authorization": "AWS4-HMAC-SHA256 Credential=456/20250102/test_region/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=17510fa1f724403dd0a563b61c9b31d1d718f877fcbd75455620d17a8afce5fb",
|
||||
"Host": "test_bucket.example.com",
|
||||
"Accept-Encoding": "identity",
|
||||
"X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD",
|
||||
"X-Amz-Date": "20250102T150405Z",
|
||||
})
|
||||
@@ -182,6 +184,7 @@ func TestS3SignAndSend(t *testing.T) {
|
||||
return tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"Authorization": "AWS4-HMAC-SHA256 Credential=456/20250102/test_region/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=9458a033554f52913801b3de16f54409b36ed25c6da3aed14e64439500e2c5e1",
|
||||
"Host": "test_bucket.example.com",
|
||||
"Accept-Encoding": "identity",
|
||||
"X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD",
|
||||
"X-Amz-Date": "20250102T150405Z",
|
||||
})
|
||||
@@ -198,6 +201,7 @@ func TestS3SignAndSend(t *testing.T) {
|
||||
req.Header.Set("x-amz-example", "123")
|
||||
req.Header.Set("x-amz-meta-a", "456")
|
||||
req.Header.Set("content-type", "image/png")
|
||||
req.Header.Set("accept-encoding", "custom")
|
||||
req.Header.Set("x-test", "789") // shouldn't be included in the signing headers
|
||||
},
|
||||
&s3.S3{
|
||||
@@ -214,6 +218,7 @@ func TestS3SignAndSend(t *testing.T) {
|
||||
return tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"authorization": "AWS4-HMAC-SHA256 Credential=123/20250102/test_region/s3/aws4_request, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-example;x-amz-meta-a, Signature=86dccbcd012c33073dc99e9d0a9e0b717a4d8c11c37848cfa9a4a02716bc0db3",
|
||||
"host": "test_bucket.example.com",
|
||||
"accept-encoding": "custom",
|
||||
"x-amz-date": "20250102T150405Z",
|
||||
"x-amz-content-sha256": "test_sha256",
|
||||
"x-amz-example": "123",
|
||||
|
||||
@@ -334,10 +334,10 @@ func (u *Uploader) multipartUpload(ctx context.Context, initPart []byte, optReqF
|
||||
var g errgroup.Group
|
||||
g.SetLimit(u.MaxConcurrency)
|
||||
|
||||
totalParallel := u.MaxConcurrency
|
||||
totalWorkers := u.MaxConcurrency
|
||||
|
||||
if len(initPart) != 0 {
|
||||
totalParallel--
|
||||
totalWorkers--
|
||||
initPartNumber := u.lastPartNumber
|
||||
g.Go(func() error {
|
||||
mp, err := u.uploadPart(ctx, initPartNumber, initPart, optReqFuncs...)
|
||||
@@ -353,7 +353,9 @@ func (u *Uploader) multipartUpload(ctx context.Context, initPart []byte, optReqF
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < totalParallel; i++ {
|
||||
totalWorkers = max(totalWorkers, 1)
|
||||
|
||||
for i := 0; i < totalWorkers; i++ {
|
||||
g.Go(func() error {
|
||||
for {
|
||||
part, num, err := u.nextPart()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -124,20 +125,24 @@ func TestUploaderSingleUpload(t *testing.T) {
|
||||
func TestUploaderMultipartUploadSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
httpClient := tests.NewClient(
|
||||
&tests.RequestStub{
|
||||
Method: http.MethodPost,
|
||||
URL: "http://test_bucket.example.com/test_key?uploads",
|
||||
Match: func(req *http.Request) bool {
|
||||
return tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"x-amz-meta-a": "123",
|
||||
"x-amz-meta-b": "456",
|
||||
"test_header": "test",
|
||||
"Authorization": "^.+Credential=123/.+$",
|
||||
})
|
||||
},
|
||||
Response: &http.Response{
|
||||
Body: io.NopCloser(strings.NewReader(`
|
||||
maxConcurrencies := []int{-1, 0, 1, 10}
|
||||
|
||||
for _, mc := range maxConcurrencies {
|
||||
t.Run("MaxConcurrency_"+strconv.Itoa(mc), func(t *testing.T) {
|
||||
httpClient := tests.NewClient(
|
||||
&tests.RequestStub{
|
||||
Method: http.MethodPost,
|
||||
URL: "http://test_bucket.example.com/test_key?uploads",
|
||||
Match: func(req *http.Request) bool {
|
||||
return tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"x-amz-meta-a": "123",
|
||||
"x-amz-meta-b": "456",
|
||||
"test_header": "test",
|
||||
"Authorization": "^.+Credential=123/.+$",
|
||||
})
|
||||
},
|
||||
Response: &http.Response{
|
||||
Body: io.NopCloser(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<InitiateMultipartUploadResult>
|
||||
<Bucket>test_bucket</Bucket>
|
||||
@@ -145,108 +150,111 @@ func TestUploaderMultipartUploadSuccess(t *testing.T) {
|
||||
<UploadId>test_id</UploadId>
|
||||
</InitiateMultipartUploadResult>
|
||||
`)),
|
||||
},
|
||||
},
|
||||
&tests.RequestStub{
|
||||
Method: http.MethodPut,
|
||||
URL: "http://test_bucket.example.com/test_key?partNumber=1&uploadId=test_id",
|
||||
Match: func(req *http.Request) bool {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
},
|
||||
},
|
||||
&tests.RequestStub{
|
||||
Method: http.MethodPut,
|
||||
URL: "http://test_bucket.example.com/test_key?partNumber=1&uploadId=test_id",
|
||||
Match: func(req *http.Request) bool {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return string(body) == "abc" && tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"Content-Length": "3",
|
||||
"test_header": "test",
|
||||
"Authorization": "^.+Credential=123/.+$",
|
||||
})
|
||||
},
|
||||
Response: &http.Response{
|
||||
Header: http.Header{"Etag": []string{"etag1"}},
|
||||
},
|
||||
},
|
||||
&tests.RequestStub{
|
||||
Method: http.MethodPut,
|
||||
URL: "http://test_bucket.example.com/test_key?partNumber=2&uploadId=test_id",
|
||||
Match: func(req *http.Request) bool {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return string(body) == "abc" && tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"Content-Length": "3",
|
||||
"test_header": "test",
|
||||
"Authorization": "^.+Credential=123/.+$",
|
||||
})
|
||||
},
|
||||
Response: &http.Response{
|
||||
Header: http.Header{"Etag": []string{"etag1"}},
|
||||
},
|
||||
},
|
||||
&tests.RequestStub{
|
||||
Method: http.MethodPut,
|
||||
URL: "http://test_bucket.example.com/test_key?partNumber=2&uploadId=test_id",
|
||||
Match: func(req *http.Request) bool {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return string(body) == "def" && tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"Content-Length": "3",
|
||||
"test_header": "test",
|
||||
"Authorization": "^.+Credential=123/.+$",
|
||||
})
|
||||
},
|
||||
Response: &http.Response{
|
||||
Header: http.Header{"Etag": []string{"etag2"}},
|
||||
},
|
||||
},
|
||||
&tests.RequestStub{
|
||||
Method: http.MethodPut,
|
||||
URL: "http://test_bucket.example.com/test_key?partNumber=3&uploadId=test_id",
|
||||
Match: func(req *http.Request) bool {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return string(body) == "g" && tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"Content-Length": "1",
|
||||
"test_header": "test",
|
||||
"Authorization": "^.+Credential=123/.+$",
|
||||
})
|
||||
},
|
||||
Response: &http.Response{
|
||||
Header: http.Header{"Etag": []string{"etag3"}},
|
||||
},
|
||||
},
|
||||
&tests.RequestStub{
|
||||
Method: http.MethodPost,
|
||||
URL: "http://test_bucket.example.com/test_key?uploadId=test_id",
|
||||
Match: func(req *http.Request) bool {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return string(body) == "def" && tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"Content-Length": "3",
|
||||
"test_header": "test",
|
||||
"Authorization": "^.+Credential=123/.+$",
|
||||
})
|
||||
},
|
||||
Response: &http.Response{
|
||||
Header: http.Header{"Etag": []string{"etag2"}},
|
||||
},
|
||||
},
|
||||
&tests.RequestStub{
|
||||
Method: http.MethodPut,
|
||||
URL: "http://test_bucket.example.com/test_key?partNumber=3&uploadId=test_id",
|
||||
Match: func(req *http.Request) bool {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return string(body) == "g" && tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"Content-Length": "1",
|
||||
"test_header": "test",
|
||||
"Authorization": "^.+Credential=123/.+$",
|
||||
})
|
||||
},
|
||||
Response: &http.Response{
|
||||
Header: http.Header{"Etag": []string{"etag3"}},
|
||||
},
|
||||
},
|
||||
&tests.RequestStub{
|
||||
Method: http.MethodPost,
|
||||
URL: "http://test_bucket.example.com/test_key?uploadId=test_id",
|
||||
Match: func(req *http.Request) bool {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
expected := `<CompleteMultipartUpload><Part><ETag>etag1</ETag><PartNumber>1</PartNumber></Part><Part><ETag>etag2</ETag><PartNumber>2</PartNumber></Part><Part><ETag>etag3</ETag><PartNumber>3</PartNumber></Part></CompleteMultipartUpload>`
|
||||
expected := `<CompleteMultipartUpload><Part><ETag>etag1</ETag><PartNumber>1</PartNumber></Part><Part><ETag>etag2</ETag><PartNumber>2</PartNumber></Part><Part><ETag>etag3</ETag><PartNumber>3</PartNumber></Part></CompleteMultipartUpload>`
|
||||
|
||||
return strings.Contains(string(body), expected) && tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"test_header": "test",
|
||||
"Authorization": "^.+Credential=123/.+$",
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
return strings.Contains(string(body), expected) && tests.ExpectHeaders(req.Header, map[string]string{
|
||||
"test_header": "test",
|
||||
"Authorization": "^.+Credential=123/.+$",
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
uploader := &s3.Uploader{
|
||||
S3: &s3.S3{
|
||||
Client: httpClient,
|
||||
Region: "test_region",
|
||||
Bucket: "test_bucket",
|
||||
Endpoint: "http://example.com",
|
||||
AccessKey: "123",
|
||||
SecretKey: "abc",
|
||||
},
|
||||
Key: "test_key",
|
||||
Payload: strings.NewReader("abcdefg"),
|
||||
Metadata: map[string]string{"a": "123", "b": "456"},
|
||||
MinPartSize: 3,
|
||||
}
|
||||
uploader := &s3.Uploader{
|
||||
S3: &s3.S3{
|
||||
Client: httpClient,
|
||||
Region: "test_region",
|
||||
Bucket: "test_bucket",
|
||||
Endpoint: "http://example.com",
|
||||
AccessKey: "123",
|
||||
SecretKey: "abc",
|
||||
},
|
||||
Key: "test_key",
|
||||
Payload: strings.NewReader("abcdefg"),
|
||||
Metadata: map[string]string{"a": "123", "b": "456"},
|
||||
MinPartSize: 3,
|
||||
MaxConcurrency: mc,
|
||||
}
|
||||
|
||||
err := uploader.Upload(context.Background(), func(r *http.Request) {
|
||||
r.Header.Set("test_header", "test")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := uploader.Upload(context.Background(), func(r *http.Request) {
|
||||
r.Header.Set("test_header", "test")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = httpClient.AssertNoRemaining()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
err = httpClient.AssertNoRemaining()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import (
|
||||
|
||||
const defaultPageSize = 1000
|
||||
|
||||
// New creates a new instance of the S3 driver backed by the the internal S3 client.
|
||||
// New creates a new instance of the S3 driver backed by the internal S3 client.
|
||||
func New(s3Client *s3.S3) (blob.Driver, error) {
|
||||
if s3Client.Bucket == "" {
|
||||
return nil, errors.New("s3blob.New: missing bucket name")
|
||||
|
||||
@@ -36,7 +36,7 @@ type TaggedHook[T Tagger] struct {
|
||||
// CanTriggerOn checks if the current TaggedHook can be triggered with
|
||||
// the provided event data tags.
|
||||
//
|
||||
// It returns always true if the hook doens't have any tags.
|
||||
// It returns always true if the hook doesn't have any tags.
|
||||
func (h *TaggedHook[T]) CanTriggerOn(tagsToCheck []string) bool {
|
||||
if len(h.tags) == 0 {
|
||||
return true // match all
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
var _ io.ReadWriteCloser = (*bufferWithFile)(nil)
|
||||
|
||||
// newBufferWithFile initializes and returns a new bufferWithFile with the specified memoryLimit.
|
||||
//
|
||||
// If memoryLimit is negative or zero, defaults to [DefaultMaxMemory].
|
||||
func newBufferWithFile(memoryLimit int64) *bufferWithFile {
|
||||
if memoryLimit <= 0 {
|
||||
memoryLimit = DefaultMaxMemory
|
||||
}
|
||||
|
||||
return &bufferWithFile{
|
||||
buf: new(bytes.Buffer),
|
||||
memoryLimit: memoryLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// bufferWithFile is similar to [bytes.Buffer] but after the limit it
|
||||
// fallbacks to a temporary file to minimize excessive memory usage.
|
||||
type bufferWithFile struct {
|
||||
buf *bytes.Buffer
|
||||
file *os.File
|
||||
memoryLimit int64
|
||||
fileReadOffset int64
|
||||
}
|
||||
|
||||
// Read implements the standard [io.Reader] interface by reading
|
||||
// up to len(p) bytes into p.
|
||||
func (b *bufferWithFile) Read(p []byte) (n int, err error) {
|
||||
if b.buf == nil {
|
||||
return 0, errors.New("[bufferWithFile.Read] not initialized or already closed")
|
||||
}
|
||||
|
||||
// eagerly get length because bytes.Buffer may resize and change it
|
||||
maxToRead := len(p)
|
||||
|
||||
// read first from the memory buffer
|
||||
if b.buf.Len() > 0 {
|
||||
n, err = b.buf.Read(p)
|
||||
if err != nil && err != io.EOF {
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
|
||||
// continue reading from the file to fill the remaining bytes
|
||||
if n < maxToRead && b.file != nil {
|
||||
fileN, fileErr := b.file.ReadAt(p[n:maxToRead], b.fileReadOffset)
|
||||
b.fileReadOffset += int64(fileN)
|
||||
n += fileN
|
||||
err = fileErr
|
||||
}
|
||||
|
||||
// return EOF if the buffers are empty and nothing has been read
|
||||
// (to minimize potential breaking changes and for consistency with the bytes.Buffer rules)
|
||||
if n == 0 && maxToRead > 0 && err == nil {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Write implements the standard [io.Writer] interface by writing the
|
||||
// content of p into the buffer.
|
||||
//
|
||||
// If the current memory buffer doesn't have enough space to hold len(p),
|
||||
// it write p into a temp disk file.
|
||||
func (b *bufferWithFile) Write(p []byte) (int, error) {
|
||||
if b.buf == nil {
|
||||
return 0, errors.New("[bufferWithFile.Write] not initialized or already closed")
|
||||
}
|
||||
|
||||
// already above the limit -> continue with the file
|
||||
if b.file != nil {
|
||||
return b.file.Write(p)
|
||||
}
|
||||
|
||||
// above limit -> create and write to file
|
||||
if int64(b.buf.Len()+len(p)) > b.memoryLimit {
|
||||
if b.file == nil {
|
||||
var err error
|
||||
b.file, err = os.CreateTemp("", "pb_buffer_file_*")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return b.file.Write(p)
|
||||
}
|
||||
|
||||
// write in memory
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
// Close implements the standard [io.Closer] interface.
|
||||
//
|
||||
// It unsets the memory buffer and will cleanup after the fallback
|
||||
// temporary file (if exists).
|
||||
//
|
||||
// It is safe to call Close multiple times.
|
||||
// Once Close is invoked the buffer no longer can be used and should be discarded.
|
||||
func (b *bufferWithFile) Close() error {
|
||||
if b.file != nil {
|
||||
err := errors.Join(
|
||||
b.file.Close(),
|
||||
os.Remove(b.file.Name()),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.file = nil
|
||||
}
|
||||
|
||||
b.buf = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewBufferWithFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
limit int64
|
||||
expected int64
|
||||
}{
|
||||
{"negative limit", -1, DefaultMaxMemory},
|
||||
{"zero limit", 0, DefaultMaxMemory},
|
||||
{"> 0", 1, 1},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
b := newBufferWithFile(s.limit)
|
||||
|
||||
if b.file != nil {
|
||||
t.Fatalf("Expected no file descriptor to be open, got %v", b.file)
|
||||
}
|
||||
|
||||
if b.buf == nil {
|
||||
t.Fatal("Expected buf to be initialized, got nil")
|
||||
}
|
||||
|
||||
if b.memoryLimit != s.expected {
|
||||
t.Fatalf("Expected %d limit, got %d", 10, b.memoryLimit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferWithFile_WriteReadClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := newBufferWithFile(4)
|
||||
|
||||
t.Run("write under limit", func(t *testing.T) {
|
||||
n, err := b.Write([]byte("ab"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n != 2 {
|
||||
t.Fatalf("Expected %d bytes to be written, got %v", 2, n)
|
||||
}
|
||||
|
||||
if l := b.buf.Len(); l != 2 {
|
||||
t.Fatalf("Expected memory buf lenth %d, got %d", 2, l)
|
||||
}
|
||||
|
||||
if b.file != nil {
|
||||
t.Fatalf("Expected temp file to remain nil, got %v", b.file)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write under limit (again)", func(t *testing.T) {
|
||||
n, err := b.Write([]byte("c"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n != 1 {
|
||||
t.Fatalf("Expected %d bytes to be written, got %v", 1, n)
|
||||
}
|
||||
|
||||
if l := b.buf.Len(); l != 3 {
|
||||
t.Fatalf("Expected memory buf lenth %d, got %d", 3, l)
|
||||
}
|
||||
|
||||
if b.file != nil {
|
||||
t.Fatalf("Expected temp file to remain nil, got %v", b.file)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write beyound limit (aka. skip memory buf and write into file)", func(t *testing.T) {
|
||||
n, err := b.Write([]byte("de"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n != 2 {
|
||||
t.Fatalf("Expected %d bytes to be written, got %v", 2, n)
|
||||
}
|
||||
|
||||
if l := b.buf.Len(); l != 3 {
|
||||
t.Fatalf("Expected memory buf lenth to be unchanged (%d), got %d", 3, l)
|
||||
}
|
||||
|
||||
if b.file == nil {
|
||||
t.Fatal("Expected temp file to be initialized")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read 0 bytes fromm non-empty buffer", func(t *testing.T) {
|
||||
temp := []byte{}
|
||||
|
||||
n, err := b.Read(temp)
|
||||
if err != nil { // should return nil for consistency with bytes.Buffer
|
||||
t.Fatalf("Expected nil, got %v", err)
|
||||
}
|
||||
|
||||
if n != 0 {
|
||||
t.Fatalf("Expected 0 bytes to be read, got %d (%q)", n, temp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read under limit", func(t *testing.T) {
|
||||
expected := "ab"
|
||||
temp := make([]byte, 2)
|
||||
|
||||
n, err := b.Read(temp)
|
||||
if err != nil && err != io.EOF {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n != len(temp) {
|
||||
t.Fatalf("Expected %d bytes to be read, got %d (%q)", len(temp), n, temp)
|
||||
}
|
||||
|
||||
if str := string(temp); str != expected {
|
||||
t.Fatalf("Expected to read %q, got %q", expected, str)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read beyound limit", func(t *testing.T) {
|
||||
expected := "cde"
|
||||
temp := make([]byte, 3)
|
||||
|
||||
n, err := b.Read(temp)
|
||||
if err != nil && err != io.EOF {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n != len(temp) {
|
||||
t.Fatalf("Expected %d bytes to be read, got %d (%q)", len(temp), n, temp)
|
||||
}
|
||||
|
||||
if str := string(temp); str != expected {
|
||||
t.Fatalf("Expected to read %q, got %q", expected, str)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read from empty buffers", func(t *testing.T) {
|
||||
temp := make([]byte, 3)
|
||||
|
||||
n, err := b.Read(temp)
|
||||
if err != io.EOF {
|
||||
t.Fatalf("Expected EOF, got %v", err)
|
||||
}
|
||||
|
||||
if n != 0 {
|
||||
t.Fatalf("Expected 0 bytes to be read, got %d (%q)", n, temp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("close cleanup", func(t *testing.T) {
|
||||
if b.file == nil {
|
||||
t.Fatal("Expected temp file to be initialized, got nil")
|
||||
}
|
||||
|
||||
filename := b.file.Name()
|
||||
|
||||
_, err := os.Stat(filename)
|
||||
if err != nil || errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("Expected the temp file to exist and be accessible, got %v", err)
|
||||
}
|
||||
|
||||
err = b.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(filename)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected the temp file to be deleted after close, got %v", info)
|
||||
}
|
||||
|
||||
if b.buf != nil || b.file != nil {
|
||||
t.Fatal("Expected the internal buffers to be nil after close")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func (e *Event) InternalServerError(message string, errData any) *ApiError {
|
||||
// Binders
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const DefaultMaxMemory = 32 << 20 // 32mb
|
||||
const DefaultMaxMemory = 16 << 20 // 16mb
|
||||
|
||||
// BindBody unmarshal the request body into the provided dst.
|
||||
//
|
||||
|
||||
@@ -336,7 +336,7 @@ func TestEventSetAllGetAll(t *testing.T) {
|
||||
event := router.Event{}
|
||||
event.SetAll(data)
|
||||
|
||||
// modify the data to ensure that the map was shallow coppied
|
||||
// modify the data to ensure that the map was shallow copied
|
||||
data["c"] = 789
|
||||
|
||||
result := event.GetAll()
|
||||
@@ -354,19 +354,19 @@ func TestEventString(t *testing.T) {
|
||||
scenarios := []testResponseWriteScenario[string]{
|
||||
{
|
||||
name: "no explicit content-type",
|
||||
status: 123,
|
||||
status: 234,
|
||||
headers: nil,
|
||||
body: "test",
|
||||
expectedStatus: 123,
|
||||
expectedStatus: 234,
|
||||
expectedHeaders: map[string]string{"content-type": "text/plain; charset=utf-8"},
|
||||
expectedBody: "test",
|
||||
},
|
||||
{
|
||||
name: "with explicit content-type",
|
||||
status: 123,
|
||||
status: 234,
|
||||
headers: map[string]string{"content-type": "text/test"},
|
||||
body: "test",
|
||||
expectedStatus: 123,
|
||||
expectedStatus: 234,
|
||||
expectedHeaders: map[string]string{"content-type": "text/test"},
|
||||
expectedBody: "test",
|
||||
},
|
||||
@@ -383,19 +383,19 @@ func TestEventHTML(t *testing.T) {
|
||||
scenarios := []testResponseWriteScenario[string]{
|
||||
{
|
||||
name: "no explicit content-type",
|
||||
status: 123,
|
||||
status: 234,
|
||||
headers: nil,
|
||||
body: "test",
|
||||
expectedStatus: 123,
|
||||
expectedStatus: 234,
|
||||
expectedHeaders: map[string]string{"content-type": "text/html; charset=utf-8"},
|
||||
expectedBody: "test",
|
||||
},
|
||||
{
|
||||
name: "with explicit content-type",
|
||||
status: 123,
|
||||
status: 234,
|
||||
headers: map[string]string{"content-type": "text/test"},
|
||||
body: "test",
|
||||
expectedStatus: 123,
|
||||
expectedStatus: 234,
|
||||
expectedHeaders: map[string]string{"content-type": "text/test"},
|
||||
expectedBody: "test",
|
||||
},
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
var (
|
||||
_ io.ReadCloser = (*RereadableReadCloser)(nil)
|
||||
_ Rereader = (*RereadableReadCloser)(nil)
|
||||
_ io.ReadCloser = (*RereadableReadCloser)(nil)
|
||||
)
|
||||
|
||||
// Rereader defines an interface for rewindable readers.
|
||||
@@ -15,30 +15,44 @@ type Rereader interface {
|
||||
Reread()
|
||||
}
|
||||
|
||||
// RereadableReadCloser defines a wrapper around a io.ReadCloser reader
|
||||
// RereadableReadCloser defines a wrapper around a [io.ReadCloser] reader
|
||||
// allowing to read the original reader multiple times.
|
||||
//
|
||||
// NB! Make sure to call Close after done working with the reader.
|
||||
type RereadableReadCloser struct {
|
||||
io.ReadCloser
|
||||
|
||||
copy *bytes.Buffer
|
||||
active io.Reader
|
||||
copy io.ReadWriteCloser
|
||||
closeErrors []error
|
||||
|
||||
// MaxMemory specifies the max size of the in memory copy buffer
|
||||
// before switching to read/write from temp disk file.
|
||||
//
|
||||
// If negative or zero, defaults to [DefaultMaxMemory].
|
||||
MaxMemory int64
|
||||
}
|
||||
|
||||
// Read implements the standard io.Reader interface.
|
||||
// Read implements the standard [io.Reader] interface.
|
||||
//
|
||||
// It reads up to len(b) bytes into b and at at the same time writes
|
||||
// the read data into an internal bytes buffer.
|
||||
// It reads up to len(p) bytes into p and at the same time copies
|
||||
// the read data into an internal buffer (memory + temp file).
|
||||
//
|
||||
// On EOF the r is "rewinded" to allow reading from r multiple times.
|
||||
func (r *RereadableReadCloser) Read(b []byte) (int, error) {
|
||||
if r.active == nil {
|
||||
// On EOF r is "rewinded" to allow reading multiple times.
|
||||
func (r *RereadableReadCloser) Read(p []byte) (int, error) {
|
||||
n, err := r.ReadCloser.Read(p)
|
||||
|
||||
// copy the read bytes into the internal buffer
|
||||
if n > 0 {
|
||||
if r.copy == nil {
|
||||
r.copy = &bytes.Buffer{}
|
||||
r.copy = newBufferWithFile(r.MaxMemory)
|
||||
}
|
||||
|
||||
if n, err := r.copy.Write(p[:n]); err != nil {
|
||||
return n, err
|
||||
}
|
||||
r.active = io.TeeReader(r.ReadCloser, r.copy)
|
||||
}
|
||||
|
||||
n, err := r.active.Read(b)
|
||||
// end reached -> reset for the next read
|
||||
if err == io.EOF {
|
||||
r.Reread()
|
||||
}
|
||||
@@ -50,11 +64,33 @@ func (r *RereadableReadCloser) Read(b []byte) (int, error) {
|
||||
//
|
||||
// note: not named Reset to avoid conflicts with other reader interfaces.
|
||||
func (r *RereadableReadCloser) Reread() {
|
||||
if r.copy == nil || r.copy.Len() == 0 {
|
||||
return // nothing to reset or it has been already reset
|
||||
if r.copy == nil {
|
||||
return // nothing to reset
|
||||
}
|
||||
|
||||
oldCopy := r.copy
|
||||
r.copy = &bytes.Buffer{}
|
||||
r.active = io.TeeReader(oldCopy, r.copy)
|
||||
// eagerly close the old reader to prevent accumulating too much memory or temp files
|
||||
if err := r.ReadCloser.Close(); err != nil {
|
||||
r.closeErrors = append(r.closeErrors, err)
|
||||
}
|
||||
|
||||
r.ReadCloser = r.copy
|
||||
r.copy = nil
|
||||
}
|
||||
|
||||
// Close implements the standard [io.Closer] interface by cleaning up related resources.
|
||||
//
|
||||
// It is safe to call Close multiple times.
|
||||
// Once Close is invoked the reader no longer can be used and should be discarded.
|
||||
func (r *RereadableReadCloser) Close() error {
|
||||
if r.copy != nil {
|
||||
if err := r.copy.Close(); err != nil {
|
||||
r.closeErrors = append(r.closeErrors, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.ReadCloser.Close(); err != nil {
|
||||
r.closeErrors = append(r.closeErrors, err)
|
||||
}
|
||||
|
||||
return errors.Join(r.closeErrors...)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,69 @@
|
||||
package router_test
|
||||
package router
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
)
|
||||
|
||||
func TestRereadableReadCloser(t *testing.T) {
|
||||
content := "test"
|
||||
|
||||
rereadable := &router.RereadableReadCloser{
|
||||
rereadable := &RereadableReadCloser{
|
||||
ReadCloser: io.NopCloser(strings.NewReader(content)),
|
||||
MaxMemory: 2, // should store the rest 2 bytes in temp file
|
||||
}
|
||||
|
||||
// read multiple times
|
||||
for i := 0; i < 3; i++ {
|
||||
result, err := io.ReadAll(rereadable)
|
||||
if err != nil {
|
||||
t.Fatalf("[read:%d] %v", i, err)
|
||||
}
|
||||
if str := string(result); str != content {
|
||||
t.Fatalf("[read:%d] Expected %q, got %q", i, content, result)
|
||||
totalRereads := 5
|
||||
|
||||
tempFilenames := make([]string, 0, totalRereads)
|
||||
|
||||
// reread multiple times
|
||||
for i := 0; i < totalRereads; i++ {
|
||||
t.Run("run_"+strconv.Itoa(i), func(t *testing.T) {
|
||||
if i > 3 {
|
||||
// test allso with manual Reread calls to ensure that
|
||||
// r.copy is reseted and written to only when there are n>0 bytes
|
||||
rereadable.Reread()
|
||||
}
|
||||
|
||||
result, err := io.ReadAll(rereadable)
|
||||
if err != nil {
|
||||
t.Fatalf("[read:%d] %v", i, err)
|
||||
}
|
||||
if str := string(result); str != content {
|
||||
t.Fatalf("[read:%d] Expected %q, got %q", i, content, result)
|
||||
}
|
||||
|
||||
b, ok := rereadable.ReadCloser.(*bufferWithFile)
|
||||
if !ok {
|
||||
t.Fatalf("Expected bufferWithFile replacement, got %v", b)
|
||||
}
|
||||
|
||||
if b.file != nil {
|
||||
tempFilenames = append(tempFilenames, b.file.Name())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if v := len(tempFilenames); v != totalRereads {
|
||||
t.Fatalf("Expected %d temp files to have been created during the previous rereads, got %d", totalRereads, v)
|
||||
}
|
||||
|
||||
err := rereadable.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no close errors, got %v", err)
|
||||
}
|
||||
|
||||
// ensure that no lingering temp files are left after close
|
||||
for _, name := range tempFilenames {
|
||||
info, err := os.Stat(name)
|
||||
if err == nil || !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("Expected file name %q to be deleted, got %v (%v)", name, info, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,10 @@ func (r *Router[T]) loadMux(mux *http.ServeMux, group *RouterGroup[T], parents [
|
||||
resp = &ResponseWriter{ResponseWriter: resp}
|
||||
|
||||
// wrap the request body to allow multiple reads
|
||||
req.Body = &RereadableReadCloser{ReadCloser: req.Body}
|
||||
body := &RereadableReadCloser{ReadCloser: req.Body}
|
||||
defer body.Close()
|
||||
|
||||
req.Body = body
|
||||
|
||||
event, cleanupFunc := r.eventFactory(resp, req)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package routine
|
||||
|
||||
import (
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"runtime"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -22,8 +22,11 @@ func FireAndForget(f func(), wg ...*sync.WaitGroup) {
|
||||
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("RECOVERED FROM PANIC (safe to ignore): %v", err)
|
||||
log.Println(string(debug.Stack()))
|
||||
log.Println("RECOVERED FROM PANIC (safe to ignore):", err)
|
||||
|
||||
stack := make([]byte, 2<<10) // 2 KB
|
||||
length := runtime.Stack(stack, false)
|
||||
log.Println(string(stack[:length]))
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
+23
-15
@@ -218,7 +218,7 @@ func buildResolversExpr(
|
||||
expr = dbx.Enclose(dbx.And(expr, mm))
|
||||
} else if left.MultiMatchSubQuery != nil {
|
||||
mm := &manyVsOneExpr{
|
||||
noCoalesce: left.NoCoalesce,
|
||||
nullFallback: left.NullFallback,
|
||||
subQuery: left.MultiMatchSubQuery,
|
||||
op: op,
|
||||
otherOperand: right,
|
||||
@@ -227,7 +227,7 @@ func buildResolversExpr(
|
||||
expr = dbx.Enclose(dbx.And(expr, mm))
|
||||
} else if right.MultiMatchSubQuery != nil {
|
||||
mm := &manyVsOneExpr{
|
||||
noCoalesce: right.NoCoalesce,
|
||||
nullFallback: right.NullFallback,
|
||||
subQuery: right.MultiMatchSubQuery,
|
||||
op: op,
|
||||
otherOperand: left,
|
||||
@@ -326,9 +326,6 @@ func resolveToken(token fexpr.Token, fieldResolver FieldResolver) (*ResolverResu
|
||||
// `COALESCE(a, "") = ""` since the direct match can be accomplished
|
||||
// with a seek while the COALESCE will induce a table scan.
|
||||
func resolveEqualExpr(equal bool, left, right *ResolverResult) dbx.Expression {
|
||||
isLeftEmpty := isEmptyIdentifier(left) || (len(left.Params) == 1 && hasEmptyParamValue(left))
|
||||
isRightEmpty := isEmptyIdentifier(right) || (len(right.Params) == 1 && hasEmptyParamValue(right))
|
||||
|
||||
equalOp := "="
|
||||
nullEqualOp := "IS"
|
||||
concatOp := "OR"
|
||||
@@ -343,16 +340,23 @@ func resolveEqualExpr(equal bool, left, right *ResolverResult) dbx.Expression {
|
||||
nullExpr = "IS NOT NULL"
|
||||
}
|
||||
|
||||
// no coalesce (eg. compare to a json field)
|
||||
// no coalesce fallback (eg. compare to a json field)
|
||||
// a IS b
|
||||
// a IS NOT b
|
||||
if left.NoCoalesce || right.NoCoalesce {
|
||||
if left.NullFallback == NullFallbackDisabled ||
|
||||
right.NullFallback == NullFallbackDisabled {
|
||||
return dbx.NewExp(
|
||||
fmt.Sprintf("%s %s %s", left.Identifier, nullEqualOp, right.Identifier),
|
||||
mergeParams(left.Params, right.Params),
|
||||
)
|
||||
}
|
||||
|
||||
isLeftEmpty := isEmptyIdentifier(left) ||
|
||||
(left.NullFallback == NullFallbackAuto && len(left.Params) == 1 && hasEmptyParamValue(left))
|
||||
|
||||
isRightEmpty := isEmptyIdentifier(right) ||
|
||||
(right.NullFallback == NullFallbackAuto && len(right.Params) == 1 && hasEmptyParamValue(right))
|
||||
|
||||
// both operands are empty
|
||||
if isLeftEmpty && isRightEmpty {
|
||||
return dbx.NewExp(fmt.Sprintf("'' %s ''", equalOp), mergeParams(left.Params, right.Params))
|
||||
@@ -421,6 +425,10 @@ func hasEmptyParamValue(result *ResolverResult) bool {
|
||||
}
|
||||
|
||||
func isKnownNonEmptyIdentifier(result *ResolverResult) bool {
|
||||
if result.NullFallback == NullFallbackEnforced {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.ToLower(result.Identifier) {
|
||||
case "1", "0", "false", `true`:
|
||||
return true
|
||||
@@ -631,13 +639,13 @@ func (e *manyVsManyExpr) Build(db *dbx.DB, params dbx.Params) string {
|
||||
|
||||
whereExpr, buildErr := buildResolversExpr(
|
||||
&ResolverResult{
|
||||
NoCoalesce: e.left.NoCoalesce,
|
||||
Identifier: "[[" + lAlias + ".multiMatchValue]]",
|
||||
NullFallback: e.left.NullFallback,
|
||||
Identifier: "[[" + lAlias + ".multiMatchValue]]",
|
||||
},
|
||||
e.op,
|
||||
&ResolverResult{
|
||||
NoCoalesce: e.right.NoCoalesce,
|
||||
Identifier: "[[" + rAlias + ".multiMatchValue]]",
|
||||
NullFallback: e.right.NullFallback,
|
||||
Identifier: "[[" + rAlias + ".multiMatchValue]]",
|
||||
// note: the AfterBuild needs to be handled only once and it
|
||||
// doesn't matter whether it is applied on the left or right subquery operand
|
||||
AfterBuild: dbx.Not, // inverse for the not-exist expression
|
||||
@@ -672,7 +680,7 @@ type manyVsOneExpr struct {
|
||||
subQuery dbx.Expression
|
||||
op fexpr.SignOp
|
||||
inverse bool
|
||||
noCoalesce bool
|
||||
nullFallback NullFallbackPreference
|
||||
}
|
||||
|
||||
// Build converts the expression into a SQL fragment.
|
||||
@@ -686,9 +694,9 @@ func (e *manyVsOneExpr) Build(db *dbx.DB, params dbx.Params) string {
|
||||
alias := "__sm" + security.PseudorandomString(8)
|
||||
|
||||
r1 := &ResolverResult{
|
||||
NoCoalesce: e.noCoalesce,
|
||||
Identifier: "[[" + alias + ".multiMatchValue]]",
|
||||
AfterBuild: dbx.Not, // inverse for the not-exist expression
|
||||
NullFallback: e.nullFallback,
|
||||
Identifier: "[[" + alias + ".multiMatchValue]]",
|
||||
AfterBuild: dbx.Not, // inverse for the not-exist expression
|
||||
}
|
||||
|
||||
r2 := &ResolverResult{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ dbx.Expression = (*MultiMatchSubquery)(nil)
|
||||
|
||||
// Join defines common fields required for a single SQL JOIN clause.
|
||||
type Join struct {
|
||||
TableName string
|
||||
TableAlias string
|
||||
On dbx.Expression
|
||||
}
|
||||
|
||||
// MultiMatchSubquery defines a multi-match record subquery expression.
|
||||
type MultiMatchSubquery struct {
|
||||
TargetTableAlias string
|
||||
FromTableName string
|
||||
FromTableAlias string
|
||||
ValueIdentifier string
|
||||
Joins []*Join
|
||||
Params dbx.Params
|
||||
}
|
||||
|
||||
// Build converts the expression into a SQL fragment.
|
||||
//
|
||||
// Implements [dbx.Expression] interface.
|
||||
func (m *MultiMatchSubquery) Build(db *dbx.DB, params dbx.Params) string {
|
||||
if m.TargetTableAlias == "" || m.FromTableName == "" || m.FromTableAlias == "" {
|
||||
return "0=1"
|
||||
}
|
||||
|
||||
if params == nil {
|
||||
params = m.Params
|
||||
} else {
|
||||
// merge by updating the parent params
|
||||
for k, v := range m.Params {
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
var mergedJoins strings.Builder
|
||||
for i, j := range m.Joins {
|
||||
if i > 0 {
|
||||
mergedJoins.WriteString(" ")
|
||||
}
|
||||
mergedJoins.WriteString("LEFT JOIN ")
|
||||
mergedJoins.WriteString(db.QuoteTableName(j.TableName))
|
||||
mergedJoins.WriteString(" ")
|
||||
mergedJoins.WriteString(db.QuoteTableName(j.TableAlias))
|
||||
if j.On != nil {
|
||||
mergedJoins.WriteString(" ON ")
|
||||
mergedJoins.WriteString(j.On.Build(db, params))
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
`SELECT %s as [[multiMatchValue]] FROM %s %s %s WHERE %s = %s`,
|
||||
db.QuoteColumnName(m.ValueIdentifier),
|
||||
db.QuoteTableName(m.FromTableName),
|
||||
db.QuoteTableName(m.FromTableAlias),
|
||||
mergedJoins.String(),
|
||||
db.QuoteColumnName(m.FromTableAlias+".id"),
|
||||
db.QuoteColumnName(m.TargetTableAlias+".id"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package search_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
func TestMultiMatchSubqueryBuild(t *testing.T) {
|
||||
// create a dummy db
|
||||
sqlDB, err := sql.Open("sqlite", "file::memory:?cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := dbx.NewFromDB(sqlDB, "sqlite")
|
||||
|
||||
mm := search.MultiMatchSubquery{
|
||||
TargetTableAlias: "test_TargetTableAlias",
|
||||
FromTableName: "test_FromTableName",
|
||||
FromTableAlias: "test_FromTableAlias",
|
||||
ValueIdentifier: "({:mm},{:external})",
|
||||
Joins: []*search.Join{
|
||||
{TableName: "join_table1", TableAlias: "join_alias1"},
|
||||
{TableName: "join_table2", TableAlias: "join_alias2", On: dbx.NewExp("123={:join}", dbx.Params{"join": "test_join"})},
|
||||
},
|
||||
Params: dbx.Params{"mm": "test_mm"},
|
||||
}
|
||||
|
||||
params := dbx.Params{"external": "test_external"}
|
||||
|
||||
result := mm.Build(db, params)
|
||||
|
||||
expectedResult := "SELECT ({:mm},{:external}) as [[multiMatchValue]] FROM `test_FromTableName` `test_FromTableAlias` LEFT JOIN `join_table1` `join_alias1` LEFT JOIN `join_table2` `join_alias2` ON 123={:join} WHERE `test_FromTableAlias`.`id` = `test_TargetTableAlias`.`id`"
|
||||
if expectedResult != result {
|
||||
t.Fatalf("Expected build result\n%v\ngot\n%v", expectedResult, result)
|
||||
}
|
||||
|
||||
// the params from all expressions should be merged in the root
|
||||
rawParams, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedParams := []byte(`{"external":"test_external","join":"test_join","mm":"test_mm"}`)
|
||||
if !bytes.Equal(rawParams, expectedParams) {
|
||||
t.Fatalf("Expected final params\n%s\ngot\n%s", expectedParams, rawParams)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
@@ -298,13 +299,21 @@ func (s *Provider) Exec(items any) (*Result, error) {
|
||||
countExec := func() error {
|
||||
queryInfo := countQuery.Info()
|
||||
countCol := s.countCol
|
||||
|
||||
if len(queryInfo.From) > 0 {
|
||||
countCol = queryInfo.From[0] + "." + countCol
|
||||
firstFrom := dbutils.AliasOrIdentifier(queryInfo.From[0])
|
||||
countCol = firstFrom + "." + countCol
|
||||
}
|
||||
|
||||
// @todo while currently there is no such use case, evaluate if
|
||||
// wrapping as a subquery would be more suitable for the cases
|
||||
// when there is "Group By" different from the default deduplication one
|
||||
// added by RecordFieldResolver.UpdateQuery
|
||||
|
||||
// note: countQuery is shallow cloned and slice/map in-place modifications should be avoided
|
||||
err := countQuery.Distinct(false).
|
||||
Select("COUNT(DISTINCT [[" + countCol + "]])").
|
||||
GroupBy( /* reset */ ).
|
||||
OrderBy( /* reset */ ).
|
||||
Row(&totalCount)
|
||||
if err != nil {
|
||||
@@ -327,7 +336,6 @@ func (s *Provider) Exec(items any) (*Result, error) {
|
||||
if !s.skipTotal {
|
||||
// execute the 2 queries concurrently
|
||||
errg := new(errgroup.Group)
|
||||
errg.SetLimit(2)
|
||||
errg.Go(countExec)
|
||||
errg.Go(modelsExec)
|
||||
if err := errg.Wait(); err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -741,6 +742,7 @@ type testTableStruct struct {
|
||||
|
||||
type testDB struct {
|
||||
*dbx.DB
|
||||
mu sync.Mutex
|
||||
CalledQueries []string
|
||||
}
|
||||
|
||||
@@ -765,6 +767,8 @@ func createTestDB() (*testDB, error) {
|
||||
db.Insert("test", dbx.Params{"id": 1, "test1": 1, "test2": "test2.1"}).Execute()
|
||||
db.Insert("test", dbx.Params{"id": 2, "test1": 2, "test2": "test2.2"}).Execute()
|
||||
db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
db.CalledQueries = append(db.CalledQueries, sql)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,26 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
type NullFallbackPreference int
|
||||
|
||||
const (
|
||||
NullFallbackAuto NullFallbackPreference = 0
|
||||
NullFallbackDisabled NullFallbackPreference = 1
|
||||
NullFallbackEnforced NullFallbackPreference = 2
|
||||
)
|
||||
|
||||
// ResolverResult defines a single FieldResolver.Resolve() successfully parsed result.
|
||||
type ResolverResult struct {
|
||||
// Identifier is the plain SQL identifier/column that will be used
|
||||
// in the final db expression as left or right operand.
|
||||
Identifier string
|
||||
|
||||
// NoCoalesce instructs to not use COALESCE or NULL fallbacks
|
||||
// when building the identifier expression.
|
||||
NoCoalesce bool
|
||||
// NullFallback specify the preference for how NULL or empty values
|
||||
// should be resolved (default to "auto").
|
||||
//
|
||||
// Set to NullFallbackDisabled to prevent any COALESCE or NULL fallbacks.
|
||||
// Set to NullFallbackEnforced to prefer COALESCE or NULL fallbacks when needed.
|
||||
NullFallback NullFallbackPreference
|
||||
|
||||
// Params is a map with db placeholder->value pairs that will be added
|
||||
// to the query when building both resolved operands/sides in a single expression.
|
||||
@@ -26,7 +37,7 @@ type ResolverResult struct {
|
||||
|
||||
// MultiMatchSubQuery is an optional sub query expression that will be added
|
||||
// in addition to the combined ResolverResult expression during build.
|
||||
MultiMatchSubQuery dbx.Expression
|
||||
MultiMatchSubQuery *MultiMatchSubquery
|
||||
|
||||
// AfterBuild is an optional function that will be called after building
|
||||
// and combining the result of both resolved operands/sides in a single expression.
|
||||
@@ -103,7 +114,7 @@ func (r *SimpleFieldResolver) Resolve(field string) (*ResolverResult, error) {
|
||||
}
|
||||
|
||||
return &ResolverResult{
|
||||
NoCoalesce: true,
|
||||
NullFallback: NullFallbackDisabled,
|
||||
Identifier: fmt.Sprintf(
|
||||
"JSON_EXTRACT([[%s]], '%s')",
|
||||
inflector.Columnify(parts[0]),
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ganigeorgiev/fexpr"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var TokenFunctions = map[string]func(
|
||||
@@ -44,7 +48,7 @@ var TokenFunctions = map[string]func(
|
||||
latB := resolvedArgs[3].Identifier
|
||||
|
||||
return &ResolverResult{
|
||||
NoCoalesce: true,
|
||||
NullFallback: NullFallbackDisabled,
|
||||
Identifier: `(6371 * acos(` +
|
||||
`cos(radians(` + latA + `)) * cos(radians(` + latB + `)) * ` +
|
||||
`cos(radians(` + lonB + `) - radians(` + lonA + `)) + ` +
|
||||
@@ -53,4 +57,139 @@ var TokenFunctions = map[string]func(
|
||||
Params: mergeParams(resolvedArgs[0].Params, resolvedArgs[1].Params, resolvedArgs[2].Params, resolvedArgs[3].Params),
|
||||
}, nil
|
||||
},
|
||||
|
||||
// strftime(format, [timeValue, modifier1, modifier2, ...]) returns
|
||||
// a date string formatted according to the specified format argument.
|
||||
//
|
||||
// It is similar to the builtin SQLite strftime function (https://sqlite.org/lang_datefunc.html)
|
||||
// with the main difference that NULL results will be normalized for
|
||||
// consistency with the non-nullable PocketBase "text" and "date" fields.
|
||||
//
|
||||
// The function accepts 1, 2 or 3+ arguments.
|
||||
//
|
||||
// (1) The first (format) argument must be always a formatting string
|
||||
// with valid substitutions as listed in https://sqlite.org/lang_datefunc.html.
|
||||
//
|
||||
// (2) The second (time-value) argument is optional and must be either a date string, number or collection field identifier
|
||||
// that matches one of the formats listed in https://sqlite.org/lang_datefunc.html#time_values.
|
||||
//
|
||||
// (3+) The remaining (modifiers) optional arguments are expected to be
|
||||
// string literals matching the listed modifiers in https://sqlite.org/lang_datefunc.html#modifiers.
|
||||
//
|
||||
// A multi-match constraint will be also applied in case the time-value
|
||||
// is an identifier as a result of a multi-value relation field.
|
||||
"strftime": func(argTokenResolverFunc func(fexpr.Token) (*ResolverResult, error), args ...fexpr.Token) (*ResolverResult, error) {
|
||||
totalArgs := len(args)
|
||||
|
||||
if totalArgs < 1 {
|
||||
return nil, fmt.Errorf("[strftime] expected at least 1 arguments, got %d", len(args))
|
||||
}
|
||||
|
||||
// limit the number of arguments to prevent abuse
|
||||
if totalArgs > 10 {
|
||||
return nil, fmt.Errorf("[strftime] too many arguments (max allowed 10, got %d)", totalArgs)
|
||||
}
|
||||
|
||||
// format arg
|
||||
// -----------------------------------------------------------
|
||||
if args[0].Type != fexpr.TokenText {
|
||||
return nil, errors.New("[strftime] expects the first argument to be a format string")
|
||||
}
|
||||
|
||||
formatArgResult, err := argTokenResolverFunc(args[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[strftime] failed to resolve format argument: %w", err)
|
||||
}
|
||||
|
||||
// no further arguments
|
||||
if totalArgs == 1 {
|
||||
formatArgResult.NullFallback = NullFallbackEnforced
|
||||
formatArgResult.Identifier = "strftime(" + formatArgResult.Identifier + ")"
|
||||
return formatArgResult, nil
|
||||
}
|
||||
|
||||
// time-value arg
|
||||
// -----------------------------------------------------------
|
||||
allowedTimeValueTokens := []fexpr.TokenType{fexpr.TokenText, fexpr.TokenIdentifier, fexpr.TokenNumber}
|
||||
if !slices.Contains(allowedTimeValueTokens, args[1].Type) {
|
||||
return nil, errors.New("[strftime] expects the second argument to be of a valid time-value type")
|
||||
}
|
||||
|
||||
timeValueArgResult, err := argTokenResolverFunc(args[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[strftime] failed to resolve time-value argument: %w", err)
|
||||
}
|
||||
|
||||
// modifiers args
|
||||
// -----------------------------------------------------------
|
||||
resolvedModifierArgs := make([]*ResolverResult, totalArgs-2)
|
||||
for i, arg := range args[2:] {
|
||||
if arg.Type != fexpr.TokenText {
|
||||
return nil, fmt.Errorf("[strftime] invalid modifier argument %d - can be only string", i)
|
||||
}
|
||||
|
||||
resolved, err := argTokenResolverFunc(arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[strftime] failed to resolve modifier argument %d: %w", i, err)
|
||||
}
|
||||
|
||||
resolvedModifierArgs[i] = resolved
|
||||
}
|
||||
|
||||
// generating new ResolverResult
|
||||
// -----------------------------------------------------------
|
||||
result := &ResolverResult{
|
||||
NullFallback: NullFallbackEnforced,
|
||||
Params: dbx.Params{},
|
||||
}
|
||||
|
||||
identifiers := make([]string, 0, totalArgs)
|
||||
|
||||
identifiers = append(identifiers, formatArgResult.Identifier)
|
||||
if err = concatUniqueParams(result.Params, formatArgResult.Params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identifiers = append(identifiers, timeValueArgResult.Identifier)
|
||||
if err = concatUniqueParams(result.Params, timeValueArgResult.Params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range resolvedModifierArgs {
|
||||
identifiers = append(identifiers, m.Identifier)
|
||||
err = concatUniqueParams(result.Params, m.Params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result.Identifier = "strftime(" + strings.Join(identifiers, ",") + ")"
|
||||
|
||||
if timeValueArgResult.MultiMatchSubQuery != nil {
|
||||
// replace the regular time-value identifier with the multi-match one
|
||||
identifiers[1] = timeValueArgResult.MultiMatchSubQuery.ValueIdentifier
|
||||
result.MultiMatchSubQuery = timeValueArgResult.MultiMatchSubQuery
|
||||
result.MultiMatchSubQuery.ValueIdentifier = "strftime(" + strings.Join(identifiers, ",") + ")"
|
||||
|
||||
err = concatUniqueParams(result.MultiMatchSubQuery.Params, result.Params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
},
|
||||
}
|
||||
|
||||
func concatUniqueParams(destParams, newParams dbx.Params) error {
|
||||
for k, v := range newParams {
|
||||
found, ok := destParams[k]
|
||||
if ok && v != found {
|
||||
return fmt.Errorf("conflicting param key %s", k)
|
||||
}
|
||||
|
||||
destParams[k] = v
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user