mirror of
https://github.com/penpot/penpot.git
synced 2025-05-12 00:36:37 +02:00
Merge branch 'develop' into token-studio-develop
This commit is contained in:
commit
6af6dd1288
489 changed files with 80267 additions and 46712 deletions
|
@ -111,7 +111,7 @@ jobs:
|
||||||
yarn run build:app:assets
|
yarn run build:app:assets
|
||||||
clojure -M:dev:shadow-cljs release main
|
clojure -M:dev:shadow-cljs release main
|
||||||
yarn playwright install --with-deps chromium
|
yarn playwright install --with-deps chromium
|
||||||
yarn e2e:test
|
yarn test:e2e
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: "backend tests"
|
name: "backend tests"
|
||||||
|
|
176
CHANGES.md
176
CHANGES.md
|
@ -1,18 +1,190 @@
|
||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
## 2.2.0
|
## 2.4.0
|
||||||
|
|
||||||
### :rocket: Epics and highlights
|
### :rocket: Epics and highlights
|
||||||
|
|
||||||
### :boom: Breaking changes & Deprecations
|
### :boom: Breaking changes & Deprecations
|
||||||
|
|
||||||
|
- Use [nginx-unprivileged](https://hub.docker.com/r/nginxinc/nginx-unprivileged) as base image for Penpot's frontend docker image. Now all the docker images runs with the same unprivileged user (penpot). Because of that, the default NGINX listen port now is 8080, instead of 80, so you will have to modify your infrastructure to apply this change.
|
||||||
|
|
||||||
### :heart: Community contributions (Thank you!)
|
### :heart: Community contributions (Thank you!)
|
||||||
|
|
||||||
### :sparkles: New features
|
### :sparkles: New features
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
## 2.3.0
|
||||||
|
|
||||||
|
### :rocket: Epics and highlights
|
||||||
|
|
||||||
|
- **New plugin system.**
|
||||||
|
|
||||||
|
Penpot now supports custom plugins. Read everything about developing your plugins [HERE](https://help.penpot.app/plugins/)
|
||||||
|
|
||||||
|
### :boom: Breaking changes & Deprecations
|
||||||
|
|
||||||
|
### :heart: Community contributions (Thank you!)
|
||||||
|
|
||||||
|
- All our plugins beta testers :heart:.
|
||||||
|
- Fix problem when translating multiple path points by @eeropic [#4459](https://github.com/penpot/penpot/issues/4459)
|
||||||
|
|
||||||
|
### :sparkles: New features
|
||||||
|
|
||||||
|
- **Replace Draft.js completely with a custom editor** [Taiga #7706](https://tree.taiga.io/project/penpot/us/7706)
|
||||||
|
|
||||||
|
This refactor adds better IME support, more performant text editing
|
||||||
|
experience and a better clipboard support while keeping full
|
||||||
|
retrocompatibility with previous editor.
|
||||||
|
|
||||||
|
You can enable it with the `enable-feature-text-editor-v2` configuration flag.
|
||||||
|
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix problem with constraints buttons [Taiga #8465](https://tree.taiga.io/project/penpot/issue/8465)
|
||||||
|
- Fix problem with go back button on error page [Taiga #8887](https://tree.taiga.io/project/penpot/issue/8887)
|
||||||
|
- Fix problem with shadows in text for Safari [Taiga #8770](https://tree.taiga.io/project/penpot/issue/8770)
|
||||||
|
- Fix a regression with feedback form subject and content limits [Taiga #8908](https://tree.taiga.io/project/penpot/issue/8908)
|
||||||
|
- Fix problem with stroke and filter ordering in frames [Github #5058](https://github.com/penpot/penpot/issues/5058)
|
||||||
|
- Fix problem with hover layers when hidden/blocked [Github #5074](https://github.com/penpot/penpot/issues/5074)
|
||||||
|
- Fix problem with precision on boolean calculation [Taiga #8482](https://tree.taiga.io/project/penpot/issue/8482)
|
||||||
|
- Fix problem when translating multiple path points [Github #4459](https://github.com/penpot/penpot/issues/4459)
|
||||||
|
- Fix problem on importing (and exporting) files with flows [Taiga #8914](https://tree.taiga.io/project/penpot/issue/8914)
|
||||||
|
- Fix Internal Error page: "go to your penpot" wrong design [Taiga #8922](https://tree.taiga.io/project/penpot/issue/8922)
|
||||||
|
- Fix problem updating layout when toggle visibility in component copy [Github #5143](https://github.com/penpot/penpot/issues/5143)
|
||||||
|
- Fix "Done" button on toolbar on inspect mode should go to design mode [Taiga #8933](https://tree.taiga.io/project/penpot/issue/8933)
|
||||||
|
- Fix problem with shortcuts in text editor [Github #5078](https://github.com/penpot/penpot/issues/5078)
|
||||||
|
- Fix problems with show in viewer and interactions [Github #4868](https://github.com/penpot/penpot/issues/4868)
|
||||||
|
- Add visual feedback when moving an element into a board [Github #3210](https://github.com/penpot/penpot/issues/3210)
|
||||||
|
- Fix percent calculation on grid layout tracks [Github #4688](https://github.com/penpot/penpot/issues/4688)
|
||||||
|
- Fix problem with caps and inner shadows [Github #4517](https://github.com/penpot/penpot/issues/4517)
|
||||||
|
- Fix problem with horizontal/vertical lines and shadows [Github #4516](https://github.com/penpot/penpot/issues/4516)
|
||||||
|
|
||||||
|
## 2.2.1
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix problem with Ctrl+F shortcut on the dashboard [Taiga #8876](https://tree.taiga.io/project/penpot/issue/8876)
|
||||||
|
- Fix visual problem with the font-size dropdown in assets [Taiga #8872](https://tree.taiga.io/project/penpot/issue/8872)
|
||||||
|
- Add limits for invitation RPC methods (hard limit 25 emails per request)
|
||||||
|
|
||||||
|
## 2.2.0
|
||||||
|
|
||||||
|
### :rocket: Epics and highlights
|
||||||
|
|
||||||
|
### :boom: Breaking changes & Deprecations
|
||||||
|
|
||||||
|
- Removed "merge assets" option when exporting ".svg + .json" files. After the components changes the option wasn't
|
||||||
|
working properly and we're planning to change the format soon. We think it's better to deprecate the option for the
|
||||||
|
time being.
|
||||||
|
|
||||||
|
### :heart: Community contributions (Thank you!)
|
||||||
|
|
||||||
|
- Set proper default tenant on exporter (by @june128) [#4946](https://github.com/penpot/penpot/pull/4946)
|
||||||
|
- Correct a spelling in onboarding.edn (by @n-stha) [#4936](https://github.com/penpot/penpot/pull/4936)
|
||||||
|
|
||||||
|
### :sparkles: New features
|
||||||
|
|
||||||
|
- **Tiered File Data Storage** [Taiga #8376](https://tree.taiga.io/project/penpot/us/8376)
|
||||||
|
|
||||||
|
This feature allows offloading file data that is not actively used
|
||||||
|
from the database to object storage (e.g., filesystem, S3), thereby
|
||||||
|
freeing up space in the database. It can be enabled with the
|
||||||
|
`enable-enable-tiered-file-data-storage` flag.
|
||||||
|
|
||||||
|
*(On-Premise feature, EXPERIMENTAL).*
|
||||||
|
|
||||||
|
- **JSON Interoperability for HTTP API** [Taiga #8372](https://tree.taiga.io/project/penpot/us/8372)
|
||||||
|
|
||||||
|
Enables full JSON interoperability for our HTTP API. Previously,
|
||||||
|
JSON was only barely supported for output when the
|
||||||
|
`application/json` media type was specified in the `Accept` header,
|
||||||
|
or when `_fmt=json` was passed as a query parameter. With this
|
||||||
|
update, we now offer proper bi-directional support for using our API
|
||||||
|
with plain JSON, instead of Transit.
|
||||||
|
|
||||||
|
- **Automatic File Snapshotting**
|
||||||
|
|
||||||
|
Adds the ability to automatically take and maintain a limited set of
|
||||||
|
snapshots of active files without explicit user intervention. This
|
||||||
|
feature allows on-premise administrators to recover the state of a
|
||||||
|
file from a past point in time in a limited manner.
|
||||||
|
|
||||||
|
It can be enabled with the `enable-auto-file-snapshot` flag and
|
||||||
|
configured with the following settings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Take snapshots every 10 update operations
|
||||||
|
PENPOT_AUTO_FILE_SNAPSHOT_EVERY=10
|
||||||
|
|
||||||
|
# Take a snapshot if it has been more than 3 hours since the file was last modified
|
||||||
|
PENPOT_AUTO_FILE_SNAPSHOT_TIMEOUT=3h
|
||||||
|
|
||||||
|
# The total number of snapshots to keep
|
||||||
|
PENPOT_AUTO_FILE_SNAPSHOT_TOTAL=10
|
||||||
|
```
|
||||||
|
|
||||||
|
Snapshots are only taken during update operations; there is NO
|
||||||
|
active background process for this.
|
||||||
|
|
||||||
|
- Add separated flag `enable-oidc-registration` for enable the
|
||||||
|
registration only for OIDC authentication backend [Github
|
||||||
|
#4882](https://github.com/penpot/penpot/issues/4882)
|
||||||
|
|
||||||
|
- Update templates in libraries & templates in dashboard modal [Taiga #8145](https://tree.taiga.io/project/penpot/us/8145)
|
||||||
|
|
||||||
|
- **Design System**
|
||||||
|
|
||||||
|
We implemented and subbed in new components from our Design System: `loader*` ([Taiga #8355](https://tree.taiga.io/project/penpot/task/8355)) and `tab-switcher*` ([Taiga #8518](https://tree.taiga.io/project/penpot/task/8518)).
|
||||||
|
|
||||||
|
- **Storybook** [Taiga #6329](https://tree.taiga.io/project/penpot/us/6329)
|
||||||
|
|
||||||
|
The Design System components are now published in a Storybook, available at `/storybook`.
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix webhook checkbox position [Taiga #8634](https://tree.taiga.io/project/penpot/issue/8634)
|
||||||
|
- Fix wrong props on padding input [Taiga #8254](https://tree.taiga.io/project/penpot/issue/8254)
|
||||||
|
- Fix fill collapsed options [Taiga #8351](https://tree.taiga.io/project/penpot/issue/8351)
|
||||||
|
- Fix scroll on color picker modal [Taiga #8353](https://tree.taiga.io/project/penpot/issue/8353)
|
||||||
- Fix components are not dragged from the group to the assets tab [Taiga #8273](https://tree.taiga.io/project/penpot/issue/8273)
|
- Fix components are not dragged from the group to the assets tab [Taiga #8273](https://tree.taiga.io/project/penpot/issue/8273)
|
||||||
|
- Fix problem with SVG import [Github #4888](https://github.com/penpot/penpot/issues/4888)
|
||||||
|
- Fix problem with overlay positions in viewer [Taiga #8464](https://tree.taiga.io/project/penpot/issue/8464)
|
||||||
|
- Fix layer panel overflowing [Taiga #8665](https://tree.taiga.io/project/penpot/issue/8665)
|
||||||
|
- Fix problem when creating a component instance from grid layout [Github #4881](https://github.com/penpot/penpot/issues/4881)
|
||||||
|
- Fix problem when dismissing shared library update [Taiga #8669](https://tree.taiga.io/project/penpot/issue/8669)
|
||||||
|
- Fix visual problem with stroke cap menu [Taiga #8730](https://tree.taiga.io/project/penpot/issue/8730)
|
||||||
|
- Fix issue when exporting libraries when merging libraries [Taiga #8758](https://tree.taiga.io/project/penpot/issue/8758)
|
||||||
|
- Fix problem with comments max length [Taiga #8778](https://tree.taiga.io/project/penpot/issue/8778)
|
||||||
|
- Fix copy/paste images in Safari [Taiga #8771](https://tree.taiga.io/project/penpot/issue/8771)
|
||||||
|
- Fix swap when the copy is the only child of a group [#5075](https://github.com/penpot/penpot/issues/5075)
|
||||||
|
|
||||||
|
## 2.1.5
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix broken webhooks [Taiga #8370](https://tree.taiga.io/project/penpot/issue/8370)
|
||||||
|
|
||||||
|
## 2.1.4
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix json encoding on zip encoding decoding.
|
||||||
|
- Add schema validation for color changes.
|
||||||
|
- Fix render of some texts without position data.
|
||||||
|
|
||||||
|
## 2.1.3
|
||||||
|
|
||||||
|
- Don't allow registration when registration is disabled and invitation token is used [Github #4975](https://github.com/penpot/penpot/issues/4975)
|
||||||
|
|
||||||
|
## 2.1.2
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- User switch language to "zh_hant" will get 400 [Github #4884](https://github.com/penpot/penpot/issues/4884)
|
||||||
|
- Smtp config ignoring port if ssl is set [Github #4872](https://github.com/penpot/penpot/issues/4872)
|
||||||
|
- Ability to let users to authenticate with a private oidc provider only [Github #4963](https://github.com/penpot/penpot/issues/4963)
|
||||||
|
|
||||||
## 2.1.1
|
## 2.1.1
|
||||||
|
|
||||||
|
@ -33,7 +205,7 @@
|
||||||
|
|
||||||
### :boom: Breaking changes & Deprecations
|
### :boom: Breaking changes & Deprecations
|
||||||
|
|
||||||
### :heart: Community contributions (Thank you!)
|
### :heart: Communityq contributions (Thank you!)
|
||||||
|
|
||||||
### :sparkles: New features
|
### :sparkles: New features
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,7 @@ quick win.
|
||||||
If is going to be your first pull request, You can learn how from this
|
If is going to be your first pull request, You can learn how from this
|
||||||
free video series:
|
free video series:
|
||||||
|
|
||||||
https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github
|
https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github
|
||||||
|
|
||||||
We will use the `easy fix` mark for tag for indicate issues that are
|
We will use the `easy fix` mark for tag for indicate issues that are
|
||||||
easy for beginners.
|
easy for beginners.
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
[app.common.schema.desc-native :as smdn]
|
[app.common.schema.desc-native :as smdn]
|
||||||
[app.common.schema.generators :as sg]
|
[app.common.schema.generators :as sg]
|
||||||
[app.common.spec :as us]
|
[app.common.spec :as us]
|
||||||
|
[app.common.json :as json]
|
||||||
[app.common.transit :as t]
|
[app.common.transit :as t]
|
||||||
[app.common.types.file :as ctf]
|
[app.common.types.file :as ctf]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
|
@ -29,7 +30,6 @@
|
||||||
[app.srepl.helpers :as srepl.helpers]
|
[app.srepl.helpers :as srepl.helpers]
|
||||||
[app.srepl.main :as srepl]
|
[app.srepl.main :as srepl]
|
||||||
[app.util.blob :as blob]
|
[app.util.blob :as blob]
|
||||||
[app.util.json :as json]
|
|
||||||
[app.util.time :as dt]
|
[app.util.time :as dt]
|
||||||
[clj-async-profiler.core :as prof]
|
[clj-async-profiler.core :as prof]
|
||||||
[clojure.contrib.humanize :as hum]
|
[clojure.contrib.humanize :as hum]
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>
|
<title>
|
||||||
|
@ -110,15 +111,20 @@
|
||||||
class="" style="vertical-align:top;width:600px;"
|
class="" style="vertical-align:top;width:600px;"
|
||||||
>
|
>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<td style="width:97px;">
|
||||||
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png" style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;" width="97" />
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -151,7 +157,8 @@
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
@ -164,29 +171,43 @@
|
||||||
class="" style="vertical-align:top;width:600px;"
|
class="" style="vertical-align:top;width:600px;"
|
||||||
>
|
>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name|abbreviate:25}}!</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello {{name|abbreviate:25}}!</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">We received a request to change your current email to {{ pending-email }}.</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
We received a request to change your current email to {{ pending-email }}.</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Click to the link below to confirm the change:</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Click to the link below to confirm the change:</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="center" vertical-align="middle"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" bgcolor="#31EFB8" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;" valign="middle">
|
<td align="center" bgcolor="#31EFB8" role="presentation"
|
||||||
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}" style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Confirm email change </a>
|
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
|
||||||
|
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
||||||
|
target="_blank"> Confirm email change </a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -194,17 +215,24 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">If you received this email by mistake, please consider changing your password for security reasons.</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
If you received this email by mistake, please consider changing your password for security
|
||||||
|
reasons.</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Enjoy!</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Enjoy!</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">The Penpot team.</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -221,257 +249,9 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
{% include "app/email/includes/footer.html" %}
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:425px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-px-425 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:600px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://penpot.app/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://twitter.com/penpotapp" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://github.com/penpot/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-github.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://www.instagram.com/penpot.app/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://tree.taiga.io/project/penpot" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:600px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot | Made with <3 and Open Source</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
323
backend/resources/app/email/includes/footer.html
Normal file
323
backend/resources/app/email/includes/footer.html
Normal file
|
@ -0,0 +1,323 @@
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:425px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-px-425 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="vertical-align:top;" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
|
||||||
|
Penpot is the first Open Source design and prototyping platform meant for
|
||||||
|
cross-domain teams.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="vertical-align:top;" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<![endif]-->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="float:none;display:inline-table;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 8px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-radius:3px;width:24px;">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||||
|
<a href="https://penpot.app/" target="_blank">
|
||||||
|
<img height="24"
|
||||||
|
src="{{ public-uri }}/images/email/logo-uxbox.png"
|
||||||
|
style="border-radius:3px;display:block;"
|
||||||
|
width="24" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<![endif]-->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="float:none;display:inline-table;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 8px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-radius:3px;width:24px;">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||||
|
<a href="https://x.com/penpotapp" target="_blank">
|
||||||
|
<img height="24"
|
||||||
|
src="{{ public-uri }}/images/email/logo-x.png"
|
||||||
|
style="border-radius:3px;display:block;"
|
||||||
|
width="24" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<![endif]-->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="float:none;display:inline-table;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 8px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-radius:3px;width:24px;">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||||
|
<a href="https://github.com/penpot/" target="_blank">
|
||||||
|
<img height="24"
|
||||||
|
src="{{ public-uri }}/images/email/logo-github.png"
|
||||||
|
style="border-radius:3px;display:block;"
|
||||||
|
width="24" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<![endif]-->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="float:none;display:inline-table;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 8px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-radius:3px;width:24px;">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||||
|
<a href="https://www.linkedin.com/company/penpotdesign/"
|
||||||
|
target="_blank">
|
||||||
|
<img height="24"
|
||||||
|
src="{{ public-uri }}/images/email/logo-linkedin.png"
|
||||||
|
style="border-radius:3px;display:block;"
|
||||||
|
width="24" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<![endif]-->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="float:none;display:inline-table;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 8px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-radius:3px;width:24px;">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||||
|
<a href="https://fosstodon.org/@penpot/" target="_blank">
|
||||||
|
<img height="24"
|
||||||
|
src="{{ public-uri }}/images/email/logo-mastodon.png"
|
||||||
|
style="border-radius:3px;display:block;"
|
||||||
|
width="24" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<![endif]-->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="float:none;display:inline-table;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 8px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-radius:3px;width:24px;">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||||
|
<a href="https://tree.taiga.io/project/penpot"
|
||||||
|
target="_blank">
|
||||||
|
<img height="24"
|
||||||
|
src="{{ public-uri }}/images/email/logo-taiga.png"
|
||||||
|
style="border-radius:3px;display:block;"
|
||||||
|
width="24" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="vertical-align:top;" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
|
||||||
|
Penpot | Made with <3 and Open Source</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
|
@ -1,5 +1,6 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>
|
<title>
|
||||||
|
@ -110,15 +111,20 @@
|
||||||
class="" style="vertical-align:top;width:600px;"
|
class="" style="vertical-align:top;width:600px;"
|
||||||
>
|
>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<td style="width:97px;">
|
||||||
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png" style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;" width="97" />
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -151,7 +157,8 @@
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
@ -164,24 +171,36 @@
|
||||||
class="" style="vertical-align:top;width:600px;"
|
class="" style="vertical-align:top;width:600px;"
|
||||||
>
|
>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello!</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello!</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="center" vertical-align="middle"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" bgcolor="#31EFB8" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;" valign="middle">
|
<td align="center" bgcolor="#31EFB8" role="presentation"
|
||||||
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}" style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Accept invite </a>
|
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
|
||||||
|
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
||||||
|
target="_blank"> Accept invite </a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -189,12 +208,16 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Enjoy!</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Enjoy!</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">The Penpot team.</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -211,257 +234,9 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
{% include "app/email/includes/footer.html" %}
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:425px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-px-425 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:600px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://penpot.app/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://twitter.com/penpotapp" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://github.com/penpot/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-github.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://www.instagram.com/penpot.app/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://tree.taiga.io/project/penpot" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:600px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot | Made with <3 and Open Source</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
244
backend/resources/app/email/join-team/en.html
Normal file
244
backend/resources/app/email/join-team/en.html
Normal file
|
@ -0,0 +1,244 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>
|
||||||
|
</title>
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width:480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-column-px-425 {
|
||||||
|
width: 425px !important;
|
||||||
|
max-width: 425px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="background-color:#E5E5E5;">
|
||||||
|
<div style="background-color:#E5E5E5;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:97px;">
|
||||||
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello!</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
As you requested, {{invited-by|abbreviate:25}} has added you to the team “{{
|
||||||
|
team|abbreviate:25}}”.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" vertical-align="middle"
|
||||||
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#31EFB8" role="presentation"
|
||||||
|
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ public-uri }}/#/dashboard/team/{{team-id}}/projects"
|
||||||
|
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
||||||
|
target="_blank"> Go to the Team </a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Enjoy!</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "app/email/includes/footer.html" %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
1
backend/resources/app/email/join-team/en.subj
Normal file
1
backend/resources/app/email/join-team/en.subj
Normal file
|
@ -0,0 +1 @@
|
||||||
|
You have joined {{team}}
|
10
backend/resources/app/email/join-team/en.txt
Normal file
10
backend/resources/app/email/join-team/en.txt
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
Hello!
|
||||||
|
|
||||||
|
As you requested, {{invited-by|abbreviate:25}} has added you to the team “{{ team|abbreviate:25}}”.
|
||||||
|
|
||||||
|
Go to the team with this link:
|
||||||
|
|
||||||
|
{{ public-uri }}/#/dashboard/team/{{team-id}}
|
||||||
|
|
||||||
|
Enjoy!
|
||||||
|
The Penpot team.
|
|
@ -1,5 +1,6 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>
|
<title>
|
||||||
|
@ -110,15 +111,20 @@
|
||||||
class="" style="vertical-align:top;width:600px;"
|
class="" style="vertical-align:top;width:600px;"
|
||||||
>
|
>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<td style="width:97px;">
|
||||||
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png" style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;" width="97" />
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -151,7 +157,8 @@
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
@ -164,24 +171,37 @@
|
||||||
class="" style="vertical-align:top;width:600px;"
|
class="" style="vertical-align:top;width:600px;"
|
||||||
>
|
>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name|abbreviate:25}}!</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello {{name|abbreviate:25}}!</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">We have received a request to reset your password. Click the link below to choose a new one:</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
We have received a request to reset your password. Click the link below to choose a new one:
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="center" vertical-align="middle"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" bgcolor="#31EFB8" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;" valign="middle">
|
<td align="center" bgcolor="#31EFB8" role="presentation"
|
||||||
<a href="{{ public-uri }}/#/auth/recovery?token={{token}}" style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Reset password </a>
|
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ public-uri }}/#/auth/recovery?token={{token}}"
|
||||||
|
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
||||||
|
target="_blank"> Reset password </a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -189,17 +209,24 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">If you received this email by mistake, you can safely ignore it. Your password won't be changed.</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
If you received this email by mistake, you can safely ignore it. Your password won't be changed.
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Enjoy!</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Enjoy!</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">The Penpot team.</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -216,257 +243,9 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
{% include "app/email/includes/footer.html" %}
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:425px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-px-425 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:600px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://penpot.app/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://twitter.com/penpotapp" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://github.com/penpot/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-github.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://www.instagram.com/penpot.app/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://tree.taiga.io/project/penpot" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:600px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot | Made with <3 and Open Source</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>
|
<title>
|
||||||
|
@ -110,15 +111,20 @@
|
||||||
class="" style="vertical-align:top;width:600px;"
|
class="" style="vertical-align:top;width:600px;"
|
||||||
>
|
>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<td style="width:97px;">
|
||||||
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png" style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;" width="97" />
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -151,7 +157,8 @@
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
@ -164,24 +171,37 @@
|
||||||
class="" style="vertical-align:top;width:600px;"
|
class="" style="vertical-align:top;width:600px;"
|
||||||
>
|
>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name|abbreviate:25}}!</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello {{name|abbreviate:25}}!</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Thanks for signing up for your Penpot account! Please verify your email using the link below and get started building mockups and prototypes today!</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Thanks for signing up for your Penpot account! Please verify your email using the link below and
|
||||||
|
get started building mockups and prototypes today!</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="center" vertical-align="middle"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" bgcolor="#31EFB8" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;" valign="middle">
|
<td align="center" bgcolor="#31EFB8" role="presentation"
|
||||||
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}" style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Verify email </a>
|
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
|
||||||
|
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
||||||
|
target="_blank"> Verify email </a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -189,12 +209,16 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Enjoy!</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Enjoy!</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">The Penpot team.</div>
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -211,257 +235,9 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
{% include "app/email/includes/footer.html" %}
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:425px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-px-425 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:600px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://penpot.app/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-uxbox.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://twitter.com/penpotapp" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-twitter.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://github.com/penpot/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-github.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://www.instagram.com/penpot.app/" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<![endif]-->
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 8px;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
|
||||||
<tr>
|
|
||||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
|
||||||
<a href="https://tree.taiga.io/project/penpot" target="_blank">
|
|
||||||
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table
|
|
||||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
|
||||||
<![endif]-->
|
|
||||||
<div style="margin:0px auto;max-width:600px;">
|
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="" style="vertical-align:top;width:600px;"
|
|
||||||
>
|
|
||||||
<![endif]-->
|
|
||||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
||||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">Penpot | Made with <3 and Open Source</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,254 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>
|
||||||
|
</title>
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width:480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-column-px-425 {
|
||||||
|
width: 425px !important;
|
||||||
|
max-width: 425px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="background-color:#E5E5E5;">
|
||||||
|
<div style="background-color:#E5E5E5;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:97px;">
|
||||||
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello!</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<p>
|
||||||
|
{{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have view-only access to the
|
||||||
|
file named “{{file-name|abbreviate:25}}”.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Since this file is in your Penpot team, you can provide access by sending a view-only link.
|
||||||
|
This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
|
||||||
|
</p>
|
||||||
|
<p>To proceed, please click the button below to generate and send the view-only link:</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" vertical-align="middle"
|
||||||
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#31EFB8" role="presentation"
|
||||||
|
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}§ion=interactions&index=0&share=true"
|
||||||
|
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
||||||
|
target="_blank"> Send a View-Only link </a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<p>If you do not wish to grant access at this time, you can simply disregard this email.</p>
|
||||||
|
<p>Thank you</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "app/email/includes/footer.html" %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -0,0 +1 @@
|
||||||
|
Request View-Only Access to “{{file-name|abbreviate:25}}”
|
|
@ -0,0 +1,17 @@
|
||||||
|
Hello!
|
||||||
|
|
||||||
|
{{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have view-only access to the file named “{{file-name|abbreviate:25}}”.
|
||||||
|
|
||||||
|
Since this file is in your Penpot team, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
|
||||||
|
|
||||||
|
To proceed, please click the link below to generate and send the view-only link:
|
||||||
|
|
||||||
|
{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}§ion=interactions&index=0&share=true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
If you do not wish to grant access at this time, you can simply disregard this email.
|
||||||
|
Thank you
|
||||||
|
|
||||||
|
|
||||||
|
The Penpot team.
|
|
@ -0,0 +1,277 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>
|
||||||
|
</title>
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width:480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-column-px-425 {
|
||||||
|
width: 425px !important;
|
||||||
|
max-width: 425px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="background-color:#E5E5E5;">
|
||||||
|
<div style="background-color:#E5E5E5;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:97px;">
|
||||||
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello!</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<p>
|
||||||
|
{{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named
|
||||||
|
“{{file-name|abbreviate:25}}”.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please note that the file is currently in Your Penpot 's team, so direct access cannot be
|
||||||
|
granted. However, you have two options to provide the requested access:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<p>Move the File to Another Team:</p>
|
||||||
|
<p>You can move the file to another team and then give access to that team, inviting
|
||||||
|
{{requested-by|abbreviate:25}}.</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<p>Send a View-Only Link:</p>
|
||||||
|
<p>Alternatively, you can create and share a view-only link to the file. This will allow
|
||||||
|
{{requested-by|abbreviate:25}} to view the content without making any changes.</p>
|
||||||
|
<p>Click the button below to generate and send the link:</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" vertical-align="middle"
|
||||||
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#31EFB8" role="presentation"
|
||||||
|
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}§ion=interactions&index=0&share=true"
|
||||||
|
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
||||||
|
target="_blank"> Send a View-Only link </a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<p>If you do not wish to grant access at this time, you can simply disregard this email.</p>
|
||||||
|
<p>Thank you</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "app/email/includes/footer.html" %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -0,0 +1 @@
|
||||||
|
Request Access to “{{file-name|abbreviate:25}}”
|
|
@ -0,0 +1,30 @@
|
||||||
|
Hello!
|
||||||
|
|
||||||
|
|
||||||
|
Hello!
|
||||||
|
|
||||||
|
{{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named “{{file-name|abbreviate:25}}”.
|
||||||
|
|
||||||
|
Please note that the file is currently in Your Penpot 's team, so direct access cannot be granted. However, you have two options to provide the requested access:
|
||||||
|
|
||||||
|
- Move the File to Another Team:
|
||||||
|
|
||||||
|
You can move the file to another team and then give access to that team, inviting {{requested-by|abbreviate:25}}.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- Send a View-Only Link:
|
||||||
|
|
||||||
|
Alternatively, you can create and share a view-only link to the file. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
|
||||||
|
|
||||||
|
Click the link below to generate and send the link:
|
||||||
|
|
||||||
|
{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}§ion=interactions&index=0&share=true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
If you do not wish to grant access at this time, you can simply disregard this email.
|
||||||
|
Thank you
|
||||||
|
|
||||||
|
|
||||||
|
The Penpot team.
|
295
backend/resources/app/email/request-file-access/en.html
Normal file
295
backend/resources/app/email/request-file-access/en.html
Normal file
|
@ -0,0 +1,295 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>
|
||||||
|
</title>
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width:480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-column-px-425 {
|
||||||
|
width: 425px !important;
|
||||||
|
max-width: 425px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="background-color:#E5E5E5;">
|
||||||
|
<div style="background-color:#E5E5E5;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:97px;">
|
||||||
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello!</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<p>
|
||||||
|
{{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named
|
||||||
|
“{{file-name|abbreviate:25}}”.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
To provide this access, you have the following options:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<p>Give Access to the “{{team-name|abbreviate:25}}” Team:</p>
|
||||||
|
<p>This will automatically include {{requested-by|abbreviate:25}} in the team, so the user
|
||||||
|
can see all the projects and files in it.</p>
|
||||||
|
<p>Click the button below to provide team access:</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" vertical-align="middle"
|
||||||
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#31EFB8" role="presentation"
|
||||||
|
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ public-uri }}/#/dashboard/team/{{team-id}}/members?invite-email={{requested-by-email|urlescape }}"
|
||||||
|
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
||||||
|
target="_blank"> Give access to “{{team-name|abbreviate:25}}” Team </a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<p>Send a View-Only Link:</p>
|
||||||
|
<p>Alternatively, you can create and share a view-only link to the file. This will allow
|
||||||
|
{{requested-by|abbreviate:25}} to view the content without making any changes.</p>
|
||||||
|
<p>Click the button below to generate and send the link:</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" vertical-align="middle"
|
||||||
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#31EFB8" role="presentation"
|
||||||
|
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}§ion=interactions&index=0&share=true"
|
||||||
|
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
||||||
|
target="_blank"> Send a View-Only link </a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<p>If you do not wish to grant access at this time, you can simply disregard this email.</p>
|
||||||
|
<p>Thank you</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "app/email/includes/footer.html" %}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
1
backend/resources/app/email/request-file-access/en.subj
Normal file
1
backend/resources/app/email/request-file-access/en.subj
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Request Access to “{{file-name|abbreviate:25}}”
|
34
backend/resources/app/email/request-file-access/en.txt
Normal file
34
backend/resources/app/email/request-file-access/en.txt
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
Hello!
|
||||||
|
|
||||||
|
|
||||||
|
Hello!
|
||||||
|
|
||||||
|
{{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named “{{file-name|abbreviate:25}}”.
|
||||||
|
|
||||||
|
To provide this access, you have the following options:
|
||||||
|
|
||||||
|
- Give Access to the “{{team-name|abbreviate:25}}” Team:
|
||||||
|
|
||||||
|
This will automatically include {{requested-by|abbreviate:25}} in the team, so the user can see all the projects and files in it.
|
||||||
|
|
||||||
|
Click the link below to provide team access:
|
||||||
|
|
||||||
|
{{ public-uri }}/#/dashboard/team/{{team-id}}/members?invite-email={{requested-by-email|urlescape}}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- Send a View-Only Link:
|
||||||
|
|
||||||
|
Alternatively, you can create and share a view-only link to the file. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
|
||||||
|
|
||||||
|
Click the link below to generate and send the link:
|
||||||
|
|
||||||
|
{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}§ion=interactions&index=0&share=true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
If you do not wish to grant access at this time, you can simply disregard this email.
|
||||||
|
Thank you
|
||||||
|
|
||||||
|
|
||||||
|
The Penpot team.
|
252
backend/resources/app/email/request-team-access/en.html
Normal file
252
backend/resources/app/email/request-team-access/en.html
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>
|
||||||
|
</title>
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width:480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-column-px-425 {
|
||||||
|
width: 425px !important;
|
||||||
|
max-width: 425px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="background-color:#E5E5E5;">
|
||||||
|
<div style="background-color:#E5E5E5;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:97px;">
|
||||||
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello!</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<p>
|
||||||
|
{{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have access to the
|
||||||
|
“{{team-name|abbreviate:25}}” Team.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
To provide access, please click the button below:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" vertical-align="middle"
|
||||||
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#31EFB8" role="presentation"
|
||||||
|
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ public-uri }}/#/dashboard/team/{{team-id}}/members?invite-email={{requested-by-email|urlescape}}"
|
||||||
|
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
||||||
|
target="_blank"> Give access to “{{team-name|abbreviate:25}}” </a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<p>If you do not wish to grant access at this time, you can simply disregard this email.</p>
|
||||||
|
<p>Thank you</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "app/email/includes/footer.html" %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
1
backend/resources/app/email/request-team-access/en.subj
Normal file
1
backend/resources/app/email/request-team-access/en.subj
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Request Access to “{{team-name|abbreviate:25}}”
|
14
backend/resources/app/email/request-team-access/en.txt
Normal file
14
backend/resources/app/email/request-team-access/en.txt
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
Hello!
|
||||||
|
|
||||||
|
{{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have access to the “{{team-name|abbreviate:25}}” Team.
|
||||||
|
|
||||||
|
To provide access, please click the link below:
|
||||||
|
|
||||||
|
{{ public-uri }}/#/dashboard/team/{{team-id}}/members?invite-email={{requested-by-email|urlescape}}
|
||||||
|
|
||||||
|
|
||||||
|
If you do not wish to grant access at this time, you can simply disregard this email.
|
||||||
|
Thank you
|
||||||
|
|
||||||
|
|
||||||
|
The Penpot team.
|
|
@ -1,39 +1,42 @@
|
||||||
[{:id "wireframing-kit"
|
[{:id "wireframing-kit"
|
||||||
:name "Wireframe library"
|
:name "Wireframe library"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/wireframing-kit.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/wireframing-kit.penpot"}
|
||||||
{:id "prototype-examples"
|
{:id "prototype-examples"
|
||||||
:name "Prototype template"
|
:name "Prototype template"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/prototype-examples.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/prototype-examples.penpot"}
|
||||||
{:id "plants-app"
|
{:id "plants-app"
|
||||||
:name "UI mockup example"
|
:name "UI mockup example"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Plants-app.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/Plants-app.penpot"}
|
||||||
{:id "penpot-design-system"
|
{:id "penpot-design-system"
|
||||||
:name "Design system example"
|
:name "Design system example"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Penpot-Design-system.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/Penpot-Design-system.penpot"}
|
||||||
{:id "tutorial-for-beginners"
|
{:id "tutorial-for-beginners"
|
||||||
:name "Tutorial for beginners"
|
:name "Tutorial for beginners"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/tutorial-for-beginners.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/tutorial-for-beginners.penpot"}
|
||||||
{:id "lucide-icons"
|
{:id "lucide-icons"
|
||||||
:name "Lucide Icons"
|
:name "Lucide Icons"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Lucide-icons.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/Lucide-icons.penpot"}
|
||||||
{:id "font-awesome"
|
{:id "font-awesome"
|
||||||
:name "Font Awesome"
|
:name "Font Awesome"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Font-Awesome.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/FontAwesome.penpot"}
|
||||||
{:id "black-white-mobile-templates"
|
{:id "black-white-mobile-templates"
|
||||||
:name "Black & White Mobile Templates"
|
:name "Black & White Mobile Templates"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Black-White-Mobile-Templates.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/Black-&-White-Mobile-Templates.penpot"}
|
||||||
{:id "avataaars"
|
{:id "avataaars"
|
||||||
:name "Avataaars"
|
:name "Avataaars"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Avataaars-by-Pablo-Stanley.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/Avataaars-by-Pablo-Stanley.penpot"}
|
||||||
{:id "ux-notes"
|
{:id "ux-notes"
|
||||||
:name "UX Notes"
|
:name "UX Notes"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/UX-Notes.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/UX-Notes.penpot"}
|
||||||
{:id "whiteboarding-kit"
|
{:id "whiteboarding-kit"
|
||||||
:name "Whiteboarding Kit"
|
:name "Whiteboarding Kit"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Whiteboarding-mapping-kit.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/Whiteboarding-mapping-kit.penpot"}
|
||||||
{:id "open-color-scheme"
|
{:id "open-color-scheme"
|
||||||
:name "Open Color Scheme"
|
:name "Open Color Scheme"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Open-Color-Scheme.penpot"}
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/Open%20Color%20Scheme%20(v1.9.1).penpot"}
|
||||||
{:id "flex-layout-playground"
|
{:id "flex-layout-playground"
|
||||||
:name "Flex Layout Playground"
|
:name "Flex Layout Playground"
|
||||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Flex-Layout-Playground.penpot"}]
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/Flex%20Layout%20Playground.penpot"}
|
||||||
|
{:id "welcome"
|
||||||
|
:name "Welcome"
|
||||||
|
:file-uri "https://github.com/penpot/penpot-files/raw/main/welcome.penpot"}]
|
||||||
|
|
|
@ -23,10 +23,12 @@ export PENPOT_FLAGS="\
|
||||||
enable-urepl-server \
|
enable-urepl-server \
|
||||||
enable-rpc-climit \
|
enable-rpc-climit \
|
||||||
enable-rpc-rlimit \
|
enable-rpc-rlimit \
|
||||||
|
enable-quotes \
|
||||||
enable-soft-rpc-rlimit \
|
enable-soft-rpc-rlimit \
|
||||||
enable-file-snapshot \
|
enable-auto-file-snapshot \
|
||||||
enable-webhooks \
|
enable-webhooks \
|
||||||
enable-access-tokens \
|
enable-access-tokens \
|
||||||
|
enable-tiered-file-data-storage \
|
||||||
enable-file-validation \
|
enable-file-validation \
|
||||||
enable-file-schema-validation \
|
enable-file-schema-validation \
|
||||||
disable-feature-design-tokens";
|
disable-feature-design-tokens";
|
||||||
|
@ -63,9 +65,10 @@ mc mb penpot-s3/penpot -p -q
|
||||||
|
|
||||||
export AWS_ACCESS_KEY_ID=penpot-devenv
|
export AWS_ACCESS_KEY_ID=penpot-devenv
|
||||||
export AWS_SECRET_ACCESS_KEY=penpot-devenv
|
export AWS_SECRET_ACCESS_KEY=penpot-devenv
|
||||||
export PENPOT_ASSETS_STORAGE_BACKEND=assets-s3
|
|
||||||
export PENPOT_STORAGE_ASSETS_S3_ENDPOINT=http://minio:9000
|
export PENPOT_OBJECTS_STORAGE_BACKEND=s3
|
||||||
export PENPOT_STORAGE_ASSETS_S3_BUCKET=penpot
|
export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000
|
||||||
|
export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot
|
||||||
|
|
||||||
export OPTIONS="
|
export OPTIONS="
|
||||||
-A:jmx-remote -A:dev \
|
-A:jmx-remote -A:dev \
|
||||||
|
|
|
@ -17,8 +17,10 @@ export PENPOT_FLAGS="\
|
||||||
disable-secure-session-cookies \
|
disable-secure-session-cookies \
|
||||||
enable-rpc-climit \
|
enable-rpc-climit \
|
||||||
enable-smtp \
|
enable-smtp \
|
||||||
|
enable-quotes \
|
||||||
enable-file-snapshot \
|
enable-file-snapshot \
|
||||||
enable-access-tokens \
|
enable-access-tokens \
|
||||||
|
enable-tiered-file-data-storage \
|
||||||
enable-file-validation \
|
enable-file-validation \
|
||||||
enable-file-schema-validation \
|
enable-file-schema-validation \
|
||||||
disable-feature-design-tokens";
|
disable-feature-design-tokens";
|
||||||
|
@ -57,9 +59,9 @@ mc mb penpot-s3/penpot -p -q
|
||||||
|
|
||||||
export AWS_ACCESS_KEY_ID=penpot-devenv
|
export AWS_ACCESS_KEY_ID=penpot-devenv
|
||||||
export AWS_SECRET_ACCESS_KEY=penpot-devenv
|
export AWS_SECRET_ACCESS_KEY=penpot-devenv
|
||||||
export PENPOT_ASSETS_STORAGE_BACKEND=assets-s3
|
export PENPOT_OBJECTS_STORAGE_BACKEND=s3
|
||||||
export PENPOT_STORAGE_ASSETS_S3_ENDPOINT=http://minio:9000
|
export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000
|
||||||
export PENPOT_STORAGE_ASSETS_S3_BUCKET=penpot
|
export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot
|
||||||
|
|
||||||
entrypoint=${1:-app.main};
|
entrypoint=${1:-app.main};
|
||||||
|
|
||||||
|
|
|
@ -567,7 +567,6 @@
|
||||||
(tokens/generate (::setup/props cfg)
|
(tokens/generate (::setup/props cfg)
|
||||||
{:iss :auth
|
{:iss :auth
|
||||||
:exp (dt/in-future "15m")
|
:exp (dt/in-future "15m")
|
||||||
:props (:props info)
|
|
||||||
:profile-id (:id profile)}))
|
:profile-id (:id profile)}))
|
||||||
props (audit/profile->props profile)
|
props (audit/profile->props profile)
|
||||||
context (d/without-nils {:external-session-id (:external-session-id info)})]
|
context (d/without-nils {:external-session-id (:external-session-id info)})]
|
||||||
|
@ -592,7 +591,8 @@
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(let [info (assoc info :is-active (provider-has-email-verified? cfg info))]
|
(let [info (assoc info :is-active (provider-has-email-verified? cfg info))]
|
||||||
(if (contains? cf/flags :registration)
|
(if (or (contains? cf/flags :registration)
|
||||||
|
(contains? cf/flags :oidc-registration))
|
||||||
(redirect-to-register cfg info request)
|
(redirect-to-register cfg info request)
|
||||||
(redirect-with-error "registration-disabled")))))
|
(redirect-with-error "registration-disabled")))))
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,6 @@
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.media :as media]
|
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.teams :as teams]
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
|
@ -403,9 +402,9 @@
|
||||||
(write-obj! output rels)))
|
(write-obj! output rels)))
|
||||||
|
|
||||||
(defmethod write-section :v1/sobjects
|
(defmethod write-section :v1/sobjects
|
||||||
[{:keys [::sto/storage ::output]}]
|
[{:keys [::output] :as cfg}]
|
||||||
(let [sids (-> bfc/*state* deref :sids)
|
(let [sids (-> bfc/*state* deref :sids)
|
||||||
storage (media/configure-assets-storage storage)]
|
storage (sto/resolve cfg)]
|
||||||
|
|
||||||
(l/dbg :hint "found sobjects"
|
(l/dbg :hint "found sobjects"
|
||||||
:items (count sids)
|
:items (count sids)
|
||||||
|
@ -620,8 +619,8 @@
|
||||||
::l/sync? true))))))
|
::l/sync? true))))))
|
||||||
|
|
||||||
(defmethod read-section :v1/sobjects
|
(defmethod read-section :v1/sobjects
|
||||||
[{:keys [::sto/storage ::db/conn ::input ::bfc/overwrite ::bfc/timestamp]}]
|
[{:keys [::db/conn ::input ::bfc/overwrite ::bfc/timestamp] :as cfg}]
|
||||||
(let [storage (media/configure-assets-storage storage)
|
(let [storage (sto/resolve cfg)
|
||||||
ids (read-obj! input)
|
ids (read-obj! input)
|
||||||
thumb? (into #{} (map :media-id) (:thumbnails @bfc/*state*))]
|
thumb? (into #{} (map :media-id) (:thumbnails @bfc/*state*))]
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,6 @@
|
||||||
[app.db.sql :as sql]
|
[app.db.sql :as sql]
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.media :as media]
|
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
[app.storage.tmp :as tmp]
|
[app.storage.tmp :as tmp]
|
||||||
[app.util.events :as events]
|
[app.util.events :as events]
|
||||||
|
@ -347,9 +346,7 @@
|
||||||
[cfg team-id]
|
[cfg team-id]
|
||||||
(let [id (uuid/next)
|
(let [id (uuid/next)
|
||||||
tp (dt/tpoint)
|
tp (dt/tpoint)
|
||||||
|
cfg (create-database cfg)]
|
||||||
cfg (-> (create-database cfg)
|
|
||||||
(update ::sto/storage media/configure-assets-storage))]
|
|
||||||
|
|
||||||
(l/inf :hint "start"
|
(l/inf :hint "start"
|
||||||
:operation "export"
|
:operation "export"
|
||||||
|
@ -390,7 +387,6 @@
|
||||||
tp (dt/tpoint)
|
tp (dt/tpoint)
|
||||||
|
|
||||||
cfg (-> (create-database cfg path)
|
cfg (-> (create-database cfg path)
|
||||||
(update ::sto/storage media/configure-assets-storage)
|
|
||||||
(assoc ::bfc/timestamp (dt/now)))]
|
(assoc ::bfc/timestamp (dt/now)))]
|
||||||
|
|
||||||
(l/inf :hint "start"
|
(l/inf :hint "start"
|
||||||
|
|
|
@ -42,9 +42,9 @@
|
||||||
:rpc-rlimit-config "resources/rlimit.edn"
|
:rpc-rlimit-config "resources/rlimit.edn"
|
||||||
:rpc-climit-config "resources/climit.edn"
|
:rpc-climit-config "resources/climit.edn"
|
||||||
|
|
||||||
:file-snapshot-total 10
|
:auto-file-snapshot-total 10
|
||||||
:file-snapshot-every 5
|
:auto-file-snapshot-every 5
|
||||||
:file-snapshot-timeout "3h"
|
:auto-file-snapshot-timeout "3h"
|
||||||
|
|
||||||
:public-uri "http://localhost:3449"
|
:public-uri "http://localhost:3449"
|
||||||
:host "localhost"
|
:host "localhost"
|
||||||
|
@ -52,8 +52,8 @@
|
||||||
|
|
||||||
:redis-uri "redis://redis/0"
|
:redis-uri "redis://redis/0"
|
||||||
|
|
||||||
:assets-storage-backend :assets-fs
|
:objects-storage-backend "fs"
|
||||||
:storage-assets-fs-directory "assets"
|
:objects-storage-fs-directory "assets"
|
||||||
|
|
||||||
:assets-path "/internal/assets/"
|
:assets-path "/internal/assets/"
|
||||||
:smtp-default-reply-to "Penpot <no-reply@example.com>"
|
:smtp-default-reply-to "Penpot <no-reply@example.com>"
|
||||||
|
@ -91,25 +91,25 @@
|
||||||
[:public-uri {:optional false} :string]
|
[:public-uri {:optional false} :string]
|
||||||
[:host {:optional false} :string]
|
[:host {:optional false} :string]
|
||||||
|
|
||||||
[:http-server-port {:optional true} :int]
|
[:http-server-port {:optional true} ::sm/int]
|
||||||
[:http-server-host {:optional true} :string]
|
[:http-server-host {:optional true} :string]
|
||||||
[:http-server-max-body-size {:optional true} :int]
|
[:http-server-max-body-size {:optional true} ::sm/int]
|
||||||
[:http-server-max-multipart-body-size {:optional true} :int]
|
[:http-server-max-multipart-body-size {:optional true} ::sm/int]
|
||||||
[:http-server-io-threads {:optional true} :int]
|
[:http-server-io-threads {:optional true} ::sm/int]
|
||||||
[:http-server-worker-threads {:optional true} :int]
|
[:http-server-worker-threads {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:telemetry-uri {:optional true} :string]
|
[:telemetry-uri {:optional true} :string]
|
||||||
[:telemetry-with-taiga {:optional true} :boolean] ;; DELETE
|
[:telemetry-with-taiga {:optional true} ::sm/boolean] ;; DELETE
|
||||||
|
|
||||||
[:file-snapshot-total {:optional true} :int]
|
[:auto-file-snapshot-total {:optional true} ::sm/int]
|
||||||
[:file-snapshot-every {:optional true} :int]
|
[:auto-file-snapshot-every {:optional true} ::sm/int]
|
||||||
[:file-snapshot-timeout {:optional true} ::dt/duration]
|
[:auto-file-snapshot-timeout {:optional true} ::dt/duration]
|
||||||
|
|
||||||
[:media-max-file-size {:optional true} :int]
|
[:media-max-file-size {:optional true} ::sm/int]
|
||||||
[:deletion-delay {:optional true} ::dt/duration] ;; REVIEW
|
[:deletion-delay {:optional true} ::dt/duration] ;; REVIEW
|
||||||
[:telemetry-enabled {:optional true} :boolean]
|
[:telemetry-enabled {:optional true} ::sm/boolean]
|
||||||
[:default-blob-version {:optional true} :int]
|
[:default-blob-version {:optional true} ::sm/int]
|
||||||
[:allow-demo-users {:optional true} :boolean]
|
[:allow-demo-users {:optional true} ::sm/boolean]
|
||||||
[:error-report-webhook {:optional true} :string]
|
[:error-report-webhook {:optional true} :string]
|
||||||
[:user-feedback-destination {:optional true} :string]
|
[:user-feedback-destination {:optional true} :string]
|
||||||
|
|
||||||
|
@ -118,30 +118,30 @@
|
||||||
[:rpc-climit-config {:optional true} ::fs/path]
|
[:rpc-climit-config {:optional true} ::fs/path]
|
||||||
|
|
||||||
[:audit-log-archive-uri {:optional true} :string]
|
[:audit-log-archive-uri {:optional true} :string]
|
||||||
[:audit-log-http-handler-concurrency {:optional true} :int]
|
[:audit-log-http-handler-concurrency {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:default-executor-parallelism {:optional true} :int] ;; REVIEW
|
[:default-executor-parallelism {:optional true} ::sm/int] ;; REVIEW
|
||||||
[:scheduled-executor-parallelism {:optional true} :int] ;; REVIEW
|
[:scheduled-executor-parallelism {:optional true} ::sm/int] ;; REVIEW
|
||||||
[:worker-default-parallelism {:optional true} :int]
|
[:worker-default-parallelism {:optional true} ::sm/int]
|
||||||
[:worker-webhook-parallelism {:optional true} :int]
|
[:worker-webhook-parallelism {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:database-password {:optional true} [:maybe :string]]
|
[:database-password {:optional true} [:maybe :string]]
|
||||||
[:database-uri {:optional true} :string]
|
[:database-uri {:optional true} :string]
|
||||||
[:database-username {:optional true} [:maybe :string]]
|
[:database-username {:optional true} [:maybe :string]]
|
||||||
[:database-readonly {:optional true} :boolean]
|
[:database-readonly {:optional true} ::sm/boolean]
|
||||||
[:database-min-pool-size {:optional true} :int]
|
[:database-min-pool-size {:optional true} ::sm/int]
|
||||||
[:database-max-pool-size {:optional true} :int]
|
[:database-max-pool-size {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:quotes-teams-per-profile {:optional true} :int]
|
[:quotes-teams-per-profile {:optional true} ::sm/int]
|
||||||
[:quotes-access-tokens-per-profile {:optional true} :int]
|
[:quotes-access-tokens-per-profile {:optional true} ::sm/int]
|
||||||
[:quotes-projects-per-team {:optional true} :int]
|
[:quotes-projects-per-team {:optional true} ::sm/int]
|
||||||
[:quotes-invitations-per-team {:optional true} :int]
|
[:quotes-invitations-per-team {:optional true} ::sm/int]
|
||||||
[:quotes-profiles-per-team {:optional true} :int]
|
[:quotes-profiles-per-team {:optional true} ::sm/int]
|
||||||
[:quotes-files-per-project {:optional true} :int]
|
[:quotes-files-per-project {:optional true} ::sm/int]
|
||||||
[:quotes-files-per-team {:optional true} :int]
|
[:quotes-files-per-team {:optional true} ::sm/int]
|
||||||
[:quotes-font-variants-per-team {:optional true} :int]
|
[:quotes-font-variants-per-team {:optional true} ::sm/int]
|
||||||
[:quotes-comment-threads-per-file {:optional true} :int]
|
[:quotes-comment-threads-per-file {:optional true} ::sm/int]
|
||||||
[:quotes-comments-per-file {:optional true} :int]
|
[:quotes-comments-per-file {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:auth-data-cookie-domain {:optional true} :string]
|
[:auth-data-cookie-domain {:optional true} :string]
|
||||||
[:auth-token-cookie-name {:optional true} :string]
|
[:auth-token-cookie-name {:optional true} :string]
|
||||||
|
@ -178,15 +178,15 @@
|
||||||
[:ldap-bind-dn {:optional true} :string]
|
[:ldap-bind-dn {:optional true} :string]
|
||||||
[:ldap-bind-password {:optional true} :string]
|
[:ldap-bind-password {:optional true} :string]
|
||||||
[:ldap-host {:optional true} :string]
|
[:ldap-host {:optional true} :string]
|
||||||
[:ldap-port {:optional true} :int]
|
[:ldap-port {:optional true} ::sm/int]
|
||||||
[:ldap-ssl {:optional true} :boolean]
|
[:ldap-ssl {:optional true} ::sm/boolean]
|
||||||
[:ldap-starttls {:optional true} :boolean]
|
[:ldap-starttls {:optional true} ::sm/boolean]
|
||||||
[:ldap-user-query {:optional true} :string]
|
[:ldap-user-query {:optional true} :string]
|
||||||
|
|
||||||
[:profile-bounce-max-age {:optional true} ::dt/duration]
|
[:profile-bounce-max-age {:optional true} ::dt/duration]
|
||||||
[:profile-bounce-threshold {:optional true} :int]
|
[:profile-bounce-threshold {:optional true} ::sm/int]
|
||||||
[:profile-complaint-max-age {:optional true} ::dt/duration]
|
[:profile-complaint-max-age {:optional true} ::dt/duration]
|
||||||
[:profile-complaint-threshold {:optional true} :int]
|
[:profile-complaint-threshold {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:redis-uri {:optional true} :string]
|
[:redis-uri {:optional true} :string]
|
||||||
|
|
||||||
|
@ -197,26 +197,34 @@
|
||||||
[:smtp-default-reply-to {:optional true} :string]
|
[:smtp-default-reply-to {:optional true} :string]
|
||||||
[:smtp-host {:optional true} :string]
|
[:smtp-host {:optional true} :string]
|
||||||
[:smtp-password {:optional true} [:maybe :string]]
|
[:smtp-password {:optional true} [:maybe :string]]
|
||||||
[:smtp-port {:optional true} :int]
|
[:smtp-port {:optional true} ::sm/int]
|
||||||
[:smtp-ssl {:optional true} :boolean]
|
[:smtp-ssl {:optional true} ::sm/boolean]
|
||||||
[:smtp-tls {:optional true} :boolean]
|
[:smtp-tls {:optional true} ::sm/boolean]
|
||||||
[:smtp-username {:optional true} [:maybe :string]]
|
[:smtp-username {:optional true} [:maybe :string]]
|
||||||
|
|
||||||
[:urepl-host {:optional true} :string]
|
[:urepl-host {:optional true} :string]
|
||||||
[:urepl-port {:optional true} :int]
|
[:urepl-port {:optional true} ::sm/int]
|
||||||
[:prepl-host {:optional true} :string]
|
[:prepl-host {:optional true} :string]
|
||||||
[:prepl-port {:optional true} :int]
|
[:prepl-port {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:assets-storage-backend {:optional true} :keyword]
|
|
||||||
[:media-directory {:optional true} :string] ;; REVIEW
|
[:media-directory {:optional true} :string] ;; REVIEW
|
||||||
[:media-uri {:optional true} :string]
|
[:media-uri {:optional true} :string]
|
||||||
[:assets-path {:optional true} :string]
|
[:assets-path {:optional true} :string]
|
||||||
|
|
||||||
|
;; Legacy, will be removed in 2.5
|
||||||
|
[:assets-storage-backend {:optional true} :keyword]
|
||||||
[:storage-assets-fs-directory {:optional true} :string]
|
[:storage-assets-fs-directory {:optional true} :string]
|
||||||
[:storage-assets-s3-bucket {:optional true} :string]
|
[:storage-assets-s3-bucket {:optional true} :string]
|
||||||
[:storage-assets-s3-region {:optional true} :keyword]
|
[:storage-assets-s3-region {:optional true} :keyword]
|
||||||
[:storage-assets-s3-endpoint {:optional true} :string]
|
[:storage-assets-s3-endpoint {:optional true} :string]
|
||||||
[:storage-assets-s3-io-threads {:optional true} :int]]))
|
[:storage-assets-s3-io-threads {:optional true} ::sm/int]
|
||||||
|
|
||||||
|
[:objects-storage-backend {:optional true} :keyword]
|
||||||
|
[:objects-storage-fs-directory {:optional true} :string]
|
||||||
|
[:objects-storage-s3-bucket {:optional true} :string]
|
||||||
|
[:objects-storage-s3-region {:optional true} :keyword]
|
||||||
|
[:objects-storage-s3-endpoint {:optional true} :string]
|
||||||
|
[:objects-storage-s3-io-threads {:optional true} ::sm/int]]))
|
||||||
|
|
||||||
(def default-flags
|
(def default-flags
|
||||||
[:enable-backend-api-doc
|
[:enable-backend-api-doc
|
||||||
|
@ -245,7 +253,7 @@
|
||||||
env)))
|
env)))
|
||||||
|
|
||||||
(def decode-config
|
(def decode-config
|
||||||
(sm/decoder schema:config sm/default-transformer))
|
(sm/decoder schema:config sm/string-transformer))
|
||||||
|
|
||||||
(def validate-config
|
(def validate-config
|
||||||
(sm/validator schema:config))
|
(sm/validator schema:config))
|
||||||
|
|
|
@ -153,7 +153,7 @@
|
||||||
(s/def ::conn some?)
|
(s/def ::conn some?)
|
||||||
(s/def ::nilable-pool (s/nilable ::pool))
|
(s/def ::nilable-pool (s/nilable ::pool))
|
||||||
(s/def ::pool pool?)
|
(s/def ::pool pool?)
|
||||||
(s/def ::pool-or-conn some?)
|
(s/def ::connectable some?)
|
||||||
|
|
||||||
(defn closed?
|
(defn closed?
|
||||||
[pool]
|
[pool]
|
||||||
|
@ -407,6 +407,7 @@
|
||||||
(ex/raise :type :not-found
|
(ex/raise :type :not-found
|
||||||
:code :object-not-found
|
:code :object-not-found
|
||||||
:table table
|
:table table
|
||||||
|
:params params
|
||||||
:hint "database object not found"))
|
:hint "database object not found"))
|
||||||
row))
|
row))
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,8 @@
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.db.sql :as sql]
|
[app.db.sql :as sql]
|
||||||
[app.email.invite-to-team :as-alias email.invite-to-team]
|
[app.email.invite-to-team :as-alias email.invite-to-team]
|
||||||
|
[app.email.join-team :as-alias email.join-team]
|
||||||
|
[app.email.request-team-access :as-alias email.request-team-access]
|
||||||
[app.metrics :as mtx]
|
[app.metrics :as mtx]
|
||||||
[app.util.template :as tmpl]
|
[app.util.template :as tmpl]
|
||||||
[app.worker :as wrk]
|
[app.worker :as wrk]
|
||||||
|
@ -155,10 +157,10 @@
|
||||||
[:map
|
[:map
|
||||||
[::username {:optional true} :string]
|
[::username {:optional true} :string]
|
||||||
[::password {:optional true} :string]
|
[::password {:optional true} :string]
|
||||||
[::tls {:optional true} :boolean]
|
[::tls {:optional true} ::sm/boolean]
|
||||||
[::ssl {:optional true} :boolean]
|
[::ssl {:optional true} ::sm/boolean]
|
||||||
[::host {:optional true} :string]
|
[::host {:optional true} :string]
|
||||||
[::port {:optional true} :int]
|
[::port {:optional true} ::sm/int]
|
||||||
[::default-from {:optional true} :string]
|
[::default-from {:optional true} :string]
|
||||||
[::default-reply-to {:optional true} :string]])
|
[::default-reply-to {:optional true} :string]])
|
||||||
|
|
||||||
|
@ -304,6 +306,8 @@
|
||||||
(let [session (create-smtp-session cfg)]
|
(let [session (create-smtp-session cfg)]
|
||||||
(with-open [transport (.getTransport session (if (::ssl cfg) "smtps" "smtp"))]
|
(with-open [transport (.getTransport session (if (::ssl cfg) "smtps" "smtp"))]
|
||||||
(.connect ^Transport transport
|
(.connect ^Transport transport
|
||||||
|
^String (::host cfg)
|
||||||
|
^String (::port cfg)
|
||||||
^String (::username cfg)
|
^String (::username cfg)
|
||||||
^String (::password cfg))
|
^String (::password cfg))
|
||||||
|
|
||||||
|
@ -311,15 +315,13 @@
|
||||||
(l/dbg :hint "sendmail"
|
(l/dbg :hint "sendmail"
|
||||||
:id (:id params)
|
:id (:id params)
|
||||||
:to (:to params)
|
:to (:to params)
|
||||||
:subject (str/trim (:subject params))
|
:subject (str/trim (:subject params)))
|
||||||
:body (str/join "," (map :type (:body params))))
|
|
||||||
|
|
||||||
(.sendMessage ^Transport transport
|
(.sendMessage ^Transport transport
|
||||||
^MimeMessage message
|
^MimeMessage message
|
||||||
(.getAllRecipients message))))))
|
(.getAllRecipients message))))))
|
||||||
|
|
||||||
(when (or (contains? cf/flags :log-emails)
|
(when (contains? cf/flags :log-emails)
|
||||||
(not (contains? cf/flags :smtp)))
|
|
||||||
(send-to-logger! cfg params))))
|
(send-to-logger! cfg params))))
|
||||||
|
|
||||||
(defmethod ig/pre-init-spec ::handler [_]
|
(defmethod ig/pre-init-spec ::handler [_]
|
||||||
|
@ -397,6 +399,79 @@
|
||||||
"Teams member invitation email."
|
"Teams member invitation email."
|
||||||
(template-factory ::invite-to-team))
|
(template-factory ::invite-to-team))
|
||||||
|
|
||||||
|
|
||||||
|
(s/def ::email.join-team/invited-by ::us/string)
|
||||||
|
(s/def ::email.join-team/team ::us/string)
|
||||||
|
(s/def ::email.join-team/team-id ::us/uuid)
|
||||||
|
|
||||||
|
(s/def ::join-team
|
||||||
|
(s/keys :req-un [::email.join-team/invited-by
|
||||||
|
::email.join-team/team-id
|
||||||
|
::email.join-team/team]))
|
||||||
|
|
||||||
|
(def join-team
|
||||||
|
"Teams member joined after request email."
|
||||||
|
(template-factory ::join-team))
|
||||||
|
|
||||||
|
(s/def ::email.request-team-access/requested-by ::us/string)
|
||||||
|
(s/def ::email.request-team-access/requested-by-email ::us/string)
|
||||||
|
(s/def ::email.request-team-access/team-name ::us/string)
|
||||||
|
(s/def ::email.request-team-access/team-id ::us/uuid)
|
||||||
|
(s/def ::email.request-team-access/file-name ::us/string)
|
||||||
|
(s/def ::email.request-team-access/file-id ::us/uuid)
|
||||||
|
(s/def ::email.request-team-access/page-id ::us/uuid)
|
||||||
|
|
||||||
|
(s/def ::request-file-access
|
||||||
|
(s/keys :req-un [::email.request-team-access/requested-by
|
||||||
|
::email.request-team-access/requested-by-email
|
||||||
|
::email.request-team-access/team-name
|
||||||
|
::email.request-team-access/team-id
|
||||||
|
::email.request-team-access/file-name
|
||||||
|
::email.request-team-access/file-id
|
||||||
|
::email.request-team-access/page-id]))
|
||||||
|
|
||||||
|
(def request-file-access
|
||||||
|
"File access request email."
|
||||||
|
(template-factory ::request-file-access))
|
||||||
|
|
||||||
|
|
||||||
|
(s/def ::request-file-access-yourpenpot
|
||||||
|
(s/keys :req-un [::email.request-team-access/requested-by
|
||||||
|
::email.request-team-access/requested-by-email
|
||||||
|
::email.request-team-access/team-name
|
||||||
|
::email.request-team-access/team-id
|
||||||
|
::email.request-team-access/file-name
|
||||||
|
::email.request-team-access/file-id
|
||||||
|
::email.request-team-access/page-id]))
|
||||||
|
|
||||||
|
(def request-file-access-yourpenpot
|
||||||
|
"File access on Your Penpot request email."
|
||||||
|
(template-factory ::request-file-access-yourpenpot))
|
||||||
|
|
||||||
|
(s/def ::request-file-access-yourpenpot-view
|
||||||
|
(s/keys :req-un [::email.request-team-access/requested-by
|
||||||
|
::email.request-team-access/requested-by-email
|
||||||
|
::email.request-team-access/team-name
|
||||||
|
::email.request-team-access/team-id
|
||||||
|
::email.request-team-access/file-name
|
||||||
|
::email.request-team-access/file-id
|
||||||
|
::email.request-team-access/page-id]))
|
||||||
|
|
||||||
|
(def request-file-access-yourpenpot-view
|
||||||
|
"File access on Your Penpot view mode request email."
|
||||||
|
(template-factory ::request-file-access-yourpenpot-view))
|
||||||
|
|
||||||
|
(s/def ::request-team-access
|
||||||
|
(s/keys :req-un [::email.request-team-access/requested-by
|
||||||
|
::email.request-team-access/requested-by-email
|
||||||
|
::email.request-team-access/team-name
|
||||||
|
::email.request-team-access/team-id]))
|
||||||
|
|
||||||
|
(def request-team-access
|
||||||
|
"Team access request email."
|
||||||
|
(template-factory ::request-team-access))
|
||||||
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; BOUNCE/COMPLAINS HELPERS
|
;; BOUNCE/COMPLAINS HELPERS
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
|
@ -62,6 +62,7 @@
|
||||||
[datoteka.io :as io]
|
[datoteka.io :as io]
|
||||||
[promesa.util :as pu]))
|
[promesa.util :as pu]))
|
||||||
|
|
||||||
|
|
||||||
(def ^:dynamic *stats*
|
(def ^:dynamic *stats*
|
||||||
"A dynamic var for setting up state for collect stats globally."
|
"A dynamic var for setting up state for collect stats globally."
|
||||||
nil)
|
nil)
|
||||||
|
@ -113,7 +114,7 @@
|
||||||
(sm/lazy-validator ::ctc/color))
|
(sm/lazy-validator ::ctc/color))
|
||||||
|
|
||||||
(def valid-fill?
|
(def valid-fill?
|
||||||
(sm/lazy-validator ::cts/fill))
|
(sm/lazy-validator cts/schema:fill))
|
||||||
|
|
||||||
(def valid-stroke?
|
(def valid-stroke?
|
||||||
(sm/lazy-validator ::cts/stroke))
|
(sm/lazy-validator ::cts/stroke))
|
||||||
|
@ -134,10 +135,10 @@
|
||||||
(sm/lazy-validator ::ctc/rgb-color))
|
(sm/lazy-validator ::ctc/rgb-color))
|
||||||
|
|
||||||
(def valid-shape-points?
|
(def valid-shape-points?
|
||||||
(sm/lazy-validator ::cts/points))
|
(sm/lazy-validator cts/schema:points))
|
||||||
|
|
||||||
(def valid-image-attrs?
|
(def valid-image-attrs?
|
||||||
(sm/lazy-validator ::cts/image-attrs))
|
(sm/lazy-validator cts/schema:image-attrs))
|
||||||
|
|
||||||
(def valid-column-grid-params?
|
(def valid-column-grid-params?
|
||||||
(sm/lazy-validator ::ctg/column-params))
|
(sm/lazy-validator ::ctg/column-params))
|
||||||
|
@ -1742,7 +1743,7 @@
|
||||||
:validate validate?
|
:validate validate?
|
||||||
:skip-on-graphic-error skip-on-graphic-error?)
|
:skip-on-graphic-error skip-on-graphic-error?)
|
||||||
|
|
||||||
(db/tx-run! (update system ::sto/storage media/configure-assets-storage)
|
(db/tx-run! system
|
||||||
(fn [system]
|
(fn [system]
|
||||||
(binding [*system* system]
|
(binding [*system* system]
|
||||||
(when (string? label)
|
(when (string? label)
|
||||||
|
|
|
@ -12,10 +12,19 @@
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.db.sql :as-alias sql]
|
[app.db.sql :as-alias sql]
|
||||||
|
[app.storage :as sto]
|
||||||
[app.util.blob :as blob]
|
[app.util.blob :as blob]
|
||||||
[app.util.objects-map :as omap]
|
[app.util.objects-map :as omap]
|
||||||
[app.util.pointer-map :as pmap]))
|
[app.util.pointer-map :as pmap]))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; OFFLOAD
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn offloaded?
|
||||||
|
[file]
|
||||||
|
(= "objects-storage" (:data-backend file)))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; OBJECTS-MAP
|
;; OBJECTS-MAP
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
@ -55,31 +64,45 @@
|
||||||
;; POINTER-MAP
|
;; POINTER-MAP
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn get-file-data
|
||||||
|
"Get file data given a file instance."
|
||||||
|
[system file]
|
||||||
|
(if (offloaded? file)
|
||||||
|
(let [storage (sto/resolve system ::db/reuse-conn true)]
|
||||||
|
(->> (sto/get-object storage (:data-ref-id file))
|
||||||
|
(sto/get-object-bytes storage)))
|
||||||
|
(:data file)))
|
||||||
|
|
||||||
|
(defn resolve-file-data
|
||||||
|
[system file]
|
||||||
|
(let [data (get-file-data system file)]
|
||||||
|
(assoc file :data data)))
|
||||||
|
|
||||||
(defn load-pointer
|
(defn load-pointer
|
||||||
"A database loader pointer helper"
|
"A database loader pointer helper"
|
||||||
[system file-id id]
|
[system file-id id]
|
||||||
(let [{:keys [content]} (db/get system :file-data-fragment
|
(let [fragment (db/get* system :file-data-fragment
|
||||||
{:id id :file-id file-id}
|
{:id id :file-id file-id}
|
||||||
{::sql/columns [:content]
|
{::sql/columns [:data :data-backend :data-ref-id :id]})]
|
||||||
::db/check-deleted false})]
|
|
||||||
|
|
||||||
(l/trc :hint "load pointer"
|
(l/trc :hint "load pointer"
|
||||||
:file-id (str file-id)
|
:file-id (str file-id)
|
||||||
:id (str id)
|
:id (str id)
|
||||||
:found (some? content))
|
:found (some? fragment))
|
||||||
|
|
||||||
(when-not content
|
(when-not fragment
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
:code :fragment-not-found
|
:code :fragment-not-found
|
||||||
:hint "fragment not found"
|
:hint "fragment not found"
|
||||||
:file-id file-id
|
:file-id file-id
|
||||||
:fragment-id id))
|
:fragment-id id))
|
||||||
|
|
||||||
(blob/decode content)))
|
(let [data (get-file-data system fragment)]
|
||||||
|
;; FIXME: conditional thread scheduling for decoding big objects
|
||||||
|
(blob/decode data))))
|
||||||
|
|
||||||
(defn persist-pointers!
|
(defn persist-pointers!
|
||||||
"Given a database connection and the final file-id, persist all
|
"Persist all currently tracked pointer objects"
|
||||||
pointers to the underlying storage (the database)."
|
|
||||||
[system file-id]
|
[system file-id]
|
||||||
(let [conn (db/get-connection system)]
|
(let [conn (db/get-connection system)]
|
||||||
(doseq [[id item] @pmap/*tracked*]
|
(doseq [[id item] @pmap/*tracked*]
|
||||||
|
@ -89,7 +112,7 @@
|
||||||
(db/insert! conn :file-data-fragment
|
(db/insert! conn :file-data-fragment
|
||||||
{:id id
|
{:id id
|
||||||
:file-id file-id
|
:file-id file-id
|
||||||
:content content}))))))
|
:data content}))))))
|
||||||
|
|
||||||
(defn process-pointers
|
(defn process-pointers
|
||||||
"Apply a function to all pointers on the file. Usuly used for
|
"Apply a function to all pointers on the file. Usuly used for
|
||||||
|
|
|
@ -57,11 +57,10 @@
|
||||||
(defn- serve-object
|
(defn- serve-object
|
||||||
"Helper function that returns the appropriate response depending on
|
"Helper function that returns the appropriate response depending on
|
||||||
the storage object backend type."
|
the storage object backend type."
|
||||||
[{:keys [::sto/storage] :as cfg} {:keys [backend] :as obj}]
|
[cfg {:keys [backend] :as obj}]
|
||||||
(let [backend (sto/resolve-backend storage backend)]
|
(case backend
|
||||||
(case (::sto/type backend)
|
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
|
||||||
:s3 (serve-object-from-s3 cfg obj)
|
(:fs :assets-fs) (serve-object-from-fs cfg obj)))
|
||||||
:fs (serve-object-from-fs cfg obj))))
|
|
||||||
|
|
||||||
(defn objects-handler
|
(defn objects-handler
|
||||||
"Handler that servers storage objects by id."
|
"Handler that servers storage objects by id."
|
||||||
|
|
|
@ -7,11 +7,13 @@
|
||||||
(ns app.http.middleware
|
(ns app.http.middleware
|
||||||
(:require
|
(:require
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.json :as json]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
|
[app.common.schema :as-alias sm]
|
||||||
[app.common.transit :as t]
|
[app.common.transit :as t]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.http.errors :as errors]
|
[app.http.errors :as errors]
|
||||||
[clojure.data.json :as json]
|
[app.util.pointer-map :as pmap]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[ring.request :as rreq]
|
[ring.request :as rreq]
|
||||||
[ring.response :as rres]
|
[ring.response :as rres]
|
||||||
|
@ -39,16 +41,6 @@
|
||||||
(java.io.BufferedReader.
|
(java.io.BufferedReader.
|
||||||
(java.io.InputStreamReader. body))))
|
(java.io.InputStreamReader. body))))
|
||||||
|
|
||||||
(defn- read-json-key
|
|
||||||
[k]
|
|
||||||
(-> k str/kebab keyword))
|
|
||||||
|
|
||||||
(defn- write-json-key
|
|
||||||
[k]
|
|
||||||
(if (or (keyword? k) (symbol? k))
|
|
||||||
(str/camel k)
|
|
||||||
(str k)))
|
|
||||||
|
|
||||||
(defn wrap-parse-request
|
(defn wrap-parse-request
|
||||||
[handler]
|
[handler]
|
||||||
(letfn [(process-request [request]
|
(letfn [(process-request [request]
|
||||||
|
@ -63,7 +55,7 @@
|
||||||
|
|
||||||
(str/starts-with? header "application/json")
|
(str/starts-with? header "application/json")
|
||||||
(with-open [reader (get-reader request)]
|
(with-open [reader (get-reader request)]
|
||||||
(let [params (json/read reader :key-fn read-json-key)]
|
(let [params (json/read reader :key-fn json/read-kebab-key)]
|
||||||
(-> request
|
(-> request
|
||||||
(assoc :body-params params)
|
(assoc :body-params params)
|
||||||
(update :params merge params))))
|
(update :params merge params))))
|
||||||
|
@ -113,6 +105,12 @@
|
||||||
|
|
||||||
(def ^:const buffer-size (:xnio/buffer-size yt/defaults))
|
(def ^:const buffer-size (:xnio/buffer-size yt/defaults))
|
||||||
|
|
||||||
|
(defn- write-json-value
|
||||||
|
[_ val]
|
||||||
|
(if (pmap/pointer-map? val)
|
||||||
|
[(pmap/get-id val) (meta val)]
|
||||||
|
val))
|
||||||
|
|
||||||
(defn wrap-format-response
|
(defn wrap-format-response
|
||||||
[handler]
|
[handler]
|
||||||
(letfn [(transit-streamable-body [data opts]
|
(letfn [(transit-streamable-body [data opts]
|
||||||
|
@ -134,10 +132,11 @@
|
||||||
(reify rres/StreamableResponseBody
|
(reify rres/StreamableResponseBody
|
||||||
(-write-body-to-stream [_ _ output-stream]
|
(-write-body-to-stream [_ _ output-stream]
|
||||||
(try
|
(try
|
||||||
|
(let [encode (or (-> data meta :encode/json) identity)
|
||||||
|
data (encode data)]
|
||||||
(with-open [^OutputStream bos (buffered-output-stream output-stream buffer-size)]
|
(with-open [^OutputStream bos (buffered-output-stream output-stream buffer-size)]
|
||||||
(with-open [^java.io.OutputStreamWriter writer (java.io.OutputStreamWriter. bos)]
|
(with-open [^java.io.OutputStreamWriter writer (java.io.OutputStreamWriter. bos)]
|
||||||
(json/write data writer :key-fn write-json-key)))
|
(json/write writer data :key-fn json/write-camel-key :value-fn write-json-value))))
|
||||||
|
|
||||||
(catch java.io.IOException _)
|
(catch java.io.IOException _)
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(binding [l/*context* {:value data}]
|
(binding [l/*context* {:value data}]
|
||||||
|
|
|
@ -60,6 +60,9 @@
|
||||||
(try
|
(try
|
||||||
(let [result (handler)]
|
(let [result (handler)]
|
||||||
(events/tap :end result))
|
(events/tap :end result))
|
||||||
|
|
||||||
|
(catch java.io.EOFException cause
|
||||||
|
(events/tap :error (errors/handle' cause request)))
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(l/err :hint "unexpected error on processing sse response"
|
(l/err :hint "unexpected error on processing sse response"
|
||||||
:cause cause)
|
:cause cause)
|
||||||
|
|
|
@ -278,18 +278,18 @@
|
||||||
:inc 1)
|
:inc 1)
|
||||||
message)
|
message)
|
||||||
|
|
||||||
(def ^:private schema:params
|
|
||||||
(sm/define
|
|
||||||
[:map {:title "params"}
|
|
||||||
[:session-id ::sm/uuid]]))
|
|
||||||
|
|
||||||
(defn- http-handler
|
(defn- http-handler
|
||||||
[cfg {:keys [params ::session/profile-id] :as request}]
|
[cfg {:keys [params ::session/profile-id] :as request}]
|
||||||
(let [{:keys [session-id]} (sm/conform! schema:params params)]
|
(let [session-id (some-> params :session-id sm/parse-uuid)]
|
||||||
|
(when-not (uuid? session-id)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :missing-session-id
|
||||||
|
:hint "missing or invalid session-id found"))
|
||||||
|
|
||||||
(cond
|
(cond
|
||||||
(not profile-id)
|
(not profile-id)
|
||||||
(ex/raise :type :authentication
|
(ex/raise :type :authentication
|
||||||
:hint "Authentication required.")
|
:hint "authentication required")
|
||||||
|
|
||||||
;; WORKAROUND: we use the adapter specific predicate for
|
;; WORKAROUND: we use the adapter specific predicate for
|
||||||
;; performance reasons; for now, the ring default impl for
|
;; performance reasons; for now, the ring default impl for
|
||||||
|
|
|
@ -263,6 +263,8 @@
|
||||||
(assoc ::wrk/dedupe dedupe?)
|
(assoc ::wrk/dedupe dedupe?)
|
||||||
(assoc ::wrk/label label)
|
(assoc ::wrk/label label)
|
||||||
(assoc ::wrk/params (-> params
|
(assoc ::wrk/params (-> params
|
||||||
|
(dissoc :source)
|
||||||
|
(dissoc :context)
|
||||||
(dissoc :ip-addr)
|
(dissoc :ip-addr)
|
||||||
(dissoc :type)))))))
|
(dissoc :type)))))))
|
||||||
params))
|
params))
|
||||||
|
|
|
@ -66,21 +66,18 @@
|
||||||
(defmethod ig/init-key ::process-event-handler
|
(defmethod ig/init-key ::process-event-handler
|
||||||
[_ cfg]
|
[_ cfg]
|
||||||
(fn [{:keys [props] :as task}]
|
(fn [{:keys [props] :as task}]
|
||||||
(let [event (:event props)]
|
(l/dbg :hint "process webhook event" :name (:name props))
|
||||||
(l/dbg :hint "process webhook event" :name (:name event))
|
|
||||||
|
|
||||||
(when-let [items (lookup-webhooks cfg event)]
|
(when-let [items (lookup-webhooks cfg props)]
|
||||||
(l/trc :hint "webhooks found for event" :total (count items))
|
(l/trc :hint "webhooks found for event" :total (count items))
|
||||||
|
|
||||||
(db/tx-run! cfg (fn [cfg]
|
(db/tx-run! cfg (fn [cfg]
|
||||||
(doseq [item items]
|
(doseq [item items]
|
||||||
(wrk/submit! (-> cfg
|
(wrk/submit! (-> cfg
|
||||||
(assoc ::wrk/task :run-webhook)
|
(assoc ::wrk/task :run-webhook)
|
||||||
(assoc ::wrk/queue :webhooks)
|
(assoc ::wrk/queue :webhooks)
|
||||||
(assoc ::wrk/max-retries 3)
|
(assoc ::wrk/max-retries 3)
|
||||||
(assoc ::wrk/params {:event event
|
(assoc ::wrk/params {:event props
|
||||||
:config item}))))))))))
|
:config item})))))))))
|
||||||
|
|
||||||
;; --- RUN
|
;; --- RUN
|
||||||
|
|
||||||
(declare interpret-exception)
|
(declare interpret-exception)
|
||||||
|
@ -138,7 +135,7 @@
|
||||||
|
|
||||||
(l/dbg :hint "run webhook"
|
(l/dbg :hint "run webhook"
|
||||||
:event-name (:name event)
|
:event-name (:name event)
|
||||||
:webhook-id (:id whook)
|
:webhook-id (str (:id whook))
|
||||||
:webhook-uri (:uri whook)
|
:webhook-uri (:uri whook)
|
||||||
:webhook-mtype (:mtype whook))
|
:webhook-mtype (:mtype whook))
|
||||||
|
|
||||||
|
|
|
@ -344,6 +344,8 @@
|
||||||
{:sendmail (ig/ref ::email/handler)
|
{:sendmail (ig/ref ::email/handler)
|
||||||
:objects-gc (ig/ref :app.tasks.objects-gc/handler)
|
:objects-gc (ig/ref :app.tasks.objects-gc/handler)
|
||||||
:file-gc (ig/ref :app.tasks.file-gc/handler)
|
:file-gc (ig/ref :app.tasks.file-gc/handler)
|
||||||
|
:file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/handler)
|
||||||
|
:offload-file-data (ig/ref :app.tasks.offload-file-data/handler)
|
||||||
:file-xlog-gc (ig/ref :app.tasks.file-xlog-gc/handler)
|
:file-xlog-gc (ig/ref :app.tasks.file-xlog-gc/handler)
|
||||||
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
|
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
|
||||||
:telemetry (ig/ref :app.tasks.telemetry/handler)
|
:telemetry (ig/ref :app.tasks.telemetry/handler)
|
||||||
|
@ -394,9 +396,17 @@
|
||||||
{::db/pool (ig/ref ::db/pool)
|
{::db/pool (ig/ref ::db/pool)
|
||||||
::sto/storage (ig/ref ::sto/storage)}
|
::sto/storage (ig/ref ::sto/storage)}
|
||||||
|
|
||||||
:app.tasks.file-xlog-gc/handler
|
:app.tasks.file-gc-scheduler/handler
|
||||||
{::db/pool (ig/ref ::db/pool)}
|
{::db/pool (ig/ref ::db/pool)}
|
||||||
|
|
||||||
|
:app.tasks.offload-file-data/handler
|
||||||
|
{::db/pool (ig/ref ::db/pool)
|
||||||
|
::sto/storage (ig/ref ::sto/storage)}
|
||||||
|
|
||||||
|
:app.tasks.file-xlog-gc/handler
|
||||||
|
{::db/pool (ig/ref ::db/pool)
|
||||||
|
::sto/storage (ig/ref ::sto/storage)}
|
||||||
|
|
||||||
:app.tasks.telemetry/handler
|
:app.tasks.telemetry/handler
|
||||||
{::db/pool (ig/ref ::db/pool)
|
{::db/pool (ig/ref ::db/pool)
|
||||||
::http.client/client (ig/ref ::http.client/client)
|
::http.client/client (ig/ref ::http.client/client)
|
||||||
|
@ -448,17 +458,28 @@
|
||||||
::sto/storage
|
::sto/storage
|
||||||
{::db/pool (ig/ref ::db/pool)
|
{::db/pool (ig/ref ::db/pool)
|
||||||
::sto/backends
|
::sto/backends
|
||||||
{:assets-s3 (ig/ref [::assets :app.storage.s3/backend])
|
{:s3 (ig/ref :app.storage.s3/backend)
|
||||||
:assets-fs (ig/ref [::assets :app.storage.fs/backend])}}
|
:fs (ig/ref :app.storage.fs/backend)
|
||||||
|
|
||||||
[::assets :app.storage.s3/backend]
|
;; LEGACY (should not be removed, can only be removed after an
|
||||||
{::sto.s3/region (cf/get :storage-assets-s3-region)
|
;; explicit migration because the database objects/rows will
|
||||||
::sto.s3/endpoint (cf/get :storage-assets-s3-endpoint)
|
;; still reference the old names).
|
||||||
::sto.s3/bucket (cf/get :storage-assets-s3-bucket)
|
:assets-s3 (ig/ref :app.storage.s3/backend)
|
||||||
::sto.s3/io-threads (cf/get :storage-assets-s3-io-threads)}
|
:assets-fs (ig/ref :app.storage.fs/backend)}}
|
||||||
|
|
||||||
[::assets :app.storage.fs/backend]
|
:app.storage.s3/backend
|
||||||
{::sto.fs/directory (cf/get :storage-assets-fs-directory)}})
|
{::sto.s3/region (or (cf/get :storage-assets-s3-region)
|
||||||
|
(cf/get :objects-storage-s3-region))
|
||||||
|
::sto.s3/endpoint (or (cf/get :storage-assets-s3-endpoint)
|
||||||
|
(cf/get :objects-storage-s3-endpoint))
|
||||||
|
::sto.s3/bucket (or (cf/get :storage-assets-s3-bucket)
|
||||||
|
(cf/get :objects-storage-s3-bucket))
|
||||||
|
::sto.s3/io-threads (or (cf/get :storage-assets-s3-io-threads)
|
||||||
|
(cf/get :objects-storage-s3-io-threads))}
|
||||||
|
|
||||||
|
:app.storage.fs/backend
|
||||||
|
{::sto.fs/directory (or (cf/get :storage-assets-fs-directory)
|
||||||
|
(cf/get :objects-storage-fs-directory))}})
|
||||||
|
|
||||||
|
|
||||||
(def worker-config
|
(def worker-config
|
||||||
|
@ -485,7 +506,7 @@
|
||||||
:task :tasks-gc}
|
:task :tasks-gc}
|
||||||
|
|
||||||
{:cron #app/cron "0 0 2 * * ?" ;; daily
|
{:cron #app/cron "0 0 2 * * ?" ;; daily
|
||||||
:task :file-gc}
|
:task :file-gc-scheduler}
|
||||||
|
|
||||||
{:cron #app/cron "0 30 */3,23 * * ?"
|
{:cron #app/cron "0 30 */3,23 * * ?"
|
||||||
:task :telemetry}
|
:task :telemetry}
|
||||||
|
|
|
@ -49,7 +49,7 @@
|
||||||
(sm/register! ::upload
|
(sm/register! ::upload
|
||||||
[:map {:title "Upload"}
|
[:map {:title "Upload"}
|
||||||
[:filename :string]
|
[:filename :string]
|
||||||
[:size :int]
|
[:size ::sm/int]
|
||||||
[:path ::fs/path]
|
[:path ::fs/path]
|
||||||
[:mtype {:optional true} :string]
|
[:mtype {:optional true} :string]
|
||||||
[:headers {:optional true}
|
[:headers {:optional true}
|
||||||
|
@ -313,17 +313,3 @@
|
||||||
(= stype :ttf)
|
(= stype :ttf)
|
||||||
(-> (assoc "font/otf" (ttf->otf sfnt))
|
(-> (assoc "font/otf" (ttf->otf sfnt))
|
||||||
(assoc "font/ttf" sfnt)))))))))
|
(assoc "font/ttf" sfnt)))))))))
|
||||||
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
;; Utility functions
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
|
|
||||||
(defn configure-assets-storage
|
|
||||||
"Given storage map, returns a storage configured with the appropriate
|
|
||||||
backend for assets and optional connection attached."
|
|
||||||
([storage]
|
|
||||||
(assoc storage ::sto/backend (cf/get :assets-storage-backend :assets-fs)))
|
|
||||||
([storage pool-or-conn]
|
|
||||||
(-> (configure-assets-storage storage)
|
|
||||||
(assoc ::db/pool-or-conn pool-or-conn))))
|
|
||||||
|
|
|
@ -379,7 +379,40 @@
|
||||||
:fn (mg/resource "app/migrations/sql/0119-mod-file-table.sql")}
|
:fn (mg/resource "app/migrations/sql/0119-mod-file-table.sql")}
|
||||||
|
|
||||||
{:name "0120-mod-audit-log-table"
|
{:name "0120-mod-audit-log-table"
|
||||||
:fn (mg/resource "app/migrations/sql/0120-mod-audit-log-table.sql")}])
|
:fn (mg/resource "app/migrations/sql/0120-mod-audit-log-table.sql")}
|
||||||
|
|
||||||
|
{:name "0121-mod-file-data-fragment-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0121-mod-file-data-fragment-table.sql")}
|
||||||
|
|
||||||
|
{:name "0122-mod-file-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0122-mod-file-table.sql")}
|
||||||
|
|
||||||
|
{:name "0122-mod-file-data-fragment-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0122-mod-file-data-fragment-table.sql")}
|
||||||
|
|
||||||
|
{:name "0123-mod-file-change-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0123-mod-file-change-table.sql")}
|
||||||
|
|
||||||
|
{:name "0124-mod-profile-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0124-mod-profile-table.sql")}
|
||||||
|
|
||||||
|
{:name "0125-mod-file-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0125-mod-file-table.sql")}
|
||||||
|
|
||||||
|
{:name "0126-add-team-access-request-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0126-add-team-access-request-table.sql")}
|
||||||
|
|
||||||
|
{:name "0127-mod-storage-object-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0127-mod-storage-object-table.sql")}
|
||||||
|
|
||||||
|
{:name "0128-mod-task-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0128-mod-task-table.sql")}
|
||||||
|
|
||||||
|
{:name "0129-mod-file-change-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0129-mod-file-change-table.sql")}
|
||||||
|
|
||||||
|
{:name "0130-mod-file-change-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0130-mod-file-change-table.sql")}])
|
||||||
|
|
||||||
(defn apply-migrations!
|
(defn apply-migrations!
|
||||||
[pool name migrations]
|
[pool name migrations]
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
ALTER TABLE file_data_fragment
|
||||||
|
ADD COLUMN data bytea NULL;
|
||||||
|
|
||||||
|
UPDATE file_data_fragment
|
||||||
|
SET data = content;
|
||||||
|
|
||||||
|
ALTER TABLE file_data_fragment
|
||||||
|
DROP COLUMN content;
|
|
@ -0,0 +1,6 @@
|
||||||
|
ALTER TABLE file_data_fragment
|
||||||
|
ADD COLUMN data_backend text NULL,
|
||||||
|
ADD COLUMN data_ref_id uuid NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS file_data_fragment__data_ref_id__idx
|
||||||
|
ON file_data_fragment (data_ref_id);
|
|
@ -0,0 +1,6 @@
|
||||||
|
ALTER TABLE file_data_fragment
|
||||||
|
ADD COLUMN data_backend text NULL,
|
||||||
|
ADD COLUMN data_ref_id uuid NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS file_data_fragment__data_ref_id__idx
|
||||||
|
ON file_data_fragment (data_ref_id);
|
4
backend/src/app/migrations/sql/0122-mod-file-table.sql
Normal file
4
backend/src/app/migrations/sql/0122-mod-file-table.sql
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER TABLE file ADD COLUMN data_ref_id uuid NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS file__data_ref_id__idx
|
||||||
|
ON file (data_ref_id);
|
|
@ -0,0 +1,2 @@
|
||||||
|
CREATE INDEX IF NOT EXISTS file_change__created_at__label__idx
|
||||||
|
ON file_change (created_at, label);
|
|
@ -0,0 +1,2 @@
|
||||||
|
CREATE INDEX profile__props__newsletter1__idx ON profile (email) WHERE props->>'~:newsletter-news' = 'true';
|
||||||
|
CREATE INDEX profile__props__newsletter2__idx ON profile (email) WHERE props->>'~:newsletter-updates' = 'true';
|
3
backend/src/app/migrations/sql/0125-mod-file-table.sql
Normal file
3
backend/src/app/migrations/sql/0125-mod-file-table.sql
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
--- This setting allow to optimize the table for heavy write workload
|
||||||
|
--- leaving space on the page for HOT updates
|
||||||
|
ALTER TABLE file SET (FILLFACTOR=50);
|
|
@ -0,0 +1,10 @@
|
||||||
|
CREATE TABLE team_access_request (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
team_id uuid NOT NULL REFERENCES team(id) ON DELETE CASCADE DEFERRABLE,
|
||||||
|
requester_id uuid NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE,
|
||||||
|
valid_until timestamptz NOT NULL,
|
||||||
|
auto_join_until timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (team_id, requester_id)
|
||||||
|
);
|
|
@ -0,0 +1,3 @@
|
||||||
|
--- This setting allow to optimize the table for heavy write workload
|
||||||
|
--- leaving space on the page for HOT updates
|
||||||
|
ALTER TABLE storage_object SET (FILLFACTOR=60);
|
3
backend/src/app/migrations/sql/0128-mod-task-table.sql
Normal file
3
backend/src/app/migrations/sql/0128-mod-task-table.sql
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
--- This setting allow to optimize the table for heavy write workload
|
||||||
|
--- leaving space on the page for HOT updates
|
||||||
|
ALTER TABLE task SET (FILLFACTOR=60);
|
|
@ -0,0 +1,6 @@
|
||||||
|
ALTER TABLE file_change
|
||||||
|
ADD COLUMN data_backend text NULL,
|
||||||
|
ADD COLUMN data_ref_id uuid NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS file_change__data_ref_id__idx
|
||||||
|
ON file_change (data_ref_id);
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE file_change
|
||||||
|
ADD COLUMN version integer NULL;
|
|
@ -149,6 +149,13 @@
|
||||||
:hint "authentication required for this endpoint")
|
:hint "authentication required for this endpoint")
|
||||||
(f cfg params)))))
|
(f cfg params)))))
|
||||||
|
|
||||||
|
(defn- wrap-db-transaction
|
||||||
|
[_ f mdata]
|
||||||
|
(if (::db/transaction mdata)
|
||||||
|
(fn [cfg params]
|
||||||
|
(db/tx-run! cfg f params))
|
||||||
|
f))
|
||||||
|
|
||||||
(defn- wrap-audit
|
(defn- wrap-audit
|
||||||
[_ f mdata]
|
[_ f mdata]
|
||||||
(if (or (contains? cf/flags :webhooks)
|
(if (or (contains? cf/flags :webhooks)
|
||||||
|
@ -178,41 +185,25 @@
|
||||||
(if-let [schema (::sm/params mdata)]
|
(if-let [schema (::sm/params mdata)]
|
||||||
(let [validate (sm/validator schema)
|
(let [validate (sm/validator schema)
|
||||||
explain (sm/explainer schema)
|
explain (sm/explainer schema)
|
||||||
decode (sm/decoder schema)]
|
decode (sm/decoder schema sm/json-transformer)
|
||||||
|
encode (sm/encoder schema sm/json-transformer)]
|
||||||
(fn [cfg params]
|
(fn [cfg params]
|
||||||
(let [params (decode params)]
|
(let [params (decode params)]
|
||||||
(if (validate params)
|
(if (validate params)
|
||||||
(f cfg params)
|
(let [result (f cfg params)]
|
||||||
|
(if (instance? clojure.lang.IObj result)
|
||||||
|
(vary-meta result assoc :encode/json encode)
|
||||||
|
result))
|
||||||
(let [params (d/without-qualified params)]
|
(let [params (d/without-qualified params)]
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :params-validation
|
:code :params-validation
|
||||||
::sm/explain (explain params)))))))
|
::sm/explain (explain params)))))))
|
||||||
f))
|
f))
|
||||||
|
|
||||||
(defn- wrap-output-validation
|
|
||||||
[_ f mdata]
|
|
||||||
(if (contains? cf/flags :rpc-output-validation)
|
|
||||||
(or (when-let [schema (::sm/result mdata)]
|
|
||||||
(let [schema (if (sm/lazy-schema? schema)
|
|
||||||
schema
|
|
||||||
(sm/define schema))
|
|
||||||
validate (sm/validator schema)
|
|
||||||
explain (sm/explainer schema)]
|
|
||||||
(fn [cfg params]
|
|
||||||
(let [response (f cfg params)]
|
|
||||||
(when (map? response)
|
|
||||||
(when-not (validate response)
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :data-validation
|
|
||||||
::sm/explain (explain response))))
|
|
||||||
response))))
|
|
||||||
f)
|
|
||||||
f))
|
|
||||||
|
|
||||||
(defn- wrap-all
|
(defn- wrap-all
|
||||||
[cfg f mdata]
|
[cfg f mdata]
|
||||||
(as-> f $
|
(as-> f $
|
||||||
|
(wrap-db-transaction cfg $ mdata)
|
||||||
(cond/wrap cfg $ mdata)
|
(cond/wrap cfg $ mdata)
|
||||||
(retry/wrap-retry cfg $ mdata)
|
(retry/wrap-retry cfg $ mdata)
|
||||||
(climit/wrap cfg $ mdata)
|
(climit/wrap cfg $ mdata)
|
||||||
|
@ -220,7 +211,6 @@
|
||||||
(rlimit/wrap cfg $ mdata)
|
(rlimit/wrap cfg $ mdata)
|
||||||
(wrap-audit cfg $ mdata)
|
(wrap-audit cfg $ mdata)
|
||||||
(wrap-spec-conform cfg $ mdata)
|
(wrap-spec-conform cfg $ mdata)
|
||||||
(wrap-output-validation cfg $ mdata)
|
|
||||||
(wrap-params-validation cfg $ mdata)
|
(wrap-params-validation cfg $ mdata)
|
||||||
(wrap-authentication cfg $ mdata)))
|
(wrap-authentication cfg $ mdata)))
|
||||||
|
|
||||||
|
|
|
@ -30,9 +30,8 @@
|
||||||
:tid token-id
|
:tid token-id
|
||||||
:iat created-at})
|
:iat created-at})
|
||||||
|
|
||||||
expires-at (some-> expiration dt/in-future)]
|
expires-at (some-> expiration dt/in-future)
|
||||||
|
token (db/insert! conn :access-token
|
||||||
(db/insert! conn :access-token
|
|
||||||
{:id token-id
|
{:id token-id
|
||||||
:name name
|
:name name
|
||||||
:token token
|
:token token
|
||||||
|
@ -40,8 +39,8 @@
|
||||||
:created-at created-at
|
:created-at created-at
|
||||||
:updated-at created-at
|
:updated-at created-at
|
||||||
:expires-at expires-at
|
:expires-at expires-at
|
||||||
:perms (db/create-array conn "text" [])})))
|
:perms (db/create-array conn "text" [])})]
|
||||||
|
(decode-row token)))
|
||||||
|
|
||||||
(defn repl:create-access-token
|
(defn repl:create-access-token
|
||||||
[{:keys [::db/pool] :as system} profile-id name expiration]
|
[{:keys [::db/pool] :as system} profile-id name expiration]
|
||||||
|
@ -60,14 +59,12 @@
|
||||||
(sv/defmethod ::create-access-token
|
(sv/defmethod ::create-access-token
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
::sm/params schema:create-access-token}
|
::sm/params schema:create-access-token}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id name expiration]}]
|
[cfg {:keys [::rpc/profile-id name expiration]}]
|
||||||
(db/with-atomic [conn pool]
|
|
||||||
(let [cfg (assoc cfg ::db/conn conn)]
|
(quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
|
||||||
(quotes/check-quote! conn
|
|
||||||
{::quotes/id ::quotes/access-tokens-per-profile
|
|
||||||
::quotes/profile-id profile-id})
|
::quotes/profile-id profile-id})
|
||||||
(-> (create-access-token cfg profile-id name expiration)
|
|
||||||
(decode-row)))))
|
(db/tx-run! cfg create-access-token profile-id name expiration))
|
||||||
|
|
||||||
(def ^:private schema:delete-access-token
|
(def ^:private schema:delete-access-token
|
||||||
[:map {:title "delete-access-token"}
|
[:map {:title "delete-access-token"}
|
||||||
|
|
|
@ -27,9 +27,11 @@
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
[app.rpc.helpers :as rph]
|
[app.rpc.helpers :as rph]
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
|
[app.setup.welcome-file :refer [create-welcome-file]]
|
||||||
[app.tokens :as tokens]
|
[app.tokens :as tokens]
|
||||||
[app.util.services :as sv]
|
[app.util.services :as sv]
|
||||||
[app.util.time :as dt]
|
[app.util.time :as dt]
|
||||||
|
[app.worker :as wrk]
|
||||||
[cuerdas.core :as str]))
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
(def schema:password
|
(def schema:password
|
||||||
|
@ -180,10 +182,11 @@
|
||||||
(defn- validate-register-attempt!
|
(defn- validate-register-attempt!
|
||||||
[cfg params]
|
[cfg params]
|
||||||
|
|
||||||
(when-not (contains? cf/flags :registration)
|
(when (or
|
||||||
(when-not (contains? params :invitation-token)
|
(not (contains? cf/flags :registration))
|
||||||
|
(not (contains? cf/flags :login-with-password)))
|
||||||
(ex/raise :type :restriction
|
(ex/raise :type :restriction
|
||||||
:code :registration-disabled)))
|
:code :registration-disabled))
|
||||||
|
|
||||||
(when (contains? params :invitation-token)
|
(when (contains? params :invitation-token)
|
||||||
(let [invitation (tokens/verify (::setup/props cfg)
|
(let [invitation (tokens/verify (::setup/props cfg)
|
||||||
|
@ -240,6 +243,7 @@
|
||||||
|
|
||||||
params (d/without-nils params)
|
params (d/without-nils params)
|
||||||
token (tokens/generate (::setup/props cfg) params)]
|
token (tokens/generate (::setup/props cfg) params)]
|
||||||
|
|
||||||
(with-meta {:token token}
|
(with-meta {:token token}
|
||||||
{::audit/profile-id uuid/zero})))
|
{::audit/profile-id uuid/zero})))
|
||||||
|
|
||||||
|
@ -282,6 +286,7 @@
|
||||||
is-demo (:is-demo params false)
|
is-demo (:is-demo params false)
|
||||||
is-muted (:is-muted params false)
|
is-muted (:is-muted params false)
|
||||||
is-active (:is-active params false)
|
is-active (:is-active params false)
|
||||||
|
theme (:theme params nil)
|
||||||
email (str/lower email)
|
email (str/lower email)
|
||||||
|
|
||||||
params {:id id
|
params {:id id
|
||||||
|
@ -292,6 +297,7 @@
|
||||||
:password password
|
:password password
|
||||||
:deleted-at (:deleted-at params)
|
:deleted-at (:deleted-at params)
|
||||||
:props props
|
:props props
|
||||||
|
:theme theme
|
||||||
:is-active is-active
|
:is-active is-active
|
||||||
:is-muted is-muted
|
:is-muted is-muted
|
||||||
:is-demo is-demo}]
|
:is-demo is-demo}]
|
||||||
|
@ -347,30 +353,43 @@
|
||||||
:extra-data ptoken})))
|
:extra-data ptoken})))
|
||||||
|
|
||||||
(defn register-profile
|
(defn register-profile
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [token fullname] :as params}]
|
[{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token fullname theme] :as params}]
|
||||||
(let [claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register})
|
(let [theme (when (= theme "light") theme)
|
||||||
|
claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register})
|
||||||
params (-> claims
|
params (-> claims
|
||||||
(into params)
|
(into params)
|
||||||
(assoc :fullname fullname))
|
(assoc :fullname fullname)
|
||||||
|
(assoc :theme theme))
|
||||||
|
|
||||||
profile (if-let [profile-id (:profile-id claims)]
|
profile (if-let [profile-id (:profile-id claims)]
|
||||||
(profile/get-profile conn profile-id)
|
(profile/get-profile conn profile-id)
|
||||||
|
;; NOTE: we first try to match existing profile
|
||||||
|
;; by email, that in normal circumstances will
|
||||||
|
;; not return anything, but when a user tries to
|
||||||
|
;; reuse the same token multiple times, we need
|
||||||
|
;; to detect if the profile is already registered
|
||||||
|
(or (profile/get-profile-by-email conn (:email claims))
|
||||||
(let [is-active (or (boolean (:is-active claims))
|
(let [is-active (or (boolean (:is-active claims))
|
||||||
(not (contains? cf/flags :email-verification)))
|
(not (contains? cf/flags :email-verification)))
|
||||||
params (-> params
|
params (-> params
|
||||||
(assoc :is-active is-active)
|
(assoc :is-active is-active)
|
||||||
(update :password #(profile/derive-password cfg %)))]
|
(update :password #(profile/derive-password cfg %)))
|
||||||
(->> (create-profile! conn params)
|
profile (->> (create-profile! conn params)
|
||||||
(create-profile-rels! conn))))
|
(create-profile-rels! conn))]
|
||||||
|
(vary-meta profile assoc :created true))))
|
||||||
|
|
||||||
;; When no profile-id comes on claims means a new register
|
created? (-> profile meta :created true?)
|
||||||
created? (not (:profile-id claims))
|
|
||||||
|
|
||||||
invitation (when-let [token (:invitation-token params)]
|
invitation (when-let [token (:invitation-token params)]
|
||||||
(tokens/verify (::setup/props cfg) {:token token :iss :team-invitation}))
|
(tokens/verify (::setup/props cfg) {:token token :iss :team-invitation}))
|
||||||
|
|
||||||
props (audit/profile->props profile)]
|
props (audit/profile->props profile)
|
||||||
|
|
||||||
|
create-welcome-file-when-needed
|
||||||
|
(fn []
|
||||||
|
(when (:create-welcome-file params)
|
||||||
|
(let [cfg (dissoc cfg ::db/conn)]
|
||||||
|
(wrk/submit! executor (create-welcome-file cfg profile)))))]
|
||||||
(cond
|
(cond
|
||||||
;; When profile is blocked, we just ignore it and return plain data
|
;; When profile is blocked, we just ignore it and return plain data
|
||||||
(:is-blocked profile)
|
(:is-blocked profile)
|
||||||
|
@ -407,6 +426,7 @@
|
||||||
(if (:is-active profile)
|
(if (:is-active profile)
|
||||||
(-> (profile/strip-private-attrs profile)
|
(-> (profile/strip-private-attrs profile)
|
||||||
(rph/with-transform (session/create-fn cfg (:id profile)))
|
(rph/with-transform (session/create-fn cfg (:id profile)))
|
||||||
|
(rph/with-defer create-welcome-file-when-needed)
|
||||||
(rph/with-meta
|
(rph/with-meta
|
||||||
{::audit/replace-props props
|
{::audit/replace-props props
|
||||||
::audit/context {:action "login"}
|
::audit/context {:action "login"}
|
||||||
|
@ -416,15 +436,17 @@
|
||||||
(when-not (eml/has-reports? conn (:email profile))
|
(when-not (eml/has-reports? conn (:email profile))
|
||||||
(send-email-verification! cfg profile))
|
(send-email-verification! cfg profile))
|
||||||
|
|
||||||
(rph/with-meta {:email (:email profile)}
|
(-> {:email (:email profile)}
|
||||||
|
(rph/with-defer create-welcome-file-when-needed)
|
||||||
|
(rph/with-meta
|
||||||
{::audit/replace-props props
|
{::audit/replace-props props
|
||||||
::audit/context {:action "email-verification"}
|
::audit/context {:action "email-verification"}
|
||||||
::audit/profile-id (:id profile)})))
|
::audit/profile-id (:id profile)}))))
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(let [elapsed? (elapsed-verify-threshold? profile)
|
(let [elapsed? (elapsed-verify-threshold? profile)
|
||||||
complaints? (eml/has-reports? conn (:email profile))
|
reports? (eml/has-reports? conn (:email profile))
|
||||||
action (if complaints?
|
action (if reports?
|
||||||
"ignore-because-complaints"
|
"ignore-because-complaints"
|
||||||
(if elapsed?
|
(if elapsed?
|
||||||
"resend-email-verification"
|
"resend-email-verification"
|
||||||
|
@ -450,7 +472,9 @@
|
||||||
(def schema:register-profile
|
(def schema:register-profile
|
||||||
[:map {:title "register-profile"}
|
[:map {:title "register-profile"}
|
||||||
[:token schema:token]
|
[:token schema:token]
|
||||||
[:fullname [::sm/word-string {:max 100}]]])
|
[:fullname [::sm/word-string {:max 100}]]
|
||||||
|
[:theme {:optional true} [:string {:max 10}]]
|
||||||
|
[:create-welcome-file {:optional true} :boolean]])
|
||||||
|
|
||||||
(sv/defmethod ::register-profile
|
(sv/defmethod ::register-profile
|
||||||
{::rpc/auth false
|
{::rpc/auth false
|
||||||
|
@ -522,7 +546,6 @@
|
||||||
(create-recovery-token)
|
(create-recovery-token)
|
||||||
(send-email-notification conn)))))))
|
(send-email-notification conn)))))))
|
||||||
|
|
||||||
|
|
||||||
(def schema:request-profile-recovery
|
(def schema:request-profile-recovery
|
||||||
[:map {:title "request-profile-recovery"}
|
[:map {:title "request-profile-recovery"}
|
||||||
[:email ::sm/email]])
|
[:email ::sm/email]])
|
||||||
|
|
|
@ -71,10 +71,15 @@
|
||||||
[conn comment-id & {:as opts}]
|
[conn comment-id & {:as opts}]
|
||||||
(db/get-by-id conn :comment comment-id opts))
|
(db/get-by-id conn :comment comment-id opts))
|
||||||
|
|
||||||
|
(def ^:private sql:get-next-seqn
|
||||||
|
"SELECT (f.comment_thread_seqn + 1) AS next_seqn
|
||||||
|
FROM file AS f
|
||||||
|
WHERE f.id = ?
|
||||||
|
FOR UPDATE")
|
||||||
|
|
||||||
(defn- get-next-seqn
|
(defn- get-next-seqn
|
||||||
[conn file-id]
|
[conn file-id]
|
||||||
(let [sql "select (f.comment_thread_seqn + 1) as next_seqn from file as f where f.id = ?"
|
(let [res (db/exec-one! conn [sql:get-next-seqn file-id])]
|
||||||
res (db/exec-one! conn [sql file-id])]
|
|
||||||
(:next-seqn res)))
|
(:next-seqn res)))
|
||||||
|
|
||||||
(def sql:upsert-comment-thread-status
|
(def sql:upsert-comment-thread-status
|
||||||
|
@ -292,7 +297,7 @@
|
||||||
[:map {:title "create-comment-thread"}
|
[:map {:title "create-comment-thread"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:position ::gpt/point]
|
[:position ::gpt/point]
|
||||||
[:content [:string {:max 250}]]
|
[:content [:string {:max 750}]]
|
||||||
[:page-id ::sm/uuid]
|
[:page-id ::sm/uuid]
|
||||||
[:frame-id ::sm/uuid]
|
[:frame-id ::sm/uuid]
|
||||||
[:share-id {:optional true} [:maybe ::sm/uuid]]])
|
[:share-id {:optional true} [:maybe ::sm/uuid]]])
|
||||||
|
@ -304,38 +309,43 @@
|
||||||
::rtry/when rtry/conflict-exception?
|
::rtry/when rtry/conflict-exception?
|
||||||
::sm/params schema:create-comment-thread}
|
::sm/params schema:create-comment-thread}
|
||||||
[cfg {:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id position content frame-id]}]
|
[cfg {:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id position content frame-id]}]
|
||||||
|
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
|
||||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||||
(let [{:keys [team-id project-id page-name]} (get-file conn file-id page-id)]
|
|
||||||
|
|
||||||
(run! (partial quotes/check-quote! cfg)
|
(let [{:keys [team-id project-id page-name]} (get-file cfg file-id page-id)]
|
||||||
(list {::quotes/id ::quotes/comment-threads-per-file
|
|
||||||
::quotes/profile-id profile-id
|
|
||||||
::quotes/team-id team-id
|
|
||||||
::quotes/project-id project-id
|
|
||||||
::quotes/file-id file-id}
|
|
||||||
{::quotes/id ::quotes/comments-per-file
|
|
||||||
::quotes/profile-id profile-id
|
|
||||||
::quotes/team-id team-id
|
|
||||||
::quotes/project-id project-id
|
|
||||||
::quotes/file-id file-id}))
|
|
||||||
|
|
||||||
(create-comment-thread conn {:created-at request-at
|
(-> cfg
|
||||||
|
(assoc ::quotes/profile-id profile-id)
|
||||||
|
(assoc ::quotes/team-id team-id)
|
||||||
|
(assoc ::quotes/project-id project-id)
|
||||||
|
(assoc ::quotes/file-id file-id)
|
||||||
|
(quotes/check! {::quotes/id ::quotes/comment-threads-per-file}
|
||||||
|
{::quotes/id ::quotes/comments-per-file}))
|
||||||
|
|
||||||
|
(let [params {:created-at request-at
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:file-id file-id
|
:file-id file-id
|
||||||
:page-id page-id
|
:page-id page-id
|
||||||
:page-name page-name
|
:page-name page-name
|
||||||
:position position
|
:position position
|
||||||
:content content
|
:content content
|
||||||
:frame-id frame-id})))))
|
:frame-id frame-id}
|
||||||
|
thread (db/tx-run! cfg create-comment-thread params)]
|
||||||
|
|
||||||
|
(vary-meta thread assoc ::audit/props thread))))
|
||||||
|
|
||||||
(defn- create-comment-thread
|
(defn- create-comment-thread
|
||||||
[conn {:keys [profile-id file-id page-id page-name created-at position content frame-id]}]
|
[{:keys [::db/conn] :as cfg}
|
||||||
|
{:keys [profile-id file-id page-id page-name created-at position content frame-id]}]
|
||||||
|
|
||||||
|
(let [;; NOTE: we take the next seq number from a separate query
|
||||||
|
;; because we need to lock the file for avoid race conditions
|
||||||
|
|
||||||
|
;; FIXME: this method touches and locks the file table,which
|
||||||
|
;; is already heavy-update tablel; we need to think on move
|
||||||
|
;; the sequence state management to a different table or
|
||||||
|
;; different storage (example: redis) for alivate the update
|
||||||
|
;; pression on the file table
|
||||||
|
|
||||||
(let [;; NOTE: we take the next seq number from a separate query because the whole
|
|
||||||
;; operation can be retried on conflict, and in this case the new seq shold be
|
|
||||||
;; retrieved from the database.
|
|
||||||
seqn (get-next-seqn conn file-id)
|
seqn (get-next-seqn conn file-id)
|
||||||
thread-id (uuid/next)
|
thread-id (uuid/next)
|
||||||
thread (db/insert! conn :comment-thread
|
thread (db/insert! conn :comment-thread
|
||||||
|
@ -364,7 +374,8 @@
|
||||||
;; Optimistic update of current seq number on file.
|
;; Optimistic update of current seq number on file.
|
||||||
(db/update! conn :file
|
(db/update! conn :file
|
||||||
{:comment-thread-seqn seqn}
|
{:comment-thread-seqn seqn}
|
||||||
{:id file-id})
|
{:id file-id}
|
||||||
|
{::db/return-keys false})
|
||||||
|
|
||||||
(-> thread
|
(-> thread
|
||||||
(select-keys [:id :file-id :page-id])
|
(select-keys [:id :file-id :page-id])
|
||||||
|
@ -387,7 +398,6 @@
|
||||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||||
(upsert-comment-thread-status! conn profile-id id)))))
|
(upsert-comment-thread-status! conn profile-id id)))))
|
||||||
|
|
||||||
|
|
||||||
;; --- COMMAND: Update Comment Thread
|
;; --- COMMAND: Update Comment Thread
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
|
@ -432,8 +442,7 @@
|
||||||
{:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
|
{:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
|
||||||
|
|
||||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||||
(quotes/check-quote! conn
|
(quotes/check! cfg {::quotes/id ::quotes/comments-per-file
|
||||||
{::quotes/id ::quotes/comments-per-file
|
|
||||||
::quotes/profile-id profile-id
|
::quotes/profile-id profile-id
|
||||||
::quotes/team-id team-id
|
::quotes/team-id team-id
|
||||||
::quotes/project-id project-id
|
::quotes/project-id project-id
|
||||||
|
|
|
@ -21,8 +21,8 @@
|
||||||
|
|
||||||
(def ^:private schema:send-user-feedback
|
(def ^:private schema:send-user-feedback
|
||||||
[:map {:title "send-user-feedback"}
|
[:map {:title "send-user-feedback"}
|
||||||
[:subject [:string {:max 250}]]
|
[:subject [:string {:max 400}]]
|
||||||
[:content [:string {:max 250}]]])
|
[:content [:string {:max 2500}]]])
|
||||||
|
|
||||||
(sv/defmethod ::send-user-feedback
|
(sv/defmethod ::send-user-feedback
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
[app.common.schema.desc-js-like :as-alias smdj]
|
[app.common.schema.desc-js-like :as-alias smdj]
|
||||||
[app.common.types.components-list :as ctkl]
|
[app.common.types.components-list :as ctkl]
|
||||||
[app.common.types.file :as ctf]
|
[app.common.types.file :as ctf]
|
||||||
|
[app.common.uri :as uri]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.db.sql :as-alias sql]
|
[app.db.sql :as-alias sql]
|
||||||
|
@ -68,6 +69,9 @@
|
||||||
:max-version fmg/version))
|
:max-version fmg/version))
|
||||||
file))
|
file))
|
||||||
|
|
||||||
|
|
||||||
|
;; --- FILE DATA
|
||||||
|
|
||||||
;; --- FILE PERMISSIONS
|
;; --- FILE PERMISSIONS
|
||||||
|
|
||||||
(def ^:private sql:file-permissions
|
(def ^:private sql:file-permissions
|
||||||
|
@ -171,38 +175,34 @@
|
||||||
;; --- COMMAND QUERY: get-file (by id)
|
;; --- COMMAND QUERY: get-file (by id)
|
||||||
|
|
||||||
(def schema:file
|
(def schema:file
|
||||||
(sm/define
|
|
||||||
[:map {:title "File"}
|
[:map {:title "File"}
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:features ::cfeat/features]
|
[:features ::cfeat/features]
|
||||||
[:has-media-trimmed :boolean]
|
[:has-media-trimmed ::sm/boolean]
|
||||||
[:comment-thread-seqn {:min 0} :int]
|
[:comment-thread-seqn [::sm/int {:min 0}]]
|
||||||
[:name [:string {:max 250}]]
|
[:name [:string {:max 250}]]
|
||||||
[:revn {:min 0} :int]
|
[:revn [::sm/int {:min 0}]]
|
||||||
[:modified-at ::dt/instant]
|
[:modified-at ::dt/instant]
|
||||||
[:is-shared :boolean]
|
[:is-shared ::sm/boolean]
|
||||||
[:project-id ::sm/uuid]
|
[:project-id ::sm/uuid]
|
||||||
[:created-at ::dt/instant]
|
[:created-at ::dt/instant]
|
||||||
[:data {:optional true} :any]]))
|
[:data {:optional true} :any]])
|
||||||
|
|
||||||
(def schema:permissions-mixin
|
(def schema:permissions-mixin
|
||||||
(sm/define
|
|
||||||
[:map {:title "PermissionsMixin"}
|
[:map {:title "PermissionsMixin"}
|
||||||
[:permissions ::perms/permissions]]))
|
[:permissions ::perms/permissions]])
|
||||||
|
|
||||||
(def schema:file-with-permissions
|
(def schema:file-with-permissions
|
||||||
(sm/define
|
|
||||||
[:merge {:title "FileWithPermissions"}
|
[:merge {:title "FileWithPermissions"}
|
||||||
schema:file
|
schema:file
|
||||||
schema:permissions-mixin]))
|
schema:permissions-mixin])
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:get-file
|
schema:get-file
|
||||||
(sm/define
|
|
||||||
[:map {:title "get-file"}
|
[:map {:title "get-file"}
|
||||||
[:features {:optional true} ::cfeat/features]
|
[:features {:optional true} ::cfeat/features]
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:project-id {:optional true} ::sm/uuid]]))
|
[:project-id {:optional true} ::sm/uuid]])
|
||||||
|
|
||||||
(defn- migrate-file
|
(defn- migrate-file
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [id] :as file}]
|
[{:keys [::db/conn] :as cfg} {:keys [id] :as file}]
|
||||||
|
@ -258,10 +258,11 @@
|
||||||
(let [params (merge {:id id}
|
(let [params (merge {:id id}
|
||||||
(when (some? project-id)
|
(when (some? project-id)
|
||||||
{:project-id project-id}))
|
{:project-id project-id}))
|
||||||
file (-> (db/get conn :file params
|
file (->> (db/get conn :file params
|
||||||
{::db/check-deleted (not include-deleted?)
|
{::db/check-deleted (not include-deleted?)
|
||||||
::db/remove-deleted (not include-deleted?)
|
::db/remove-deleted (not include-deleted?)
|
||||||
::sql/for-update lock-for-update?})
|
::sql/for-update lock-for-update?})
|
||||||
|
(feat.fdata/resolve-file-data cfg)
|
||||||
(decode-row))]
|
(decode-row))]
|
||||||
(if (and migrate? (fmg/need-migration? file))
|
(if (and migrate? (fmg/need-migration? file))
|
||||||
(migrate-file cfg file)
|
(migrate-file cfg file)
|
||||||
|
@ -269,24 +270,41 @@
|
||||||
|
|
||||||
(defn get-minimal-file
|
(defn get-minimal-file
|
||||||
[cfg id & {:as opts}]
|
[cfg id & {:as opts}]
|
||||||
(let [opts (assoc opts ::sql/columns [:id :modified-at :revn])]
|
(let [opts (assoc opts ::sql/columns [:id :modified-at :deleted-at :revn :data-ref-id :data-backend])]
|
||||||
(db/get cfg :file {:id id} opts)))
|
(db/get cfg :file {:id id} opts)))
|
||||||
|
|
||||||
|
(defn- get-minimal-file-with-perms
|
||||||
|
[cfg {:keys [:id ::rpc/profile-id]}]
|
||||||
|
(let [mfile (get-minimal-file cfg id)
|
||||||
|
perms (get-permissions cfg profile-id id)]
|
||||||
|
(assoc mfile :permissions perms)))
|
||||||
|
|
||||||
(defn get-file-etag
|
(defn get-file-etag
|
||||||
[{:keys [::rpc/profile-id]} {:keys [modified-at revn]}]
|
[{:keys [::rpc/profile-id]} {:keys [modified-at revn permissions]}]
|
||||||
(str profile-id (dt/format-instant modified-at :iso) revn))
|
(str profile-id "/" revn "/"
|
||||||
|
(dt/format-instant modified-at :iso)
|
||||||
|
"/"
|
||||||
|
(uri/map->query-string permissions)))
|
||||||
|
|
||||||
(sv/defmethod ::get-file
|
(sv/defmethod ::get-file
|
||||||
"Retrieve a file by its ID. Only authenticated users."
|
"Retrieve a file by its ID. Only authenticated users."
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::cond/get-object #(get-minimal-file %1 (:id %2))
|
::cond/get-object #(get-minimal-file-with-perms %1 %2)
|
||||||
::cond/key-fn get-file-etag
|
::cond/key-fn get-file-etag
|
||||||
::sm/params schema:get-file
|
::sm/params schema:get-file
|
||||||
::sm/result schema:file-with-permissions}
|
::sm/result schema:file-with-permissions
|
||||||
[cfg {:keys [::rpc/profile-id id project-id] :as params}]
|
::db/transaction true}
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id project-id] :as params}]
|
||||||
(let [perms (get-permissions conn profile-id id)]
|
;; The COND middleware makes initial request for a file and
|
||||||
|
;; permissions when the incoming request comes with an
|
||||||
|
;; ETAG. When ETAG does not matches, the request is resolved
|
||||||
|
;; and this code is executed, in this case the permissions
|
||||||
|
;; will be already prefetched and we just reuse them instead
|
||||||
|
;; of making an additional database queries.
|
||||||
|
(let [perms (or (:permissions (::cond/object params))
|
||||||
|
(get-permissions conn profile-id id))]
|
||||||
(check-read-permissions! perms)
|
(check-read-permissions! perms)
|
||||||
|
|
||||||
(let [team (teams/get-team conn
|
(let [team (teams/get-team conn
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:project-id project-id
|
:project-id project-id
|
||||||
|
@ -294,22 +312,20 @@
|
||||||
|
|
||||||
file (-> (get-file cfg id :project-id project-id)
|
file (-> (get-file cfg id :project-id project-id)
|
||||||
(assoc :permissions perms)
|
(assoc :permissions perms)
|
||||||
(check-version!))
|
(check-version!))]
|
||||||
|
|
||||||
_ (-> (cfeat/get-team-enabled-features cf/flags team)
|
(-> (cfeat/get-team-enabled-features cf/flags team)
|
||||||
(cfeat/check-client-features! (:features params))
|
(cfeat/check-client-features! (:features params))
|
||||||
(cfeat/check-file-features! (:features file) (:features params)))
|
(cfeat/check-file-features! (:features file) (:features params)))
|
||||||
|
|
||||||
;; This operation is needed for backward comapatibility with frontends that
|
;; This operation is needed for backward comapatibility with frontends that
|
||||||
;; does not support pointer-map resolution mechanism; this just resolves the
|
;; does not support pointer-map resolution mechanism; this just resolves the
|
||||||
;; pointers on backend and return a complete file.
|
;; pointers on backend and return a complete file.
|
||||||
file (if (and (contains? (:features file) "fdata/pointer-map")
|
(if (and (contains? (:features file) "fdata/pointer-map")
|
||||||
(not (contains? (:features params) "fdata/pointer-map")))
|
(not (contains? (:features params) "fdata/pointer-map")))
|
||||||
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
|
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
|
||||||
(update file :data feat.fdata/process-pointers deref))
|
(update file :data feat.fdata/process-pointers deref))
|
||||||
file)]
|
file))))
|
||||||
|
|
||||||
(vary-meta file assoc ::cond/key (get-file-etag params file)))))))
|
|
||||||
|
|
||||||
;; --- COMMAND QUERY: get-file-fragment (by id)
|
;; --- COMMAND QUERY: get-file-fragment (by id)
|
||||||
|
|
||||||
|
@ -327,9 +343,11 @@
|
||||||
[:share-id {:optional true} ::sm/uuid]])
|
[:share-id {:optional true} ::sm/uuid]])
|
||||||
|
|
||||||
(defn- get-file-fragment
|
(defn- get-file-fragment
|
||||||
[conn file-id fragment-id]
|
[cfg file-id fragment-id]
|
||||||
(some-> (db/get conn :file-data-fragment {:file-id file-id :id fragment-id})
|
(let [resolve-file-data (partial feat.fdata/resolve-file-data cfg)]
|
||||||
(update :content blob/decode)))
|
(some-> (db/get cfg :file-data-fragment {:file-id file-id :id fragment-id})
|
||||||
|
(resolve-file-data)
|
||||||
|
(update :data blob/decode))))
|
||||||
|
|
||||||
(sv/defmethod ::get-file-fragment
|
(sv/defmethod ::get-file-fragment
|
||||||
"Retrieve a file fragment by its ID. Only authenticated users."
|
"Retrieve a file fragment by its ID. Only authenticated users."
|
||||||
|
@ -337,12 +355,12 @@
|
||||||
::rpc/auth false
|
::rpc/auth false
|
||||||
::sm/params schema:get-file-fragment
|
::sm/params schema:get-file-fragment
|
||||||
::sm/result schema:file-fragment}
|
::sm/result schema:file-fragment}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id fragment-id share-id]}]
|
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
|
||||||
(dm/with-open [conn (db/open pool)]
|
(db/run! cfg (fn [cfg]
|
||||||
(let [perms (get-permissions conn profile-id file-id share-id)]
|
(let [perms (get-permissions cfg profile-id file-id share-id)]
|
||||||
(check-read-permissions! perms)
|
(check-read-permissions! perms)
|
||||||
(-> (get-file-fragment conn file-id fragment-id)
|
(-> (get-file-fragment cfg file-id fragment-id)
|
||||||
(rph/with-http-cache long-cache-duration)))))
|
(rph/with-http-cache long-cache-duration))))))
|
||||||
|
|
||||||
;; --- COMMAND QUERY: get-project-files
|
;; --- COMMAND QUERY: get-project-files
|
||||||
|
|
||||||
|
@ -402,7 +420,7 @@
|
||||||
"Checks if the file has libraries. Returns a boolean"
|
"Checks if the file has libraries. Returns a boolean"
|
||||||
{::doc/added "1.15.1"
|
{::doc/added "1.15.1"
|
||||||
::sm/params schema:has-file-libraries
|
::sm/params schema:has-file-libraries
|
||||||
::sm/result :boolean}
|
::sm/result ::sm/boolean}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
|
||||||
(dm/with-open [conn (db/open pool)]
|
(dm/with-open [conn (db/open pool)]
|
||||||
(check-read-permissions! pool profile-id file-id)
|
(check-read-permissions! pool profile-id file-id)
|
||||||
|
@ -481,7 +499,7 @@
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:page-id {:optional true} ::sm/uuid]
|
[:page-id {:optional true} ::sm/uuid]
|
||||||
[:share-id {:optional true} ::sm/uuid]
|
[:share-id {:optional true} ::sm/uuid]
|
||||||
[:object-id {:optional true} [:or ::sm/uuid ::sm/coll-of-uuid]]
|
[:object-id {:optional true} [:or ::sm/uuid [::sm/set ::sm/uuid]]]
|
||||||
[:features {:optional true} ::cfeat/features]])
|
[:features {:optional true} ::cfeat/features]])
|
||||||
|
|
||||||
(sv/defmethod ::get-page
|
(sv/defmethod ::get-page
|
||||||
|
@ -723,6 +741,23 @@
|
||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
(db/tx-run! cfg get-file-summary (assoc params :profile-id profile-id)))
|
(db/tx-run! cfg get-file-summary (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
|
|
||||||
|
;; --- COMMAND QUERY: get-file-info
|
||||||
|
|
||||||
|
(defn- get-file-info
|
||||||
|
[{:keys [::db/conn] :as cfg} {:keys [id] :as params}]
|
||||||
|
(db/get* conn :file
|
||||||
|
{:id id}
|
||||||
|
{::sql/columns [:id]}))
|
||||||
|
|
||||||
|
(sv/defmethod ::get-file-info
|
||||||
|
"Retrieve minimal file info by its ID."
|
||||||
|
{::rpc/auth false
|
||||||
|
::doc/added "2.2.0"
|
||||||
|
::sm/params schema:get-file}
|
||||||
|
[cfg params]
|
||||||
|
(db/tx-run! cfg get-file-info params))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; MUTATION COMMANDS
|
;; MUTATION COMMANDS
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
@ -802,7 +837,8 @@
|
||||||
(db/update! cfg :file
|
(db/update! cfg :file
|
||||||
{:revn (inc (:revn file))
|
{:revn (inc (:revn file))
|
||||||
:data (blob/encode (:data file))
|
:data (blob/encode (:data file))
|
||||||
:modified-at (dt/now)}
|
:modified-at (dt/now)
|
||||||
|
:has-media-trimmed false}
|
||||||
{:id file-id})
|
{:id file-id})
|
||||||
|
|
||||||
(feat.fdata/persist-pointers! cfg file-id))))
|
(feat.fdata/persist-pointers! cfg file-id))))
|
||||||
|
@ -890,10 +926,9 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:set-file-shared
|
schema:set-file-shared
|
||||||
(sm/define
|
|
||||||
[:map {:title "set-file-shared"}
|
[:map {:title "set-file-shared"}
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:is-shared :boolean]]))
|
[:is-shared ::sm/boolean]])
|
||||||
|
|
||||||
(sv/defmethod ::set-file-shared
|
(sv/defmethod ::set-file-shared
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
|
@ -920,9 +955,8 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:delete-file
|
schema:delete-file
|
||||||
(sm/define
|
|
||||||
[:map {:title "delete-file"}
|
[:map {:title "delete-file"}
|
||||||
[:id ::sm/uuid]]))
|
[:id ::sm/uuid]])
|
||||||
|
|
||||||
(defn- delete-file
|
(defn- delete-file
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}]
|
||||||
|
@ -954,10 +988,9 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:link-file-to-library
|
schema:link-file-to-library
|
||||||
(sm/define
|
|
||||||
[:map {:title "link-file-to-library"}
|
[:map {:title "link-file-to-library"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:library-id ::sm/uuid]]))
|
[:library-id ::sm/uuid]])
|
||||||
|
|
||||||
(sv/defmethod ::link-file-to-library
|
(sv/defmethod ::link-file-to-library
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
|
@ -1034,7 +1067,7 @@
|
||||||
(def ^:private schema:ignore-file-library-sync-status
|
(def ^:private schema:ignore-file-library-sync-status
|
||||||
[:map {:title "ignore-file-library-sync-status"}
|
[:map {:title "ignore-file-library-sync-status"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:date ::dt/duration]])
|
[:date ::dt/instant]])
|
||||||
|
|
||||||
;; TODO: improve naming
|
;; TODO: improve naming
|
||||||
(sv/defmethod ::ignore-file-library-sync-status
|
(sv/defmethod ::ignore-file-library-sync-status
|
||||||
|
|
|
@ -91,17 +91,16 @@
|
||||||
[:name [:string {:max 250}]]
|
[:name [:string {:max 250}]]
|
||||||
[:project-id ::sm/uuid]
|
[:project-id ::sm/uuid]
|
||||||
[:id {:optional true} ::sm/uuid]
|
[:id {:optional true} ::sm/uuid]
|
||||||
[:is-shared {:optional true} :boolean]
|
[:is-shared {:optional true} ::sm/boolean]
|
||||||
[:features {:optional true} ::cfeat/features]])
|
[:features {:optional true} ::cfeat/features]])
|
||||||
|
|
||||||
(sv/defmethod ::create-file
|
(sv/defmethod ::create-file
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::doc/module :files
|
::doc/module :files
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sm/params schema:create-file}
|
::sm/params schema:create-file
|
||||||
[cfg {:keys [::rpc/profile-id project-id] :as params}]
|
::db/transaction true}
|
||||||
(db/tx-run! cfg
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
|
||||||
(fn [{:keys [::db/conn] :as cfg}]
|
|
||||||
(projects/check-edition-permissions! conn profile-id project-id)
|
(projects/check-edition-permissions! conn profile-id project-id)
|
||||||
(let [team (teams/get-team conn
|
(let [team (teams/get-team conn
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
|
@ -125,11 +124,15 @@
|
||||||
(assoc :profile-id profile-id)
|
(assoc :profile-id profile-id)
|
||||||
(assoc :features features))]
|
(assoc :features features))]
|
||||||
|
|
||||||
(run! (partial quotes/check-quote! conn)
|
(quotes/check! cfg {::quotes/id ::quotes/files-per-project
|
||||||
(list {::quotes/id ::quotes/files-per-project
|
|
||||||
::quotes/team-id team-id
|
::quotes/team-id team-id
|
||||||
::quotes/profile-id profile-id
|
::quotes/profile-id profile-id
|
||||||
::quotes/project-id project-id}))
|
::quotes/project-id project-id})
|
||||||
|
|
||||||
|
;; FIXME: IMPORTANT: this code can have race
|
||||||
|
;; conditions, because we have no locks for updating
|
||||||
|
;; team so, creating two files concurrently can lead
|
||||||
|
;; to lost team features updating
|
||||||
|
|
||||||
;; When newly computed features does not match exactly with
|
;; When newly computed features does not match exactly with
|
||||||
;; the features defined on team row, we update it.
|
;; the features defined on team row, we update it.
|
||||||
|
@ -140,4 +143,4 @@
|
||||||
{:id team-id})))
|
{:id team-id})))
|
||||||
|
|
||||||
(-> (create-file cfg params)
|
(-> (create-file cfg params)
|
||||||
(vary-meta assoc ::audit/props {:team-id team-id}))))))
|
(vary-meta assoc ::audit/props {:team-id team-id}))))
|
||||||
|
|
|
@ -13,13 +13,15 @@
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.db.sql :as-alias sql]
|
[app.db.sql :as-alias sql]
|
||||||
|
[app.features.fdata :as feat.fdata]
|
||||||
[app.main :as-alias main]
|
[app.main :as-alias main]
|
||||||
[app.media :as media]
|
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
|
[app.util.blob :as blob]
|
||||||
|
[app.util.pointer-map :as pmap]
|
||||||
[app.util.services :as sv]
|
[app.util.services :as sv]
|
||||||
[app.util.time :as dt]
|
[app.util.time :as dt]
|
||||||
[cuerdas.core :as str]))
|
[cuerdas.core :as str]))
|
||||||
|
@ -34,20 +36,21 @@
|
||||||
:code :authentication-required
|
:code :authentication-required
|
||||||
:hint "only admins allowed")))
|
:hint "only admins allowed")))
|
||||||
|
|
||||||
|
(def sql:get-file-snapshots
|
||||||
|
"SELECT id, label, revn, created_at
|
||||||
|
FROM file_change
|
||||||
|
WHERE file_id = ?
|
||||||
|
AND created_at < ?
|
||||||
|
AND label IS NOT NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?")
|
||||||
|
|
||||||
(defn get-file-snapshots
|
(defn get-file-snapshots
|
||||||
[{:keys [::db/conn]} {:keys [file-id limit start-at]
|
[{:keys [::db/conn]} {:keys [file-id limit start-at]
|
||||||
:or {limit Long/MAX_VALUE}}]
|
:or {limit Long/MAX_VALUE}}]
|
||||||
(let [query (str "select id, label, revn, created_at "
|
(let [start-at (or start-at (dt/now))
|
||||||
" from file_change "
|
|
||||||
" where file_id = ? "
|
|
||||||
" and created_at < ? "
|
|
||||||
" and data is not null "
|
|
||||||
" order by created_at desc "
|
|
||||||
" limit ?")
|
|
||||||
start-at (or start-at (dt/now))
|
|
||||||
limit (min limit 20)]
|
limit (min limit 20)]
|
||||||
|
(->> (db/exec! conn [sql:get-file-snapshots file-id start-at limit])
|
||||||
(->> (db/exec! conn [query file-id start-at limit])
|
|
||||||
(mapv (fn [row]
|
(mapv (fn [row]
|
||||||
(update row :created-at dt/format-instant :rfc1123))))))
|
(update row :created-at dt/format-instant :rfc1123))))))
|
||||||
|
|
||||||
|
@ -63,8 +66,8 @@
|
||||||
(db/run! cfg get-file-snapshots params))
|
(db/run! cfg get-file-snapshots params))
|
||||||
|
|
||||||
(defn restore-file-snapshot!
|
(defn restore-file-snapshot!
|
||||||
[{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id id]}]
|
[{:keys [::db/conn] :as cfg} {:keys [file-id id]}]
|
||||||
(let [storage (media/configure-assets-storage storage conn)
|
(let [storage (sto/resolve cfg {::db/reuse-conn true})
|
||||||
file (files/get-minimal-file conn file-id {::db/for-update true})
|
file (files/get-minimal-file conn file-id {::db/for-update true})
|
||||||
snapshot (db/get* conn :file-change
|
snapshot (db/get* conn :file-change
|
||||||
{:file-id file-id
|
{:file-id file-id
|
||||||
|
@ -78,6 +81,7 @@
|
||||||
:id id
|
:id id
|
||||||
:file-id file-id))
|
:file-id file-id))
|
||||||
|
|
||||||
|
(let [snapshot (feat.fdata/resolve-file-data cfg snapshot)]
|
||||||
(when-not (:data snapshot)
|
(when-not (:data snapshot)
|
||||||
(ex/raise :type :precondition
|
(ex/raise :type :precondition
|
||||||
:code :snapshot-without-data
|
:code :snapshot-without-data
|
||||||
|
@ -90,9 +94,19 @@
|
||||||
:label (:label snapshot)
|
:label (:label snapshot)
|
||||||
:snapshot-id (str (:id snapshot)))
|
:snapshot-id (str (:id snapshot)))
|
||||||
|
|
||||||
|
;; If the file was already offloaded, on restring the snapshot
|
||||||
|
;; we are going to replace the file data, so we need to touch
|
||||||
|
;; the old referenced storage object and avoid possible leaks
|
||||||
|
(when (feat.fdata/offloaded? file)
|
||||||
|
(sto/touch-object! storage (:data-ref-id file)))
|
||||||
|
|
||||||
(db/update! conn :file
|
(db/update! conn :file
|
||||||
{:data (:data snapshot)
|
{:data (:data snapshot)
|
||||||
:revn (inc (:revn file))
|
:revn (inc (:revn file))
|
||||||
|
:version (:version snapshot)
|
||||||
|
:data-backend nil
|
||||||
|
:data-ref-id nil
|
||||||
|
:has-media-trimmed false
|
||||||
:features (:features snapshot)}
|
:features (:features snapshot)}
|
||||||
{:id file-id})
|
{:id file-id})
|
||||||
|
|
||||||
|
@ -101,11 +115,10 @@
|
||||||
" set deleted_at = now() "
|
" set deleted_at = now() "
|
||||||
" where file_id=? returning media_id")
|
" where file_id=? returning media_id")
|
||||||
res (db/exec! conn [sql file-id])]
|
res (db/exec! conn [sql file-id])]
|
||||||
|
|
||||||
(doseq [media-id (into #{} (keep :media-id) res)]
|
(doseq [media-id (into #{} (keep :media-id) res)]
|
||||||
(sto/touch-object! storage media-id)))
|
(sto/touch-object! storage media-id)))
|
||||||
|
|
||||||
;; clean object thumbnails
|
;; clean file thumbnails
|
||||||
(let [sql (str "update file_thumbnail "
|
(let [sql (str "update file_thumbnail "
|
||||||
" set deleted_at = now() "
|
" set deleted_at = now() "
|
||||||
" where file_id=? returning media_id")
|
" where file_id=? returning media_id")
|
||||||
|
@ -114,7 +127,7 @@
|
||||||
(sto/touch-object! storage media-id)))
|
(sto/touch-object! storage media-id)))
|
||||||
|
|
||||||
{:id (:id snapshot)
|
{:id (:id snapshot)
|
||||||
:label (:label snapshot)}))
|
:label (:label snapshot)})))
|
||||||
|
|
||||||
(defn- resolve-snapshot-by-label
|
(defn- resolve-snapshot-by-label
|
||||||
[conn file-id label]
|
[conn file-id label]
|
||||||
|
@ -146,21 +159,33 @@
|
||||||
(merge (resolve-snapshot-by-label conn file-id label)))]
|
(merge (resolve-snapshot-by-label conn file-id label)))]
|
||||||
(restore-file-snapshot! cfg params)))))
|
(restore-file-snapshot! cfg params)))))
|
||||||
|
|
||||||
|
(defn- get-file
|
||||||
|
[cfg file-id]
|
||||||
|
(let [file (->> (db/get cfg :file {:id file-id})
|
||||||
|
(feat.fdata/resolve-file-data cfg))]
|
||||||
|
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
|
||||||
|
(-> file
|
||||||
|
(update :data blob/decode)
|
||||||
|
(update :data feat.fdata/process-pointers deref)
|
||||||
|
(update :data feat.fdata/process-objects (partial into {}))
|
||||||
|
(update :data blob/encode)))))
|
||||||
|
|
||||||
(defn take-file-snapshot!
|
(defn take-file-snapshot!
|
||||||
[cfg {:keys [file-id label]}]
|
[cfg {:keys [file-id label ::rpc/profile-id]}]
|
||||||
(let [conn (db/get-connection cfg)
|
(let [file (get-file cfg file-id)
|
||||||
file (db/get conn :file {:id file-id})
|
|
||||||
id (uuid/next)]
|
id (uuid/next)]
|
||||||
|
|
||||||
(l/debug :hint "creating file snapshot"
|
(l/debug :hint "creating file snapshot"
|
||||||
:file-id (str file-id)
|
:file-id (str file-id)
|
||||||
:label label)
|
:label label)
|
||||||
|
|
||||||
(db/insert! conn :file-change
|
(db/insert! cfg :file-change
|
||||||
{:id id
|
{:id id
|
||||||
:revn (:revn file)
|
:revn (:revn file)
|
||||||
:data (:data file)
|
:data (:data file)
|
||||||
|
:version (:version file)
|
||||||
:features (:features file)
|
:features (:features file)
|
||||||
|
:profile-id profile-id
|
||||||
:file-id (:id file)
|
:file-id (:id file)
|
||||||
:label label}
|
:label label}
|
||||||
{::db/return-keys false})
|
{::db/return-keys false})
|
||||||
|
|
|
@ -38,23 +38,23 @@
|
||||||
[:name [:string {:max 250}]]
|
[:name [:string {:max 250}]]
|
||||||
[:project-id ::sm/uuid]
|
[:project-id ::sm/uuid]
|
||||||
[:id {:optional true} ::sm/uuid]
|
[:id {:optional true} ::sm/uuid]
|
||||||
[:is-shared :boolean]
|
[:is-shared ::sm/boolean]
|
||||||
[:features ::cfeat/features]
|
[:features ::cfeat/features]
|
||||||
[:create-page :boolean]])
|
[:create-page ::sm/boolean]])
|
||||||
|
|
||||||
(sv/defmethod ::create-temp-file
|
(sv/defmethod ::create-temp-file
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::doc/module :files
|
::doc/module :files
|
||||||
::sm/params schema:create-temp-file}
|
::sm/params schema:create-temp-file
|
||||||
[cfg {:keys [::rpc/profile-id project-id] :as params}]
|
::db/transaction true}
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
|
||||||
(projects/check-edition-permissions! conn profile-id project-id)
|
(projects/check-edition-permissions! conn profile-id project-id)
|
||||||
(let [team (teams/get-team conn :profile-id profile-id :project-id project-id)
|
(let [team (teams/get-team conn :profile-id profile-id :project-id project-id)
|
||||||
|
|
||||||
;; When we create files, we only need to respect the team
|
;; When we create files, we only need to respect the team
|
||||||
;; features, because some features can be enabled
|
;; features, because some features can be enabled
|
||||||
;; globally, but the team is still not migrated properly.
|
;; globally, but the team is still not migrated properly.
|
||||||
input-features (:features params #{})
|
input-features
|
||||||
|
(:features params #{})
|
||||||
|
|
||||||
;; If the imported project doesn't contain v2 we need to remove it
|
;; If the imported project doesn't contain v2 we need to remove it
|
||||||
team-features
|
team-features
|
||||||
|
@ -62,20 +62,21 @@
|
||||||
(not (contains? input-features "components/v2"))
|
(not (contains? input-features "components/v2"))
|
||||||
(disj "components/v2"))
|
(disj "components/v2"))
|
||||||
|
|
||||||
|
|
||||||
;; We also include all no migration features declared by
|
;; We also include all no migration features declared by
|
||||||
;; client; that enables the ability to enable a runtime
|
;; client; that enables the ability to enable a runtime
|
||||||
;; feature on frontend and make it permanent on file
|
;; feature on frontend and make it permanent on file
|
||||||
features (-> input-features
|
features
|
||||||
|
(-> input-features
|
||||||
(set/intersection cfeat/no-migration-features)
|
(set/intersection cfeat/no-migration-features)
|
||||||
(set/union team-features))
|
(set/union team-features))
|
||||||
|
|
||||||
params (-> params
|
params
|
||||||
|
(-> params
|
||||||
(assoc :profile-id profile-id)
|
(assoc :profile-id profile-id)
|
||||||
(assoc :deleted-at (dt/in-future {:days 1}))
|
(assoc :deleted-at (dt/in-future {:days 1}))
|
||||||
(assoc :features features))]
|
(assoc :features features))]
|
||||||
|
|
||||||
(files.create/create-file cfg params)))))
|
(files.create/create-file cfg params)))
|
||||||
|
|
||||||
;; --- MUTATION COMMAND: update-temp-file
|
;; --- MUTATION COMMAND: update-temp-file
|
||||||
|
|
||||||
|
@ -83,7 +84,7 @@
|
||||||
(def ^:private schema:update-temp-file
|
(def ^:private schema:update-temp-file
|
||||||
[:map {:title "update-temp-file"}
|
[:map {:title "update-temp-file"}
|
||||||
[:changes [:vector ::cpc/change]]
|
[:changes [:vector ::cpc/change]]
|
||||||
[:revn {:min 0} :int]
|
[:revn [::sm/int {:min 0}]]
|
||||||
[:session-id ::sm/uuid]
|
[:session-id ::sm/uuid]
|
||||||
[:id ::sm/uuid]])
|
[:id ::sm/uuid]])
|
||||||
|
|
||||||
|
|
|
@ -179,18 +179,16 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:get-file-data-for-thumbnail
|
schema:get-file-data-for-thumbnail
|
||||||
(sm/define
|
|
||||||
[:map {:title "get-file-data-for-thumbnail"}
|
[:map {:title "get-file-data-for-thumbnail"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:features {:optional true} ::cfeat/features]]))
|
[:features {:optional true} ::cfeat/features]])
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:partial-file
|
schema:partial-file
|
||||||
(sm/define
|
|
||||||
[:map {:title "PartialFile"}
|
[:map {:title "PartialFile"}
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:revn {:min 0} :int]
|
[:revn {:min 0} ::sm/int]
|
||||||
[:page :any]]))
|
[:page :any]])
|
||||||
|
|
||||||
(sv/defmethod ::get-file-data-for-thumbnail
|
(sv/defmethod ::get-file-data-for-thumbnail
|
||||||
"Retrieves the data for generate the thumbnail of the file. Used
|
"Retrieves the data for generate the thumbnail of the file. Used
|
||||||
|
@ -233,7 +231,7 @@
|
||||||
"INSERT INTO file_tagged_object_thumbnail (file_id, object_id, tag, media_id)
|
"INSERT INTO file_tagged_object_thumbnail (file_id, object_id, tag, media_id)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT (file_id, object_id, tag)
|
ON CONFLICT (file_id, object_id, tag)
|
||||||
DO UPDATE SET updated_at=?, media_id=?, deleted_at=null
|
DO UPDATE SET updated_at=?, media_id=?, deleted_at=?
|
||||||
RETURNING *")
|
RETURNING *")
|
||||||
|
|
||||||
(defn- persist-thumbnail!
|
(defn- persist-thumbnail!
|
||||||
|
@ -251,17 +249,19 @@
|
||||||
:content-type mtype
|
:content-type mtype
|
||||||
:bucket "file-object-thumbnail"})))
|
:bucket "file-object-thumbnail"})))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(defn- create-file-object-thumbnail!
|
(defn- create-file-object-thumbnail!
|
||||||
[{:keys [::sto/storage] :as cfg} file-id object-id media tag]
|
[{:keys [::sto/storage] :as cfg} file object-id media tag]
|
||||||
(let [tsnow (dt/now)
|
(let [file-id (:id file)
|
||||||
media (persist-thumbnail! storage media tsnow)
|
timestamp (dt/now)
|
||||||
|
media (persist-thumbnail! storage media timestamp)
|
||||||
[th1 th2] (db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
[th1 th2] (db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
||||||
(let [th1 (db/exec-one! conn [sql:get-file-object-thumbnail file-id object-id tag])
|
(let [th1 (db/exec-one! conn [sql:get-file-object-thumbnail file-id object-id tag])
|
||||||
th2 (db/exec-one! conn [sql:create-file-object-thumbnail
|
th2 (db/exec-one! conn [sql:create-file-object-thumbnail
|
||||||
file-id object-id tag (:id media)
|
file-id object-id tag
|
||||||
tsnow (:id media)])]
|
(:id media)
|
||||||
|
timestamp
|
||||||
|
(:id media)
|
||||||
|
(:deleted-at file)])]
|
||||||
[th1 th2])))]
|
[th1 th2])))]
|
||||||
|
|
||||||
(when (and (some? th1)
|
(when (and (some? th1)
|
||||||
|
@ -294,9 +294,8 @@
|
||||||
(media/validate-media-size! media)
|
(media/validate-media-size! media)
|
||||||
|
|
||||||
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
||||||
|
(when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})]
|
||||||
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
|
(create-file-object-thumbnail! cfg file object-id media (or tag "frame"))))
|
||||||
(create-file-object-thumbnail! cfg file-id object-id media (or tag "frame"))))
|
|
||||||
|
|
||||||
;; --- MUTATION COMMAND: delete-file-object-thumbnail
|
;; --- MUTATION COMMAND: delete-file-object-thumbnail
|
||||||
|
|
||||||
|
@ -327,7 +326,7 @@
|
||||||
(files/check-edition-permissions! cfg profile-id file-id)
|
(files/check-edition-permissions! cfg profile-id file-id)
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||||
(-> cfg
|
(-> cfg
|
||||||
(update ::sto/storage media/configure-assets-storage conn)
|
(update ::sto/storage sto/configure conn)
|
||||||
(delete-file-object-thumbnail! file-id object-id))
|
(delete-file-object-thumbnail! file-id object-id))
|
||||||
nil)))
|
nil)))
|
||||||
|
|
||||||
|
@ -386,7 +385,7 @@
|
||||||
schema:create-file-thumbnail
|
schema:create-file-thumbnail
|
||||||
[:map {:title "create-file-thumbnail"}
|
[:map {:title "create-file-thumbnail"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:revn :int]
|
[:revn ::sm/int]
|
||||||
[:media ::media/upload]])
|
[:media ::media/upload]])
|
||||||
|
|
||||||
(sv/defmethod ::create-file-thumbnail
|
(sv/defmethod ::create-file-thumbnail
|
||||||
|
@ -405,7 +404,6 @@
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||||
(files/check-edition-permissions! conn profile-id file-id)
|
(files/check-edition-permissions! conn profile-id file-id)
|
||||||
(when-not (db/read-only? conn)
|
(when-not (db/read-only? conn)
|
||||||
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)
|
(let [media (create-file-thumbnail! cfg params)]
|
||||||
media (create-file-thumbnail! cfg params)]
|
|
||||||
{:uri (files/resolve-public-uri (:id media))
|
{:uri (files/resolve-public-uri (:id media))
|
||||||
:id (:id media)})))))
|
:id (:id media)})))))
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
[app.rpc.commands.teams :as teams]
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
[app.rpc.helpers :as rph]
|
[app.rpc.helpers :as rph]
|
||||||
|
[app.storage :as sto]
|
||||||
[app.util.blob :as blob]
|
[app.util.blob :as blob]
|
||||||
[app.util.pointer-map :as pmap]
|
[app.util.pointer-map :as pmap]
|
||||||
[app.util.services :as sv]
|
[app.util.services :as sv]
|
||||||
|
@ -37,6 +38,20 @@
|
||||||
[clojure.set :as set]
|
[clojure.set :as set]
|
||||||
[promesa.exec :as px]))
|
[promesa.exec :as px]))
|
||||||
|
|
||||||
|
(declare ^:private get-lagged-changes)
|
||||||
|
(declare ^:private send-notifications!)
|
||||||
|
(declare ^:private update-file)
|
||||||
|
(declare ^:private update-file*)
|
||||||
|
(declare ^:private process-changes-and-validate)
|
||||||
|
(declare ^:private take-snapshot?)
|
||||||
|
(declare ^:private delete-old-snapshots!)
|
||||||
|
|
||||||
|
;; PUBLIC API; intended to be used outside of this module
|
||||||
|
(declare update-file!)
|
||||||
|
(declare update-file-data!)
|
||||||
|
(declare persist-file!)
|
||||||
|
(declare get-file)
|
||||||
|
|
||||||
;; --- SCHEMA
|
;; --- SCHEMA
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
|
@ -44,7 +59,7 @@
|
||||||
[:map {:title "update-file"}
|
[:map {:title "update-file"}
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:session-id ::sm/uuid]
|
[:session-id ::sm/uuid]
|
||||||
[:revn {:min 0} :int]
|
[:revn {:min 0} ::sm/int]
|
||||||
[:features {:optional true} ::cfeat/features]
|
[:features {:optional true} ::cfeat/features]
|
||||||
[:changes {:optional true} [:vector ::cpc/change]]
|
[:changes {:optional true} [:vector ::cpc/change]]
|
||||||
[:changes-with-metadata {:optional true}
|
[:changes-with-metadata {:optional true}
|
||||||
|
@ -52,7 +67,7 @@
|
||||||
[:changes [:vector ::cpc/change]]
|
[:changes [:vector ::cpc/change]]
|
||||||
[:hint-origin {:optional true} :keyword]
|
[:hint-origin {:optional true} :keyword]
|
||||||
[:hint-events {:optional true} [:vector [:string {:max 250}]]]]]]
|
[:hint-events {:optional true} [:vector [:string {:max 250}]]]]]]
|
||||||
[:skip-validate {:optional true} :boolean]])
|
[:skip-validate {:optional true} ::sm/boolean]])
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:update-file-result
|
schema:update-file-result
|
||||||
|
@ -61,7 +76,7 @@
|
||||||
[:changes [:vector ::cpc/change]]
|
[:changes [:vector ::cpc/change]]
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:revn {:min 0} :int]
|
[:revn {:min 0} ::sm/int]
|
||||||
[:session-id ::sm/uuid]]])
|
[:session-id ::sm/uuid]]])
|
||||||
|
|
||||||
;; --- HELPERS
|
;; --- HELPERS
|
||||||
|
@ -96,41 +111,6 @@
|
||||||
(or (contains? library-change-types type)
|
(or (contains? library-change-types type)
|
||||||
(contains? file-change-types type)))
|
(contains? file-change-types type)))
|
||||||
|
|
||||||
(def ^:private sql:get-file
|
|
||||||
"SELECT f.*, p.team_id
|
|
||||||
FROM file AS f
|
|
||||||
JOIN project AS p ON (p.id = f.project_id)
|
|
||||||
WHERE f.id = ?
|
|
||||||
AND (f.deleted_at IS NULL OR
|
|
||||||
f.deleted_at > now())
|
|
||||||
FOR KEY SHARE")
|
|
||||||
|
|
||||||
(defn get-file
|
|
||||||
[conn id]
|
|
||||||
(let [file (db/exec-one! conn [sql:get-file id])]
|
|
||||||
(when-not file
|
|
||||||
(ex/raise :type :not-found
|
|
||||||
:code :object-not-found
|
|
||||||
:hint (format "file with id '%s' does not exists" id)))
|
|
||||||
(update file :features db/decode-pgarray #{})))
|
|
||||||
|
|
||||||
(defn- wrap-with-pointer-map-context
|
|
||||||
[f]
|
|
||||||
(fn [cfg {:keys [id] :as file}]
|
|
||||||
(binding [pmap/*tracked* (pmap/create-tracked)
|
|
||||||
pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
|
|
||||||
(let [result (f cfg file)]
|
|
||||||
(feat.fdata/persist-pointers! cfg id)
|
|
||||||
result))))
|
|
||||||
|
|
||||||
(declare ^:private delete-old-snapshots!)
|
|
||||||
(declare ^:private get-lagged-changes)
|
|
||||||
(declare ^:private send-notifications!)
|
|
||||||
(declare ^:private take-snapshot?)
|
|
||||||
(declare ^:private update-file)
|
|
||||||
(declare ^:private update-file*)
|
|
||||||
(declare ^:private update-file-data)
|
|
||||||
|
|
||||||
;; If features are specified from params and the final feature
|
;; If features are specified from params and the final feature
|
||||||
;; set is different than the persisted one, update it on the
|
;; set is different than the persisted one, update it on the
|
||||||
;; database.
|
;; database.
|
||||||
|
@ -146,7 +126,8 @@
|
||||||
::sm/result schema:update-file-result
|
::sm/result schema:update-file-result
|
||||||
::doc/module :files
|
::doc/module :files
|
||||||
::doc/added "1.17"}
|
::doc/added "1.17"}
|
||||||
[cfg {:keys [::rpc/profile-id id] :as params}]
|
[{:keys [::mtx/metrics] :as cfg}
|
||||||
|
{:keys [::rpc/profile-id id changes changes-with-metadata] :as params}]
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||||
(files/check-edition-permissions! conn profile-id id)
|
(files/check-edition-permissions! conn profile-id id)
|
||||||
(db/xact-lock! conn id)
|
(db/xact-lock! conn id)
|
||||||
|
@ -160,43 +141,21 @@
|
||||||
(cfeat/check-client-features! (:features params))
|
(cfeat/check-client-features! (:features params))
|
||||||
(cfeat/check-file-features! (:features file) (:features params)))
|
(cfeat/check-file-features! (:features file) (:features params)))
|
||||||
|
|
||||||
params (assoc params
|
changes (if changes-with-metadata
|
||||||
:profile-id profile-id
|
(->> changes-with-metadata (mapcat :changes) vec)
|
||||||
:features features
|
(vec changes))
|
||||||
:team team
|
|
||||||
:file file)
|
params (-> params
|
||||||
|
(assoc :profile-id profile-id)
|
||||||
|
(assoc :features features)
|
||||||
|
(assoc :team team)
|
||||||
|
(assoc :file file)
|
||||||
|
(assoc :changes changes))
|
||||||
|
|
||||||
|
cfg (assoc cfg ::timestamp (dt/now))
|
||||||
|
|
||||||
tpoint (dt/tpoint)]
|
tpoint (dt/tpoint)]
|
||||||
|
|
||||||
;; When newly computed features does not match exactly with
|
|
||||||
;; the features defined on team row, we update it.
|
|
||||||
(when (not= features (:features team))
|
|
||||||
(let [features (db/create-array conn "text" features)]
|
|
||||||
(db/update! conn :team
|
|
||||||
{:features features}
|
|
||||||
{:id (:id team)})))
|
|
||||||
|
|
||||||
(binding [l/*context* (some-> (meta params)
|
|
||||||
(get :app.http/request)
|
|
||||||
(errors/request->context))]
|
|
||||||
(-> (update-file cfg params)
|
|
||||||
(rph/with-defer #(let [elapsed (tpoint)]
|
|
||||||
(l/trace :hint "update-file" :time (dt/format-duration elapsed))))))))))
|
|
||||||
|
|
||||||
(defn update-file
|
|
||||||
[{:keys [::mtx/metrics] :as cfg}
|
|
||||||
{:keys [file features changes changes-with-metadata] :as params}]
|
|
||||||
(let [features (-> features
|
|
||||||
(set/difference cfeat/frontend-only-features)
|
|
||||||
(set/union (:features file)))
|
|
||||||
|
|
||||||
update-fn (cond-> update-file*
|
|
||||||
(contains? features "fdata/pointer-map")
|
|
||||||
(wrap-with-pointer-map-context))
|
|
||||||
|
|
||||||
changes (if changes-with-metadata
|
|
||||||
(->> changes-with-metadata (mapcat :changes) vec)
|
|
||||||
(vec changes))]
|
|
||||||
|
|
||||||
(when (> (:revn params)
|
(when (> (:revn params)
|
||||||
(:revn file))
|
(:revn file))
|
||||||
|
@ -206,47 +165,133 @@
|
||||||
:context {:incoming-revn (:revn params)
|
:context {:incoming-revn (:revn params)
|
||||||
:stored-revn (:revn file)}))
|
:stored-revn (:revn file)}))
|
||||||
|
|
||||||
|
;; When newly computed features does not match exactly with
|
||||||
|
;; the features defined on team row, we update it.
|
||||||
|
(when (not= features (:features team))
|
||||||
|
(let [features (db/create-array conn "text" features)]
|
||||||
|
(db/update! conn :team
|
||||||
|
{:features features}
|
||||||
|
{:id (:id team)})))
|
||||||
|
|
||||||
(mtx/run! metrics {:id :update-file-changes :inc (count changes)})
|
(mtx/run! metrics {:id :update-file-changes :inc (count changes)})
|
||||||
|
|
||||||
(binding [cfeat/*current* features
|
(binding [l/*context* (some-> (meta params)
|
||||||
cfeat/*previous* (:features file)]
|
(get :app.http/request)
|
||||||
(let [file (assoc file :features features)
|
(errors/request->context))]
|
||||||
params (-> params
|
(-> (update-file* cfg params)
|
||||||
(assoc :file file)
|
(rph/with-defer #(let [elapsed (tpoint)]
|
||||||
(assoc :changes changes)
|
(l/trace :hint "update-file" :time (dt/format-duration elapsed))))))))))
|
||||||
(assoc ::created-at (dt/now)))]
|
|
||||||
|
|
||||||
(-> (update-fn cfg params)
|
|
||||||
(vary-meta assoc ::audit/replace-props
|
|
||||||
{:id (:id file)
|
|
||||||
:name (:name file)
|
|
||||||
:features (:features file)
|
|
||||||
:project-id (:project-id file)
|
|
||||||
:team-id (:team-id file)}))))))
|
|
||||||
|
|
||||||
(defn- update-file*
|
(defn- update-file*
|
||||||
[{:keys [::db/conn ::wrk/executor] :as cfg}
|
"Internal function, part of the update-file process, that encapsulates
|
||||||
{:keys [profile-id file changes session-id ::created-at skip-validate] :as params}]
|
the changes application offload to a separated thread and emit all
|
||||||
(let [;; Process the file data on separated thread for avoid to do
|
corresponding notifications.
|
||||||
|
|
||||||
|
Follow the inner implementation to `update-file-data!` function.
|
||||||
|
|
||||||
|
Only intended for internal use on this module."
|
||||||
|
[{:keys [::db/conn ::wrk/executor ::timestamp] :as cfg}
|
||||||
|
{:keys [profile-id file features changes session-id skip-validate] :as params}]
|
||||||
|
|
||||||
|
(let [;; Retrieve the file data
|
||||||
|
file (feat.fdata/resolve-file-data cfg file)
|
||||||
|
|
||||||
|
file (assoc file :features
|
||||||
|
(-> features
|
||||||
|
(set/difference cfeat/frontend-only-features)
|
||||||
|
(set/union (:features file))))
|
||||||
|
|
||||||
|
;; Process the file data on separated thread for avoid to do
|
||||||
;; the CPU intensive operation on vthread.
|
;; the CPU intensive operation on vthread.
|
||||||
file (px/invoke! executor (partial update-file-data cfg file changes skip-validate))
|
file (px/invoke! executor
|
||||||
|
(fn []
|
||||||
|
(binding [cfeat/*current* features
|
||||||
|
cfeat/*previous* (:features file)]
|
||||||
|
(update-file-data! cfg file
|
||||||
|
process-changes-and-validate
|
||||||
|
changes skip-validate))))]
|
||||||
|
|
||||||
|
(when (feat.fdata/offloaded? file)
|
||||||
|
(let [storage (sto/resolve cfg ::db/reuse-conn true)]
|
||||||
|
(some->> (:data-ref-id file) (sto/touch-object! storage))))
|
||||||
|
|
||||||
|
;; TODO: move this to asynchronous task
|
||||||
|
(when (::snapshot-data file)
|
||||||
|
(delete-old-snapshots! cfg file))
|
||||||
|
|
||||||
|
(persist-file! cfg file)
|
||||||
|
|
||||||
|
(let [params (assoc params :file file)
|
||||||
|
response {:revn (:revn file)
|
||||||
|
:lagged (get-lagged-changes conn params)}
|
||||||
features (db/create-array conn "text" (:features file))]
|
features (db/create-array conn "text" (:features file))]
|
||||||
|
|
||||||
|
;; Insert change (xlog)
|
||||||
(db/insert! conn :file-change
|
(db/insert! conn :file-change
|
||||||
{:id (uuid/next)
|
{:id (uuid/next)
|
||||||
:session-id session-id
|
:session-id session-id
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:created-at created-at
|
:created-at timestamp
|
||||||
:file-id (:id file)
|
:file-id (:id file)
|
||||||
:revn (:revn file)
|
:revn (:revn file)
|
||||||
|
:version (:version file)
|
||||||
|
:features features
|
||||||
:label (::snapshot-label file)
|
:label (::snapshot-label file)
|
||||||
:data (::snapshot-data file)
|
:data (::snapshot-data file)
|
||||||
:features (db/create-array conn "text" (:features file))
|
|
||||||
:changes (blob/encode changes)}
|
:changes (blob/encode changes)}
|
||||||
{::db/return-keys false})
|
{::db/return-keys false})
|
||||||
|
|
||||||
(when (::snapshot-data file)
|
;; Send asynchronous notifications
|
||||||
(delete-old-snapshots! cfg file))
|
(send-notifications! cfg params)
|
||||||
|
|
||||||
|
(vary-meta response assoc ::audit/replace-props
|
||||||
|
{:id (:id file)
|
||||||
|
:name (:name file)
|
||||||
|
:features (:features file)
|
||||||
|
:project-id (:project-id file)
|
||||||
|
:team-id (:team-id file)}))))
|
||||||
|
|
||||||
|
(defn update-file!
|
||||||
|
"A public api that allows apply a transformation to a file with all context setup."
|
||||||
|
[cfg file-id update-fn & args]
|
||||||
|
(let [file (get-file cfg file-id)
|
||||||
|
file (apply update-file-data! cfg file update-fn args)]
|
||||||
|
(persist-file! cfg file)))
|
||||||
|
|
||||||
|
(def ^:private sql:get-file
|
||||||
|
"SELECT f.*, p.team_id
|
||||||
|
FROM file AS f
|
||||||
|
JOIN project AS p ON (p.id = f.project_id)
|
||||||
|
WHERE f.id = ?
|
||||||
|
AND (f.deleted_at IS NULL OR
|
||||||
|
f.deleted_at > now())
|
||||||
|
FOR KEY SHARE")
|
||||||
|
|
||||||
|
(defn get-file
|
||||||
|
"Get not-decoded file, only decodes the features set."
|
||||||
|
[conn id]
|
||||||
|
(let [file (db/exec-one! conn [sql:get-file id])]
|
||||||
|
(when-not file
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :object-not-found
|
||||||
|
:hint (format "file with id '%s' does not exists" id)))
|
||||||
|
(update file :features db/decode-pgarray #{})))
|
||||||
|
|
||||||
|
(defn persist-file!
|
||||||
|
"Function responsible of persisting already encoded file. Should be
|
||||||
|
used together with `get-file` and `update-file-data!`.
|
||||||
|
|
||||||
|
It also updates the project modified-at attr."
|
||||||
|
[{:keys [::db/conn ::timestamp]} file]
|
||||||
|
(let [features (db/create-array conn "text" (:features file))
|
||||||
|
;; The timestamp can be nil because this function is also
|
||||||
|
;; intended to be used outside of this module
|
||||||
|
modified-at (or timestamp (dt/now))]
|
||||||
|
|
||||||
|
(db/update! conn :project
|
||||||
|
{:modified-at modified-at}
|
||||||
|
{:id (:project-id file)}
|
||||||
|
{::db/return-keys false})
|
||||||
|
|
||||||
(db/update! conn :file
|
(db/update! conn :file
|
||||||
{:revn (:revn file)
|
{:revn (:revn file)
|
||||||
|
@ -254,42 +299,29 @@
|
||||||
:version (:version file)
|
:version (:version file)
|
||||||
:features features
|
:features features
|
||||||
:data-backend nil
|
:data-backend nil
|
||||||
:modified-at created-at
|
:data-ref-id nil
|
||||||
|
:modified-at modified-at
|
||||||
:has-media-trimmed false}
|
:has-media-trimmed false}
|
||||||
{:id (:id file)})
|
{:id (:id file)}
|
||||||
|
{::db/return-keys false})))
|
||||||
|
|
||||||
(db/update! conn :project
|
(defn- update-file-data!
|
||||||
{:modified-at created-at}
|
"Perform a file data transformation in with all update context setup.
|
||||||
{:id (:project-id file)})
|
|
||||||
|
|
||||||
(let [params (assoc params :file file)]
|
This function expected not-decoded file and transformation function. Returns
|
||||||
;; Send asynchronous notifications
|
an encoded file.
|
||||||
(send-notifications! cfg params)
|
|
||||||
|
|
||||||
{:revn (:revn file)
|
This function is not responsible of saving the file. It only saves
|
||||||
:lagged (get-lagged-changes conn params)})))
|
fdata/pointer-map modified fragments."
|
||||||
|
|
||||||
(defn- soft-validate-file-schema!
|
[cfg {:keys [id] :as file} update-fn & args]
|
||||||
[file]
|
(binding [pmap/*tracked* (pmap/create-tracked)
|
||||||
(try
|
pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
|
||||||
(val/validate-file-schema! file)
|
|
||||||
(catch Throwable cause
|
|
||||||
(l/error :hint "file schema validation error" :cause cause))))
|
|
||||||
|
|
||||||
(defn- soft-validate-file!
|
|
||||||
[file libs]
|
|
||||||
(try
|
|
||||||
(val/validate-file! file libs)
|
|
||||||
(catch Throwable cause
|
|
||||||
(l/error :hint "file validation error"
|
|
||||||
:cause cause))))
|
|
||||||
|
|
||||||
(defn- update-file-data
|
|
||||||
[{:keys [::db/conn] :as cfg} file changes skip-validate]
|
|
||||||
(let [file (update file :data (fn [data]
|
(let [file (update file :data (fn [data]
|
||||||
(-> data
|
(-> data
|
||||||
(blob/decode)
|
(blob/decode)
|
||||||
(assoc :id (:id file)))))
|
(assoc :id (:id file)))))
|
||||||
|
|
||||||
;; For avoid unnecesary overhead of creating multiple pointers
|
;; For avoid unnecesary overhead of creating multiple pointers
|
||||||
;; and handly internally with objects map in their worst
|
;; and handly internally with objects map in their worst
|
||||||
;; case (when probably all shapes and all pointers will be
|
;; case (when probably all shapes and all pointers will be
|
||||||
|
@ -302,31 +334,10 @@
|
||||||
(fmg/migrate-file))
|
(fmg/migrate-file))
|
||||||
file)
|
file)
|
||||||
|
|
||||||
;; WARNING: this ruins performance; maybe we need to find
|
file (apply update-fn cfg file args)
|
||||||
;; some other way to do general validation
|
|
||||||
libs (when (and (or (contains? cf/flags :file-validation)
|
|
||||||
(contains? cf/flags :soft-file-validation))
|
|
||||||
(not skip-validate))
|
|
||||||
(->> (files/get-file-libraries conn (:id file))
|
|
||||||
(into [file] (map (fn [{:keys [id]}]
|
|
||||||
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
|
|
||||||
pmap/*tracked* nil]
|
|
||||||
;; We do not resolve the objects maps here
|
|
||||||
;; because there is a lower probability that all
|
|
||||||
;; shapes needed to be loded into memory, so we
|
|
||||||
;; leeave it on lazy status
|
|
||||||
(-> (files/get-file cfg id :migrate? false)
|
|
||||||
(update :data feat.fdata/process-pointers deref) ; ensure all pointers resolved
|
|
||||||
(update :data feat.fdata/process-objects (partial into {}))
|
|
||||||
(fmg/migrate-file))))))
|
|
||||||
(d/index-by :id)))
|
|
||||||
|
|
||||||
|
|
||||||
file (-> (files/check-version! file)
|
|
||||||
(update :revn inc)
|
|
||||||
(update :data cpc/process-changes changes)
|
|
||||||
(update :data d/without-nils))
|
|
||||||
|
|
||||||
|
;; TODO: reuse operations if file is migrated
|
||||||
|
;; TODO: move encoding to a separated thread
|
||||||
file (if (take-snapshot? file)
|
file (if (take-snapshot? file)
|
||||||
(let [tpoint (dt/tpoint)
|
(let [tpoint (dt/tpoint)
|
||||||
snapshot (-> (:data file)
|
snapshot (-> (:data file)
|
||||||
|
@ -345,7 +356,68 @@
|
||||||
(-> file
|
(-> file
|
||||||
(assoc ::snapshot-data snapshot)
|
(assoc ::snapshot-data snapshot)
|
||||||
(assoc ::snapshot-label label)))
|
(assoc ::snapshot-label label)))
|
||||||
file)]
|
file)
|
||||||
|
|
||||||
|
file (cond-> file
|
||||||
|
(contains? cfeat/*current* "fdata/objects-map")
|
||||||
|
(feat.fdata/enable-objects-map)
|
||||||
|
|
||||||
|
(contains? cfeat/*current* "fdata/pointer-map")
|
||||||
|
(feat.fdata/enable-pointer-map)
|
||||||
|
|
||||||
|
:always
|
||||||
|
(update :data blob/encode))]
|
||||||
|
|
||||||
|
(feat.fdata/persist-pointers! cfg id)
|
||||||
|
|
||||||
|
file)))
|
||||||
|
|
||||||
|
(defn- get-file-libraries
|
||||||
|
"A helper for preload file libraries, mainly used for perform file
|
||||||
|
semantical and structural validation"
|
||||||
|
[{:keys [::db/conn] :as cfg} file]
|
||||||
|
(->> (files/get-file-libraries conn (:id file))
|
||||||
|
(into [file] (map (fn [{:keys [id]}]
|
||||||
|
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
|
||||||
|
pmap/*tracked* nil]
|
||||||
|
;; We do not resolve the objects maps here
|
||||||
|
;; because there is a lower probability that all
|
||||||
|
;; shapes needed to be loded into memory, so we
|
||||||
|
;; leeave it on lazy status
|
||||||
|
(-> (files/get-file cfg id :migrate? false)
|
||||||
|
(update :data feat.fdata/process-pointers deref) ; ensure all pointers resolved
|
||||||
|
(update :data feat.fdata/process-objects (partial into {}))
|
||||||
|
(fmg/migrate-file))))))
|
||||||
|
(d/index-by :id)))
|
||||||
|
|
||||||
|
(defn- soft-validate-file-schema!
|
||||||
|
[file]
|
||||||
|
(try
|
||||||
|
(val/validate-file-schema! file)
|
||||||
|
(catch Throwable cause
|
||||||
|
(l/error :hint "file schema validation error" :cause cause))))
|
||||||
|
|
||||||
|
(defn- soft-validate-file!
|
||||||
|
[file libs]
|
||||||
|
(try
|
||||||
|
(val/validate-file! file libs)
|
||||||
|
(catch Throwable cause
|
||||||
|
(l/error :hint "file validation error"
|
||||||
|
:cause cause))))
|
||||||
|
|
||||||
|
(defn- process-changes-and-validate
|
||||||
|
[cfg file changes skip-validate]
|
||||||
|
(let [;; WARNING: this ruins performance; maybe we need to find
|
||||||
|
;; some other way to do general validation
|
||||||
|
libs (when (and (or (contains? cf/flags :file-validation)
|
||||||
|
(contains? cf/flags :soft-file-validation))
|
||||||
|
(not skip-validate))
|
||||||
|
(get-file-libraries cfg file))
|
||||||
|
|
||||||
|
file (-> (files/check-version! file)
|
||||||
|
(update :revn inc)
|
||||||
|
(update :data cpc/process-changes changes)
|
||||||
|
(update :data d/without-nils))]
|
||||||
|
|
||||||
(binding [pmap/*tracked* nil]
|
(binding [pmap/*tracked* nil]
|
||||||
(when (contains? cf/flags :soft-file-validation)
|
(when (contains? cf/flags :soft-file-validation)
|
||||||
|
@ -362,22 +434,14 @@
|
||||||
(not skip-validate))
|
(not skip-validate))
|
||||||
(val/validate-file-schema! file)))
|
(val/validate-file-schema! file)))
|
||||||
|
|
||||||
(cond-> file
|
file))
|
||||||
(contains? cfeat/*current* "fdata/objects-map")
|
|
||||||
(feat.fdata/enable-objects-map)
|
|
||||||
|
|
||||||
(contains? cfeat/*current* "fdata/pointer-map")
|
|
||||||
(feat.fdata/enable-pointer-map)
|
|
||||||
|
|
||||||
:always
|
|
||||||
(update :data blob/encode))))
|
|
||||||
|
|
||||||
(defn- take-snapshot?
|
(defn- take-snapshot?
|
||||||
"Defines the rule when file `data` snapshot should be saved."
|
"Defines the rule when file `data` snapshot should be saved."
|
||||||
[{:keys [revn modified-at] :as file}]
|
[{:keys [revn modified-at] :as file}]
|
||||||
(when (contains? cf/flags :file-snapshot)
|
(when (contains? cf/flags :auto-file-snapshot)
|
||||||
(let [freq (or (cf/get :file-snapshot-every) 20)
|
(let [freq (or (cf/get :auto-file-snapshot-every) 20)
|
||||||
timeout (or (cf/get :file-snapshot-timeout)
|
timeout (or (cf/get :auto-file-snapshot-timeout)
|
||||||
(dt/duration {:hours 1}))]
|
(dt/duration {:hours 1}))]
|
||||||
|
|
||||||
(or (= 1 freq)
|
(or (= 1 freq)
|
||||||
|
@ -401,19 +465,18 @@
|
||||||
"UPDATE file_change
|
"UPDATE file_change
|
||||||
SET label = NULL
|
SET label = NULL
|
||||||
WHERE file_id = ?
|
WHERE file_id = ?
|
||||||
AND label IS NOT NULL
|
AND label LIKE 'internal/%'
|
||||||
AND created_at < ?")
|
AND created_at < ?")
|
||||||
|
|
||||||
(defn- delete-old-snapshots!
|
(defn- delete-old-snapshots!
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [id] :as file}]
|
[{:keys [::db/conn] :as cfg} {:keys [id] :as file}]
|
||||||
(when-let [snapshots (not-empty (db/exec! conn [sql:get-latest-snapshots id
|
(when-let [snapshots (not-empty (db/exec! conn [sql:get-latest-snapshots id
|
||||||
(cf/get :file-snapshot-total 10)]))]
|
(cf/get :auto-file-snapshot-total 10)]))]
|
||||||
(let [last-date (-> snapshots peek :created-at)
|
(let [last-date (-> snapshots peek :created-at)
|
||||||
result (db/exec-one! conn [sql:delete-snapshots id last-date])]
|
result (db/exec-one! conn [sql:delete-snapshots id last-date])]
|
||||||
(l/trc :hint "delete old snapshots" :file-id (str id) :total (db/get-update-count result)))))
|
(l/trc :hint "delete old snapshots" :file-id (str id) :total (db/get-update-count result)))))
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private sql:lagged-changes
|
||||||
sql:lagged-changes
|
|
||||||
"select s.id, s.revn, s.file_id,
|
"select s.id, s.revn, s.file_id,
|
||||||
s.session_id, s.changes
|
s.session_id, s.changes
|
||||||
from file_change as s
|
from file_change as s
|
||||||
|
|
|
@ -86,6 +86,9 @@
|
||||||
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
||||||
[:font-style [::sm/one-of {:format "string"} valid-style]]])
|
[:font-style [::sm/one-of {:format "string"} valid-style]]])
|
||||||
|
|
||||||
|
;; FIXME: IMPORTANT: refactor this, we should not hold a whole db
|
||||||
|
;; connection around the font creation
|
||||||
|
|
||||||
(sv/defmethod ::create-font-variant
|
(sv/defmethod ::create-font-variant
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
::climit/id [[:process-font/by-profile ::rpc/profile-id]
|
::climit/id [[:process-font/by-profile ::rpc/profile-id]
|
||||||
|
@ -95,12 +98,11 @@
|
||||||
[cfg {:keys [::rpc/profile-id team-id] :as params}]
|
[cfg {:keys [::rpc/profile-id team-id] :as params}]
|
||||||
(db/tx-run! cfg
|
(db/tx-run! cfg
|
||||||
(fn [{:keys [::db/conn] :as cfg}]
|
(fn [{:keys [::db/conn] :as cfg}]
|
||||||
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
|
|
||||||
(teams/check-edition-permissions! conn profile-id team-id)
|
(teams/check-edition-permissions! conn profile-id team-id)
|
||||||
(quotes/check-quote! conn {::quotes/id ::quotes/font-variants-per-team
|
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
||||||
::quotes/profile-id profile-id
|
::quotes/profile-id profile-id
|
||||||
::quotes/team-id team-id})
|
::quotes/team-id team-id})
|
||||||
(create-font-variant cfg (assoc params :profile-id profile-id))))))
|
(create-font-variant cfg (assoc params :profile-id profile-id)))))
|
||||||
|
|
||||||
(defn create-font-variant
|
(defn create-font-variant
|
||||||
[{:keys [::sto/storage ::db/conn ::wrk/executor]} {:keys [data] :as params}]
|
[{:keys [::sto/storage ::db/conn ::wrk/executor]} {:keys [data] :as params}]
|
||||||
|
@ -203,14 +205,13 @@
|
||||||
::sm/params schema:delete-font}
|
::sm/params schema:delete-font}
|
||||||
[cfg {:keys [::rpc/profile-id id team-id]}]
|
[cfg {:keys [::rpc/profile-id id team-id]}]
|
||||||
(db/tx-run! cfg
|
(db/tx-run! cfg
|
||||||
(fn [{:keys [::db/conn ::sto/storage] :as cfg}]
|
(fn [{:keys [::db/conn] :as cfg}]
|
||||||
(teams/check-edition-permissions! conn profile-id team-id)
|
(teams/check-edition-permissions! conn profile-id team-id)
|
||||||
(let [fonts (db/query conn :team-font-variant
|
(let [fonts (db/query conn :team-font-variant
|
||||||
{:team-id team-id
|
{:team-id team-id
|
||||||
:font-id id
|
:font-id id
|
||||||
:deleted-at nil}
|
:deleted-at nil}
|
||||||
{::sql/for-update true})
|
{::sql/for-update true})
|
||||||
storage (media/configure-assets-storage storage conn)
|
|
||||||
tnow (dt/now)]
|
tnow (dt/now)]
|
||||||
|
|
||||||
(when-not (seq fonts)
|
(when-not (seq fonts)
|
||||||
|
@ -220,11 +221,7 @@
|
||||||
(doseq [font fonts]
|
(doseq [font fonts]
|
||||||
(db/update! conn :team-font-variant
|
(db/update! conn :team-font-variant
|
||||||
{:deleted-at tnow}
|
{:deleted-at tnow}
|
||||||
{:id (:id font)})
|
{:id (:id font)}))
|
||||||
(some->> (:woff1-file-id font) (sto/touch-object! storage))
|
|
||||||
(some->> (:woff2-file-id font) (sto/touch-object! storage))
|
|
||||||
(some->> (:ttf-file-id font) (sto/touch-object! storage))
|
|
||||||
(some->> (:otf-file-id font) (sto/touch-object! storage)))
|
|
||||||
|
|
||||||
(rph/with-meta (rph/wrap)
|
(rph/with-meta (rph/wrap)
|
||||||
{::audit/props {:id id
|
{::audit/props {:id id
|
||||||
|
@ -245,22 +242,16 @@
|
||||||
::sm/params schema:delete-font-variant}
|
::sm/params schema:delete-font-variant}
|
||||||
[cfg {:keys [::rpc/profile-id id team-id]}]
|
[cfg {:keys [::rpc/profile-id id team-id]}]
|
||||||
(db/tx-run! cfg
|
(db/tx-run! cfg
|
||||||
(fn [{:keys [::db/conn ::sto/storage] :as cfg}]
|
(fn [{:keys [::db/conn] :as cfg}]
|
||||||
(teams/check-edition-permissions! conn profile-id team-id)
|
(teams/check-edition-permissions! conn profile-id team-id)
|
||||||
(let [variant (db/get conn :team-font-variant
|
(let [variant (db/get conn :team-font-variant
|
||||||
{:id id :team-id team-id}
|
{:id id :team-id team-id}
|
||||||
{::sql/for-update true})
|
{::sql/for-update true})]
|
||||||
storage (media/configure-assets-storage storage conn)]
|
|
||||||
|
|
||||||
(db/update! conn :team-font-variant
|
(db/update! conn :team-font-variant
|
||||||
{:deleted-at (dt/now)}
|
{:deleted-at (dt/now)}
|
||||||
{:id (:id variant)})
|
{:id (:id variant)})
|
||||||
|
|
||||||
(some->> (:woff1-file-id variant) (sto/touch-object! storage))
|
|
||||||
(some->> (:woff2-file-id variant) (sto/touch-object! storage))
|
|
||||||
(some->> (:ttf-file-id variant) (sto/touch-object! storage))
|
|
||||||
(some->> (:otf-file-id variant) (sto/touch-object! storage))
|
|
||||||
|
|
||||||
(rph/with-meta (rph/wrap)
|
(rph/with-meta (rph/wrap)
|
||||||
{::audit/props {:font-family (:font-family variant)
|
{::audit/props {:font-family (:font-family variant)
|
||||||
:font-id (:font-id variant)}})))))
|
:font-id (:font-id variant)}})))))
|
||||||
|
|
|
@ -88,10 +88,9 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:duplicate-file
|
schema:duplicate-file
|
||||||
(sm/define
|
|
||||||
[:map {:title "duplicate-file"}
|
[:map {:title "duplicate-file"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:name {:optional true} [:string {:max 250}]]]))
|
[:name {:optional true} [:string {:max 250}]]])
|
||||||
|
|
||||||
(sv/defmethod ::duplicate-file
|
(sv/defmethod ::duplicate-file
|
||||||
"Duplicate a single file in the same team."
|
"Duplicate a single file in the same team."
|
||||||
|
@ -150,10 +149,9 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:duplicate-project
|
schema:duplicate-project
|
||||||
(sm/define
|
|
||||||
[:map {:title "duplicate-project"}
|
[:map {:title "duplicate-project"}
|
||||||
[:project-id ::sm/uuid]
|
[:project-id ::sm/uuid]
|
||||||
[:name {:optional true} [:string {:max 250}]]]))
|
[:name {:optional true} [:string {:max 250}]]])
|
||||||
|
|
||||||
(sv/defmethod ::duplicate-project
|
(sv/defmethod ::duplicate-project
|
||||||
"Duplicate an entire project with all the files"
|
"Duplicate an entire project with all the files"
|
||||||
|
@ -327,10 +325,9 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:move-files
|
schema:move-files
|
||||||
(sm/define
|
|
||||||
[:map {:title "move-files"}
|
[:map {:title "move-files"}
|
||||||
[:ids ::sm/set-of-uuid]
|
[:ids ::sm/set-of-uuid]
|
||||||
[:project-id ::sm/uuid]]))
|
[:project-id ::sm/uuid]])
|
||||||
|
|
||||||
(sv/defmethod ::move-files
|
(sv/defmethod ::move-files
|
||||||
"Move a set of files from one project to other."
|
"Move a set of files from one project to other."
|
||||||
|
@ -382,10 +379,9 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:move-project
|
schema:move-project
|
||||||
(sm/define
|
|
||||||
[:map {:title "move-project"}
|
[:map {:title "move-project"}
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
[:project-id ::sm/uuid]]))
|
[:project-id ::sm/uuid]])
|
||||||
|
|
||||||
(sv/defmethod ::move-project
|
(sv/defmethod ::move-project
|
||||||
"Move projects between teams"
|
"Move projects between teams"
|
||||||
|
@ -397,8 +393,8 @@
|
||||||
|
|
||||||
;; --- COMMAND: Clone Template
|
;; --- COMMAND: Clone Template
|
||||||
|
|
||||||
(defn- clone-template
|
(defn clone-template
|
||||||
[cfg {:keys [project-id ::rpc/profile-id] :as params} template]
|
[cfg {:keys [project-id profile-id] :as params} template]
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn ::wrk/executor] :as cfg}]
|
(db/tx-run! cfg (fn [{:keys [::db/conn ::wrk/executor] :as cfg}]
|
||||||
;; NOTE: the importation process performs some operations that
|
;; NOTE: the importation process performs some operations that
|
||||||
;; are not very friendly with virtual threads, and for avoid
|
;; are not very friendly with virtual threads, and for avoid
|
||||||
|
@ -417,6 +413,7 @@
|
||||||
(doseq [file-id result]
|
(doseq [file-id result]
|
||||||
(let [props (assoc props :id file-id)
|
(let [props (assoc props :id file-id)
|
||||||
event (-> (audit/event-from-rpc-params params)
|
event (-> (audit/event-from-rpc-params params)
|
||||||
|
(assoc ::audit/profile-id profile-id)
|
||||||
(assoc ::audit/name "create-file")
|
(assoc ::audit/name "create-file")
|
||||||
(assoc ::audit/props props))]
|
(assoc ::audit/props props))]
|
||||||
(audit/submit! cfg event))))
|
(audit/submit! cfg event))))
|
||||||
|
@ -425,10 +422,9 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:clone-template
|
schema:clone-template
|
||||||
(sm/define
|
|
||||||
[:map {:title "clone-template"}
|
[:map {:title "clone-template"}
|
||||||
[:project-id ::sm/uuid]
|
[:project-id ::sm/uuid]
|
||||||
[:template-id ::sm/word-string]]))
|
[:template-id ::sm/word-string]])
|
||||||
|
|
||||||
(sv/defmethod ::clone-template
|
(sv/defmethod ::clone-template
|
||||||
"Clone into the specified project the template by its id."
|
"Clone into the specified project the template by its id."
|
||||||
|
@ -439,7 +435,8 @@
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id template-id] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id template-id] :as params}]
|
||||||
(let [project (db/get-by-id pool :project project-id {:columns [:id :team-id]})
|
(let [project (db/get-by-id pool :project project-id {:columns [:id :team-id]})
|
||||||
_ (teams/check-edition-permissions! pool profile-id (:team-id project))
|
_ (teams/check-edition-permissions! pool profile-id (:team-id project))
|
||||||
template (tmpl/get-template-stream cfg template-id)]
|
template (tmpl/get-template-stream cfg template-id)
|
||||||
|
params (assoc params :profile-id profile-id)]
|
||||||
|
|
||||||
(when-not template
|
(when-not template
|
||||||
(ex/raise :type :not-found
|
(ex/raise :type :not-found
|
||||||
|
|
|
@ -46,7 +46,7 @@
|
||||||
[:map {:title "upload-file-media-object"}
|
[:map {:title "upload-file-media-object"}
|
||||||
[:id {:optional true} ::sm/uuid]
|
[:id {:optional true} ::sm/uuid]
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:is-local :boolean]
|
[:is-local ::sm/boolean]
|
||||||
[:name [:string {:max 250}]]
|
[:name [:string {:max 250}]]
|
||||||
[:content ::media/upload]])
|
[:content ::media/upload]])
|
||||||
|
|
||||||
|
@ -56,8 +56,6 @@
|
||||||
::climit/id [[:process-image/by-profile ::rpc/profile-id]
|
::climit/id [[:process-image/by-profile ::rpc/profile-id]
|
||||||
[:process-image/global]]}
|
[:process-image/global]]}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
|
||||||
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
|
|
||||||
|
|
||||||
(files/check-edition-permissions! pool profile-id file-id)
|
(files/check-edition-permissions! pool profile-id file-id)
|
||||||
(media/validate-media-type! content)
|
(media/validate-media-type! content)
|
||||||
(media/validate-media-size! content)
|
(media/validate-media-size! content)
|
||||||
|
@ -70,7 +68,7 @@
|
||||||
:size (:size content)
|
:size (:size content)
|
||||||
:mtype (:mtype content)}]
|
:mtype (:mtype content)}]
|
||||||
(with-meta object
|
(with-meta object
|
||||||
{::audit/replace-props props}))))))
|
{::audit/replace-props props})))))
|
||||||
|
|
||||||
(defn- big-enough-for-thumbnail?
|
(defn- big-enough-for-thumbnail?
|
||||||
"Checks if the provided image info is big enough for
|
"Checks if the provided image info is big enough for
|
||||||
|
@ -174,7 +172,7 @@
|
||||||
(def ^:private schema:create-file-media-object-from-url
|
(def ^:private schema:create-file-media-object-from-url
|
||||||
[:map {:title "create-file-media-object-from-url"}
|
[:map {:title "create-file-media-object-from-url"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:is-local :boolean]
|
[:is-local ::sm/boolean]
|
||||||
[:url ::sm/uri]
|
[:url ::sm/uri]
|
||||||
[:id {:optional true} ::sm/uuid]
|
[:id {:optional true} ::sm/uuid]
|
||||||
[:name {:optional true} [:string {:max 250}]]])
|
[:name {:optional true} [:string {:max 250}]]])
|
||||||
|
@ -183,9 +181,8 @@
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::sm/params schema:create-file-media-object-from-url}
|
::sm/params schema:create-file-media-object-from-url}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||||
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
|
|
||||||
(files/check-edition-permissions! pool profile-id file-id)
|
(files/check-edition-permissions! pool profile-id file-id)
|
||||||
(create-file-media-object-from-url cfg (assoc params :profile-id profile-id))))
|
(create-file-media-object-from-url cfg (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
(defn download-image
|
(defn download-image
|
||||||
[{:keys [::http/client]} uri]
|
[{:keys [::http/client]} uri]
|
||||||
|
@ -256,7 +253,7 @@
|
||||||
(def ^:private schema:clone-file-media-object
|
(def ^:private schema:clone-file-media-object
|
||||||
[:map {:title "clone-file-media-object"}
|
[:map {:title "clone-file-media-object"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:is-local :boolean]
|
[:is-local ::sm/boolean]
|
||||||
[:id ::sm/uuid]])
|
[:id ::sm/uuid]])
|
||||||
|
|
||||||
(sv/defmethod ::clone-file-media-object
|
(sv/defmethod ::clone-file-media-object
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
|
[app.common.types.plugins :refer [schema:plugin-registry]]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
|
@ -40,6 +41,33 @@
|
||||||
(declare strip-private-attrs)
|
(declare strip-private-attrs)
|
||||||
(declare verify-password)
|
(declare verify-password)
|
||||||
|
|
||||||
|
(def schema:props
|
||||||
|
[:map {:title "ProfileProps"}
|
||||||
|
[:plugins {:optional true} schema:plugin-registry]
|
||||||
|
[:newsletter-updates {:optional true} ::sm/boolean]
|
||||||
|
[:newsletter-news {:optional true} ::sm/boolean]
|
||||||
|
[:onboarding-team-id {:optional true} ::sm/uuid]
|
||||||
|
[:onboarding-viewed {:optional true} ::sm/boolean]
|
||||||
|
[:v2-info-shown {:optional true} ::sm/boolean]
|
||||||
|
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
|
||||||
|
[:release-notes-viewed {:optional true}
|
||||||
|
[::sm/text {:max 100}]]])
|
||||||
|
|
||||||
|
(def schema:profile
|
||||||
|
[:map {:title "Profile"}
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:fullname [::sm/word-string {:max 250}]]
|
||||||
|
[:email ::sm/email]
|
||||||
|
[:is-active {:optional true} ::sm/boolean]
|
||||||
|
[:is-blocked {:optional true} ::sm/boolean]
|
||||||
|
[:is-demo {:optional true} ::sm/boolean]
|
||||||
|
[:is-muted {:optional true} ::sm/boolean]
|
||||||
|
[:created-at {:optional true} ::sm/inst]
|
||||||
|
[:modified-at {:optional true} ::sm/inst]
|
||||||
|
[:default-project-id {:optional true} ::sm/uuid]
|
||||||
|
[:default-team-id {:optional true} ::sm/uuid]
|
||||||
|
[:props {:optional true} schema:props]])
|
||||||
|
|
||||||
(defn clean-email
|
(defn clean-email
|
||||||
"Clean and normalizes email address string"
|
"Clean and normalizes email address string"
|
||||||
[email]
|
[email]
|
||||||
|
@ -53,24 +81,6 @@
|
||||||
email)]
|
email)]
|
||||||
email))
|
email))
|
||||||
|
|
||||||
(def ^:private
|
|
||||||
schema:profile
|
|
||||||
(sm/define
|
|
||||||
[:map {:title "Profile"}
|
|
||||||
[:id ::sm/uuid]
|
|
||||||
[:fullname [::sm/word-string {:max 250}]]
|
|
||||||
[:email ::sm/email]
|
|
||||||
[:is-active {:optional true} :boolean]
|
|
||||||
[:is-blocked {:optional true} :boolean]
|
|
||||||
[:is-demo {:optional true} :boolean]
|
|
||||||
[:is-muted {:optional true} :boolean]
|
|
||||||
[:created-at {:optional true} ::sm/inst]
|
|
||||||
[:modified-at {:optional true} ::sm/inst]
|
|
||||||
[:default-project-id {:optional true} ::sm/uuid]
|
|
||||||
[:default-team-id {:optional true} ::sm/uuid]
|
|
||||||
[:props {:optional true}
|
|
||||||
[:map-of {:title "ProfileProps"} :keyword :any]]]))
|
|
||||||
|
|
||||||
;; --- QUERY: Get profile (own)
|
;; --- QUERY: Get profile (own)
|
||||||
|
|
||||||
(sv/defmethod ::get-profile
|
(sv/defmethod ::get-profile
|
||||||
|
@ -99,11 +109,10 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:update-profile
|
schema:update-profile
|
||||||
(sm/define
|
|
||||||
[:map {:title "update-profile"}
|
[:map {:title "update-profile"}
|
||||||
[:fullname [::sm/word-string {:max 250}]]
|
[:fullname [::sm/word-string {:max 250}]]
|
||||||
[:lang {:optional true} [:string {:max 5}]]
|
[:lang {:optional true} [:string {:max 8}]]
|
||||||
[:theme {:optional true} [:string {:max 250}]]]))
|
[:theme {:optional true} [:string {:max 250}]]])
|
||||||
|
|
||||||
(sv/defmethod ::update-profile
|
(sv/defmethod ::update-profile
|
||||||
{::doc/added "1.0"
|
{::doc/added "1.0"
|
||||||
|
@ -144,11 +153,10 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:update-profile-password
|
schema:update-profile-password
|
||||||
(sm/define
|
|
||||||
[:map {:title "update-profile-password"}
|
[:map {:title "update-profile-password"}
|
||||||
[:password [::sm/word-string {:max 500}]]
|
[:password [::sm/word-string {:max 500}]]
|
||||||
;; Social registered users don't have old-password
|
;; Social registered users don't have old-password
|
||||||
[:old-password {:optional true} [:maybe [::sm/word-string {:max 500}]]]]))
|
[:old-password {:optional true} [:maybe [::sm/word-string {:max 500}]]]])
|
||||||
|
|
||||||
(sv/defmethod ::update-profile-password
|
(sv/defmethod ::update-profile-password
|
||||||
{::doc/added "1.0"
|
{::doc/added "1.0"
|
||||||
|
@ -199,9 +207,8 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:update-profile-photo
|
schema:update-profile-photo
|
||||||
(sm/define
|
|
||||||
[:map {:title "update-profile-photo"}
|
[:map {:title "update-profile-photo"}
|
||||||
[:file ::media/upload]]))
|
[:file ::media/upload]])
|
||||||
|
|
||||||
(sv/defmethod ::update-profile-photo
|
(sv/defmethod ::update-profile-photo
|
||||||
{:doc/added "1.1"
|
{:doc/added "1.1"
|
||||||
|
@ -210,8 +217,7 @@
|
||||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||||
;; Validate incoming mime type
|
;; Validate incoming mime type
|
||||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||||
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
|
(update-profile-photo cfg (assoc params :profile-id profile-id)))
|
||||||
(update-profile-photo cfg (assoc params :profile-id profile-id))))
|
|
||||||
|
|
||||||
(defn update-profile-photo
|
(defn update-profile-photo
|
||||||
[{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id file] :as params}]
|
[{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id file] :as params}]
|
||||||
|
@ -269,9 +275,8 @@
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:request-email-change
|
schema:request-email-change
|
||||||
(sm/define
|
|
||||||
[:map {:title "request-email-change"}
|
[:map {:title "request-email-change"}
|
||||||
[:email ::sm/email]]))
|
[:email ::sm/email]])
|
||||||
|
|
||||||
(sv/defmethod ::request-email-change
|
(sv/defmethod ::request-email-change
|
||||||
{::doc/added "1.0"
|
{::doc/added "1.0"
|
||||||
|
@ -352,20 +357,15 @@
|
||||||
:extra-data ptoken})
|
:extra-data ptoken})
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
|
|
||||||
;; --- MUTATION: Update Profile Props
|
;; --- MUTATION: Update Profile Props
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:update-profile-props
|
schema:update-profile-props
|
||||||
(sm/define
|
|
||||||
[:map {:title "update-profile-props"}
|
[:map {:title "update-profile-props"}
|
||||||
[:props [:map-of :keyword :any]]]))
|
[:props schema:props]])
|
||||||
|
|
||||||
(sv/defmethod ::update-profile-props
|
(defn update-profile-props
|
||||||
{::doc/added "1.0"
|
[{:keys [::db/conn] :as cfg} profile-id props]
|
||||||
::sm/params schema:update-profile-props}
|
|
||||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id props]}]
|
|
||||||
(db/with-atomic [conn pool]
|
|
||||||
(let [profile (get-profile conn profile-id ::sql/for-update true)
|
(let [profile (get-profile conn profile-id ::sql/for-update true)
|
||||||
props (reduce-kv (fn [props k v]
|
props (reduce-kv (fn [props k v]
|
||||||
;; We don't accept namespaced keys
|
;; We don't accept namespaced keys
|
||||||
|
@ -381,7 +381,14 @@
|
||||||
{:props (db/tjson props)}
|
{:props (db/tjson props)}
|
||||||
{:id profile-id})
|
{:id profile-id})
|
||||||
|
|
||||||
(filter-props props))))
|
(filter-props props)))
|
||||||
|
|
||||||
|
(sv/defmethod ::update-profile-props
|
||||||
|
{::doc/added "1.0"
|
||||||
|
::sm/params schema:update-profile-props}
|
||||||
|
[cfg {:keys [::rpc/profile-id props]}]
|
||||||
|
(db/tx-run! cfg (fn [cfg]
|
||||||
|
(update-profile-props cfg profile-id props))))
|
||||||
|
|
||||||
;; --- MUTATION: Delete Profile
|
;; --- MUTATION: Delete Profile
|
||||||
|
|
||||||
|
|
|
@ -168,6 +168,17 @@
|
||||||
|
|
||||||
;; --- MUTATION: Create Project
|
;; --- MUTATION: Create Project
|
||||||
|
|
||||||
|
(defn- create-project
|
||||||
|
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||||
|
(let [project (teams/create-project conn params)]
|
||||||
|
(teams/create-project-role conn profile-id (:id project) :owner)
|
||||||
|
(db/insert! conn :team-project-profile-rel
|
||||||
|
{:project-id (:id project)
|
||||||
|
:profile-id profile-id
|
||||||
|
:team-id team-id
|
||||||
|
:is-pinned false})
|
||||||
|
(assoc project :is-pinned false)))
|
||||||
|
|
||||||
(def ^:private schema:create-project
|
(def ^:private schema:create-project
|
||||||
[:map {:title "create-project"}
|
[:map {:title "create-project"}
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
|
@ -178,23 +189,15 @@
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sm/params schema:create-project}
|
::sm/params schema:create-project}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
[cfg {:keys [::rpc/profile-id team-id] :as params}]
|
||||||
(db/with-atomic [conn pool]
|
|
||||||
(teams/check-edition-permissions! conn profile-id team-id)
|
(teams/check-edition-permissions! cfg profile-id team-id)
|
||||||
(quotes/check-quote! conn {::quotes/id ::quotes/projects-per-team
|
(quotes/check! cfg {::quotes/id ::quotes/projects-per-team
|
||||||
::quotes/profile-id profile-id
|
::quotes/profile-id profile-id
|
||||||
::quotes/team-id team-id})
|
::quotes/team-id team-id})
|
||||||
|
|
||||||
(let [params (assoc params :profile-id profile-id)
|
(let [params (assoc params :profile-id profile-id)]
|
||||||
project (teams/create-project conn params)]
|
(db/tx-run! cfg create-project params)))
|
||||||
(teams/create-project-role conn profile-id (:id project) :owner)
|
|
||||||
(db/insert! conn :team-project-profile-rel
|
|
||||||
{:project-id (:id project)
|
|
||||||
:profile-id profile-id
|
|
||||||
:team-id team-id
|
|
||||||
:is-pinned false})
|
|
||||||
(assoc project :is-pinned false))))
|
|
||||||
|
|
||||||
|
|
||||||
;; --- MUTATION: Toggle Project Pin
|
;; --- MUTATION: Toggle Project Pin
|
||||||
|
|
||||||
|
@ -208,7 +211,7 @@
|
||||||
(def ^:private schema:update-project-pin
|
(def ^:private schema:update-project-pin
|
||||||
[:map {:title "update-project-pin"}
|
[:map {:title "update-project-pin"}
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
[:is-pinned :boolean]
|
[:is-pinned ::sm/boolean]
|
||||||
[:id ::sm/uuid]])
|
[:id ::sm/uuid]])
|
||||||
|
|
||||||
(sv/defmethod ::update-project-pin
|
(sv/defmethod ::update-project-pin
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
|
[app.db.sql :as sql]
|
||||||
[app.email :as eml]
|
[app.email :as eml]
|
||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
[app.main :as-alias main]
|
[app.main :as-alias main]
|
||||||
|
@ -28,6 +29,7 @@
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
[app.tokens :as tokens]
|
[app.tokens :as tokens]
|
||||||
|
[app.util.blob :as blob]
|
||||||
[app.util.services :as sv]
|
[app.util.services :as sv]
|
||||||
[app.util.time :as dt]
|
[app.util.time :as dt]
|
||||||
[app.worker :as wrk]
|
[app.worker :as wrk]
|
||||||
|
@ -80,6 +82,35 @@
|
||||||
(cond-> row
|
(cond-> row
|
||||||
(some? features) (assoc :features (db/decode-pgarray features #{}))))
|
(some? features) (assoc :features (db/decode-pgarray features #{}))))
|
||||||
|
|
||||||
|
(defn- check-profile-muted
|
||||||
|
"Check if the member's email is part of the global bounce report"
|
||||||
|
[conn member]
|
||||||
|
(let [email (profile/clean-email (:email member))]
|
||||||
|
(when (and member (not (eml/allow-send-emails? conn member)))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :member-is-muted
|
||||||
|
:email email
|
||||||
|
:hint "the profile has reported repeatedly as spam or has bounces"))))
|
||||||
|
|
||||||
|
(defn- check-email-bounce
|
||||||
|
"Check if the email is part of the global complain report"
|
||||||
|
[conn email show?]
|
||||||
|
(when (eml/has-bounce-reports? conn email)
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :email-has-permanent-bounces
|
||||||
|
:email (if show? email "private")
|
||||||
|
:hint "this email has been repeatedly reported as bounce")))
|
||||||
|
|
||||||
|
(defn- check-email-spam
|
||||||
|
"Check if the member email is part of the global complain report"
|
||||||
|
[conn email show?]
|
||||||
|
(when (eml/has-complaint-reports? conn email)
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :email-has-complaints
|
||||||
|
:email (if show? email "private")
|
||||||
|
:hint "this email has been repeatedly reported as spam")))
|
||||||
|
|
||||||
|
|
||||||
;; --- Query: Teams
|
;; --- Query: Teams
|
||||||
|
|
||||||
(declare get-teams)
|
(declare get-teams)
|
||||||
|
@ -194,16 +225,16 @@
|
||||||
;; --- Query: Team Members
|
;; --- Query: Team Members
|
||||||
|
|
||||||
(def sql:team-members
|
(def sql:team-members
|
||||||
"select tp.*,
|
"SELECT tp.*,
|
||||||
p.id,
|
p.id,
|
||||||
p.email,
|
p.email,
|
||||||
p.fullname as name,
|
p.fullname AS name,
|
||||||
p.fullname as fullname,
|
p.fullname AS fullname,
|
||||||
p.photo_id,
|
p.photo_id,
|
||||||
p.is_active
|
p.is_active
|
||||||
from team_profile_rel as tp
|
FROM team_profile_rel AS tp
|
||||||
join profile as p on (p.id = tp.profile_id)
|
JOIN profile AS p ON (p.id = tp.profile_id)
|
||||||
where tp.team_id = ?")
|
WHERE tp.team_id = ?")
|
||||||
|
|
||||||
(defn get-team-members
|
(defn get-team-members
|
||||||
[conn team-id]
|
[conn team-id]
|
||||||
|
@ -333,6 +364,24 @@
|
||||||
(check-read-permissions! conn profile-id team-id)
|
(check-read-permissions! conn profile-id team-id)
|
||||||
(get-team-invitations conn team-id)))
|
(get-team-invitations conn team-id)))
|
||||||
|
|
||||||
|
|
||||||
|
;; --- COMMAND QUERY: get-team-info
|
||||||
|
|
||||||
|
(defn- get-team-info
|
||||||
|
[{:keys [::db/conn] :as cfg} {:keys [id] :as params}]
|
||||||
|
(db/get* conn :team
|
||||||
|
{:id id}
|
||||||
|
{::sql/columns [:id :is-default]}))
|
||||||
|
|
||||||
|
(sv/defmethod ::get-team-info
|
||||||
|
"Retrieve minimal team info by its ID."
|
||||||
|
{::rpc/auth false
|
||||||
|
::doc/added "2.2.0"
|
||||||
|
::sm/params schema:get-team}
|
||||||
|
[cfg params]
|
||||||
|
(db/tx-run! cfg get-team-info params))
|
||||||
|
|
||||||
|
|
||||||
;; --- Mutation: Create Team
|
;; --- Mutation: Create Team
|
||||||
|
|
||||||
(declare create-team)
|
(declare create-team)
|
||||||
|
@ -352,17 +401,19 @@
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::sm/params schema:create-team}
|
::sm/params schema:create-team}
|
||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
|
||||||
(quotes/check-quote! conn {::quotes/id ::quotes/teams-per-profile
|
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
||||||
::quotes/profile-id profile-id})
|
::quotes/profile-id profile-id})
|
||||||
|
|
||||||
(let [features (-> (cfeat/get-enabled-features cf/flags)
|
(let [features (-> (cfeat/get-enabled-features cf/flags)
|
||||||
(cfeat/check-client-features! (:features params)))
|
(cfeat/check-client-features! (:features params)))
|
||||||
team (create-team cfg (assoc params
|
params (-> params
|
||||||
:profile-id profile-id
|
(assoc :profile-id profile-id)
|
||||||
:features features))]
|
(assoc :features features))
|
||||||
|
team (db/tx-run! cfg create-team params)]
|
||||||
|
|
||||||
(with-meta team
|
(with-meta team
|
||||||
{::audit/props {:id (:id team)}})))))
|
{::audit/props {:id (:id team)}})))
|
||||||
|
|
||||||
(defn create-team
|
(defn create-team
|
||||||
"This is a complete team creation process, it creates the team
|
"This is a complete team creation process, it creates the team
|
||||||
|
@ -674,8 +725,7 @@
|
||||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||||
;; Validate incoming mime type
|
;; Validate incoming mime type
|
||||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||||
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
|
(update-team-photo cfg (assoc params :profile-id profile-id)))
|
||||||
(update-team-photo cfg (assoc params :profile-id profile-id))))
|
|
||||||
|
|
||||||
(defn update-team-photo
|
(defn update-team-photo
|
||||||
[{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id team-id] :as params}]
|
[{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||||
|
@ -717,36 +767,51 @@
|
||||||
:member-id member-id}))
|
:member-id member-id}))
|
||||||
|
|
||||||
(defn- create-profile-identity-token
|
(defn- create-profile-identity-token
|
||||||
[cfg profile]
|
[cfg profile-id]
|
||||||
|
|
||||||
|
(dm/assert!
|
||||||
|
"expected valid uuid for profile-id"
|
||||||
|
(uuid? profile-id))
|
||||||
|
|
||||||
(tokens/generate (::setup/props cfg)
|
(tokens/generate (::setup/props cfg)
|
||||||
{:iss :profile-identity
|
{:iss :profile-identity
|
||||||
:profile-id (:id profile)
|
:profile-id profile-id
|
||||||
:exp (dt/in-future {:days 30})}))
|
:exp (dt/in-future {:days 30})}))
|
||||||
|
|
||||||
|
(def ^:private schema:create-invitation
|
||||||
|
[:map {:title "params:create-invitation"}
|
||||||
|
[::rpc/profile-id ::sm/uuid]
|
||||||
|
[:team
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:name :string]]]
|
||||||
|
[:profile
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:fullname :string]]]
|
||||||
|
[:role [::sm/one-of valid-roles]]
|
||||||
|
[:email ::sm/email]])
|
||||||
|
|
||||||
|
(def ^:private check-create-invitation-params!
|
||||||
|
(sm/check-fn schema:create-invitation))
|
||||||
|
|
||||||
(defn- create-invitation
|
(defn- create-invitation
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}]
|
||||||
|
|
||||||
|
(dm/assert!
|
||||||
|
"expected valid connection on cfg parameter"
|
||||||
|
(db/connection? conn))
|
||||||
|
|
||||||
|
(dm/assert!
|
||||||
|
"expected valid params for `create-invitation` fn"
|
||||||
|
(check-create-invitation-params! params))
|
||||||
|
|
||||||
(let [email (profile/clean-email email)
|
(let [email (profile/clean-email email)
|
||||||
member (profile/get-profile-by-email conn email)]
|
member (profile/get-profile-by-email conn email)]
|
||||||
|
|
||||||
(when (and member (not (eml/allow-send-emails? conn member)))
|
(check-profile-muted conn member)
|
||||||
(ex/raise :type :validation
|
(check-email-bounce conn email true)
|
||||||
:code :member-is-muted
|
(check-email-spam conn email true)
|
||||||
:email email
|
|
||||||
:hint "the profile has reported repeatedly as spam or has bounces"))
|
|
||||||
|
|
||||||
;; Secondly check if the invited member email is part of the global bounce report.
|
|
||||||
(when (eml/has-bounce-reports? conn email)
|
|
||||||
(ex/raise :type :restriction
|
|
||||||
:code :email-has-permanent-bounces
|
|
||||||
:email email
|
|
||||||
:hint "the email you invite has been repeatedly reported as bounce"))
|
|
||||||
|
|
||||||
;; Secondly check if the invited member email is part of the global complain report.
|
|
||||||
(when (eml/has-complaint-reports? conn email)
|
|
||||||
(ex/raise :type :restriction
|
|
||||||
:code :email-has-complaints
|
|
||||||
:email email
|
|
||||||
:hint "the email you invite has been repeatedly reported as spam"))
|
|
||||||
|
|
||||||
;; When we have email verification disabled and invitation user is
|
;; When we have email verification disabled and invitation user is
|
||||||
;; already present in the database, we proceed to add it to the
|
;; already present in the database, we proceed to add it to the
|
||||||
|
@ -780,7 +845,8 @@
|
||||||
(name role) expire
|
(name role) expire
|
||||||
(name role) expire])
|
(name role) expire])
|
||||||
updated? (not= id (:id invitation))
|
updated? (not= id (:id invitation))
|
||||||
tprops {:profile-id (:id profile)
|
profile-id (:id profile)
|
||||||
|
tprops {:profile-id profile-id
|
||||||
:invitation-id (:id invitation)
|
:invitation-id (:id invitation)
|
||||||
:valid-until expire
|
:valid-until expire
|
||||||
:team-id (:id team)
|
:team-id (:id team)
|
||||||
|
@ -788,12 +854,11 @@
|
||||||
:member-id (:id member)
|
:member-id (:id member)
|
||||||
:role role}
|
:role role}
|
||||||
itoken (create-invitation-token cfg tprops)
|
itoken (create-invitation-token cfg tprops)
|
||||||
ptoken (create-profile-identity-token cfg profile)]
|
ptoken (create-profile-identity-token cfg profile-id)]
|
||||||
|
|
||||||
(when (contains? cf/flags :log-invitation-tokens)
|
(when (contains? cf/flags :log-invitation-tokens)
|
||||||
(l/info :hint "invitation token" :token itoken))
|
(l/info :hint "invitation token" :token itoken))
|
||||||
|
|
||||||
|
|
||||||
(let [props (-> (dissoc tprops :profile-id)
|
(let [props (-> (dissoc tprops :profile-id)
|
||||||
(audit/clean-props))
|
(audit/clean-props))
|
||||||
evname (if updated?
|
evname (if updated?
|
||||||
|
@ -815,63 +880,142 @@
|
||||||
|
|
||||||
itoken))))
|
itoken))))
|
||||||
|
|
||||||
|
(defn- add-user-to-team
|
||||||
|
[conn profile team role email]
|
||||||
|
|
||||||
|
(let [team-id (:id team)
|
||||||
|
member (db/get* conn :profile
|
||||||
|
{:email (str/lower email)}
|
||||||
|
{::sql/columns [:id :email]})
|
||||||
|
params (merge
|
||||||
|
{:team-id team-id
|
||||||
|
:profile-id (:id member)}
|
||||||
|
(role->params role))]
|
||||||
|
|
||||||
|
;; Do not allow blocked users to join teams.
|
||||||
|
(when (:is-blocked member)
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :profile-blocked))
|
||||||
|
|
||||||
|
(quotes/check!
|
||||||
|
{::db/conn conn
|
||||||
|
::quotes/id ::quotes/profiles-per-team
|
||||||
|
::quotes/profile-id (:id member)
|
||||||
|
::quotes/team-id team-id})
|
||||||
|
|
||||||
|
;; Insert the member to the team
|
||||||
|
(db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true})
|
||||||
|
|
||||||
|
;; Delete any request
|
||||||
|
(db/delete! conn :team-access-request
|
||||||
|
{:team-id team-id :requester-id (:id member)})
|
||||||
|
|
||||||
|
;; Delete any invitation
|
||||||
|
(db/delete! conn :team-invitation
|
||||||
|
{:team-id team-id :email-to (:email member)})
|
||||||
|
|
||||||
|
(eml/send! {::eml/conn conn
|
||||||
|
::eml/factory eml/join-team
|
||||||
|
:public-uri (cf/get :public-uri)
|
||||||
|
:to email
|
||||||
|
:invited-by (:fullname profile)
|
||||||
|
:team (:name team)
|
||||||
|
:team-id (:id team)})))
|
||||||
|
|
||||||
|
(def sql:valid-requests-email
|
||||||
|
"SELECT p.email
|
||||||
|
FROM team_access_request AS tr
|
||||||
|
JOIN profile AS p ON (tr.requester_id = p.id)
|
||||||
|
WHERE tr.team_id = ?
|
||||||
|
AND tr.auto_join_until > now()")
|
||||||
|
|
||||||
|
(defn- get-valid-requests-email
|
||||||
|
[conn team-id]
|
||||||
|
(db/exec! conn [sql:valid-requests-email team-id]))
|
||||||
|
|
||||||
|
(def ^:private xf:map-email
|
||||||
|
(map :email))
|
||||||
|
|
||||||
|
(defn- create-team-invitations
|
||||||
|
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails] :as params}]
|
||||||
|
(let [join-requests (into #{} xf:map-email
|
||||||
|
(get-valid-requests-email conn (:id team)))
|
||||||
|
team-members (into #{} xf:map-email
|
||||||
|
(get-team-members conn (:id team)))
|
||||||
|
|
||||||
|
invitations (into #{}
|
||||||
|
(comp
|
||||||
|
;; We don't re-send inviation to
|
||||||
|
;; already existing members
|
||||||
|
(remove team-members)
|
||||||
|
;; We don't send invitations to
|
||||||
|
;; join-requested members
|
||||||
|
(remove join-requests)
|
||||||
|
(map (fn [email] (assoc params :email email)))
|
||||||
|
(keep (partial create-invitation cfg)))
|
||||||
|
emails)]
|
||||||
|
|
||||||
|
;; For requested invitations, do not send invitation emails, add
|
||||||
|
;; the user directly to the team
|
||||||
|
(->> (filter join-requests emails)
|
||||||
|
(run! (partial add-user-to-team conn profile team role)))
|
||||||
|
|
||||||
|
invitations))
|
||||||
|
|
||||||
(def ^:private schema:create-team-invitations
|
(def ^:private schema:create-team-invitations
|
||||||
[:map {:title "create-team-invitations"}
|
[:map {:title "create-team-invitations"}
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
[:role schema:role]
|
[:role schema:role]
|
||||||
[:emails ::sm/set-of-emails]])
|
[:emails [::sm/set ::sm/email]]])
|
||||||
|
|
||||||
|
(def ^:private max-invitations-by-request-threshold
|
||||||
|
"The number of invitations can be sent in a single rpc request"
|
||||||
|
25)
|
||||||
|
|
||||||
(sv/defmethod ::create-team-invitations
|
(sv/defmethod ::create-team-invitations
|
||||||
"A rpc call that allow to send a single or multiple invitations to
|
"A rpc call that allow to send a single or multiple invitations to
|
||||||
join the team."
|
join the team."
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::sm/params schema:create-team-invitations}
|
::sm/params schema:create-team-invitations}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id emails role] :as params}]
|
[cfg {:keys [::rpc/profile-id team-id emails] :as params}]
|
||||||
(db/with-atomic [conn pool]
|
(let [perms (get-permissions cfg profile-id team-id)
|
||||||
(let [perms (get-permissions conn profile-id team-id)
|
profile (db/get-by-id cfg :profile profile-id)
|
||||||
profile (db/get-by-id conn :profile profile-id)
|
|
||||||
team (db/get-by-id conn :team team-id)
|
|
||||||
emails (into #{} (map profile/clean-email) emails)]
|
emails (into #{} (map profile/clean-email) emails)]
|
||||||
|
|
||||||
(run! (partial quotes/check-quote! conn)
|
|
||||||
(list {::quotes/id ::quotes/invitations-per-team
|
|
||||||
::quotes/profile-id profile-id
|
|
||||||
::quotes/team-id (:id team)
|
|
||||||
::quotes/incr (count emails)}
|
|
||||||
{::quotes/id ::quotes/profiles-per-team
|
|
||||||
::quotes/profile-id profile-id
|
|
||||||
::quotes/team-id (:id team)
|
|
||||||
::quotes/incr (count emails)}))
|
|
||||||
|
|
||||||
(when-not (:is-admin perms)
|
(when-not (:is-admin perms)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :insufficient-permissions))
|
:code :insufficient-permissions))
|
||||||
|
|
||||||
;; First check if the current profile is allowed to send emails.
|
(when (> (count emails) max-invitations-by-request-threshold)
|
||||||
(when-not (eml/allow-send-emails? conn profile)
|
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :profile-is-muted
|
:code :max-invitations-by-request
|
||||||
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
|
:hint "the maximum of invitation on single request is reached"
|
||||||
|
:threshold max-invitations-by-request-threshold))
|
||||||
|
|
||||||
(let [cfg (assoc cfg ::db/conn conn)
|
(-> cfg
|
||||||
members (->> (db/exec! conn [sql:team-members team-id])
|
(assoc ::quotes/profile-id profile-id)
|
||||||
(into #{} (map :email)))
|
(assoc ::quotes/team-id team-id)
|
||||||
|
(assoc ::quotes/incr (count emails))
|
||||||
|
(quotes/check! {::quotes/id ::quotes/invitations-per-team}
|
||||||
|
{::quotes/id ::quotes/profiles-per-team}))
|
||||||
|
|
||||||
invitations (into #{}
|
;; Check if the current profile is allowed to send emails
|
||||||
(comp
|
(check-profile-muted cfg profile)
|
||||||
;; We don't re-send inviation to already existing members
|
|
||||||
(remove (partial contains? members))
|
(let [team (db/get-by-id cfg :team team-id)
|
||||||
(map (fn [email]
|
;; NOTE: Is important pass RPC method params down to the
|
||||||
|
;; `create-team-invitations` because it uses the implicit
|
||||||
|
;; RPC properties from params for fill necessary data on
|
||||||
|
;; emiting an entry to the audit-log
|
||||||
|
invitations (db/tx-run! cfg create-team-invitations
|
||||||
(-> params
|
(-> params
|
||||||
(assoc :email email)
|
|
||||||
(assoc :team team)
|
|
||||||
(assoc :profile profile)
|
(assoc :profile profile)
|
||||||
(assoc :role role))))
|
(assoc :team team)
|
||||||
(keep (partial create-invitation cfg)))
|
(assoc :emails emails)))]
|
||||||
emails)]
|
|
||||||
(with-meta {:total (count invitations)
|
(with-meta {:total (count invitations)
|
||||||
:invitations invitations}
|
:invitations invitations}
|
||||||
{::audit/props {:invitations (count invitations)}})))))
|
{::audit/props {:invitations (count invitations)}}))))
|
||||||
|
|
||||||
;; --- Mutation: Create Team & Invite Members
|
;; --- Mutation: Create Team & Invite Members
|
||||||
|
|
||||||
|
@ -880,16 +1024,14 @@
|
||||||
[:name [:string {:max 250}]]
|
[:name [:string {:max 250}]]
|
||||||
[:features {:optional true} ::cfeat/features]
|
[:features {:optional true} ::cfeat/features]
|
||||||
[:id {:optional true} ::sm/uuid]
|
[:id {:optional true} ::sm/uuid]
|
||||||
[:emails ::sm/set-of-emails]
|
[:emails [::sm/set ::sm/email]]
|
||||||
[:role schema:role]])
|
[:role schema:role]])
|
||||||
|
|
||||||
(sv/defmethod ::create-team-with-invitations
|
(sv/defmethod ::create-team-with-invitations
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::sm/params schema:create-team-with-invitations}
|
::sm/params schema:create-team-with-invitations
|
||||||
[cfg {:keys [::rpc/profile-id emails role name] :as params}]
|
::db/transaction true}
|
||||||
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id emails role name] :as params}]
|
||||||
(db/tx-run! cfg
|
|
||||||
(fn [{:keys [::db/conn] :as cfg}]
|
|
||||||
(let [features (-> (cfeat/get-enabled-features cf/flags)
|
(let [features (-> (cfeat/get-enabled-features cf/flags)
|
||||||
(cfeat/check-client-features! (:features params)))
|
(cfeat/check-client-features! (:features params)))
|
||||||
|
|
||||||
|
@ -897,11 +1039,23 @@
|
||||||
(assoc :profile-id profile-id)
|
(assoc :profile-id profile-id)
|
||||||
(assoc :features features))
|
(assoc :features features))
|
||||||
|
|
||||||
cfg (assoc cfg ::db/conn conn)
|
|
||||||
team (create-team cfg params)
|
team (create-team cfg params)
|
||||||
profile (db/get-by-id conn :profile profile-id)
|
|
||||||
emails (into #{} (map profile/clean-email) emails)]
|
emails (into #{} (map profile/clean-email) emails)]
|
||||||
|
|
||||||
|
(-> cfg
|
||||||
|
(assoc ::quotes/profile-id profile-id)
|
||||||
|
(assoc ::quotes/team-id (:id team))
|
||||||
|
(assoc ::quotes/incr (count emails))
|
||||||
|
(quotes/check! {::quotes/id ::quotes/teams-per-profile}
|
||||||
|
{::quotes/id ::quotes/invitations-per-team}
|
||||||
|
{::quotes/id ::quotes/profiles-per-team}))
|
||||||
|
|
||||||
|
(when (> (count emails) max-invitations-by-request-threshold)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :max-invitations-by-request
|
||||||
|
:hint "the maximum of invitation on single request is reached"
|
||||||
|
:threshold max-invitations-by-request-threshold))
|
||||||
|
|
||||||
(let [props {:name name :features features}
|
(let [props {:name name :features features}
|
||||||
event (-> (audit/event-from-rpc-params params)
|
event (-> (audit/event-from-rpc-params params)
|
||||||
(assoc ::audit/name "create-team")
|
(assoc ::audit/name "create-team")
|
||||||
|
@ -909,28 +1063,16 @@
|
||||||
(audit/submit! cfg event))
|
(audit/submit! cfg event))
|
||||||
|
|
||||||
;; Create invitations for all provided emails.
|
;; Create invitations for all provided emails.
|
||||||
(->> emails
|
(let [profile (db/get-by-id conn :profile profile-id)
|
||||||
(map (fn [email]
|
params (-> params
|
||||||
(-> params
|
|
||||||
(assoc :team team)
|
(assoc :team team)
|
||||||
(assoc :profile profile)
|
(assoc :profile profile)
|
||||||
(assoc :email email)
|
(assoc :role role))
|
||||||
(assoc :role role))))
|
invitations (->> emails
|
||||||
(run! (partial create-invitation cfg)))
|
(map (fn [email] (assoc params :email email)))
|
||||||
|
(map (partial create-invitation cfg)))]
|
||||||
|
|
||||||
(run! (partial quotes/check-quote! conn)
|
(vary-meta team assoc ::audit/props {:invitations (count invitations)}))))
|
||||||
(list {::quotes/id ::quotes/teams-per-profile
|
|
||||||
::quotes/profile-id profile-id}
|
|
||||||
{::quotes/id ::quotes/invitations-per-team
|
|
||||||
::quotes/profile-id profile-id
|
|
||||||
::quotes/team-id (:id team)
|
|
||||||
::quotes/incr (count emails)}
|
|
||||||
{::quotes/id ::quotes/profiles-per-team
|
|
||||||
::quotes/profile-id profile-id
|
|
||||||
::quotes/team-id (:id team)
|
|
||||||
::quotes/incr (count emails)}))
|
|
||||||
|
|
||||||
(vary-meta team assoc ::audit/props {:invitations (count emails)})))))
|
|
||||||
|
|
||||||
;; --- Query: get-team-invitation-token
|
;; --- Query: get-team-invitation-token
|
||||||
|
|
||||||
|
@ -1007,3 +1149,130 @@
|
||||||
:email-to (profile/clean-email email)}
|
:email-to (profile/clean-email email)}
|
||||||
{::db/return-keys true})]
|
{::db/return-keys true})]
|
||||||
(rph/wrap nil {::audit/props {:invitation-id (:id invitation)}})))))
|
(rph/wrap nil {::audit/props {:invitation-id (:id invitation)}})))))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
;; --- Mutation: Request Team Invitation
|
||||||
|
|
||||||
|
(def sql:upsert-team-access-request
|
||||||
|
"INSERT INTO team_access_request (id, team_id, requester_id, valid_until, auto_join_until)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON conflict(id)
|
||||||
|
DO UPDATE SET valid_until = ?, auto_join_until = ?, updated_at = now()
|
||||||
|
RETURNING *")
|
||||||
|
|
||||||
|
|
||||||
|
(def sql:team-access-request
|
||||||
|
"SELECT id, (valid_until < now()) AS expired
|
||||||
|
FROM team_access_request
|
||||||
|
WHERE team_id = ?
|
||||||
|
AND requester_id = ?")
|
||||||
|
|
||||||
|
(def sql:team-owner
|
||||||
|
"SELECT profile_id
|
||||||
|
FROM team_profile_rel
|
||||||
|
WHERE team_id = ?
|
||||||
|
AND is_owner = true")
|
||||||
|
|
||||||
|
|
||||||
|
(defn- create-team-access-request
|
||||||
|
[{:keys [::db/conn] :as cfg} {:keys [team requester team-owner file is-viewer] :as params}]
|
||||||
|
(let [old-request (->> (db/exec-one! conn [sql:team-access-request (:id team) (:id requester)])
|
||||||
|
(decode-row))]
|
||||||
|
(when (false? (:expired old-request))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :request-already-sent
|
||||||
|
:hint "you have already made a request to join this team less than 24 hours ago"))
|
||||||
|
|
||||||
|
(let [id (or (:id old-request) (uuid/next))
|
||||||
|
valid_until (dt/in-future "24h")
|
||||||
|
auto_join_until (dt/in-future "168h") ;; 7 days
|
||||||
|
request (db/exec-one! conn [sql:upsert-team-access-request
|
||||||
|
id (:id team) (:id requester) valid_until auto_join_until
|
||||||
|
valid_until auto_join_until])
|
||||||
|
factory (cond
|
||||||
|
(and (some? file) (:is-default team) is-viewer)
|
||||||
|
eml/request-file-access-yourpenpot-view
|
||||||
|
(and (some? file) (:is-default team))
|
||||||
|
eml/request-file-access-yourpenpot
|
||||||
|
(some? file)
|
||||||
|
eml/request-file-access
|
||||||
|
:else
|
||||||
|
eml/request-team-access)
|
||||||
|
page-id (when (some? file)
|
||||||
|
(-> file :data :pages first))]
|
||||||
|
|
||||||
|
;; TODO needs audit?
|
||||||
|
|
||||||
|
(eml/send! {::eml/conn conn
|
||||||
|
::eml/factory factory
|
||||||
|
:public-uri (cf/get :public-uri)
|
||||||
|
:to (:email team-owner)
|
||||||
|
:requested-by (:fullname requester)
|
||||||
|
:requested-by-email (:email requester)
|
||||||
|
:team-name (:name team)
|
||||||
|
:team-id (:id team)
|
||||||
|
:file-name (:name file)
|
||||||
|
:file-id (:id file)
|
||||||
|
:page-id page-id})
|
||||||
|
|
||||||
|
request)))
|
||||||
|
|
||||||
|
|
||||||
|
(def ^:private schema:create-team-access-request
|
||||||
|
[:and
|
||||||
|
[:map {:title "create-team-access-request"}
|
||||||
|
[:file-id {:optional true} ::sm/uuid]
|
||||||
|
[:team-id {:optional true} ::sm/uuid]
|
||||||
|
[:is-viewer {:optional true} ::sm/boolean]]
|
||||||
|
|
||||||
|
[:fn (fn [params]
|
||||||
|
(or (contains? params :file-id)
|
||||||
|
(contains? params :team-id)))]])
|
||||||
|
|
||||||
|
|
||||||
|
(sv/defmethod ::create-team-access-request
|
||||||
|
"A rpc call that allow to request for an invitations to join the team."
|
||||||
|
{::doc/added "2.2.0"
|
||||||
|
::sm/params schema:create-team-access-request}
|
||||||
|
[cfg {:keys [::rpc/profile-id file-id team-id is-viewer] :as params}]
|
||||||
|
|
||||||
|
(db/tx-run! cfg
|
||||||
|
(fn [{:keys [::db/conn] :as cfg}]
|
||||||
|
|
||||||
|
(let [requester (db/get-by-id conn :profile profile-id)
|
||||||
|
team-id (if (some? team-id)
|
||||||
|
team-id
|
||||||
|
(:id (get-team-for-file conn file-id)))
|
||||||
|
team (db/get-by-id conn :team team-id)
|
||||||
|
owner-id (->> (db/exec! conn [sql:team-owner (:id team)])
|
||||||
|
(map decode-row)
|
||||||
|
(first)
|
||||||
|
:profile-id)
|
||||||
|
team-owner (db/get-by-id conn :profile owner-id)
|
||||||
|
file (when (some? file-id)
|
||||||
|
(db/get* conn :file
|
||||||
|
{:id file-id}
|
||||||
|
{::sql/columns [:id :name :data]}))
|
||||||
|
file (when (some? file)
|
||||||
|
(assoc file :data (blob/decode (:data file))))]
|
||||||
|
|
||||||
|
;;TODO needs quotes?
|
||||||
|
|
||||||
|
(when (or (nil? requester) (nil? team) (nil? team-owner) (and (some? file-id) (nil? file)))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-parameters))
|
||||||
|
|
||||||
|
;; Check that the requester is not muted
|
||||||
|
(check-profile-muted conn requester)
|
||||||
|
|
||||||
|
;; Check that the owner is not marked as bounce nor spam
|
||||||
|
(check-email-bounce conn (:email team-owner) false)
|
||||||
|
(check-email-spam conn (:email team-owner) true)
|
||||||
|
|
||||||
|
(let [request (create-team-access-request
|
||||||
|
cfg {:team team :requester requester :team-owner team-owner :file file :is-viewer is-viewer})]
|
||||||
|
(when request
|
||||||
|
(with-meta {:request request}
|
||||||
|
{::audit/props {:request 1}})))))))
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
(:require
|
(:require
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.db.sql :as-alias sql]
|
[app.db.sql :as-alias sql]
|
||||||
[app.http.session :as session]
|
[app.http.session :as session]
|
||||||
|
@ -29,21 +30,19 @@
|
||||||
|
|
||||||
(def ^:private schema:verify-token
|
(def ^:private schema:verify-token
|
||||||
[:map {:title "verify-token"}
|
[:map {:title "verify-token"}
|
||||||
[:token [:string {:max 1000}]]])
|
[:token [:string {:max 5000}]]])
|
||||||
|
|
||||||
(sv/defmethod ::verify-token
|
(sv/defmethod ::verify-token
|
||||||
{::rpc/auth false
|
{::rpc/auth false
|
||||||
::doc/added "1.15"
|
::doc/added "1.15"
|
||||||
::doc/module :auth
|
::doc/module :auth
|
||||||
::sm/params schema:verify-token}
|
::sm/params schema:verify-token}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [token] :as params}]
|
[cfg {:keys [token] :as params}]
|
||||||
(db/with-atomic [conn pool]
|
(let [claims (tokens/verify (::setup/props cfg) {:token token})]
|
||||||
(let [claims (tokens/verify (::setup/props cfg) {:token token})
|
(db/tx-run! cfg process-token params claims)))
|
||||||
cfg (assoc cfg :conn conn)]
|
|
||||||
(process-token cfg params claims))))
|
|
||||||
|
|
||||||
(defmethod process-token :change-email
|
(defmethod process-token :change-email
|
||||||
[{:keys [conn] :as cfg} _params {:keys [profile-id email] :as claims}]
|
[{:keys [::db/conn] :as cfg} _params {:keys [profile-id email] :as claims}]
|
||||||
(let [email (profile/clean-email email)]
|
(let [email (profile/clean-email email)]
|
||||||
(when (profile/get-profile-by-email conn email)
|
(when (profile/get-profile-by-email conn email)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
|
@ -59,7 +58,7 @@
|
||||||
::audit/profile-id profile-id})))
|
::audit/profile-id profile-id})))
|
||||||
|
|
||||||
(defmethod process-token :verify-email
|
(defmethod process-token :verify-email
|
||||||
[{:keys [conn] :as cfg} _ {:keys [profile-id] :as claims}]
|
[{:keys [::db/conn] :as cfg} _ {:keys [profile-id] :as claims}]
|
||||||
(let [profile (profile/get-profile conn profile-id)
|
(let [profile (profile/get-profile conn profile-id)
|
||||||
claims (assoc claims :profile profile)]
|
claims (assoc claims :profile profile)]
|
||||||
|
|
||||||
|
@ -80,22 +79,14 @@
|
||||||
::audit/profile-id (:id profile)}))))
|
::audit/profile-id (:id profile)}))))
|
||||||
|
|
||||||
(defmethod process-token :auth
|
(defmethod process-token :auth
|
||||||
[{:keys [conn] :as cfg} _params {:keys [profile-id] :as claims}]
|
[{:keys [::db/conn] :as cfg} _params {:keys [profile-id] :as claims}]
|
||||||
(let [profile (profile/get-profile conn profile-id {::sql/for-update true})
|
(let [profile (profile/get-profile conn profile-id)]
|
||||||
props (merge (:props profile)
|
(assoc claims :profile profile)))
|
||||||
(:props claims))]
|
|
||||||
(when (not= props (:props profile))
|
|
||||||
(db/update! conn :profile
|
|
||||||
{:props (db/tjson props)}
|
|
||||||
{:id profile-id}))
|
|
||||||
|
|
||||||
(let [profile (assoc profile :props props)]
|
|
||||||
(assoc claims :profile profile))))
|
|
||||||
|
|
||||||
;; --- Team Invitation
|
;; --- Team Invitation
|
||||||
|
|
||||||
(defn- accept-invitation
|
(defn- accept-invitation
|
||||||
[{:keys [conn] :as cfg} {:keys [team-id role member-email] :as claims} invitation member]
|
[{:keys [::db/conn] :as cfg} {:keys [team-id role member-email] :as claims} invitation member]
|
||||||
(let [;; Update the role if there is an invitation
|
(let [;; Update the role if there is an invitation
|
||||||
role (or (some-> invitation :role keyword) role)
|
role (or (some-> invitation :role keyword) role)
|
||||||
params (merge
|
params (merge
|
||||||
|
@ -108,8 +99,7 @@
|
||||||
(ex/raise :type :restriction
|
(ex/raise :type :restriction
|
||||||
:code :profile-blocked))
|
:code :profile-blocked))
|
||||||
|
|
||||||
(quotes/check-quote! conn
|
(quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
|
||||||
{::quotes/id ::quotes/profiles-per-team
|
|
||||||
::quotes/profile-id (:id member)
|
::quotes/profile-id (:id member)
|
||||||
::quotes/team-id team-id})
|
::quotes/team-id team-id})
|
||||||
|
|
||||||
|
@ -127,6 +117,10 @@
|
||||||
(db/delete! conn :team-invitation
|
(db/delete! conn :team-invitation
|
||||||
{:team-id team-id :email-to member-email})
|
{:team-id team-id :email-to member-email})
|
||||||
|
|
||||||
|
;; Delete any request
|
||||||
|
(db/delete! conn :team-access-request
|
||||||
|
{:team-id team-id :requester-id (:id member)})
|
||||||
|
|
||||||
(assoc member :is-active true)))
|
(assoc member :is-active true)))
|
||||||
|
|
||||||
(def schema:team-invitation-claims
|
(def schema:team-invitation-claims
|
||||||
|
@ -143,7 +137,7 @@
|
||||||
(sm/lazy-validator schema:team-invitation-claims))
|
(sm/lazy-validator schema:team-invitation-claims))
|
||||||
|
|
||||||
(defmethod process-token :team-invitation
|
(defmethod process-token :team-invitation
|
||||||
[{:keys [conn] :as cfg}
|
[{:keys [::db/conn] :as cfg}
|
||||||
{:keys [::rpc/profile-id token] :as params}
|
{:keys [::rpc/profile-id token] :as params}
|
||||||
{:keys [member-id team-id member-email] :as claims}]
|
{:keys [member-id team-id member-email] :as claims}]
|
||||||
|
|
||||||
|
@ -156,7 +150,8 @@
|
||||||
{:team-id team-id :email-to member-email})
|
{:team-id team-id :email-to member-email})
|
||||||
profile (db/get* conn :profile
|
profile (db/get* conn :profile
|
||||||
{:id profile-id}
|
{:id profile-id}
|
||||||
{:columns [:id :email]})]
|
{:columns [:id :email]})
|
||||||
|
registration-disabled? (not (contains? cf/flags :registration))]
|
||||||
(when (nil? invitation)
|
(when (nil? invitation)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :invalid-token
|
:code :invalid-token
|
||||||
|
@ -185,12 +180,12 @@
|
||||||
:hint "logged-in user does not matches the invitation"))
|
:hint "logged-in user does not matches the invitation"))
|
||||||
|
|
||||||
;; If we have not logged-in user, and invitation comes with member-id we
|
;; If we have not logged-in user, and invitation comes with member-id we
|
||||||
;; redirect user to login, if no memeber-id is present in the invitation
|
;; redirect user to login, if no memeber-id is present and in the invitation
|
||||||
;; token, we redirect user the the register page.
|
;; token and registration is enabled, we redirect user the the register page.
|
||||||
|
|
||||||
{:invitation-token token
|
{:invitation-token token
|
||||||
:iss :team-invitation
|
:iss :team-invitation
|
||||||
:redirect-to (if member-id :auth-login :auth-register)
|
:redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register)
|
||||||
:state :pending})))
|
:state :pending})))
|
||||||
|
|
||||||
;; --- Default
|
;; --- Default
|
||||||
|
|
|
@ -111,7 +111,7 @@
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:uri ::sm/uri]
|
[:uri ::sm/uri]
|
||||||
[:mtype [::sm/one-of {:format "string"} valid-mtypes]]
|
[:mtype [::sm/one-of {:format "string"} valid-mtypes]]
|
||||||
[:is-active :boolean]])
|
[:is-active ::sm/boolean]])
|
||||||
|
|
||||||
(sv/defmethod ::update-webhook
|
(sv/defmethod ::update-webhook
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
|
|
|
@ -48,20 +48,25 @@
|
||||||
(str "W/\"" (encode s) "\""))
|
(str "W/\"" (encode s) "\""))
|
||||||
|
|
||||||
(defn wrap
|
(defn wrap
|
||||||
[_ f {:keys [::get-object ::key-fn ::reuse-key?] :as mdata}]
|
[_ f {:keys [::get-object ::key-fn ::reuse-key?] :or {reuse-key? true} :as mdata}]
|
||||||
(if (and (ifn? get-object) (ifn? key-fn))
|
(if (and (ifn? get-object) (ifn? key-fn))
|
||||||
(do
|
(do
|
||||||
(l/trc :hint "instrumenting method" :service (::sv/name mdata))
|
(l/trc :hint "instrumenting method" :service (::sv/name mdata))
|
||||||
(fn [cfg {:keys [::key] :as params}]
|
(fn [cfg {:keys [::key] :as params}]
|
||||||
(if *enabled*
|
(if *enabled*
|
||||||
(let [key' (when (or key reuse-key?)
|
(let [object (when (some? key)
|
||||||
(some->> (get-object cfg params) (key-fn params) (fmt-key)))]
|
(get-object cfg params))
|
||||||
|
key' (when (some? object)
|
||||||
|
(->> object (key-fn params) (fmt-key)))]
|
||||||
(if (and (some? key) (= key key'))
|
(if (and (some? key) (= key key'))
|
||||||
(fn [_] {::rres/status 304})
|
(fn [_] {::rres/status 304})
|
||||||
(let [result (f cfg params)
|
(let [params (if (some? object)
|
||||||
|
(assoc params ::object object)
|
||||||
|
params)
|
||||||
|
result (f cfg params)
|
||||||
etag (or (and reuse-key? key')
|
etag (or (and reuse-key? key')
|
||||||
(some-> result meta ::key fmt-key)
|
(some->> result meta ::key fmt-key)
|
||||||
(some-> result key-fn fmt-key))]
|
(some->> result (key-fn params) fmt-key))]
|
||||||
(rph/with-header result "etag" etag))))
|
(rph/with-header result "etag" etag))))
|
||||||
(f cfg params))))
|
(f cfg params))))
|
||||||
f))
|
f))
|
||||||
|
|
|
@ -26,7 +26,6 @@
|
||||||
[clojure.spec.alpha :as s]
|
[clojure.spec.alpha :as s]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
[malli.transform :as mt]
|
|
||||||
[pretty-spec.core :as ps]
|
[pretty-spec.core :as ps]
|
||||||
[ring.response :as-alias rres]))
|
[ring.response :as-alias rres]))
|
||||||
|
|
||||||
|
@ -98,15 +97,18 @@
|
||||||
;; OPENAPI / SWAGGER (v3.1)
|
;; OPENAPI / SWAGGER (v3.1)
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def output-transformer
|
|
||||||
(mt/transformer
|
|
||||||
sm/default-transformer
|
|
||||||
(mt/key-transformer {:encode str/camel
|
|
||||||
:decode (comp keyword str/kebab)})))
|
|
||||||
|
|
||||||
(defn prepare-openapi-context
|
(defn prepare-openapi-context
|
||||||
[methods]
|
[methods]
|
||||||
(letfn [(gen-response-doc [tsx schema]
|
(let [definitions (atom {})
|
||||||
|
options {:registry sr/default-registry
|
||||||
|
::oapi/definitions-path "#/components/schemas/"
|
||||||
|
::oapi/definitions definitions}
|
||||||
|
|
||||||
|
output-transformer
|
||||||
|
(sm/json-transformer)
|
||||||
|
|
||||||
|
gen-response-doc
|
||||||
|
(fn [tsx schema]
|
||||||
(let [schema (sm/schema schema)
|
(let [schema (sm/schema schema)
|
||||||
example (sm/generate schema)
|
example (sm/generate schema)
|
||||||
example (sm/encode schema example output-transformer)]
|
example (sm/encode schema example output-transformer)]
|
||||||
|
@ -117,7 +119,8 @@
|
||||||
{:schema tsx
|
{:schema tsx
|
||||||
:example example}}}}))
|
:example example}}}}))
|
||||||
|
|
||||||
(gen-params-doc [tsx schema]
|
gen-params-doc
|
||||||
|
(fn [tsx schema]
|
||||||
(let [example (sm/generate schema)
|
(let [example (sm/generate schema)
|
||||||
example (sm/encode schema example output-transformer)]
|
example (sm/encode schema example output-transformer)]
|
||||||
{:required true
|
{:required true
|
||||||
|
@ -126,7 +129,8 @@
|
||||||
{:schema tsx
|
{:schema tsx
|
||||||
:example example}}}))
|
:example example}}}))
|
||||||
|
|
||||||
(gen-method-doc [options mdata]
|
gen-method-doc
|
||||||
|
(fn [mdata]
|
||||||
(let [pschema (::sm/params mdata)
|
(let [pschema (::sm/params mdata)
|
||||||
rschema (::sm/result mdata)
|
rschema (::sm/result mdata)
|
||||||
|
|
||||||
|
@ -143,22 +147,19 @@
|
||||||
|
|
||||||
{:name (-> mdata ::sv/name d/name)
|
{:name (-> mdata ::sv/name d/name)
|
||||||
:module (-> (:ns mdata) (str/split ".") last)
|
:module (-> (:ns mdata) (str/split ".") last)
|
||||||
:repr {:post rpost}}))]
|
:repr {:post rpost}}))
|
||||||
|
|
||||||
(let [definitions (atom {})
|
paths
|
||||||
options {:registry sr/default-registry
|
(binding [oapi/*definitions* definitions]
|
||||||
::oapi/definitions-path "#/components/schemas/"
|
|
||||||
::oapi/definitions definitions}
|
|
||||||
|
|
||||||
paths (binding [oapi/*definitions* definitions]
|
|
||||||
(->> methods
|
(->> methods
|
||||||
(map (comp first val))
|
(map (comp first val))
|
||||||
(filter ::sm/params)
|
(filter ::sm/params)
|
||||||
(map (partial gen-method-doc options))
|
(map gen-method-doc)
|
||||||
(sort-by (juxt :module :name))
|
(sort-by (juxt :module :name))
|
||||||
(map (fn [doc]
|
(map (fn [doc]
|
||||||
[(str/ffmt "/command/%" (:name doc)) (:repr doc)]))
|
[(str/ffmt "/command/%" (:name doc)) (:repr doc)]))
|
||||||
(into {})))]
|
(into {})))]
|
||||||
|
|
||||||
{:openapi "3.0.0"
|
{:openapi "3.0.0"
|
||||||
:info {:version (:main cf/version)}
|
:info {:version (:main cf/version)}
|
||||||
:servers [{:url (str/ffmt "%/api/rpc" (cf/get :public-uri))
|
:servers [{:url (str/ffmt "%/api/rpc" (cf/get :public-uri))
|
||||||
|
@ -168,7 +169,7 @@
|
||||||
{:api_key []}
|
{:api_key []}
|
||||||
|
|
||||||
:paths paths
|
:paths paths
|
||||||
:components {:schemas @definitions}})))
|
:components {:schemas @definitions}}))
|
||||||
|
|
||||||
(defn openapi-json-handler
|
(defn openapi-json-handler
|
||||||
[context]
|
[context]
|
||||||
|
|
|
@ -15,11 +15,11 @@
|
||||||
(sm/register! ::permissions
|
(sm/register! ::permissions
|
||||||
[:map {:title "Permissions"}
|
[:map {:title "Permissions"}
|
||||||
[:type {:gen/elements [:membership :share-link]} :keyword]
|
[:type {:gen/elements [:membership :share-link]} :keyword]
|
||||||
[:is-owner :boolean]
|
[:is-owner ::sm/boolean]
|
||||||
[:is-admin :boolean]
|
[:is-admin ::sm/boolean]
|
||||||
[:can-edit :boolean]
|
[:can-edit ::sm/boolean]
|
||||||
[:can-read :boolean]
|
[:can-read ::sm/boolean]
|
||||||
[:is-logged :boolean]])
|
[:is-logged ::sm/boolean]])
|
||||||
|
|
||||||
|
|
||||||
(s/def ::role #{:admin :owner :editor :viewer})
|
(s/def ::role #{:admin :owner :editor :viewer})
|
||||||
|
|
|
@ -7,16 +7,13 @@
|
||||||
(ns app.rpc.quotes
|
(ns app.rpc.quotes
|
||||||
"Penpot resource usage quotes."
|
"Penpot resource usage quotes."
|
||||||
(:require
|
(:require
|
||||||
[app.common.data.macros :as dm]
|
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.spec :as us]
|
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.util.time :as dt]
|
[app.util.time :as dt]
|
||||||
[app.worker :as wrk]
|
[app.worker :as wrk]
|
||||||
[clojure.spec.alpha :as s]
|
|
||||||
[cuerdas.core :as str]))
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
(defmulti check-quote ::id)
|
(defmulti check-quote ::id)
|
||||||
|
@ -26,14 +23,16 @@
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private schema:quote
|
(def ^:private schema:quote
|
||||||
(sm/define
|
|
||||||
[:map {:title "Quote"}
|
[:map {:title "Quote"}
|
||||||
[::team-id {:optional true} ::sm/uuid]
|
[::team-id {:optional true} ::sm/uuid]
|
||||||
[::project-id {:optional true} ::sm/uuid]
|
[::project-id {:optional true} ::sm/uuid]
|
||||||
[::file-id {:optional true} ::sm/uuid]
|
[::file-id {:optional true} ::sm/uuid]
|
||||||
[::incr {:optional true} [:int {:min 0}]]
|
[::incr {:optional true} [::sm/int {:min 0}]]
|
||||||
[::id :keyword]
|
[::id :keyword]
|
||||||
[::profile-id ::sm/uuid]]))
|
[::profile-id ::sm/uuid]])
|
||||||
|
|
||||||
|
(def valid-quote?
|
||||||
|
(sm/lazy-validator schema:quote))
|
||||||
|
|
||||||
(def ^:private enabled (volatile! true))
|
(def ^:private enabled (volatile! true))
|
||||||
|
|
||||||
|
@ -47,20 +46,31 @@
|
||||||
[]
|
[]
|
||||||
(vswap! enabled (constantly false)))
|
(vswap! enabled (constantly false)))
|
||||||
|
|
||||||
(defn check-quote!
|
(defn- check
|
||||||
[ds quote]
|
[cfg quote]
|
||||||
(dm/assert!
|
(let [quote (merge cfg quote)
|
||||||
"expected valid quote map"
|
id (::id quote)]
|
||||||
(sm/validate schema:quote quote))
|
|
||||||
|
|
||||||
|
(when-not (valid-quote? quote)
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :invalid-quote-definition
|
||||||
|
:hint "found invalid data for quote schema"
|
||||||
|
:quote (name id)))
|
||||||
|
|
||||||
|
(-> (assoc quote ::target (name id))
|
||||||
|
(check-quote))))
|
||||||
|
|
||||||
|
(defn check!
|
||||||
|
([cfg]
|
||||||
(when (contains? cf/flags :quotes)
|
(when (contains? cf/flags :quotes)
|
||||||
(when @enabled
|
(when @enabled
|
||||||
;; This approach add flexibility on how and where the
|
(db/run! cfg check {}))))
|
||||||
;; check-quote! can be called (in or out of transaction)
|
|
||||||
(db/run! ds (fn [cfg]
|
([cfg & others]
|
||||||
(-> (merge cfg quote)
|
(when (contains? cf/flags :quotes)
|
||||||
(assoc ::target (name (::id quote)))
|
(when @enabled
|
||||||
(check-quote)))))))
|
(db/run! cfg (fn [cfg]
|
||||||
|
(run! (partial check cfg) others)))))))
|
||||||
|
|
||||||
(defn- send-notification!
|
(defn- send-notification!
|
||||||
[{:keys [::db/conn] :as params}]
|
[{:keys [::db/conn] :as params}]
|
||||||
|
@ -101,7 +111,7 @@
|
||||||
(map :quote)
|
(map :quote)
|
||||||
(reduce max (- Integer/MAX_VALUE)))
|
(reduce max (- Integer/MAX_VALUE)))
|
||||||
quote (if (pos? quote) quote default)
|
quote (if (pos? quote) quote default)
|
||||||
total (->> (db/exec! conn count-sql) first :total)]
|
total (:total (db/exec-one! conn count-sql))]
|
||||||
|
|
||||||
(when (> (+ total incr) quote)
|
(when (> (+ total incr) quote)
|
||||||
(if (contains? cf/flags :soft-quotes)
|
(if (contains? cf/flags :soft-quotes)
|
||||||
|
@ -113,72 +123,81 @@
|
||||||
:count total)))))
|
:count total)))))
|
||||||
|
|
||||||
(def ^:private sql:get-quotes-1
|
(def ^:private sql:get-quotes-1
|
||||||
"select id, quote from usage_quote
|
"SELECT id, quote
|
||||||
where target = ?
|
FROM usage_quote
|
||||||
and profile_id = ?
|
WHERE target = ?
|
||||||
and team_id is null
|
AND profile_id = ?
|
||||||
and project_id is null
|
AND team_id IS NULL
|
||||||
and file_id is null;")
|
AND project_id IS NULL
|
||||||
|
AND file_id IS NULL;")
|
||||||
|
|
||||||
(def ^:private sql:get-quotes-2
|
(def ^:private sql:get-quotes-2
|
||||||
"select id, quote from usage_quote
|
"SELECT id, quote
|
||||||
where target = ?
|
FROM usage_quote
|
||||||
and ((team_id = ? and (profile_id = ? or profile_id is null)) or
|
WHERE target = ?
|
||||||
(profile_id = ? and team_id is null and project_id is null and file_id is null));")
|
AND ((team_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
|
||||||
|
(profile_id = ? AND team_id IS NULL AND project_id IS NULL AND file_id IS NULL));")
|
||||||
|
|
||||||
(def ^:private sql:get-quotes-3
|
(def ^:private sql:get-quotes-3
|
||||||
"select id, quote from usage_quote
|
"SELECT id, quote
|
||||||
where target = ?
|
FROM usage_quote
|
||||||
and ((project_id = ? and (profile_id = ? or profile_id is null)) or
|
WHERE target = ?
|
||||||
(team_id = ? and (profile_id = ? or profile_id is null)) or
|
AND ((project_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
|
||||||
(profile_id = ? and team_id is null and project_id is null and file_id is null));")
|
(team_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
|
||||||
|
(profile_id = ? AND team_id IS NULL AND project_id IS NULL AND file_id IS NULL));")
|
||||||
|
|
||||||
(def ^:private sql:get-quotes-4
|
(def ^:private sql:get-quotes-4
|
||||||
"select id, quote from usage_quote
|
"SELECT id, quote
|
||||||
where target = ?
|
FROM usage_quote
|
||||||
and ((file_id = ? and (profile_id = ? or profile_id is null)) or
|
WHERE target = ?
|
||||||
(project_id = ? and (profile_id = ? or profile_id is null)) or
|
AND ((file_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
|
||||||
(team_id = ? and (profile_id = ? or profile_id is null)) or
|
(project_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
|
||||||
(profile_id = ? and team_id is null and project_id is null and file_id is null));")
|
(team_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
|
||||||
|
(profile_id = ? AND team_id IS NULL AND project_id IS NULL AND file_id IS NULL));")
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; QUOTE: TEAMS-PER-PROFILE
|
;; QUOTE: TEAMS-PER-PROFILE
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private sql:get-teams-per-profile
|
(def ^:private schema:teams-per-profile
|
||||||
"select count(*) as total
|
[:map [::profile-id ::sm/uuid]])
|
||||||
from team_profile_rel
|
|
||||||
where profile_id = ?")
|
|
||||||
|
|
||||||
(s/def ::profile-id ::us/uuid)
|
(def ^:private valid-teams-per-profile-quote?
|
||||||
(s/def ::teams-per-profile
|
(sm/lazy-validator schema:teams-per-profile))
|
||||||
(s/keys :req [::profile-id ::target]))
|
|
||||||
|
(def ^:private sql:get-teams-per-profile
|
||||||
|
"SELECT count(*) AS total
|
||||||
|
FROM team_profile_rel
|
||||||
|
WHERE profile_id = ?")
|
||||||
|
|
||||||
(defmethod check-quote ::teams-per-profile
|
(defmethod check-quote ::teams-per-profile
|
||||||
[{:keys [::profile-id ::target] :as quote}]
|
[{:keys [::profile-id ::target] :as quote}]
|
||||||
(us/assert! ::teams-per-profile quote)
|
(assert (valid-teams-per-profile-quote? quote) "invalid quote parameters")
|
||||||
(-> quote
|
(-> quote
|
||||||
(assoc ::default (cf/get :quotes-teams-per-profile Integer/MAX_VALUE))
|
(assoc ::default (cf/get :quotes-teams-per-profile Integer/MAX_VALUE))
|
||||||
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
|
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
|
||||||
(assoc ::count-sql [sql:get-teams-per-profile profile-id])
|
(assoc ::count-sql [sql:get-teams-per-profile profile-id])
|
||||||
(generic-check!)))
|
(generic-check!)))
|
||||||
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; QUOTE: ACCESS-TOKENS-PER-PROFILE
|
;; QUOTE: ACCESS-TOKENS-PER-PROFILE
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private sql:get-access-tokens-per-profile
|
(def ^:private schema:access-tokens-per-profile
|
||||||
"select count(*) as total
|
[:map [::profile-id ::sm/uuid]])
|
||||||
from access_token
|
|
||||||
where profile_id = ?")
|
|
||||||
|
|
||||||
(s/def ::access-tokens-per-profile
|
(def ^:private valid-access-tokens-per-profile-quote?
|
||||||
(s/keys :req [::profile-id ::target]))
|
(sm/lazy-validator schema:access-tokens-per-profile))
|
||||||
|
|
||||||
|
(def ^:private sql:get-access-tokens-per-profile
|
||||||
|
"SELECT count(*) AS total
|
||||||
|
FROM access_token
|
||||||
|
WHERE profile_id = ?")
|
||||||
|
|
||||||
(defmethod check-quote ::access-tokens-per-profile
|
(defmethod check-quote ::access-tokens-per-profile
|
||||||
[{:keys [::profile-id ::target] :as quote}]
|
[{:keys [::profile-id ::target] :as quote}]
|
||||||
(us/assert! ::access-tokens-per-profile quote)
|
(assert (valid-access-tokens-per-profile-quote? quote) "invalid quote parameters")
|
||||||
|
|
||||||
(-> quote
|
(-> quote
|
||||||
(assoc ::default (cf/get :quotes-access-tokens-per-profile Integer/MAX_VALUE))
|
(assoc ::default (cf/get :quotes-access-tokens-per-profile Integer/MAX_VALUE))
|
||||||
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
|
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
|
||||||
|
@ -189,40 +208,51 @@
|
||||||
;; QUOTE: PROJECTS-PER-TEAM
|
;; QUOTE: PROJECTS-PER-TEAM
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private sql:get-projects-per-team
|
(def ^:private schema:projects-per-team
|
||||||
"select count(*) as total
|
[:map
|
||||||
from project as p
|
[::profile-id ::sm/uuid]
|
||||||
where p.team_id = ?
|
[::team-id ::sm/uuid]])
|
||||||
and p.deleted_at is null")
|
|
||||||
|
|
||||||
(s/def ::team-id ::us/uuid)
|
(def ^:private valid-projects-per-team-quote?
|
||||||
(s/def ::projects-per-team
|
(sm/lazy-validator schema:projects-per-team))
|
||||||
(s/keys :req [::profile-id ::team-id ::target]))
|
|
||||||
|
(def ^:private sql:get-projects-per-team
|
||||||
|
"SELECT count(*) AS total
|
||||||
|
FROM project AS p
|
||||||
|
WHERE p.team_id = ?
|
||||||
|
AND p.deleted_at IS NULL")
|
||||||
|
|
||||||
(defmethod check-quote ::projects-per-team
|
(defmethod check-quote ::projects-per-team
|
||||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||||
|
(assert (valid-projects-per-team-quote? quote) "invalid quote parameters")
|
||||||
|
|
||||||
(-> quote
|
(-> quote
|
||||||
(assoc ::default (cf/get :quotes-projects-per-team Integer/MAX_VALUE))
|
(assoc ::default (cf/get :quotes-projects-per-team Integer/MAX_VALUE))
|
||||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||||
(assoc ::count-sql [sql:get-projects-per-team team-id])
|
(assoc ::count-sql [sql:get-projects-per-team team-id])
|
||||||
(generic-check!)))
|
(generic-check!)))
|
||||||
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; QUOTE: FONT-VARIANTS-PER-TEAM
|
;; QUOTE: FONT-VARIANTS-PER-TEAM
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private sql:get-font-variants-per-team
|
(def ^:private schema:font-variants-per-team
|
||||||
"select count(*) as total
|
[:map
|
||||||
from team_font_variant as v
|
[::profile-id ::sm/uuid]
|
||||||
where v.team_id = ?")
|
[::team-id ::sm/uuid]])
|
||||||
|
|
||||||
(s/def ::font-variants-per-team
|
(def ^:private valid-font-variant-per-team-quote?
|
||||||
(s/keys :req [::profile-id ::team-id ::target]))
|
(sm/lazy-validator schema:font-variants-per-team))
|
||||||
|
|
||||||
|
(def ^:private sql:get-font-variants-per-team
|
||||||
|
"SELECT count(*) AS total
|
||||||
|
FROM team_font_variant AS v
|
||||||
|
WHERE v.team_id = ?")
|
||||||
|
|
||||||
(defmethod check-quote ::font-variants-per-team
|
(defmethod check-quote ::font-variants-per-team
|
||||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||||
(us/assert! ::font-variants-per-team quote)
|
(assert (valid-font-variant-per-team-quote? quote) "invalid quote parameters")
|
||||||
|
|
||||||
(-> quote
|
(-> quote
|
||||||
(assoc ::default (cf/get :quotes-font-variants-per-team Integer/MAX_VALUE))
|
(assoc ::default (cf/get :quotes-font-variants-per-team Integer/MAX_VALUE))
|
||||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||||
|
@ -234,70 +264,86 @@
|
||||||
;; QUOTE: INVITATIONS-PER-TEAM
|
;; QUOTE: INVITATIONS-PER-TEAM
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private sql:get-invitations-per-team
|
(def ^:private schema:invitations-per-team
|
||||||
"select count(*) as total
|
[:map
|
||||||
from team_invitation
|
[::profile-id ::sm/uuid]
|
||||||
where team_id = ?")
|
[::team-id ::sm/uuid]])
|
||||||
|
|
||||||
(s/def ::invitations-per-team
|
(def ^:private valid-invitations-per-team-quote?
|
||||||
(s/keys :req [::profile-id ::team-id ::target]))
|
(sm/lazy-validator schema:invitations-per-team))
|
||||||
|
|
||||||
|
(def ^:private sql:get-invitations-per-team
|
||||||
|
"SELECT count(*) AS total
|
||||||
|
FROM team_invitation
|
||||||
|
WHERE team_id = ?")
|
||||||
|
|
||||||
(defmethod check-quote ::invitations-per-team
|
(defmethod check-quote ::invitations-per-team
|
||||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||||
(us/assert! ::invitations-per-team quote)
|
(assert (valid-invitations-per-team-quote? quote) "invalid quote parameters")
|
||||||
|
|
||||||
(-> quote
|
(-> quote
|
||||||
(assoc ::default (cf/get :quotes-invitations-per-team Integer/MAX_VALUE))
|
(assoc ::default (cf/get :quotes-invitations-per-team Integer/MAX_VALUE))
|
||||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||||
(assoc ::count-sql [sql:get-invitations-per-team team-id])
|
(assoc ::count-sql [sql:get-invitations-per-team team-id])
|
||||||
(generic-check!)))
|
(generic-check!)))
|
||||||
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; QUOTE: PROFILES-PER-TEAM
|
;; QUOTE: PROFILES-PER-TEAM
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(def ^:private schema:profiles-per-team
|
||||||
|
[:map
|
||||||
|
[::profile-id ::sm/uuid]
|
||||||
|
[::team-id ::sm/uuid]])
|
||||||
|
|
||||||
|
(def ^:private valid-profiles-per-team-quote?
|
||||||
|
(sm/lazy-validator schema:profiles-per-team))
|
||||||
|
|
||||||
(def ^:private sql:get-profiles-per-team
|
(def ^:private sql:get-profiles-per-team
|
||||||
"select (select count(*)
|
"SELECT (SELECT count(*)
|
||||||
from team_profile_rel
|
FROM team_profile_rel
|
||||||
where team_id = ?) +
|
WHERE team_id = ?) +
|
||||||
(select count(*)
|
(SELECT count(*)
|
||||||
from team_invitation
|
FROM team_invitation
|
||||||
where team_id = ?
|
WHERE team_id = ?
|
||||||
and valid_until > now()) as total;")
|
AND valid_until > now()) AS total;")
|
||||||
|
|
||||||
;; NOTE: the total number of profiles is determined by the number of
|
;; NOTE: the total number of profiles is determined by the number of
|
||||||
;; effective members plus ongoing valid invitations.
|
;; effective members plus ongoing valid invitations.
|
||||||
|
|
||||||
(s/def ::profiles-per-team
|
|
||||||
(s/keys :req [::profile-id ::team-id ::target]))
|
|
||||||
|
|
||||||
(defmethod check-quote ::profiles-per-team
|
(defmethod check-quote ::profiles-per-team
|
||||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||||
(us/assert! ::profiles-per-team quote)
|
(assert (valid-profiles-per-team-quote? quote) "invalid quote parameters")
|
||||||
|
|
||||||
(-> quote
|
(-> quote
|
||||||
(assoc ::default (cf/get :quotes-profiles-per-team Integer/MAX_VALUE))
|
(assoc ::default (cf/get :quotes-profiles-per-team Integer/MAX_VALUE))
|
||||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||||
(assoc ::count-sql [sql:get-profiles-per-team team-id team-id])
|
(assoc ::count-sql [sql:get-profiles-per-team team-id team-id])
|
||||||
(generic-check!)))
|
(generic-check!)))
|
||||||
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; QUOTE: FILES-PER-PROJECT
|
;; QUOTE: FILES-PER-PROJECT
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private sql:get-files-per-project
|
(def ^:private schema:files-per-project
|
||||||
"select count(*) as total
|
[:map
|
||||||
from file as f
|
[::profile-id ::sm/uuid]
|
||||||
where f.project_id = ?
|
[::project-id ::sm/uuid]
|
||||||
and f.deleted_at is null")
|
[::team-id ::sm/uuid]])
|
||||||
|
|
||||||
(s/def ::project-id ::us/uuid)
|
(def ^:private valid-files-per-project-quote?
|
||||||
(s/def ::files-per-project
|
(sm/lazy-validator schema:files-per-project))
|
||||||
(s/keys :req [::profile-id ::project-id ::team-id ::target]))
|
|
||||||
|
(def ^:private sql:get-files-per-project
|
||||||
|
"SELECT count(*) AS total
|
||||||
|
FROM file AS f
|
||||||
|
WHERE f.project_id = ?
|
||||||
|
AND f.deleted_at IS NULL")
|
||||||
|
|
||||||
(defmethod check-quote ::files-per-project
|
(defmethod check-quote ::files-per-project
|
||||||
[{:keys [::profile-id ::project-id ::team-id ::target] :as quote}]
|
[{:keys [::profile-id ::project-id ::team-id ::target] :as quote}]
|
||||||
(us/assert! ::files-per-project quote)
|
(assert (valid-files-per-project-quote? quote) "invalid quote parameters")
|
||||||
|
|
||||||
(-> quote
|
(-> quote
|
||||||
(assoc ::default (cf/get :quotes-files-per-project Integer/MAX_VALUE))
|
(assoc ::default (cf/get :quotes-files-per-project Integer/MAX_VALUE))
|
||||||
(assoc ::quote-sql [sql:get-quotes-3 target project-id profile-id team-id profile-id profile-id])
|
(assoc ::quote-sql [sql:get-quotes-3 target project-id profile-id team-id profile-id profile-id])
|
||||||
|
@ -308,17 +354,24 @@
|
||||||
;; QUOTE: COMMENT-THREADS-PER-FILE
|
;; QUOTE: COMMENT-THREADS-PER-FILE
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private sql:get-comment-threads-per-file
|
(def ^:private schema:comment-threads-per-file
|
||||||
"select count(*) as total
|
[:map
|
||||||
from comment_thread as ct
|
[::profile-id ::sm/uuid]
|
||||||
where ct.file_id = ?")
|
[::project-id ::sm/uuid]
|
||||||
|
[::team-id ::sm/uuid]])
|
||||||
|
|
||||||
(s/def ::comment-threads-per-file
|
(def ^:private valid-comment-threads-per-file-quote?
|
||||||
(s/keys :req [::profile-id ::project-id ::team-id ::target]))
|
(sm/lazy-validator schema:comment-threads-per-file))
|
||||||
|
|
||||||
|
(def ^:private sql:get-comment-threads-per-file
|
||||||
|
"SELECT count(*) AS total
|
||||||
|
FROM comment_thread AS ct
|
||||||
|
WHERE ct.file_id = ?")
|
||||||
|
|
||||||
(defmethod check-quote ::comment-threads-per-file
|
(defmethod check-quote ::comment-threads-per-file
|
||||||
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
|
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
|
||||||
(us/assert! ::files-per-project quote)
|
(assert (valid-comment-threads-per-file-quote? quote) "invalid quote parameters")
|
||||||
|
|
||||||
(-> quote
|
(-> quote
|
||||||
(assoc ::default (cf/get :quotes-comment-threads-per-file Integer/MAX_VALUE))
|
(assoc ::default (cf/get :quotes-comment-threads-per-file Integer/MAX_VALUE))
|
||||||
(assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
|
(assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
|
||||||
|
@ -326,23 +379,28 @@
|
||||||
(assoc ::count-sql [sql:get-comment-threads-per-file file-id])
|
(assoc ::count-sql [sql:get-comment-threads-per-file file-id])
|
||||||
(generic-check!)))
|
(generic-check!)))
|
||||||
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; QUOTE: COMMENTS-PER-FILE
|
;; QUOTE: COMMENTS-PER-FILE
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private sql:get-comments-per-file
|
(def ^:private schema:comments-per-file
|
||||||
"select count(*) as total
|
[:map
|
||||||
from comment as c
|
[::profile-id ::sm/uuid]
|
||||||
join comment_thread as ct on (ct.id = c.thread_id)
|
[::project-id ::sm/uuid]
|
||||||
where ct.file_id = ?")
|
[::team-id ::sm/uuid]])
|
||||||
|
|
||||||
(s/def ::comments-per-file
|
(def ^:private valid-comments-per-file-quote?
|
||||||
(s/keys :req [::profile-id ::project-id ::team-id ::target]))
|
(sm/lazy-validator schema:comments-per-file))
|
||||||
|
|
||||||
|
(def ^:private sql:get-comments-per-file
|
||||||
|
"SELECT count(*) AS total
|
||||||
|
FROM comment AS c
|
||||||
|
JOIN comment_thread AS ct ON (ct.id = c.thread_id)
|
||||||
|
WHERE ct.file_id = ?")
|
||||||
|
|
||||||
(defmethod check-quote ::comments-per-file
|
(defmethod check-quote ::comments-per-file
|
||||||
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
|
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
|
||||||
(us/assert! ::files-per-project quote)
|
(assert (valid-comments-per-file-quote? quote) "invalid quote parameters")
|
||||||
(-> quote
|
(-> quote
|
||||||
(assoc ::default (cf/get :quotes-comments-per-file Integer/MAX_VALUE))
|
(assoc ::default (cf/get :quotes-comments-per-file Integer/MAX_VALUE))
|
||||||
(assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
|
(assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
|
@ -19,28 +20,26 @@
|
||||||
[datoteka.fs :as fs]
|
[datoteka.fs :as fs]
|
||||||
[integrant.core :as ig]))
|
[integrant.core :as ig]))
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private schema:template
|
||||||
schema:template
|
|
||||||
(sm/define
|
|
||||||
[:map {:title "Template"}
|
[:map {:title "Template"}
|
||||||
[:id ::sm/word-string]
|
[:id ::sm/word-string]
|
||||||
[:name ::sm/word-string]
|
[:name ::sm/word-string]
|
||||||
[:file-uri ::sm/word-string]]))
|
[:file-uri ::sm/word-string]])
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private schema:templates
|
||||||
schema:templates
|
[:vector schema:template])
|
||||||
(sm/define
|
|
||||||
[:vector schema:template]))
|
(def check-templates!
|
||||||
|
(sm/check-fn schema:templates
|
||||||
|
:code :invalid-templates
|
||||||
|
:hint "invalid templates"))
|
||||||
|
|
||||||
(defmethod ig/init-key ::setup/templates
|
(defmethod ig/init-key ::setup/templates
|
||||||
[_ _]
|
[_ _]
|
||||||
(let [templates (-> "app/onboarding.edn" io/resource slurp edn/read-string)
|
(let [templates (-> "app/onboarding.edn" io/resource slurp edn/read-string)
|
||||||
|
templates (check-templates! templates)
|
||||||
dest (fs/join fs/*cwd* "builtin-templates")]
|
dest (fs/join fs/*cwd* "builtin-templates")]
|
||||||
|
|
||||||
(dm/verify!
|
|
||||||
"expected a valid templates file"
|
|
||||||
(sm/check! schema:templates templates))
|
|
||||||
|
|
||||||
(doseq [{:keys [id path] :as template} templates]
|
(doseq [{:keys [id path] :as template} templates]
|
||||||
(let [path (or path (fs/join dest id))]
|
(let [path (or path (fs/join dest id))]
|
||||||
(if (fs/exists? path)
|
(if (fs/exists? path)
|
||||||
|
@ -60,9 +59,9 @@
|
||||||
(let [resp (http/req! cfg
|
(let [resp (http/req! cfg
|
||||||
{:method :get :uri (:file-uri template)}
|
{:method :get :uri (:file-uri template)}
|
||||||
{:response-type :input-stream :sync? true})]
|
{:response-type :input-stream :sync? true})]
|
||||||
|
(when-not (= 200 (:status resp))
|
||||||
(dm/verify!
|
(ex/raise :type :internal
|
||||||
"unexpected response found on fetching template"
|
:code :unexpected-status-code
|
||||||
(= 200 (:status resp)))
|
:hint (str "unable to download template, recevied status " (:status resp))))
|
||||||
|
|
||||||
(io/input-stream (:body resp)))))))
|
(io/input-stream (:body resp)))))))
|
||||||
|
|
64
backend/src/app/setup/welcome_file.clj
Normal file
64
backend/src/app/setup/welcome_file.clj
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
(ns app.setup.welcome-file
|
||||||
|
(:require
|
||||||
|
[app.common.logging :as l]
|
||||||
|
[app.db :as db]
|
||||||
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.climit :as-alias climit]
|
||||||
|
[app.rpc.commands.files-update :as fupdate]
|
||||||
|
[app.rpc.commands.management :as management]
|
||||||
|
[app.rpc.commands.profile :as profile]
|
||||||
|
[app.rpc.doc :as-alias doc]
|
||||||
|
[app.setup :as-alias setup]
|
||||||
|
[app.setup.templates :as tmpl]
|
||||||
|
[app.worker :as-alias wrk]))
|
||||||
|
|
||||||
|
(def ^:private page-id #uuid "2c6952ee-d00e-8160-8004-d2250b7210cb")
|
||||||
|
(def ^:private shape-id #uuid "765e9f82-c44e-802e-8004-d72a10b7b445")
|
||||||
|
|
||||||
|
(def ^:private update-path
|
||||||
|
[:data :pages-index page-id :objects shape-id
|
||||||
|
:content :children 0 :children 0 :children 0])
|
||||||
|
|
||||||
|
(def ^:private sql:mark-file-object-thumbnails-deleted
|
||||||
|
"UPDATE file_tagged_object_thumbnail
|
||||||
|
SET deleted_at = now()
|
||||||
|
WHERE file_id = ?")
|
||||||
|
|
||||||
|
(def ^:private sql:mark-file-thumbnail-deleted
|
||||||
|
"UPDATE file_thumbnail
|
||||||
|
SET deleted_at = now()
|
||||||
|
WHERE file_id = ?")
|
||||||
|
|
||||||
|
(defn- update-welcome-shape
|
||||||
|
[_ file name]
|
||||||
|
(let [text (str "Welcome to Penpot, " name "!")]
|
||||||
|
(-> file
|
||||||
|
(update-in update-path assoc :text text)
|
||||||
|
(update-in [:data :pages-index page-id :objects shape-id] assoc :name "Welcome to Penpot!")
|
||||||
|
(update-in [:data :pages-index page-id :objects shape-id] dissoc :position-data))))
|
||||||
|
|
||||||
|
(defn create-welcome-file
|
||||||
|
[cfg {:keys [id fullname] :as profile}]
|
||||||
|
(try
|
||||||
|
(let [cfg (dissoc cfg ::db/conn)
|
||||||
|
params {:profile-id (:id profile)
|
||||||
|
:project-id (:default-project-id profile)}
|
||||||
|
template-stream (tmpl/get-template-stream cfg "welcome")
|
||||||
|
file-id (-> (management/clone-template cfg params template-stream)
|
||||||
|
first)]
|
||||||
|
|
||||||
|
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||||
|
(fupdate/update-file! cfg file-id update-welcome-shape fullname)
|
||||||
|
(profile/update-profile-props cfg id {:welcome-file-id file-id})
|
||||||
|
(db/exec-one! conn [sql:mark-file-object-thumbnails-deleted file-id])
|
||||||
|
(db/exec-one! conn [sql:mark-file-thumbnail-deleted file-id]))))
|
||||||
|
|
||||||
|
(catch Throwable cause
|
||||||
|
(l/error :hint "unexpected error on create welcome file " :cause cause))))
|
||||||
|
|
|
@ -75,6 +75,7 @@
|
||||||
:created-at (:created-at file)
|
:created-at (:created-at file)
|
||||||
:modified-at (:modified-at file)
|
:modified-at (:modified-at file)
|
||||||
:data-backend nil
|
:data-backend nil
|
||||||
|
:data-ref-id nil
|
||||||
:has-media-trimmed false}
|
:has-media-trimmed false}
|
||||||
{:id (:id file)})))
|
{:id (:id file)})))
|
||||||
|
|
||||||
|
|
|
@ -155,9 +155,10 @@
|
||||||
|
|
||||||
(defn enable-team-feature!
|
(defn enable-team-feature!
|
||||||
[team-id feature]
|
[team-id feature]
|
||||||
(dm/verify!
|
(when-not (contains? cfeat/supported-features feature)
|
||||||
"feature should be supported"
|
(ex/raise :type :assertion
|
||||||
(contains? cfeat/supported-features feature))
|
:code :feature-not-supported
|
||||||
|
:hint (str "feature '" feature "' not supported")))
|
||||||
|
|
||||||
(let [team-id (h/parse-uuid team-id)]
|
(let [team-id (h/parse-uuid team-id)]
|
||||||
(db/tx-run! main/system
|
(db/tx-run! main/system
|
||||||
|
@ -173,9 +174,11 @@
|
||||||
|
|
||||||
(defn disable-team-feature!
|
(defn disable-team-feature!
|
||||||
[team-id feature]
|
[team-id feature]
|
||||||
(dm/verify!
|
|
||||||
"feature should be supported"
|
(when-not (contains? cfeat/supported-features feature)
|
||||||
(contains? cfeat/supported-features feature))
|
(ex/raise :type :assertion
|
||||||
|
:code :feature-not-supported
|
||||||
|
:hint (str "feature '" feature "' not supported")))
|
||||||
|
|
||||||
(let [team-id (h/parse-uuid team-id)]
|
(let [team-id (h/parse-uuid team-id)]
|
||||||
(db/tx-run! main/system
|
(db/tx-run! main/system
|
||||||
|
@ -203,9 +206,11 @@
|
||||||
[{:keys [::mbus/msgbus ::db/pool]} & {:keys [dest code message level]
|
[{:keys [::mbus/msgbus ::db/pool]} & {:keys [dest code message level]
|
||||||
:or {code :generic level :info}
|
:or {code :generic level :info}
|
||||||
:as params}]
|
:as params}]
|
||||||
(dm/verify!
|
|
||||||
["invalid level %" level]
|
(when-not (contains? #{:success :error :info :warning} level)
|
||||||
(contains? #{:success :error :info :warning} level))
|
(ex/raise :type :assertion
|
||||||
|
:code :incorrect-level
|
||||||
|
:hint (str "level '" level "' not supported")))
|
||||||
|
|
||||||
(letfn [(send [dest]
|
(letfn [(send [dest]
|
||||||
(l/inf :hint "sending notification" :dest (str dest))
|
(l/inf :hint "sending notification" :dest (str dest))
|
||||||
|
@ -727,13 +732,15 @@
|
||||||
deleted 0
|
deleted 0
|
||||||
total 0]
|
total 0]
|
||||||
(if-let [email (first emails)]
|
(if-let [email (first emails)]
|
||||||
(if-let [profile (db/get* system :profile
|
(if-let [profile (some-> (db/get* system :profile
|
||||||
{:email (str/lower email)}
|
{:email (str/lower email)}
|
||||||
{::db/remove-deleted false})]
|
{::db/remove-deleted false})
|
||||||
|
(profile/decode-row))]
|
||||||
(do
|
(do
|
||||||
(audit/insert! system
|
(audit/insert! system
|
||||||
{::audit/name "delete-profile"
|
{::audit/name "delete-profile"
|
||||||
::audit/type "action"
|
::audit/type "action"
|
||||||
|
::audit/profile-id (:id profile)
|
||||||
::audit/tracked-at deleted-at
|
::audit/tracked-at deleted-at
|
||||||
::audit/props (audit/profile->props profile)
|
::audit/props (audit/profile->props profile)
|
||||||
::audit/context {:triggered-by "srepl"
|
::audit/context {:triggered-by "srepl"
|
||||||
|
|
|
@ -6,11 +6,13 @@
|
||||||
|
|
||||||
(ns app.storage
|
(ns app.storage
|
||||||
"Objects storage abstraction layer."
|
"Objects storage abstraction layer."
|
||||||
|
(:refer-clojure :exclude [resolve])
|
||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.spec :as us]
|
[app.common.spec :as us]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.storage.fs :as sfs]
|
[app.storage.fs :as sfs]
|
||||||
[app.storage.impl :as impl]
|
[app.storage.impl :as impl]
|
||||||
|
@ -18,16 +20,23 @@
|
||||||
[app.util.time :as dt]
|
[app.util.time :as dt]
|
||||||
[clojure.spec.alpha :as s]
|
[clojure.spec.alpha :as s]
|
||||||
[datoteka.fs :as fs]
|
[datoteka.fs :as fs]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig])
|
||||||
[promesa.core :as p])
|
|
||||||
(:import
|
(:import
|
||||||
java.io.InputStream))
|
java.io.InputStream))
|
||||||
|
|
||||||
|
(defn get-legacy-backend
|
||||||
|
[]
|
||||||
|
(let [name (cf/get :assets-storage-backend)]
|
||||||
|
(case name
|
||||||
|
:assets-fs :fs
|
||||||
|
:assets-s3 :s3
|
||||||
|
:fs)))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; Storage Module State
|
;; Storage Module State
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(s/def ::id #{:assets-fs :assets-s3})
|
(s/def ::id #{:assets-fs :assets-s3 :fs :s3})
|
||||||
(s/def ::s3 ::ss3/backend)
|
(s/def ::s3 ::ss3/backend)
|
||||||
(s/def ::fs ::sfs/backend)
|
(s/def ::fs ::sfs/backend)
|
||||||
(s/def ::type #{:fs :s3})
|
(s/def ::type #{:fs :s3})
|
||||||
|
@ -45,11 +54,13 @@
|
||||||
[_ {:keys [::backends ::db/pool] :as cfg}]
|
[_ {:keys [::backends ::db/pool] :as cfg}]
|
||||||
(-> (d/without-nils cfg)
|
(-> (d/without-nils cfg)
|
||||||
(assoc ::backends (d/without-nils backends))
|
(assoc ::backends (d/without-nils backends))
|
||||||
(assoc ::db/pool-or-conn pool)))
|
(assoc ::backend (or (get-legacy-backend)
|
||||||
|
(cf/get :objects-storage-backend :fs)))
|
||||||
|
(assoc ::db/connectable pool)))
|
||||||
|
|
||||||
(s/def ::backend keyword?)
|
(s/def ::backend keyword?)
|
||||||
(s/def ::storage
|
(s/def ::storage
|
||||||
(s/keys :req [::backends ::db/pool ::db/pool-or-conn]
|
(s/keys :req [::backends ::db/pool ::db/connectable]
|
||||||
:opt [::backend]))
|
:opt [::backend]))
|
||||||
|
|
||||||
(s/def ::storage-with-backend
|
(s/def ::storage-with-backend
|
||||||
|
@ -61,23 +72,26 @@
|
||||||
|
|
||||||
(defn get-metadata
|
(defn get-metadata
|
||||||
[params]
|
[params]
|
||||||
(into {}
|
(reduce-kv (fn [res k _]
|
||||||
(remove (fn [[k _]] (qualified-keyword? k)))
|
(if (qualified-keyword? k)
|
||||||
|
(dissoc res k)
|
||||||
|
res))
|
||||||
|
params
|
||||||
params))
|
params))
|
||||||
|
|
||||||
(defn- get-database-object-by-hash
|
(defn- get-database-object-by-hash
|
||||||
[pool-or-conn backend bucket hash]
|
[connectable backend bucket hash]
|
||||||
(let [sql (str "select * from storage_object "
|
(let [sql (str "select * from storage_object "
|
||||||
" where (metadata->>'~:hash') = ? "
|
" where (metadata->>'~:hash') = ? "
|
||||||
" and (metadata->>'~:bucket') = ? "
|
" and (metadata->>'~:bucket') = ? "
|
||||||
" and backend = ?"
|
" and backend = ?"
|
||||||
" and deleted_at is null"
|
" and deleted_at is null"
|
||||||
" limit 1")]
|
" limit 1")]
|
||||||
(some-> (db/exec-one! pool-or-conn [sql hash bucket (name backend)])
|
(some-> (db/exec-one! connectable [sql hash bucket (name backend)])
|
||||||
(update :metadata db/decode-transit-pgobject))))
|
(update :metadata db/decode-transit-pgobject))))
|
||||||
|
|
||||||
(defn- create-database-object
|
(defn- create-database-object
|
||||||
[{:keys [::backend ::db/pool-or-conn]} {:keys [::content ::expired-at ::touched-at] :as params}]
|
[{:keys [::backend ::db/connectable]} {:keys [::content ::expired-at ::touched-at ::touch] :as params}]
|
||||||
(let [id (or (:id params) (uuid/random))
|
(let [id (or (:id params) (uuid/random))
|
||||||
mdata (cond-> (get-metadata params)
|
mdata (cond-> (get-metadata params)
|
||||||
(satisfies? impl/IContentHash content)
|
(satisfies? impl/IContentHash content)
|
||||||
|
@ -86,7 +100,9 @@
|
||||||
:always
|
:always
|
||||||
(dissoc :id))
|
(dissoc :id))
|
||||||
|
|
||||||
;; FIXME: touch object on deduplicated put operation ??
|
touched-at (if touch
|
||||||
|
(or touched-at (dt/now))
|
||||||
|
touched-at)
|
||||||
|
|
||||||
;; NOTE: for now we don't reuse the deleted objects, but in
|
;; NOTE: for now we don't reuse the deleted objects, but in
|
||||||
;; futute we can consider reusing deleted objects if we
|
;; futute we can consider reusing deleted objects if we
|
||||||
|
@ -95,10 +111,20 @@
|
||||||
result (when (and (::deduplicate? params)
|
result (when (and (::deduplicate? params)
|
||||||
(:hash mdata)
|
(:hash mdata)
|
||||||
(:bucket mdata))
|
(:bucket mdata))
|
||||||
(get-database-object-by-hash pool-or-conn backend (:bucket mdata) (:hash mdata)))
|
(let [result (get-database-object-by-hash connectable backend
|
||||||
|
(:bucket mdata)
|
||||||
|
(:hash mdata))]
|
||||||
|
(if touch
|
||||||
|
(do
|
||||||
|
(db/update! connectable :storage-object
|
||||||
|
{:touched-at touched-at}
|
||||||
|
{:id (:id result)}
|
||||||
|
{::db/return-keys false})
|
||||||
|
(assoc result :touced-at touched-at))
|
||||||
|
result)))
|
||||||
|
|
||||||
result (or result
|
result (or result
|
||||||
(-> (db/insert! pool-or-conn :storage-object
|
(-> (db/insert! connectable :storage-object
|
||||||
{:id id
|
{:id id
|
||||||
:size (impl/get-size content)
|
:size (impl/get-size content)
|
||||||
:backend (name backend)
|
:backend (name backend)
|
||||||
|
@ -154,9 +180,9 @@
|
||||||
(dm/export impl/object?)
|
(dm/export impl/object?)
|
||||||
|
|
||||||
(defn get-object
|
(defn get-object
|
||||||
[{:keys [::db/pool-or-conn] :as storage} id]
|
[{:keys [::db/connectable] :as storage} id]
|
||||||
(us/assert! ::storage storage)
|
(us/assert! ::storage storage)
|
||||||
(retrieve-database-object pool-or-conn id))
|
(retrieve-database-object connectable id))
|
||||||
|
|
||||||
(defn put-object!
|
(defn put-object!
|
||||||
"Creates a new object with the provided content."
|
"Creates a new object with the provided content."
|
||||||
|
@ -172,10 +198,10 @@
|
||||||
|
|
||||||
(defn touch-object!
|
(defn touch-object!
|
||||||
"Mark object as touched."
|
"Mark object as touched."
|
||||||
[{:keys [::db/pool-or-conn] :as storage} object-or-id]
|
[{:keys [::db/connectable] :as storage} object-or-id]
|
||||||
(us/assert! ::storage storage)
|
(us/assert! ::storage storage)
|
||||||
(let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id)]
|
(let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id)]
|
||||||
(-> (db/update! pool-or-conn :storage-object
|
(-> (db/update! connectable :storage-object
|
||||||
{:touched-at (dt/now)}
|
{:touched-at (dt/now)}
|
||||||
{:id id})
|
{:id id})
|
||||||
(db/get-update-count)
|
(db/get-update-count)
|
||||||
|
@ -195,11 +221,10 @@
|
||||||
"Returns a byte array of object content."
|
"Returns a byte array of object content."
|
||||||
[storage object]
|
[storage object]
|
||||||
(us/assert! ::storage storage)
|
(us/assert! ::storage storage)
|
||||||
(if (or (nil? (:expired-at object))
|
(when (or (nil? (:expired-at object))
|
||||||
(dt/is-after? (:expired-at object) (dt/now)))
|
(dt/is-after? (:expired-at object) (dt/now)))
|
||||||
(-> (impl/resolve-backend storage (:backend object))
|
(-> (impl/resolve-backend storage (:backend object))
|
||||||
(impl/get-object-bytes object))
|
(impl/get-object-bytes object))))
|
||||||
(p/resolved nil)))
|
|
||||||
|
|
||||||
(defn get-object-url
|
(defn get-object-url
|
||||||
([storage object]
|
([storage object]
|
||||||
|
@ -223,13 +248,26 @@
|
||||||
(-> (impl/get-object-url backend object nil) file-url->path))))
|
(-> (impl/get-object-url backend object nil) file-url->path))))
|
||||||
|
|
||||||
(defn del-object!
|
(defn del-object!
|
||||||
[{:keys [::db/pool-or-conn] :as storage} object-or-id]
|
[{:keys [::db/connectable] :as storage} object-or-id]
|
||||||
(us/assert! ::storage storage)
|
(us/assert! ::storage storage)
|
||||||
(let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id)
|
(let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id)
|
||||||
res (db/update! pool-or-conn :storage-object
|
res (db/update! connectable :storage-object
|
||||||
{:deleted-at (dt/now)}
|
{:deleted-at (dt/now)}
|
||||||
{:id id})]
|
{:id id})]
|
||||||
(pos? (db/get-update-count res))))
|
(pos? (db/get-update-count res))))
|
||||||
|
|
||||||
(dm/export impl/resolve-backend)
|
|
||||||
(dm/export impl/calculate-hash)
|
(dm/export impl/calculate-hash)
|
||||||
|
|
||||||
|
(defn configure
|
||||||
|
[storage connectable]
|
||||||
|
(assoc storage ::db/connectable connectable))
|
||||||
|
|
||||||
|
(defn resolve
|
||||||
|
"Resolves the storage instance with preconfigured backend. You can
|
||||||
|
specify to reuse the database connection from provided
|
||||||
|
cfg/system (default false)."
|
||||||
|
[cfg & {:as opts}]
|
||||||
|
(let [storage (::storage cfg)]
|
||||||
|
(if (::db/reuse-conn opts false)
|
||||||
|
(configure storage (db/get-connectable cfg))
|
||||||
|
storage)))
|
||||||
|
|
|
@ -121,5 +121,3 @@
|
||||||
:total total)
|
:total total)
|
||||||
|
|
||||||
{:deleted total}))))))
|
{:deleted total}))))))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -28,58 +28,80 @@
|
||||||
[clojure.spec.alpha :as s]
|
[clojure.spec.alpha :as s]
|
||||||
[integrant.core :as ig]))
|
[integrant.core :as ig]))
|
||||||
|
|
||||||
(def ^:private sql:get-team-font-variant-nrefs
|
(def ^:private sql:has-team-font-variant-refs
|
||||||
"SELECT ((SELECT count(*) FROM team_font_variant WHERE woff1_file_id = ?) +
|
"SELECT ((SELECT EXISTS (SELECT 1 FROM team_font_variant WHERE woff1_file_id = ?)) OR
|
||||||
(SELECT count(*) FROM team_font_variant WHERE woff2_file_id = ?) +
|
(SELECT EXISTS (SELECT 1 FROM team_font_variant WHERE woff2_file_id = ?)) OR
|
||||||
(SELECT count(*) FROM team_font_variant WHERE otf_file_id = ?) +
|
(SELECT EXISTS (SELECT 1 FROM team_font_variant WHERE otf_file_id = ?)) OR
|
||||||
(SELECT count(*) FROM team_font_variant WHERE ttf_file_id = ?)) AS nrefs")
|
(SELECT EXISTS (SELECT 1 FROM team_font_variant WHERE ttf_file_id = ?))) AS has_refs")
|
||||||
|
|
||||||
(defn- get-team-font-variant-nrefs
|
(defn- has-team-font-variant-refs?
|
||||||
[conn id]
|
[conn id]
|
||||||
(-> (db/exec-one! conn [sql:get-team-font-variant-nrefs id id id id])
|
(-> (db/exec-one! conn [sql:has-team-font-variant-refs id id id id])
|
||||||
(get :nrefs)))
|
(get :has-refs)))
|
||||||
|
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
sql:get-file-media-object-nrefs
|
sql:has-file-media-object-refs
|
||||||
"SELECT ((SELECT count(*) FROM file_media_object WHERE media_id = ?) +
|
"SELECT ((SELECT EXISTS (SELECT 1 FROM file_media_object WHERE media_id = ?)) OR
|
||||||
(SELECT count(*) FROM file_media_object WHERE thumbnail_id = ?)) AS nrefs")
|
(SELECT EXISTS (SELECT 1 FROM file_media_object WHERE thumbnail_id = ?))) AS has_refs")
|
||||||
|
|
||||||
(defn- get-file-media-object-nrefs
|
(defn- has-file-media-object-refs?
|
||||||
[conn id]
|
[conn id]
|
||||||
(-> (db/exec-one! conn [sql:get-file-media-object-nrefs id id])
|
(-> (db/exec-one! conn [sql:has-file-media-object-refs id id])
|
||||||
(get :nrefs)))
|
(get :has-refs)))
|
||||||
|
|
||||||
|
(def ^:private sql:has-profile-refs
|
||||||
|
"SELECT ((SELECT EXISTS (SELECT 1 FROM profile WHERE photo_id = ?)) OR
|
||||||
|
(SELECT EXISTS (SELECT 1 FROM team WHERE photo_id = ?))) AS has_refs")
|
||||||
|
|
||||||
(def ^:private sql:get-profile-nrefs
|
(defn- has-profile-refs?
|
||||||
"SELECT ((SELECT count(*) FROM profile WHERE photo_id = ?) +
|
|
||||||
(SELECT count(*) FROM team WHERE photo_id = ?)) AS nrefs")
|
|
||||||
|
|
||||||
(defn- get-profile-nrefs
|
|
||||||
[conn id]
|
[conn id]
|
||||||
(-> (db/exec-one! conn [sql:get-profile-nrefs id id])
|
(-> (db/exec-one! conn [sql:has-profile-refs id id])
|
||||||
(get :nrefs)))
|
(get :has-refs)))
|
||||||
|
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
sql:get-file-object-thumbnail-nrefs
|
sql:has-file-object-thumbnail-refs
|
||||||
"SELECT (SELECT count(*) FROM file_tagged_object_thumbnail WHERE media_id = ?) AS nrefs")
|
"SELECT EXISTS (SELECT 1 FROM file_tagged_object_thumbnail WHERE media_id = ?) AS has_refs")
|
||||||
|
|
||||||
(defn- get-file-object-thumbnails
|
(defn- has-file-object-thumbnails-refs?
|
||||||
[conn id]
|
[conn id]
|
||||||
(-> (db/exec-one! conn [sql:get-file-object-thumbnail-nrefs id])
|
(-> (db/exec-one! conn [sql:has-file-object-thumbnail-refs id])
|
||||||
(get :nrefs)))
|
(get :has-refs)))
|
||||||
|
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
sql:get-file-thumbnail-nrefs
|
sql:has-file-thumbnail-refs
|
||||||
"SELECT (SELECT count(*) FROM file_thumbnail WHERE media_id = ?) AS nrefs")
|
"SELECT EXISTS (SELECT 1 FROM file_thumbnail WHERE media_id = ?) AS has_refs")
|
||||||
|
|
||||||
(defn- get-file-thumbnails
|
(defn- has-file-thumbnails-refs?
|
||||||
[conn id]
|
[conn id]
|
||||||
(-> (db/exec-one! conn [sql:get-file-thumbnail-nrefs id])
|
(-> (db/exec-one! conn [sql:has-file-thumbnail-refs id])
|
||||||
(get :nrefs)))
|
(get :has-refs)))
|
||||||
|
|
||||||
|
(def ^:private
|
||||||
|
sql:has-file-data-refs
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM file WHERE data_ref_id = ?) AS has_refs")
|
||||||
|
|
||||||
|
(defn- has-file-data-refs?
|
||||||
|
[conn id]
|
||||||
|
(-> (db/exec-one! conn [sql:has-file-data-refs id])
|
||||||
|
(get :has-refs)))
|
||||||
|
|
||||||
|
(def ^:private
|
||||||
|
sql:has-file-data-fragment-refs
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM file_data_fragment WHERE data_ref_id = ?) AS has_refs")
|
||||||
|
|
||||||
|
(defn- has-file-data-fragment-refs?
|
||||||
|
[conn id]
|
||||||
|
(-> (db/exec-one! conn [sql:has-file-data-fragment-refs id])
|
||||||
|
(get :has-refs)))
|
||||||
|
|
||||||
|
(def ^:private
|
||||||
|
sql:has-file-change-refs
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM file_change WHERE data_ref_id = ?) AS has_refs")
|
||||||
|
|
||||||
|
(defn- has-file-change-refs?
|
||||||
|
[conn id]
|
||||||
|
(-> (db/exec-one! conn [sql:has-file-change-refs id])
|
||||||
|
(get :has-refs)))
|
||||||
|
|
||||||
(def ^:private sql:mark-freeze-in-bulk
|
(def ^:private sql:mark-freeze-in-bulk
|
||||||
"UPDATE storage_object
|
"UPDATE storage_object
|
||||||
|
@ -91,7 +113,6 @@
|
||||||
(let [ids (db/create-array conn "uuid" ids)]
|
(let [ids (db/create-array conn "uuid" ids)]
|
||||||
(db/exec-one! conn [sql:mark-freeze-in-bulk ids])))
|
(db/exec-one! conn [sql:mark-freeze-in-bulk ids])))
|
||||||
|
|
||||||
|
|
||||||
(def ^:private sql:mark-delete-in-bulk
|
(def ^:private sql:mark-delete-in-bulk
|
||||||
"UPDATE storage_object
|
"UPDATE storage_object
|
||||||
SET deleted_at = now(),
|
SET deleted_at = now(),
|
||||||
|
@ -123,25 +144,24 @@
|
||||||
"file-media-object"))
|
"file-media-object"))
|
||||||
|
|
||||||
(defn- process-objects!
|
(defn- process-objects!
|
||||||
[conn get-fn ids bucket]
|
[conn has-refs? ids bucket]
|
||||||
(loop [to-freeze #{}
|
(loop [to-freeze #{}
|
||||||
to-delete #{}
|
to-delete #{}
|
||||||
ids (seq ids)]
|
ids (seq ids)]
|
||||||
(if-let [id (first ids)]
|
(if-let [id (first ids)]
|
||||||
(let [nrefs (get-fn conn id)]
|
(if (has-refs? conn id)
|
||||||
(if (pos? nrefs)
|
|
||||||
(do
|
(do
|
||||||
(l/debug :hint "processing object"
|
(l/debug :hint "processing object"
|
||||||
:id (str id)
|
:id (str id)
|
||||||
:status "freeze"
|
:status "freeze"
|
||||||
:bucket bucket :refs nrefs)
|
:bucket bucket)
|
||||||
(recur (conj to-freeze id) to-delete (rest ids)))
|
(recur (conj to-freeze id) to-delete (rest ids)))
|
||||||
(do
|
(do
|
||||||
(l/debug :hint "processing object"
|
(l/debug :hint "processing object"
|
||||||
:id (str id)
|
:id (str id)
|
||||||
:status "delete"
|
:status "delete"
|
||||||
:bucket bucket :refs nrefs)
|
:bucket bucket)
|
||||||
(recur to-freeze (conj to-delete id) (rest ids)))))
|
(recur to-freeze (conj to-delete id) (rest ids))))
|
||||||
(do
|
(do
|
||||||
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
|
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
|
||||||
(some->> (seq to-delete) (mark-delete-in-bulk! conn))
|
(some->> (seq to-delete) (mark-delete-in-bulk! conn))
|
||||||
|
@ -150,15 +170,26 @@
|
||||||
(defn- process-bucket!
|
(defn- process-bucket!
|
||||||
[conn bucket ids]
|
[conn bucket ids]
|
||||||
(case bucket
|
(case bucket
|
||||||
"file-media-object" (process-objects! conn get-file-media-object-nrefs ids bucket)
|
"file-media-object" (process-objects! conn has-file-media-object-refs? ids bucket)
|
||||||
"team-font-variant" (process-objects! conn get-team-font-variant-nrefs ids bucket)
|
"team-font-variant" (process-objects! conn has-team-font-variant-refs? ids bucket)
|
||||||
"file-object-thumbnail" (process-objects! conn get-file-object-thumbnails ids bucket)
|
"file-object-thumbnail" (process-objects! conn has-file-object-thumbnails-refs? ids bucket)
|
||||||
"file-thumbnail" (process-objects! conn get-file-thumbnails ids bucket)
|
"file-thumbnail" (process-objects! conn has-file-thumbnails-refs? ids bucket)
|
||||||
"profile" (process-objects! conn get-profile-nrefs ids bucket)
|
"profile" (process-objects! conn has-profile-refs? ids bucket)
|
||||||
|
"file-data" (process-objects! conn has-file-data-refs? ids bucket)
|
||||||
|
"file-data-fragment" (process-objects! conn has-file-data-fragment-refs? ids bucket)
|
||||||
|
"file-change" (process-objects! conn has-file-change-refs? ids bucket)
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
:code :unexpected-unknown-reference
|
:code :unexpected-unknown-reference
|
||||||
:hint (dm/fmt "unknown reference %" bucket))))
|
:hint (dm/fmt "unknown reference '%'" bucket))))
|
||||||
|
|
||||||
|
(defn process-chunk!
|
||||||
|
[{:keys [::db/conn]} chunk]
|
||||||
|
(reduce-kv (fn [[nfo ndo] bucket ids]
|
||||||
|
(let [[nfo' ndo'] (process-bucket! conn bucket ids)]
|
||||||
|
[(+ nfo nfo')
|
||||||
|
(+ ndo ndo')]))
|
||||||
|
[0 0]
|
||||||
|
(d/group-by lookup-bucket :id #{} chunk)))
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
sql:get-touched-storage-objects
|
sql:get-touched-storage-objects
|
||||||
|
@ -167,29 +198,22 @@
|
||||||
WHERE so.touched_at IS NOT NULL
|
WHERE so.touched_at IS NOT NULL
|
||||||
ORDER BY touched_at ASC
|
ORDER BY touched_at ASC
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
SKIP LOCKED")
|
SKIP LOCKED
|
||||||
|
LIMIT 10")
|
||||||
|
|
||||||
(defn- group-by-bucket
|
(defn get-chunk
|
||||||
[row]
|
|
||||||
(d/group-by lookup-bucket :id #{} row))
|
|
||||||
|
|
||||||
(defn- get-buckets
|
|
||||||
[conn]
|
[conn]
|
||||||
(sequence
|
(->> (db/exec! conn [sql:get-touched-storage-objects])
|
||||||
(comp (map impl/decode-row)
|
(map impl/decode-row)
|
||||||
(partition-all 25)
|
(not-empty)))
|
||||||
(mapcat group-by-bucket))
|
|
||||||
(db/cursor conn sql:get-touched-storage-objects)))
|
|
||||||
|
|
||||||
(defn- process-touched!
|
(defn- process-touched!
|
||||||
[{:keys [::db/conn]}]
|
[{:keys [::db/pool] :as cfg}]
|
||||||
(loop [buckets (get-buckets conn)
|
(loop [freezed 0
|
||||||
freezed 0
|
|
||||||
deleted 0]
|
deleted 0]
|
||||||
(if-let [[bucket ids] (first buckets)]
|
(if-let [chunk (get-chunk pool)]
|
||||||
(let [[nfo ndo] (process-bucket! conn bucket ids)]
|
(let [[nfo ndo] (db/tx-run! cfg process-chunk! chunk)]
|
||||||
(recur (rest buckets)
|
(recur (+ freezed nfo)
|
||||||
(+ freezed nfo)
|
|
||||||
(+ deleted ndo)))
|
(+ deleted ndo)))
|
||||||
(do
|
(do
|
||||||
(l/inf :hint "task finished"
|
(l/inf :hint "task finished"
|
||||||
|
@ -198,11 +222,14 @@
|
||||||
|
|
||||||
{:freeze freezed :delete deleted}))))
|
{:freeze freezed :delete deleted}))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; HANDLER
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(defmethod ig/pre-init-spec ::handler [_]
|
(defmethod ig/pre-init-spec ::handler [_]
|
||||||
(s/keys :req [::db/pool]))
|
(s/keys :req [::db/pool]))
|
||||||
|
|
||||||
(defmethod ig/init-key ::handler
|
(defmethod ig/init-key ::handler
|
||||||
[_ cfg]
|
[_ cfg]
|
||||||
(fn [_]
|
(fn [_] (process-touched! cfg)))
|
||||||
(db/tx-run! cfg process-touched!)))
|
|
||||||
|
|
||||||
|
|
|
@ -207,15 +207,13 @@
|
||||||
(str "blake2b:" result)))
|
(str "blake2b:" result)))
|
||||||
|
|
||||||
(defn resolve-backend
|
(defn resolve-backend
|
||||||
[{:keys [::db/pool] :as storage} backend-id]
|
[storage backend-id]
|
||||||
(let [backend (get-in storage [::sto/backends backend-id])]
|
(let [backend (get-in storage [::sto/backends backend-id])]
|
||||||
(when-not backend
|
(when-not backend
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
:code :backend-not-configured
|
:code :backend-not-configured
|
||||||
:hint (dm/fmt "backend '%' not configured" backend-id)))
|
:hint (dm/fmt "backend '%' not configured" backend-id)))
|
||||||
(-> backend
|
(assoc backend ::sto/id backend-id)))
|
||||||
(assoc ::sto/id backend-id)
|
|
||||||
(assoc ::db/pool pool))))
|
|
||||||
|
|
||||||
(defrecord StorageObject [id size created-at expired-at touched-at backend])
|
(defrecord StorageObject [id size created-at expired-at touched-at backend])
|
||||||
|
|
||||||
|
|
|
@ -21,78 +21,31 @@
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.features.fdata :as feat.fdata]
|
[app.features.fdata :as feat.fdata]
|
||||||
[app.media :as media]
|
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
[app.util.blob :as blob]
|
[app.util.blob :as blob]
|
||||||
[app.util.pointer-map :as pmap]
|
[app.util.pointer-map :as pmap]
|
||||||
[app.util.time :as dt]
|
[app.util.time :as dt]
|
||||||
|
[app.worker :as wrk]
|
||||||
[clojure.set :as set]
|
[clojure.set :as set]
|
||||||
[clojure.spec.alpha :as s]
|
[clojure.spec.alpha :as s]
|
||||||
[integrant.core :as ig]))
|
[integrant.core :as ig]))
|
||||||
|
|
||||||
(declare ^:private clean-file!)
|
(declare ^:private get-file)
|
||||||
|
(declare ^:private decode-file)
|
||||||
|
(declare ^:private persist-file!)
|
||||||
|
|
||||||
(defn- decode-file
|
(def ^:private sql:get-snapshots
|
||||||
[cfg {:keys [id] :as file}]
|
"SELECT f.file_id AS id,
|
||||||
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
|
|
||||||
(-> file
|
|
||||||
(update :features db/decode-pgarray #{})
|
|
||||||
(update :data blob/decode)
|
|
||||||
(update :data feat.fdata/process-pointers deref)
|
|
||||||
(update :data feat.fdata/process-objects (partial into {}))
|
|
||||||
(update :data assoc :id id)
|
|
||||||
(fmg/migrate-file))))
|
|
||||||
|
|
||||||
(defn- update-file!
|
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [id] :as file}]
|
|
||||||
(let [file (if (contains? (:features file) "fdata/objects-map")
|
|
||||||
(feat.fdata/enable-objects-map file)
|
|
||||||
file)
|
|
||||||
|
|
||||||
file (if (contains? (:features file) "fdata/pointer-map")
|
|
||||||
(binding [pmap/*tracked* (pmap/create-tracked)]
|
|
||||||
(let [file (feat.fdata/enable-pointer-map file)]
|
|
||||||
(feat.fdata/persist-pointers! cfg id)
|
|
||||||
file))
|
|
||||||
file)
|
|
||||||
|
|
||||||
file (-> file
|
|
||||||
(update :features db/encode-pgarray conn "text")
|
|
||||||
(update :data blob/encode))]
|
|
||||||
|
|
||||||
(db/update! conn :file
|
|
||||||
{:has-media-trimmed true
|
|
||||||
:features (:features file)
|
|
||||||
:version (:version file)
|
|
||||||
:data (:data file)}
|
|
||||||
{:id id}
|
|
||||||
{::db/return-keys true})))
|
|
||||||
|
|
||||||
(def ^:private
|
|
||||||
sql:get-candidates
|
|
||||||
"SELECT f.id,
|
|
||||||
f.data,
|
f.data,
|
||||||
f.revn,
|
f.revn,
|
||||||
f.version,
|
f.version,
|
||||||
f.features,
|
f.features,
|
||||||
f.modified_at
|
f.data_backend,
|
||||||
FROM file AS f
|
f.data_ref_id
|
||||||
WHERE f.has_media_trimmed IS false
|
FROM file_change AS f
|
||||||
AND f.modified_at < now() - ?::interval
|
WHERE f.file_id = ?
|
||||||
AND f.deleted_at IS NULL
|
AND f.label IS NOT NULL
|
||||||
ORDER BY f.modified_at DESC
|
ORDER BY f.created_at ASC")
|
||||||
FOR UPDATE
|
|
||||||
SKIP LOCKED")
|
|
||||||
|
|
||||||
(defn- get-candidates
|
|
||||||
[{:keys [::db/conn ::min-age ::file-id]}]
|
|
||||||
(if (uuid? file-id)
|
|
||||||
(do
|
|
||||||
(l/warn :hint "explicit file id passed on params" :file-id (str file-id))
|
|
||||||
(db/query conn :file {:id file-id}))
|
|
||||||
|
|
||||||
(let [min-age (db/interval min-age)]
|
|
||||||
(db/cursor conn [sql:get-candidates min-age] {:chunk-size 1}))))
|
|
||||||
|
|
||||||
(def ^:private sql:mark-file-media-object-deleted
|
(def ^:private sql:mark-file-media-object-deleted
|
||||||
"UPDATE file_media_object
|
"UPDATE file_media_object
|
||||||
|
@ -100,10 +53,17 @@
|
||||||
WHERE file_id = ? AND id != ALL(?::uuid[])
|
WHERE file_id = ? AND id != ALL(?::uuid[])
|
||||||
RETURNING id")
|
RETURNING id")
|
||||||
|
|
||||||
|
(def ^:private xf:collect-used-media
|
||||||
|
(comp (map :data) (mapcat bfc/collect-used-media)))
|
||||||
|
|
||||||
(defn- clean-file-media!
|
(defn- clean-file-media!
|
||||||
"Performs the garbage collection of file media objects."
|
"Performs the garbage collection of file media objects."
|
||||||
[{:keys [::db/conn]} {:keys [id data] :as file}]
|
[{:keys [::db/conn] :as cfg} {:keys [id] :as file}]
|
||||||
(let [used (bfc/collect-used-media data)
|
(let [used (into #{}
|
||||||
|
xf:collect-used-media
|
||||||
|
(cons file
|
||||||
|
(->> (db/cursor conn [sql:get-snapshots id])
|
||||||
|
(map (partial decode-file cfg)))))
|
||||||
ids (db/create-array conn "uuid" used)
|
ids (db/create-array conn "uuid" used)
|
||||||
unused (->> (db/exec! conn [sql:mark-file-media-object-deleted id ids])
|
unused (->> (db/exec! conn [sql:mark-file-media-object-deleted id ids])
|
||||||
(into #{} (map :id)))]
|
(into #{} (map :id)))]
|
||||||
|
@ -172,9 +132,14 @@
|
||||||
|
|
||||||
file))
|
file))
|
||||||
|
|
||||||
|
|
||||||
(def ^:private sql:get-files-for-library
|
(def ^:private sql:get-files-for-library
|
||||||
"SELECT f.id, f.data, f.modified_at, f.features, f.version
|
"SELECT f.id,
|
||||||
|
f.data,
|
||||||
|
f.modified_at,
|
||||||
|
f.features,
|
||||||
|
f.version,
|
||||||
|
f.data_backend,
|
||||||
|
f.data_ref_id
|
||||||
FROM file AS f
|
FROM file AS f
|
||||||
LEFT JOIN file_library_rel AS fl ON (fl.file_id = f.id)
|
LEFT JOIN file_library_rel AS fl ON (fl.file_id = f.id)
|
||||||
WHERE fl.library_file_id = ?
|
WHERE fl.library_file_id = ?
|
||||||
|
@ -230,11 +195,6 @@
|
||||||
(l/dbg :hint "clean" :rel "components" :file-id (str file-id) :total (count unused))
|
(l/dbg :hint "clean" :rel "components" :file-id (str file-id) :total (count unused))
|
||||||
file))
|
file))
|
||||||
|
|
||||||
(def ^:private sql:get-changes
|
|
||||||
"SELECT id, data FROM file_change
|
|
||||||
WHERE file_id = ? AND data IS NOT NULL
|
|
||||||
ORDER BY created_at ASC")
|
|
||||||
|
|
||||||
(def ^:private sql:mark-deleted-data-fragments
|
(def ^:private sql:mark-deleted-data-fragments
|
||||||
"UPDATE file_data_fragment
|
"UPDATE file_data_fragment
|
||||||
SET deleted_at = now()
|
SET deleted_at = now()
|
||||||
|
@ -250,8 +210,7 @@
|
||||||
|
|
||||||
(defn- clean-data-fragments!
|
(defn- clean-data-fragments!
|
||||||
[{:keys [::db/conn]} {:keys [id] :as file}]
|
[{:keys [::db/conn]} {:keys [id] :as file}]
|
||||||
(let [used (into #{} xf:collect-pointers
|
(let [used (into #{} xf:collect-pointers [file])
|
||||||
(cons file (db/cursor conn [sql:get-changes id])))
|
|
||||||
|
|
||||||
unused (let [ids (db/create-array conn "uuid" used)]
|
unused (let [ids (db/create-array conn "uuid" used)]
|
||||||
(->> (db/exec! conn [sql:mark-deleted-data-fragments id ids])
|
(->> (db/exec! conn [sql:mark-deleted-data-fragments id ids])
|
||||||
|
@ -274,17 +233,83 @@
|
||||||
(cfv/validate-file-schema! file)
|
(cfv/validate-file-schema! file)
|
||||||
file))
|
file))
|
||||||
|
|
||||||
|
(def ^:private sql:get-file
|
||||||
|
"SELECT f.id,
|
||||||
|
f.data,
|
||||||
|
f.revn,
|
||||||
|
f.version,
|
||||||
|
f.features,
|
||||||
|
f.modified_at,
|
||||||
|
f.data_backend,
|
||||||
|
f.data_ref_id
|
||||||
|
FROM file AS f
|
||||||
|
WHERE f.has_media_trimmed IS false
|
||||||
|
AND f.modified_at < now() - ?::interval
|
||||||
|
AND f.deleted_at IS NULL
|
||||||
|
AND f.id = ?
|
||||||
|
FOR UPDATE
|
||||||
|
SKIP LOCKED")
|
||||||
|
|
||||||
|
(defn- get-file
|
||||||
|
[{:keys [::db/conn ::min-age ::file-id]}]
|
||||||
|
(->> (db/exec! conn [sql:get-file min-age file-id])
|
||||||
|
(first)))
|
||||||
|
|
||||||
|
(defn- decode-file
|
||||||
|
[cfg {:keys [id] :as file}]
|
||||||
|
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
|
||||||
|
(-> (feat.fdata/resolve-file-data cfg file)
|
||||||
|
(update :features db/decode-pgarray #{})
|
||||||
|
(update :data blob/decode)
|
||||||
|
(update :data feat.fdata/process-pointers deref)
|
||||||
|
(update :data feat.fdata/process-objects (partial into {}))
|
||||||
|
(update :data assoc :id id)
|
||||||
|
(fmg/migrate-file))))
|
||||||
|
|
||||||
|
(defn- persist-file!
|
||||||
|
[{:keys [::db/conn ::sto/storage] :as cfg} {:keys [id] :as file}]
|
||||||
|
(let [file (if (contains? (:features file) "fdata/objects-map")
|
||||||
|
(feat.fdata/enable-objects-map file)
|
||||||
|
file)
|
||||||
|
|
||||||
|
file (if (contains? (:features file) "fdata/pointer-map")
|
||||||
|
(binding [pmap/*tracked* (pmap/create-tracked)]
|
||||||
|
(let [file (feat.fdata/enable-pointer-map file)]
|
||||||
|
(feat.fdata/persist-pointers! cfg id)
|
||||||
|
file))
|
||||||
|
file)
|
||||||
|
|
||||||
|
file (-> file
|
||||||
|
(update :features db/encode-pgarray conn "text")
|
||||||
|
(update :data blob/encode))]
|
||||||
|
|
||||||
|
;; If file was already offloaded, we touch the underlying storage
|
||||||
|
;; object for properly trigger storage-gc-touched task
|
||||||
|
(when (feat.fdata/offloaded? file)
|
||||||
|
(some->> (:data-ref-id file) (sto/touch-object! storage)))
|
||||||
|
|
||||||
|
(db/update! conn :file
|
||||||
|
{:has-media-trimmed true
|
||||||
|
:features (:features file)
|
||||||
|
:version (:version file)
|
||||||
|
:data (:data file)
|
||||||
|
:data-backend nil
|
||||||
|
:data-ref-id nil}
|
||||||
|
{:id id}
|
||||||
|
{::db/return-keys true})))
|
||||||
|
|
||||||
(defn- process-file!
|
(defn- process-file!
|
||||||
[cfg file]
|
[cfg]
|
||||||
(try
|
(if-let [file (get-file cfg)]
|
||||||
(let [file (decode-file cfg file)
|
(let [file (decode-file cfg file)
|
||||||
file (clean-media! cfg file)
|
file (clean-media! cfg file)
|
||||||
file (update-file! cfg file)]
|
file (persist-file! cfg file)]
|
||||||
(clean-data-fragments! cfg file))
|
(clean-data-fragments! cfg file)
|
||||||
(catch Throwable cause
|
true)
|
||||||
(l/err :hint "error on cleaning file (skiping)"
|
|
||||||
:file-id (str (:id file))
|
(do
|
||||||
:cause cause))))
|
(l/dbg :hint "skip" :file-id (str (::file-id cfg)))
|
||||||
|
false)))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; HANDLER
|
;; HANDLER
|
||||||
|
@ -293,33 +318,29 @@
|
||||||
(defmethod ig/pre-init-spec ::handler [_]
|
(defmethod ig/pre-init-spec ::handler [_]
|
||||||
(s/keys :req [::db/pool ::sto/storage]))
|
(s/keys :req [::db/pool ::sto/storage]))
|
||||||
|
|
||||||
(defmethod ig/prep-key ::handler
|
|
||||||
[_ cfg]
|
|
||||||
(assoc cfg ::min-age (cf/get-deletion-delay)))
|
|
||||||
|
|
||||||
(defmethod ig/init-key ::handler
|
(defmethod ig/init-key ::handler
|
||||||
[_ cfg]
|
[_ cfg]
|
||||||
(fn [{:keys [props] :as task}]
|
(fn [{:keys [props] :as task}]
|
||||||
(db/tx-run! cfg
|
(let [min-age (dt/duration (or (:min-age props)
|
||||||
(fn [{:keys [::db/conn] :as cfg}]
|
(cf/get-deletion-delay)))
|
||||||
(let [min-age (dt/duration (or (:min-age props) (::min-age cfg)))
|
|
||||||
cfg (-> cfg
|
cfg (-> cfg
|
||||||
(update ::sto/storage media/configure-assets-storage conn)
|
(assoc ::db/rollback (:rollback? props))
|
||||||
(assoc ::file-id (:file-id props))
|
(assoc ::file-id (:file-id props))
|
||||||
(assoc ::min-age min-age))
|
(assoc ::min-age (db/interval min-age)))]
|
||||||
|
|
||||||
total (reduce (fn [total file]
|
(try
|
||||||
(process-file! cfg file)
|
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||||
(inc total))
|
(let [cfg (update cfg ::sto/storage sto/configure conn)
|
||||||
0
|
processed? (process-file! cfg)]
|
||||||
(get-candidates cfg))]
|
(when (and processed? (contains? cf/flags :tiered-file-data-storage))
|
||||||
|
(wrk/submit! (-> cfg
|
||||||
|
(assoc ::wrk/task :offload-file-data)
|
||||||
|
(assoc ::wrk/params props)
|
||||||
|
(assoc ::wrk/priority 10)
|
||||||
|
(assoc ::wrk/delay 1000))))
|
||||||
|
processed?)))
|
||||||
|
|
||||||
(l/inf :hint "finished"
|
(catch Throwable cause
|
||||||
:min-age (dt/format-duration min-age)
|
(l/err :hint "error on cleaning file"
|
||||||
:processed total)
|
:file-id (str (:file-id props))
|
||||||
|
:cause cause))))))
|
||||||
;; Allow optional rollback passed by params
|
|
||||||
(when (:rollback? props)
|
|
||||||
(db/rollback! conn))
|
|
||||||
|
|
||||||
{:processed total})))))
|
|
||||||
|
|
64
backend/src/app/tasks/file_gc_scheduler.clj
Normal file
64
backend/src/app/tasks/file_gc_scheduler.clj
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
(ns app.tasks.file-gc-scheduler
|
||||||
|
"A maintenance task that is responsible of properly scheduling the
|
||||||
|
file-gc task for all files that matches the eligibility threshold."
|
||||||
|
(:require
|
||||||
|
[app.config :as cf]
|
||||||
|
[app.db :as db]
|
||||||
|
[app.util.time :as dt]
|
||||||
|
[app.worker :as wrk]
|
||||||
|
[clojure.spec.alpha :as s]
|
||||||
|
[integrant.core :as ig]))
|
||||||
|
|
||||||
|
(def ^:private
|
||||||
|
sql:get-candidates
|
||||||
|
"SELECT f.id,
|
||||||
|
f.modified_at
|
||||||
|
FROM file AS f
|
||||||
|
WHERE f.has_media_trimmed IS false
|
||||||
|
AND f.modified_at < now() - ?::interval
|
||||||
|
AND f.deleted_at IS NULL
|
||||||
|
ORDER BY f.modified_at DESC
|
||||||
|
FOR UPDATE
|
||||||
|
SKIP LOCKED")
|
||||||
|
|
||||||
|
(defn- get-candidates
|
||||||
|
[{:keys [::db/conn ::min-age] :as cfg}]
|
||||||
|
(let [min-age (db/interval min-age)]
|
||||||
|
(db/cursor conn [sql:get-candidates min-age] {:chunk-size 10})))
|
||||||
|
|
||||||
|
(defn- schedule!
|
||||||
|
[{:keys [::min-age] :as cfg}]
|
||||||
|
(let [total (reduce (fn [total {:keys [id]}]
|
||||||
|
(let [params {:file-id id :min-age min-age}]
|
||||||
|
(wrk/submit! (assoc cfg ::wrk/params params))
|
||||||
|
(inc total)))
|
||||||
|
0
|
||||||
|
(get-candidates cfg))]
|
||||||
|
|
||||||
|
{:processed total}))
|
||||||
|
|
||||||
|
(defmethod ig/pre-init-spec ::handler [_]
|
||||||
|
(s/keys :req [::db/pool]))
|
||||||
|
|
||||||
|
(defmethod ig/prep-key ::handler
|
||||||
|
[_ cfg]
|
||||||
|
(assoc cfg ::min-age (cf/get-deletion-delay)))
|
||||||
|
|
||||||
|
(defmethod ig/init-key ::handler
|
||||||
|
[_ cfg]
|
||||||
|
(fn [{:keys [props] :as task}]
|
||||||
|
(let [min-age (dt/duration (or (:min-age props) (::min-age cfg)))]
|
||||||
|
(-> cfg
|
||||||
|
(assoc ::db/rollback (:rollback? props))
|
||||||
|
(assoc ::min-age min-age)
|
||||||
|
(assoc ::wrk/task :file-gc)
|
||||||
|
(assoc ::wrk/priority 10)
|
||||||
|
(assoc ::wrk/mark-retries 0)
|
||||||
|
(assoc ::wrk/delay 1000)
|
||||||
|
(db/tx-run! schedule!)))))
|
|
@ -10,35 +10,59 @@
|
||||||
(:require
|
(:require
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
|
[app.features.fdata :as feat.fdata]
|
||||||
|
[app.storage :as sto]
|
||||||
[app.util.time :as dt]
|
[app.util.time :as dt]
|
||||||
[clojure.spec.alpha :as s]
|
[clojure.spec.alpha :as s]
|
||||||
[integrant.core :as ig]))
|
[integrant.core :as ig]))
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
sql:delete-files-xlog
|
sql:delete-files-xlog
|
||||||
"delete from file_change
|
"DELETE FROM file_change
|
||||||
where created_at < now() - ?::interval
|
WHERE id IN (SELECT id FROM file_change
|
||||||
and label is NULL")
|
WHERE label IS NULL
|
||||||
|
AND created_at < ?
|
||||||
|
ORDER BY created_at LIMIT ?)
|
||||||
|
RETURNING id, data_backend, data_ref_id")
|
||||||
|
|
||||||
|
(def xf:filter-offloded
|
||||||
|
(comp
|
||||||
|
(filter feat.fdata/offloaded?)
|
||||||
|
(keep :data-ref-id)))
|
||||||
|
|
||||||
|
(defn- delete-in-chunks
|
||||||
|
[{:keys [::chunk-size ::threshold] :as cfg}]
|
||||||
|
(let [storage (sto/resolve cfg ::db/reuse-conn true)]
|
||||||
|
(loop [total 0]
|
||||||
|
(let [chunk (db/exec! cfg [sql:delete-files-xlog threshold chunk-size])
|
||||||
|
length (count chunk)]
|
||||||
|
|
||||||
|
;; touch all references on offloaded changes entries
|
||||||
|
(doseq [data-ref-id (sequence xf:filter-offloded chunk)]
|
||||||
|
(l/trc :hint "touching referenced storage object"
|
||||||
|
:storage-object-id (str data-ref-id))
|
||||||
|
(sto/touch-object! storage data-ref-id))
|
||||||
|
|
||||||
|
(if (pos? length)
|
||||||
|
(recur (+ total length))
|
||||||
|
total)))))
|
||||||
|
|
||||||
(defmethod ig/pre-init-spec ::handler [_]
|
(defmethod ig/pre-init-spec ::handler [_]
|
||||||
(s/keys :req [::db/pool]))
|
(s/keys :req [::db/pool]))
|
||||||
|
|
||||||
(defmethod ig/prep-key ::handler
|
|
||||||
[_ cfg]
|
|
||||||
(assoc cfg ::min-age (dt/duration {:hours 72})))
|
|
||||||
|
|
||||||
(defmethod ig/init-key ::handler
|
(defmethod ig/init-key ::handler
|
||||||
[_ {:keys [::db/pool] :as cfg}]
|
[_ cfg]
|
||||||
(fn [{:keys [props] :as task}]
|
(fn [{:keys [props] :as task}]
|
||||||
(let [min-age (or (:min-age props) (::min-age cfg))]
|
(let [min-age (or (:min-age props)
|
||||||
(db/with-atomic [conn pool]
|
(dt/duration "72h"))
|
||||||
(let [interval (db/interval min-age)
|
chunk-size (:chunk-size props 5000)
|
||||||
result (db/exec-one! conn [sql:delete-files-xlog interval])
|
threshold (dt/minus (dt/now) min-age)]
|
||||||
result (db/get-update-count result)]
|
|
||||||
|
|
||||||
(l/info :hint "task finished" :min-age (dt/format-duration min-age) :total result)
|
(-> cfg
|
||||||
|
(assoc ::db/rollback (:rollback props false))
|
||||||
(when (:rollback? props)
|
(assoc ::threshold threshold)
|
||||||
(db/rollback! conn))
|
(assoc ::chunk-size chunk-size)
|
||||||
|
(db/tx-run! (fn [cfg]
|
||||||
result)))))
|
(let [total (delete-in-chunks cfg)]
|
||||||
|
(l/trc :hint "file xlog cleaned" :total total)
|
||||||
|
total)))))))
|
||||||
|
|
|
@ -11,7 +11,6 @@
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.media :as media]
|
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
[app.util.time :as dt]
|
[app.util.time :as dt]
|
||||||
[clojure.spec.alpha :as s]
|
[clojure.spec.alpha :as s]
|
||||||
|
@ -126,7 +125,7 @@
|
||||||
0)))
|
0)))
|
||||||
|
|
||||||
(def ^:private sql:get-files
|
(def ^:private sql:get-files
|
||||||
"SELECT id, deleted_at, project_id
|
"SELECT id, deleted_at, project_id, data_backend, data_ref_id
|
||||||
FROM file
|
FROM file
|
||||||
WHERE deleted_at IS NOT NULL
|
WHERE deleted_at IS NOT NULL
|
||||||
AND deleted_at < now() - ?::interval
|
AND deleted_at < now() - ?::interval
|
||||||
|
@ -136,15 +135,18 @@
|
||||||
SKIP LOCKED")
|
SKIP LOCKED")
|
||||||
|
|
||||||
(defn- delete-files!
|
(defn- delete-files!
|
||||||
[{:keys [::db/conn ::min-age ::chunk-size] :as cfg}]
|
[{:keys [::db/conn ::sto/storage ::min-age ::chunk-size] :as cfg}]
|
||||||
(->> (db/cursor conn [sql:get-files min-age chunk-size] {:chunk-size 1})
|
(->> (db/cursor conn [sql:get-files min-age chunk-size] {:chunk-size 1})
|
||||||
(reduce (fn [total {:keys [id deleted-at project-id]}]
|
(reduce (fn [total {:keys [id deleted-at project-id] :as file}]
|
||||||
(l/trc :hint "permanently delete"
|
(l/trc :hint "permanently delete"
|
||||||
:rel "file"
|
:rel "file"
|
||||||
:id (str id)
|
:id (str id)
|
||||||
:project-id (str project-id)
|
:project-id (str project-id)
|
||||||
:deleted-at (dt/format-instant deleted-at))
|
:deleted-at (dt/format-instant deleted-at))
|
||||||
|
|
||||||
|
(when (= "objects-storage" (:data-backend file))
|
||||||
|
(sto/touch-object! storage (:data-ref-id file)))
|
||||||
|
|
||||||
;; And finally, permanently delete the file.
|
;; And finally, permanently delete the file.
|
||||||
(db/delete! conn :file {:id id})
|
(db/delete! conn :file {:id id})
|
||||||
|
|
||||||
|
@ -210,7 +212,7 @@
|
||||||
0)))
|
0)))
|
||||||
|
|
||||||
(def ^:private sql:get-file-data-fragments
|
(def ^:private sql:get-file-data-fragments
|
||||||
"SELECT file_id, id, deleted_at
|
"SELECT file_id, id, deleted_at, data_ref_id
|
||||||
FROM file_data_fragment
|
FROM file_data_fragment
|
||||||
WHERE deleted_at IS NOT NULL
|
WHERE deleted_at IS NOT NULL
|
||||||
AND deleted_at < now() - ?::interval
|
AND deleted_at < now() - ?::interval
|
||||||
|
@ -220,15 +222,16 @@
|
||||||
SKIP LOCKED")
|
SKIP LOCKED")
|
||||||
|
|
||||||
(defn- delete-file-data-fragments!
|
(defn- delete-file-data-fragments!
|
||||||
[{:keys [::db/conn ::min-age ::chunk-size] :as cfg}]
|
[{:keys [::db/conn ::sto/storage ::min-age ::chunk-size] :as cfg}]
|
||||||
(->> (db/cursor conn [sql:get-file-data-fragments min-age chunk-size] {:chunk-size 1})
|
(->> (db/cursor conn [sql:get-file-data-fragments min-age chunk-size] {:chunk-size 1})
|
||||||
(reduce (fn [total {:keys [file-id id deleted-at]}]
|
(reduce (fn [total {:keys [file-id id deleted-at data-ref-id]}]
|
||||||
(l/trc :hint "permanently delete"
|
(l/trc :hint "permanently delete"
|
||||||
:rel "file-data-fragment"
|
:rel "file-data-fragment"
|
||||||
:id (str id)
|
:id (str id)
|
||||||
:file-id (str file-id)
|
:file-id (str file-id)
|
||||||
:deleted-at (dt/format-instant deleted-at))
|
:deleted-at (dt/format-instant deleted-at))
|
||||||
|
|
||||||
|
(some->> data-ref-id (sto/touch-object! storage))
|
||||||
(db/delete! conn :file-data-fragment {:file-id file-id :id id})
|
(db/delete! conn :file-data-fragment {:file-id file-id :id id})
|
||||||
|
|
||||||
(inc total))
|
(inc total))
|
||||||
|
@ -299,9 +302,7 @@
|
||||||
[_ cfg]
|
[_ cfg]
|
||||||
(fn [{:keys [props] :as task}]
|
(fn [{:keys [props] :as task}]
|
||||||
(let [min-age (dt/duration (or (:min-age props) (::min-age cfg)))
|
(let [min-age (dt/duration (or (:min-age props) (::min-age cfg)))
|
||||||
cfg (-> cfg
|
cfg (assoc cfg ::min-age (db/interval min-age))]
|
||||||
(assoc ::min-age (db/interval min-age))
|
|
||||||
(update ::sto/storage media/configure-assets-storage))]
|
|
||||||
|
|
||||||
(loop [procs (map deref deletion-proc-vars)
|
(loop [procs (map deref deletion-proc-vars)
|
||||||
total 0]
|
total 0]
|
||||||
|
|
124
backend/src/app/tasks/offload_file_data.clj
Normal file
124
backend/src/app/tasks/offload_file_data.clj
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
(ns app.tasks.offload-file-data
|
||||||
|
"A maintenance task responsible of moving file data from hot
|
||||||
|
storage (the database row) to a cold storage (fs or s3)."
|
||||||
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.logging :as l]
|
||||||
|
[app.db :as db]
|
||||||
|
[app.db.sql :as-alias sql]
|
||||||
|
[app.storage :as sto]
|
||||||
|
[clojure.spec.alpha :as s]
|
||||||
|
[integrant.core :as ig]))
|
||||||
|
|
||||||
|
(defn- offload-file-data!
|
||||||
|
[{:keys [::db/conn ::sto/storage ::file-id] :as cfg}]
|
||||||
|
(let [file (db/get conn :file {:id file-id}
|
||||||
|
{::sql/for-update true})]
|
||||||
|
(when (nil? (:data file))
|
||||||
|
(ex/raise :hint "file already offloaded"
|
||||||
|
:type :internal
|
||||||
|
:code :file-already-offloaded
|
||||||
|
:file-id file-id))
|
||||||
|
|
||||||
|
(let [data (sto/content (:data file))
|
||||||
|
sobj (sto/put-object! storage
|
||||||
|
{::sto/content data
|
||||||
|
::sto/touch true
|
||||||
|
:bucket "file-data"
|
||||||
|
:content-type "application/octet-stream"
|
||||||
|
:file-id file-id})]
|
||||||
|
|
||||||
|
(l/trc :hint "offload file data"
|
||||||
|
:file-id (str file-id)
|
||||||
|
:storage-id (str (:id sobj)))
|
||||||
|
|
||||||
|
(db/update! conn :file
|
||||||
|
{:data-backend "objects-storage"
|
||||||
|
:data-ref-id (:id sobj)
|
||||||
|
:data nil}
|
||||||
|
{:id file-id}
|
||||||
|
{::db/return-keys false}))))
|
||||||
|
|
||||||
|
(defn- offload-file-data-fragments!
|
||||||
|
[{:keys [::db/conn ::sto/storage ::file-id] :as cfg}]
|
||||||
|
(doseq [fragment (db/query conn :file-data-fragment
|
||||||
|
{:file-id file-id
|
||||||
|
:deleted-at nil
|
||||||
|
:data-backend nil}
|
||||||
|
{::db/for-update true})]
|
||||||
|
(let [data (sto/content (:data fragment))
|
||||||
|
sobj (sto/put-object! storage
|
||||||
|
{::sto/content data
|
||||||
|
::sto/touch true
|
||||||
|
:bucket "file-data-fragment"
|
||||||
|
:content-type "application/octet-stream"
|
||||||
|
:file-id file-id
|
||||||
|
:file-fragment-id (:id fragment)})]
|
||||||
|
|
||||||
|
(l/trc :hint "offload file data fragment"
|
||||||
|
:file-id (str file-id)
|
||||||
|
:file-fragment-id (str (:id fragment))
|
||||||
|
:storage-id (str (:id sobj)))
|
||||||
|
|
||||||
|
(db/update! conn :file-data-fragment
|
||||||
|
{:data-backend "objects-storage"
|
||||||
|
:data-ref-id (:id sobj)
|
||||||
|
:data nil}
|
||||||
|
{:id (:id fragment)}
|
||||||
|
{::db/return-keys false}))))
|
||||||
|
|
||||||
|
(def sql:get-snapshots
|
||||||
|
"SELECT fc.*
|
||||||
|
FROM file_change AS fc
|
||||||
|
WHERE fc.file_id = ?
|
||||||
|
AND fc.label IS NOT NULL
|
||||||
|
AND fc.data IS NOT NULL
|
||||||
|
AND fc.data_backend IS NULL")
|
||||||
|
|
||||||
|
(defn- offload-file-snapshots!
|
||||||
|
[{:keys [::db/conn ::sto/storage ::file-id] :as cfg}]
|
||||||
|
(doseq [snapshot (db/exec! conn [sql:get-snapshots file-id])]
|
||||||
|
(let [data (sto/content (:data snapshot))
|
||||||
|
sobj (sto/put-object! storage
|
||||||
|
{::sto/content data
|
||||||
|
::sto/touch true
|
||||||
|
:bucket "file-change"
|
||||||
|
:content-type "application/octet-stream"
|
||||||
|
:file-id file-id
|
||||||
|
:file-change-id (:id snapshot)})]
|
||||||
|
|
||||||
|
(l/trc :hint "offload file change"
|
||||||
|
:file-id (str file-id)
|
||||||
|
:file-change-id (str (:id snapshot))
|
||||||
|
:storage-id (str (:id sobj)))
|
||||||
|
|
||||||
|
(db/update! conn :file-change
|
||||||
|
{:data-backend "objects-storage"
|
||||||
|
:data-ref-id (:id sobj)
|
||||||
|
:data nil}
|
||||||
|
{:id (:id snapshot)}
|
||||||
|
{::db/return-keys false}))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; HANDLER
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defmethod ig/pre-init-spec ::handler [_]
|
||||||
|
(s/keys :req [::db/pool ::sto/storage]))
|
||||||
|
|
||||||
|
(defmethod ig/init-key ::handler
|
||||||
|
[_ cfg]
|
||||||
|
(fn [{:keys [props] :as task}]
|
||||||
|
(-> cfg
|
||||||
|
(assoc ::db/rollback (:rollback? props))
|
||||||
|
(assoc ::file-id (:file-id props))
|
||||||
|
(db/tx-run! (fn [cfg]
|
||||||
|
(offload-file-data! cfg)
|
||||||
|
(offload-file-data-fragments! cfg)
|
||||||
|
(offload-file-snapshots! cfg))))))
|
|
@ -62,19 +62,25 @@
|
||||||
[conn]
|
[conn]
|
||||||
(-> (db/exec-one! conn ["SELECT count(*) AS count FROM file"]) :count))
|
(-> (db/exec-one! conn ["SELECT count(*) AS count FROM file"]) :count))
|
||||||
|
|
||||||
|
(def ^:private sql:num-file-changes
|
||||||
|
"SELECT count(*) AS count
|
||||||
|
FROM file_change
|
||||||
|
WHERE created_at < date_trunc('day', now()) + '24 hours'::interval
|
||||||
|
AND created_at > date_trunc('day', now())")
|
||||||
|
|
||||||
(defn- get-num-file-changes
|
(defn- get-num-file-changes
|
||||||
[conn]
|
[conn]
|
||||||
(let [sql (str "SELECT count(*) AS count "
|
(-> (db/exec-one! conn [sql:num-file-changes]) :count))
|
||||||
" FROM file_change "
|
|
||||||
" where date_trunc('day', created_at) = date_trunc('day', now())")]
|
(def ^:private sql:num-touched-files
|
||||||
(-> (db/exec-one! conn [sql]) :count)))
|
"SELECT count(distinct file_id) AS count
|
||||||
|
FROM file_change
|
||||||
|
WHERE created_at < date_trunc('day', now()) + '24 hours'::interval
|
||||||
|
AND created_at > date_trunc('day', now())")
|
||||||
|
|
||||||
(defn- get-num-touched-files
|
(defn- get-num-touched-files
|
||||||
[conn]
|
[conn]
|
||||||
(let [sql (str "SELECT count(distinct file_id) AS count "
|
(-> (db/exec-one! conn [sql:num-touched-files]) :count))
|
||||||
" FROM file_change "
|
|
||||||
" where date_trunc('day', created_at) = date_trunc('day', now())")]
|
|
||||||
(-> (db/exec-one! conn [sql]) :count)))
|
|
||||||
|
|
||||||
(defn- get-num-users
|
(defn- get-num-users
|
||||||
[conn]
|
[conn]
|
||||||
|
|
|
@ -13,7 +13,6 @@
|
||||||
[clojure.pprint :as pprint]
|
[clojure.pprint :as pprint]
|
||||||
[datoteka.fs :as fs]))
|
[datoteka.fs :as fs]))
|
||||||
|
|
||||||
|
|
||||||
(prefer-method print-method
|
(prefer-method print-method
|
||||||
clojure.lang.IRecord
|
clojure.lang.IRecord
|
||||||
clojure.lang.IDeref)
|
clojure.lang.IDeref)
|
||||||
|
@ -26,7 +25,6 @@
|
||||||
clojure.lang.IPersistentMap
|
clojure.lang.IPersistentMap
|
||||||
clojure.lang.IDeref)
|
clojure.lang.IDeref)
|
||||||
|
|
||||||
|
|
||||||
(sm/register! ::fs/path
|
(sm/register! ::fs/path
|
||||||
{:type ::fs/path
|
{:type ::fs/path
|
||||||
:pred fs/path?
|
:pred fs/path?
|
||||||
|
@ -36,6 +34,6 @@
|
||||||
:error/message "expected a valid fs path instance"
|
:error/message "expected a valid fs path instance"
|
||||||
:error/code "errors.invalid-path"
|
:error/code "errors.invalid-path"
|
||||||
:gen/gen (sg/generator :string)
|
:gen/gen (sg/generator :string)
|
||||||
|
:decode/string fs/path
|
||||||
::oapi/type "string"
|
::oapi/type "string"
|
||||||
::oapi/format "unix-path"
|
::oapi/format "unix-path"}})
|
||||||
::oapi/decode fs/path}})
|
|
||||||
|
|
|
@ -141,21 +141,22 @@
|
||||||
|
|
||||||
;; --- INSTANT
|
;; --- INSTANT
|
||||||
|
|
||||||
|
(defn instant?
|
||||||
|
[v]
|
||||||
|
(instance? Instant v))
|
||||||
|
|
||||||
(defn instant
|
(defn instant
|
||||||
([s]
|
([s]
|
||||||
(if (int? s)
|
(cond
|
||||||
(Instant/ofEpochMilli s)
|
(instant? s) s
|
||||||
(Instant/parse s)))
|
(int? s) (Instant/ofEpochMilli s)
|
||||||
|
:else (Instant/parse s)))
|
||||||
([s fmt]
|
([s fmt]
|
||||||
(case fmt
|
(case fmt
|
||||||
:rfc1123 (Instant/from (.parse DateTimeFormatter/RFC_1123_DATE_TIME ^String s))
|
:rfc1123 (Instant/from (.parse DateTimeFormatter/RFC_1123_DATE_TIME ^String s))
|
||||||
:iso (Instant/from (.parse DateTimeFormatter/ISO_INSTANT ^String s))
|
:iso (Instant/from (.parse DateTimeFormatter/ISO_INSTANT ^String s))
|
||||||
:iso8601 (Instant/from (.parse DateTimeFormatter/ISO_INSTANT ^String s)))))
|
:iso8601 (Instant/from (.parse DateTimeFormatter/ISO_INSTANT ^String s)))))
|
||||||
|
|
||||||
(defn instant?
|
|
||||||
[v]
|
|
||||||
(instance? Instant v))
|
|
||||||
|
|
||||||
(defn is-after?
|
(defn is-after?
|
||||||
[da db]
|
[da db]
|
||||||
(.isAfter ^Instant da ^Instant db))
|
(.isAfter ^Instant da ^Instant db))
|
||||||
|
@ -374,7 +375,10 @@
|
||||||
:type-properties
|
:type-properties
|
||||||
{:error/message "should be an instant"
|
{:error/message "should be an instant"
|
||||||
:title "instant"
|
:title "instant"
|
||||||
::sm/decode instant
|
:decode/string instant
|
||||||
|
:encode/string format-instant
|
||||||
|
:decode/json instant
|
||||||
|
:encode/json format-instant
|
||||||
:gen/gen (tgen/fmap (fn [i] (in-past i)) tgen/pos-int)
|
:gen/gen (tgen/fmap (fn [i] (in-past i)) tgen/pos-int)
|
||||||
::oapi/type "string"
|
::oapi/type "string"
|
||||||
::oapi/format "iso"}})
|
::oapi/format "iso"}})
|
||||||
|
@ -386,6 +390,9 @@
|
||||||
{:error/message "should be a duration"
|
{:error/message "should be a duration"
|
||||||
:gen/gen (tgen/fmap duration tgen/pos-int)
|
:gen/gen (tgen/fmap duration tgen/pos-int)
|
||||||
:title "duration"
|
:title "duration"
|
||||||
::sm/decode duration
|
:decode/string duration
|
||||||
|
:encode/string format-duration
|
||||||
|
:decode/json duration
|
||||||
|
:encode/json format-duration
|
||||||
::oapi/type "string"
|
::oapi/type "string"
|
||||||
::oapi/format "duration"}})
|
::oapi/format "duration"}})
|
||||||
|
|
|
@ -76,7 +76,7 @@
|
||||||
:enable-feature-fdata-pointer-map
|
:enable-feature-fdata-pointer-map
|
||||||
:enable-feature-fdata-objets-map
|
:enable-feature-fdata-objets-map
|
||||||
:enable-feature-components-v2
|
:enable-feature-components-v2
|
||||||
:enable-file-snapshot
|
:enable-auto-file-snapshot
|
||||||
:disable-file-validation])
|
:disable-file-validation])
|
||||||
|
|
||||||
(defn state-init
|
(defn state-init
|
||||||
|
@ -304,16 +304,18 @@
|
||||||
([params] (update-file* *system* params))
|
([params] (update-file* *system* params))
|
||||||
([system {:keys [file-id changes session-id profile-id revn]
|
([system {:keys [file-id changes session-id profile-id revn]
|
||||||
:or {session-id (uuid/next) revn 0}}]
|
:or {session-id (uuid/next) revn 0}}]
|
||||||
(db/tx-run! system (fn [{:keys [::db/conn] :as system}]
|
(-> system
|
||||||
|
(assoc ::files.update/timestamp (dt/now))
|
||||||
|
(db/tx-run! (fn [{:keys [::db/conn] :as system}]
|
||||||
(let [file (files.update/get-file conn file-id)]
|
(let [file (files.update/get-file conn file-id)]
|
||||||
(files.update/update-file system
|
(#'files.update/update-file* system
|
||||||
{:id file-id
|
{:id file-id
|
||||||
:revn revn
|
:revn revn
|
||||||
:file file
|
:file file
|
||||||
:features (:features file)
|
:features (:features file)
|
||||||
:changes changes
|
:changes changes
|
||||||
:session-id session-id
|
:session-id session-id
|
||||||
:profile-id profile-id}))))))
|
:profile-id profile-id})))))))
|
||||||
|
|
||||||
(declare command!)
|
(declare command!)
|
||||||
|
|
||||||
|
|
|
@ -21,10 +21,9 @@
|
||||||
(with-mocks [submit-mock {:target 'app.worker/submit! :return nil}]
|
(with-mocks [submit-mock {:target 'app.worker/submit! :return nil}]
|
||||||
(let [prof (th/create-profile* 1 {:is-active true})
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
res (th/run-task! :process-webhook-event
|
res (th/run-task! :process-webhook-event
|
||||||
{:event
|
|
||||||
{:type "command"
|
{:type "command"
|
||||||
:name "create-project"
|
:name "create-project"
|
||||||
:props {:team-id (:default-team-id prof)}}})]
|
:props {:team-id (:default-team-id prof)}})]
|
||||||
|
|
||||||
(t/is (= 0 (:call-count @submit-mock)))
|
(t/is (= 0 (:call-count @submit-mock)))
|
||||||
(t/is (nil? res)))))
|
(t/is (nil? res)))))
|
||||||
|
@ -34,10 +33,9 @@
|
||||||
(let [prof (th/create-profile* 1 {:is-active true})
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
whk (th/create-webhook* {:team-id (:default-team-id prof)})
|
whk (th/create-webhook* {:team-id (:default-team-id prof)})
|
||||||
res (th/run-task! :process-webhook-event
|
res (th/run-task! :process-webhook-event
|
||||||
{:event
|
|
||||||
{:type "command"
|
{:type "command"
|
||||||
:name "create-project"
|
:name "create-project"
|
||||||
:props {:team-id (:default-team-id prof)}}})]
|
:props {:team-id (:default-team-id prof)}})]
|
||||||
|
|
||||||
(t/is (= 1 (:call-count @submit-mock)))
|
(t/is (= 1 (:call-count @submit-mock)))
|
||||||
(t/is (nil? res)))))
|
(t/is (nil? res)))))
|
||||||
|
|
|
@ -39,7 +39,6 @@
|
||||||
(t/is (nil? error))
|
(t/is (nil? error))
|
||||||
(t/is (map? result))
|
(t/is (map? result))
|
||||||
(t/is (contains? (meta result) :app.http/headers))
|
(t/is (contains? (meta result) :app.http/headers))
|
||||||
(t/is (contains? (meta result) :app.rpc.cond/key))
|
|
||||||
|
|
||||||
(let [etag (-> result meta :app.http/headers (get "etag"))
|
(let [etag (-> result meta :app.http/headers (get "etag"))
|
||||||
{:keys [error result]} (th/command! (assoc params ::cond/key etag))]
|
{:keys [error result]} (th/command! (assoc params ::cond/key etag))]
|
||||||
|
|
|
@ -25,6 +25,20 @@
|
||||||
(t/use-fixtures :once th/state-init)
|
(t/use-fixtures :once th/state-init)
|
||||||
(t/use-fixtures :each th/database-reset)
|
(t/use-fixtures :each th/database-reset)
|
||||||
|
|
||||||
|
(defn- update-file!
|
||||||
|
[& {:keys [profile-id file-id changes revn] :or {revn 0}}]
|
||||||
|
(let [params {::th/type :update-file
|
||||||
|
::rpc/profile-id profile-id
|
||||||
|
:id file-id
|
||||||
|
:session-id (uuid/random)
|
||||||
|
:revn revn
|
||||||
|
:features cfeat/supported-features
|
||||||
|
:changes changes}
|
||||||
|
out (th/command! params)]
|
||||||
|
;; (th/print-result! out)
|
||||||
|
(t/is (nil? (:error out)))
|
||||||
|
(:result out)))
|
||||||
|
|
||||||
(t/deftest files-crud
|
(t/deftest files-crud
|
||||||
(let [prof (th/create-profile* 1 {:is-active true})
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
team-id (:default-team-id prof)
|
team-id (:default-team-id prof)
|
||||||
|
@ -149,8 +163,7 @@
|
||||||
shape-id (uuid/random)]
|
shape-id (uuid/random)]
|
||||||
|
|
||||||
;; Preventive file-gc
|
;; Preventive file-gc
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
;; Check the number of fragments before adding the page
|
;; Check the number of fragments before adding the page
|
||||||
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)})]
|
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)})]
|
||||||
|
@ -171,8 +184,7 @@
|
||||||
(t/is (= 3 (count rows))))
|
(t/is (= 3 (count rows))))
|
||||||
|
|
||||||
;; The file-gc should mark for remove unused fragments
|
;; The file-gc should mark for remove unused fragments
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
;; Check the number of fragments
|
;; Check the number of fragments
|
||||||
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)})]
|
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)})]
|
||||||
|
@ -210,15 +222,13 @@
|
||||||
(t/is (= 3 (count rows))))
|
(t/is (= 3 (count rows))))
|
||||||
|
|
||||||
;; The file-gc should mark for remove unused fragments
|
;; The file-gc should mark for remove unused fragments
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
;; The objects-gc should remove unused fragments
|
;; The objects-gc should remove unused fragments
|
||||||
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||||
(t/is (= 3 (:processed res))))
|
(t/is (= 3 (:processed res))))
|
||||||
|
|
||||||
;; Check the number of fragments; should be 3 because changes
|
;; Check the number of fragments;
|
||||||
;; are also holding pointers to fragments;
|
|
||||||
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)
|
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)
|
||||||
:deleted-at nil})]
|
:deleted-at nil})]
|
||||||
(t/is (= 2 (count rows))))
|
(t/is (= 2 (count rows))))
|
||||||
|
@ -231,8 +241,7 @@
|
||||||
|
|
||||||
;; The file-gc should remove fragments related to changes
|
;; The file-gc should remove fragments related to changes
|
||||||
;; snapshots previously deleted.
|
;; snapshots previously deleted.
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
;; Check the number of fragments;
|
;; Check the number of fragments;
|
||||||
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)})]
|
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)})]
|
||||||
|
@ -325,12 +334,10 @@
|
||||||
(t/is (= 0 (:delete res))))
|
(t/is (= 0 (:delete res))))
|
||||||
|
|
||||||
;; run the file-gc task immediately without forced min-age
|
;; run the file-gc task immediately without forced min-age
|
||||||
(let [res (th/run-task! :file-gc)]
|
(t/is (false? (th/run-task! :file-gc {:file-id (:id file)})))
|
||||||
(t/is (= 0 (:processed res))))
|
|
||||||
|
|
||||||
;; run the task again
|
;; run the task again
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
;; retrieve file and check trimmed attribute
|
;; retrieve file and check trimmed attribute
|
||||||
(let [row (th/db-get :file {:id (:id file)})]
|
(let [row (th/db-get :file {:id (:id file)})]
|
||||||
|
@ -367,8 +374,7 @@
|
||||||
;; Now, we have deleted the usage of pointers to the
|
;; Now, we have deleted the usage of pointers to the
|
||||||
;; file-media-objects, if we paste file-gc, they should be marked
|
;; file-media-objects, if we paste file-gc, they should be marked
|
||||||
;; as deleted.
|
;; as deleted.
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||||
(t/is (= 3 (:processed res))))
|
(t/is (= 3 (:processed res))))
|
||||||
|
@ -490,12 +496,10 @@
|
||||||
:strokes [{:opacity 1 :stroke-image {:id (:id fmo5) :width 100 :height 100 :mtype "image/jpeg"}}]})}])
|
:strokes [{:opacity 1 :stroke-image {:id (:id fmo5) :width 100 :height 100 :mtype "image/jpeg"}}]})}])
|
||||||
|
|
||||||
;; run the file-gc task immediately without forced min-age
|
;; run the file-gc task immediately without forced min-age
|
||||||
(let [res (th/run-task! :file-gc)]
|
(t/is (false? (th/run-task! :file-gc {:file-id (:id file)})))
|
||||||
(t/is (= 0 (:processed res))))
|
|
||||||
|
|
||||||
;; run the task again
|
;; run the task again
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||||
(t/is (= 2 (:processed res))))
|
(t/is (= 2 (:processed res))))
|
||||||
|
@ -534,9 +538,7 @@
|
||||||
;; Now, we have deleted the usage of pointers to the
|
;; Now, we have deleted the usage of pointers to the
|
||||||
;; file-media-objects, if we paste file-gc, they should be marked
|
;; file-media-objects, if we paste file-gc, they should be marked
|
||||||
;; as deleted.
|
;; as deleted.
|
||||||
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||||
(t/is (= 7 (:processed res))))
|
(t/is (= 7 (:processed res))))
|
||||||
|
@ -581,7 +583,7 @@
|
||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(:result out)))
|
(:result out)))
|
||||||
|
|
||||||
(update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}]
|
#_(update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}]
|
||||||
(let [params {::th/type :update-file
|
(let [params {::th/type :update-file
|
||||||
::rpc/profile-id profile-id
|
::rpc/profile-id profile-id
|
||||||
:id file-id
|
:id file-id
|
||||||
|
@ -616,7 +618,6 @@
|
||||||
:frame-id frame-id-2)]
|
:frame-id frame-id-2)]
|
||||||
|
|
||||||
;; Add a two frames
|
;; Add a two frames
|
||||||
|
|
||||||
(update-file!
|
(update-file!
|
||||||
:file-id (:id file)
|
:file-id (:id file)
|
||||||
:profile-id (:id profile)
|
:profile-id (:id profile)
|
||||||
|
@ -659,12 +660,10 @@
|
||||||
(t/is (= 0 (:delete res))))
|
(t/is (= 0 (:delete res))))
|
||||||
|
|
||||||
;; run the file-gc task immediately without forced min-age
|
;; run the file-gc task immediately without forced min-age
|
||||||
(let [res (th/run-task! :file-gc)]
|
(t/is (false? (th/run-task! :file-gc {:file-id (:id file)})))
|
||||||
(t/is (= 0 (:processed res))))
|
|
||||||
|
|
||||||
;; run the task again
|
;; run the task again
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
;; retrieve file and check trimmed attribute
|
;; retrieve file and check trimmed attribute
|
||||||
(let [row (th/db-get :file {:id (:id file)})]
|
(let [row (th/db-get :file {:id (:id file)})]
|
||||||
|
@ -693,8 +692,7 @@
|
||||||
:page-id page-id
|
:page-id page-id
|
||||||
:id frame-id-2}])
|
:id frame-id-2}])
|
||||||
|
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id file-id})]
|
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id file-id})]
|
||||||
(t/is (= 2 (count rows)))
|
(t/is (= 2 (count rows)))
|
||||||
|
@ -727,8 +725,7 @@
|
||||||
:page-id page-id
|
:page-id page-id
|
||||||
:id frame-id-1}])
|
:id frame-id-1}])
|
||||||
|
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id file-id})]
|
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id file-id})]
|
||||||
(t/is (= 1 (count rows)))
|
(t/is (= 1 (count rows)))
|
||||||
|
@ -1127,8 +1124,7 @@
|
||||||
(th/sleep 300)
|
(th/sleep 300)
|
||||||
|
|
||||||
;; run the task
|
;; run the task
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
;; check that object thumbnails are still here
|
;; check that object thumbnails are still here
|
||||||
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})]
|
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})]
|
||||||
|
@ -1157,8 +1153,7 @@
|
||||||
(t/is (= 2 (count rows))))
|
(t/is (= 2 (count rows))))
|
||||||
|
|
||||||
;; run the task again
|
;; run the task again
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
;; check that we have all object thumbnails
|
;; check that we have all object thumbnails
|
||||||
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})]
|
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})]
|
||||||
|
@ -1220,8 +1215,7 @@
|
||||||
(t/is (= 2 (count rows)))))
|
(t/is (= 2 (count rows)))))
|
||||||
|
|
||||||
(t/testing "gc task"
|
(t/testing "gc task"
|
||||||
(let [res (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed res))))
|
|
||||||
|
|
||||||
(let [rows (th/db-query :file-thumbnail {:file-id (:id file)})]
|
(let [rows (th/db-query :file-thumbnail {:file-id (:id file)})]
|
||||||
(t/is (= 2 (count rows)))
|
(t/is (= 2 (count rows)))
|
||||||
|
@ -1232,3 +1226,98 @@
|
||||||
|
|
||||||
(let [rows (th/db-query :file-thumbnail {:file-id (:id file)})]
|
(let [rows (th/db-query :file-thumbnail {:file-id (:id file)})]
|
||||||
(t/is (= 1 (count rows)))))))
|
(t/is (= 1 (count rows)))))))
|
||||||
|
|
||||||
|
(t/deftest file-tiered-storage
|
||||||
|
(let [profile (th/create-profile* 1)
|
||||||
|
file (th/create-file* 1 {:profile-id (:id profile)
|
||||||
|
:project-id (:default-project-id profile)
|
||||||
|
:is-shared false})
|
||||||
|
|
||||||
|
page-id (uuid/random)
|
||||||
|
shape-id (uuid/random)]
|
||||||
|
|
||||||
|
;; Preventive file-gc
|
||||||
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
|
|
||||||
|
;; Preventive objects-gc
|
||||||
|
(let [result (th/run-task! :objects-gc {:min-age 0})]
|
||||||
|
(t/is (= 1 (:processed result))))
|
||||||
|
|
||||||
|
;; Check the number of fragments before adding the page
|
||||||
|
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)})]
|
||||||
|
(t/is (= 1 (count rows)))
|
||||||
|
(t/is (every? #(some? (:data %)) rows)))
|
||||||
|
|
||||||
|
;; Mark the file ellegible again for GC
|
||||||
|
(th/db-update! :file
|
||||||
|
{:has-media-trimmed false}
|
||||||
|
{:id (:id file)})
|
||||||
|
|
||||||
|
;; Run FileGC again, with tiered storage activated
|
||||||
|
(with-redefs [app.config/flags (conj app.config/flags :tiered-file-data-storage)]
|
||||||
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
|
|
||||||
|
;; The FileGC task will schedule an inner taskq
|
||||||
|
(th/run-pending-tasks!))
|
||||||
|
|
||||||
|
;; Clean objects after file-gc
|
||||||
|
(let [result (th/run-task! :objects-gc {:min-age 0})]
|
||||||
|
(t/is (= 1 (:processed result))))
|
||||||
|
|
||||||
|
;; Check the number of fragments before adding the page
|
||||||
|
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)})]
|
||||||
|
(t/is (= 1 (count rows)))
|
||||||
|
(t/is (every? #(nil? (:data %)) rows))
|
||||||
|
(t/is (every? #(uuid? (:data-ref-id %)) rows))
|
||||||
|
(t/is (every? #(= "objects-storage" (:data-backend %)) rows)))
|
||||||
|
|
||||||
|
(let [file (th/db-get :file {:id (:id file)})
|
||||||
|
storage (sto/resolve th/*system*)]
|
||||||
|
(t/is (= "objects-storage" (:data-backend file)))
|
||||||
|
(t/is (nil? (:data file)))
|
||||||
|
(t/is (uuid? (:data-ref-id file)))
|
||||||
|
|
||||||
|
(let [sobj (sto/get-object storage (:data-ref-id file))]
|
||||||
|
(t/is (= "file-data" (:bucket (meta sobj))))
|
||||||
|
(t/is (= (:id file) (:file-id (meta sobj))))))
|
||||||
|
|
||||||
|
;; Add shape to page that should load from cold storage again into the hot storage (db)
|
||||||
|
(update-file!
|
||||||
|
:file-id (:id file)
|
||||||
|
:profile-id (:id profile)
|
||||||
|
:revn 0
|
||||||
|
:changes
|
||||||
|
[{:type :add-page
|
||||||
|
:name "test"
|
||||||
|
:id page-id}])
|
||||||
|
|
||||||
|
;; Check the number of fragments
|
||||||
|
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)})]
|
||||||
|
(t/is (= 2 (count rows))))
|
||||||
|
|
||||||
|
;; Check the number of fragments
|
||||||
|
(let [[row1 row2 :as rows]
|
||||||
|
(th/db-query :file-data-fragment
|
||||||
|
{:file-id (:id file)
|
||||||
|
:deleted-at nil}
|
||||||
|
{:order-by [:created-at]})]
|
||||||
|
;; (pp/pprint rows)
|
||||||
|
(t/is (= 2 (count rows)))
|
||||||
|
(t/is (nil? (:data row1)))
|
||||||
|
(t/is (= "objects-storage" (:data-backend row1)))
|
||||||
|
(t/is (bytes? (:data row2)))
|
||||||
|
(t/is (nil? (:data-backend row2))))
|
||||||
|
|
||||||
|
;; The file-gc should mark for remove unused fragments
|
||||||
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
|
|
||||||
|
;; The objects-gc should remove unused fragments
|
||||||
|
(let [res (th/run-task! :objects-gc {:min-age 0})]
|
||||||
|
(t/is (= 2 (:processed res))))
|
||||||
|
|
||||||
|
;; Check the number of fragments before adding the page
|
||||||
|
(let [rows (th/db-query :file-data-fragment {:file-id (:id file)})]
|
||||||
|
(t/is (= 2 (count rows)))
|
||||||
|
(t/is (every? #(bytes? (:data %)) rows))
|
||||||
|
(t/is (every? #(nil? (:data-ref-id %)) rows))
|
||||||
|
(t/is (every? #(nil? (:data-backend %)) rows)))))
|
||||||
|
|
|
@ -114,8 +114,7 @@
|
||||||
|
|
||||||
;; Run the File GC task that should remove unused file object
|
;; Run the File GC task that should remove unused file object
|
||||||
;; thumbnails
|
;; thumbnails
|
||||||
(let [result (th/run-task! :file-gc {:min-age 0})]
|
(th/run-task! :file-gc {:min-age 0 :file-id (:id file)})
|
||||||
(t/is (= 1 (:processed result))))
|
|
||||||
|
|
||||||
(let [result (th/run-task! :objects-gc {:min-age 0})]
|
(let [result (th/run-task! :objects-gc {:min-age 0})]
|
||||||
(t/is (= 3 (:processed result))))
|
(t/is (= 3 (:processed result))))
|
||||||
|
@ -134,7 +133,7 @@
|
||||||
(t/is (some? (sto/get-object storage (:media-id row2))))
|
(t/is (some? (sto/get-object storage (:media-id row2))))
|
||||||
|
|
||||||
;; run the task again
|
;; run the task again
|
||||||
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
|
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
|
||||||
(t/is (= 1 (:delete res)))
|
(t/is (= 1 (:delete res)))
|
||||||
(t/is (= 0 (:freeze res))))
|
(t/is (= 0 (:freeze res))))
|
||||||
|
|
||||||
|
@ -217,8 +216,7 @@
|
||||||
|
|
||||||
;; Run the File GC task that should remove unused file object
|
;; Run the File GC task that should remove unused file object
|
||||||
;; thumbnails
|
;; thumbnails
|
||||||
(let [result (th/run-task! :file-gc {:min-age 0})]
|
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
|
||||||
(t/is (= 1 (:processed result))))
|
|
||||||
|
|
||||||
(let [result (th/run-task! :objects-gc {:min-age 0})]
|
(let [result (th/run-task! :objects-gc {:min-age 0})]
|
||||||
(t/is (= 2 (:processed result))))
|
(t/is (= 2 (:processed result))))
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue