Compare commits

..

416 Commits

Author SHA1 Message Date
Alex Turchyn da44d51a3b Merge from docusealco/wip 2026-06-15 13:37:25 +03:00
Pete Matsyburka 73ce10b26a add cleanup document helper 2026-06-15 12:05:50 +03:00
Pete Matsyburka 13874b8830 add documents editor 2026-06-15 10:21:23 +03:00
Pete Matsyburka ac66809a05 fix date tz 2026-06-12 14:03:20 +03:00
Pete Matsyburka f5229d5dec fix zoom 2026-06-12 12:47:23 +03:00
Pete Matsyburka 222291d11d update gem 2026-06-09 21:51:06 +03:00
Pete Matsyburka 28f47e1093 handle template archived 2026-06-09 19:49:12 +03:00
Pete Matsyburka 7784346a97 update gem 2026-06-09 12:11:16 +03:00
Alex Turchyn ed46af8418 Merge from docusealco/wip 2026-06-08 11:56:14 +03:00
Pete Matsyburka 095f25a6e9 adjust image type 2026-06-08 09:31:38 +03:00
Pete Matsyburka 3cdab30dc8 remove oj 2026-06-05 16:55:55 +03:00
Pete Matsyburka 8e462047ba fix upload callback 2026-06-05 14:45:22 +03:00
Pete Matsyburka b7f8b65d9d adjust button style 2026-06-05 09:55:21 +03:00
Alex Turchyn da21c37254 add Google Drive replace option 2026-06-05 09:55:21 +03:00
Pete Matsyburka a89aef4a89 adjust zoom 2026-06-04 14:39:02 +03:00
Alex Turchyn dd6516fd11 add cmd+scroll zooming to template builder 2026-06-04 08:20:38 +03:00
Pete Matsyburka 344a3ce198 adjust sanitize 2026-06-03 13:24:08 +03:00
Pete Matsyburka 62f8283587 adjust html to text 2026-06-02 13:44:52 +03:00
Alex Turchyn 1f89accac3 Merge from docusealco/wip 2026-06-01 14:58:06 +03:00
Pete Matsyburka 9fcaef4cf7 use new_from_memory_copy 2026-05-31 14:28:35 +03:00
Pete Matsyburka d5738a0631 disable variant_processor 2026-05-31 12:51:52 +03:00
Pete Matsyburka 8bf7a1f95a adjust image detect fields 2026-05-31 09:20:51 +03:00
Pete Matsyburka 04cf36891e fix complete button press enter 2026-05-31 09:09:57 +03:00
Pete Matsyburka b2d9948c30 remove active storage analyzers 2026-05-31 08:06:52 +03:00
Pete Matsyburka a2c9ac1707 remove image_processing 2026-05-30 21:37:00 +03:00
Pete Matsyburka 46a6bd0108 fix stamp 2026-05-30 12:04:23 +03:00
Pete Matsyburka 5f069e7a40 use load vips 2026-05-30 07:49:40 +03:00
Pete Matsyburka ff57e5c6ae fix pdfa 2026-05-29 15:31:32 +03:00
Alex Turchyn f65b6e2d76 add confirm prompt for template upload via URL 2026-05-29 09:29:21 +03:00
Pete Matsyburka 58fd180ae0 update gem 2026-05-29 08:04:53 +03:00
Pete Matsyburka cd6503c4c3 adjust image size 2026-05-28 15:37:20 +03:00
Pete Matsyburka e8b36c2b6d update gh stars 2026-05-28 14:38:34 +03:00
Pete Matsyburka 51743f1359 fix spec 2026-05-28 10:18:45 +03:00
Pete Matsyburka 504c42646b refactor template builder data 2026-05-28 10:06:47 +03:00
Pete Matsyburka c61e84d1b4 adjust archive 2026-05-28 08:40:05 +03:00
Pete Matsyburka b8ab01c46c add dynamic documents to template response 2026-05-28 08:29:10 +03:00
Pete Matsyburka 9558060bde check resubmit config 2026-05-28 08:29:09 +03:00
Pete Matsyburka 0741c879f1 use expires_at with file links 2026-05-27 22:19:18 +03:00
Pete Matsyburka 2e6dd2867e file url ttl env var 2026-05-26 21:54:59 +03:00
Pete Matsyburka 9f3fcccb61 optimize build 2026-05-26 16:50:10 +03:00
Pete Matsyburka ae08d7abad adjust opacity_layer 2026-05-26 13:11:15 +03:00
Pete Matsyburka 16808a7ec6 use pngload_buffer 2026-05-26 13:11:15 +03:00
Pete Matsyburka b1dfcd6283 fix email typo 2026-05-26 13:11:15 +03:00
Pete Matsyburka 369f4c0f08 sync prefillable field changes 2026-05-26 13:11:15 +03:00
Pete Matsyburka 827de03579 fix result generatrion 2026-05-26 08:57:30 +03:00
Alex Turchyn 0c7f4e5b43 fix sidekiq embedded redis connection 2026-05-25 21:52:34 +03:00
Alex Turchyn 9c700a3fb6 Merge from docusealco/wip 2026-05-25 12:28:05 +03:00
Pete Matsyburka 89bf83febb adjust autorot 2026-05-23 11:43:28 +03:00
Pete Matsyburka d44b35cf0a convert images on upload 2026-05-23 08:25:04 +03:00
Pete Matsyburka 891833e273 refactor process doc 2026-05-22 14:06:04 +03:00
Pete Matsyburka 29d3a80d3f fix rubocop 2026-05-21 21:55:30 +03:00
Pete Matsyburka 90fef58684 ruby 4.0.5 2026-05-21 21:46:27 +03:00
Pete Matsyburka 07841722ac adjust image load 2026-05-21 21:44:13 +03:00
Pete Matsyburka 9e4da5948b convert images on upload 2026-05-21 14:50:30 +03:00
Pete Matsyburka dc6e4313a1 fix ci 2026-05-20 16:15:15 +03:00
Pete Matsyburka a68cc0b689 convert images on upload 2026-05-20 15:23:42 +03:00
Pete Matsyburka d057fb0f67 content type priority 2026-05-19 13:27:16 +03:00
Pete Matsyburka 7e8f045a29 cleanup 2026-05-19 11:42:14 +03:00
Pete Matsyburka 5249d8d18d update gem 2026-05-19 11:02:15 +03:00
Pete Matsyburka f0fafbadd4 isolate editor nodes 2026-05-19 10:52:48 +03:00
Pete Matsyburka 8b51079dcc update gem 2026-05-18 18:42:04 +03:00
Pete Matsyburka 354bccd6e8 handle dangerous extensions 2026-05-18 18:05:16 +03:00
Pete Matsyburka 6806772346 add security headers 2026-05-18 16:47:19 +03:00
Alex Turchyn 60082655d4 Merge from docusealco/wip 2026-05-18 13:45:07 +03:00
Pete Matsyburka 7fe56941fd optimize pdf images 2026-05-17 19:55:03 +03:00
Pete Matsyburka abd498dd33 adjust validation message 2026-05-16 08:15:02 +03:00
Pete Matsyburka 99ca0136ed add percent format 2026-05-16 08:15:02 +03:00
Pete Matsyburka e52830c9b4 add authorization checks 2026-05-16 08:15:01 +03:00
Pete Matsyburka 755decca27 adjust capture revision 2026-05-15 17:50:18 +03:00
Pete Matsyburka 41604008d1 clamp area box 2026-05-15 17:20:29 +03:00
Pete Matsyburka 5bddce8969 fix i18n 2026-05-14 12:16:36 +03:00
Pete Matsyburka b25503f141 fix i18n 2026-05-13 10:08:13 +03:00
Pete Matsyburka bdf1850448 resend emails 2026-05-13 09:59:06 +03:00
Pete Matsyburka 230d3ccf69 fix with download config 2026-05-13 08:52:41 +03:00
Pete Matsyburka e378025a2d fix refactor complete button 2026-05-12 22:24:09 +03:00
Alex Turchyn 04129ded90 add complete button to signing form header 2026-05-12 19:19:49 +03:00
Pete Matsyburka a7891f89f8 do not override custom webhook header 2026-05-11 17:13:26 +03:00
Pete Matsyburka 37d4a8e834 update gem 2026-05-11 14:06:17 +03:00
Alex Turchyn 528a1216f8 Merge from docusealco/wip 2026-05-11 13:52:58 +03:00
Pete Matsyburka f2479bd259 adjust open revisions 2026-05-11 11:11:16 +03:00
Pete Matsyburka 135f0826d2 fix style 2026-05-09 21:17:18 +03:00
Pete Matsyburka 5710f0177b update gh stars 2026-05-09 19:25:42 +03:00
Pete Matsyburka 1a3a0528ba fix i18n 2026-05-09 10:34:20 +03:00
Pete Matsyburka 59793ff374 adjust builder 2026-05-08 19:35:46 +03:00
Pete Matsyburka 3c4ed42419 fix i18n 2026-05-08 18:08:35 +03:00
Pete Matsyburka 6a17b61550 update gem 2026-05-08 16:25:11 +03:00
Pete Matsyburka 76659497b7 typos 2026-05-08 16:12:09 +03:00
Pete Matsyburka ee50c957cb update gem 2026-05-08 15:43:34 +03:00
Pete Matsyburka a9dd200919 add pdfium rotate 2026-05-08 15:43:34 +03:00
Pete Matsyburka 1fa953104a update gh stars 2026-05-08 15:43:34 +03:00
Pete Matsyburka 50eb5b070e fix typo 2026-05-08 15:43:34 +03:00
Pete Matsyburka 10fd624bec add revisions 2026-05-08 15:43:34 +03:00
Alex Turchyn 04ec2f8260 allow permanent delete submissions in archived templates 2026-05-08 15:43:34 +03:00
Pete Matsyburka d828d79574 improve log 2026-05-08 15:43:34 +03:00
Pete Matsyburka 01dd3fefe5 retry webhooks in test mode 2026-05-08 15:43:34 +03:00
Pete Matsyburka 45ae954c0c add hmac webhook secret 2026-05-08 15:43:34 +03:00
Pete Matsyburka 1304849b55 update gh stars 2026-05-08 15:43:34 +03:00
Pete Matsyburka 0875faa079 fix date preview 2026-05-08 15:43:34 +03:00
Pete Matsyburka 3aa15d6ea6 update gem 2026-05-08 15:43:34 +03:00
Pete Matsyburka 6557329e97 gh 13k 2026-05-08 15:43:34 +03:00
Pete Matsyburka be27ce4161 Merge pull request #666 from aqilaziz/docs-fix-readme-typos
docs: fix README typos
2026-05-08 14:02:33 +03:00
aqilaziz a3391a970e docs: fix README typos 2026-05-08 05:23:28 +07:00
Alex Turchyn 744d45d2c5 Merge from docusealco/wip 2026-05-04 13:55:19 +03:00
Pete Matsyburka 310b16f54a use mcp source 2026-05-04 09:42:51 +03:00
Pete Matsyburka 6d3ad4a869 update gh stars 2026-05-03 18:37:56 +03:00
Alex Turchyn 1ce2448820 update create_template and send_documents annotations 2026-05-03 17:43:22 +03:00
Pete Matsyburka 07b4ada5be optimize edit json 2026-05-03 16:32:37 +03:00
Pete Matsyburka f70bf530ba hide qr link 2026-05-03 08:19:58 +03:00
Pete Matsyburka a26aa0f411 remove log 2026-05-03 07:27:24 +03:00
Pete Matsyburka 584577c21f fix ed.jp email typo 2026-05-01 13:50:17 +03:00
Pete Matsyburka 1503375eed respond to HEAD file proxy 2026-04-30 17:55:13 +03:00
Pete Matsyburka 2dec099f20 fix signature canvas minimize 2026-04-30 14:51:24 +03:00
Pete Matsyburka bf6eed4ea2 fix canvas error 2026-04-30 14:42:22 +03:00
Pete Matsyburka e0dc33cfb4 fix email domain search 2026-04-30 11:27:17 +03:00
Pete Matsyburka 9c9dc27537 fix new area order 2026-04-30 11:13:35 +03:00
Pete Matsyburka 7305637b8c sort appears on 2026-04-30 10:32:51 +03:00
Pete Matsyburka a6e22adf2e fix preview 2026-04-29 18:00:47 +03:00
Pete Matsyburka 809c6b1aa8 fix modal open 2026-04-29 10:27:02 +03:00
Pete Matsyburka 1536ded2a6 redis error log 2026-04-28 19:30:51 +03:00
Pete Matsyburka 76d54971ce fix test mode clone 2026-04-28 11:58:17 +03:00
Pete Matsyburka 6dec56198c add require email 2fa to submitter update 2026-04-28 11:58:17 +03:00
Pete Matsyburka d96d252df3 use post for test mode 2026-04-28 11:58:17 +03:00
Pete Matsyburka 3fca76a716 change state param 2026-04-28 11:58:17 +03:00
Pete Matsyburka a184fa11f1 move auth links 2026-04-28 11:58:17 +03:00
Pete Matsyburka e9c0b6a703 rate limit 2026-04-28 11:58:17 +03:00
Pete Matsyburka ec7df08496 adjust send submission email 2026-04-28 11:58:17 +03:00
Pete Matsyburka 6627d7eac5 fix erblint 2026-04-27 15:34:57 +03:00
Pete Matsyburka 1f8945d710 fix expired filter 2026-04-27 15:31:29 +03:00
Alex Turchyn daaa289a5c Merge from docusealco/wip 2026-04-27 13:43:34 +03:00
Pete Matsyburka 81a6a48d70 validate color 2026-04-26 15:13:19 +03:00
Pete Matsyburka b93c7cd261 add has_many document_metadata 2026-04-26 10:24:20 +03:00
Pete Matsyburka 570482e12d rename error 2026-04-25 12:39:26 +03:00
Pete Matsyburka 39407557f2 adjust page preview 2026-04-25 09:01:53 +03:00
Pete Matsyburka 3b040d558d adjust compression 2026-04-24 18:39:19 +03:00
Alex Turchyn 56ba3c2a52 add custom classes 2026-04-24 17:01:33 +03:00
Pete Matsyburka c7afe33c72 add time formats 2026-04-24 14:01:28 +03:00
Pete Matsyburka 82500c2d7d update dep 2026-04-24 10:56:19 +03:00
Pete Matsyburka fb1c1f117b remove unused const 2026-04-24 10:56:19 +03:00
Pete Matsyburka 7c6a7513fd add with custom field tab 2026-04-24 09:59:34 +03:00
Pete Matsyburka 6d13119ee0 input mode condition and formula 2026-04-22 10:58:50 +03:00
Pete Matsyburka 1e2c752937 detect existing fields 2026-04-22 10:27:18 +03:00
Pete Matsyburka e82faf8320 queue page field detection 2026-04-22 07:43:45 +03:00
Pete Matsyburka 4adb1001b0 add rotate incremental config 2026-04-21 12:35:00 +03:00
Pete Matsyburka 992a1b26c0 adjust tooltip position 2026-04-20 20:18:07 +03:00
Pete Matsyburka c07bc6687c add metadata param 2026-04-20 15:43:41 +03:00
Alex Turchyn eea44bda34 Merge from docusealco/wip 2026-04-20 13:57:33 +03:00
Pete Matsyburka 7cdf263da1 add screen reader mode 2026-04-19 20:18:52 +03:00
Pete Matsyburka ee65a5693c improve accessibility 2026-04-19 10:08:16 +03:00
Pete Matsyburka 0af6ccf35f pipeline field detection 2026-04-19 09:01:08 +03:00
Pete Matsyburka 888f1ec6df adjust mcp 2026-04-18 08:33:42 +03:00
Pete Matsyburka c95a8616ac deduplicate submitter uuids 2026-04-17 22:01:03 +03:00
Pete Matsyburka 5ea6289b7a fix test mode modal 2026-04-16 09:00:43 +03:00
Pete Matsyburka d4a79ca5db adjust dynamic editor 2026-04-15 10:06:41 +03:00
Pete Matsyburka 70015ce1c4 adjust dynamic editor 2026-04-14 14:14:25 +03:00
Pete Matsyburka 6b85c28944 add error message 2026-04-14 10:51:12 +03:00
Pete Matsyburka 6c289cf273 optimize build 2026-04-14 09:37:27 +03:00
Pete Matsyburka a64bc3c618 change button style 2026-04-14 09:21:39 +03:00
Pete Matsyburka 46cf1e3067 add log 2026-04-14 09:13:33 +03:00
Pete Matsyburka 565e1eb2bc adjust validation 2026-04-13 19:55:07 +03:00
Pete Matsyburka e689687805 fix erblint 2026-04-13 19:21:24 +03:00
Pete Matsyburka 3c3b61fb47 adjust reason field 2026-04-13 19:18:55 +03:00
Pete Matsyburka 1355d350c5 fix xlsx boolean value 2026-04-13 18:13:38 +03:00
Pete Matsyburka fda911e178 add submitter field validation 2026-04-13 16:32:03 +03:00
Pete Matsyburka f995e1864c add shared link qr code 2026-04-13 13:59:00 +03:00
Alex Turchyn 911e55ccc3 Merge from docusealco/wip 2026-04-13 13:25:16 +03:00
Pete Matsyburka 41fedfcc40 show verification error 2026-04-12 13:30:13 +03:00
Pete Matsyburka 2f9fc95af3 remove unused module 2026-04-11 18:13:25 +03:00
Pete Matsyburka 11b0b16ca7 prefill signature client side 2026-04-11 18:13:25 +03:00
Pete Matsyburka f8cb7ffdab remove mathjs 2026-04-11 18:13:23 +03:00
Pete Matsyburka 8b59c0aaa0 adjust normalize values 2026-04-10 15:20:35 +03:00
Pete Matsyburka bb2fb7a0c2 refactor download 2026-04-10 12:54:48 +03:00
Pete Matsyburka 8f8b36617a fix first party download from preview 2026-04-10 11:15:16 +03:00
Pete Matsyburka ff53436fd4 fix dynamic editor layout 2026-04-08 17:18:38 +03:00
Pete Matsyburka cf09a4f733 update gem 2026-04-08 12:24:31 +03:00
Pete Matsyburka 2303c21cea download users csv 2026-04-08 12:24:22 +03:00
Pete Matsyburka 89e797b95f update message 2026-04-08 10:29:45 +03:00
Pete Matsyburka 20375c3a42 update gem 2026-04-08 08:40:41 +03:00
Pete Matsyburka 339ceda18d upload attachment if not completed 2026-04-07 18:20:16 +03:00
Pete Matsyburka 97ce32fd52 set attachment name 2026-04-07 16:34:15 +03:00
Alex Turchyn 8c4cbd86f7 Merge from docusealco/wip 2026-04-06 15:22:38 +03:00
Pete Matsyburka eaedaa96bb fix rubocop 2026-04-02 13:28:12 +03:00
Pete Matsyburka bd7ad5fce8 update gems 2026-04-02 13:28:12 +03:00
Pete Matsyburka 759dac1b70 update schema 2026-04-01 19:07:57 +03:00
Pete Matsyburka 38577c6235 fix dynamic doc fields assignment 2026-04-01 16:08:49 +03:00
Pete Matsyburka b24fff0f55 fix rtl stamp 2026-04-01 11:10:09 +03:00
Pete Matsyburka 9e0efeb092 fix unicode in stamp 2026-04-01 10:29:31 +03:00
Pete Matsyburka 41152f90fc fix erblint 2026-03-30 15:18:34 +03:00
Alex Turchyn 4486f8040e Merge from docusealco/wip 2026-03-30 14:35:18 +03:00
Pete Matsyburka a98caad968 use musl build 2026-03-29 11:18:41 +03:00
Pete Matsyburka ffb75238b4 disable confetti by default 2026-03-28 16:53:20 +02:00
Pete Matsyburka cba3e74f3a optimize build 2026-03-28 16:44:08 +02:00
Pete Matsyburka 8d3f0b5b98 silence not found trace 2026-03-28 14:50:38 +02:00
Pete Matsyburka a2598b30f7 optimize build 2026-03-28 13:48:39 +02:00
Pete Matsyburka 8d11f5ddae adjust email 2fa 2026-03-28 12:51:00 +02:00
Pete Matsyburka 6ffc88b4ed fix n+1 2026-03-28 09:27:50 +02:00
Pete Matsyburka 640b451727 adjust shared link 2026-03-28 09:07:52 +02:00
Pete Matsyburka db6e2eb7ca preserve order with verification 2026-03-27 17:16:06 +02:00
Pete Matsyburka 407816b576 redirect delegate completed 2026-03-27 14:51:14 +02:00
Pete Matsyburka 486cc493e6 add i18n 2026-03-27 14:21:47 +02:00
Pete Matsyburka 3eb0b8a89f allow to delegate 2026-03-27 14:21:47 +02:00
Pete Matsyburka c23eca0ade fix field style 2026-03-26 16:09:57 +02:00
Pete Matsyburka 67de153016 increase image limit 2026-03-26 11:40:42 +02:00
Pete Matsyburka ba2da69a67 fix load ico 2026-03-26 08:29:17 +02:00
Pete Matsyburka 111bef14e1 update rails 2026-03-24 23:56:40 +02:00
Alex Turchyn 22d4c1bd10 Merge from docusealco/wip 2026-03-23 14:59:53 +02:00
Pete Matsyburka e3dfb0ae83 add SMTP_ENABLE_TLS 2026-03-23 10:02:54 +02:00
Pete Matsyburka 8d7c3f0e26 fix generation 2026-03-23 09:48:39 +02:00
Pete Matsyburka b11775ebda remove premailer 2026-03-22 18:13:41 +02:00
Pete Matsyburka 608191183a update deps 2026-03-21 13:37:31 +02:00
Pete Matsyburka 2ba903e170 fix specs 2026-03-18 10:18:28 +02:00
Alex Turchyn 342b64d502 add translations 2026-03-18 09:52:58 +02:00
Pete Matsyburka 1a8f048aaa update devise 2026-03-18 09:52:21 +02:00
Pete Matsyburka 7309581b75 add template archived event 2026-03-18 09:52:21 +02:00
Pete Matsyburka 36d59c40a7 add template update event 2026-03-18 08:36:36 +02:00
Pete Matsyburka ed3c7c324b adjust save dynamic 2026-03-16 17:24:07 +02:00
Alex Turchyn fd868360a3 Merge from docusealco/wip 2026-03-16 17:07:07 +02:00
Pete Matsyburka 8dc2ebfbe0 fix builder save 2026-03-16 16:59:45 +02:00
Alex Turchyn 997711b34a Merge from docusealco/wip 2026-03-16 13:36:06 +02:00
Pete Matsyburka ac01b7129d add i18n 2026-03-16 12:42:58 +02:00
Pete Matsyburka f4b7528c0d fix send form 2026-03-16 08:52:42 +02:00
Pete Matsyburka d7b9ae5df5 add datenow size 2026-03-16 08:21:36 +02:00
Pete Matsyburka 4db29126e9 fix variables list 2026-03-16 08:04:02 +02:00
Pete Matsyburka 4cfee2fe45 fix schema documents 2026-03-15 15:02:46 +02:00
Pete Matsyburka db5dbd6ac2 adjust dynamic editor 2026-03-15 14:05:20 +02:00
Pete Matsyburka 44db775b89 fix variable style 2026-03-14 17:47:53 +02:00
Alex Turchyn 8e2e780a1a fix webhook events filtering 2026-03-13 20:04:09 +02:00
Pete Matsyburka 7aba2eb4da disable decline 2026-03-12 08:58:56 +02:00
Pete Matsyburka 3e250bfb28 error update if declined 2026-03-11 23:44:06 +02:00
Pete Matsyburka 9c02ed66f5 fix field scroll 2026-03-11 14:43:16 +02:00
Pete Matsyburka f5ae8c60d3 preload dynamic editor 2026-03-11 10:44:12 +02:00
Pete Matsyburka e90ac391b2 update readme 2026-03-10 19:09:00 +02:00
Pete Matsyburka 0e5f949bcf remove email from signing reason 2026-03-10 11:39:00 +02:00
Pete Matsyburka b7c270bef8 dir auto section 2026-03-10 10:31:45 +02:00
Pete Matsyburka 5f16b506f5 fix n+1 2026-03-10 08:12:32 +02:00
Pete Matsyburka 176148fc71 adjust default initial 2026-03-09 17:29:46 +02:00
Alex Turchyn 861daaedf4 Merge from docusealco/wip 2026-03-09 10:40:29 +02:00
Pete Matsyburka d8430f4e63 fix migration 2026-03-09 09:02:56 +02:00
Pete Matsyburka dd44864485 fix field position 2026-03-06 11:37:53 +02:00
Pete Matsyburka b3bb29f383 fix brakeman 2026-03-05 21:42:55 +02:00
Pete Matsyburka b4d137c0de remove flash 2026-03-05 12:30:26 +02:00
Alex Turchyn 62a969d8fe add MCP support 2026-03-05 12:17:14 +02:00
Pete Matsyburka 961f09e092 set dynamic fields 2026-03-05 10:08:29 +02:00
Pete Matsyburka b2dfa83f18 upload json 2026-03-04 12:27:15 +02:00
Pete Matsyburka 37196ff89f add dynamic documents 2026-03-04 10:13:30 +02:00
Pete Matsyburka b50d982497 smtp timeout env vars 2026-03-03 22:49:55 +02:00
Pete Matsyburka 4a484aca69 hide storage nav if configured with env 2026-03-03 22:01:44 +02:00
Pete Matsyburka 0bf37d571a update color 2026-03-03 18:46:06 +02:00
Alex Turchyn d4e94ac2bc Merge from docusealco/wip 2026-03-02 17:26:04 +02:00
Pete Matsyburka 75bc789bb1 add reply to 2026-03-02 14:47:37 +02:00
Pete Matsyburka bc231d5bf1 add reply to 2026-03-02 14:34:16 +02:00
Alex Turchyn ab9008b3be fix brakeman SQL injection warning 2026-03-01 16:14:15 +02:00
Pete Matsyburka 65491936c3 fix archived export 2026-02-26 17:20:05 +02:00
Pete Matsyburka e589998840 add field type class 2026-02-26 11:08:26 +02:00
Pete Matsyburka 0afba8d11e adjust with prefillable 2026-02-26 11:03:08 +02:00
Pete Matsyburka f622ed168f fix autocomplete 2026-02-25 13:01:03 +02:00
Pete Matsyburka 5a31df40d8 add field settings classes 2026-02-25 11:02:00 +02:00
Pete Matsyburka db3b80c96c fix clone 2026-02-25 11:02:00 +02:00
Pete Matsyburka bd28a761d0 adjust bmp loader 2026-02-25 11:02:00 +02:00
Pete Matsyburka 4f2830a34d increase image resolution 2026-02-25 11:02:00 +02:00
Pete Matsyburka a5716cd518 show html textarea 2026-02-25 11:02:00 +02:00
Pete Matsyburka d035525c7a additional terms 2026-02-25 11:02:00 +02:00
Alex Turchyn 13fa87c449 Merge from docusealco/wip 2026-02-23 13:56:04 +02:00
Pete Matsyburka 246ccfb049 fix highlight 2026-02-23 08:31:38 +02:00
Pete Matsyburka cbffb1e5b8 cloudflare r2 fix 2026-02-22 20:13:19 +02:00
Pete Matsyburka c4b007411d radio select in formula 2026-02-22 19:54:06 +02:00
Pete Matsyburka 04264fe937 fix reason field 2026-02-22 12:15:44 +02:00
Pete Matsyburka 0c37ad12b7 add variables key 2026-02-22 09:46:04 +02:00
Pete Matsyburka 8c5298ed2e refactor link tooltip 2026-02-22 09:28:30 +02:00
Pete Matsyburka 262118e047 update deps 2026-02-22 07:37:05 +02:00
Pete Matsyburka e5b63ea2b0 fix multi party email form 2026-02-21 17:31:04 +02:00
Pete Matsyburka 746757d402 rtl in editor 2026-02-21 16:38:25 +02:00
Pete Matsyburka e6e640328b use br for email content 2026-02-21 16:20:48 +02:00
Pete Matsyburka 845782a69c link input width 2026-02-21 15:48:59 +02:00
Pete Matsyburka fc6baa1b3b adjust autolink 2026-02-21 15:48:58 +02:00
Pete Matsyburka 47822ecc15 remove autolink 2026-02-21 14:55:11 +02:00
Pete Matsyburka 3d0c7f1118 markdown in preferences 2026-02-21 14:44:19 +02:00
Pete Matsyburka e9c8e4d325 adjust editor 2026-02-21 14:32:10 +02:00
Alex Turchyn 39cc82ce0c add markdown editor 2026-02-21 11:15:09 +02:00
Pete Matsyburka 451f83421a fix cell render 2026-02-21 10:49:05 +02:00
Pete Matsyburka 5073f2eefb adjsut sign yourself 2026-02-21 10:17:58 +02:00
Pete Matsyburka 231fff5508 canvas blocked message 2026-02-21 09:53:03 +02:00
Pete Matsyburka f1d146eca3 fix typos 2026-02-20 22:39:03 +02:00
Pete Matsyburka b6635fcc4f change user only when root 2026-02-20 22:25:12 +02:00
Pete Matsyburka fb5e13ee4c adjust recipients form 2026-02-20 18:26:10 +02:00
Pete Matsyburka ba84741a64 invite party via field 2026-02-20 12:41:45 +02:00
Pete Matsyburka 65c275ac17 rename 2026-02-20 10:16:06 +02:00
Pete Matsyburka 825322d489 adjust for custom domain 2026-02-20 09:14:36 +02:00
Pete Matsyburka 739e2abdf8 vips size limit 2026-02-19 14:24:05 +02:00
Pete Matsyburka 1d2394e31e size limit 2026-02-19 14:17:59 +02:00
Pete Matsyburka 1b41af798d update 2026-02-17 19:42:47 +02:00
Pete Matsyburka 118f4a231b detailed time format 2026-02-17 18:09:34 +02:00
Pete Matsyburka e48652f425 fix rubocop 2026-02-17 10:29:00 +02:00
Pete Matsyburka 848f01edf8 add spec 2026-02-17 10:19:29 +02:00
Pete Matsyburka 43fbc42770 fix reason field 2026-02-17 10:04:11 +02:00
Pete Matsyburka 2c736a0eed validate readonly field 2026-02-17 08:53:13 +02:00
Alex Turchyn 33ca930055 Merge from docusealco/wip
Wip
2026-02-16 15:08:07 +02:00
Pete Matsyburka 3b396f8421 fix redirect 2026-02-16 10:00:51 +02:00
Pete Matsyburka aa77e3a8d3 adjust port check 2026-02-16 08:08:41 +02:00
Pete Matsyburka 75316d8d87 fix draw custom field 2026-02-15 22:02:00 +02:00
Pete Matsyburka a999109a5c add port check 2026-02-15 12:18:53 +02:00
Pete Matsyburka bdd33c7d6b fix spec 2026-02-15 09:53:31 +02:00
Pete Matsyburka 61c5ee22a0 hide form credentials 2026-02-15 09:11:40 +02:00
Pete Matsyburka 12c5b909e0 use cancan 2026-02-14 19:13:05 +02:00
Pete Matsyburka 871ef6dda6 add fetch options 2026-02-14 18:50:48 +02:00
Pete Matsyburka 40052a2d7c use query_params 2026-02-14 17:42:08 +02:00
Pete Matsyburka 347be0137d refactor 2fa 2026-02-14 17:35:10 +02:00
Pete Matsyburka fe6baba8bf fix erb lint 2026-02-14 12:10:21 +02:00
Pete Matsyburka ed8c313bd4 timestamp controller multitenant 2026-02-14 12:02:37 +02:00
Pete Matsyburka ca0acb34d6 use url for open modal 2026-02-14 12:02:31 +02:00
Pete Matsyburka 34ea639c25 escape wildcard query 2026-02-14 12:02:25 +02:00
Pete Matsyburka 680ab9dbed raise invalid param 2026-02-14 10:18:58 +02:00
Pete Matsyburka 9377766e52 fix url download 2026-02-14 09:08:52 +02:00
Pete Matsyburka 7a2c37454e update favicon 2026-02-12 15:56:54 +02:00
Pete Matsyburka 8f6f418f54 fix font size 2026-02-12 14:27:15 +02:00
Pete Matsyburka 9b7745c565 hide resend on archived 2026-02-11 17:32:04 +02:00
Alex Turchyn a389e8cc38 Merge from docusealco/wip 2026-02-11 16:49:32 +02:00
Pete Matsyburka 2056301f70 remember send tab selection 2026-02-11 13:14:09 +02:00
Pete Matsyburka 93babe3660 update logo 2026-02-11 10:01:12 +02:00
Pete Matsyburka 4aef2d8eb0 update deps 2026-02-10 09:02:37 +02:00
Pete Matsyburka c5ec2ed230 fix document condition 2026-02-05 11:56:22 +02:00
Pete Matsyburka a156d5f6cc increase webhook timeout 2026-02-05 08:42:53 +02:00
Pete Matsyburka f84674cddd adjust field focus 2026-02-04 17:05:12 +02:00
Pete Matsyburka b7b62c197a fix date field 2026-02-04 16:56:35 +02:00
Pete Matsyburka f5350eac36 prefill typed signature and initials from name 2026-02-04 16:48:40 +02:00
Pete Matsyburka ee6228db15 only required field prop 2026-02-04 10:51:11 +02:00
Pete Matsyburka 3e434c0878 fix 2026-02-04 00:47:34 +02:00
Pete Matsyburka 7c5372a8e2 fix pdf sign 2026-02-02 22:04:43 +02:00
Alex Turchyn 6ab68cc36b Merge from docusealco/wip 2026-02-02 14:13:54 +02:00
Pete Matsyburka ee2400a5c5 touch signature attachment 2026-02-02 12:46:34 +02:00
Pete Matsyburka 80be5d2cf5 sandbox 2026-02-01 08:10:02 +02:00
Pete Matsyburka 76cf44a31b fix 2026-01-31 22:17:11 +02:00
Pete Matsyburka eb63113227 use trilogy mysql 2026-01-31 22:15:17 +02:00
Pete Matsyburka b7045fb3d9 fix azure proxy 2026-01-31 13:07:42 +02:00
Pete Matsyburka 11a5b5842d starttls label 2026-01-31 11:41:52 +02:00
Pete Matsyburka 362994afd5 adjust i18n 2026-01-31 10:26:09 +02:00
Pete Matsyburka d7682f4ebd add number conditions 2026-01-31 09:00:24 +02:00
Pete Matsyburka 56d4fe7fa9 add white color 2026-01-31 07:58:12 +02:00
Pete Matsyburka 93a852ac14 fix save modal 2026-01-30 21:45:55 +02:00
Pete Matsyburka 964db0ab08 fix menu 2026-01-30 21:03:56 +02:00
Pete Matsyburka 8547295da7 border color 2026-01-30 20:53:47 +02:00
Pete Matsyburka c75050fa46 adjust kba 2026-01-30 18:17:47 +02:00
Pete Matsyburka 65372c52aa fix order 2026-01-30 17:42:50 +02:00
Alex Turchyn 12c767b7d3 add more options to context menu 2026-01-30 16:36:50 +02:00
Pete Matsyburka 2fc5e9cfb3 format cells 2026-01-30 11:21:47 +02:00
Pete Matsyburka bccc78ee65 optimize builder 2026-01-30 10:23:51 +02:00
Pete Matsyburka c0793f3ce7 cell font settings 2026-01-29 21:43:01 +02:00
Pete Matsyburka a8dd8699a3 adjust kba 2026-01-29 15:55:15 +02:00
Pete Matsyburka 91bb25e8b6 fix reload 2026-01-29 15:33:24 +02:00
Pete Matsyburka c3f4480cdc add submitter index 2026-01-29 13:01:44 +02:00
Pete Matsyburka 33e226861e add SMTP_SSL_VERIFY env 2026-01-29 11:34:37 +02:00
Pete Matsyburka 4f5acbec9d readonly radio 2026-01-28 21:36:48 +02:00
Pete Matsyburka d2c9bbf6ab optimize conditions 2026-01-28 13:24:05 +02:00
Pete Matsyburka b5e57e1f2f add include fields 2026-01-28 11:49:35 +02:00
Pete Matsyburka c82c8b2272 fix race condition 2026-01-28 08:50:44 +02:00
Pete Matsyburka 43f1b598a7 adjust smtp noverify 2026-01-28 08:27:25 +02:00
Pete Matsyburka 5081e387d0 fix undefined 2026-01-27 20:13:26 +02:00
Pete Matsyburka 1e5f55c1d6 fix Point is invalid 2026-01-27 20:07:57 +02:00
Alex Turchyn aa38773be3 Merge from docusealco/wip 2026-01-27 18:39:04 +02:00
Pete Matsyburka 96530bd809 fix race condition 2026-01-27 17:06:30 +02:00
Pete Matsyburka 127f95a9d0 fix typo 2026-01-27 12:31:06 +02:00
Pete Matsyburka cf31e12348 adjust smtp configs 2026-01-27 10:57:04 +02:00
Pete Matsyburka 4adaa531b7 fix pagination 2026-01-27 10:34:18 +02:00
Pete Matsyburka c7659563b8 fix puma warnings 2026-01-26 20:23:11 +02:00
Alex Turchyn 7b5494468f update rails to 8.1.2 2026-01-26 18:22:47 +02:00
Pete Matsyburka d12c67fd54 with force open 2026-01-26 18:00:46 +02:00
Pete Matsyburka e20eacb5ee center on click 2026-01-26 12:35:53 +02:00
Pete Matsyburka 98055f0762 add can parse polyfill 2026-01-26 12:17:57 +02:00
Pete Matsyburka a46c42c3a0 fix resize warning 2026-01-26 12:06:41 +02:00
Pete Matsyburka c3d1503361 cleanup 2026-01-26 11:44:12 +02:00
Pete Matsyburka 65b47d27cd adjust form 2026-01-26 11:38:33 +02:00
Pete Matsyburka 968c07f8be include idcode 2026-01-26 11:13:01 +02:00
Pete Matsyburka 91814ca105 custom fields 2026-01-26 11:12:55 +02:00
Pete Matsyburka 9128f24270 fix synology acl 2026-01-24 18:01:49 +02:00
Pete Matsyburka 264897bbd5 audit with sender 2026-01-23 12:12:37 +02:00
Pete Matsyburka 67d59ff1ba fix invite by order 2026-01-22 21:20:34 +02:00
Pete Matsyburka 81eee56e1e i18n 2026-01-21 20:14:53 +02:00
Pete Matsyburka 92ea8af786 fix options drag 2026-01-21 16:02:20 +02:00
Pete Matsyburka df0616f77e paste options 2026-01-21 15:42:38 +02:00
Pete Matsyburka a337dff85c change model 2026-01-21 10:23:26 +02:00
Pete Matsyburka 48e349c1d2 fix signature pad when resize 2026-01-20 17:24:05 +02:00
Pete Matsyburka df9fa0fec7 fix scroll into field 2026-01-20 15:27:35 +02:00
Alex Turchyn b3e72f0726 Merge from docusealco/wip 2026-01-19 15:40:56 +02:00
Pete Matsyburka 700ebc4727 render reason 2026-01-19 13:56:17 +02:00
Pete Matsyburka 745a18efca fix select mode draw 2026-01-19 11:57:09 +02:00
Pete Matsyburka b4a8147ff0 fix radio group paste 2026-01-19 11:51:00 +02:00
Pete Matsyburka 0543c25054 detect fields in context menu 2026-01-19 09:23:04 +02:00
Pete Matsyburka b1a0afea66 fix menu 2026-01-18 10:29:15 +02:00
Pete Matsyburka 43c41e2557 add select fields mode 2026-01-18 09:52:43 +02:00
Pete Matsyburka bc8eb33f05 Revert "hide area name menu on desktop"
This reverts commit 5212773d0a.
2026-01-17 19:42:27 +02:00
Pete Matsyburka d1cebc92f7 fix eslint 2026-01-16 12:38:29 +02:00
Pete Matsyburka bf02cf4058 fix paste 2026-01-16 12:25:50 +02:00
Pete Matsyburka 5212773d0a hide area name menu on desktop 2026-01-16 12:18:17 +02:00
Alex Turchyn bf53204a41 add context menu to template builder 2026-01-16 12:07:59 +02:00
Pete Matsyburka 092a19966c adjust condition 2026-01-15 17:34:05 +02:00
Pete Matsyburka 6060a2b798 fix icon 2026-01-14 21:58:30 +02:00
Pete Matsyburka ce12af68b2 fix required 2026-01-14 14:32:00 +02:00
Pete Matsyburka c05f8d47aa require 2fa 2026-01-14 12:44:27 +02:00
Pete Matsyburka f8be19c20e pg 18 2026-01-13 20:44:52 +02:00
Pete Matsyburka 2fe32a2145 show version 2026-01-13 12:25:08 +02:00
Pete Matsyburka 794ea4d0e8 remove expired from pending filter 2026-01-13 12:07:39 +02:00
Pete Matsyburka 307b0f5afd fix build 2026-01-13 09:44:58 +02:00
Alex Turchyn 9011e3f4b4 Merge from docusealco/wip 2026-01-12 13:25:54 +02:00
Pete Matsyburka 0b45ce5c0c update model 2026-01-12 12:05:54 +02:00
Pete Matsyburka a8ab93a4cc adjust sort 2026-01-12 11:27:19 +02:00
Pete Matsyburka d1768be636 secure env 2026-01-12 11:27:17 +02:00
Pete Matsyburka b2cd7c82a7 insert new field at page position 2026-01-11 14:45:42 +02:00
Pete Matsyburka b88d99afe0 merge preview docs 2026-01-11 11:09:00 +02:00
Pete Matsyburka 4523d38551 direct links option 2026-01-09 22:05:37 +02:00
Pete Matsyburka c4ba892559 refactor clone 2026-01-08 09:34:45 +02:00
Pete Matsyburka 40f8093e00 adjust onnx 2026-01-07 14:50:27 +02:00
Pete Matsyburka 4375459856 add nmm 2026-01-07 12:19:05 +02:00
Pete Matsyburka b3eb8a70bf fix submitter uuid 2026-01-07 00:01:47 +02:00
Pete Matsyburka 6245a487b6 add kba countdown 2026-01-06 11:24:30 +02:00
Pete Matsyburka a89f4c1b10 adjust trim 2026-01-06 10:22:34 +02:00
Pete Matsyburka 7df729b8b5 containment threshold 2026-01-06 09:04:25 +02:00
Pete Matsyburka f1db7c7e82 adjust trim 2026-01-06 08:54:29 +02:00
436 changed files with 26868 additions and 4605 deletions
+9 -9
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -62,7 +62,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v1
with:
node-version: 20.9.0
node-version: 20.19.0
- name: Cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -104,7 +104,7 @@ jobs:
bundle install --jobs 4 --retry 4
yarn install
sudo apt-get update
sudo apt-get install libvips
sudo apt-get install libvips liblept5
- name: Run Brakeman
run: bundle exec brakeman -q --exit-on-warn
@@ -132,11 +132,11 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.5
- name: Set up Node
uses: actions/setup-node@v1
with:
node-version: 20.9.0
node-version: 20.19.0
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
with:
@@ -162,8 +162,8 @@ jobs:
bundle install --jobs 4 --retry 4
yarn install
sudo apt-get update
sudo apt-get install -y libvips
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
sudo apt-get install -y libvips liblept5
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz
- name: Run
+29 -4
View File
@@ -1,4 +1,4 @@
require:
plugins:
- rubocop-performance
- rubocop-rails
- rubocop-rspec
@@ -10,7 +10,7 @@ AllCops:
- node_modules/**/*
- bin/*
- vendor/**/*
TargetRubyVersion: '3.3'
TargetRubyVersion: '4.0'
SuggestExtensions: false
Metrics/BlockLength:
@@ -28,7 +28,7 @@ Lint/MissingSuper:
Enabled: false
Metrics/ParameterLists:
Max: 10
Max: 12
Metrics/MethodLength:
Max: 30
@@ -48,6 +48,18 @@ Style/MultipleComparison:
Style/NumericPredicate:
Enabled: false
Style/MinMaxComparison:
Enabled: false
Style/EmptyClassDefinition:
Enabled: false
Style/OneClassPerFile:
Enabled: false
Layout/MultilineMethodCallIndentation:
Enabled: false
Naming/PredicateMethod:
Enabled: false
@@ -81,7 +93,7 @@ RSpec/AnyInstance:
Enabled: false
Metrics/BlockNesting:
Max: 5
Max: 6
Rails/I18nLocaleTexts:
Enabled: false
@@ -97,3 +109,16 @@ Rails/ApplicationController:
Rails/Output:
Enabled: false
Rails/StrongParametersExpect:
Enabled: false
Rails/RedirectBackOrTo:
Enabled: false
Rails/UnknownEnv:
Environments:
- development
- test
- production
- local
+42 -36
View File
@@ -1,22 +1,23 @@
FROM ruby:3.4.2-alpine AS download
FROM ruby:4.0.5-alpine AS download
WORKDIR /fonts
RUN apk --no-cache add fontforge wget && \
RUN apk --no-cache add wget unzip && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260613/pdfium-musl-$(uname -m).zip" && \
case "$(uname -m)" in \
x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \
aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \
esac | sha256sum -c - && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
unzip -q pdfium-linux.zip -d /pdfium-linux
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.4.2-alpine AS webpack
FROM ruby:4.0.5-alpine AS webpack
ENV RAILS_ENV=production
ENV NODE_ENV=production
@@ -36,22 +37,25 @@ COPY ./config/shakapacker.yml ./config/shakapacker.yml
COPY ./postcss.config.js ./postcss.config.js
COPY ./tailwind.config.js ./tailwind.config.js
COPY ./tailwind.form.config.js ./tailwind.form.config.js
COPY ./tailwind.dynamic.config.js ./tailwind.dynamic.config.js
COPY ./tailwind.application.config.js ./tailwind.application.config.js
COPY ./app/javascript ./app/javascript
COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.4.2-alpine AS app
FROM ruby:4.0.5-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/app/openssl_legacy.cnf
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
@@ -63,37 +67,39 @@ legacy = legacy_sect\n\
activate = 1\n\
\n\
[legacy_sect]\n\
activate = 1' >> /app/openssl_legacy.cnf
activate = 1' >> /etc/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN apk add --no-cache build-base git libpq-dev yaml-dev && bundle install && apk del --no-cache build-base git libpq-dev yaml-dev && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales,resources/unicode_data/properties}'] + Dir['/usr/local/bundle/gems/*/{test,tests,examples,sample,misc,doc,docs}'] + Dir['/usr/local/bundle/gems/*/ext/**/*.{c,h,o,S}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime
COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app
COPY --chown=docuseal:docuseal ./config ./config
COPY --chown=docuseal:docuseal ./db/migrate ./db/migrate
COPY --chown=docuseal:docuseal ./log ./log
COPY --chown=docuseal:docuseal ./lib ./lib
COPY --chown=docuseal:docuseal ./public ./public
COPY --chown=docuseal:docuseal ./tmp ./tmp
COPY --chown=docuseal:docuseal LICENSE LICENSE_ADDITIONAL_TERMS README.md Rakefile config.ru .version ./
COPY --chown=docuseal:docuseal .version ./public/version
COPY ./bin ./bin
COPY ./app ./app
COPY ./config ./config
COPY ./db/migrate ./db/migrate
COPY ./log ./log
COPY ./lib ./lib
COPY ./public ./public
COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/version
COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
COPY --from=download /model.onnx /app/tmp/model.onnx
COPY --from=webpack /app/public/packs ./public/packs
COPY --from=download /pdfium-linux/licenses/ /usr/lib/libpdfium-licenses/
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
RUN bundle exec bootsnap precompile -j 1 --gemfile app/ lib/
RUN mkdir -p /app/public/fonts && ln -s /fonts/DancingScript-Regular.otf /app/public/fonts/ && \
mkdir -p /usr/share/fonts/noto && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/ && fc-cache -f && \
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
chown -R docuseal:docuseal /app/tmp/cache
WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000
ENV VIPS_BLOCK_UNTRUSTED=1
EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+12 -15
View File
@@ -2,16 +2,17 @@
source 'https://rubygems.org'
ruby '3.4.2'
ruby '4.0.5'
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic'
gem 'addressable'
gem 'arabic-letter-connector', require: false
gem 'aws-sdk-s3', require: false
gem 'aws-sdk-secretsmanager', require: false
gem 'azure-storage-blob', require: false
gem 'azure-blob', require: false
gem 'bootsnap', require: false
gem 'cancancan'
gem 'csv'
gem 'csv-safe'
gem 'csv', require: false
gem 'csv-safe', require: false
gem 'devise'
gem 'devise-two-factor'
gem 'dotenv', require: false
@@ -20,31 +21,27 @@ gem 'faraday'
gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false
gem 'hexapdf'
gem 'image_processing'
gem 'jwt'
gem 'jwt', require: false
gem 'lograge'
gem 'mysql2', require: false
gem 'numo-narray'
gem 'oj'
gem 'onnxruntime'
gem 'numo-narray-alt', require: false
gem 'onnxruntime', require: false
gem 'pagy'
gem 'pg', require: false
gem 'premailer-rails'
gem 'pretender'
gem 'puma', require: false
gem 'rack'
gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n'
gem 'rotp'
gem 'rouge', require: false
gem 'rqrcode'
gem 'rqrcode', require: false
gem 'ruby-vips'
gem 'rubyXL'
gem 'rubyXL', require: false
gem 'shakapacker'
gem 'sidekiq'
gem 'sqlite3', require: false
gem 'strip_attributes'
gem 'trilogy', require: false
gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data'
+290 -324
View File
@@ -1,29 +1,31 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
action_text-trix (2.1.18)
railties
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
actionmailer (8.0.2.1)
actionpack (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.2.1)
actionview (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -31,55 +33,58 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.2.1)
actionpack (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
actiontext (8.1.3)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.2.1)
activesupport (= 8.0.2.1)
actionview (8.1.3)
activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.2.1)
activesupport (= 8.0.2.1)
activejob (8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.3.6)
activemodel (8.0.2.1)
activesupport (= 8.0.2.1)
activerecord (8.0.2.1)
activemodel (= 8.0.2.1)
activesupport (= 8.0.2.1)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
timeout (>= 0.4.0)
activestorage (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activesupport (= 8.0.2.1)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
marcel (~> 1.0)
activesupport (8.0.2.1)
activesupport (8.1.3)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
annotaterb (4.14.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
annotaterb (4.22.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
arabic-letter-connector (0.1.1)
ast (2.4.3)
aws-eventstream (1.4.0)
aws-partitions (1.1197.0)
aws-sdk-core (3.240.0)
aws-partitions (1.1233.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -87,44 +92,38 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.218.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sdk-secretsmanager (1.110.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-secretsmanager (1.129.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
nokogiri (~> 1, >= 1.10.8)
azure-storage-common (2.0.4)
faraday (~> 1.0)
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
azure-blob (0.8.0)
cgi
rexml
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
bcrypt (3.1.22)
better_html (2.2.0)
actionview (>= 7.0)
activesupport (>= 7.0)
ast (~> 2.0)
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.2.2)
bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.18.4)
bootsnap (1.23.0)
msgpack (~> 1.2)
brakeman (7.0.0)
brakeman (8.0.4)
racc
builder (3.3.0)
bullet (8.0.0)
bullet (8.1.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
camertron-eprun (1.1.1)
@@ -138,51 +137,50 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
cldr-plurals-runtime-rb (1.1.0)
cmdparse (3.0.7)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
crack (1.0.0)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
css_parser (1.21.0)
addressable
csv (3.3.2)
csv (3.3.5)
csv-safe (3.3.1)
csv (~> 3.0)
cuprite (0.15.1)
cuprite (0.17)
capybara (~> 3.0)
ferrum (~> 0.15.0)
date (3.4.1)
debug (1.10.0)
ferrum (~> 0.17.0)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
devise (4.9.4)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
railties (>= 7.0)
responders
warden (~> 1.2.3)
devise-two-factor (6.1.0)
activesupport (>= 7.0, < 8.1)
devise (~> 4.0)
railties (>= 7.0, < 8.1)
devise-two-factor (6.4.0)
activesupport (>= 7.2, < 8.2)
devise (>= 4.0, < 6.0)
railties (>= 7.2, < 8.2)
rotp (~> 6.0)
diff-lcs (1.5.1)
digest-crc (0.6.5)
diff-lcs (1.6.2)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.1)
dotenv (3.1.7)
dotenv (3.2.0)
drb (2.2.3)
email_typo (0.2.3)
erb (5.0.2)
erb_lint (0.7.0)
erb (6.0.4)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
@@ -190,117 +188,97 @@ GEM
rubocop (>= 1)
smart_properties
erubi (1.13.1)
factory_bot (6.5.0)
activesupport (>= 5.0.0)
factory_bot_rails (6.4.4)
factory_bot (6.5.6)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.1)
factory_bot (~> 6.5)
railties (>= 5.0.0)
faker (3.5.1)
railties (>= 6.1.0)
faker (3.6.1)
i18n (>= 1.8.11, < 2)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-follow_redirects (0.3.0)
faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
ferrum (0.15)
faraday-net_http (3.4.2)
net-http (~> 0.5)
ferrum (0.17.2)
addressable (~> 2.5)
base64 (~> 0.2)
concurrent-ruby (~> 1.1)
webrick (~> 1.7)
websocket-driver (~> 0.7)
ffi (1.17.1)
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
foreman (0.88.1)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
foreman (0.90.0)
thor (~> 1.4)
geom2d (0.4.1)
globalid (1.2.1)
globalid (1.3.0)
activesupport (>= 6.1)
google-apis-core (0.15.1)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
google-apis-core (1.0.2)
addressable (~> 2.8, >= 2.8.7)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.22.0)
retriable (~> 3.1)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.49.0)
google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.2.1)
google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.4.0)
google-cloud-storage (1.54.0)
google-cloud-errors (1.6.0)
google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (~> 0.13)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (~> 0.38)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.1.0)
googleauth (1.12.2)
google-logging-utils (0.2.0)
googleauth (1.16.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 3.0)
jwt (>= 1.4, < 4.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.1.2)
hexapdf (1.4.0)
hashdiff (1.2.1)
hexapdf (1.7.0)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
strscan (>= 3.1.2)
htmlentities (4.3.4)
httpclient (2.8.3)
i18n (1.14.7)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1)
irb (1.15.2)
io-console (0.8.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.15.0)
jwt (2.9.3)
json (2.19.8)
jwt (3.2.0)
base64
language_server-protocol (3.17.0.5)
launchy (3.0.1)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener_web (3.0.0)
@@ -315,29 +293,27 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.24.1)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
matrix (0.4.2)
marcel (1.1.0)
matrix (0.4.3)
method_source (1.1.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
msgpack (1.7.5)
multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
mysql2 (0.5.6)
net-http-persistent (4.0.5)
connection_pool (~> 2.2)
net-imap (0.5.9)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
@@ -346,124 +322,113 @@ GEM
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.9)
mini_portile2 (~> 2.8.2)
nio4r (2.7.5)
nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-gnu)
nokogiri (1.19.3-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-musl)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-musl)
racc (~> 1.4)
numo-narray (0.9.2.1)
oj (3.16.11)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
onnxruntime (0.10.1)
ffi
numo-narray-alt (0.10.3)
onnxruntime (0.10.1-aarch64-linux)
ffi
onnxruntime (0.10.1-arm64-darwin)
ffi
onnxruntime (0.10.1-x86_64-linux)
ffi
openssl (3.3.0)
openssl (4.0.1)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
package_json (0.1.0)
pagy (9.3.3)
parallel (1.27.0)
parser (3.3.9.0)
package_json (0.2.0)
pagy (43.4.4)
json
uri
yaml
parallel (1.28.0)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
pg (1.5.9)
pp (0.6.2)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint
premailer (1.27.0)
addressable
css_parser (>= 1.19.0)
htmlentities (>= 4.0.0)
premailer-rails (1.12.0)
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
pretender (0.5.0)
actionpack (>= 6.1)
pretender (1.0.0)
actionpack (>= 7.2)
prettyprint (0.2.0)
prism (1.5.1)
pry (0.15.0)
prism (1.9.0)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
reline (>= 0.6.0)
pry-rails (0.3.11)
pry (>= 0.13.0)
psych (5.2.6)
psych (5.3.1)
date
stringio
public_suffix (6.0.1)
puma (6.5.0)
public_suffix (7.0.5)
puma (8.0.2)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.3)
rack (3.2.6)
rack-proxy (0.7.7)
rack
rack-session (2.1.1)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
rails (8.0.2.1)
actioncable (= 8.0.2.1)
actionmailbox (= 8.0.2.1)
actionmailer (= 8.0.2.1)
actionpack (= 8.0.2.1)
actiontext (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activemodel (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
bundler (>= 1.15.0)
railties (= 8.0.2.1)
railties (= 8.1.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.1)
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
rails_autolink (1.1.8)
actionview (> 3.1)
activesupport (> 3.1)
railties (> 3.1)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.14.2)
rake (13.4.2)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
redis-client (0.23.0)
tsort
redis-client (0.29.0)
connection_pool
regexp_parser (2.11.3)
reline (0.6.2)
reline (0.6.3)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
@@ -471,35 +436,35 @@ GEM
uber (< 0.2.0)
request_store (1.7.0)
rack (>= 1.4)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
retriable (3.1.2)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
retriable (3.4.1)
rexml (3.4.4)
rotp (6.3.0)
rouge (4.5.2)
rqrcode (2.2.0)
rouge (4.7.0)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rspec-core (3.13.2)
rqrcode_core (~> 2.0)
rqrcode_core (2.1.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.0)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.2)
rubocop (1.81.1)
rspec-rails (8.0.4)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (>= 3.13.0, < 5.0.0)
rspec-expectations (>= 3.13.0, < 5.0.0)
rspec-mocks (>= 3.13.0, < 5.0.0)
rspec-support (>= 3.13.0, < 5.0.0)
rspec-support (3.13.7)
rubocop (1.86.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -507,122 +472,127 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.23.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.27.0)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.3.0)
rubocop (~> 1.61)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.9.0)
lint_roller (~> 1.1)
rubocop (~> 1.81)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyXL (3.4.33)
rubyXL (3.4.35)
nokogiri (>= 1.10.8)
rubyzip (>= 1.3.0)
rubyzip (2.3.2)
rubyzip (>= 3.2.2)
rubyzip (3.2.2)
securerandom (0.4.1)
semantic_range (3.1.0)
shakapacker (8.0.2)
semantic_range (3.1.1)
shakapacker (9.7.0)
activesupport (>= 5.2)
package_json
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
sidekiq (7.3.7)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
signet (0.19.0)
sidekiq (8.1.6)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
logger (>= 1.7.0)
rack (>= 3.2.0)
redis-client (>= 0.29.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sqlite3 (2.5.0)
mini_portile2 (~> 2.8.0)
sqlite3 (2.5.0-aarch64-linux-gnu)
sqlite3 (2.5.0-aarch64-linux-musl)
sqlite3 (2.5.0-arm64-darwin)
sqlite3 (2.5.0-x86_64-linux-gnu)
sqlite3 (2.5.0-x86_64-linux-musl)
stringio (3.1.7)
strip_attributes (1.14.1)
sqlite3 (2.9.2-aarch64-linux-gnu)
sqlite3 (2.9.2-aarch64-linux-musl)
sqlite3 (2.9.2-arm64-darwin)
sqlite3 (2.9.2-x86_64-linux-gnu)
sqlite3 (2.9.2-x86_64-linux-musl)
stringio (3.2.0)
strip_attributes (2.0.1)
activemodel (>= 3.0, < 9.0)
strscan (3.1.5)
thor (1.4.0)
timeout (0.4.3)
strscan (3.1.8)
thor (1.5.0)
timeout (0.6.1)
trailblazer-option (0.1.2)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
twitter_cldr (6.12.1)
trilogy (2.12.2)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
twitter_cldr (6.14.0)
base64
camertron-eprun
cldr-plurals-runtime-rb (~> 1.1)
tzinfo
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.2)
tzinfo-data (1.2026.1)
tzinfo (>= 1.0.0)
uber (0.1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uniform_notifier (1.16.0)
unicode-emoji (4.2.0)
uniform_notifier (1.18.0)
uri (1.1.1)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.24.0)
railties (>= 8.0.0)
webmock (3.26.2)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
webrick (1.9.2)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.3)
yaml (0.4.0)
zeitwerk (2.7.5)
PLATFORMS
aarch64-linux
aarch64-linux-musl
arm64-darwin
ruby
x86_64-linux
x86_64-linux-musl
DEPENDENCIES
addressable
annotaterb
arabic-letter-connector
aws-sdk-s3
aws-sdk-secretsmanager
azure-storage-blob
azure-blob
better_html
bootsnap
brakeman
@@ -645,24 +615,19 @@ DEPENDENCIES
foreman
google-cloud-storage
hexapdf
image_processing
jwt
letter_opener_web
lograge
mysql2
numo-narray
oj
numo-narray-alt
onnxruntime
pagy
pg
premailer-rails
pretender
pry-rails
puma
rack
rails
rails-i18n
rails_autolink
rotp
rouge
rqrcode
@@ -678,6 +643,7 @@ DEPENDENCIES
simplecov
sqlite3
strip_attributes
trilogy
turbo-rails
twitter_cldr
tzinfo-data
@@ -685,7 +651,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.4.2p28
ruby 4.0.5
BUNDLED WITH
2.5.3
4.0.3
+5
View File
@@ -0,0 +1,5 @@
Additional Terms
In accordance with Section 7(b) of the GNU Affero General Public License,
a covered work must retain the original DocuSeal attribution in interactive
user interfaces.
+6 -6
View File
@@ -1,7 +1,7 @@
<h1 align="center" style="border-bottom: none">
<div>
<a href="https://www.docuseal.com">
<img alt="DocuSeal" src="https://github.com/docusealco/docuseal/assets/5418788/c12cd051-81cd-4402-bc3a-92f2cfdc1b06" width="80" />
<img alt="DocuSeal" src="https://github.com/user-attachments/assets/38b45682-ffa4-4919-abde-d2d422325c44" width="80" />
<br>
</a>
DocuSeal
@@ -50,7 +50,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- Company logo and white-label
- User roles
- Automated reminders
- Invitation and identify verification via SMS
- Invitation and identity verification via SMS
- Conditional fields and formulas
- Bulk send with CSV, XLSX spreadsheet import
- SSO / SAML
@@ -64,7 +64,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|Heroku|Railway|
|:--:|:---:|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.com/deploy/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
@@ -74,7 +74,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
docker run --name docuseal -p 3000:3000 -v.:/data docuseal/docuseal
```
By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable.
By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible to use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable.
#### Docker Compose
@@ -97,8 +97,8 @@ At DocuSeal we have expertise and technologies to make documents creation, filli
## License
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
Unless otherwise noted, all files © 2023 DocuSeal LLC.
Distributed under the AGPLv3 License with Section 7(b) Additional Terms. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) and [LICENSE_ADDITIONAL_TERMS](https://github.com/docusealco/docuseal/blob/master/LICENSE_ADDITIONAL_TERMS) for more information.
Unless otherwise noted, all files © 2023-2026 DocuSeal LLC.
## Tools
@@ -11,6 +11,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ALLOW_TO_DECLINE_KEY,
AccountConfig::ALLOW_TO_DELEGATE_KEY,
AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY,
@@ -23,7 +24,8 @@ class AccountConfigsController < ApplicationController
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY
AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY,
AccountConfig::ENABLE_MCP_KEY
].freeze
InvalidKey = Class.new(StandardError)
@@ -35,7 +37,7 @@ class AccountConfigsController < ApplicationController
end
def destroy
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
raise InvalidKey unless allowed_keys.include?(@account_config.key)
@account_config.destroy!
@@ -44,8 +46,12 @@ class AccountConfigsController < ApplicationController
private
def allowed_keys
ALLOWED_KEYS
end
def load_account_config
raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key])
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class AccountCustomFieldsController < ApplicationController
before_action :load_account_config, only: :create
def create
authorize!(:create, Template)
@account_config.update!(account_config_params)
render json: @account_config.value
end
private
def load_account_config
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY)
end
def account_config_params
params.permit(
value: [[:uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
{ preferences: {},
default_value: [],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w option_uuid]] }]]
)
end
end
+3 -1
View File
@@ -53,7 +53,9 @@ class AccountsController < ApplicationController
# rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace(
:account_delete_button,
html: helpers.tag.p(I18n.t('your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account'))
html: helpers.tag.p(I18n.t('your_account_will_be_permanently_deleted_within_2_months_please_contact_us_if_you_want_to_keep_your_account')) +
helpers.tag.br +
helpers.tag.p(I18n.t('your_email_address_has_been_released_immediately_you_can_now_be_added_to_your_company_team_without_waiting_for_the_deletion_period_to_end'))
)
# rubocop:enable Layout/LineLength
end
@@ -9,7 +9,9 @@ module Api
before_action :set_cors_headers
before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics
def show
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
@@ -21,6 +23,12 @@ module Api
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
attachment = blob.attachments.take
@record = attachment.record
@@ -33,12 +41,19 @@ module Api
else
http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition]
if request.head?
response.headers['Content-Type'] = blob.content_type_for_serving
head :ok
else
send_blob_stream blob, disposition: params[:disposition]
end
response.headers['Content-Length'] = blob.byte_size.to_s
end
end
end
# rubocop:enable Metrics
private
@@ -57,8 +72,6 @@ module Api
return if !require_ttl && !require_auth
end
Rollbar.error('Blob unauthorized') if defined?(Rollbar)
raise CanCan::AccessDenied
end
end
@@ -9,6 +9,7 @@ module Api
before_action :set_cors_headers
before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics
def show
@@ -18,6 +19,12 @@ module Api
return head :not_found unless blob
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
is_permitted = blob.attachments.any? do |a|
(current_user && a.record.account.id == current_user.account_id) ||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
+5 -1
View File
@@ -3,7 +3,7 @@
module Api
class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent
include Pagy::Backend
include Pagy::Method
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
@@ -102,6 +102,10 @@ module Api
headers['X-Robots-Tag'] = 'noindex'
end
def set_security_headers
response.headers['X-Content-Type-Options'] = 'nosniff'
end
def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
+23 -7
View File
@@ -8,29 +8,37 @@ module Api
COOKIE_STORE_LIMIT = 10
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(@submitter)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
file = params[:file]
if params[:type].in?(%w[initials signature])
image = Vips::Image.new_from_file(params[:file].path)
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar)
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
end
if ImageUtils.error?(image)
Rollbar.error("Error signature: #{submitter.id}") if defined?(Rollbar)
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content
end
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
end
attachment = Submitters.create_attachment!(submitter, params)
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
if params[:remember_signature] == 'true' && submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(submitter, attachment)
if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
@@ -40,6 +48,14 @@ module Api
render json: { error: e.message }, status: :unprocessable_content
end
def can_upload?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at?
end
def build_new_cookie_signatures_json(submitter, attachment)
values =
begin
@@ -5,34 +5,17 @@ module Api
load_and_authorize_resource :submission
def index
is_merge = params[:merge] == 'true' &&
(@submission.schema_documents || @submission.template.schema_documents).size > 1
documents =
if @submission.submitters.all?(&:completed_at?)
last_submitter = @submission.submitters.max_by(&:completed_at)
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end
last_submitter.documents_attachments
build_completed_documents(@submission, merge: is_merge)
else
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(@submission)
if @submission.preview_documents.present? &&
@submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
@submission.preview_documents
else
ApplicationRecord.no_touching do
@submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(@submission, values_hash:)
end
build_preview_documents(@submission, merge: is_merge)
end
ActiveRecord::Associations::Preloader.new(
records: documents,
associations: [:blob]
).call
ActiveRecord::Associations::Preloader.new(records: documents, associations: [:blob]).call
expires_at = Accounts.link_expires_at(current_account)
@@ -43,5 +26,50 @@ module Api
end
}
end
private
def build_completed_documents(submission, merge: false)
last_submitter = submission.submitters.max_by(&:completed_at)
if merge
if submission.merged_document_attachment.blank?
submission.merged_document_attachment =
Submissions::GenerateCombinedAttachment.call(last_submitter, with_audit: false)
end
[submission.merged_document_attachment]
else
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end
last_submitter.documents_attachments
end
end
def build_preview_documents(submission, merge: false)
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(submission)
if merge
if submission.preview_merged_document_attachment.present? &&
submission.preview_merged_document_attachment.metadata['values_hash'] == values_hash
[submission.preview_merged_document_attachment]
else
ApplicationRecord.no_touching { submission.preview_merged_document_attachment&.destroy }
Submissions::GeneratePreviewAttachments.call(submission, values_hash:, merge: true)
end
elsif submission.preview_documents.present? &&
submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
submission.preview_documents
else
ApplicationRecord.no_touching do
submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(submission, values_hash:)
end
end
end
end
+12 -3
View File
@@ -54,6 +54,12 @@ module Api
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.archived_at?
Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template has been archived' }, status: :unprocessable_content
end
if @template.fields.blank?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
@@ -156,7 +162,7 @@ module Api
params:)
else
submissions_attrs, attachments =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template)
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template, purpose: :api)
submissions = Submissions.create_from_submitters(
template:,
@@ -172,7 +178,10 @@ module Api
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters)
submitters.each do |submitter|
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request) if submitter.completed_at?
if submitter.completed_at?
Submitters::SubmitValues.maybe_invite_via_field(submitter, request)
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request)
end
end
submissions
@@ -188,7 +197,7 @@ module Api
message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
:require_phone_2fa, :require_email_2fa, :order, :invite_by,
:require_phone_2fa, :require_email_2fa, :order, :index, :invite_by,
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :required, :validation_pattern, :invalid_message,
+18 -5
View File
@@ -34,18 +34,24 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true, params:)
end
# rubocop:disable Metrics/MethodLength
def update
if @submitter.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
if @submitter.declined_at?
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
submitter_params.merge(role:),
@submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
for_submitter: @submitter
for_submitter: @submitter,
purpose: :api
)
Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
@@ -60,7 +66,10 @@ module Api
@submitter.submission.save!
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request) if @submitter.completed_at?
if @submitter.completed_at?
Submitters::SubmitValues.maybe_invite_via_field(@submitter, request)
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request)
end
end
if @submitter.completed_at?
@@ -78,6 +87,7 @@ module Api
render json: { error: e.message }, status: :unprocessable_content
end
# rubocop:enable Metrics/MethodLength
def submitter_params
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
@@ -151,9 +161,7 @@ module Api
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
end
submitter.values = submitter.values.transform_values do |v|
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end
submitter.values = Submitters::SubmitValues.replace_current_date_placeholders(submitter)
end
submitter
@@ -195,10 +203,15 @@ module Api
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
if submitter_preferences.key?('require_phone_2fa')
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
end
if submitter_preferences.key?('require_email_2fa')
submitter.preferences['require_email_2fa'] = submitter_preferences['require_email_2fa']
end
if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end
@@ -9,7 +9,7 @@ module Api
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
associations: [{ schema_documents: :preview_images_attachments }]
).call
cloned_template = Templates::Clone.call(
+43 -16
View File
@@ -9,20 +9,7 @@ module Api
templates = paginate(templates.preload(:author, folder: :parent_folder))
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: ['0.png', '0.jpg'] })
.where(record_id: schema_documents.map(&:id),
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
schema_documents, dynamic_documents, preview_image_attachments = preload_relations(templates)
expires_at = Accounts.link_expires_at(current_account)
@@ -30,6 +17,7 @@ module Api
data: templates.map do |t|
Templates::SerializeForApi.call(t,
schema_documents: schema_documents.select { |e| e.record_id == t.id },
dynamic_documents:,
preview_image_attachments:,
expires_at:)
end,
@@ -69,6 +57,7 @@ module Api
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.updated')
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
render json: @template.as_json(only: %i[id updated_at])
end
@@ -78,6 +67,8 @@ module Api
@template.destroy!
else
@template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
end
render json: @template.as_json(only: %i[id archived_at])
@@ -85,6 +76,41 @@ module Api
private
def preload_relations(templates)
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
dynamic_document_uuids =
templates.flat_map { |t| t.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid') }
dynamic_documents =
if dynamic_document_uuids.present?
DynamicDocument.where(template: templates.map(&:id))
.where(uuid: dynamic_document_uuids)
.preload(current_version: { document_attachment: :blob })
.select(:id, :uuid, :template_id, :sha1, :created_at, :updated_at)
else
DynamicDocument.none
end
preview_attachment_ids =
schema_documents.map(&:id) + dynamic_documents.filter_map { |d| d.current_version&.document_attachment&.id }
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: ['0.png', '0.jpg'] })
.where(record_id: preview_attachment_ids,
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
[schema_documents, dynamic_documents, preview_image_attachments]
end
def filter_templates(templates, params)
templates = Templates.search(current_user, templates, params[:q])
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
@@ -107,7 +133,8 @@ module Api
:external_id,
:shared_link,
{
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email order]],
submitters: [%i[name uuid is_requester invite_by_uuid invite_via_field_uuid
optional_invite_by_uuid linked_to_uuid email order]],
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description, :prefillable,
@@ -116,7 +143,7 @@ module Api
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]]
}
]
+8 -7
View File
@@ -4,7 +4,7 @@ class ApplicationController < ActionController::Base
BROWSER_LOCALE_REGEXP = /\A\w{2}(?:-\w{2})?/
include ActiveStorage::SetCurrent
include Pagy::Backend
include Pagy::Method
check_authorization unless: :devise_controller?
@@ -17,12 +17,13 @@ class ApplicationController < ActionController::Base
helper_method :button_title,
:current_account,
:true_ability,
:form_link_host,
:svg_icon
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
rescue_from Pagy::OverflowError do
rescue_from Pagy::RangeError do
redirect_to request.path
end
@@ -41,10 +42,6 @@ class ApplicationController < ActionController::Base
end
def default_url_options
if request.domain == 'docuseal.com'
return { host: 'docuseal.com', protocol: ENV['FORCE_SSL'].present? ? 'https' : 'http' }
end
Docuseal.default_url_options
end
@@ -59,7 +56,7 @@ class ApplicationController < ActionController::Base
def pagy_auto(collection, **keyword_args)
if current_ability.can?(:manage, :countless)
pagy_countless(collection, **keyword_args)
pagy(:countless, collection, **keyword_args)
else
pagy(collection, **keyword_args)
end
@@ -102,6 +99,10 @@ class ApplicationController < ActionController::Base
current_user&.account
end
def true_ability
@true_ability ||= Ability.new(true_user)
end
def maybe_redirect_to_setup
redirect_to setup_index_path unless User.exists?
end
+2 -2
View File
@@ -40,7 +40,7 @@ class ErrorsController < ActionController::Base
render json: { status: error_status_code, error: }.compact, status: error_status_code
end
f.html { render error_status_code.to_s, status: error_status_code }
f.any { render error_status_code.to_s, status: error_status_code }
end
end
@@ -51,7 +51,7 @@ class ErrorsController < ActionController::Base
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true
headers['Access-Control-Allow-Credentials'] = 'true'
end
def error_status_code
+1 -1
View File
@@ -3,7 +3,7 @@
class EsignSettingsController < ApplicationController
DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated'
CertFormRecord = Struct.new(:name, :file, :password, keyword_init: true) do
CertFormRecord = Struct.new(:name, :file, :password) do
include ActiveModel::Validations
def to_key
+58
View File
@@ -0,0 +1,58 @@
# frozen_string_literal: true
class McpController < ActionController::API
before_action :authenticate_user!
before_action :verify_mcp_enabled!
before_action do
authorize!(:manage, :mcp)
end
def call
return head :ok if request.raw_post.blank?
body = JSON.parse(request.raw_post)
result = Mcp::HandleRequest.call(body, current_user, current_ability)
if result
render json: result
else
head :accepted
end
rescue CanCan::AccessDenied
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
rescue JSON::ParserError
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
end
private
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class McpSettingsController < ApplicationController
load_and_authorize_resource :mcp_token, parent: false
before_action do
authorize!(:manage, :mcp)
end
def index
@mcp_tokens = @mcp_tokens.active.order(id: :desc)
end
def create
@mcp_token = current_user.mcp_tokens.new(mcp_token_params)
if @mcp_token.save
@mcp_tokens = [@mcp_token]
render :index, status: :created
else
render turbo_stream: turbo_stream.replace(:modal, template: 'mcp_settings/new'), status: :unprocessable_content
end
end
def destroy
@mcp_token.update!(archived_at: Time.current)
redirect_back fallback_location: settings_mcp_index_path, notice: I18n.t('mcp_token_has_been_removed')
end
private
def mcp_token_params
params.require(:mcp_token).permit(:name)
end
end
@@ -6,9 +6,17 @@ class PreviewDocumentPageController < ActionController::API
FORMAT = Templates::ProcessDocument::FORMAT
def show
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
result_data =
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid
attachment =
if result_data.is_a?(Array) && result_data.compact_blank.size == 2
attachment_id, attachment_uuid = result_data
ActiveStorage::Attachment.find_by(id: attachment_id, uuid: attachment_uuid)
elsif result_data
ActiveStorage::Attachment.find_by(uuid: result_data)
end
return head :not_found unless attachment
@@ -33,7 +41,7 @@ class PreviewDocumentPageController < ActionController::API
end
def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX)
@@ -1,6 +1,14 @@
# frozen_string_literal: true
class RevealAccessTokenController < ApplicationController
rate_limit to: 4, within: 1.minute, only: %i[create], by: -> { current_user.id }, with: lambda {
Rollbar.error('Rate limit api key') if defined?(Rollbar)
render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show',
locals: { error_message: I18n.t(:too_many_attempts) }),
status: :unprocessable_content
}
def show
authorize!(:manage, current_user.access_token)
end
@@ -14,7 +14,7 @@ class SendSubmissionEmailController < ApplicationController
template = Template.find_by!(slug: params[:template_slug])
@submitter =
Submitter.completed.where(submission: template.submissions).find_by!(email: params[:email].to_s.downcase)
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
elsif params[:submission_slug]
submission = Submission.find_by(slug: params[:submission_slug])
@@ -27,9 +27,11 @@ class SendSubmissionEmailController < ApplicationController
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
if @submitter
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
end
respond_to do |f|
f.html { render :success }
+10 -1
View File
@@ -101,11 +101,18 @@ class StartFormController < ApplicationController
def load_resubmit_submitter
@resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
Submitter.find_by(slug: params[:resubmit])
submitter = Submitter.find_by(slug: params[:resubmit])
submitter if submitter && can_resubmit?(submitter)
end
end
def can_resubmit?(submitter)
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
end
def authorize_start!
return redirect_to submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && @template.archived_at?
return redirect_to start_form_path(@template.slug) if @template.archived_at?
return if @resubmit_submitter
@@ -172,6 +179,8 @@ class StartFormController < ApplicationController
submitters: [submitter],
source: :link)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
submitter.account_id = submitter.submission.account_id
submitter
@@ -16,6 +16,7 @@ class SubmissionEventsController < ApplicationController
'email_verified' => 'email_check',
'click_sms' => 'hand_click',
'decline_form' => 'x',
'delegate_form' => 'user_share',
'start_verification' => 'player_play',
'complete_verification' => 'check',
'invite_party' => 'user_plus'
+19 -13
View File
@@ -26,7 +26,7 @@ class SubmissionsController < ApplicationController
unless @submission.submitters.all?(&:completed_at?)
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [submitters: :start_form_submission_events]
associations: [{ submitters: :start_form_submission_events }]
).call
end
@@ -38,6 +38,8 @@ class SubmissionsController < ApplicationController
end
def create
return redirect_to template_path(@template), alert: I18n.t('template_has_been_archived') if @template.archived_at?
save_template_message(@template, params) if params[:save_message] == '1'
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
@@ -51,18 +53,7 @@ class SubmissionsController < ApplicationController
emails: params[:emails],
params: params.merge('send_completed_email' => true))
else
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template, add_fields: true)
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
create_submissions(@template, submissions_params, params)
end
WebhookUrls.enqueue_events(submissions, 'submission.created')
@@ -97,6 +88,21 @@ class SubmissionsController < ApplicationController
private
def create_submissions(template, submissions_params, params)
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, template, add_fields: true)
Submissions.create_from_submitters(template: template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
end
def save_template_message(template, params)
template.preferences['request_email_subject'] = params[:subject] if params[:subject].present?
template.preferences['request_email_body'] = params[:body] if params[:body].present?
@@ -9,7 +9,7 @@ class SubmissionsDebugController < ApplicationController
def index
@submitter = Submitter.preload({ attachments_attachments: :blob },
submission: { template: { documents_attachments: :blob } })
.find_by(slug: params[:submitter_slug])
.find_by(slug: params[:submit_form_slug])
respond_to do |f|
f.html do
@@ -1,40 +1,17 @@
# frozen_string_literal: true
class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
FILES_TTL = 5.minutes
load_and_authorize_resource :submission
def index
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if @submitter&.slug == params[:submitter_slug]
true
else
@submitter = nil
end
@submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
Submissions::EnsureResultGenerated.call(@submitter)
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if params[:combined] == 'true'
url = build_combined_url(@submitter)
url = Submitters.build_combined_url(last_submitter)
if url
render json: [url]
@@ -42,43 +19,7 @@ class SubmissionsDownloadController < ApplicationController
head :not_found
end
else
render json: build_urls(last_submitter)
render json: Submitters.build_document_urls(last_submitter)
end
end
private
def current_user_submitter?(submitter)
current_user && current_user.account.submitters.exists?(id: submitter.id)
end
def build_urls(submitter)
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
Submitters.select_attachments_for_download(submitter).map do |attachment|
ActiveStorage::Blob.proxy_url(
attachment.blob,
expires_at: FILES_TTL.from_now.to_i,
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
)
end
end
def build_combined_url(submitter)
return if submitter.submission.submitters.exists?(completed_at: nil)
return if submitter.submission.submitters.order(:completed_at).last != submitter
attachment = submitter.submission.combined_document_attachment
attachment ||= Submissions::EnsureCombinedGenerated.call(submitter)
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
ActiveStorage::Blob.proxy_url(
attachment.blob,
expires_at: FILES_TTL.from_now.to_i,
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
)
end
end
@@ -5,10 +5,11 @@ class SubmissionsExportController < ApplicationController
load_and_authorize_resource :submission, through: :template, parent: false, only: :index
def index
submissions = @submissions.active
.preload(submitters: { documents_attachments: :blob,
attachments_attachments: :blob })
.order(id: :asc)
submissions = params[:archived] == 'true' ? @submissions.archived : @submissions.active
submissions = submissions.preload(submitters: { documents_attachments: :blob,
attachments_attachments: :blob })
.order(id: :asc)
submissions = Submissions.search(current_user, submissions, params[:q], search_values: true)
submissions = Submissions::Filter.call(submissions, current_user, params)
@@ -10,13 +10,15 @@ class SubmissionsPreviewController < ApplicationController
TTL = 40.minutes
def show
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
@sig_submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if submitter && submitter.submission.slug == params[:slug]
@submission = submitter.submission
if @sig_submitter && @sig_submitter.submission.slug == params[:slug]
@submission = @sig_submitter.submission
true
else
@sig_submitter = nil
end
@submission ||= Submission.find_by!(slug: params[:slug])
@@ -36,7 +38,7 @@ class SubmissionsPreviewController < ApplicationController
@submission = Submissions.preload_with_pages(@submission)
render 'submissions/show', layout: 'plain'
render 'submissions/show', layout: 'plain', locals: { is_preview: true }
end
def completed
@@ -0,0 +1,64 @@
# frozen_string_literal: true
class SubmissionsPreviewDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
def index
@submission = Submission.find_by!(slug: params[:submission_slug] || params[:submissions_preview_slug])
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
unless current_user_submission?(@submission)
if use_2fa?(@submission)
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
end
if params[:combined] == 'true'
respond_with_combined(last_submitter)
else
render json: Submitters.build_document_urls(last_submitter)
end
end
private
def respond_with_combined(submitter)
url = Submitters.build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
end
def current_user_submission?(submission)
current_user && current_ability.can?(:read, submission)
end
def use_2fa?(submission)
return true if submission.submitters.any? do |e|
e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa']
end
return true if submission.template&.preferences&.dig('require_phone_2fa')
return true if submission.template&.preferences&.dig('require_email_2fa')
false
end
end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class SubmissionsResendEmailController < ApplicationController
load_and_authorize_resource :submission
before_action do
authorize!(:manage, :resend_all)
authorize!(:update, @submission)
end
def create
submitters = @submission.submitters.reject(&:completed_at?).select { |s| s.email.present? && !s.declined_at? }
if Docuseal.multitenant?
recent_submitter_ids =
SubmissionEvent.where(submitter_id: submitters.map(&:id),
event_type: 'send_email',
created_at: 10.hours.ago..Time.current).pluck(:submitter_id).to_set
submitters = submitters.reject { |s| recent_submitter_ids.include?(s.id) }
end
submitters.each do |submitter|
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
submitter.sent_at ||= Time.current
submitter.save!
end
notice =
if submitters.empty?
I18n.t('email_has_been_sent_already')
else
I18n.t('emails_have_been_sent_to_n_recipients', count: submitters.size)
end
redirect_back(fallback_location: submission_path(@submission), notice:)
end
end
@@ -4,6 +4,8 @@ class SubmissionsUnarchiveController < ApplicationController
load_and_authorize_resource :submission
def create
authorize!(:update, @submission)
@submission.update!(archived_at: nil)
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived')
@@ -0,0 +1,70 @@
# frozen_string_literal: true
class SubmitFormCompletedDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
FILES_TTL = 5.minutes
def index
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if @submitter&.slug == submitter_slug
true
else
@submitter = nil
end
@submitter ||= Submitter.find_by!(slug: submitter_slug)
Submissions::EnsureResultGenerated.call(@submitter)
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
if !signature_valid && !current_user_submitter?(last_submitter)
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
end
if params[:combined] == 'true'
respond_with_combined(last_submitter)
else
render json: Submitters.build_document_urls(last_submitter)
end
end
private
def submitter_slug
params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id]
end
def respond_with_combined(submitter)
url = Submitters.build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
end
def current_user_submitter?(submitter)
current_user && current_ability.can?(:read, submitter)
end
end
+28 -18
View File
@@ -3,21 +3,22 @@
class SubmitFormController < ApplicationController
layout 'form'
around_action :with_browser_locale, only: %i[show completed success]
around_action :with_browser_locale, only: %i[show completed success delegated]
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter, only: %i[show update completed]
before_action :maybe_redirect_delegated, only: %i[show completed]
before_action :maybe_render_locked_page, only: :show
before_action :maybe_require_link_2fa, only: %i[show update]
before_action :maybe_require_link_2fa, only: %i[show]
CONFIG_KEYS = [].freeze
def show
submission = @submitter.submission
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
return render :email_2fa if require_email_2fa?(@submitter)
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
@@ -48,7 +49,7 @@ class SubmitFormController < ApplicationController
end
def update
if require_email_2fa?(@submitter)
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
return render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') },
status: :unprocessable_content
end
@@ -78,24 +79,31 @@ class SubmitFormController < ApplicationController
render json: { field_uuid: e.message }, status: :unprocessable_content
rescue Submitters::SubmitValues::ValidationError => e
Rollbar.warning("Validation error #{@submitter.id}: #{e.message}") if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content
end
def completed
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at?
redirect_to submit_form_path(params[:submit_form_slug]) if require_email_2fa?(@submitter)
return if Submitters::AuthorizedForForm.call(@submitter, current_user, request)
redirect_to submit_form_path(params[:submit_form_slug])
end
def success; end
def delegated
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = submitter_version.submitter
end
private
def maybe_require_link_2fa
return if @submitter.submission.source != 'link'
return unless @submitter.submission.template&.preferences&.dig('shared_link_2fa') == true
return if cookies.encrypted[:email_2fa_slug] == @submitter.slug
return if @submitter.email == current_user&.email && current_user&.account_id == @submitter.account_id
return if Submitters::AuthorizedForForm.pass_link_2fa?(@submitter, current_user, request)
redirect_to start_form_path(@submitter.submission.template.slug)
end
@@ -109,20 +117,22 @@ class SubmitFormController < ApplicationController
render :declined if @submitter.declined_at?
end
def maybe_redirect_delegated
return if @submitter
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
submitter_version.submitter.submission_events.find_by!(event_type: :delegate_form)
redirect_to submit_form_delegated_path(submitter_version.slug)
end
def load_submitter
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = Submitter.find_by(slug: params[:slug] || params[:submit_form_slug])
end
def build_attachments_index(submission)
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)
end
def require_email_2fa?(submitter)
return false if submitter.submission.template&.preferences&.dig('require_email_2fa') != true &&
submitter.preferences['require_email_2fa'] != true
return false if cookies.encrypted[:email_2fa_slug] == submitter.slug
true
end
end
@@ -4,29 +4,38 @@ class SubmitFormDeclineController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
before_action :load_submitter
return redirect_to submit_form_path(submitter.slug) if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template&.archived_at?
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
ApplicationRecord.transaction do
submitter.update!(declined_at: Time.current)
@submitter.update!(declined_at: Time.current)
SubmissionEvents.create_with_tracking_data(submitter, 'decline_form', request, { reason: params[:reason] })
SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
end
user = submitter.submission.created_by_user || submitter.template.author
user = @submitter.submission.created_by_user || @submitter.template.author
if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
SubmitterMailer.declined_email(submitter, user).deliver_later!
SubmitterMailer.declined_email(@submitter, user).deliver_later!
end
WebhookUrls.enqueue_events(submitter, 'form.declined')
WebhookUrls.enqueue_events(@submitter, 'form.declined')
redirect_to submit_form_path(submitter.slug)
redirect_to submit_form_path(@submitter.slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
class SubmitFormDelegateController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
email = Submissions.normalize_email(params[:email])
return redirect_to submit_form_path(@submitter.slug) if email.blank?
old_slug = @submitter.slug
ApplicationRecord.transaction do
@submitter.submitter_versions.create!(slug: old_slug, email: @submitter.email,
name: @submitter.name, phone: @submitter.phone)
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
{ old_email: @submitter.email, email: })
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14))
end
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
redirect_to submit_form_delegated_path(old_slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
end
@@ -9,7 +9,7 @@ class SubmitFormDownloadController < ApplicationController
def index
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at?
return redirect_to submit_form_documents_path(@submitter.slug) if @submitter.completed_at?
return head :unprocessable_content if @submitter.declined_at? ||
@submitter.submission.archived_at? ||
@@ -17,7 +17,8 @@ class SubmitFormDownloadController < ApplicationController
@submitter.submission.template&.archived_at? ||
AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false)
value: false) ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
last_completed_submitter = @submitter.submission.submitters
.where.not(id: @submitter.id)
@@ -32,7 +33,7 @@ class SubmitFormDownloadController < ApplicationController
end
urls = attachments.map do |attachment|
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
ActiveStorage::Blob.proxy_path(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
end
render json: urls
@@ -12,7 +12,8 @@ class SubmitFormDrawSignatureController < ApplicationController
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
return redirect_to submit_form_path(@submitter.slug)
end
@@ -5,12 +5,12 @@ class SubmitFormInviteController < ApplicationController
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :unprocessable_content unless can_invite?(submitter)
return head :unprocessable_content unless can_invite?(@submitter)
invite_submitters = filter_invite_submitters(submitter, 'invite_by_uuid')
optional_invite_submitters = filter_invite_submitters(submitter, 'optional_invite_by_uuid')
invite_submitters = filter_invite_submitters(@submitter, 'invite_by_uuid')
optional_invite_submitters = filter_invite_submitters(@submitter, 'optional_invite_by_uuid')
ApplicationRecord.transaction do
(invite_submitters + optional_invite_submitters).each do |item|
@@ -19,18 +19,20 @@ class SubmitFormInviteController < ApplicationController
next unless attrs
next if attrs[:email].blank?
submitter.submission.submitters.create!(**attrs, account_id: submitter.account_id)
email = Submissions.normalize_email(attrs[:email])
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid })
@submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: @submitter.account_id)
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: @submitter.uuid })
end
submitter.submission.update!(submitters_order: :preserved)
@submitter.submission.update!(submitters_order: :preserved)
end
submitter.submission.submitters.reload
@submitter.submission.submitters.reload
if invite_submitters.all? { |s| submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
Submitters::SubmitValues.call(submitter, ActionController::Parameters.new(completed: 'true'), request)
if invite_submitters.all? { |s| @submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
Submitters::SubmitValues.call(@submitter, ActionController::Parameters.new(completed: 'true'), request)
head :ok
else
@@ -45,7 +47,8 @@ class SubmitFormInviteController < ApplicationController
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at?
!submitter.submission.template&.archived_at? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request)
end
def filter_invite_submitters(submitter, key = 'invite_by_uuid')
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class SubmitFormMetadataController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :not_found if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
@submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
submission = @submitter.submission
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: @submitter.uuid)
documents = schema.filter_map do |item|
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
end
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
text_runs = documents.to_h do |document|
[
document.uuid,
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs
]
end
render json: { text_runs: }
end
end
@@ -5,19 +5,21 @@ class SubmitFormValuesController < ApplicationController
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? || submitter.declined_at?
return render json: {} if submitter.submission.template&.archived_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired?
return render json: {} if @submitter.completed_at? ||
@submitter.declined_at? ||
@submitter.submission.template&.archived_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
value = submitter.values[params['field_uuid']]
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
value = @submitter.values[params['field_uuid']]
attachment = @submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
render json: {
value:,
attachment: attachment&.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
}, head: :ok
}
end
end
@@ -7,25 +7,34 @@ class SubmittersAutocompleteController < ApplicationController
LIMIT = 100
def index
submitters = search_submitters(@submitters)
field = SELECT_COLUMNS.find { |c| c == params[:field] }
submitters = search_submitters(@submitters, field)
arel_columns = SELECT_COLUMNS.map { |col| Submitter.arel_table[col] }
values = submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
values =
if field
max_ids = submitters.group(field).limit(LIMIT).select(Submitter.arel_table[:id].maximum)
submitters.where(id: max_ids).order(id: :desc).pluck(arel_columns)
else
submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
end
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
render json: attrs
end
private
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
def search_submitters(submitters, field)
if field
if Docuseal.fulltext_search?
Submitters.fulltext_search_field(current_user, submitters, params[:q], params[:field])
Submitters.fulltext_search_field(current_user, submitters, params[:q], field)
else
column = Submitter.arel_table[params[:field].to_sym]
column = Submitter.arel_table[field.to_sym]
term = "#{params[:q].downcase}%"
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class SubmittersDownloadController < ApplicationController
load_and_authorize_resource :submitter
def index
Submissions::EnsureResultGenerated.call(@submitter)
render json: Submitters.build_document_urls(@submitter)
end
end
@@ -1,9 +1,11 @@
# frozen_string_literal: true
class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
load_and_authorize_resource :submitter
def create
authorize!(:update, @submitter)
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
event_type: 'send_email',
created_at: 10.hours.ago..Time.current)
@@ -6,17 +6,19 @@ class TemplateDocumentsController < ApplicationController
FILES_TTL = 5.minutes
def index
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_url(d.blob, expires_at: FILES_TTL.from_now.to_i) }
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
end
def create
authorize!(:update, @template)
if params[:blobs].blank? && params[:files].blank?
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
end
old_fields_hash = @template.fields.hash
documents = Templates::CreateAttachments.call(@template, params, extract_fields: true)
documents, = Templates::CreateAttachments.call(@template, params, extract_fields: true)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
@@ -27,7 +29,7 @@ class TemplateDocumentsController < ApplicationController
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
documents: documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class TemplateDocumentsCropController < ApplicationController
load_and_authorize_resource :template
before_action :load_attachment
rescue_from Leptonica::LeptonicaError do
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
end
def index
render json: { corners: Leptonica.detect_document_corners(@attachment.download) }
end
def create
authorize!(:update, @template)
document = Templates::CreateDocumentCrop.call(@template, @attachment, crop_params)
render json: {
document: document.as_json(
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
end
private
def load_attachment
@attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
end
def crop_params
params.permit(:scan, :rotate, :flip_h, :flip_v, corners: [%i[x y]])
end
end
@@ -0,0 +1,32 @@
# frozen_string_literal: true
class TemplateDocumentsModifyController < ApplicationController
load_and_authorize_resource :template
def create
authorize!(:update, @template)
documents_layout =
params.require(:documents).map do |item|
item.permit(:attachment_uuid,
pages: [:attachment_uuid, :page, :rotate,
{ redact: [%i[x y w h]], replaced_page: %i[attachment_uuid page] }]).to_h
end
Templates::ModifyDocuments.call(@template, documents_layout)
render json: {
schema: @template.schema,
fields: @template.fields,
submitters: @template.submitters,
documents: @template.schema_documents.reload.preload(:blob, preview_images_attachments: :blob).as_json(
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::ModifyDocuments::InvalidLayout
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
end
end
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class TemplateDocumentsPageObjectsController < ApplicationController
load_and_authorize_resource :template
def index
attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
render json: Templates::ModifyDocuments.page_objects(attachment, params[:page].to_i)
end
end
@@ -8,11 +8,14 @@ class TemplatesCloneAndReplaceController < ApplicationController
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
associations: [{ schema_documents: :preview_images_attachments }]
).call
cloned_template = Templates::Clone.call(@template, author: current_user)
cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
authorize!(:create, cloned_template)
cloned_template.save!
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
@@ -0,0 +1,56 @@
# frozen_string_literal: true
class TemplatesCloneController < ApplicationController
load_and_authorize_resource :template, instance_name: :base_template
def new
authorize!(:create, Template)
@template = Template.new(name: "#{@base_template.name} (#{I18n.t('clone')})")
end
def create
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [{ schema_documents: :preview_images_attachments }]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
authorize!(:create, @template)
if params[:account_id].present? && true_ability.can?(:manage, Account.find(params[:account_id]))
@template.account_id = params[:account_id]
@template.author = true_user if true_user.account_id == @template.account_id
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
end
Templates.maybe_assign_access(@template)
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template)
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
else
render turbo_stream: turbo_stream.replace(:modal, partial: 'templates_clone/form'), status: :unprocessable_content
end
end
private
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(template))
else
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end
end
end
+13 -62
View File
@@ -3,8 +3,6 @@
class TemplatesController < ApplicationController
load_and_authorize_resource :template
before_action :load_base_template, only: %i[new create]
def show
submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank?
@@ -26,59 +24,27 @@ class TemplatesController < ApplicationController
redirect_to root_path
end
def new
@template.name = "#{@base_template.name} (#{I18n.t('clone')})" if @base_template
end
def new; end
def edit
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: [:blob, { preview_images_attachments: :blob }]]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@template_data = Templates.serialize_for_builder(@template)
render :edit, layout: 'plain'
end
def create
if @base_template
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [schema_documents: :preview_images_attachments]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
else
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
@template.account_id = params[:account_id]
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
end
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.account = current_account
Templates.maybe_assign_access(@template)
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
redirect_to(edit_template_path(@template))
else
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_content
end
@@ -107,6 +73,8 @@ class TemplatesController < ApplicationController
else
@template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
I18n.t('template_has_been_archived')
end
@@ -118,9 +86,11 @@ class TemplatesController < ApplicationController
def template_params
params.require(:template).permit(
:name,
{ schema: [[:attachment_uuid, :google_drive_file_id, :name,
{ schema: [[:attachment_uuid, :google_drive_file_id, :name, :dynamic,
{ conditions: [%i[field_uuid value action operation]] }]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email order]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_via_field_uuid
invite_by_uuid optional_invite_by_uuid email order]],
variables_schema: {},
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description, :prefillable,
@@ -129,26 +99,7 @@ class TemplatesController < ApplicationController
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]] }
)
end
def authorized_clone_account_id?(account_id)
true_user.account_id.to_s == account_id.to_s ||
true_user.account.linked_accounts.accessible_by(current_ability).exists?(id: account_id)
end
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(@template))
else
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end
end
def load_base_template
return if params[:base_template_id].blank?
@base_template = Template.accessible_by(current_ability).find_by(id: params[:base_template_id])
end
end
+24 -15
View File
@@ -9,32 +9,34 @@ class TemplatesDebugController < ApplicationController
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
data = attachment.download
if attachment
data = attachment.download
unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data))
unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, data)
fields = Templates::FindAcroFields.call(pdf, attachment, data)
end
# fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
end
fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
debug_file if DEBUG_FILE.present?
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }]
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@@ -58,9 +60,16 @@ class TemplatesDebugController < ApplicationController
params = { files: [file] }
documents = Templates::CreateAttachments.call(@template, params)
documents, dynamic_documents = Templates::CreateAttachments.call(@template, params,
dynamic: DEBUG_FILE.ends_with?('.docx'))
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
schema = documents.map do |doc|
{
attachment_uuid: doc.uuid,
name: doc.filename.base,
dynamic: dynamic_documents.find { |e| e.uuid == doc.uuid }.present?
}
end
@template.update!(schema:)
end
@@ -11,11 +11,19 @@ class TemplatesDetectFieldsController < ApplicationController
sse = SSE.new(response.stream)
documents = @template.schema_documents.preload(:blob)
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present?
page_number = params[:page].presence&.to_i
documents.each do |document|
io = StringIO.new(document.download)
io =
if document.image?
StringIO.new(document.preview_images.joins(:blob).find_by(blob: { filename: ['0.png', '0.jpg'] }).download)
else
StringIO.new(document.download)
end
Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)|
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
sse.write({ attachment_uuid:, page:, fields: })
end
end
@@ -6,6 +6,8 @@ class TemplatesFoldersController < ApplicationController
def edit; end
def update
authorize!(:update, @template)
name = [params[:parent_name], params[:name]].compact_blank.join(' / ')
@template.folder = TemplateFolders.find_or_create_by_name(current_user, name)
@@ -58,8 +58,8 @@ class TemplatesPreferencesController < ApplicationController
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: []]
[{ completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: [] }]
).tap do |attrs|
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
@@ -4,18 +4,7 @@ class TemplatesPreviewController < ApplicationController
load_and_authorize_resource :template
def show
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@template_data = Templates.serialize_for_builder(@template)
render :show, layout: 'plain'
end
@@ -22,7 +22,7 @@ class TemplatesRecipientsController < ApplicationController
def submitters_params
permit_params = { submitters: [%i[name uuid is_requester optional_invite_by_uuid
invite_by_uuid linked_to_uuid email option order]] }
invite_by_uuid invite_via_field_uuid linked_to_uuid email option order]] }
params.require(:template).permit(permit_params).fetch(:submitters, {}).values.filter_map do |s|
next if s[:uuid].blank?
@@ -36,6 +36,7 @@ class TemplatesRecipientsController < ApplicationController
s[:order] = s[:order].to_i if s[:order].present?
s.delete(:invite_by_uuid) if s[:invite_by_uuid].blank?
s.delete(:optional_invite_by_uuid) if s[:optional_invite_by_uuid].blank?
s.delete(:invite_via_field_uuid) if s[:invite_via_field_uuid].blank?
normalize_option_value(s)
end
@@ -53,6 +54,7 @@ class TemplatesRecipientsController < ApplicationController
attrs.delete(:email)
attrs.delete(:linked_to_uuid)
attrs.delete(:invite_by_uuid)
attrs.delete(:invite_via_field_uuid)
attrs.delete(:optional_invite_by_uuid)
when /\Alinked_to_(.*)\z/
attrs[:linked_to_uuid] = ::Regexp.last_match(-1)
@@ -4,8 +4,12 @@ class TemplatesRestoreController < ApplicationController
load_and_authorize_resource :template
def create
authorize!(:update, @template)
@template.update!(archived_at: nil)
WebhookUrls.enqueue_events(@template, 'template.updated')
redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived')
end
end
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class TemplatesShareLinkQrController < ApplicationController
load_and_authorize_resource :template
def show
return render :disabled, layout: 'plain' unless @template.shared_link?
shared_link_url = start_form_url(slug: @template.slug, host: form_link_host)
@qr_svg_code = RQRCode::QRCode.new(shared_link_url, level: :m).as_svg(viewbox: true)
@page_size =
if TimeUtils.timezone_abbr(current_account.timezone, Time.current.beginning_of_year).in?(TimeUtils::US_TIMEZONES)
'Letter'
else
'A4'
end
render :show, layout: false
end
end
@@ -5,14 +5,16 @@ class TemplatesUploadsController < ApplicationController
layout 'plain'
def show; end
def show
redirect_to root_path if params[:url].blank?
end
def create
url_params = create_file_params_from_url if params[:url].present?
save_template!(@template, url_params)
documents = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
documents, = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if @template.fields.blank?
@@ -56,7 +58,7 @@ class TemplatesUploadsController < ApplicationController
def create_file_params_from_url
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(DownloadUtils.call(params[:url]).body)
tempfile.write(DownloadUtils.call(params[:url], validate: true).body)
tempfile.rewind
filename = URI.decode_www_form_component(params[:filename]) if params[:filename].present?
@@ -0,0 +1,25 @@
# frozen_string_literal: true
class TemplatesVersionsController < ApplicationController
load_and_authorize_resource :template
def index
versions = @template.template_versions.order(id: :desc).preload(:author)
render json: versions.as_json(TemplateVersions::SERIALIZE_PARAMS)
end
def show
version = @template.template_versions.find(params[:id])
render json: TemplateVersions.serialize(version)
end
def create
authorize!(:update, @template)
TemplateVersions.find_or_create_for(@template, author: current_user)
head :ok
end
end
@@ -3,7 +3,7 @@
class TestingAccountsController < ApplicationController
skip_authorization_check only: :destroy
def show
def create
authorize!(:manage, current_account)
authorize!(:manage, current_user)
@@ -32,7 +32,7 @@ class TimestampServerController < ApplicationController
uri = Addressable::URI.parse(url)
conn = Faraday.new(uri.origin) do |c|
c.basic_auth(uri.user, uri.password) if uri.password.present?
c.request :authorization, :basic, uri.user, uri.password if uri.password.present?
end
response = conn.post(uri.path, req.to_der,
@@ -11,6 +11,12 @@ class UserInitialsController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
@@ -11,6 +11,12 @@ class UserSignaturesController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
+13 -1
View File
@@ -16,7 +16,19 @@ class UsersController < ApplicationController
@users.active.where.not(role: 'integration')
end
@pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc))
@users = @users.preload(account: :account_accesses).where(account: current_account).order(id: :desc)
respond_to do |format|
format.html do
@pagy, @users = pagy(@users)
end
if current_ability.can?(:manage, current_account)
format.csv do
send_data Users.generate_csv(@users), filename: "users-#{Time.current.iso8601}.csv", type: 'text/csv'
end
end
end
end
def new; end
+1 -1
View File
@@ -15,7 +15,7 @@ class WebhookEventsController < ApplicationController
Submissions::SerializeForApi.call(@webhook_event.record)
when 'template.created', 'template.updated'
Templates::SerializeForApi.call(@webhook_event.record)
when 'submission.archived'
when 'submission.archived', 'template.archived'
@webhook_event.record.as_json(only: %i[id archived_at])
end
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class WebhookHmacController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false
def show; end
end
@@ -15,7 +15,7 @@ class WebhookSettingsController < ApplicationController
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
@pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
render :show
end
@@ -26,15 +26,19 @@ class WebhookSettingsController < ApplicationController
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
@pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
end
def new; end
def create
@webhook_url.save!
if @webhook_url.url.present?
@webhook_url.save!
redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')
redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')
else
redirect_back fallback_location: settings_webhooks_path
end
end
def update
+47 -7
View File
@@ -21,6 +21,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import ConvertUpload from './elements/convert_upload'
import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import ToggleSubmit from './elements/toggle_submit'
@@ -40,19 +41,20 @@ import DashboardDropzone from './elements/dashboard_dropzone'
import RequiredCheckboxGroup from './elements/required_checkbox_group'
import PageContainer from './elements/page_container'
import EmailEditor from './elements/email_editor'
import MarkdownEditor from './elements/markdown_editor'
import MountOnClick from './elements/mount_on_click'
import RemoveOnEvent from './elements/remove_on_event'
import ScrollTo from './elements/scroll_to'
import SetValue from './elements/set_value'
import ReviewForm from './elements/review_form'
import ShowOnValue from './elements/show_on_value'
import CustomValidation from './elements/custom_validation'
import ToggleClasses from './elements/toggle_classes'
import AutosizeField from './elements/autosize_field'
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal'
import BarChart from './elements/bar_chart'
import FieldCondition from './elements/field_condition'
import ConfirmUpload from './elements/confirm_upload'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -111,6 +113,7 @@ safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete)
safeRegisterElement('folder-autocomplete', FolderAutocomplete)
safeRegisterElement('signature-form', SignatureForm)
safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('convert-upload', ConvertUpload)
safeRegisterElement('prompt-password', PromptPassword)
safeRegisterElement('emails-textarea', EmailsTextarea)
safeRegisterElement('toggle-cookies', ToggleCookies)
@@ -131,19 +134,20 @@ safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('email-editor', EmailEditor)
safeRegisterElement('markdown-editor', MarkdownEditor)
safeRegisterElement('mount-on-click', MountOnClick)
safeRegisterElement('remove-on-event', RemoveOnEvent)
safeRegisterElement('scroll-to', ScrollTo)
safeRegisterElement('set-value', SetValue)
safeRegisterElement('review-form', ReviewForm)
safeRegisterElement('show-on-value', ShowOnValue)
safeRegisterElement('custom-validation', CustomValidation)
safeRegisterElement('toggle-classes', ToggleClasses)
safeRegisterElement('autosize-field', AutosizeField)
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('field-condition', FieldCondition)
safeRegisterElement('confirm-upload', ConfirmUpload)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
@@ -153,24 +157,35 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.appElem.classList.add('md:h-screen')
const template = reactive(JSON.parse(this.dataset.template))
this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)),
template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5',
locale: this.dataset.locale,
withPhone: this.dataset.withPhone === 'true',
withPrefillable: template.fields?.some((f) => f.prefillable),
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
withRevisions: true,
withRevisionsMenu: this.dataset.withRevisionsMenu === 'true',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true,
withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
withFormula: this.dataset.withFormula === 'true',
withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === 'true',
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
pagePreviewFormat: this.dataset.pagePreviewFormat || '.jpg',
withReplaceAndCloneUpload: true,
withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
@@ -184,10 +199,35 @@ safeRegisterElement('template-builder', class extends HTMLElement {
}
onSubmit = (e) => {
if (e.detail.success && e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
e.detail.fetchResponse.response.json().then((data) => {
this.component.template.submitters = data.submitters
})
if (e.detail.success) {
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
e.detail.fetchResponse.response.json().then((data) => {
this.component.template.submitters = data.submitters
})
}
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
e.detail.fetchResponse.response.text().then((data) => {
const doc = new DOMParser().parseFromString(data, 'text/html')
const fragment = doc.querySelector('turbo-stream template').content
const prefillableUuidsIndex = {}
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
prefillableUuidsIndex[field.value] = true
})
this.component.template.fields.forEach((field) => {
if (prefillableUuidsIndex[field.uuid]) {
field.prefillable = true
field.readonly = true
} else if (field.prefillable) {
delete field.prefillable
delete field.readonly
}
})
})
}
}
}
+4
View File
@@ -155,3 +155,7 @@ button[disabled] .enabled, button.btn-disabled .enabled {
.font-courier {
font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco;
}
markdown-editor [contenteditable] p {
margin-bottom: 18px;
}
+52 -6
View File
@@ -4,15 +4,27 @@ import { isValidSignatureCanvas } from './submission_form/validate_signature'
window.customElements.define('draw-signature', class extends HTMLElement {
connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
this.setCanvasSize()
this.pad = new SignaturePad(this.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas
this.setCanvasSize()
if (this.canvas.width !== width || this.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.canvas.parentNode)
if (this.dataset.color) {
this.pad.penColor = this.dataset.color
}
@@ -57,6 +69,40 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.updateSubmitButtonVisibility()
}
disconnectedCallback () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
setCanvasSize () {
const scale = 3
const width = this.canvas.parentNode.clientWidth
const height = this.canvas.parentNode.clientHeight
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
this.canvas.width = width * scale
this.canvas.height = height * scale
this.canvas.getContext('2d').scale(scale, scale)
}
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
}
}
updateSubmitButtonVisibility () {
if (this.pad.isEmpty()) {
this.submitButton.style.display = 'none'
+27
View File
@@ -0,0 +1,27 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
export default targetable(class extends HTMLElement {
static [target.static] = [
'prompt',
'processing',
'logo'
]
connectedCallback () {
this.form.addEventListener('submit', this.onSubmit)
}
disconnectedCallback () {
this.form.removeEventListener('submit', this.onSubmit)
}
onSubmit = () => {
this.prompt.classList.add('hidden')
this.processing.classList.remove('hidden')
this.logo.classList.add('animate-bounce')
}
get form () {
return this.querySelector('form')
}
})
+73
View File
@@ -0,0 +1,73 @@
export function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const dt = new DataTransfer()
let didConvert = false
for (const file of Array.from(input.files)) {
let converted = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
converted = await convertImage(file, 'image/png')
didConvert = true
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
converted = await convertImage(file, 'image/jpeg', 0.9)
didConvert = true
}
} catch (e) {
alert(e.message)
}
dt.items.add(converted)
}
if (didConvert) {
input.files = dt.files
}
}
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input[type="file"]')
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
form.requestSubmit()
})
}
}
@@ -1,14 +0,0 @@
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input')
const invalidMessage = this.dataset.invalidMessage || ''
input.addEventListener('invalid', () => {
input.setCustomValidity(input.value ? invalidMessage : '')
})
input.addEventListener('input', () => {
input.setCustomValidity('')
})
}
}
@@ -1,4 +1,5 @@
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -150,12 +151,16 @@ export default targetable(class extends HTMLElement {
if (!this.isLoading) this.hideDraghover()
}
uploadFiles (files, url) {
async uploadFiles (files, url) {
this.isLoading = true
this.form.action = url
this.form.querySelector('[type="file"]').files = files
const input = this.form.querySelector('[type="file"]')
input.files = files
await convertImagesInInput(input)
this.form.querySelector('[type="submit"]').click()
}
@@ -5,6 +5,12 @@ export default targetable(class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', () => this.downloadFiles())
this.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
this.downloadFiles()
}
})
}
toggleState () {
@@ -64,6 +64,22 @@ export default class extends HTMLElement {
if (action === 'empty' || action === 'unchecked') return this.isEmpty(actual)
if (action === 'not_empty' || action === 'checked') return !this.isEmpty(actual)
if (['equal', 'not_equal', 'greater_than', 'less_than'].includes(action) && this.sourceEl?.getAttribute('type') === 'number') {
if (this.isEmpty(actual) || this.isEmpty(expected)) return false
const actualNumber = parseFloat(actual)
const expectedNumber = parseFloat(expected)
if (Number.isNaN(actualNumber) || Number.isNaN(expectedNumber)) return false
if (action === 'equal') return Math.abs(actualNumber - expectedNumber) < Number.EPSILON
if (action === 'not_equal') return Math.abs(actualNumber - expectedNumber) > Number.EPSILON
if (action === 'greater_than') return actualNumber > expectedNumber
if (action === 'less_than') return actualNumber < expectedNumber
return false
}
if (action === 'equal') {
const list = Array.isArray(actual) ? actual : [actual]
return list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected))
+8 -3
View File
@@ -1,5 +1,6 @@
import { actionable } from '@github/catalyst/lib/actionable'
import { target, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
@@ -38,17 +39,21 @@ export default actionable(targetable(class extends HTMLElement {
this.classList.add('border-base-300', 'hover:bg-base-200/30')
}
onDrop (e) {
async onDrop (e) {
e.preventDefault()
this.input.files = e.dataTransfer.files
this.uploadFiles(e.dataTransfer.files)
await convertImagesInInput(this.input)
this.uploadFiles(this.input.files)
}
onSelectFiles (e) {
async onSelectFiles (e) {
e.preventDefault()
await convertImagesInInput(this.input)
this.uploadFiles(this.input.files)
}
+382
View File
@@ -0,0 +1,382 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
import { actionable } from '@github/catalyst/lib/actionable'
function loadTiptap () {
return Promise.all([
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-bold'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-italic'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-paragraph'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-link'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-underline'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/markdown'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
]).then(([core, bold, italic, paragraph, text, hardBreak, document, link, underline, extensions, markdown, pmState, pmView]) => ({
Editor: core.Editor,
Extension: core.Extension,
Bold: bold.default || bold,
Italic: italic.default || italic,
Paragraph: paragraph.default || paragraph,
Text: text.default || text,
HardBreak: hardBreak.default || hardBreak,
Document: document.default || document,
Link: link.default || link,
Underline: underline.default || underline,
UndoRedo: extensions.UndoRedo,
Markdown: markdown.Markdown,
Plugin: pmState.Plugin,
Decoration: pmView.Decoration,
DecorationSet: pmView.DecorationSet
}))
}
class LinkTooltip {
constructor (container, editor, templateEl) {
this.container = container
this.editor = editor
this.tooltip = templateEl.content.firstElementChild.cloneNode(true)
this.input = this.tooltip.querySelector('input')
this.saveButton = this.tooltip.querySelector('[data-role="link-save"]')
this.removeButton = this.tooltip.querySelector('[data-role="link-remove"]')
container.style.position = 'relative'
container.appendChild(this.tooltip)
}
isVisible () {
return !this.tooltip.classList.contains('hidden')
}
normalizeUrl (url) {
if (!url) return url
if (/^{/i.test(url)) return url
if (/^https?:\/\//i.test(url)) return url
if (/^mailto:/i.test(url)) return url
return `https://${url}`
}
show (url, pos, { focus = false } = {}) {
this.input.value = url || ''
this.removeButton.classList.toggle('hidden', !url)
this.tooltip.classList.remove('hidden')
const coords = this.editor.view.coordsAtPos(pos)
const containerRect = this.container.getBoundingClientRect()
this.tooltip.style.left = `${coords.left - containerRect.left}px`
this.tooltip.style.top = `${coords.bottom - containerRect.top + 4}px`
if (focus) this.input.focus()
this.saveHandler = () => {
const inputUrl = this.input.value.trim()
if (inputUrl) {
this.editor.chain().focus().extendMarkRange('link').setLink({ href: this.normalizeUrl(inputUrl) }).run()
}
this.hide()
}
this.removeHandler = () => {
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.hide()
}
this.keyHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
this.saveHandler()
} else if (e.key === 'Escape') {
e.preventDefault()
this.hide()
}
}
this.saveButton.addEventListener('click', this.saveHandler, { once: true })
this.removeButton.addEventListener('click', this.removeHandler, { once: true })
this.input.addEventListener('keydown', this.keyHandler)
}
hide () {
if (this.saveHandler) {
this.saveButton.removeEventListener('click', this.saveHandler)
this.saveHandler = null
}
if (this.removeHandler) {
this.removeButton.removeEventListener('click', this.removeHandler)
this.removeHandler = null
}
if (this.keyHandler) {
this.input.removeEventListener('keydown', this.keyHandler)
this.keyHandler = null
}
this.tooltip.classList.add('hidden')
this.currentMark = null
}
}
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
'textarea',
'editorElement',
'boldButton',
'italicButton',
'underlineButton',
'linkButton',
'linkTooltipTemplate'
]
async connectedCallback () {
if (!this.textarea || !this.editorElement) return
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()
const { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = await loadTiptap()
const buildDecorations = (doc) => {
const decorations = []
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
doc.descendants((node, pos) => {
if (!node.isText) return
let match
while ((match = regex.exec(node.text)) !== null) {
decorations.push(
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
class: 'bg-amber-100 py-0.5 px-1 rounded'
})
)
}
})
return DecorationSet.create(doc, decorations)
}
const VariableHighlight = Extension.create({
name: 'variableHighlight',
addProseMirrorPlugins () {
return [new Plugin({
state: {
init (_, { doc }) {
return buildDecorations(doc)
},
apply (tr, oldSet) {
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
}
},
props: {
decorations (state) {
return this.getState(state)
}
}
})]
}
})
this.editor = new Editor({
element: this.editorElement,
extensions: [
Markdown,
Document,
Paragraph,
Text,
Bold,
Italic,
HardBreak.extend({
addKeyboardShortcuts () {
return {
Enter: () => this.editor.commands.setHardBreak()
}
}
}),
UndoRedo,
Link.extend({
inclusive: true,
addKeyboardShortcuts: () => ({
'Mod-k': () => {
this.toggleLink()
return true
}
})
}).configure({
openOnClick: false,
HTMLAttributes: {
class: 'link',
'data-turbo': 'false',
style: 'color: #2563eb; text-decoration: underline; cursor: text;'
}
}),
Underline,
VariableHighlight
],
content: (this.textarea.value || '').trim().replace(/ *\n/g, '<br>'),
contentType: 'markdown',
editorProps: {
attributes: {
style: 'min-height: 220px',
dir: 'auto',
class: 'p-3 outline-none focus:outline-none'
}
},
onUpdate: ({ editor }) => {
this.textarea.value = editor.getMarkdown()
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
},
onSelectionUpdate: ({ editor }) => {
this.updateToolbarState()
this.handleLinkTooltip(editor)
},
onBlur: () => {
setTimeout(() => {
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
this.linkTooltip.hide()
}
}, 0)
}
})
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
}
adjustShortcutsForPlatform () {
if ((navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')) {
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
const tip = tooltip.getAttribute('data-tip')
if (tip && tip.includes('Ctrl')) {
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
}
})
}
}
bold (e) {
e.preventDefault()
this.editor.chain().focus().toggleBold().run()
this.updateToolbarState()
}
italic (e) {
e.preventDefault()
this.editor.chain().focus().toggleItalic().run()
this.updateToolbarState()
}
underline (e) {
e.preventDefault()
this.editor.chain().focus().toggleUnderline().run()
this.updateToolbarState()
}
linkSelection (e) {
e.preventDefault()
this.toggleLink()
this.updateToolbarState()
}
undo (e) {
e.preventDefault()
this.editor.chain().focus().undo().run()
this.updateToolbarState()
}
redo (e) {
e.preventDefault()
this.editor.chain().focus().redo().run()
this.updateToolbarState()
}
updateToolbarState () {
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
}
handleLinkTooltip (editor) {
const { from } = editor.state.selection
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
if (!mark) {
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
return
}
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
let linkStart = from
const start = editor.state.doc.resolve(from).start()
for (let i = from - 1; i >= start; i--) {
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
linkStart = i
} else {
break
}
}
this.linkTooltip.hide()
this.linkTooltip.show(mark.attrs.href, linkStart > start ? linkStart - 1 : linkStart)
this.linkTooltip.currentMark = mark
}
toggleLink () {
if (this.editor.isActive('link')) {
this.linkTooltip.hide()
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.updateToolbarState()
} else {
const { from } = this.editor.state.selection
this.linkTooltip.hide()
this.linkTooltip.show(this.editor.getAttributes('link').href, from, { focus: true })
}
}
insertVariable (e) {
const variable = e.target.closest('[data-variable]')?.dataset.variable
if (variable) {
const { from, to } = this.editor.state.selection
if (variable.includes('link') && from !== to) {
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
} else {
this.editor.chain().focus().insertContent(`{${variable}}`).run()
}
}
}
disconnectedCallback () {
this.linkTooltip.hide()
if (this.editor) {
this.editor.destroy()
}
}
}))
+18
View File
@@ -0,0 +1,18 @@
export default class extends HTMLElement {
connectedCallback () {
const dialog = document.getElementById(this.dataset.target)
this.querySelector('button').addEventListener('click', () => {
if (dialog) {
dialog.inert = false
dialog.showModal()
}
})
if (dialog) {
dialog.addEventListener('close', () => {
dialog.inert = true
})
}
}
}
@@ -47,11 +47,13 @@ export default class extends HTMLElement {
this.classList.remove('hidden', '-translate-y-10', 'opacity-0')
this.classList.add('translate-y-0', 'opacity-100')
this.inert = false
}
hideButtons () {
this.classList.remove('translate-y-0', 'opacity-100')
this.classList.add('-translate-y-10', 'opacity-0')
this.inert = true
setTimeout(() => {
if (this.classList.contains('-translate-y-10')) {
+53 -7
View File
@@ -5,17 +5,29 @@ export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
const { default: SignaturePad } = await import('signature_pad')
this.setCanvasSize()
this.pad = new SignaturePad(this.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas
this.setCanvasSize()
if (this.canvas.width !== width || this.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.canvas.parentNode)
this.clear.addEventListener('click', (e) => {
e.preventDefault()
@@ -47,4 +59,38 @@ export default targetable(class extends HTMLElement {
this.closest('form').requestSubmit()
}
disconnectedCallback () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
setCanvasSize () {
const scale = 3
const width = this.canvas.parentNode.clientWidth
const height = this.canvas.parentNode.clientWidth / 2.5
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
this.canvas.width = width * scale
this.canvas.height = height * scale
this.canvas.getContext('2d').scale(scale, scale)
}
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
}
}
})
+8 -2
View File
@@ -1,12 +1,18 @@
export default class extends HTMLElement {
connectedCallback () {
this.input.addEventListener('change', (event) => {
if (!this.target) return
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
const dataValue = this.dataset.value === 'false' ? false : this.dataset.value || true
if (this.dataset.attribute) {
this.target[this.dataset.attribute] = event.target.checked
this.target[this.dataset.attribute] = value === dataValue
}
if (this.dataset.className) {
this.target.classList.toggle(this.dataset.className, event.target.value !== this.dataset.value)
this.target.classList.toggle(this.dataset.className, value !== dataValue)
if (this.dataset.className === 'hidden' && this.target.tagName === 'INPUT') {
this.target.disabled = event.target.value !== this.dataset.value
}
+10 -2
View File
@@ -1,10 +1,18 @@
export default class extends HTMLElement {
connectedCallback () {
const button = this.querySelector('a, button')
const button = this.querySelector('a, button, label')
const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
button.addEventListener('click', () => {
this.dataset.classes.split(' ').forEach((cls) => {
button.classList.toggle(cls)
if (this.dataset.action === 'remove') {
target.classList.remove(cls)
} else if (this.dataset.action === 'add') {
target.classList.add(cls)
} else {
target.classList.toggle(cls)
}
})
})
}
+1 -1
View File
@@ -12,6 +12,6 @@ export default class extends HTMLElement {
}
get button () {
return this.querySelector('button')
return this.querySelector('button, label')
}
}
+7 -2
View File
@@ -7,6 +7,7 @@ import FetchForm from './elements/fetch_form'
import ScrollButtons from './elements/scroll_buttons'
import PageContainer from './elements/page_container'
import SubmitForm from './elements/submit_form'
import ModalButton from './elements/modal_button'
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
@@ -16,6 +17,7 @@ safeRegisterElement('fetch-form', FetchForm)
safeRegisterElement('scroll-buttons', ScrollButtons)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('modal-button', ModalButton)
safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
@@ -31,12 +33,13 @@ safeRegisterElement('submission-form', class extends HTMLElement {
isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
scrollPadding: this.dataset.scrollPadding || '-80px',
signatureText: this.dataset.signatureText,
language: this.dataset.language,
dryRun: this.dataset.dryRun === 'true',
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
withSignatureId: this.dataset.withSignatureId === 'true',
requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti !== 'false',
withConfetti: this.dataset.withConfetti === 'true',
withFieldLabels: this.dataset.withFieldLabels !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false',
@@ -49,7 +52,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
completedRedirectUrl: this.dataset.completedRedirectUrl,
attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields)
fields: JSON.parse(this.dataset.fields),
completeButtonContainer: document.getElementById('complete_button_container'),
completeButtonScrollContainer: document.getElementById('complete_button_container_scroll')
})
this.app.mount(this.appElem)
@@ -0,0 +1,434 @@
<template>
<template
v-for="(pages, docUuid) in textRuns"
:key="docUuid"
>
<template
v-for="(_, pageIndex) in pages"
:key="pageIndex"
>
<template
v-for="(pageElem, i) in [findPageElement(docUuid, pageIndex)]"
:key="i"
>
<Teleport
v-if="pageElem"
:to="pageElem"
>
<template
v-for="(item, index) in sortedItemsForPage(docUuid, pageIndex)"
:key="index"
>
<template v-if="item.type === 'text_group'">
<span
class="absolute overflow-hidden text-transparent select-none pointer-events-none"
:style="{ left: item.x * 100 + '%', top: item.y * 100 + '%', width: item.w * 100 + '%', height: item.h * 100 + '%' }"
>{{ item.text }}</span>
<span
v-for="(run, runIndex) in item.items"
:key="runIndex"
aria-hidden="true"
class="absolute overflow-hidden text-transparent"
:style="{ left: run.x * 100 + '%', top: run.y * 100 + '%', width: run.w * 100 + '%', height: run.h * 100 + '%', fontSize: run.font_size ? (run.font_size / 10) + 'cqmin' : undefined, textAlign: 'justify', textAlignLast: 'justify', textJustify: 'inter-character' }"
>{{ run.text }}</span>
</template>
<FieldArea
v-else-if="item.type === 'field_area'"
:ref="setAreaRef"
v-model="values[item.field.uuid]"
:values="values"
:field="item.field"
:area="item.area"
:submittable="true"
:page-width="1400"
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
:field-index="item.fieldIndex"
:is-inline-size="isInlineSize"
:scroll-padding="scrollPadding"
:submitter="submitter"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:is-active="currentStep === item.step"
:with-label="withLabel && !withFieldPlaceholder && item.step.length < 2"
:is-value-set="item.step.some((f) => f.uuid in values)"
:attachments-index="attachmentsIndex"
@click="[$emit('focus-step', item.stepIndex), maybeScrollOnClick(item.field, item.area)]"
/>
<FieldArea
v-else-if="item.type === 'readonly_field_area'"
:model-value="readonlyConditionalFieldValues[item.field.uuid]"
:values="readonlyConditionalFieldValues"
:field="item.field"
:area="item.area"
:submittable="false"
:page-width="1400"
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
:field-index="item.fieldIndex"
:is-inline-size="isInlineSize"
:submitter="submitter"
:attachments-index="attachmentsIndex"
/>
<FieldArea
v-else-if="item.type === 'formula_area' && isMathLoaded"
:model-value="calculateFormula(item.field)"
:is-inline-size="isInlineSize"
:field="item.field"
:area="item.area"
:submittable="false"
:field-index="item.fieldIndex"
/>
</template>
</Teleport>
</template>
</template>
</template>
</template>
<script>
import FieldArea from './area'
import FormulaAreas from './formula_areas'
import FieldAreas from './areas'
export default {
name: 'AccessibilityAreas',
components: {
FieldArea
},
inject: ['baseUrl', 't'],
props: {
submitterSlug: {
type: String,
required: true
},
filledFieldsIndex: {
type: Object,
required: false,
default: null
},
steps: {
type: Array,
required: false,
default: () => []
},
readonlyConditionalFields: {
type: Array,
required: false,
default: () => []
},
readonlyConditionalFieldValues: {
type: Object,
required: false,
default: () => ({})
},
formulaFields: {
type: Array,
required: false,
default: () => []
},
values: {
type: Object,
required: false,
default: () => ({})
},
readonlyValues: {
type: Object,
required: false,
default: () => ({})
},
submitter: {
type: Object,
required: true
},
currentStep: {
type: Array,
required: false,
default: () => []
},
withFieldPlaceholder: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: false
},
withLabel: {
type: Boolean,
required: false,
default: true
},
scrollPadding: {
type: String,
required: false,
default: '-80px'
},
scrollEl: {
type: Object,
required: false,
default: null
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
}
},
emits: ['focus-step'],
data () {
return {
isMathLoaded: false,
math: null,
textRuns: {},
areaRefs: []
}
},
computed: {
fieldValuesIndex () {
return this.filledFieldsIndex || this.extractStaticValues()
},
isMobileContainer: FieldAreas.computed.isMobileContainer,
isInlineSize: FieldAreas.computed.isInlineSize,
fieldsUuidIndex () {
return this.formulaFields.reduce((acc, field) => {
acc[field.uuid] = field
return acc
}, {})
},
fieldAreasIndex () {
const index = Object.create(null)
this.steps.forEach((step, stepIndex) => {
step.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'field_area',
field,
area,
step,
stepIndex,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
})
return index
},
formulaAreasIndex () {
const index = Object.create(null)
this.formulaFields.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'formula_area',
field,
area,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
return index
},
readonlyFieldAreasIndex () {
const index = Object.create(null)
this.readonlyConditionalFields.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'readonly_field_area',
field,
area,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
return index
}
},
beforeUpdate () {
this.areaRefs = []
},
async mounted () {
const [metadataResult] = await Promise.all([
fetch(this.baseUrl + `/s/${this.submitterSlug}/metadata`, {
...this.fetchOptions
}).then((r) => r.json()).catch(() => ({})),
this.loadMath()
])
this.textRuns = metadataResult.text_runs || {}
},
methods: {
normalizeFormula: FormulaAreas.methods.normalizeFormula,
calculateFormula: FormulaAreas.methods.calculateFormula,
scrollInContainer: FieldAreas.methods.scrollInContainer,
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
scrollIntoField: FieldAreas.methods.scrollIntoField,
maybeScrollOnClick: FieldAreas.methods.maybeScrollOnClick,
setAreaRef (el) {
if (el) {
this.areaRefs.push(el)
}
},
async loadMath () {
if (this.formulaFields.length && !this.isMathLoaded) {
const { Calculator } = await import('./calculator')
this.math = new Calculator()
this.isMathLoaded = true
}
},
extractStaticValues () {
const result = Object.create(null)
const root = this.$root.$el?.parentNode?.getRootNode() || document
const pageContainers = root.querySelectorAll('page-container')
pageContainers.forEach((container) => {
const overlay = container.querySelector('[id^="page-"]')
if (!overlay) return
const parts = overlay.id.split('-')
const pageIndex = parseInt(parts[parts.length - 1])
const docUuid = parts.slice(1, -1).join('-')
const fieldValues = overlay.querySelectorAll('field-value')
if (!fieldValues.length) return
result[docUuid] ||= Object.create(null)
result[docUuid][pageIndex] = []
fieldValues.forEach((el) => {
const style = el.style
const x = parseFloat(style.left) / 100
const y = parseFloat(style.top) / 100
const w = parseFloat(style.width) / 100
const h = parseFloat(style.height) / 100
const text = el.textContent.trim()
if (text) {
result[docUuid][pageIndex].push({ type: 'static_value', text, x, y, w, h })
}
})
})
return result
},
findPageElement (docUuid, pageIndex) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${docUuid}-${pageIndex}`)
},
sortedItemsForPage (docUuid, pageIndex) {
const items = []
const pageTextRuns = this.textRuns[docUuid]?.[pageIndex] || []
pageTextRuns.forEach((run) => {
items.push({
type: 'text_run',
text: run.text,
x: run.x,
y: run.y,
w: run.w,
h: run.h,
font_size: run.font_size
})
})
const fieldAreas = this.fieldAreasIndex[docUuid]?.[pageIndex] || []
items.push(...fieldAreas)
const readonlyFieldAreas = this.readonlyFieldAreasIndex[docUuid]?.[pageIndex] || []
items.push(...readonlyFieldAreas)
const formulaAreas = this.formulaAreasIndex[docUuid]?.[pageIndex] || []
items.push(...formulaAreas)
const pageFieldValues = this.fieldValuesIndex[docUuid]?.[pageIndex] || []
items.push(...pageFieldValues)
items.sort((a, b) => {
const aCenterY = a.y + a.h / 2
const bCenterY = b.y + b.h / 2
const lineThreshold = Math.min(a.h, b.h) / 2
if (Math.abs(aCenterY - bCenterY) < lineThreshold) {
return a.x - b.x
}
return aCenterY - bCenterY
})
const grouped = []
let currentGroup = null
const closeGroup = () => {
if (!currentGroup) return
const groupItems = currentGroup.items
const minX = Math.min(...groupItems.map((i) => i.x))
const minY = Math.min(...groupItems.map((i) => i.y))
const maxEndX = Math.max(...groupItems.map((i) => i.x + i.w))
const maxEndY = Math.max(...groupItems.map((i) => i.y + i.h))
currentGroup.x = minX
currentGroup.y = minY
currentGroup.w = maxEndX - minX
currentGroup.h = maxEndY - minY
currentGroup.text = groupItems.map((i) => i.text).join(' ').replace(/\s+/g, ' ').trim()
grouped.push(currentGroup)
currentGroup = null
}
for (const item of items) {
const isTextLike = item.type === 'text_run' || item.type === 'static_value'
if (!isTextLike) {
closeGroup()
grouped.push(item)
continue
}
if (!currentGroup) {
currentGroup = { type: 'text_group', items: [] }
}
currentGroup.items.push(item)
}
closeGroup()
return grouped
}
}
}
</script>
+17 -2
View File
@@ -42,10 +42,25 @@ export default {
const areas = {}
this.field.areas?.forEach((area) => {
areas[area.attachment_uuid + area.page] ||= area
areas[area.attachment_uuid] ||= []
areas[area.attachment_uuid].push(area)
})
return Object.values(areas).slice(0, 6)
const sortedAreas = Object.values(areas).reduce((acc, group) => {
const seen = {}
const sortedGroup = [...group].sort((a, b) => a.page - b.page)
sortedGroup.forEach((area) => {
if (!seen[area.page]) {
seen[area.page] = true
acc.push(area)
}
})
return acc
}, [])
return sortedAreas.slice(0, 6)
}
}
}
+74 -30
View File
@@ -1,9 +1,14 @@
<template>
<div
class="flex absolute lg:text-base -outline-offset-1 field-area"
class="flex absolute lg:text-base -outline-offset-1 focus-visible:outline-blue-500 focus-visible:outline-2 focus-visible:outline field-area"
dir="auto"
:style="[computedStyle, fontStyle]"
:class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
:role="submittable && !isNativeInputField ? 'button' : undefined"
:tabindex="submittable && !isNativeInputField ? 0 : undefined"
:aria-label="submittable && !isNativeInputField ? fieldAreaLabel : undefined"
@keydown.enter.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
@keydown.space.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
>
<div
v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@@ -18,6 +23,7 @@
width="100%"
height="100%"
class="max-h-10 text-base-content"
aria-hidden="true"
/>
</span>
</div>
@@ -129,6 +135,7 @@
v-if="submittable"
type="checkbox"
:value="false"
:aria-label="field.name || fieldNames[field.type]"
class="aspect-square base-checkbox"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue"
@@ -149,6 +156,8 @@
v-if="submittable"
type="radio"
:value="false"
:name="`radio-area-${field.uuid}`"
:aria-label="optionValue(option)"
class="aspect-square checked:checkbox checked:checkbox-xs"
:class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue === optionValue(option)"
@@ -169,6 +178,7 @@
v-if="submittable"
type="checkbox"
:value="false"
:aria-label="optionValue(option)"
class="aspect-square base-checkbox"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue.includes(optionValue(option))"
@@ -182,8 +192,8 @@
</div>
<div
v-else-if="field.type === 'cells'"
class="w-full flex items-center"
:class="{ 'justify-end': field.preferences?.align === 'right', ...fontClasses }"
class="w-full flex"
:class="{ 'justify-end': field.preferences?.align === 'right', ...alignClasses, ...fontClasses }"
>
<div
v-for="(char, index) in modelValue"
@@ -400,6 +410,16 @@ export default {
kba: this.t('kba')
}
},
isNativeInputField () {
return ['checkbox', 'radio', 'multiple'].includes(this.field.type)
},
fieldAreaLabel () {
const name = this.field.name || this.fieldNames[this.field.type] || this.field.type
if (this.area.option_uuid && this.option) {
return `${name} - ${this.optionValue(this.option)}`
}
return name
},
strikethroughWidth () {
if (this.isInlineSize) {
return '0.6cqmin'
@@ -461,7 +481,7 @@ export default {
phone: IconPhoneCheck,
payment: IconCreditCard,
verification: IconId,
kba: IconUserScan,
kba: IconUserScan
}
},
image () {
@@ -507,7 +527,8 @@ export default {
try {
return this.formatDate(
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
{ withTimePlaceholders: this.modelValue === '{{date}}', utc: this.modelValue !== '{{date}}' }
)
} catch {
return this.modelValue
@@ -622,40 +643,63 @@ export default {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else {
return number
}
},
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
formatDate (date, format, { withTimePlaceholders = false, utc = true } = {}) {
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
const minuteFormats = { m: 'numeric', mm: '2-digit' }
const secondFormats = { s: 'numeric', ss: '2-digit' }
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const hasTime = /[HhAasz]/.test(format)
const yearFormats = {
YYYY: 'numeric',
YYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
const opts = {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)],
timeZone: 'UTC'
}).formatToParts(date)
year: yearFormats[format.match(/Y+/)]
}
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
if (!hasTime && utc) opts.timeZone = 'UTC'
const partTypes = {
M: 'month',
D: 'day',
Y: 'year',
H: 'hour',
h: 'hour',
m: 'minute',
s: 'second',
z: 'timeZoneName',
A: 'dayPeriod',
a: 'dayPeriod'
}
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
if (withTimePlaceholders && /^(HH|hh|H|h|mm|m|ss|s|A|a)$/.test(token)) return '--'
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
if (token === 'A') return (value || '').toUpperCase()
if (token === 'a') return (value || '').toLowerCase()
return value
})
},
updateMultipleSelectValue (value) {
if (this.modelValue?.includes(value)) {
@@ -1,7 +1,11 @@
<template>
<div>
<div v-if="value.length">
<div
<ul
v-if="value.length"
:aria-label="t('uploaded_files')"
class="list-none p-0 m-0"
>
<li
v-for="(val, index) in value"
:key="index"
class="flex mb-2"
@@ -20,23 +24,27 @@
<IconPaperclip
:width="16"
class="flex-none"
:heigh="16"
:height="16"
aria-hidden="true"
/>
<span>
{{ attachmentsIndex[val].filename }}
</span>
</a>
<button
type="button"
class="remove-attachment-button"
:aria-label="`${t('clear')} ${attachmentsIndex[val].filename}`"
@click.prevent="removeAttachment(val)"
>
<IconTrashX
:width="18"
:heigh="19"
:height="19"
aria-hidden="true"
/>
</button>
</div>
</div>
</li>
</ul>
<template v-else>
<input
value=""
@@ -0,0 +1,874 @@
/*
* Portions of this file are derived from the Dentaku gem
* https://github.com/rubysolo/dentaku
*
* MIT License
*
* Copyright (c) 2012 Solomon White
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Modifications and JavaScript port
* Copyright (c) 2026 DocuSeal, LLC
*/
class CalculatorError extends Error { constructor (msg) { super(msg); this.name = 'CalculatorError' } }
class ParseError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ParseError' } }
class TokenizerError extends CalculatorError { constructor (msg) { super(msg); this.name = 'TokenizerError' } }
class ArgumentError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ArgumentError' } }
class ZeroDivisionError extends CalculatorError { constructor () { super('divided by 0'); this.name = 'ZeroDivisionError' } }
class UnboundVariableError extends CalculatorError {
constructor (vars) {
super('no value provided for variables: ' + vars.join(', '))
this.name = 'UnboundVariableError'
this.unbound = vars
}
}
class Token {
constructor (category, value, raw) {
this.category = category
this.value = value
this.raw = raw == null ? String(value) : String(raw)
}
is (cat) { return this.category === cat }
}
const OPERATOR_NAMES = {
'^': 'pow',
'+': 'add',
'-': 'subtract',
'*': 'multiply',
'/': 'divide',
'%': 'mod',
'|': 'bitor',
'&': 'bitand',
'<<': 'bitshiftleft',
'>>': 'bitshiftright'
}
const COMPARATOR_NAMES = {
'<=': 'le',
'>=': 'ge',
'!=': 'ne',
'<>': 'ne',
'<': 'lt',
'>': 'gt',
'==': 'eq',
'=': 'eq'
}
const GROUPING_NAMES = { '(': 'open', ')': 'close', ',': 'comma' }
function buildScanners (caseSensitive) {
return [
(s) => {
const m = /^\s+/.exec(s)
return m ? { tokens: [], length: m[0].length } : null
},
(s) => {
const m = /^null\b/i.exec(s)
return m ? { tokens: [new Token('null', null, m[0])], length: m[0].length } : null
},
(s) => {
const m = /^((?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?)\b/.exec(s)
if (!m) return null
return { tokens: [new Token('numeric', Number(m[0]), m[0])], length: m[0].length }
},
(s) => {
const m = /^0x[0-9a-f]+\b/i.exec(s)
if (!m) return null
return { tokens: [new Token('numeric', parseInt(m[0].slice(2), 16), m[0])], length: m[0].length }
},
(s) => {
const m = /^"([^"]*)"/.exec(s)
if (!m) return null
return { tokens: [new Token('string', m[1], m[0])], length: m[0].length }
},
(s) => {
const m = /^'([^']*)'/.exec(s)
if (!m) return null
return { tokens: [new Token('string', m[1], m[0])], length: m[0].length }
},
(s, last) => {
if (s[0] !== '-') return null
const ok = last == null ||
last.is('operator') ||
last.is('comparator') ||
last.is('combinator') ||
last.value === 'open' ||
last.value === 'comma' ||
last.value === 'array_start'
if (!ok) return null
return { tokens: [new Token('operator', 'negate', '-')], length: 1 }
},
(s) => {
const m = /^(and|or|&&|\|\|)\s/i.exec(s)
if (!m) return null
const raw = m[1].toLowerCase()
const name = raw === '&&' ? 'and' : raw === '||' ? 'or' : raw
return { tokens: [new Token('combinator', name, m[0])], length: m[0].length }
},
(s) => {
const m = /^(<<|>>|\^|\+|-|\*|\/|%|\||&)/.exec(s)
if (!m) return null
return { tokens: [new Token('operator', OPERATOR_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
const m = /^(\(|\)|,)/.exec(s)
if (!m) return null
return { tokens: [new Token('grouping', GROUPING_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
if (s[0] === '{') return { tokens: [new Token('array', 'array_start', '{')], length: 1 }
if (s[0] === '}') return { tokens: [new Token('array', 'array_end', '}')], length: 1 }
return null
},
(s) => {
const m = /^(<=|>=|!=|<>|==|=|<|>)/.exec(s)
if (!m) return null
return { tokens: [new Token('comparator', COMPARATOR_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
const m = /^(true|false)\b/i.exec(s)
if (!m) return null
return { tokens: [new Token('logical', m[0].toLowerCase() === 'true', m[0])], length: m[0].length }
},
(s) => {
const m = /^(\w+!?)\s*\(/.exec(s)
if (!m) return null
const name = m[1].toLowerCase()
return {
tokens: [
new Token('function', name, m[1]),
new Token('grouping', 'open', '(')
],
length: m[0].length
}
},
(s) => {
const m = /^[A-Za-z_][\w.]*\b/.exec(s)
if (!m) return null
const value = caseSensitive ? m[0] : m[0].toLowerCase()
return { tokens: [new Token('identifier', value, m[0])], length: m[0].length }
},
(s) => {
const m = /^`([^`]*)`/.exec(s)
if (!m) return null
return { tokens: [new Token('identifier', m[1], m[0])], length: m[0].length }
}
]
}
function tokenize (input, options) {
options = options || {}
let remaining = String(input).replace(/\/\*[\s\S]*?\*\//g, '')
const scanners = buildScanners(options.caseSensitive === true)
const tokens = []
let nesting = 0
while (remaining.length > 0) {
let matched = false
for (let i = 0; i < scanners.length; i++) {
const result = scanners[i](remaining, tokens.length ? tokens[tokens.length - 1] : null)
if (result) {
for (const tok of result.tokens) {
if (tok.category === 'grouping' && tok.value === 'open') nesting++
if (tok.category === 'grouping' && tok.value === 'close') {
nesting--
if (nesting < 0) throw new TokenizerError('too many closing parentheses')
}
tokens.push(tok)
}
remaining = remaining.slice(result.length)
matched = true
break
}
}
if (!matched) throw new TokenizerError("parse error at: '" + remaining + "'")
}
if (nesting > 0) throw new TokenizerError('too many opening parentheses')
return tokens
}
function uniq (arr) {
const seen = new Set()
const out = []
for (const x of arr) { if (!seen.has(x)) { seen.add(x); out.push(x) } }
return out
}
function toNumber (val) {
if (typeof val === 'number') return val
if (typeof val === 'boolean') return val ? 1 : 0
if (val == null) throw new ArgumentError("'" + val + "' is not coercible to numeric")
if (typeof val === 'string') {
if (val.length === 0) throw new ArgumentError("'' is not coercible to numeric")
const n = Number(val)
if (isNaN(n)) throw new ArgumentError("'" + val + "' is not coercible to numeric")
return n
}
throw new ArgumentError("'" + val + "' is not coercible to numeric")
}
class Node {
static precedence = 0
static arity = null
static rightAssociative = false
static resolveClass () { return this }
static minParamCount () { return this.arity }
static maxParamCount () { return this.arity }
value () { return null }
dependencies () { return [] }
get type () { return null }
}
class NilNode extends Node { value () { return null } }
class Numeric extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'numeric' }
}
class Logical extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'logical' }
}
class StringNode extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'string' }
}
class Identifier extends Node {
constructor (token) { super(); this.ident = token.value }
value (ctx) {
if (ctx && (this.ident in ctx)) {
const v = ctx[this.ident]
if (v instanceof Node) return v.value(ctx)
if (typeof v === 'function') return v()
return v
}
throw new UnboundVariableError([this.ident])
}
dependencies (ctx) {
if (ctx && (this.ident in ctx)) {
const v = ctx[this.ident]
return v instanceof Node ? v.dependencies(ctx) : []
}
return [this.ident]
}
}
class Grouping extends Node {
constructor (node) { super(); this.node = node }
value (ctx) { return this.node.value(ctx) }
get type () { return this.node.type }
dependencies (ctx) { return this.node.dependencies(ctx) }
}
class Operation extends Node {
constructor (left, right) { super(); this.left = left; this.right = right }
dependencies (ctx) {
const l = this.left ? this.left.dependencies(ctx) : []
const r = this.right ? this.right.dependencies(ctx) : []
return uniq(l.concat(r))
}
}
class Arithmetic extends Operation { get type () { return 'numeric' } }
class Addition extends Arithmetic {
static precedence = 10
value (ctx) {
const l = this.left.value(ctx)
const r = this.right.value(ctx)
if (typeof l === 'string' || typeof r === 'string') return String(l) + String(r)
return toNumber(l) + toNumber(r)
}
}
class Subtraction extends Arithmetic {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) - toNumber(this.right.value(ctx)) }
}
class Multiplication extends Arithmetic {
static precedence = 20
value (ctx) { return toNumber(this.left.value(ctx)) * toNumber(this.right.value(ctx)) }
}
class Division extends Arithmetic {
static precedence = 20
value (ctx) {
const r = toNumber(this.right.value(ctx))
if (r === 0) throw new ZeroDivisionError()
return toNumber(this.left.value(ctx)) / r
}
}
class Modulo extends Arithmetic {
static arity = 2
static precedence = 20
static resolveClass (nextTok) {
if (!nextTok) return Percentage
if (nextTok.category === 'operator') return Percentage
if (nextTok.category === 'comparator') return Percentage
if (nextTok.category === 'combinator') return Percentage
if (nextTok.category === 'grouping' && (nextTok.value === 'close' || nextTok.value === 'comma')) return Percentage
if (nextTok.category === 'array' && nextTok.value === 'array_end') return Percentage
return Modulo
}
value (ctx) {
const r = toNumber(this.right.value(ctx))
if (r === 0) throw new ZeroDivisionError()
return toNumber(this.left.value(ctx)) % r
}
}
class Percentage extends Arithmetic {
static arity = 1
static precedence = 30
constructor (child) { super(child, null) }
value (ctx) { return toNumber(this.left.value(ctx)) * 0.01 }
dependencies (ctx) { return this.left.dependencies(ctx) }
}
class Exponentiation extends Arithmetic {
static precedence = 30
static rightAssociative = true
value (ctx) { return Math.pow(toNumber(this.left.value(ctx)), toNumber(this.right.value(ctx))) }
}
class Negation extends Arithmetic {
static arity = 1
static precedence = 40
static rightAssociative = true
constructor (node) { super(node, null) }
value (ctx) { return -toNumber(this.left.value(ctx)) }
dependencies (ctx) { return this.left.dependencies(ctx) }
}
class BitwiseOr extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) | toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseAnd extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) & toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseShiftLeft extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) << toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseShiftRight extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) >> toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
function cmpCast (v) {
if (typeof v === 'string' && /^-?\d*\.?\d+$/.test(v)) return Number(v)
return v
}
class Comparator extends Operation {
static precedence = 5
get type () { return 'logical' }
}
class LessThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) < cmpCast(this.right.value(ctx)) } }
class LessThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) <= cmpCast(this.right.value(ctx)) } }
class GreaterThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) > cmpCast(this.right.value(ctx)) } }
class GreaterThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) >= cmpCast(this.right.value(ctx)) } }
class NotEqual extends Comparator {
value (ctx) {
const l = cmpCast(this.left.value(ctx))
const r = cmpCast(this.right.value(ctx))
return l !== r
}
}
class Equal extends Comparator {
value (ctx) {
const l = cmpCast(this.left.value(ctx))
const r = cmpCast(this.right.value(ctx))
return l === r
}
}
class Combinator extends Operation {
static precedence = 0
get type () { return 'logical' }
}
class And extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) && Boolean(this.right.value(ctx)) } }
class Or extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) || Boolean(this.right.value(ctx)) } }
class ArrayNode extends Node {
static arity = null
static minParamCount () { return 0 }
static maxParamCount () { return Infinity }
constructor (...items) {
super()
this.items = items
}
value (ctx) { return this.items.map((i) => i.value(ctx)) }
dependencies (ctx) {
return uniq([].concat.apply([], this.items.map((i) => i.dependencies(ctx))))
}
get type () { return 'array' }
}
class FunctionNode extends Node {
static arity = null
static minParamCount () { return 0 }
static maxParamCount () { return Infinity }
constructor (...args) {
super()
this.args = args
}
dependencies (ctx) {
return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx))))
}
}
const FUNCTION_REGISTRY = Object.create(null)
function registerFunctionClass (name, cls) { FUNCTION_REGISTRY[name.toLowerCase()] = cls }
function getFunctionClass (name) { return FUNCTION_REGISTRY[name.toLowerCase()] }
function defineFunction (name, spec) {
const min = spec.min == null ? 0 : spec.min
const max = spec.max == null ? Infinity : spec.max
const call = spec.call
const type = spec.type || 'numeric'
class F extends FunctionNode {
static arity = (min === max) ? min : null
static minParamCount () { return min }
static maxParamCount () { return max }
value (ctx) {
const vals = this.args.map((a) => a.value(ctx))
return call(vals, ctx)
}
get type () { return type }
}
registerFunctionClass(name, F)
return F
}
function flattenNumeric (vals) {
const out = [];
(function walk (v) {
if (Array.isArray(v)) v.forEach(walk)
else out.push(toNumber(v))
})(vals)
return out
}
defineFunction('abs', { min: 1, max: 1, call: (v) => Math.abs(toNumber(v[0])) })
defineFunction('min', { min: 1, call: (v) => Math.min.apply(null, flattenNumeric(v)) })
defineFunction('max', { min: 1, call: (v) => Math.max.apply(null, flattenNumeric(v)) })
defineFunction('sum', { min: 1, call: (v) => flattenNumeric(v).reduce((a, b) => a + b, 0) })
defineFunction('avg', {
min: 1,
call: (v) => {
const nums = flattenNumeric(v)
if (nums.length === 0) return 0
return nums.reduce((a, b) => a + b, 0) / nums.length
}
})
defineFunction('count', {
min: 1,
max: 1,
call: (v) => Array.isArray(v[0]) ? v[0].length : (v[0] == null ? 0 : 1)
})
defineFunction('round', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return Math.round(n * f) / f
}
})
defineFunction('roundup', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return (n < 0 ? -Math.floor(-n * f) : Math.ceil(n * f)) / f
}
})
defineFunction('rounddown', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return (n < 0 ? -Math.ceil(-n * f) : Math.floor(n * f)) / f
}
})
const MATH_FNS = {
sqrt: Math.sqrt,
cbrt: Math.cbrt,
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
asin: Math.asin,
acos: Math.acos,
atan: Math.atan,
sinh: Math.sinh,
cosh: Math.cosh,
tanh: Math.tanh,
exp: Math.exp,
log: Math.log,
log2: Math.log2,
log10: Math.log10,
floor: Math.floor,
ceil: Math.ceil
}
Object.keys(MATH_FNS).forEach((name) => {
const fn = MATH_FNS[name]
defineFunction(name, { min: 1, max: 1, call: (v) => fn(toNumber(v[0])) })
})
defineFunction('atan2', { min: 2, max: 2, call: (v) => Math.atan2(toNumber(v[0]), toNumber(v[1])) })
defineFunction('hypot', { min: 1, call: (v) => Math.hypot.apply(null, flattenNumeric(v)) })
defineFunction('pow', { min: 2, max: 2, call: (v) => Math.pow(toNumber(v[0]), toNumber(v[1])) })
defineFunction('pi', { min: 0, max: 0, call: () => Math.PI })
defineFunction('e', { min: 0, max: 0, call: () => Math.E })
defineFunction('not', { min: 1, max: 1, type: 'logical', call: (v) => !v[0] })
defineFunction('and', { min: 1, type: 'logical', call: (v) => v.every((x) => Boolean(x)) })
defineFunction('or', { min: 1, type: 'logical', call: (v) => v.some((x) => Boolean(x)) })
defineFunction('xor', {
min: 2,
type: 'logical',
call: (v) => v.reduce((acc, x) => acc !== Boolean(x), false)
})
class IfFunction extends FunctionNode {
static arity = 3
static minParamCount () { return 3 }
static maxParamCount () { return 3 }
value (ctx) {
return this.args[0].value(ctx) ? this.args[1].value(ctx) : this.args[2].value(ctx)
}
get type () { return this.args[1].type }
dependencies (ctx) {
try {
return this.args[0].value(ctx) ? this.args[1].dependencies(ctx) : this.args[2].dependencies(ctx)
} catch (e) {
return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx))))
}
}
}
registerFunctionClass('if', IfFunction)
defineFunction('length', { min: 1, max: 1, call: (v) => v[0] == null ? 0 : String(v[0]).length })
defineFunction('upcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toUpperCase() })
defineFunction('downcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toLowerCase() })
defineFunction('concat', { min: 1, type: 'string', call: (v) => v.map((x) => String(x)).join('') })
defineFunction('left', {
min: 2,
max: 2,
type: 'string',
call: (v) => String(v[0]).slice(0, toNumber(v[1]))
})
defineFunction('right', {
min: 2,
max: 2,
type: 'string',
call: (v) => {
const n = toNumber(v[1])
return n === 0 ? '' : String(v[0]).slice(-n)
}
})
defineFunction('mid', {
min: 3,
max: 3,
type: 'string',
call: (v) => {
const s = String(v[0])
const start = toNumber(v[1]) - 1
const len = toNumber(v[2])
return s.slice(start, start + len)
}
})
defineFunction('trim', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).trim() })
defineFunction('contains', {
min: 2,
max: 2,
type: 'logical',
call: (v) => {
if (Array.isArray(v[0])) return v[0].indexOf(v[1]) !== -1
return String(v[0]).indexOf(String(v[1])) !== -1
}
})
const OP_CLASSES = {
add: Addition,
subtract: Subtraction,
multiply: Multiplication,
divide: Division,
pow: Exponentiation,
negate: Negation,
mod: Modulo,
bitor: BitwiseOr,
bitand: BitwiseAnd,
bitshiftleft: BitwiseShiftLeft,
bitshiftright: BitwiseShiftRight,
lt: LessThan,
gt: GreaterThan,
le: LessThanOrEqual,
ge: GreaterThanOrEqual,
ne: NotEqual,
eq: Equal,
and: And,
or: Or
}
function isOperationClass (cls) {
return cls && (cls.prototype instanceof Operation)
}
function parse (tokens) {
if (!tokens || tokens.length === 0) return new NilNode()
const output = []
const operations = []
const arities = []
const skip = new Set()
function consume (count) {
if (count == null) count = 2
const op = operations.pop()
if (!op) throw new ParseError('invalid statement')
const outputSize = output.length
const opMin = (typeof op.minParamCount === 'function') ? op.minParamCount() : null
const opMax = (typeof op.maxParamCount === 'function') ? op.maxParamCount() : null
const actualArgs = (op.arity != null) ? op.arity : count
const minSize = (op.arity != null) ? op.arity : (opMin != null ? opMin : count)
const maxSize = (op.arity != null) ? op.arity : (opMax != null ? opMax : count)
if (outputSize < minSize || actualArgs < minSize) {
throw new ParseError((op.name || 'operator') + ' has too few operands (given ' + outputSize + ', expected ' + minSize + ')')
}
if ((outputSize > maxSize && operations.length === 0) || actualArgs > maxSize) {
throw new ParseError((op.name || 'operator') + ' has too many operands (given ' + outputSize + ', expected ' + maxSize + ')')
}
if (op === ArrayNode && output.length === 0) {
output.push(new ArrayNode())
return
}
if (outputSize < actualArgs) throw new ParseError('invalid statement')
const args = []
for (let i = 0; i < actualArgs; i++) args.unshift(output.pop())
// eslint-disable-next-line new-cap
output.push(new op(...args))
}
function handleOperator (token, lookahead) {
let cls = OP_CLASSES[token.value]
if (!cls) throw new ParseError('Unknown operator ' + token.value)
if (typeof cls.resolveClass === 'function') cls = cls.resolveClass(lookahead)
const prec = cls.precedence
if (cls.rightAssociative) {
while (operations.length > 0) {
const top = operations[operations.length - 1]
if (isOperationClass(top) && prec < top.precedence) consume()
else break
}
} else {
while (operations.length > 0) {
const top = operations[operations.length - 1]
if (isOperationClass(top) && prec <= top.precedence) consume()
else break
}
}
operations.push(cls)
}
function handleFunction (token) {
const fn = getFunctionClass(token.value)
if (!fn) throw new ParseError('Undefined function ' + token.value)
arities.push(0)
operations.push(fn)
}
function handleArray (token) {
if (token.value === 'array_start') {
operations.push(ArrayNode)
arities.push(0)
} else {
while (operations.length > 0 && operations[operations.length - 1] !== ArrayNode) consume()
if (operations[operations.length - 1] !== ArrayNode) throw new ParseError('Unbalanced bracket')
consume(arities.pop() + 1)
}
}
function handleGrouping (token, lookahead, index) {
if (token.value === 'open') {
if (lookahead && lookahead.value === 'close') {
skip.add(index + 1)
arities.pop()
consume(0)
} else {
operations.push(Grouping)
}
} else if (token.value === 'close') {
while (operations.length > 0 && operations[operations.length - 1] !== Grouping) consume()
const lparen = operations.pop()
if (lparen !== Grouping) throw new ParseError('Unbalanced parenthesis')
const top = operations[operations.length - 1]
if (top && (top.prototype instanceof FunctionNode)) consume(arities.pop() + 1)
} else if (token.value === 'comma') {
if (arities.length === 0) throw new ParseError('invalid statement')
arities[arities.length - 1] += 1
while (operations.length > 0 &&
operations[operations.length - 1] !== Grouping &&
operations[operations.length - 1] !== ArrayNode) consume()
} else {
throw new ParseError('Unknown grouping token ' + token.value)
}
}
for (let i = 0; i < tokens.length; i++) {
if (skip.has(i)) continue
const token = tokens[i]
const lookahead = tokens[i + 1]
switch (token.category) {
case 'numeric': output.push(new Numeric(token)); break
case 'logical': output.push(new Logical(token)); break
case 'string': output.push(new StringNode(token)); break
case 'identifier': output.push(new Identifier(token)); break
case 'null': output.push(new NilNode()); break
case 'operator':
case 'comparator':
case 'combinator':
handleOperator(token, lookahead); break
case 'function': handleFunction(token); break
case 'array': handleArray(token); break
case 'grouping': handleGrouping(token, lookahead, i); break
default:
throw new ParseError('Not implemented for tokens of category ' + token.category)
}
}
while (operations.length > 0) consume()
if (output.length !== 1) throw new ParseError('Invalid statement')
return output[0]
}
class Calculator {
constructor (options) {
options = options || {}
this.caseSensitive = options.caseSensitive === true
this.memory = Object.create(null)
this._astCache = Object.create(null)
}
key (k) { return this.caseSensitive ? k : String(k).toLowerCase() }
normalize (data) {
const out = Object.create(null)
if (!data) return out
for (const k of Object.keys(data)) out[this.key(k)] = data[k]
return out
}
store (data) { Object.assign(this.memory, this.normalize(data)); return this }
bind (data) { return this.store(data) }
clear () { this.memory = Object.create(null); return this }
ast (expression) {
const cached = this._astCache[expression]
if (cached) return cached
const node = parse(tokenize(expression, { caseSensitive: this.caseSensitive }))
this._astCache[expression] = node
return node
}
tokenize (expression) {
return tokenize(expression, { caseSensitive: this.caseSensitive })
}
dependencies (expression) {
return this.ast(expression).dependencies(this.memory)
}
evaluate (expression, data, onError) {
try {
return this.evaluateStrict(expression, data)
} catch (e) {
if (typeof data === 'function') return data(expression, e)
if (typeof onError === 'function') return onError(expression, e)
return undefined
}
}
evaluateStrict (expression, data) {
const ctx = Object.assign(Object.create(null), this.memory, this.normalize(data))
const node = this.ast(expression)
return node.value(ctx)
}
addFunction (name, spec) {
defineFunction(name, {
min: spec.minArgs == null ? (spec.min == null ? 0 : spec.min) : spec.minArgs,
max: spec.maxArgs == null ? (spec.max == null ? Infinity : spec.max) : spec.maxArgs,
type: spec.type || 'numeric',
call: spec.call
})
return this
}
}
export {
Calculator,
tokenize,
parse,
CalculatorError,
ParseError,
TokenizerError,
UnboundVariableError,
ZeroDivisionError,
ArgumentError
}
export function addFunction (name, spec) { defineFunction(name, spec) }
+23 -4
View File
@@ -3,10 +3,13 @@
id="form_completed"
class="mx-auto max-w-md flex flex-col completed-form"
dir="auto"
role="status"
tabindex="-1"
>
<div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
<IconCircleCheck
class="inline text-green-600"
aria-hidden="true"
:width="30"
:height="30"
/>
@@ -42,8 +45,12 @@
<IconInnerShadowTop
v-if="isSendingCopy"
class="animate-spin"
aria-hidden="true"
/>
<IconMail
v-else
aria-hidden="true"
/>
<IconMail v-else />
<span>
{{ t('send_copy_via_email') }}
</span>
@@ -57,8 +64,12 @@
<IconInnerShadowTop
v-if="isDownloading"
class="animate-spin"
aria-hidden="true"
/>
<IconDownload
v-else
aria-hidden="true"
/>
<IconDownload v-else />
<span>
{{ t('download') }}
</span>
@@ -161,6 +172,11 @@ export default {
required: false,
default: false
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
},
completedButton: {
type: Object,
required: false,
@@ -194,7 +210,7 @@ export default {
})
}
document.querySelectorAll('#decline_button').forEach((button) => {
document.querySelectorAll('#decline_button, #decline_button_mobile, #delegate_button, #delegate_button_mobile').forEach((button) => {
button.setAttribute('disabled', 'true')
})
},
@@ -214,7 +230,10 @@ export default {
download () {
this.isDownloading = true
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then(async (response) => {
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`, {
method: 'GET',
...this.fetchOptions
}).then(async (response) => {
if (response.ok) {
const urls = await response.json()
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
+65 -18
View File
@@ -27,22 +27,27 @@
</label>
<button
v-if="withToday"
type="button"
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
@click.prevent="[setCurrentDate(), $emit('focus')]"
>
<IconCalendarCheck :width="16" />
<IconCalendarCheck
:width="16"
aria-hidden="true"
/>
{{ t('set_today') }}
</button>
</div>
<div
v-if="field.description"
:id="field.uuid + '-desc'"
class="mb-3 px-1 field-description-text"
dir="auto"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<div class="text-center">
<div class="text-center flex">
<input
:id="field.uuid"
ref="input"
@@ -51,12 +56,19 @@
:max="validationMax"
class="base-input !text-2xl text-center w-full"
:required="field.required"
type="date"
:name="`values[${field.uuid}]`"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:type="inputType"
:name="formatType === 'datetime' ? undefined : `values[${field.uuid}]`"
@keydown.enter="onEnter"
@focus="$emit('focus')"
@paste="onPaste"
>
<input
v-if="formatType === 'datetime'"
type="hidden"
:name="`values[${field.uuid}]`"
:value="modelValue"
>
</div>
</div>
</template>
@@ -92,14 +104,19 @@ export default {
},
emits: ['update:model-value', 'focus', 'submit'],
computed: {
formatType () {
const format = this.field.preferences?.format || ''
if (/[HhAasz]/.test(format)) return 'datetime'
if (format && !/[Dd]/.test(format)) return 'month'
return 'date'
},
inputType () {
return { datetime: 'datetime-local', month: 'month', date: 'date' }[this.formatType]
},
dateNowString () {
const today = new Date()
const yyyy = today.getFullYear()
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
return this.formatDateValue(new Date())
},
validationMin () {
if (this.field.validation?.min) {
@@ -116,6 +133,8 @@ export default {
}
},
withToday () {
if (this.formatType === 'datetime') return false
const todayDate = new Date().setHours(0, 0, 0, 0)
if (this.validationMin) {
@@ -132,9 +151,25 @@ export default {
},
value: {
set (value) {
if (this.formatType === 'datetime' && value) {
const d = new Date(value)
if (!isNaN(d)) {
this.$emit('update:model-value', d.toISOString())
return
}
}
this.$emit('update:model-value', value)
},
get () {
if (this.formatType === 'datetime') {
const d = new Date(this.modelValue)
return isNaN(d) ? '' : this.formatDateValue(d)
}
return this.modelValue
}
}
@@ -158,20 +193,32 @@ export default {
const parsedDate = new Date(pasteData)
if (!isNaN(parsedDate)) {
const inputEl = this.$refs.input
if (isNaN(parsedDate)) return
inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
}
this.setInputValue(parsedDate)
},
setCurrentDate () {
this.setInputValue(new Date())
},
setInputValue (date) {
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
if (this.formatType === 'date') {
inputEl.valueAsDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
} else {
inputEl.value = this.formatDateValue(date)
}
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
},
formatDateValue (date) {
const pad = (n) => String(n).padStart(2, '0')
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
if (this.formatType === 'month') return ymd.slice(0, 7)
if (this.formatType === 'datetime') return `${ymd}T${pad(date.getHours())}:${pad(date.getMinutes())}`
return ymd
}
}
}
+40 -29
View File
@@ -39,8 +39,9 @@
ref="input"
:multiple="multiple"
:accept="accept"
:aria-label="message"
type="file"
class="hidden"
class="sr-only"
@change="onSelectFiles"
>
</label>
@@ -50,6 +51,36 @@
<script>
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export default {
name: 'FileDropzone',
components: {
@@ -152,8 +183,14 @@ export default {
}
})
} else {
if (file.type === 'image/bmp' || file.type === 'image/vnd.microsoft.icon') {
file = await this.convertBmpToPng(file)
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
file = await convertImage(file, 'image/png')
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
file = await convertImage(file, 'image/jpeg', 0.9)
}
} catch (e) {
alert(e.message)
}
formData.append('file', file)
@@ -180,32 +217,6 @@ export default {
}).finally(() => {
this.isLoading = false
})
},
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
resolve(newFile)
}, 'image/png')
}
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
}
}
}

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