Compare commits

..

1 Commits

Author SHA1 Message Date
David Nguyen 0ee5e441b9 feat: add field multislect 2026-06-25 16:10:45 +10:00
54 changed files with 773 additions and 609 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso"
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
# [[SMTP]]
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | resend | mailchannels
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
# OPTIONAL: Defines the host to use for sending emails.
NEXT_PRIVATE_SMTP_HOST="127.0.0.1"
+2 -1
View File
@@ -34,7 +34,7 @@ body:
label: Browser [e.g., Chrome, Firefox]
- type: input
attributes:
label: Version [e.g., 2.13.0]
label: Version [e.g., 2.0.1]
- type: checkboxes
attributes:
label: Please check the boxes that apply to this issue report.
@@ -44,3 +44,4 @@ body:
- label: I have included relevant environment information.
- label: I have included any relevant screenshots.
- label: I understand that this is a voluntary contribution and that there is no guarantee of resolution.
- label: I want to work on creating a PR for this issue if approved
-11
View File
@@ -1,11 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/documenso/documenso/security/advisories/new
about: Please report security vulnerabilities privately via GitHub Security Advisories. Do not open a public issue.
- name: Questions & Discussions
url: https://github.com/documenso/documenso/discussions
about: Ask questions, share ideas, and discuss Documenso with the community.
- name: Discord
url: https://documen.so/discord
about: Chat with the community and the team.
@@ -33,3 +33,4 @@ body:
- label: I have explained the use case or scenario for this feature.
- label: I have included any relevant technical details or design suggestions.
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
- label: I want to work on creating a PR for this issue if approved
+11
View File
@@ -15,6 +15,17 @@ body:
description: 'Are there any additional context or information that might be relevant to the improvement suggestion.'
validations:
required: false
- type: dropdown
id: assignee
attributes:
label: 'Do you want to work on this improvement?'
multiple: false
options:
- 'No'
- 'Yes'
default: 0
validations:
required: true
- type: checkboxes
attributes:
label: 'Please check the boxes that apply to this improvement suggestion.'
-11
View File
@@ -1,14 +1,3 @@
<!--
We are no longer accepting external pull requests.
Aside from a small group of trusted contributors we reach out to directly,
new external PRs will usually be closed with a request to open an issue instead.
This is a security decision. See https://documenso.com/blog/why-we-re-pausing-external-pull-requests
If you're a trusted contributor or maintainer, continue below.
Otherwise, please open a detailed issue: https://github.com/documenso/documenso/issues/new/choose
-->
## Description
<!--- Describe the changes introduced by this pull request. -->
@@ -0,0 +1,40 @@
---
name: Test Addition
about: Submit a new test, either unit or end-to-end (E2E), for review and inclusion
---
## Description
<!--- Provide a clear and concise description of the new test you are adding. -->
<!--- Explain the purpose of the test and what it aims to validate. -->
## Related Issue
<!--- If this test addition is related to a specific issue, reference it here using #issue_number. -->
<!--- For example, "Fixes #123" or "Addresses #456". -->
## Test Details
<!--- Describe the details of the test you're adding. -->
<!--- Include information about inputs, expected outputs, and any specific scenarios. -->
- Test Name: Name of the test
- Type: [Unit / E2E]
- Description: Brief description of what the test checks
- Inputs: What inputs the test uses (if applicable)
- Expected Output: What output or behavior the test expects
## Checklist
<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->
- [ ] I have written the new test and ensured it works as intended.
- [ ] I have added necessary documentation to explain the purpose of the test.
- [ ] I have followed the project's testing guidelines and coding style.
- [ ] I have addressed any review feedback from previous submissions, if applicable.
## Additional Notes
<!--- Provide any additional context or notes for the reviewers. -->
<!--- This might include explanations about the testing approach or any potential concerns. -->
+7 -1
View File
@@ -1,10 +1,13 @@
name: 'Welcome New Contributors'
on:
pull_request:
types: ['opened']
issues:
types: ['opened']
permissions:
pull-requests: write
issues: write
jobs:
@@ -17,7 +20,10 @@ jobs:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |
Thank you for creating your first Pull Request and for being a part of the open signing revolution! 💚🚀
<br /> Feel free to hop into our community in [Discord](https://documen.so/discord)
issue-message: |
Thank you for opening your first issue and for being a part of the open signing revolution!
<br /> One of our team members will review it and get back to you as soon as possible 💚
<br /> One of our team members will review it and get back to you as soon as it possible 💚
<br /> Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)
@@ -0,0 +1,62 @@
name: 'Issue Assignee Check'
on:
issues:
types: ['assigned']
permissions:
issues: write
jobs:
countIssues:
if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install Octokit
run: npm install @octokit/rest@18
- name: Check Assigned User's Issue Count
id: parse-comment
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const username = context.payload.issue.assignee.login;
console.log(`Username Extracted: ${username}`);
const { data: issues } = await octokit.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
assignee: username,
state: 'open'
});
const issueCount = issues.length;
console.log(`Issue Count For ${username}: ${issueCount}`);
if (issueCount > 3) {
let issueCountMessage = `### 🚨 Documenso Police 🚨`;
issueCountMessage += `\n@${username} has ${issueCount} open issues assigned already. Consider whether this issue should be assigned to them or left open for another contributor.`;
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: issueCountMessage,
headers: {
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
}
});
}
+63
View File
@@ -0,0 +1,63 @@
name: 'PR Review Reminder'
on:
pull_request:
types: ['opened', 'ready_for_review']
permissions:
pull-requests: write
jobs:
checkPRs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install Octokit
run: npm install @octokit/rest@18
- name: Check user's PRs awaiting review
id: parse-prs
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const username = context.payload.pull_request.user.login;
console.log(`Username Extracted: ${username}`);
const { data: pullRequests } = await octokit.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'created',
direction: 'asc',
});
const userPullRequests = pullRequests.filter(pr => pr.user.login === username && (pr.state === 'open' || pr.state === 'ready_for_review'));
const prCount = userPullRequests.length;
console.log(`PR Count for ${username}: ${prCount}`);
if (prCount > 3) {
let prReminderMessage = `🚨 @${username} has ${prCount} pull requests awaiting review. Please consider reviewing them when possible. 🚨`;
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: prReminderMessage,
headers: {
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
}
});
}
@@ -16,6 +16,24 @@ jobs:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- name: Check PR creator's previous activity
id: check_activity
run: |
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
if [ "$ACTIVITY" -eq 0 ]; then
echo "::set-output name=is_new::true"
else
echo "::set-output name=is_new::false"
fi
- name: Count PRs created by user
id: count_prs
run: |
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
echo "::set-output name=pr_count::$PR_COUNT"
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
@@ -26,6 +44,8 @@ jobs:
with:
header: pr-title-lint-error
message: |
Hey There! and thank you for opening this pull request! 📝👋🏼
We require pull request titles to follow the [Conventional Commits Spec](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
Details:
@@ -33,3 +53,10 @@ jobs:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions for pull request titles! 💚🚀
+1 -1
View File
@@ -21,4 +21,4 @@ jobs:
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
close-pr-message: 'This PR has been closed because of inactivity.'
exempt-pr-labels: 'WIP,on-hold,needs review'
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,status: assigned,status: triage'
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage'
+2 -1
View File
@@ -31,7 +31,8 @@ vscode:
extensions:
- aaron-bond.better-comments
- bradlc.vscode-tailwindcss
- biomejs.biome
- dbaeumer.vscode-eslint
- esbenp.prettier-vscode
- mikestead.dotenv
- unifiedjs.vscode-mdx
- GitHub.vscode-pull-request-github
+4 -11
View File
@@ -1,14 +1,7 @@
# Report security vulnerabilities privately via GitHub Security Advisories (preferred).
Contact: https://github.com/documenso/documenso/security/advisories/new
# Alternatively, report critical issues privately by email.
Contact: mailto:security@documenso.com
# Security policy
Policy: https://github.com/documenso/documenso/security/policy
# General (non-security) issues
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt
Canonical: https://documenso.com/.well-known/security.txt
+6 -20
View File
@@ -1,27 +1,13 @@
# Contributing to Documenso
> **We are no longer accepting external pull requests.**
>
> Aside from a small group of trusted contributors we reach out to directly, we no longer merge external PRs. New pull requests will usually be closed with a request to open an issue instead. This is a security decision, not a judgement on your work. Read [Why We're Pausing External Pull Requests](https://documenso.com/blog/why-we-re-pausing-external-pull-requests) for the full reasoning.
>
> Documenso stays open source. You can still read, audit, run, and fork the code. The best way to contribute is through detailed issues.
If you plan to contribute to Documenso, please take a moment to feel awesome ✨ People like you are what open source is about ♥. Any contributions, no matter how big or small, are highly appreciated.
## How to contribute now
## Before getting started
The most useful contribution is a detailed issue. Treat it like a spec. The more detail, the better:
- The problem you're trying to solve, and who it affects
- How you expect the feature or change to behave
- Edge cases, constraints, and anything you've already considered
- Examples, mockups, or references where they help
Before opening an issue, search [existing issues](https://github.com/documenso/documenso/issues) and [discussions](https://github.com/documenso/documenso/discussions) for related items. If a proposal is detailed and fits where Documenso is heading, we'll pick it up and build against it.
For security vulnerabilities, do not open a public issue. Follow our [Security Policy](./SECURITY.md) instead.
---
The sections below are for trusted contributors working with us directly, and for anyone running Documenso locally or maintaining a fork.
- Before jumping into a PR be sure to search [existing PRs](https://github.com/documenso/documenso/pulls) or [issues](https://github.com/documenso/documenso/issues) for an open or closed item that relates to your submission.
- Select an issue from [here](https://github.com/documenso/documenso/issues) or create a new one
- Consider the results from the discussion on the issue
- Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
## English only PRs and Issues
+12 -22
View File
@@ -51,18 +51,16 @@ Join us in creating the next generation of open trust infrastructure.
## Community and Next Steps 🎯
- Try Documenso by self-hosting it or signing up at [documenso.com](https://documenso.com).
- Check out the first source code release in this repository and test it.
- Tell us what you think in the [Discussions](https://github.com/documenso/documenso/discussions).
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know other community members.
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members.
- ⭐ the repository to help us raise awareness.
- Open detailed [issues](https://github.com/documenso/documenso/issues) to report bugs or propose features.
- Spread the word on Twitter that Documenso is working towards a more open signing tool.
- Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release.
## Contributing
> **Note**: We no longer accept external pull requests, aside from a small group of trusted contributors we reach out to directly. The best way to contribute is through detailed issues. Read [Why We're Pausing External Pull Requests](https://documenso.com/blog/why-we-re-pausing-external-pull-requests) for the reasoning.
- Documenso stays open source. You can read, audit, run, and fork the code.
- To report issues or propose changes, see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
- To contribute, please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
## Contact us
@@ -83,21 +81,17 @@ Contact us if you are interested in our Enterprise plan for large organizations
<a href=""><img src="" alt=""></a>
</p>
- [TypeScript](https://www.typescriptlang.org/) - Language
- [React Router v7](https://reactrouter.com/) - Framework
- [Hono](https://hono.dev/) - Server
- [Typescript](https://www.typescriptlang.org/) - Language
- [ReactRouter](https://reactrouter.com/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [Tailwind CSS](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) + [Radix UI](https://www.radix-ui.com/) - Component Library
- [Tailwind](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
- [react-email](https://react.email/) - Email Templates
- [Lingui](https://lingui.dev/) - Internationalization
- [tRPC](https://trpc.io/) - API
- [@libpdf/core](https://www.npmjs.com/package/@libpdf/core) - PDF Signatures
- [pdf.js](https://mozilla.github.io/pdf.js/) - Viewing PDFs
- [@cantoo/pdf-lib](https://github.com/cantoo-scribe/pdf-lib) - PDF manipulation
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures (launching soon)
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
- [Stripe](https://stripe.com/) - Payments
- [Biome](https://biomejs.dev/) - Linting & Formatting
- [Playwright](https://playwright.dev/) - E2E Testing
<!-- - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. -->
@@ -202,10 +196,6 @@ For full instructions, requirements, and configuration details, see the [Self Ho
[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/documenso)
## Security
If you believe you have found a security vulnerability in Documenso, please report it through our [Security Policy](https://github.com/documenso/documenso/security/policy). We prioritize private reports via [GitHub Security Advisories](https://github.com/documenso/documenso/security/advisories/new). See [SECURITY.md](./SECURITY.md) for scope and details.
## Troubleshooting
For troubleshooting self-hosted deployments, see the [Troubleshooting guide](https://docs.documenso.com/docs/self-hosting/maintenance/troubleshooting) and [Tips & Common Pitfalls](https://docs.documenso.com/docs/self-hosting/getting-started/tips).
-38
View File
@@ -1,38 +0,0 @@
# Security Policy
We take the security of Documenso seriously. As a platform trusted with legally binding documents, the safety of the project and the people who rely on it is a priority for us. We're grateful to the security researchers who help keep it that way. If you've found an issue, we'd genuinely like to hear about it.
## Reporting a Vulnerability
Report security vulnerabilities privately. Do not open a public issue, discussion, or pull request for security reports.
We accept reports through two channels, in order of preference:
1. **GitHub Security Advisories (preferred)**. Use the [private vulnerability reporting form](https://github.com/documenso/documenso/security/advisories/new). This is our primary channel and lets us triage and work with you on a fix.
2. **Email**. If you cannot use GitHub Security Advisories, email [security@documenso.com](mailto:security@documenso.com).
Include the affected version, a clear description, steps to reproduce, and the potential impact.
## Triage and Response
We triage reports as we have availability. We read every report we receive, and we appreciate the time and effort it takes to put one together.
We also run [Codex](https://openai.com/codex/) security analysis across the codebase. If Codex has already reported the issue you're sending us, we may close your report as a duplicate. Please don't take this as a reflection on your work; it just means our automated tooling happened to surface the same thing first.
## Scope
This policy covers vulnerabilities in the Documenso application code in this repository.
The items below are out of scope and will not be accepted. They are deployment, infrastructure, and configuration concerns that belong with the operator's firewall, network, and environment setup, not the application:
- Server-Side Request Forgery (SSRF) and related network-egress concerns
- DNS rebinding and other DNS-level issues
- Rate limiting, denial of service, and volumetric attacks
- TLS and certificate configuration, HTTP security headers, and other reverse-proxy or web-server configuration
- Findings that depend on insecure self-hosted infrastructure or misconfiguration
If you're unsure whether something is in scope, report it privately anyway and we'll happily take a look.
## Supported Versions
Security fixes are applied to the latest release. Run the most recent version of Documenso.
+64 -6
View File
@@ -1,9 +1,67 @@
# Signing Certificate
# Creating your own signing certificate
Documenso needs a signing certificate to digitally sign documents. For full, up-to-date instructions on generating, converting, and configuring a certificate, see the official documentation:
For the digital signature of your documents you need a signing certificate in .p12 format (public and private key). You can buy one (not recommended for dev) or use the steps to create a self-signed one:
- [Signing Certificate](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate): Overview and all certificate options
- [Local Certificate](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/local): Generate a self-signed `.p12` certificate with OpenSSL
- [Google Cloud HSM](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm): Sign using Google Cloud KMS
1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key:
For deploying Documenso with Docker, see the [Docker Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker) and [Docker Compose](https://docs.documenso.com/docs/self-hosting/deployment/docker-compose) guides.
`openssl genrsa -out private.key 2048`
2. Generate a self-signed certificate using the private key. You can run the following command to generate a self-signed certificate:
`openssl req -new -x509 -key private.key -out certificate.crt -days 365`
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The `-days` parameter sets the number of days for which the certificate is valid.
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following commands to do this:
```bash
# Set certificate password securely (won't appear in command history)
read -s -p "Enter certificate password: " CERT_PASS
echo
# Create the p12 certificate using the environment variable
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt \
-password env:CERT_PASS \
-keypbe PBE-SHA1-3DES \
-certpbe PBE-SHA1-3DES \
-macalg sha1
```
4. **IMPORTANT**: A certificate password is required to prevent signing failures. Make sure to use a strong password (minimum 4 characters) when prompted. Certificates without passwords will cause "Failed to get private key bags" errors during document signing.
5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created)
## Docker
> We are still working on the publishing of docker images, in the meantime you can follow the steps below to create a production ready docker image.
Want to create a production ready docker image? Follow these steps:
- cd into `docker` directory
- Make `build.sh` executable by running `chmod +x build.sh`
- Run `./build.sh` to start building the docker image.
- Publish the image to your docker registry of choice (or) If you prefer running the image from local, run the below command
```
docker run -d --restart=unless-stopped -p 3000:3000 -v documenso:/app/data --name documenso documenso:latest
```
Command Breakdown:
- `-d` - Let's you run the container in background
- `-p` - Passes down which ports to use. First half is the host port, Second half is the app port. You can change the first half anything you want and reverse proxy to that port.
- `-v` - Volume let's you persist the data
- `--name` - Name of the container
- `documenso:latest` - Image you have built
## Deployment
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
## Railway
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
## Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso)
+38 -9
View File
@@ -1,16 +1,45 @@
# @documenso/docs
# docs
The Documenso documentation site, built with [Next.js](https://nextjs.org/) and [Fumadocs](https://fumadocs.dev/). Published at [docs.documenso.com](https://docs.documenso.com).
This is a Next.js application generated with
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
Content lives under `content/docs/` as MDX. See [WRITING_STYLE.md](../../WRITING_STYLE.md) for the documentation writing conventions.
Run development server:
```bash
# From the monorepo root
npm run dev --filter=@documenso/docs
npm run dev
# or
pnpm dev
# or
yarn dev
```
## Structure
Open http://localhost:3000 with your browser to see the result.
- `content/docs/`: Documentation pages (MDX).
- `lib/source.ts`: Content source adapter.
- `lib/layout.shared.tsx`: Shared layout options.
## Explore
In the project, you can see:
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
| Route | Description |
| ------------------------- | ------------------------------------------------------ |
| `app/(home)` | The route group for your landing page and other pages. |
| `app/docs` | The documentation layout and pages. |
| `app/api/search/route.ts` | The Route Handler for search. |
### Fumadocs MDX
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
## Learn More
To learn more about Next.js and Fumadocs, take a look at the following
resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs
@@ -15,17 +15,16 @@ Pick the one that fits your needs the best.
## Tech Stack
- [TypeScript](https://www.typescriptlang.org/) - Language
- [React Router v7](https://reactrouter.com/) - Framework
- [Hono](https://hono.dev/) - Server
- [Typescript](https://www.typescriptlang.org/) - Language
- [React Router](https://reactrouter.com/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [Tailwind CSS](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) + [Radix UI](https://www.radix-ui.com/) - Component Library
- [Tailwind](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
- [react-email](https://react.email/) - Email Templates
- [Lingui](https://lingui.dev/) - Internationalization
- [tRPC](https://trpc.io/) - API
- [@libpdf/core](https://www.npmjs.com/package/@libpdf/core) - PDF Signing and Manipulation
- [pdf.js](https://mozilla.github.io/pdf.js/) - Viewing PDFs
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
- [Stripe](https://stripe.com/) - Payments
<div className="mt-16 flex items-center justify-center gap-4">
@@ -278,9 +278,7 @@ Test your email configuration by creating an account or resetting a password. Th
### Using a Test SMTP Server
For development or testing, use a local SMTP server like [Inbucket](https://www.inbucket.org/), [Mailpit](https://github.com/axllent/mailpit), or [Mailhog](https://github.com/mailhog/MailHog). The default development setup (`docker/development/compose.yml`) already runs Inbucket, with its web UI on port 9000 and SMTP on port 2500.
To run one standalone instead:
For development or testing, use a local SMTP server like [Mailhog](https://github.com/mailhog/MailHog) or [Mailpit](https://github.com/axllent/mailpit):
```bash
# Using Docker
@@ -86,21 +86,6 @@ Callback URL: `https://<your-domain>/api/auth/callback/microsoft`
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | `false` | Skip email verification for OIDC accounts |
| `NEXT_PRIVATE_OIDC_PROMPT` | `login` | OIDC prompt parameter. Set to empty string to omit |
### Webhooks
| Variable | Default | Description |
| --------------------------------------- | ------- | ------------------------------------------------------------------------ |
| `NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` | - | Comma-separated hostnames or IPs allowed to resolve to private addresses |
Before delivering a webhook, Documenso checks whether the target resolves to a
private or loopback address and blocks it if so. This check is best-effort and
fails open. Use `NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` to allow specific
internal hosts, for example when delivering to a service on your own network:
```bash
NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS="hooks.internal.example,10.0.0.5"
```
---
## Email Configuration
@@ -235,7 +235,7 @@ spec:
```
<Callout type="info">
Pin to a specific image tag (e.g., `documenso/documenso:<version>`) in production instead of `latest`
Pin to a specific image tag (e.g., `documenso/documenso:1.5.0`) in production instead of `latest`
to ensure predictable deployments.
</Callout>
@@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
## Prerequisites
- Node.js 22 or later
- npm 11 or later
- Node.js 20 or later
- npm
- PostgreSQL 14 or later
- A Linux server (for systemd service setup)
@@ -124,16 +124,12 @@ docker compose -f docker/development/compose.yml exec database \
The quick start setup runs the following containers:
| Container | Purpose | Port |
| ----------- | ------------------------------------ | ----------------------------- |
| `documenso` | Main application | 3000 |
| `database` | PostgreSQL database | 54320 |
| `inbucket` | Local email testing server | 9000 (web UI), 2500 (SMTP) |
| `redis` | Cache and background job queue | 63790 |
| `minio` | S3-compatible storage | 9002 (API), 9001 (console) |
| `gotenberg` | Document conversion (optional) | 3005 |
The local email server is [Inbucket](https://www.inbucket.org/). Open its web UI at [http://localhost:9000](http://localhost:9000) to view emails Documenso sends during development. For your own deployment you can use any SMTP-compatible mailserver, such as Inbucket, [Mailpit](https://github.com/axllent/mailpit), or [Mailhog](https://github.com/mailhog/MailHog).
| Container | Purpose | Port |
| ----------- | -------------------------------- | ----- |
| `documenso` | Main application | 3000 |
| `database` | PostgreSQL database | 54320 |
| `maildev` | Local email testing server | 2500 |
| `minio` | S3-compatible storage (optional) | 9000 |
## Useful Commands
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
| Requirement | Version |
| ----------- | ------- |
| Node.js | 22+ |
| npm | 11+ |
| Node.js | 18+ |
| npm | 8+ |
---
@@ -169,7 +169,7 @@ Documenso runs on:
| MySQL/MariaDB | PostgreSQL-specific features required |
| SQLite | Not suitable for production workloads |
| MongoDB | Relational database required |
| Node.js < 22 | Modern JavaScript features required |
| Node.js < 18 | Modern JavaScript features required |
---
@@ -92,7 +92,7 @@ Use a specific version tag in production:
```bash
# Good — predictable, reproducible
docker pull documenso/documenso:<version>
docker pull documenso/documenso:1.8.0
# Risky — may pull breaking changes
docker pull documenso/documenso:latest
@@ -27,14 +27,6 @@ import { Callout } from 'fumadocs-ui/components/callout';
Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding.
</Callout>
<Callout type="warn">
**You are responsible for your own network security.** Documenso applies best-effort, non-exhaustive
checks to outbound requests such as webhooks, but these are not a complete SSRF mitigation and they
fail open. A self-hosted instance can reach internal addresses on your network. Restricting outbound
traffic, egress filtering, and blocking access to internal services and cloud metadata endpoints is
your responsibility through your firewall and network configuration.
</Callout>
---
## Deployment Options
@@ -165,10 +165,10 @@ See [Backups](/docs/self-hosting/maintenance/backups) for automated backup strat
### Pull the new image
```bash
docker pull documenso/documenso:<version>
docker pull documenso/documenso:1.6.0
```
Replace `<version>` with your target version.
Replace `1.6.0` with your target version.
</Step>
<Step>
@@ -189,7 +189,7 @@ docker run -d \
-p 3000:3000 \
--env-file .env \
-v /path/to/cert.p12:/opt/documenso/cert.p12:ro \
documenso/documenso:<version>
documenso/documenso:1.6.0
```
</Step>
@@ -223,14 +223,14 @@ Edit `compose.yml` or your `.env` file to specify the new version:
```yaml
services:
documenso:
image: documenso/documenso:<version>
image: documenso/documenso:1.6.0
```
Or if using environment variable substitution:
```bash
# In .env
DOCUMENSO_VERSION=<version>
DOCUMENSO_VERSION=1.6.0
```
```yaml
@@ -283,7 +283,7 @@ Edit the deployment directly:
```bash
kubectl set image deployment/documenso \
documenso=documenso/documenso:<version> \
documenso=documenso/documenso:1.6.0 \
-n documenso
```
@@ -295,7 +295,7 @@ spec:
spec:
containers:
- name: documenso
image: documenso/documenso:<version>
image: documenso/documenso:1.6.0
```
Then apply:
@@ -421,12 +421,12 @@ To run migrations manually before upgrading:
```bash
# Pull the new image
docker pull documenso/documenso:<version>
docker pull documenso/documenso:1.6.0
# Run migrations only
docker run --rm \
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@host:5432/documenso" \
documenso/documenso:<version> \
documenso/documenso:1.6.0 \
npx prisma migrate deploy
```
@@ -516,7 +516,7 @@ docker run -d \
-p 3000:3000 \
--env-file .env \
-v /path/to/cert.p12:/opt/documenso/cert.p12:ro \
documenso/documenso:<previous-version>
documenso/documenso:1.5.0
```
</Tab>
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]
+93 -7
View File
@@ -1,14 +1,100 @@
# @documenso/remix
# Welcome to React Router!
The main Documenso web application. Built with [React Router v7](https://reactrouter.com/) and served by a [Hono](https://hono.dev/) server.
A modern, production-ready template for building full-stack React applications using React Router.
This package is part of the Documenso monorepo and is not meant to be run standalone. Use the root scripts instead.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
- Local development: see the [root README](../../README.md) and the [Local Development docs](https://docs.documenso.com/docs/developers/local-development).
- Self-hosting and deployment: see the [Self-Hosting docs](https://docs.documenso.com/docs/self-hosting).
- Architecture overview: see [ARCHITECTURE.md](../../ARCHITECTURE.md).
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash
npm install
```
### Development
Start the development server with HMR:
```bash
# From the monorepo root
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
This template includes three Dockerfiles optimized for different package managers:
- `Dockerfile` - for npm
- `Dockerfile.pnpm` - for pnpm
- `Dockerfile.bun` - for bun
To build and run using Docker:
```bash
# For npm
docker build -t my-app .
# For pnpm
docker build -f Dockerfile.pnpm -t my-app .
# For bun
docker build -f Dockerfile.bun -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.
@@ -11,10 +11,10 @@ import { DownloadIcon } from 'lucide-react';
export type DocumentAuditLogDownloadButtonProps = {
className?: string;
envelopeId: string;
documentId: number;
};
export const DocumentAuditLogDownloadButton = ({ className, envelopeId }: DocumentAuditLogDownloadButtonProps) => {
export const DocumentAuditLogDownloadButton = ({ className, documentId }: DocumentAuditLogDownloadButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -22,7 +22,7 @@ export const DocumentAuditLogDownloadButton = ({ className, envelopeId }: Docume
const onDownloadAuditLogsClick = async () => {
try {
const { data, envelopeTitle } = await downloadAuditLogs({ envelopeId });
const { data, envelopeTitle } = await downloadAuditLogs({ documentId });
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
@@ -13,13 +13,13 @@ import { DownloadIcon } from 'lucide-react';
export type DocumentCertificateDownloadButtonProps = {
className?: string;
envelopeId: string;
documentId: number;
documentStatus: DocumentStatus;
};
export const DocumentCertificateDownloadButton = ({
className,
envelopeId,
documentId,
documentStatus,
}: DocumentCertificateDownloadButtonProps) => {
const { toast } = useToast();
@@ -29,7 +29,7 @@ export const DocumentCertificateDownloadButton = ({
const onDownloadCertificatesClick = async () => {
try {
const { data, envelopeTitle } = await downloadCertificate({ envelopeId });
const { data, envelopeTitle } = await downloadCertificate({ documentId });
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
@@ -237,10 +237,26 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
fieldGroup.off('transformend');
fieldGroup.off('dragend');
// Set up field selection.
fieldGroup.on('click', () => {
// Set up field selection. Shift + click toggles this field in/out of the current
// multi-selection, so fields can be added to a group by clicking them --
// complementing marquee drag-selection. A plain click (no modifier) selects just
// this field.
fieldGroup.on('click', (event) => {
removePendingField();
setSelectedFields([fieldGroup]);
const isMultiSelectModifier = event.evt.shiftKey;
if (isMultiSelectModifier) {
const currentNodes = interactiveTransformer.current?.nodes() ?? [];
const isAlreadySelected = currentNodes.includes(fieldGroup);
setSelectedFields(
isAlreadySelected ? currentNodes.filter((node) => node !== fieldGroup) : [...currentNodes, fieldGroup],
);
} else {
setSelectedFields([fieldGroup]);
}
pageLayer.current?.batchDraw();
});
@@ -445,43 +461,18 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
}
});
// Clicks should select/deselect shapes
// Clicking empty stage area clears the selection. Field clicks -- including
// Shift+click multi-select -- are handled by each field group's own click
// handler in `unsafeRenderFieldOnLayer`.
currentStage.on('click tap', (e) => {
// if we are selecting with rect, do nothing
// If we are selecting with the marquee rectangle, do nothing.
if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
return;
}
// If empty area clicked, remove all selections
// If empty area clicked, remove all selections.
if (e.target === stage.current) {
setSelectedFields([]);
return;
}
// Do nothing if field not clicked, or if field is not editable
if (!e.target.hasName('field-group') || e.target.draggable() === false) {
return;
}
// do we pressed shift or ctrl?
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
if (!metaPressed && !isSelected) {
// if no key pressed and the node is not selected
// select just one
setSelectedFields([e.target]);
} else if (metaPressed && isSelected) {
// if we pressed keys and node was selected
// we need to remove it from selection:
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
// remove node from array
nodes.splice(nodes.indexOf(e.target), 1);
setSelectedFields(nodes);
} else if (metaPressed && !isSelected) {
// add the node into selection
const nodes = transformer.nodes().concat([e.target]);
setSelectedFields(nodes);
}
});
@@ -153,11 +153,11 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DocumentCertificateDownloadButton
className="mr-2"
envelopeId={document.envelopeId}
documentId={document.id}
documentStatus={document.status}
/>
<DocumentAuditLogDownloadButton envelopeId={document.envelopeId} />
<DocumentAuditLogDownloadButton documentId={document.id} />
</div>
</div>
</div>
+4 -11
View File
@@ -1,14 +1,7 @@
# Report security vulnerabilities privately via GitHub Security Advisories (preferred).
Contact: https://github.com/documenso/documenso/security/advisories/new
# Alternatively, report critical issues privately by email.
Contact: mailto:security@documenso.com
# Security policy
Policy: https://github.com/documenso/documenso/security/policy
# General (non-security) issues
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt
Canonical: https://documenso.com/.well-known/security.txt
+40 -212
View File
@@ -1,52 +1,20 @@
import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById, getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import contentDisposition from 'content-disposition';
import { EnvelopeType } from '@prisma/client';
import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { handleEnvelopeItemFileRequest } from '../files/files.helpers';
import {
ZDownloadDocumentRequestParamsSchema,
ZDownloadEnvelopeAuditLogPdfRequestParamsSchema,
ZDownloadEnvelopeCertificatePdfRequestParamsSchema,
ZDownloadEnvelopeItemRequestParamsSchema,
ZDownloadEnvelopeItemRequestQuerySchema,
} from './download.types';
/**
* Resolve and validate an API token from the Authorization header.
*
* Supports both "Authorization: Bearer api_xxx" and "Authorization: api_xxx".
*/
const resolveApiToken = async (authorizationHeader: string | undefined) => {
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token was not provided',
});
}
const apiToken = await getApiTokenByToken({ token });
if (apiToken.user.disabled) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User is disabled',
});
}
return apiToken;
};
export const downloadRoute = new Hono<HonoEnv>()
/**
* Download an envelope item by its ID.
@@ -62,8 +30,24 @@ export const downloadRoute = new Hono<HonoEnv>()
try {
const { envelopeItemId } = c.req.valid('param');
const { version } = c.req.valid('query');
const authorizationHeader = c.req.header('authorization');
const apiToken = await resolveApiToken(c.req.header('authorization'));
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token was not provided',
});
}
const apiToken = await getApiTokenByToken({ token });
if (apiToken.user.disabled) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User is disabled',
});
}
logger.info({
auth: 'api',
@@ -141,180 +125,6 @@ export const downloadRoute = new Hono<HonoEnv>()
}
},
)
/**
* Download the audit log for a document as a PDF.
* Requires API key authentication via Authorization header.
*/
.get(
'/envelope/:envelopeId/audit-log/pdf',
sValidator('param', ZDownloadEnvelopeAuditLogPdfRequestParamsSchema),
async (c) => {
const logger = c.get('logger');
try {
const { envelopeId } = c.req.valid('param');
const apiToken = await resolveApiToken(c.req.header('authorization'));
logger.info({
auth: 'api',
source: 'apiV2',
path: c.req.path,
userId: apiToken.user.id,
apiTokenId: apiToken.id,
envelopeId,
});
const envelope = await getEnvelopeById({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: EnvelopeType.DOCUMENT,
userId: apiToken.user.id,
teamId: apiToken.teamId,
}).catch(() => null);
if (!envelope) {
return c.json({ error: 'Document not found' }, 404);
}
const auditLogPdf = await generateAuditLogPdf({
envelope,
recipients: envelope.recipients,
fields: envelope.fields,
language: envelope.documentMeta.language,
envelopeOwner: {
email: envelope.user.email,
name: envelope.user.name || '',
},
envelopeItems: envelope.envelopeItems.map((item) => item.title),
pageWidth: PDF_SIZE_A4_72PPI.width,
pageHeight: PDF_SIZE_A4_72PPI.height,
});
const result = await auditLogPdf.save();
const baseTitle = envelope.title.replace(/\.pdf$/i, '');
c.header('Content-Type', 'application/pdf');
c.header('Content-Disposition', contentDisposition(`${baseTitle}_audit-log.pdf`));
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
return c.body(result);
} catch (error) {
logger.error(error);
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Internal server error' }, 500);
}
},
)
/**
* Download the signing certificate for a completed document as a PDF.
* Requires API key authentication via Authorization header.
*/
.get(
'/envelope/:envelopeId/certificate/pdf',
sValidator('param', ZDownloadEnvelopeCertificatePdfRequestParamsSchema),
async (c) => {
const logger = c.get('logger');
try {
const { envelopeId } = c.req.valid('param');
const apiToken = await resolveApiToken(c.req.header('authorization'));
logger.info({
auth: 'api',
source: 'apiV2',
path: c.req.path,
userId: apiToken.user.id,
apiTokenId: apiToken.id,
envelopeId,
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: EnvelopeType.DOCUMENT,
userId: apiToken.user.id,
teamId: apiToken.teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
fields: {
include: {
signature: true,
},
},
documentMeta: true,
user: {
select: {
email: true,
name: true,
},
},
},
});
if (!envelope) {
return c.json({ error: 'Document not found' }, 404);
}
// A cancelled document was never sealed/completed, so a signing certificate
// must not be generated for it.
if (!isDocumentCompleted(envelope.status) || envelope.status === DocumentStatus.CANCELLED) {
throw new AppError('DOCUMENT_NOT_COMPLETE', {
message: 'Document is not complete',
});
}
const certificatePdf = await generateCertificatePdf({
envelope,
recipients: envelope.recipients,
fields: envelope.fields,
language: envelope.documentMeta.language,
envelopeOwner: {
email: envelope.user.email,
name: envelope.user.name || '',
},
pageWidth: PDF_SIZE_A4_72PPI.width,
pageHeight: PDF_SIZE_A4_72PPI.height,
});
const result = await certificatePdf.save();
const baseTitle = envelope.title.replace(/\.pdf$/i, '');
c.header('Content-Type', 'application/pdf');
c.header('Content-Disposition', contentDisposition(`${baseTitle}_certificate.pdf`));
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
return c.body(result);
} catch (error) {
logger.error(error);
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Internal server error' }, 500);
}
},
)
/**
* Download a document by its ID.
* Requires API key authentication via Authorization header.
@@ -324,8 +134,24 @@ export const downloadRoute = new Hono<HonoEnv>()
try {
const { documentId, version } = c.req.valid('param');
const authorizationHeader = c.req.header('authorization');
const apiToken = await resolveApiToken(c.req.header('authorization'));
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token was not provided',
});
}
const apiToken = await getApiTokenByToken({ token });
if (apiToken.user.disabled) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User is disabled',
});
}
logger.info({
auth: 'api',
@@ -374,9 +200,11 @@ export const downloadRoute = new Hono<HonoEnv>()
logger.error(error);
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
if (error.code === AppErrorCode.UNAUTHORIZED) {
return c.json({ error: error.message }, 401);
}
return c.json({ error: body.message, code: error.code }, status);
return c.json({ error: error.message }, 400);
}
return c.json({ error: 'Internal server error' }, 500);
@@ -30,17 +30,3 @@ export const ZDownloadDocumentRequestParamsSchema = z.object({
});
export type TDownloadDocumentRequestParams = z.infer<typeof ZDownloadDocumentRequestParamsSchema>;
export const ZDownloadEnvelopeAuditLogPdfRequestParamsSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to download the audit log for.'),
});
export type TDownloadEnvelopeAuditLogPdfRequestParams = z.infer<typeof ZDownloadEnvelopeAuditLogPdfRequestParamsSchema>;
export const ZDownloadEnvelopeCertificatePdfRequestParamsSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to download the certificate for.'),
});
export type TDownloadEnvelopeCertificatePdfRequestParams = z.infer<
typeof ZDownloadEnvelopeCertificatePdfRequestParamsSchema
>;
+1
View File
@@ -35,6 +35,7 @@
"with:env": "dotenv -e .env -e .env.local --",
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
"precommit": "npm install && git add package.json package-lock.json",
"trigger:dev": "npm run with:env -- npx trigger-cli dev --handler-path=\"/api/jobs\"",
"inngest:dev": "inngest dev -u http://localhost:3000/api/jobs",
"make:version": "npm version --workspace @documenso/remix --include-workspace-root --no-git-tag-version -m \"v%s\"",
"translate": "npm run translate:extract && npm run translate:compile",
@@ -20,7 +20,7 @@ import {
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage } from '../fixtures/konva';
import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
type TFieldFlowResult = {
externalId: string;
@@ -98,6 +98,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number
await canvas.click({ position, force: true });
};
/**
* Shift+click a field on the canvas to toggle it in/out of the current multi-selection.
*/
const shiftClickFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await root.waitForTimeout(300);
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
await canvas.click({ position, modifiers: ['Shift'], force: true });
};
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
const externalId = `e2e-fields-${nanoid()}`;
@@ -760,9 +771,106 @@ const assertChangeFieldTypePersistedInDatabase = async ({
expect(actualMetaTypes).toEqual(['date', 'date']);
};
// --- Shift+click multi-select flow ---
type TShiftClickFlowResult = {
externalId: string;
};
const SHIFT_CLICK_FIELD_POSITIONS = {
signature: { x: 150, y: 120 },
text: { x: 150, y: 260 },
name: { x: 150, y: 400 },
};
const runShiftClickMultiSelectFlow = async (surface: TEnvelopeEditorSurface): Promise<TShiftClickFlowResult> => {
const externalId = `e2e-shift-click-${nanoid()}`;
const root = surface.root;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
// Place three fields, spaced far enough apart that their action toolbars don't
// overlap a neighbouring field's click target.
await placeFieldOnPdf(root, 'Signature', SHIFT_CLICK_FIELD_POSITIONS.signature);
await placeFieldOnPdf(root, 'Text', SHIFT_CLICK_FIELD_POSITIONS.text);
await placeFieldOnPdf(root, 'Name', SHIFT_CLICK_FIELD_POSITIONS.name);
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
// A plain click selects exactly one field.
await selectFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
// Shift+click a second field ADDS it to the selection (the new behaviour).
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.text);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
// Shift+click an already-selected field REMOVES it from the selection.
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
// Shift+click it again RE-ADDS it, leaving Signature + Text selected and Name excluded.
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
// Delete the two selected fields via the floating action toolbar. Only the
// un-selected Name field should remain -- proving the multi-selection contained
// exactly the two Shift-clicked fields.
await expect(root.locator('button[title="Remove"]')).toBeVisible();
await root.locator('button[title="Remove"]').click();
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
// Navigate away and back to verify persistence.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
return { externalId };
};
const assertShiftClickMultiSelectPersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true },
});
// Signature + Text were multi-selected via Shift+click and deleted; only Name remains.
expect(envelope.fields).toHaveLength(1);
expect(envelope.fields[0].type).toBe(FieldType.NAME);
};
// --- Test describe blocks ---
test.describe('document editor', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runShiftClickMultiSelectFlow(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
@@ -815,6 +923,16 @@ test.describe('document editor', () => {
});
test.describe('template editor', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runShiftClickMultiSelectFlow(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
@@ -867,6 +985,21 @@ test.describe('template editor', () => {
});
test.describe('embedded create', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-shift-click',
});
const result = await runShiftClickMultiSelectFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
@@ -944,6 +1077,22 @@ test.describe('embedded create', () => {
});
test.describe('embedded edit', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-shift-click',
});
const result = await runShiftClickMultiSelectFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
+32
View File
@@ -16,3 +16,35 @@ export const getKonvaElementCountForPage = async (page: Page, pageNumber: number
{ pageNumber, elementSelector },
);
};
/**
* Returns how many field groups are currently attached to the page's Konva
* transformer, i.e. the size of the active canvas selection. Used to assert
* multi-select behaviour (marquee drag and Shift+click).
*/
export const getKonvaTransformerNodeCountForPage = async (page: Page, pageNumber: number) => {
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
return await page.evaluate(
({ pageNumber }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
const stage = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
if (!stage) {
return 0;
}
const transformer = stage.find('Transformer')[0];
if (!transformer) {
return 0;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (transformer as Konva.Transformer).nodes().length;
},
{ pageNumber },
);
};
@@ -71,17 +71,10 @@ const isBypassedHost = (url: string): boolean => {
};
/**
* Assert that a webhook URL does not point at a private/loopback address,
* checking both the literal host and its resolved DNS records. Throws an
* AppError with WEBHOOK_INVALID_REQUEST if it does. Hosts listed in
* NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
* Asserts that a webhook URL does not resolve to a private or loopback
* address. Throws an AppError with WEBHOOK_INVALID_REQUEST if it does.
*
* This is best-effort, non-exhaustive SSRF defence, NOT a complete mitigation.
* It does not cover DNS rebinding (the resolved address can change between this
* check and the actual request), obscure IP encodings, or every IPv6 form, and
* it fails open on lookup errors/timeouts (see the catch below). Network-level
* SSRF protection (firewall/egress rules, blocking internal services and cloud
* metadata endpoints) remains the responsibility of the deployment.
* Hosts listed in NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
*/
export const assertNotPrivateUrl = async (
url: string,
@@ -3,11 +3,10 @@ import { z } from 'zod';
const ZIpSchema = z.string().ip();
/**
* Synchronously check whether a URL's host is a known private/loopback address
* (localhost, RFC 1918, link-local, loopback, etc.), regardless of protocol.
* Check whether a URL points to a known private/loopback address.
*
* Best-effort and non-exhaustive: unrecognised or unparseable hosts return
* `false` (fail open). See `assertNotPrivateUrl` for the full SSRF caveats.
* Performs a synchronous check against known private hostnames and IP ranges.
* Works regardless of the URL protocol.
*/
export const isPrivateUrl = (url: string): boolean => {
try {
@@ -15,18 +15,18 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure
.output(ZDownloadDocumentAuditLogsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { envelopeId } = input;
const { documentId } = input;
ctx.logger.info({
input: {
envelopeId,
documentId,
},
});
const envelope = await getEnvelopeById({
id: {
type: 'envelopeId',
id: envelopeId,
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
userId: ctx.user.id,
@@ -55,8 +55,10 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure
const result = await certificatePdf.save();
const base64 = Buffer.from(result).toString('base64');
return {
data: Buffer.from(result).toString('base64'),
data: base64,
envelopeTitle: envelope.title,
};
});
@@ -1,7 +1,7 @@
import { z } from 'zod';
export const ZDownloadDocumentAuditLogsRequestSchema = z.object({
envelopeId: z.string(),
documentId: z.number(),
});
export const ZDownloadDocumentAuditLogsResponseSchema = z.object({
@@ -17,18 +17,18 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
.output(ZDownloadDocumentCertificateResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { envelopeId } = input;
const { documentId } = input;
ctx.logger.info({
input: {
envelopeId,
documentId,
},
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
userId: ctx.user.id,
@@ -81,8 +81,10 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
const result = await certificatePdf.save();
const base64 = Buffer.from(result).toString('base64');
return {
data: Buffer.from(result).toString('base64'),
data: base64,
envelopeTitle: envelope.title,
};
});
@@ -1,7 +1,7 @@
import { z } from 'zod';
export const ZDownloadDocumentCertificateRequestSchema = z.object({
envelopeId: z.string(),
documentId: z.number(),
});
export const ZDownloadDocumentCertificateResponseSchema = z.object({
@@ -1,23 +0,0 @@
import { authenticatedProcedure } from '../trpc';
import {
downloadEnvelopeAuditLogPdfMeta,
ZDownloadEnvelopeAuditLogPdfRequestSchema,
ZDownloadEnvelopeAuditLogPdfResponseSchema,
} from './download-envelope-audit-log-pdf.types';
export const downloadEnvelopeAuditLogPdfRoute = authenticatedProcedure
.meta(downloadEnvelopeAuditLogPdfMeta)
.input(ZDownloadEnvelopeAuditLogPdfRequestSchema)
.output(ZDownloadEnvelopeAuditLogPdfResponseSchema)
.query(({ input, ctx }) => {
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
// This endpoint is purely for V2 API, which is implemented in the Hono remix server.
throw new Error('NOT_IMPLEMENTED');
});
@@ -1,25 +0,0 @@
import { z } from 'zod';
import type { TrpcRouteMeta } from '../trpc';
export const downloadEnvelopeAuditLogPdfMeta: TrpcRouteMeta = {
openapi: {
method: 'GET',
path: '/envelope/{envelopeId}/audit-log/pdf',
summary: 'Download envelope audit log PDF',
description: 'Download the audit log for a document as a PDF.',
tags: ['Envelope'],
responseHeaders: z.object({
'Content-Type': z.literal('application/pdf'),
}),
},
};
export const ZDownloadEnvelopeAuditLogPdfRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to download the audit log for.'),
});
export const ZDownloadEnvelopeAuditLogPdfResponseSchema = z.instanceof(Uint8Array);
export type TDownloadEnvelopeAuditLogPdfRequest = z.infer<typeof ZDownloadEnvelopeAuditLogPdfRequestSchema>;
export type TDownloadEnvelopeAuditLogPdfResponse = z.infer<typeof ZDownloadEnvelopeAuditLogPdfResponseSchema>;
@@ -1,23 +0,0 @@
import { authenticatedProcedure } from '../trpc';
import {
downloadEnvelopeCertificatePdfMeta,
ZDownloadEnvelopeCertificatePdfRequestSchema,
ZDownloadEnvelopeCertificatePdfResponseSchema,
} from './download-envelope-certificate-pdf.types';
export const downloadEnvelopeCertificatePdfRoute = authenticatedProcedure
.meta(downloadEnvelopeCertificatePdfMeta)
.input(ZDownloadEnvelopeCertificatePdfRequestSchema)
.output(ZDownloadEnvelopeCertificatePdfResponseSchema)
.query(({ input, ctx }) => {
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
// This endpoint is purely for V2 API, which is implemented in the Hono remix server.
throw new Error('NOT_IMPLEMENTED');
});
@@ -1,25 +0,0 @@
import { z } from 'zod';
import type { TrpcRouteMeta } from '../trpc';
export const downloadEnvelopeCertificatePdfMeta: TrpcRouteMeta = {
openapi: {
method: 'GET',
path: '/envelope/{envelopeId}/certificate/pdf',
summary: 'Download envelope certificate PDF',
description: 'Download the signing certificate for a completed document as a PDF.',
tags: ['Envelope'],
responseHeaders: z.object({
'Content-Type': z.literal('application/pdf'),
}),
},
};
export const ZDownloadEnvelopeCertificatePdfRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to download the certificate for.'),
});
export const ZDownloadEnvelopeCertificatePdfResponseSchema = z.instanceof(Uint8Array);
export type TDownloadEnvelopeCertificatePdfRequest = z.infer<typeof ZDownloadEnvelopeCertificatePdfRequestSchema>;
export type TDownloadEnvelopeCertificatePdfResponse = z.infer<typeof ZDownloadEnvelopeCertificatePdfResponseSchema>;
@@ -12,8 +12,6 @@ import { createEnvelopeItemsRoute } from './create-envelope-items';
import { deleteEnvelopeRoute } from './delete-envelope';
import { deleteEnvelopeItemRoute } from './delete-envelope-item';
import { distributeEnvelopeRoute } from './distribute-envelope';
import { downloadEnvelopeAuditLogPdfRoute } from './download-envelope-audit-log-pdf';
import { downloadEnvelopeCertificatePdfRoute } from './download-envelope-certificate-pdf';
import { downloadEnvelopeItemRoute } from './download-envelope-item';
import { duplicateEnvelopeRoute } from './duplicate-envelope';
import { createEnvelopeFieldsRoute } from './envelope-fields/create-envelope-fields';
@@ -87,10 +85,6 @@ export const envelopeRouter = router({
find: findEnvelopesRoute,
auditLog: {
find: findEnvelopeAuditLogsRoute,
downloadPdf: downloadEnvelopeAuditLogPdfRoute,
},
certificate: {
downloadPdf: downloadEnvelopeCertificatePdfRoute,
},
bulk: {
move: bulkMoveEnvelopesRoute,
+1 -1
View File
@@ -10,7 +10,7 @@ services:
envVars:
# Node Version
- key: NODE_VERSION
value: 22.13.0
value: 18.17.0
- key: PORT
value: 10000
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./packages/tsconfig/base.json",
"include": ["packages/**/*", "apps/**/*"]
}