Compare commits

...
10 Commits
Author SHA1 Message Date
smp46 4bcdbfd1b1 fix(lang): dont set lang cookie from fallback value (#164)
Docker Security / Security scan (push) Canceled after 0s
2026-08-01 04:51:25 +10:00
Tom van der Laanandsmp46 d69281aeff feat: managed user shares (#63)
* Implement managed user shares features:
  - Add recipient restriction settings for shares
  - Add received shares overview page for logged in users
  - Support automatic linking of user accounts to shares based on email address
  - Stop exposing registered/unregistered status of recipient emails to prevent account enumeration
  - Enable folder uploads via drag-and-drop and selection
  - Allow administrators to bypass maximum file expiration limits
  - Swap and align recipient icons, hover tips, and colors in My Shares

* harden user recipient security validations on file downloads
  - Query recipient relations in FileSecurityGuard
  - Validate restrictToRecipients for direct/tokenless file downloads and throw a ForbiddenException
  - Credit to Leonardo for reporting this issue, that led to this patch, before the code was released.

* i18n: extract restrictToRecipients hardcoded string to translations key
  - Use share.restrictedToRecipients key in ShareSecurityGuard and FileSecurityGuard

---------

Co-authored-by: smp46 <me@smp46.me>
2026-08-01 04:51:20 +10:00
smp46andGitHub 27414e6d97 feat(uploads): text based progress indicator & time remaining (#154)
* feat(uploads): text based progress indicator

* fix(uploads): align other status icons with progress

* feat(uploads): time remaing on progress indicator hover

* fix(uploads): add callback function for more accurate progress

* fix(uploads): cap progress text at 99 to prevent ui uglyness

* feat(uploads): make upload progress customisable by admin

* fix(uploads): revert status icons to original size

* fix(translations): missing grammar on new string

* feat(uploads): add circle with text an progress option

* fix(translations): simplify new upload progress string
2026-07-24 19:51:54 +02:00
smp46andGitHub 57704e5943 feat(email): add option to set reply-to to creator's email (#159)
* feat(email): add option to set reply-to to creator's email

* style: formatted code
2026-07-24 18:04:02 +02:00
smp46andGitHub 3042f86adc chore(repo): clarify AI usage policy
.
2026-07-24 10:46:15 +02:00
smp46 48c9f571f0 feat(shares): allow admins to bypass max file expiration (#144)
* feat: admins can bypass max expiry

* style: formatted code

* revert max expiration override default timespan
2026-07-23 20:20:36 +10:00
smp46 be15cc828f feat: support folder uploads via drag'n'drop and selection (#142)
* feat(upload): add folder upload UI support in Dropzone

* feat(upload): upload folder paths in new and edited shares

* feat(upload): style directory hierarchy in upload and share file lists

* feat(upload): display relative folder paths in upload page file list

* feat(i18n): add translations for folder uploads

* make filename in file lists semi-bold

* feat(upload): replace dropdown with direct folder upload button

* refactor(upload): move getNormalizedFileName helper to a utility module

* fix(i18n): update source file to include folder uploads

* feat(upload): display Append folder if upload queue is not empty

* feat(upload): prevent uploading files with paths matching existingpaths
2026-07-23 20:20:36 +10:00
smp46 5611db86a4 build: add beta release channel 2026-07-23 20:20:36 +10:00
smp46 68ebf191fe release: 1.21.2 2026-07-23 20:04:45 +10:00
smp46 9af800f2a6 fix(deps): update backend sharp to non-vuln version 2026-07-23 20:01:18 +10:00
49 changed files with 1298 additions and 307 deletions
+2
View File
@@ -27,6 +27,8 @@ jobs:
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && !github.event.release.prerelease) }}
type=raw,value=beta,enable=${{ github.event_name == 'release' && github.event.release.prerelease }}
- name: Login to Docker Hub
uses: docker/login-action@v3
+19 -7
View File
@@ -11,26 +11,38 @@ jobs:
issues: write
pull-requests: write
steps:
- name: Environment Setup
- name: Swap PR labels
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
run: |
# 1. Find all merged PRs with the 'awaiting release' label
pr_numbers=$(gh pr list --state merged --label "awaiting release" --json number --jq '.[].number')
if [ "$IS_PRERELEASE" = "true" ]; then
echo "Processing pre-release..."
FROM_LABEL="awaiting release"
TO_LABEL="in beta"
else
echo "Processing regular release..."
FROM_LABEL="in beta"
TO_LABEL="released"
fi
# 1. Find all merged PRs with the source label
pr_numbers=$(gh pr list --state merged --label "$FROM_LABEL" --limit 1000 --json number --jq '.[].number')
if [ -z "$pr_numbers" ]; then
echo "No PRs found with 'awaiting release' label."
echo "No PRs found with '$FROM_LABEL' label."
exit 0
fi
# 2. Loop through each PR to swap the labels
for pr in $pr_numbers; do
echo "Updating labels for PR #$pr"
echo "Updating labels for PR #$pr (moving from '$FROM_LABEL' to '$TO_LABEL')"
# Remove the old label
gh pr edit $pr --remove-label "awaiting release"
gh pr edit $pr --remove-label "$FROM_LABEL"
# Add the new label
gh pr edit $pr --add-label "released"
gh pr edit $pr --add-label "$TO_LABEL"
done
+6 -5
View File
@@ -13,11 +13,12 @@ If you do end up using AI tools, we ask that you only do so **assistively** (lik
## Guidelines for Using AI Tools
1. **Understand fully:** You must be able to explain every line of code you submit
2. **Test thoroughly:** Review and test all code (manually by a human) before submission
3. **Take responsibility:** You are accountable for bugs, issues, or problems with your contribution
4. **Disclose usage:** Note which AI tools you used in your PR description
5. **Follow reasonable coding practices:** Use multiple commits to break down changes,
1. **Assistance, not Generation:** Do not just copy-paste/save AI output. AI use should assist human development, not replace.
2. **Understand fully:** You must be able to explain every line of code you submit
3. **Test thoroughly:** Review and test all code (manually by a human) before submission
4. **Take responsibility:** You are accountable for bugs, issues, or problems with your contribution
5. **Disclose usage:** Note which AI tools you used in your PR description
6. **Follow reasonable coding practices:** Use multiple commits to break down changes,
a single large commit makes review and tracking code changes difficult for maintainers.
+8
View File
@@ -1,3 +1,11 @@
## [1.21.2](https://github.com/smp46/pingvin-share-x/compare/v1.21.1...v1.21.2) (2026-07-23)
### Bug Fixes
* **deps:** resolve security vulns via npm audit ([945c5e5](https://github.com/smp46/pingvin-share-x/commit/945c5e572dbf08021a15d510c9f12f1fc6ee9630))
* **deps:** update backend sharp to non-vuln version ([9af800f](https://github.com/smp46/pingvin-share-x/commit/9af800f2a66f9ffd8fbf2926adce490723a77a05))
* **deps:** update sharp & brace-expansion to non-vuln version ([d0f7ecd](https://github.com/smp46/pingvin-share-x/commit/d0f7ecdd7cb61eda264a61a3eeab346f33e2bf5a))
## [1.21.1](https://github.com/smp46/pingvin-share-x/compare/v1.21.0...v1.21.1) (2026-07-17)
### Bug Fixes
+177 -137
View File
@@ -1,12 +1,12 @@
{
"name": "pingvin-share-backend",
"version": "1.21.1",
"version": "1.21.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pingvin-share-backend",
"version": "1.21.1",
"version": "1.21.2",
"dependencies": {
"@aws-sdk/client-s3": "^3.787.0",
"@keyv/redis": "^4.4.0",
@@ -50,7 +50,7 @@
"rimraf": "^6.0.1",
"rxjs": "^7.8.2",
"serialize-javascript": "^7.0.3",
"sharp": "^0.34.1",
"sharp": "^0.35.3",
"uuid": "^11.1.1",
"yaml": "^2.7.1"
},
@@ -1901,9 +1901,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
@@ -1913,19 +1913,19 @@
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
@@ -1935,19 +1935,38 @@
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
@@ -1961,9 +1980,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
@@ -1977,9 +1996,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
@@ -1993,9 +2012,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
@@ -2009,9 +2028,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
@@ -2025,9 +2044,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
@@ -2041,9 +2060,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
@@ -2057,9 +2076,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
@@ -2073,9 +2092,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
@@ -2089,9 +2108,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
@@ -2105,9 +2124,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
@@ -2117,19 +2136,19 @@
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
@@ -2139,19 +2158,19 @@
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
@@ -2161,19 +2180,19 @@
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
@@ -2183,19 +2202,19 @@
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
@@ -2205,19 +2224,19 @@
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
@@ -2227,19 +2246,19 @@
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
@@ -2249,19 +2268,19 @@
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
@@ -2271,38 +2290,54 @@
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
@@ -2312,16 +2347,16 @@
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
@@ -2331,16 +2366,16 @@
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
@@ -2350,7 +2385,7 @@
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
@@ -9480,9 +9515,9 @@
"dev": true
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -9568,47 +9603,52 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
"semver": "^7.8.5"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/shebang-command": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "pingvin-share-backend",
"version": "1.21.1",
"version": "1.21.2",
"scripts": {
"build": "nest build",
"dev": "cross-env NODE_ENV=development nest start --watch",
@@ -62,7 +62,7 @@
"rimraf": "^6.0.1",
"rxjs": "^7.8.2",
"serialize-javascript": "^7.0.3",
"sharp": "^0.34.1",
"sharp": "^0.35.3",
"uuid": "^11.1.1",
"yaml": "^2.7.1"
},
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "ShareUserRecipient" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"shareId" TEXT NOT NULL,
CONSTRAINT "ShareUserRecipient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ShareUserRecipient_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ShareSecurity" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"password" TEXT,
"maxViews" INTEGER,
"restrictToRecipients" BOOLEAN NOT NULL DEFAULT false,
"shareId" TEXT,
CONSTRAINT "ShareSecurity_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_ShareSecurity" ("createdAt", "id", "maxViews", "password", "shareId") SELECT "createdAt", "id", "maxViews", "password", "shareId" FROM "ShareSecurity";
DROP TABLE "ShareSecurity";
ALTER TABLE "new_ShareSecurity" RENAME TO "ShareSecurity";
CREATE UNIQUE INDEX "ShareSecurity_shareId_key" ON "ShareSecurity"("shareId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "ShareUserRecipient_userId_shareId_key" ON "ShareUserRecipient"("userId", "shareId");
+25 -9
View File
@@ -19,10 +19,11 @@ model User {
ldapDN String? @unique
shareSizeLimit String?
shares Share[]
refreshTokens RefreshToken[]
loginTokens LoginToken[]
reverseShares ReverseShare[]
shares Share[]
refreshTokens RefreshToken[]
loginTokens LoginToken[]
reverseShares ReverseShare[]
shareUserRecipients ShareUserRecipient[]
totpEnabled Boolean @default(false)
totpVerified Boolean @default(false)
@@ -98,9 +99,10 @@ model Share {
reverseShareId String?
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
security ShareSecurity?
recipients ShareRecipient[]
files File[]
security ShareSecurity?
recipients ShareRecipient[]
userRecipients ShareUserRecipient[]
files File[]
storageProvider String @default("LOCAL")
}
@@ -145,13 +147,27 @@ model ShareSecurity {
id String @id @default(uuid())
createdAt DateTime @default(now())
password String?
maxViews Int?
password String?
maxViews Int?
restrictToRecipients Boolean @default(false)
shareId String? @unique
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
}
model ShareUserRecipient {
id String @id @default(uuid())
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
shareId String
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
@@unique([userId, shareId])
}
model Config {
updatedAt DateTime @updatedAt
+15
View File
@@ -66,6 +66,11 @@ export const configVariables = {
defaultValue: "",
secret: false,
},
uploadProgressStyle: {
type: "string",
defaultValue: "circle",
secret: false,
},
},
share: {
allowRegistration: {
@@ -122,6 +127,11 @@ export const configVariables = {
defaultValue: "false",
secret: false,
},
enableUserRecipients: {
type: "boolean",
defaultValue: "false",
secret: false,
},
fileRetentionPeriod: {
type: "timespan",
defaultValue: "0 days",
@@ -198,6 +208,11 @@ export const configVariables = {
defaultValue: "false",
secret: false,
},
shareRecipientsReplyToCreator: {
type: "boolean",
defaultValue: "false",
secret: false,
},
shareDownloadNotificationSubject: {
type: "string",
defaultValue: "Your file was downloaded",
+2 -1
View File
@@ -1,6 +1,7 @@
import { Injectable } from "@nestjs/common";
import * as fs from "fs";
import * as sharp from "sharp";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sharp = require("sharp");
const IMAGES_PATH = "../frontend/public/img";
+32 -18
View File
@@ -14,7 +14,7 @@ export class EmailService {
constructor(
private config: ConfigService,
private readonly i18n: I18nService,
) { }
) {}
private readonly logger = new Logger(EmailService.name);
getTransporter() {
@@ -38,23 +38,28 @@ export class EmailService {
});
}
private async sendMail(email: string, subject: string, text: string) {
const isHtml = this.config.get("email.sendHtmlEmails");
private async sendMail(
email: string,
subject: string,
text: string,
replyTo?: string,
) {
const isHtml = this.config.get("email.sendHtmlEmails");
await this.getTransporter()
.sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email",
)}>`,
to: email,
subject: subject,
[isHtml ? "html" : "text"]: text,
})
.catch((e) => {
this.logger.error(e);
throw new InternalServerErrorException(this.i18n.t("email.sendFailed"));
});
await this.getTransporter()
.sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email",
)}>`,
to: email,
subject: subject,
[isHtml ? "html" : "text"]: text,
...(replyTo && { replyTo }),
})
.catch((e) => {
this.logger.error(e);
throw new InternalServerErrorException(this.i18n.t("email.sendFailed"));
});
}
async sendMailToShareRecipients(
@@ -76,6 +81,14 @@ export class EmailService {
const lang = this.config.get("general.defaultLanguage");
const locale = this.i18n.translate("email.locale", { lang });
let replyTo: string | undefined = undefined;
if (
this.config.get("email.shareRecipientsReplyToCreator") &&
creator?.email
) {
replyTo = `"${creator.username}" <${creator.email}>`;
}
await this.sendMail(
recipientEmail,
this.config.get("email.shareRecipientsSubject"),
@@ -85,7 +98,7 @@ export class EmailService {
.replaceAll(
"{creator}",
creator?.username ??
this.i18n.t("email.shareRecipientsCreatorFallback"),
this.i18n.t("email.shareRecipientsCreatorFallback"),
)
.replaceAll("{creatorEmail}", creator?.email ?? "")
.replaceAll("{shareUrl}", shareUrl)
@@ -99,6 +112,7 @@ export class EmailService {
? moment(expiration).locale(locale).fromNow()
: this.i18n.t("email.shareRecipientsExpiresNeverFallback"),
),
replyTo,
);
}
+18 -1
View File
@@ -48,7 +48,11 @@ export class FileSecurityGuard extends ShareSecurityGuard {
const share = await this._prisma.share.findUnique({
where: { id: shareId },
include: { security: true },
include: {
security: true,
userRecipients: { select: { userId: true } },
recipients: { select: { email: true } },
},
});
// If there is no share token the user requests a file directly
@@ -70,6 +74,19 @@ export class FileSecurityGuard extends ShareSecurityGuard {
throw new NotFoundException(this._i18n.t("file.notFound"));
}
if (share.security?.restrictToRecipients) {
const user = await this.authenticateUser(context);
const isCreator = user && share.creatorId === user.id;
const isRecipient = await this.isRecipient(share, user);
if (!isCreator && !isRecipient) {
throw new ForbiddenException(
this._i18n.t("share.restrictedToRecipients"),
"share_restricted_to_recipients",
);
}
}
if (share.security?.password)
throw new ForbiddenException(this._i18n.t("file.passwordProtected"));
+2 -1
View File
@@ -12,5 +12,6 @@
"wrongPassword": "Wrong password",
"tokenRequired": "Share token required",
"privateShare": "Only reverse share creator can access this share",
"maxViewsExceeded": "Maximum views exceeded"
"maxViewsExceeded": "Maximum views exceeded",
"restrictedToRecipients": "This share is restricted to specific recipients. Please log in to access it."
}
@@ -27,19 +27,20 @@ export class ReverseShareService {
)
.toDate();
const creator = await this.prisma.user.findUnique({
where: { id: creatorId },
});
const parsedExpiration = parseRelativeDateToAbsolute(data.shareExpiration);
const maxExpiration = this.config.get("share.maxExpiration");
if (
!creator?.isAdmin &&
maxExpiration.value !== 0 &&
parsedExpiration >
moment().add(maxExpiration.value, maxExpiration.unit).toDate()
) {
throw new BadRequestException(this.i18n.t("share.maxExpirationExceeded"));
}
const creator = await this.prisma.user.findUnique({
where: { id: creatorId },
});
const userMaxShareSize = creator?.shareSizeLimit
? parseInt(creator.shareSizeLimit)
: parseInt(this.config.get("share.maxSize"));
@@ -6,4 +6,7 @@ export class MyShareSecurityDTO {
@Expose()
maxViews: number;
@Expose()
restrictToRecipients: boolean;
}
+11 -1
View File
@@ -1,4 +1,10 @@
import { IsNumber, IsOptional, IsString, Length } from "class-validator";
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
Length,
} from "class-validator";
export class ShareSecurityDTO {
@IsString()
@@ -9,4 +15,8 @@ export class ShareSecurityDTO {
@IsNumber()
@IsOptional()
maxViews: number;
@IsBoolean()
@IsOptional()
restrictToRecipients: boolean;
}
+63 -4
View File
@@ -24,6 +24,46 @@ export class ShareSecurityGuard extends JwtGuard {
super(configService);
}
protected async authenticateUser(
context: ExecutionContext,
): Promise<User | undefined> {
await super.canActivate(context);
const request: Request = context.switchToHttp().getRequest();
return request.user as User;
}
protected async isRecipient(
share: {
id: string;
userRecipients: { userId: string }[];
recipients: { email: string }[];
},
user?: User,
): Promise<boolean> {
if (!user) return false;
if (!this.configService.get("share.enableUserRecipients")) return false;
// Already linked as a recipient of this share.
const isLinked = share.userRecipients.some((r) => r.userId === user.id);
if (isLinked) return true;
// Otherwise, if the user's (account-verified) email matches one of the
// share's recipients, grant access and link them
const userEmail = user.email?.toLowerCase();
const isEmailRecipient =
!!userEmail &&
share.recipients.some((r) => r.email.toLowerCase() === userEmail);
if (isEmailRecipient) {
await this.prisma.shareUserRecipient.upsert({
where: { userId_shareId: { userId: user.id, shareId: share.id } },
create: { userId: user.id, shareId: share.id },
update: {},
});
return true;
}
return false;
}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
@@ -38,14 +78,17 @@ export class ShareSecurityGuard extends JwtGuard {
const share = await this.prisma.share.findUnique({
where: { id: shareId },
include: { security: true, reverseShare: true },
include: {
security: true,
reverseShare: true,
userRecipients: { select: { userId: true } },
recipients: { select: { email: true } },
},
});
if (!share) throw new NotFoundException(this.i18n.t("share.notFound"));
// Run the JWTGuard to set the user
await super.canActivate(context);
const user = request.user as User;
const user = await this.authenticateUser(context);
// If admin access is enabled and user is admin, allow access
if (
@@ -62,6 +105,22 @@ export class ShareSecurityGuard extends JwtGuard {
throw new NotFoundException(this.i18n.t("share.notFound"));
}
// If user sharing is enabled, check if the authenticated user is a recipient
if (await this.isRecipient(share, user)) {
return true;
}
// If share is restricted to named recipients, block everyone else (excluding the creator)
if (
share.security?.restrictToRecipients &&
(!user || share.creatorId !== user.id)
) {
throw new ForbiddenException(
this.i18n.t("share.restrictedToRecipients"),
"share_restricted_to_recipients",
);
}
if (share.security?.password && !shareToken)
throw new ForbiddenException(
this.i18n.t("file.passwordProtected"),
+11
View File
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
@@ -19,6 +20,7 @@ import * as moment from "moment";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { ConfigService } from "src/config/config.service";
import { AdminShareDTO } from "./dto/adminShare.dto";
import { CreateShareDTO } from "./dto/createShare.dto";
import { MyShareDTO } from "./dto/myShare.dto";
@@ -40,6 +42,7 @@ export class ShareController {
constructor(
private shareService: ShareService,
private jwtService: JwtService,
private config: ConfigService,
) {}
@Get("all")
@@ -56,6 +59,14 @@ export class ShareController {
);
}
@Get("received")
@UseGuards(JwtGuard)
async getReceivedShares(@GetUser() user: User) {
if (!this.config.get("share.enableUserRecipients"))
throw new ForbiddenException("User recipients are not enabled");
return this.shareService.getReceivedShares(user.id);
}
@Get(":id")
@UseGuards(IdValidation, ShareSecurityGuard)
async get(@Param("id") id: string) {
+66 -3
View File
@@ -53,10 +53,32 @@ export class ShareService {
if (!share.security || Object.keys(share.security).length == 0)
share.security = undefined;
if (share.security?.restrictToRecipients && share.security?.password) {
throw new BadRequestException(
"Cannot set a password on a share restricted to recipients.",
);
}
if (share.security?.password) {
share.security.password = await argon.hash(share.security.password);
}
if (
this.configService.get("share.enableUserRecipients") &&
share.security?.restrictToRecipients
) {
if (!share.recipients?.length) {
throw new BadRequestException(
"A share restricted to recipients must have at least one recipient.",
);
}
// Note: we intentionally do NOT check whether the recipient emails belong
// to registered accounts. Doing so would leak which emails have an account
// (account enumeration). Recipients who aren't registered yet simply sign
// up to gain access (see ShareSecurityGuard); if signups are disabled they
// can't access it.
}
let expirationDate: Date;
// If share is created by a reverse share token override the expiration date
@@ -66,7 +88,9 @@ export class ShareService {
expirationDate = reverseShare.shareExpiration;
} else {
expirationDate = this.parseExpiration(share.expiration);
this.validateExpiration(expirationDate);
if (!user?.isAdmin) {
this.validateExpiration(expirationDate);
}
}
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
@@ -163,12 +187,32 @@ export class ShareService {
recipient.email,
recipient.id,
share.id,
share.creator,
share.creator || share.reverseShare?.creator,
share.description,
share.expiration,
);
}
// Auto-link email recipients who are registered users so the share appears in their dashboard
if (this.configService.get("share.enableUserRecipients")) {
const emails = share.recipients.map((r) => r.email);
if (emails.length > 0) {
const matchedUsers = await this.prisma.user.findMany({
where: { email: { in: emails } },
select: { id: true },
});
for (const matchedUser of matchedUsers) {
await this.prisma.shareUserRecipient.upsert({
where: {
userId_shareId: { userId: matchedUser.id, shareId: share.id },
},
create: { userId: matchedUser.id, shareId: share.id },
update: {},
});
}
}
}
const notifyReverseShareCreator = share.reverseShare
? this.config.get("smtp.enabled") &&
share.reverseShare.sendEmailNotification
@@ -328,7 +372,9 @@ export class ShareService {
let expirationDate: Date | undefined;
if (body.expiration !== undefined) {
expirationDate = this.parseExpiration(body.expiration);
this.validateExpiration(expirationDate);
if (!user?.isAdmin) {
this.validateExpiration(expirationDate);
}
}
const data: Prisma.ShareUpdateInput = {
@@ -395,6 +441,22 @@ export class ShareService {
return (await this.prisma.share.findUnique({ where: { id } })).uploadLocked;
}
async getReceivedShares(userId: string) {
return this.prisma.shareUserRecipient.findMany({
where: { userId },
include: {
share: {
include: {
creator: true,
files: true,
security: true,
},
},
},
orderBy: { createdAt: "desc" },
});
}
private transformShare(share: any) {
return {
...share,
@@ -404,6 +466,7 @@ export class ShareService {
security: {
maxViews: share.security?.maxViews,
passwordProtected: !!share.security?.password,
restrictToRecipients: !!share.security?.restrictToRecipients,
},
};
}
+2
View File
@@ -121,6 +121,8 @@ email:
Pingvin Share 🐧
#Whether to send an email to the share creator when an email recipient downloads a file. This requires SMTP and email recipient sharing.
enableShareDownloadNotifications: "false"
#Whether to set the Reply-To header to the email address of the user who created the share.
shareRecipientsReplyToCreator: "false"
#Subject of the email which gets sent to the share creator when a recipient downloads a file.
shareDownloadNotificationSubject: Your file was downloaded
#Message which gets sent to the share creator when a recipient downloads a file. Available variables:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pingvin-share-frontend",
"version": "1.21.1",
"version": "1.21.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pingvin-share-frontend",
"version": "1.21.1",
"version": "1.21.2",
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/server": "^11.11.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pingvin-share-frontend",
"version": "1.21.1",
"version": "1.21.2",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -23,6 +23,7 @@ import { stringToTimespan, timespanToString } from "../../../utils/date.util";
import FileSizeInput from "../../core/FileSizeInput";
import TimespanInput from "../../core/TimespanInput";
import { LOCALES } from "../../../i18n/locales";
import useTranslate from "../../../hooks/useTranslate.hook";
const AdminConfigInput = ({
configVariable,
@@ -37,6 +38,7 @@ const AdminConfigInput = ({
updatedConfigVariables?: UpdateConfig[];
optionalConfigVariables?: AdminConfig[];
}) => {
const t = useTranslate();
const isCustomCssConfig = configVariable.key === "appearance.customCss";
const isThemePrimaryColorConfig =
configVariable.key === "appearance.themePrimaryColor";
@@ -47,6 +49,8 @@ const AdminConfigInput = ({
configVariable.key === "appearance.themeColorScheme";
const isDefaultLanguageConfig =
configVariable.key === "general.defaultLanguage";
const isUploadProgressStyleConfig =
configVariable.key === "appearance.uploadProgressStyle";
const isEmailShareConfig =
configVariable.key === "email.enableShareEmailRecipients";
const isEmailVerificationConfig =
@@ -246,6 +250,37 @@ const AdminConfigInput = ({
value={form.values.stringValue}
onChange={(value) => onValueChange(configVariable, value)}
/>
) : isUploadProgressStyleConfig ? (
<Select
style={{
width: "100%",
}}
disabled={!configVariable.allowEdit}
data={[
{
value: "circle",
label: t(
"admin.config.appearance.upload-progress-style.circle",
),
},
{
value: "circle-percentage",
label: t(
"admin.config.appearance.upload-progress-style.circle-percentage",
),
},
{
value: "percentage-time",
label: t(
"admin.config.appearance.upload-progress-style.percentage-time",
),
},
]}
value={form.values.stringValue}
placeholder={configVariable.defaultValue}
onChange={(value) => onValueChange(configVariable, value ?? "")}
allowDeselect={false}
/>
) : (
<TextInput
style={{
@@ -124,7 +124,7 @@ const ManageShareTable = ({
parseInt(config.get("share.maxSize")),
config.get("general.appUrl"),
config.get("general.appUrl", true),
config.get("share.maxExpiration"),
{ value: 0, unit: "days" },
updateShare,
);
}}
@@ -217,6 +217,14 @@ const Header = () => {
link: "/account/reverseShares",
label: t("navbar.links.reverse"),
},
...(config.get("share.enableUserRecipients")
? [
{
link: "/account/received",
label: t("navbar.links.received"),
},
]
: []),
];
const mobileProfileLinks: NavLink[] = [
@@ -1,13 +1,15 @@
import { ActionIcon, Menu } from "@mantine/core";
import Link from "next/link";
import { TbArrowLoopLeft, TbLink } from "react-icons/tb";
import { TbArrowLoopLeft, TbLink, TbMailbox } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
import { HoverTip } from "../../components/core/HoverTip";
import useConfig from "../../hooks/config.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import { useState } from "react";
const NavbarShareMneu = () => {
const t = useTranslate();
const config = useConfig();
const [menuOpened, setMenuOpened] = useState(false);
return (
@@ -32,6 +34,15 @@ const NavbarShareMneu = () => {
>
<FormattedMessage id="navbar.links.reverse" />
</Menu.Item>
{config.get("share.enableUserRecipients") && (
<Menu.Item
component={Link}
href="/account/received"
icon={<TbMailbox />}
>
<FormattedMessage id="navbar.links.received" />
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
);
+14 -1
View File
@@ -24,6 +24,19 @@ import showFilePreviewModal from "./modals/showFilePreviewModal";
import { HoverTip } from "../core/HoverTip";
import api from "../../services/api.service";
const renderFileName = (name: string) => {
const parts = name.split("/");
if (parts.length === 1) return name;
const fileName = parts.pop();
const folderPath = parts.join("/");
return (
<span>
<span style={{ opacity: 0.5 }}>{folderPath}/</span>
<span style={{ fontWeight: 600 }}>{fileName}</span>
</span>
);
};
const FileList = ({
files,
setShare,
@@ -118,7 +131,7 @@ const FileList = ({
? skeletonRows
: files!.map((file) => (
<tr key={file.name}>
<td>{file.name}</td>
<td>{renderFileName(file.name)}</td>
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
<td>
<Group position="right" noWrap>
@@ -92,7 +92,9 @@ const Body = ({
sendEmailNotification: false,
expiration_num: defaultTimespan.value,
expiration_unit: `-${defaultTimespan.unit}` as string,
simplified: !reverseShareSimpleOnly ? false : !!(getCookie("reverse-share.simplified") ?? false),
simplified: !reverseShareSimpleOnly
? false
: !!(getCookie("reverse-share.simplified") ?? false),
publicAccess: !!(getCookie("reverse-share.public-access") ?? true),
},
validate: yupResolver(
@@ -273,7 +275,7 @@ const Body = ({
})}
/>
)}
{!reverseShareSimpleOnly &&
{!reverseShareSimpleOnly && (
<Switch
mt="xs"
labelPosition="left"
@@ -285,7 +287,7 @@ const Body = ({
type: "checkbox",
})}
/>
}
)}
<Switch
mt="xs"
labelPosition="left"
@@ -301,8 +303,8 @@ const Body = ({
<FormattedMessage id="common.button.create" />
</Button>
</Stack>
</form >
</Group >
</form>
</Group>
);
};
@@ -88,9 +88,8 @@ const Body = ({
? parseInt(currentShare.creator.shareSizeLimit)
: maxShareSize;
const shareSizeRatio = resolvedMaxShareSize > 0
? currentShare.size / resolvedMaxShareSize
: 0;
const shareSizeRatio =
resolvedMaxShareSize > 0 ? currentShare.size / resolvedMaxShareSize : 0;
const formattedShareSize = byteToHumanSizeString(currentShare.size);
const formattedMaxShareSize = byteToHumanSizeString(resolvedMaxShareSize);
@@ -139,6 +138,15 @@ const Body = ({
{currentShare.description || "-"}
</Text>
{currentShare.recipients && currentShare.recipients.length > 0 && (
<Text size="sm">
<b>
<FormattedMessage id="upload.modal.accordion.email.title" />:{" "}
</b>
{currentShare.recipients.join(", ")}
</Text>
)}
<Text size="sm">
<b>
<FormattedMessage id="account.shares.table.createdAt" />:{" "}
@@ -174,14 +182,9 @@ const Body = ({
)}
<Progress
value={shareSizeProgress}
label={
shareSizeRatio >= 0.1
? formattedShareSize
: ""
}
label={shareSizeRatio >= 0.1 ? formattedShareSize : ""}
style={{
width:
shareSizeRatio < 0.1 ? "70%" : "80%",
width: shareSizeRatio < 0.1 ? "70%" : "80%",
}}
size="xl"
radius="xl"
+130 -14
View File
@@ -1,7 +1,7 @@
import { Button, Center, createStyles, Group, Text } from "@mantine/core";
import { Button, Center, createStyles, Group, Text, Menu } from "@mantine/core";
import { Dropzone as MantineDropzone } from "@mantine/dropzone";
import { ForwardedRef, useRef } from "react";
import { TbCloudUpload, TbUpload } from "react-icons/tb";
import React, { ForwardedRef, useRef } from "react";
import { TbCloudUpload, TbUpload, TbFolder } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
import useTranslate from "../../hooks/useTranslate.hook";
import { FileUpload } from "../../types/File.type";
@@ -32,6 +32,74 @@ const useStyles = createStyles((theme) => ({
},
}));
const traverseDirectory = async (entry: any, path = ""): Promise<File[]> => {
if (entry.isFile) {
return new Promise((resolve) => {
entry.file((file: File) => {
const relativePath = path ? `${path}/${file.name}` : file.name;
Object.defineProperty(file, "webkitRelativePath", {
value: relativePath,
writable: true,
configurable: true,
});
resolve([file]);
});
});
} else if (entry.isDirectory) {
const dirReader = entry.createReader();
const readEntries = (): Promise<any[]> => {
return new Promise((resolve) => {
dirReader.readEntries(
(entries: any[]) => resolve(entries),
() => resolve([]),
);
});
};
let entries: any[] = [];
let readBatch = await readEntries();
while (readBatch.length > 0) {
entries = entries.concat(readBatch);
readBatch = await readEntries();
}
const promises = entries.map((e) =>
traverseDirectory(e, path ? `${path}/${entry.name}` : entry.name)
);
const results = await Promise.all(promises);
return results.flat();
}
return [];
};
const getFilesFromEvent = async (event: any): Promise<any[]> => {
const items = event.dataTransfer ? event.dataTransfer.items : event.target.files;
if (!items) return [];
const filePromises: Promise<File[]>[] = [];
if (event.dataTransfer) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file") {
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (entry) {
filePromises.push(traverseDirectory(entry));
} else {
const file = item.getAsFile();
if (file) {
filePromises.push(Promise.resolve([file]));
}
}
}
}
const fileArrays = await Promise.all(filePromises);
return fileArrays.flat();
} else {
return Array.from(items) as File[];
}
};
const Dropzone = ({
title,
isUploading,
@@ -46,17 +114,60 @@ const Dropzone = ({
onFilesChanged: (files: FileUpload[]) => void;
}) => {
const t = useTranslate();
const { classes } = useStyles();
const openRef = useRef<() => void>();
const folderInputRef = useRef<HTMLInputElement>(null);
const isFolderUploadSupported =
typeof window !== "undefined" &&
typeof HTMLInputElement !== "undefined" &&
"webkitdirectory" in HTMLInputElement.prototype;
const handleFolderSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const filesList = event.target.files;
if (!filesList) return;
const filesArray = Array.from(filesList) as FileUpload[];
const files = filesArray.map((newFile) => {
newFile.uploadingProgress = 0;
return newFile;
});
const fileSizeSum = files.reduce((n, { size }) => n + size, 0);
if (fileSizeSum + currentFilesSize > maxShareSize) {
toast.error(
t("upload.dropzone.notify.file-too-big", {
maxSize: byteToHumanSizeString(maxShareSize),
}),
);
} else {
onFilesChanged(files);
}
event.target.value = "";
};
return (
<div className={classes.wrapper}>
<input
type="file"
ref={folderInputRef}
style={{ display: "none" }}
{...({
webkitdirectory: "",
directory: "",
} as any)}
multiple
onChange={handleFolderSelect}
/>
<MantineDropzone
onReject={(e) => {
toast.error(e[0].errors[0].message);
}}
disabled={isUploading}
openRef={openRef as ForwardedRef<() => void>}
getFilesFromEvent={getFilesFromEvent}
onDrop={(files: FileUpload[]) => {
const fileSizeSum = files.reduce((n, { size }) => n + size, 0);
@@ -93,16 +204,21 @@ const Dropzone = ({
</div>
</MantineDropzone>
<Center>
<Button
className={classes.control}
variant="light"
size="sm"
radius="xl"
disabled={isUploading}
onClick={() => openRef.current && openRef.current()}
>
{<TbUpload />}
</Button>
{isFolderUploadSupported && (
<Button
className={classes.control}
variant="light"
size="sm"
radius="xl"
disabled={isUploading}
onClick={() => folderInputRef.current?.click()}
>
<TbFolder style={{ marginRight: 6 }} />
<FormattedMessage
id={currentFilesSize > 0 ? "upload.button.folder.append" : "upload.button.folder"}
/>
</Button>
)}
</Center>
</div>
);
@@ -13,6 +13,7 @@ import useUser from "../../hooks/user.hook";
import shareService from "../../services/share.service";
import { FileListItem, FileMetaData, FileUpload } from "../../types/File.type";
import toast from "../../utils/toast.util";
import { getNormalizedFileName, filterDuplicateFiles } from "../../utils/file.util";
const promiseLimit = pLimit(3);
let errorToastShown = false;
@@ -109,7 +110,7 @@ const EditableUpload = ({
blob,
{
id: fileId,
name: file.name,
name: getNormalizedFileName(file),
},
chunkIndex,
chunks,
@@ -194,7 +195,14 @@ const EditableUpload = ({
};
const appendFiles = (appendingFiles: FileUpload[]) => {
setUploadingFiles([...appendingFiles, ...uploadingFiles]);
const combinedExisting = [...existingFiles, ...uploadingFiles];
const filtered = filterDuplicateFiles(
appendingFiles,
combinedExisting,
(normalizedName) => toast.error(t("upload.notify.duplicate-skipped", { name: normalizedName }))
);
if (filtered.length === 0) return;
setUploadingFiles([...filtered, ...uploadingFiles]);
};
useEffect(() => {
+24 -2
View File
@@ -11,6 +11,27 @@ import { HoverTip } from "../core/HoverTip";
import showTextEditorModal from "./modals/showTextEditorModal";
import shareService from "../../services/share.service";
const renderFileName = (name: string) => {
const parts = name.split("/");
if (parts.length === 1) return name;
const fileName = parts.pop();
const folderPath = parts.join("/");
return (
<span>
<span style={{ opacity: 0.5 }}>{folderPath}/</span>
<span style={{ fontWeight: 600 }}>{fileName}</span>
</span>
);
};
const getFileNameOrPath = (file: FileListItem) => {
const pathName =
"webkitRelativePath" in file && file.webkitRelativePath
? file.webkitRelativePath
: file.name;
return pathName.replace(/\\/g, "/").replace(/^\//, "");
};
const FileListRow = ({
file,
onRemove,
@@ -31,7 +52,8 @@ const FileListRow = ({
const restorable = onRestore && !uploadable && !!file.deleted;
const deleted = !uploadable && !!file.deleted;
const isTextFile = shareService.isShareTextFile(file.name);
const fileNameOrPath = getFileNameOrPath(file);
const isTextFile = shareService.isShareTextFile(fileNameOrPath);
const editable = isTextFile && uploadable && file.uploadingProgress === 0;
const t = useTranslate();
@@ -43,7 +65,7 @@ const FileListRow = ({
textDecoration: deleted ? "line-through" : "none",
}}
>
<td>{file.name}</td>
<td>{renderFileName(fileNameOrPath)}</td>
<td>{byteToHumanSizeString(+file.size)}</td>
<td>
<Group position="right" spacing="xs" noWrap>
@@ -1,17 +1,145 @@
import { Loader, RingProgress } from "@mantine/core";
import { useEffect, useRef, useState } from "react";
import { Loader, RingProgress, Text } from "@mantine/core";
import { TbCircleCheck } from "react-icons/tb";
import { HoverTip } from "../core/HoverTip";
import { useIntl } from "react-intl";
import useConfig from "../../hooks/config.hook";
const UploadProgressIndicator = ({ progress }: { progress: number }) => {
if (progress > 0 && progress < 100) {
return (
<RingProgress
sections={[{ value: progress, color: "victoria" }]}
thickness={3}
size={25}
/>
const intl = useIntl();
const config = useConfig();
const progressStyle =
config.get("appearance.uploadProgressStyle") ?? "circle";
const startTimeRef = useRef<number | null>(null);
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
useEffect(() => {
if (progress <= 0 || progress >= 100) {
startTimeRef.current = null;
setRemainingSeconds(null);
return;
}
if (!startTimeRef.current) {
startTimeRef.current = Date.now();
return;
}
const elapsedMs = Date.now() - startTimeRef.current;
if (elapsedMs > 500) {
const rate = progress / 100;
const totalMs = elapsedMs / rate;
const remainingMs = totalMs - elapsedMs;
setRemainingSeconds(remainingMs / 1000);
}
}, [progress]);
const formatRemainingTime = (seconds: number | null): string => {
if (seconds === null || !isFinite(seconds) || seconds < 0) {
return intl.formatMessage({
id: "upload.filelist.estimating",
});
}
let timeStr = "";
if (seconds < 60) {
timeStr = `${Math.round(seconds)}s`;
} else {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
if (minutes < 60) {
timeStr = `${minutes}m ${remainingSeconds}s`;
} else {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
timeStr = `${hours}h ${remainingMinutes}m`;
}
}
return intl.formatMessage(
{
id: "upload.filelist.remaining",
},
{ time: timeStr },
);
};
if (progress > 0 && progress < 100) {
const tooltipLabel = formatRemainingTime(remainingSeconds);
if (progressStyle === "circle") {
return (
<HoverTip label={tooltipLabel}>
<RingProgress
sections={[{ value: progress, color: "victoria" }]}
thickness={3}
size={25}
/>
</HoverTip>
);
} else if (progressStyle === "circle-percentage") {
return (
<HoverTip label={tooltipLabel}>
<div
style={{
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
width: 40,
height: 40,
}}
>
<RingProgress
sections={[{ value: progress, color: "victoria" }]}
thickness={3}
size={40}
label={
<Text size="xs" color="victoria" weight={500} align="center">
{Math.min(Math.round(progress), 99)}%
</Text>
}
/>
</div>
</HoverTip>
);
} else {
return (
<Text size="sm" color="dimmed" style={{ whiteSpace: "nowrap" }}>
{Math.min(Math.round(progress), 99)}% {tooltipLabel}
</Text>
);
}
} else if (progress >= 100) {
if (progressStyle === "circle-percentage") {
return (
<div
style={{
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
width: 40,
height: 40,
}}
>
<TbCircleCheck color="green" size={35} />
</div>
);
}
return <TbCircleCheck color="green" size={22} />;
} else {
if (progressStyle === "circle-percentage") {
return (
<div
style={{
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
width: 40,
height: 40,
}}
>
<Loader color="red" size={30} />
</div>
);
}
return <Loader color="red" size={19} />;
}
};
@@ -45,6 +45,7 @@ const showCreateUploadModal = (
defaultAppUrl: string;
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
enableUserRecipients: boolean;
maxExpiration: Timespan;
defaultExpiration: Timespan;
shareIdLength: number;
@@ -121,6 +122,7 @@ const CreateUploadModalBody = ({
defaultAppUrl: string;
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
enableUserRecipients: boolean;
maxExpiration: Timespan;
defaultExpiration: Timespan;
shareIdLength: number;
@@ -174,10 +176,19 @@ const CreateUploadModalBody = ({
expiration_num: defaultTimespan.value,
expiration_unit: `-${defaultTimespan.unit}` as string,
never_expires: false,
restrictToRecipients: false,
},
validate: yupResolver(validationSchema),
});
const handleRestrictToggle = (checked: boolean) => {
form.setFieldValue("restrictToRecipients", checked);
if (checked) {
// A share can't be both password-protected and restricted to recipients.
form.setFieldValue("password", undefined);
}
};
const onSubmit = form.onSubmit(async (values) => {
if (!(await shareService.isShareIdAvailable(values.link))) {
form.setFieldError("link", t("upload.modal.link.error.taken"));
@@ -223,8 +234,11 @@ const CreateUploadModalBody = ({
recipients: values.recipients,
description: values.description,
security: {
password: values.password || undefined,
password: values.restrictToRecipients
? undefined
: values.password || undefined,
maxViews: values.maxViews || undefined,
restrictToRecipients: values.restrictToRecipients || undefined,
},
},
files,
@@ -417,9 +431,18 @@ const CreateUploadModalBody = ({
return undefined;
}
form.setFieldError("recipients", null);
const newRecipients = form.values.recipients.includes(
query,
)
? form.values.recipients
: [...form.values.recipients, query];
form.setFieldValue("recipients", newRecipients);
return query;
}}
{...form.getInputProps("recipients")}
onChange={(value: string[]) => {
form.setFieldValue("recipients", value);
}}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === "," || e.key === ";") {
e.preventDefault();
@@ -428,10 +451,11 @@ const CreateUploadModalBody = ({
inputValue.match(/^\S+@\S+\.\S+$/) &&
!form.values.recipients.includes(inputValue)
) {
form.setFieldValue("recipients", [
const newRecipients = [
...form.values.recipients,
inputValue,
]);
];
form.setFieldValue("recipients", newRecipients);
}
setEmailSearch("");
} else if (e.key === " ") {
@@ -440,6 +464,18 @@ const CreateUploadModalBody = ({
}
}}
/>
{options.enableUserRecipients && (
<Checkbox
mt="sm"
label={t(
"upload.modal.accordion.email.restrict-to-recipients",
)}
checked={form.values.restrictToRecipients}
onChange={(e) =>
handleRestrictToggle(e.currentTarget.checked)
}
/>
)}
</Accordion.Panel>
</Accordion.Item>
)}
@@ -450,15 +486,19 @@ const CreateUploadModalBody = ({
</Accordion.Control>
<Accordion.Panel>
<Stack align="stretch">
<PasswordInput
variant="filled"
placeholder={t(
"upload.modal.accordion.security.password.placeholder",
)}
label={t("upload.modal.accordion.security.password.label")}
autoComplete="new-password"
{...form.getInputProps("password")}
/>
{!form.values.restrictToRecipients && (
<PasswordInput
variant="filled"
placeholder={t(
"upload.modal.accordion.security.password.placeholder",
)}
label={t(
"upload.modal.accordion.security.password.label",
)}
autoComplete="new-password"
{...form.getInputProps("password")}
/>
)}
<NumberInput
min={1}
type="number"
+39 -1
View File
@@ -7,6 +7,7 @@ export default {
"navbar.links.shares": "My shares",
"navbar.links.reverse": "Reverse shares",
"navbar.links.received": "Received shares",
"navbar.avatar.account": "My account",
"navbar.avatar.admin": "Administration",
@@ -174,6 +175,9 @@ export default {
"account.shares.table.createdAt": "Created on",
"account.shares.table.size": "Size",
"account.shares.table.password-protected": "Password protected",
"account.shares.table.recipients": "Recipients",
"account.shares.table.restricted-to-recipients": "Restricted to recipients only",
"account.shares.table.shared-with-recipients": "Shared with recipients",
"account.shares.table.visitor-count": "{count} of {max}",
"account.shares.table.expiry-never": "Never",
@@ -189,6 +193,15 @@ export default {
// END /account/shares
// /account/received
"account.received-shares.title": "Received shares",
"account.received-shares.title.empty": "No shares received yet",
"account.received-shares.description.empty":
"Shares sent to your email address will appear here.",
"account.received-shares.table.from": "From",
"account.received-shares.button.open": "Open",
// END /account/received
// /account/reverseShares
"account.reverseShares.title": "Reverse shares",
"account.reverseShares.description":
@@ -321,6 +334,7 @@ export default {
"upload.notify.generic-error":
"An error occurred while finishing your share.",
"upload.notify.count-failed": "{count} files failed to upload. Trying again.",
"upload.notify.duplicate-skipped": "Skipped duplicate file: {name}",
"upload.reverse-share.error.invalid.title": "Invalid reverse share link",
"upload.reverse-share.error.invalid.description":
"This link has no remaining uses or is invalid.",
@@ -328,13 +342,18 @@ export default {
// Dropzone.tsx
"upload.dropzone.title": "Upload files",
"upload.dropzone.description":
"Drag'n'drop files here to start your share or 'Ctrl+V' to upload text content from the clipboard. We only accept files up to {maxSize} in total.",
"Drag'n'drop files or folders here to start your share or 'Ctrl+V' to upload text content from the clipboard. We only accept files up to {maxSize} in total.",
"upload.dropzone.notify.file-too-big":
"Your files exceed the maximum share size of {maxSize}.",
"upload.button.folder": "Upload folder",
"upload.button.folder.append": "Append folder",
"upload.button.add": "Add to upload",
// FileList.tsx
"upload.filelist.name": "Name",
"upload.filelist.size": "Size",
"upload.filelist.estimating": "Estimating...",
"upload.filelist.remaining": "{time} remaining",
// showCreateUploadModal.tsx
"upload.modal.title": "Create Share",
@@ -373,6 +392,8 @@ export default {
"upload.modal.accordion.email.title": "Email recipients",
"upload.modal.accordion.email.placeholder": "Enter email recipients",
"upload.modal.accordion.email.invalid-email": "Invalid email address",
"upload.modal.accordion.email.restrict-to-recipients":
"Restrict access to these recipients only (they must sign in to access it)",
"upload.modal.accordion.security.title": "Security options",
"upload.modal.accordion.security.password.label": "Password protection",
@@ -406,6 +427,10 @@ export default {
"share.error.access-denied.title": "Private share",
"share.error.access-denied.description":
"The current account does not have permission to access this share",
"share.error.restricted.title": "Restricted share",
"share.error.restricted.description":
"This share is restricted to specific recipients. Please log in to access it.",
"share.error.restricted.button": "Log in",
"share.modal.password.title": "Password required",
"share.modal.password.description":
@@ -484,6 +509,12 @@ export default {
"admin.config.appearance.custom-css": "Custom CSS",
"admin.config.appearance.custom-css.description":
"Global CSS applied to the frontend. Use carefully, as invalid CSS may affect the UI.",
"admin.config.appearance.upload-progress-style": "Upload progress style",
"admin.config.appearance.upload-progress-style.description":
"Choose how upload progress is displayed in the file list.",
"admin.config.appearance.upload-progress-style.circle": "Circle indicator",
"admin.config.appearance.upload-progress-style.circle-percentage": "Circle with percentage",
"admin.config.appearance.upload-progress-style.percentage-time": "Percentage and time remaining",
"admin.config.general.app-url": "App URL",
"admin.config.general.app-url.description":
"On which URL Pingvin Share is available",
@@ -557,6 +588,10 @@ export default {
"admin.config.email.invite-message": "Invite message",
"admin.config.email.invite-message.description":
"Message which gets sent when an admin invites a user. {url} will be replaced with the invite URL, {email} with the email and {password} with the users password.",
"admin.config.email.share-recipients-reply-to-creator":
"Set Reply-To to creator's email",
"admin.config.email.share-recipients-reply-to-creator.description":
"Whether to set the Reply-To header to the email address of the user who created the share.",
"admin.config.email.enable-share-download-notifications":
"Enable download notifications",
"admin.config.email.enable-share-download-notifications.description":
@@ -612,6 +647,9 @@ export default {
"Force reverse shares to be created in simple mode. If disabled, the creator of the reverse share can choose between simple and advanced mode.",
"admin.config.share.allow-admin-access-all-shares.description":
"Allow administrators to access all shares, even if they are password protected, expired or deleted.",
"admin.config.share.enable-user-recipients": "Enable sharing with registered users",
"admin.config.share.enable-user-recipients.description":
"When enabled, shares sent to a registered user's email address will automatically appear in their account. Users can also restrict share access to named recipients only.",
"admin.config.share.file-retention-period": "File retention period",
"admin.config.share.file-retention-period.description":
"How long files are kept after a share expires or gets deleted. Only useful if the 'Allow admin access to all shares' is also enabled. Set to -1 to keep files forever.",
+6 -2
View File
@@ -218,7 +218,9 @@ function App({ Component, pageProps }: AppProps) {
if (!pageProps.language) return;
const cookieLanguage = getCookie("language");
if (!cookieLanguage) {
i18nUtil.setLanguageCookie(pageProps.language);
if (!pageProps.isConfigFallback) {
i18nUtil.setLanguageCookie(pageProps.language);
}
} else if (pageProps.language !== cookieLanguage) {
location.reload();
}
@@ -227,7 +229,7 @@ function App({ Component, pageProps }: AppProps) {
document.documentElement.dir = current.direction ?? "ltr";
document.documentElement.lang = current.code;
}, [pageProps.language]);
}, [pageProps.language, pageProps.isConfigFallback]);
useEffect(() => {
const userColorPreference = userPreferences.get("colorScheme");
@@ -333,6 +335,7 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
route?: string;
colorScheme: ColorScheme;
language?: string;
isConfigFallback?: boolean;
} = {
route: ctx.resolvedUrl,
colorScheme:
@@ -357,6 +360,7 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
).data;
} catch (e) {
pageProps.configVariables = getDefaultConfig();
pageProps.isConfigFallback = true;
}
pageProps.route = ctx.req.url;
+113
View File
@@ -0,0 +1,113 @@
import {
Box,
Button,
Center,
Group,
Space,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import moment from "moment";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import Meta from "../../components/Meta";
import CenterLoader from "../../components/core/CenterLoader";
import useConfig from "../../hooks/config.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import shareService from "../../services/share.service";
const ReceivedShares = () => {
const t = useTranslate();
const router = useRouter();
const config = useConfig();
const [receivedShares, setReceivedShares] = useState<any[]>();
useEffect(() => {
if (!config.get("share.enableUserRecipients")) {
router.replace("/");
return;
}
shareService.getReceivedShares().then((data) => setReceivedShares(data));
}, []);
if (!receivedShares) return <CenterLoader />;
return (
<>
<Meta title={t("account.received-shares.title")} />
<Title mb={30} order={3}>
<FormattedMessage id="account.received-shares.title" />
</Title>
{receivedShares.length === 0 ? (
<Center style={{ height: "70vh" }}>
<Stack align="center" spacing={10}>
<Title order={3}>
<FormattedMessage id="account.received-shares.title.empty" />
</Title>
<Text>
<FormattedMessage id="account.received-shares.description.empty" />
</Text>
<Space h={5} />
</Stack>
</Center>
) : (
<Box sx={{ display: "block", overflowX: "auto" }}>
<Table>
<thead>
<tr>
<th>
<FormattedMessage id="account.shares.table.id" />
</th>
<th>
<FormattedMessage id="account.shares.table.name" />
</th>
<th>
<FormattedMessage id="account.received-shares.table.from" />
</th>
<th>
<FormattedMessage id="account.shares.table.expiresAt" />
</th>
<th></th>
</tr>
</thead>
<tbody>
{receivedShares.map(({ share }) => (
<tr key={share.id}>
<td>{share.id}</td>
<td>{share.name}</td>
<td>{share.creator?.username ?? "—"}</td>
<td>
{moment(share.expiration).unix() === 0 ? (
<FormattedMessage id="account.shares.table.expiry-never" />
) : (
moment(share.expiration).format("LLL")
)}
</td>
<td>
<Group position="right">
<Button
component={Link}
href={`/share/${share.id}`}
variant="light"
size="xs"
color="victoria"
>
<FormattedMessage id="account.received-shares.button.open" />
</Button>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
)}
</>
);
};
export default ReceivedShares;
+3 -1
View File
@@ -77,7 +77,9 @@ const MyShares = () => {
showCreateReverseShareModal(
modals,
config.get("smtp.enabled"),
config.get("share.maxExpiration"),
user?.isAdmin
? { value: 0, unit: "days" }
: config.get("share.maxExpiration"),
config.get("share.defaultExpiration"),
config.get("share.reverseShareSimpleOnly"),
appUrl,
+47 -7
View File
@@ -3,6 +3,7 @@ import {
Box,
Button,
Center,
useMantineTheme,
Group,
Space,
Stack,
@@ -21,7 +22,9 @@ import {
TbLink,
TbLock,
TbTrash,
TbUsers,
} from "react-icons/tb";
import { FaUserLock } from "react-icons/fa";
import { FormattedMessage } from "react-intl";
import Meta from "../../components/Meta";
import showShareInformationsModal from "../../components/share/showShareInformationsModal";
@@ -29,6 +32,7 @@ import showShareLinkModal from "../../components/account/showShareLinkModal";
import { HoverTip } from "../../components/core/HoverTip";
import CenterLoader from "../../components/core/CenterLoader";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import shareService from "../../services/share.service";
import { MyShare } from "../../types/share.type";
@@ -38,7 +42,9 @@ const MyShares = () => {
const modals = useModals();
const clipboard = useClipboard();
const config = useConfig();
const { user } = useUser();
const t = useTranslate();
const theme = useMantineTheme();
const [shares, setShares] = useState<MyShare[]>();
@@ -80,6 +86,7 @@ const MyShares = () => {
<th>
<FormattedMessage id="account.shares.table.name" />
</th>
<th>
<FormattedMessage id="account.shares.table.visitors" />
</th>
@@ -90,16 +97,46 @@ const MyShares = () => {
</tr>
</thead>
<tbody>
{shares.map((share) => (
{shares.map((share) => {
return (
<tr key={share.id}>
<td>
<Group spacing="xs">
{share.id}{" "}
{share.security?.passwordProtected && (
<TbLock
color="orange"
title={t("account.shares.table.password-protected")}
/>
<HoverTip label="Password Protected">
<span style={{ display: "inline-flex" }}>
<TbLock
color="orange"
title={t("account.shares.table.password-protected")}
/>
</span>
</HoverTip>
)}
{config.get("share.enableUserRecipients") && (
share.security?.restrictToRecipients ? (
<HoverTip label="Recipients Only">
<span style={{ display: "inline-flex" }}>
<FaUserLock
color={theme.colors.gray[6]}
title={t(
"account.shares.table.restricted-to-recipients",
)}
/>
</span>
</HoverTip>
) : share.recipients?.length ? (
<HoverTip label="Sent to Recipients">
<span style={{ display: "inline-flex" }}>
<TbUsers
color={theme.colors.gray[6]}
title={t(
"account.shares.table.shared-with-recipients",
)}
/>
</span>
</HoverTip>
) : null
)}
</Group>
</td>
@@ -145,7 +182,9 @@ const MyShares = () => {
parseInt(config.get("share.maxSize")),
config.get("general.appUrl"),
config.get("general.appUrl", true),
config.get("share.maxExpiration"),
user?.isAdmin
? { value: 0, unit: "days" }
: config.get("share.maxExpiration"),
(updatedShare) =>
setShares(
shares.map((item) =>
@@ -221,7 +260,8 @@ const MyShares = () => {
</Group>
</td>
</tr>
))}
);
})}
</tbody>
</Table>
</Box>
+2 -5
View File
@@ -33,11 +33,8 @@ const Intro = () => {
</Anchor>{" "}
</Text>
<Text>
You can also support development via {" "}
<Anchor
target="_blank"
href="https://github.com/sponsors/smp46"
>
You can also support development via{" "}
<Anchor target="_blank" href="https://github.com/sponsors/smp46">
GitHub Sponsors
</Anchor>{" "}
</Text>
+3 -9
View File
@@ -49,27 +49,21 @@ export default function VerifyAccount() {
{status === "success" && (
<>
<Text align="center">
<FormattedMessage
id="verify.success"
/>
<FormattedMessage id="verify.success" />
</Text>
<Button
fullWidth
mt="xl"
onClick={() => router.replace("/auth/signIn")}
>
<FormattedMessage
id="verify.button.signin"
/>
<FormattedMessage id="verify.button.signin" />
</Button>
</>
)}
{status === "error" && (
<>
<Text align="center" color="red">
<FormattedMessage
id="verify.error"
/>
<FormattedMessage id="verify.error" />
</Text>
<Button
fullWidth
+5 -15
View File
@@ -32,16 +32,12 @@ export default function VerificationInfo() {
<Meta title={t("verify.info.title")} />
<Container size={420} my={40}>
<Title order={2} align="center" weight={900}>
<FormattedMessage
id="verify.info.title"
/>
<FormattedMessage id="verify.info.title" />
</Title>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<Stack align="center">
<Text align="center">
<FormattedMessage
id="verify.info.description"
/>
<FormattedMessage id="verify.info.description" />
</Text>
{email && (
<Text weight={700} size="sm">
@@ -49,9 +45,7 @@ export default function VerificationInfo() {
</Text>
)}
<Text align="center" size="sm" color="dimmed">
<FormattedMessage
id="verify.info.note"
/>
<FormattedMessage id="verify.info.note" />
</Text>
<Stack w="100%" mt="xl">
<Button
@@ -61,14 +55,10 @@ export default function VerificationInfo() {
disabled={!email}
fullWidth
>
<FormattedMessage
id="verify.info.resend.button"
/>
<FormattedMessage id="verify.info.resend.button" />
</Button>
<Button fullWidth onClick={() => router.replace("/auth/signIn")}>
<FormattedMessage
id="verify.button.signin"
/>
<FormattedMessage id="verify.button.signin" />
</Button>
</Stack>
</Stack>
+40 -2
View File
@@ -1,4 +1,13 @@
import { ActionIcon, Box, Group, Text, Title } from "@mantine/core";
import {
ActionIcon,
Box,
Button,
Center,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { useModals } from "@mantine/modals";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -32,6 +41,7 @@ const Share = ({ shareId }: { shareId: string }) => {
const modals = useModals();
const router = useRouter();
const [share, setShare] = useState<ShareType>();
const [isRestricted, setIsRestricted] = useState(false);
const { user } = useUser();
const config = useConfig();
const t = useTranslate();
@@ -53,7 +63,9 @@ const Share = ({ shareId }: { shareId: string }) => {
parseInt(config.get("share.maxSize")),
config.get("general.appUrl"),
config.get("general.appUrl", true),
config.get("share.maxExpiration"),
user?.isAdmin
? { value: 0, unit: "days" }
: config.get("share.maxExpiration"),
(updatedShare: MyShare) => {
setShare((prev) =>
prev
@@ -124,6 +136,11 @@ const Share = ({ shareId }: { shareId: string }) => {
"go-home",
);
}
} else if (
e.response.status == 403 &&
error == "share_restricted_to_recipients"
) {
setIsRestricted(true);
} else if (e.response.status == 403 && error == "private_share") {
showErrorModal(
modals,
@@ -149,6 +166,27 @@ const Share = ({ shareId }: { shareId: string }) => {
getFiles();
}, []);
if (isRestricted) {
return (
<Center style={{ height: "70vh" }}>
<Stack align="center" spacing="md">
<Title order={3}>
<FormattedMessage id="share.error.restricted.title" />
</Title>
<Text color="dimmed" align="center">
<FormattedMessage id="share.error.restricted.description" />
</Text>
<Button
component={Link}
href={`/auth/signIn?redirect=/share/${shareId}`}
>
<FormattedMessage id="share.error.restricted.button" />
</Button>
</Stack>
</Center>
);
}
return (
<>
<Meta
+46 -9
View File
@@ -19,6 +19,10 @@ import { FileUpload } from "../../types/File.type";
import { CreateShare, Share } from "../../types/share.type";
import toast from "../../utils/toast.util";
import { useRouter } from "next/router";
import {
getNormalizedFileName,
filterDuplicateFiles,
} from "../../utils/file.util";
const promiseLimit = pLimit(3);
let errorToastShown = false;
@@ -109,10 +113,23 @@ const Upload = ({
blob,
{
id: fileId,
name: file.name,
name: getNormalizedFileName(file),
},
chunkIndex,
chunks,
(progressEvent) => {
if (progressEvent.total && file.size > 0) {
const chunkProgress =
progressEvent.loaded / progressEvent.total;
const uploadedBytesBeforeThisChunk =
chunkIndex * chunkSize.current;
const uploadedBytesInThisChunk = blob.size * chunkProgress;
const totalUploaded =
uploadedBytesBeforeThisChunk + uploadedBytesInThisChunk;
const overallPercent = (totalUploaded / file.size) * 100;
setFileProgress(Math.min(overallPercent, 99.9));
}
},
)
.then((response) => {
fileId = response.id;
@@ -155,7 +172,10 @@ const Upload = ({
"share.allowUnauthenticatedShares",
),
enableEmailRecepients: config.get("email.enableShareEmailRecipients"),
maxExpiration: config.get("share.maxExpiration"),
enableUserRecipients: config.get("share.enableUserRecipients"),
maxExpiration: user?.isAdmin
? { value: 0, unit: "days" }
: config.get("share.maxExpiration"),
defaultExpiration: config.get("share.defaultExpiration"),
shareIdLength: config.get("share.shareIdLength"),
simplified,
@@ -165,12 +185,19 @@ const Upload = ({
);
};
const handleDropzoneFilesChanged = (files: FileUpload[]) => {
const handleDropzoneFilesChanged = (newFiles: FileUpload[]) => {
const filtered = filterDuplicateFiles(newFiles, files, (normalizedName) =>
toast.error(
t("upload.notify.duplicate-skipped", { name: normalizedName }),
),
);
if (filtered.length === 0) return;
if (autoOpenCreateUploadModal) {
setFiles(files);
showCreateUploadModalCallback(files);
setFiles(filtered);
showCreateUploadModalCallback(filtered);
} else {
setFiles((oldArr) => [...oldArr, ...files]);
setFiles((oldArr) => [...oldArr, ...filtered]);
}
};
@@ -205,11 +232,21 @@ const Upload = ({
const fileUpload = file as FileUpload;
fileUpload.uploadingProgress = 0;
const filtered = filterDuplicateFiles(
[fileUpload],
files,
(normalizedName) =>
toast.error(
t("upload.notify.duplicate-skipped", { name: normalizedName }),
),
);
if (filtered.length === 0) return;
if (autoOpenCreateUploadModal) {
setFiles([fileUpload]);
showCreateUploadModalCallback([fileUpload]);
setFiles(filtered);
showCreateUploadModalCallback(filtered);
} else {
setFiles((oldArr) => [...oldArr, fileUpload]);
setFiles((oldArr) => [...oldArr, ...filtered]);
}
}
};
+7
View File
@@ -73,6 +73,10 @@ const getMyShares = async (): Promise<MyShare[]> => {
return (await api.get("shares")).data;
};
const getReceivedShares = async (): Promise<any[]> => {
return (await api.get("shares/received")).data;
};
const getShareToken = async (id: string, password?: string) => {
if (!isValidId(id)) throw new Error("Invalid ID");
await api.post(`/shares/${id}/token`, { password });
@@ -132,6 +136,7 @@ const uploadFile = async (
},
chunkIndex: number,
totalChunks: number,
onUploadProgress?: (progressEvent: any) => void,
): Promise<FileUploadResponse> => {
if (!isValidId(shareId)) throw new Error("Invalid Share ID");
return (
@@ -143,6 +148,7 @@ const uploadFile = async (
chunkIndex,
totalChunks,
},
onUploadProgress,
})
).data;
};
@@ -199,6 +205,7 @@ export default {
doesFileSupportPreview,
isShareTextFile,
getMyShares,
getReceivedShares,
isShareIdAvailable,
downloadFile,
removeFile,
+4 -1
View File
@@ -49,7 +49,8 @@ export type ShareMetaData = {
export type MyShare = Omit<Share, "hasPassword"> & {
views: number;
createdAt: Date;
security?: MyShareSecurity;
recipients: string[];
security: MyShareSecurity;
};
export type MyReverseShare = {
@@ -64,9 +65,11 @@ export type MyReverseShare = {
export type ShareSecurity = {
maxViews?: number;
password?: string;
restrictToRecipients?: boolean;
};
export type MyShareSecurity = {
passwordProtected: boolean;
maxViews?: number;
restrictToRecipients: boolean;
};
+33
View File
@@ -0,0 +1,33 @@
export const getNormalizedFileName = (file: File): string => {
const pathName = file.webkitRelativePath || file.name;
return pathName.replace(/\\/g, "/").replace(/^\//, "");
};
export const filterDuplicateFiles = <T extends File>(
newFiles: T[],
existingFilesList: Array<{ name: string; webkitRelativePath?: string; deleted?: boolean }>,
onDuplicateDetected: (name: string) => void
): T[] => {
const existingNames = new Set(
existingFilesList
.filter((file) => !file.deleted)
.map((file) => {
const pathName = file.webkitRelativePath || file.name;
return pathName.replace(/\\/g, "/").replace(/^\//, "");
})
);
const filtered: T[] = [];
const seenInBatch = new Set<string>();
for (const file of newFiles) {
const normalizedName = getNormalizedFileName(file);
if (existingNames.has(normalizedName) || seenInBatch.has(normalizedName)) {
onDuplicateDetected(normalizedName);
} else {
seenInBatch.add(normalizedName);
filtered.push(file);
}
}
return filtered;
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pingvin-share-x",
"version": "1.21.1",
"version": "1.21.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pingvin-share-x",
"version": "1.21.1",
"version": "1.21.2",
"devDependencies": {
"conventional-changelog": "^7.2.0",
"conventional-changelog-conventionalcommits": "^9.3.1"
+3 -1
View File
@@ -1,12 +1,14 @@
{
"name": "pingvin-share-x",
"version": "1.21.1",
"version": "1.21.2",
"scripts": {
"format": "cd frontend && npm run format && cd ../backend && npm run format",
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",
"version": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -o CHANGELOG.md && git add CHANGELOG.md",
"release:patch": "cd backend && npm version patch --commit-hooks false && cd ../frontend && npm version patch --commit-hooks false && cd .. && git add . && npm version patch --force -m 'release: %s' && git push && git push --tags",
"release:minor": "cd backend && npm version minor --commit-hooks false && cd ../frontend && npm version minor --commit-hooks false && cd .. && git add . && npm version minor --force -m 'release: %s' && git push && git push --tags",
"release:beta:patch": "cd backend && npm version prerelease --preid beta --commit-hooks false && cd ../frontend && npm version prerelease --preid beta --commit-hooks false && cd .. && git add . && npm version prerelease --preid beta --force -m 'release: %s' && git push && git push --tags",
"release:beta:minor": "cd backend && npm version preminor --preid beta --commit-hooks false && cd ../frontend && npm version preminor --preid beta --commit-hooks false && cd .. && git add . && npm version preminor --preid beta --force -m 'release: %s' && git push && git push --tags",
"deploy:dev": "docker buildx build --push --tag smp46/pingvin-share-x:development --platform linux/amd64,linux/arm64 ."
},
"prettier": {