Compare commits
223 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f9a502e40 | ||
|
|
def1d571d0 | ||
|
|
58de3ce0df | ||
|
|
dd546b4f17 | ||
|
|
19e6b9a0be | ||
|
|
85f514316b | ||
|
|
cfdb29ed4d | ||
|
|
18b34e9bf2 | ||
|
|
9d603c41e5 | ||
|
|
7ba499d5a0 | ||
|
|
3d1656688e | ||
|
|
e53a25750d | ||
|
|
ffcd857d9a | ||
|
|
48a6ceb3b4 | ||
|
|
84833e5f91 | ||
|
|
1864951bdb | ||
|
|
427e99c7b1 | ||
|
|
ccc783ab6a | ||
|
|
853f217bf1 | ||
|
|
af047c0bc1 | ||
|
|
8ddee32664 | ||
|
|
e7128dcfef | ||
|
|
56d8517073 | ||
|
|
df4341163a | ||
|
|
81547ba1e0 | ||
|
|
a790ac73fd | ||
|
|
3fd689a760 | ||
|
|
6a8ac1aa65 | ||
|
|
4d1ce8aa49 | ||
|
|
71eab23503 | ||
|
|
2943073c10 | ||
|
|
27fca64a69 | ||
|
|
7d005db4e1 | ||
|
|
73a76a9d5b | ||
|
|
8e828311fe | ||
|
|
b7410d8589 | ||
|
|
5cfa8f3015 | ||
|
|
ccb7fdca43 | ||
|
|
ac81cb9ab7 | ||
|
|
b737cba35e | ||
|
|
1d51973358 | ||
|
|
589127e943 | ||
|
|
6722938ae6 | ||
|
|
9f720388ef | ||
|
|
e7b3c48ff4 | ||
|
|
0dfd4d014d | ||
|
|
ce0dc976a8 | ||
|
|
61418a1d8d | ||
|
|
1159d972a8 | ||
|
|
b6d1720fe6 | ||
|
|
dc30f4f3c9 | ||
|
|
e77edfd5d3 | ||
|
|
e848675d63 | ||
|
|
5e2d44b423 | ||
|
|
9dfb52a145 | ||
|
|
f4291421b5 | ||
|
|
28fdbc2281 | ||
|
|
2f74c87d0b | ||
|
|
dcea5ccd89 | ||
|
|
bfbc87817f | ||
|
|
a2e031326e | ||
|
|
ec92e85c8d | ||
|
|
4b642f419b | ||
|
|
75cd3895d9 | ||
|
|
d3b38b27d1 | ||
|
|
398a5532dc | ||
|
|
70b577f5ac | ||
|
|
0d71146a2c | ||
|
|
a53f0711fb | ||
|
|
8a7db6bf97 | ||
|
|
f78777b284 | ||
|
|
3a534c7512 | ||
|
|
2b7d3c0a8a | ||
|
|
7fea358410 | ||
|
|
f65c1ef7d8 | ||
|
|
9e4496dc03 | ||
|
|
235772a54b | ||
|
|
784e80db5b | ||
|
|
0442ece9ba | ||
|
|
7f9f8b6fe7 | ||
|
|
bf1b2633c8 | ||
|
|
b3ea96c191 | ||
|
|
4a7076a094 | ||
|
|
0c62485833 | ||
|
|
2c555eaf9f | ||
|
|
36afbf91b7 | ||
|
|
df1ffaa2bc | ||
|
|
53c05518df | ||
|
|
b58dcdba0b | ||
|
|
4d3aa398a2 | ||
|
|
a120d44185 | ||
|
|
362e7d4f38 | ||
|
|
f36ba8ac0a | ||
|
|
30caeb5b25 | ||
|
|
bfd4049c15 | ||
|
|
856c54d5d6 | ||
|
|
6a97cc279c | ||
|
|
7e09ae1f98 | ||
|
|
3946f6f237 | ||
|
|
5069abe4b9 | ||
|
|
5a54fe4cb7 | ||
|
|
0b406f0464 | ||
|
|
cbc7fd83a7 | ||
|
|
c178a83fa5 | ||
|
|
185f1b2ab7 | ||
|
|
6771bfdf50 | ||
|
|
2db1f6a112 | ||
|
|
168038eae7 | ||
|
|
3df80acff9 | ||
|
|
e86f93830b | ||
|
|
38f1626b11 | ||
|
|
ac9b0a1d53 | ||
|
|
ba2e7e122c | ||
|
|
3527dd1dd9 | ||
|
|
54af6c2055 | ||
|
|
3160f90e1d | ||
|
|
da54ce6ee0 | ||
|
|
468b25828b | ||
|
|
9d4bb55a09 | ||
|
|
f78ffd69e7 | ||
|
|
17528f999a | ||
|
|
c8f05f2475 | ||
|
|
424e2564d5 | ||
|
|
18d8cbbbab | ||
|
|
c7dacb26e8 | ||
|
|
b6d98c7c42 | ||
|
|
c52ec71920 | ||
|
|
6cf5c66fe2 | ||
|
|
51478b6a9f | ||
|
|
6f45c3b1fb | ||
|
|
ff2dd81055 | ||
|
|
c26de4e881 | ||
|
|
4ef7ebb062 | ||
|
|
d870b5721a | ||
|
|
d8084e401d | ||
|
|
e1a5d19544 | ||
|
|
4ce64206be | ||
|
|
77eef187b7 | ||
|
|
c7138bcf5d | ||
|
|
ab4f19e921 | ||
|
|
428c1d2b99 | ||
|
|
c89ca7e64b | ||
|
|
297e8c0ab1 | ||
|
|
446f9dd209 | ||
|
|
acbff6e129 | ||
|
|
546d2c1ce4 | ||
|
|
37839e6b18 | ||
|
|
0b355b94c4 | ||
|
|
6444a9d553 | ||
|
|
08079744a0 | ||
|
|
558dd2fb15 | ||
|
|
fe085b58a5 | ||
|
|
958b79d787 | ||
|
|
ede9c2a816 | ||
|
|
e195565630 | ||
|
|
520f9abcf7 | ||
|
|
bfbe8de98a | ||
|
|
d5cd3002a1 | ||
|
|
77a092a3cf | ||
|
|
613bae9033 | ||
|
|
2e692241c5 | ||
|
|
1e96011793 | ||
|
|
522a041ca1 | ||
|
|
ce6430da9f | ||
|
|
2b3ce3ffd2 | ||
|
|
104cc06145 | ||
|
|
4a50a5aa3b | ||
|
|
d6b8b56247 | ||
|
|
5883dff4cf | ||
|
|
511ae933fa | ||
|
|
df2521b192 | ||
|
|
8f16d6b53e | ||
|
|
3310fe53b3 | ||
|
|
adc4af996d | ||
|
|
61edc4f4f6 | ||
|
|
eba7984a0f | ||
|
|
69752b8b41 | ||
|
|
ee73293c0f | ||
|
|
5553607ffe | ||
|
|
2ca6e6ee5f | ||
|
|
18135b0ec0 | ||
|
|
f8bfb8ec3c | ||
|
|
187911e334 | ||
|
|
64acae11a2 | ||
|
|
6b39adfd03 | ||
|
|
d9cfe697d6 | ||
|
|
67a0fc6ea5 | ||
|
|
b13a81a88c | ||
|
|
97dc3ecfdd | ||
|
|
d00d52baa9 | ||
|
|
4c8848a2d9 | ||
|
|
3c8500008d | ||
|
|
325122b802 | ||
|
|
7dc2e56fee | ||
|
|
8b3e28bac8 | ||
|
|
347026b6d3 | ||
|
|
5a204d38a4 | ||
|
|
2eeb858f36 | ||
|
|
67faa860da | ||
|
|
beca26871d | ||
|
|
15d1756a4e | ||
|
|
be202d3d41 | ||
|
|
f0e785b1a2 | ||
|
|
92e1e82e09 | ||
|
|
0670aaa331 | ||
|
|
10b71e7035 | ||
|
|
dee70987eb | ||
|
|
3d2b978daf | ||
|
|
e813da05ae | ||
|
|
1fba0fd546 | ||
|
|
96cd353669 | ||
|
|
3e0735c620 | ||
|
|
d05988f281 | ||
|
|
42a985be04 | ||
|
|
af472af3bb | ||
|
|
f53f71f054 | ||
|
|
5622f9eb2f | ||
|
|
02b9abf6c5 | ||
|
|
6a4c3bf58f | ||
|
|
64efac5b68 | ||
|
|
8c5c696c51 | ||
|
|
01da83cdf6 | ||
|
|
cfcc5cebac |
9
.github/ISSUE_TEMPLATE/bug.yml
vendored
9
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -31,14 +31,13 @@ body:
|
||||
label: "👎 Actual Behavior"
|
||||
description: "What did actually happen? Add screenshots, if applicable."
|
||||
placeholder: "It actually ..."
|
||||
- type: input
|
||||
- type: textarea
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: "🌐 Browser"
|
||||
description: "Which browser do you use?"
|
||||
placeholder: "Firefox"
|
||||
label: "📜 Logs"
|
||||
description: "Paste any relevant logs here."
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
|
||||
3
.github/workflows/backend-system-tests.yml
vendored
3
.github/workflows/backend-system-tests.yml
vendored
@@ -10,8 +10,9 @@ on:
|
||||
|
||||
jobs:
|
||||
system-tests:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
container: node:18
|
||||
container: node:22
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install Dependencies
|
||||
|
||||
29
.github/workflows/build-docker-image.yml
vendored
29
.github/workflows/build-docker-image.yml
vendored
@@ -6,29 +6,50 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
type=semver,pattern={{major}},prefix=v
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Docker registry
|
||||
uses: docker/login-action@v2
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: stonith404/pingvin-share:latest,stonith404/pingvin-share:${{ github.ref_name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,6 +7,9 @@ node_modules
|
||||
/frontend/.next/
|
||||
/frontend/out/
|
||||
|
||||
# yarn
|
||||
yarn.lock
|
||||
|
||||
# build
|
||||
build/
|
||||
dist/
|
||||
@@ -41,6 +44,7 @@ yarn-error.log*
|
||||
/docs/build/
|
||||
/docs/.docusaurus
|
||||
/docs/.cache-loader
|
||||
/config.yaml
|
||||
|
||||
# Jetbrains specific (webstorm)
|
||||
.idea/**/**
|
||||
|
||||
317
CHANGELOG.md
317
CHANGELOG.md
@@ -1,3 +1,320 @@
|
||||
## [1.13.0](https://github.com/stonith404/pingvin-share/compare/v1.12.0...v1.13.0) (2025-05-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow to use redis cache instead of memory cache ([#832](https://github.com/stonith404/pingvin-share/issues/832)) ([85f5143](https://github.com/stonith404/pingvin-share/commit/85f514316b0b808b8c063bf571df6b528a1b3de4))
|
||||
* **backend:** allow to define path to the config file ([#838](https://github.com/stonith404/pingvin-share/issues/838)) ([cfdb29e](https://github.com/stonith404/pingvin-share/commit/cfdb29ed4dde875233b4bc3f510ae50976b963b8))
|
||||
|
||||
## [1.12.0](https://github.com/stonith404/pingvin-share/compare/v1.11.1...v1.12.0) (2025-05-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **s3:** stream s3 content over a zip file ([#822](https://github.com/stonith404/pingvin-share/issues/822)) ([ccc783a](https://github.com/stonith404/pingvin-share/commit/ccc783ab6a00841a7041c454e77afb472d76999e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disable HTML rendering in Markdown preview ([427e99c](https://github.com/stonith404/pingvin-share/commit/427e99c7b1d00ff6ed7b5fd879d8cf0f0d49281a))
|
||||
* health check for containers with reverse proxy disabled ([#816](https://github.com/stonith404/pingvin-share/issues/816)) ([a790ac7](https://github.com/stonith404/pingvin-share/commit/a790ac73fd42d266a957e09a05b1894199605f6a)), closes [#809](https://github.com/stonith404/pingvin-share/issues/809)
|
||||
* OIDC configuration from YAML configuration file doesn't get loaded ([48a6ceb](https://github.com/stonith404/pingvin-share/commit/48a6ceb3b4b4dfc0407dc6f9ee2e07cca1829cef))
|
||||
* spelling mistake and add clarity in email template ([#824](https://github.com/stonith404/pingvin-share/issues/824)) ([af047c0](https://github.com/stonith404/pingvin-share/commit/af047c0bc152a955b3ab135f5a9ea3d62b32fb0f))
|
||||
* use sandbox CSP for file previews ([1864951](https://github.com/stonith404/pingvin-share/commit/1864951bdbf573431e795109224a45545b86b54d))
|
||||
|
||||
## [1.11.1](https://github.com/stonith404/pingvin-share/compare/v1.11.0...v1.11.1) (2025-04-06)
|
||||
|
||||
## [1.11.0](https://github.com/stonith404/pingvin-share/compare/v1.10.4...v1.11.0) (2025-04-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add env variable to disable caddy ([#797](https://github.com/stonith404/pingvin-share/issues/797)) ([27fca64](https://github.com/stonith404/pingvin-share/commit/27fca64a69067eaa094d1559ca1fee4f064d89a7))
|
||||
* **s3:** allow disabling upload checksum ([#804](https://github.com/stonith404/pingvin-share/issues/804)) ([73a76a9](https://github.com/stonith404/pingvin-share/commit/73a76a9d5b9825a3dc396f49d76ddc5c303fce40))
|
||||
|
||||
## [1.10.4](https://github.com/stonith404/pingvin-share/compare/v1.10.3...v1.10.4) (2025-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* admin role gets reset if signing in with an OIDC provider ([ccb7fdc](https://github.com/stonith404/pingvin-share/commit/ccb7fdca43a2d458243e56a24510fe5325fa7942))
|
||||
|
||||
## [1.10.3](https://github.com/stonith404/pingvin-share/compare/v1.10.2...v1.10.3) (2025-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* error while signing in with OIDC if roles claim is undefined ([b737cba](https://github.com/stonith404/pingvin-share/commit/b737cba35e59255904eccae9e9de1cbd36284fb1))
|
||||
|
||||
## [1.10.2](https://github.com/stonith404/pingvin-share/compare/v1.10.1...v1.10.2) (2025-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't throw error if group claim is missing ([e7b3c48](https://github.com/stonith404/pingvin-share/commit/e7b3c48ff48bd7cfb206c32ea97862b757057573))
|
||||
|
||||
## [1.10.1](https://github.com/stonith404/pingvin-share/compare/v1.10.0...v1.10.1) (2025-02-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* admin property can't be set if OAuth2 user email doesn't match actual user's email ([1159d97](https://github.com/stonith404/pingvin-share/commit/1159d972a8c32a0d6bf53d161c2fc09e6f8dfb28))
|
||||
* type error when trying to run the seed command ([b6d1720](https://github.com/stonith404/pingvin-share/commit/b6d1720fe637497ad624c6cdc40058b1b0f0c74c))
|
||||
|
||||
## [1.10.0](https://github.com/stonith404/pingvin-share/compare/v1.9.1...v1.10.0) (2025-02-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ability to configure application with a config file ([#740](https://github.com/stonith404/pingvin-share/issues/740)) ([9dfb52a](https://github.com/stonith404/pingvin-share/commit/9dfb52a14587065dacd9fcd2bb2efa1b458880a5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* confusing config configuration description for session duration ([28fdbc2](https://github.com/stonith404/pingvin-share/commit/28fdbc22814260040c78e27a62d86b84df83751f))
|
||||
* page crash if new release check fails ([e848675](https://github.com/stonith404/pingvin-share/commit/e848675d634a08efe3aac4e02d98136c36b36bfc))
|
||||
* smtp password gets autofilled in Firefox ([f429142](https://github.com/stonith404/pingvin-share/commit/f4291421b5531b0eeae5bcca9139f80c3cd43b4b))
|
||||
|
||||
## [1.9.1](https://github.com/stonith404/pingvin-share/compare/v1.9.0...v1.9.1) (2025-02-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* page doesn't reload on user deletion ([a2e0313](https://github.com/stonith404/pingvin-share/commit/a2e031326e51f7663c2d864dd0d08a65f180318e))
|
||||
* redirection to the OIDC end session endpoint ([ec92e85](https://github.com/stonith404/pingvin-share/commit/ec92e85c8d294b30117ad2599ad03b0bbb04574c))
|
||||
|
||||
## [1.9.0](https://github.com/stonith404/pingvin-share/compare/v1.8.2...v1.9.0) (2025-02-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ability to add email to recipients list by clicking enter ([#760](https://github.com/stonith404/pingvin-share/issues/760)) ([70b577f](https://github.com/stonith404/pingvin-share/commit/70b577f5ac8385cfc6a22ffee4c7e317e3cc6403))
|
||||
|
||||
## [1.8.2](https://github.com/stonith404/pingvin-share/compare/v1.8.1...v1.8.2) (2025-01-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* normal share gets attached to previously visited reverse share ([3a534c7](https://github.com/stonith404/pingvin-share/commit/3a534c7512ef82f3fa982f80e364f53c957306a0))
|
||||
* wrong TOTP validation for password ([2b7d3c0](https://github.com/stonith404/pingvin-share/commit/2b7d3c0a8a3e527fc1f7f86795731d5ac77eda49))
|
||||
|
||||
## [1.8.1](https://github.com/stonith404/pingvin-share/compare/v1.8.0...v1.8.1) (2025-01-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* wrong validation for expiration in reverse share modal ([b3ea96c](https://github.com/stonith404/pingvin-share/commit/b3ea96c1916980863fc6903c64cd2a7b32d66cfb))
|
||||
|
||||
## [1.8.0](https://github.com/stonith404/pingvin-share/compare/v1.7.2...v1.8.0) (2025-01-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add legal page with configuration options ([#724](https://github.com/stonith404/pingvin-share/issues/724)) ([df1ffaa](https://github.com/stonith404/pingvin-share/commit/df1ffaa2bcc047668cdc207cf8f86d821778cf44))
|
||||
* improve UI for timespan inputs on admin page ([#726](https://github.com/stonith404/pingvin-share/issues/726)) ([36afbf9](https://github.com/stonith404/pingvin-share/commit/36afbf91b7ba13e5ce42f2d91ec9898363a560b1))
|
||||
* **MyShares:** show information about own share security options ([#720](https://github.com/stonith404/pingvin-share/issues/720)) ([b58dcdb](https://github.com/stonith404/pingvin-share/commit/b58dcdba0b8688b286be4cc71796e2862553972a))
|
||||
* **UI:** improve filesize input and use it in settings ([#721](https://github.com/stonith404/pingvin-share/issues/721)) ([53c0551](https://github.com/stonith404/pingvin-share/commit/53c05518dfef4f65d76f5a1b301d0c5f8735576a))
|
||||
|
||||
## [1.7.2](https://github.com/stonith404/pingvin-share/compare/v1.7.1...v1.7.2) (2024-12-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* crash on zip download if zip is larger than 4GB ([#709](https://github.com/stonith404/pingvin-share/issues/709)) ([bfd4049](https://github.com/stonith404/pingvin-share/commit/bfd4049c154caae037db0458863e5c8c5d398848))
|
||||
|
||||
## [1.7.1](https://github.com/stonith404/pingvin-share/compare/v1.7.0...v1.7.1) (2024-12-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* incorrect ownership of the public folder ([6a97cc2](https://github.com/stonith404/pingvin-share/commit/6a97cc279c51bf125b9b516d1795f85b208e6ad5))
|
||||
|
||||
## [1.7.0](https://github.com/stonith404/pingvin-share/compare/v1.6.1...v1.7.0) (2024-12-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add support for S3 as a storage provider ([#659](https://github.com/stonith404/pingvin-share/issues/659)) ([5a54fe4](https://github.com/stonith404/pingvin-share/commit/5a54fe4cb7d9c22740edd8619c0a51044ca8c791))
|
||||
|
||||
## [1.6.1](https://github.com/stonith404/pingvin-share/compare/v1.6.0...v1.6.1) (2024-11-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* error for non oidc oauth clients ([ba2e7e1](https://github.com/stonith404/pingvin-share/commit/ba2e7e122c45bfb2a783b15438112a79fee0c307))
|
||||
|
||||
## [1.6.0](https://github.com/stonith404/pingvin-share/compare/v1.5.0...v1.6.0) (2024-11-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add config variable to specify the requested OIDC sopes ([da54ce6](https://github.com/stonith404/pingvin-share/commit/da54ce6ee020a9718f55ec30c614607d411f55c8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add validation for share id and zip compression config variables ([3160f90](https://github.com/stonith404/pingvin-share/commit/3160f90e1d4bb3d6aa4017e98e400929fc4d3b2e))
|
||||
|
||||
## [1.5.0](https://github.com/stonith404/pingvin-share/compare/v1.4.0...v1.5.0) (2024-11-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **share:** add share ID length setting ([#677](https://github.com/stonith404/pingvin-share/issues/677)) ([9d4bb55](https://github.com/stonith404/pingvin-share/commit/9d4bb55a0945450f8a42c212d7f23983db38f37f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* totp can't be enabled if user is a ldap user ([c8f05f2](https://github.com/stonith404/pingvin-share/commit/c8f05f2475a5a54550cf64ef57c8b612580273be))
|
||||
|
||||
## [1.4.0](https://github.com/stonith404/pingvin-share/compare/v1.3.0...v1.4.0) (2024-11-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add "creatorEmail" config bariable to share recipient email message ([c7dacb2](https://github.com/stonith404/pingvin-share/commit/c7dacb26e87504a1c5e6b0d87cdcd5ed91b9cdf5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remote arbitrary file overwrite on file upload endpoint ([6cf5c66](https://github.com/stonith404/pingvin-share/commit/6cf5c66fe2eda1e0a525edf7440d047fe2f0e35b))
|
||||
|
||||
## [1.3.0](https://github.com/stonith404/pingvin-share/compare/v1.2.4...v1.3.0) (2024-11-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add 'secureCookies' configuration variable to explicitly set the secure flag and prevent confusion ([4ce6420](https://github.com/stonith404/pingvin-share/commit/4ce64206be7440a99299e1ed238ced7408c0563d))
|
||||
* add confirm dialog for leaving the page if an upload is in progress ([d8084e4](https://github.com/stonith404/pingvin-share/commit/d8084e401d7572b2d6e38ffa20cb678a0fb0e615))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve share completed dialog redirection for reverse shares ([4ef7ebb](https://github.com/stonith404/pingvin-share/commit/4ef7ebb0622f16d2d2c4d114b5fc15298e2ba24f))
|
||||
* prevent deletion of last admin account ([e1a5d19](https://github.com/stonith404/pingvin-share/commit/e1a5d195448e3d741b77fb982ce515489a360562))
|
||||
* throw error if no disk space is left ([c26de4e](https://github.com/stonith404/pingvin-share/commit/c26de4e881edfe6c7db617c0aeba89871397ebe2))
|
||||
* use current window url instead of app url in frontend ([6f45c3b](https://github.com/stonith404/pingvin-share/commit/6f45c3b1fbf4a95b29e5742878b55a1afa0b8886))
|
||||
|
||||
## [1.2.4](https://github.com/stonith404/pingvin-share/compare/v1.2.3...v1.2.4) (2024-10-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't enforce password lenght for sign in form because of LDAP ([428c1d2](https://github.com/stonith404/pingvin-share/commit/428c1d2b993a05a25cc94aabe56216b9ab969fa1))
|
||||
* use app name as totp issuer ([c89ca7e](https://github.com/stonith404/pingvin-share/commit/c89ca7e64b08f437dd1b7e9bf2b9d674cc612228))
|
||||
|
||||
## [1.2.3](https://github.com/stonith404/pingvin-share/compare/v1.2.2...v1.2.3) (2024-10-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* share password can be bypassed if a deleted share with the same id was visited before ([acbff6e](https://github.com/stonith404/pingvin-share/commit/acbff6e129d236452180f8b96775457d135ac080))
|
||||
|
||||
## [1.2.2](https://github.com/stonith404/pingvin-share/compare/v1.2.1...v1.2.2) (2024-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** change general config icon to gear ([#649](https://github.com/stonith404/pingvin-share/issues/649)) ([958b79d](https://github.com/stonith404/pingvin-share/commit/958b79d787585c367a693872fd105a326e6e8d38))
|
||||
* environment variable `API_URL` can't be changed ([fe085b5](https://github.com/stonith404/pingvin-share/commit/fe085b58a5f3c0152df12957aa150c0876c2a074))
|
||||
|
||||
## [1.2.1](https://github.com/stonith404/pingvin-share/compare/v1.2.0...v1.2.1) (2024-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disallow passwort reset if it's a ldap user ([2e69224](https://github.com/stonith404/pingvin-share/commit/2e692241c57b001c9312302523c6374c0c24ea0c))
|
||||
* error message for invalid max use count of reverse share ([613bae9](https://github.com/stonith404/pingvin-share/commit/613bae90330a76c0964352a3fe927df3697309eb))
|
||||
* **oauth:** add `post_logout_redirect_uri` to OAuth logout redirect URI ([#638](https://github.com/stonith404/pingvin-share/issues/638)) ([bfbe8de](https://github.com/stonith404/pingvin-share/commit/bfbe8de98a6a7a2d32dd8d4dddbcc1d4ce6388f4))
|
||||
* share can't be created if an invalid email is entered in mail recipients ([d5cd300](https://github.com/stonith404/pingvin-share/commit/d5cd3002a1661e58d584e12280be36f17948c38c))
|
||||
* trim username, email and password on sign in and sign up page ([77a092a](https://github.com/stonith404/pingvin-share/commit/77a092a3cf089a4aa8b9897b5ad14e5500181d10))
|
||||
|
||||
## [1.2.0](https://github.com/stonith404/pingvin-share/compare/v1.1.3...v1.2.0) (2024-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **oauth:** add ability to limit user IDs for Discord authentication ([#621](https://github.com/stonith404/pingvin-share/issues/621)) ([5883dff](https://github.com/stonith404/pingvin-share/commit/5883dff4cf0abe99b3ac8f0b56fdc9d04e80b51c))
|
||||
* **oauth:** Add option to logout from OpenID Connect provider ([2b3ce3f](https://github.com/stonith404/pingvin-share/commit/2b3ce3ffd250f7e3052d43c1c1e76947abf91e55)), closes [#598](https://github.com/stonith404/pingvin-share/issues/598)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use unique port env variable for backend ([d6b8b56](https://github.com/stonith404/pingvin-share/commit/d6b8b56247814087c2b676fe2367300172b5a94b))
|
||||
|
||||
## [1.1.3](https://github.com/stonith404/pingvin-share/compare/v1.1.2...v1.1.3) (2024-09-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve the LDAP implementation ([#615](https://github.com/stonith404/pingvin-share/issues/615)) ([3310fe5](https://github.com/stonith404/pingvin-share/commit/3310fe53b3e4c89db78d57ede6c8d57d8137ecc1)), closes [#601](https://github.com/stonith404/pingvin-share/issues/601)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* omit invalid username characters in oidc registration ([adc4af9](https://github.com/stonith404/pingvin-share/commit/adc4af996d30b295b06e4ee517aa53be62c0f6c1))
|
||||
|
||||
## [1.1.2](https://github.com/stonith404/pingvin-share/compare/v1.1.1...v1.1.2) (2024-09-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disable auto complete for email recipients and share password ([ee73293](https://github.com/stonith404/pingvin-share/commit/ee73293c0f822d3e79cfefd096c656d4c36a12d1))
|
||||
* enable secure cookies if app url starts with https ([69752b8](https://github.com/stonith404/pingvin-share/commit/69752b8b417edda1ab4a4acedbdda09d545d6df8))
|
||||
|
||||
## [1.1.1](https://github.com/stonith404/pingvin-share/compare/v1.1.0...v1.1.1) (2024-09-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add environment variable to trust the reverse proxy ([b13a81a](https://github.com/stonith404/pingvin-share/commit/b13a81a88ca871c5714b2ed52d0e12fb7ceca176))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disable email login if ldap is enabled ([d9cfe69](https://github.com/stonith404/pingvin-share/commit/d9cfe697d66e9db7bfbc2252b3700580793ce9bb))
|
||||
|
||||
## [1.1.0](https://github.com/stonith404/pingvin-share/compare/v1.0.4...v1.1.0) (2024-09-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow smpt without username and password ([8b3e28b](https://github.com/stonith404/pingvin-share/commit/8b3e28bac83e5326234096445395046ebdb0c4d7))
|
||||
* auto redirect to oauth provider ([7dc2e56](https://github.com/stonith404/pingvin-share/commit/7dc2e56fee1afc1078774cc702c0f1fee9bae938))
|
||||
|
||||
## [1.0.4](https://github.com/stonith404/pingvin-share/compare/v1.0.3...v1.0.4) (2024-09-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* oauth2 login can fail in some cases because the user can't be found ([92e1e82](https://github.com/stonith404/pingvin-share/commit/92e1e82e095075edf04019887f9c2048c21d00d6))
|
||||
|
||||
## [1.0.3](https://github.com/stonith404/pingvin-share/compare/v1.0.2...v1.0.3) (2024-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve oidc error logging ([dee7098](https://github.com/stonith404/pingvin-share/commit/dee70987eb74eda4a9ab7332522fa5540cee9761))
|
||||
|
||||
## [1.0.2](https://github.com/stonith404/pingvin-share/compare/v1.0.1...v1.0.2) (2024-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* default logo not displayed on fresh installations ([3e0735c](https://github.com/stonith404/pingvin-share/commit/3e0735c62079ac777fd08051b7e7602eebf74a5d))
|
||||
|
||||
## [1.0.1](https://github.com/stonith404/pingvin-share/compare/v1.0.0...v1.0.1) (2024-08-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **email:** add {email} placeholder to user invitation email ([#564](https://github.com/stonith404/pingvin-share/issues/564)) ([8c5c696](https://github.com/stonith404/pingvin-share/commit/8c5c696c514a5fb450462184240b21553d7f1532))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **translations:** add missing string for ldap group ([64efac5](https://github.com/stonith404/pingvin-share/commit/64efac5b685bf2de9d65c6a4f8890d45afe6476d))
|
||||
|
||||
## [1.0.0](https://github.com/stonith404/pingvin-share/compare/v0.29.0...v1.0.0) (2024-08-25)
|
||||
|
||||
|
||||
|
||||
15
Caddyfile
15
Caddyfile
@@ -1,15 +0,0 @@
|
||||
:3000 {
|
||||
# Reverse proxy for /api
|
||||
reverse_proxy /api/* http://localhost:8080 {
|
||||
header_up X-Forwarded-Host {host}:{server_port}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
|
||||
# Reverse proxy for all other requests
|
||||
reverse_proxy http://localhost:3333 {
|
||||
header_up X-Forwarded-Host {host}:{server_port}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
}
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,25 +1,27 @@
|
||||
# Stage 1: Frontend dependencies
|
||||
FROM node:20-alpine AS frontend-dependencies
|
||||
FROM node:22-alpine AS frontend-dependencies
|
||||
WORKDIR /opt/app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /opt/app
|
||||
COPY ./frontend .
|
||||
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Backend dependencies
|
||||
FROM node:20-alpine AS backend-dependencies
|
||||
FROM node:22-alpine AS backend-dependencies
|
||||
RUN apk add --no-cache python3
|
||||
WORKDIR /opt/app
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 4: Build backend
|
||||
FROM node:20-alpine AS backend-builder
|
||||
FROM node:22-alpine AS backend-builder
|
||||
RUN apk add openssl
|
||||
|
||||
WORKDIR /opt/app
|
||||
COPY ./backend .
|
||||
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
|
||||
@@ -27,12 +29,15 @@ RUN npx prisma generate
|
||||
RUN npm run build && npm prune --production
|
||||
|
||||
# Stage 5: Final image
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:22-alpine AS runner
|
||||
ENV NODE_ENV=docker
|
||||
|
||||
# Delete default node user
|
||||
RUN deluser --remove-home node
|
||||
|
||||
RUN apk update --no-cache \
|
||||
&& apk upgrade --no-cache \
|
||||
&& apk add --no-cache curl caddy
|
||||
&& apk add --no-cache curl caddy su-exec openssl
|
||||
|
||||
WORKDIR /opt/app/frontend
|
||||
COPY --from=frontend-builder /opt/app/public ./public
|
||||
@@ -45,14 +50,16 @@ COPY --from=backend-builder /opt/app/node_modules ./node_modules
|
||||
COPY --from=backend-builder /opt/app/dist ./dist
|
||||
COPY --from=backend-builder /opt/app/prisma ./prisma
|
||||
COPY --from=backend-builder /opt/app/package.json ./
|
||||
|
||||
COPY ./Caddyfile /etc/caddy/Caddyfile
|
||||
COPY ./scripts/docker-entrypoint.sh /opt/app/docker-entrypoint.sh
|
||||
COPY --from=backend-builder /opt/app/tsconfig.json ./
|
||||
|
||||
WORKDIR /opt/app
|
||||
|
||||
COPY ./reverse-proxy /opt/app/reverse-proxy
|
||||
COPY ./scripts/docker ./scripts/docker
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
|
||||
HEALTHCHECK --interval=10s --timeout=3s CMD /bin/sh -c '(if [[ "$CADDY_DISABLED" = "true" ]]; then curl -fs http://localhost:${BACKEND_PORT:-8080}/api/health; else curl -fs http://localhost:3000/api/health; fi) || exit 1'
|
||||
|
||||
CMD ["sh", "/opt/app/docker-entrypoint.sh"]
|
||||
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||
20
README.md
20
README.md
@@ -1,10 +1,18 @@
|
||||
> ## ⚠️ Project Archived
|
||||
>
|
||||
> After much consideration, I've chosen to focus my limited time and energy on my other project, [Pocket ID](https://github.com/pocket-id/pocket-id). As a solo developer, I've found it difficult to actively maintain multiple open source projects with the care and attention they deserve.
|
||||
>
|
||||
> If you're interested in continuing this work through a fork, I'd be happy to link to it here in the README.
|
||||
>
|
||||
> Thanks to all the contributors and users who have supported Pingvin Share over the years :)
|
||||
|
||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||
|
||||
[](https://discord.gg/wHRQ9nFRcK) [](https://crowdin.com/project/pingvin-share) [](https://github.com/sponsors/stonith404)
|
||||
|
||||
---
|
||||
|
||||
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
||||
Pingvin Share is a self-hosted file sharing platform and an alternative for WeTransfer.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -13,7 +21,10 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
||||
- Set an expiration date for shares
|
||||
- Secure shares with visitor limits and passwords
|
||||
- Email recipients
|
||||
- Reverse shares
|
||||
- OIDC and LDAP authentication
|
||||
- Integration with ClamAV for security scans
|
||||
- Different file providers: local storage and S3
|
||||
|
||||
## 🐧 Get to know Pingvin Share
|
||||
|
||||
@@ -31,6 +42,13 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
||||
|
||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
> [!TIP]
|
||||
> Checkout [Pocket ID](https://github.com/stonith404/pocket-id), a user-friendly OIDC provider that lets you easily log in to services like Pingvin Share using Passkeys.
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
For more installation options and advanced configurations, please refer to the [documentation](https://stonith404.github.io/pingvin-share).
|
||||
|
||||
## 🖤 Contribute
|
||||
|
||||
We would love it if you want to help make Pingvin Share better! You can either [help to translate](https://stonith404.github.io/pingvin-share/help-out/translate) Pingvin Share or [contribute to the codebase](https://stonith404.github.io/pingvin-share/help-out/contribute).
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
As Pingvin Share is in beta, older versions don't get security updates. Please consider to update Pingvin Share regularly. Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower).
|
||||
|
||||
Older versions of Pingvin Share do not receive security updates. To ensure your system remains secure, we strongly recommend updating Pingvin Share regularly. You can automate these updates using tools like [Watchtower](https://github.com/containrrr/watchtower).
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please write an email to [elias@eliasschneider.com](mailto:elias@eliasschneider.com).
|
||||
|
||||
8063
backend/package-lock.json
generated
8063
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.13.0",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
@@ -13,75 +13,80 @@
|
||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^2.2.2",
|
||||
"@nestjs/common": "^10.3.9",
|
||||
"@nestjs/config": "^3.2.2",
|
||||
"@nestjs/core": "^10.3.9",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.9",
|
||||
"@nestjs/schedule": "^4.0.2",
|
||||
"@nestjs/swagger": "^7.3.1",
|
||||
"@nestjs/throttler": "^5.2.0",
|
||||
"@prisma/client": "^5.16.1",
|
||||
"@aws-sdk/client-s3": "^3.787.0",
|
||||
"@keyv/redis": "^4.4.0",
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
"@nestjs/common": "^11.0.17",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.17",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.17",
|
||||
"@nestjs/schedule": "^5.0.1",
|
||||
"@nestjs/swagger": "^11.1.3",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@prisma/client": "^6.6.0",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"@types/ldapjs": "^3.0.6",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.40.3",
|
||||
"body-parser": "^1.20.2",
|
||||
"cache-manager": "^5.6.1",
|
||||
"clamscan": "^2.2.1",
|
||||
"argon2": "^0.41.1",
|
||||
"body-parser": "^2.2.0",
|
||||
"cache-manager": "^6.4.2",
|
||||
"cacheable": "^1.9.0",
|
||||
"clamscan": "^2.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"jmespath": "^0.16.0",
|
||||
"ldapjs": "^3.0.7",
|
||||
"mime-types": "^2.1.35",
|
||||
"ldapts": "^7.4.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"nanoid": "^3.3.7",
|
||||
"nodemailer": "^6.9.14",
|
||||
"nodemailer": "^6.10.1",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"qrcode-svg": "^1.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rimraf": "^5.0.7",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.4",
|
||||
"ts-node": "^10.9.2"
|
||||
"rimraf": "^6.0.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"sharp": "^0.34.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"uuid": "^11.1.0",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.2",
|
||||
"@nestjs/schematics": "^10.1.1",
|
||||
"@nestjs/testing": "^10.3.9",
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/clamscan": "^2.0.8",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"@nestjs/cli": "^11.0.6",
|
||||
"@nestjs/schematics": "^11.0.5",
|
||||
"@nestjs/testing": "^11.0.17",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/clamscan": "^2.4.1",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cron": "^2.4.0",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode-svg": "^1.1.4",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.14.1",
|
||||
"@types/qrcode-svg": "^1.1.5",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.29.1",
|
||||
"@typescript-eslint/parser": "^8.29.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"newman": "^6.1.3",
|
||||
"prettier": "^3.3.2",
|
||||
"prisma": "^5.16.1",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"newman": "^6.2.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prisma": "^6.6.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-loader": "^9.5.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "^5.5.2",
|
||||
"wait-on": "^7.2.0"
|
||||
"typescript": "^5.8.3",
|
||||
"wait-on": "^8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "RefreshToken" ADD COLUMN "oauthIDToken" TEXT;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Share" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"name" TEXT,
|
||||
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiration" DATETIME NOT NULL,
|
||||
"description" TEXT,
|
||||
"removedReason" TEXT,
|
||||
"creatorId" TEXT,
|
||||
"reverseShareId" TEXT,
|
||||
"storageProvider" TEXT NOT NULL DEFAULT 'LOCAL',
|
||||
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Share_reverseShareId_fkey" FOREIGN KEY ("reverseShareId") REFERENCES "ReverseShare" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Share" ("createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "name", "removedReason", "reverseShareId", "uploadLocked", "views") SELECT "createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "name", "removedReason", "reverseShareId", "uploadLocked", "views" FROM "Share";
|
||||
DROP TABLE "Share";
|
||||
ALTER TABLE "new_Share" RENAME TO "Share";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE Config SET `value` = `value` || ' hours' WHERE name = "maxExpiration" OR name = "sessionDuration";
|
||||
@@ -40,6 +40,8 @@ model RefreshToken {
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
oauthIDToken String? // prefixed with the ID of the issuing OAuth provider, separated by a colon
|
||||
}
|
||||
|
||||
model LoginToken {
|
||||
@@ -93,6 +95,7 @@ model Share {
|
||||
security ShareSecurity?
|
||||
recipients ShareRecipient[]
|
||||
files File[]
|
||||
storageProvider String @default("LOCAL")
|
||||
}
|
||||
|
||||
model ReverseShare {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
const configVariables: ConfigVariables = {
|
||||
export const configVariables = {
|
||||
internal: {
|
||||
jwtSecret: {
|
||||
type: "string",
|
||||
@@ -20,14 +20,18 @@ const configVariables: ConfigVariables = {
|
||||
defaultValue: "http://localhost:3000",
|
||||
secret: false,
|
||||
},
|
||||
secureCookies: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
showHomePage: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
secret: false,
|
||||
},
|
||||
sessionDuration: {
|
||||
type: "number",
|
||||
defaultValue: "2160",
|
||||
type: "timespan",
|
||||
defaultValue: "3 months",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
@@ -43,12 +47,17 @@ const configVariables: ConfigVariables = {
|
||||
secret: false,
|
||||
},
|
||||
maxExpiration: {
|
||||
type: "timespan",
|
||||
defaultValue: "0 days",
|
||||
secret: false,
|
||||
},
|
||||
shareIdLength: {
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
defaultValue: "8",
|
||||
secret: false,
|
||||
},
|
||||
maxSize: {
|
||||
type: "number",
|
||||
type: "filesize",
|
||||
defaultValue: "1000000000",
|
||||
secret: false,
|
||||
},
|
||||
@@ -57,7 +66,7 @@ const configVariables: ConfigVariables = {
|
||||
defaultValue: "9",
|
||||
},
|
||||
chunkSize: {
|
||||
type: "number",
|
||||
type: "filesize",
|
||||
defaultValue: "10000000",
|
||||
secret: false,
|
||||
},
|
||||
@@ -67,6 +76,25 @@ const configVariables: ConfigVariables = {
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
"redis-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"redis-url": {
|
||||
type: "string",
|
||||
defaultValue: "redis://pingvin-redis:6379",
|
||||
secret: true,
|
||||
},
|
||||
ttl: {
|
||||
type: "number",
|
||||
defaultValue: "60",
|
||||
},
|
||||
maxItems: {
|
||||
type: "number",
|
||||
defaultValue: "1000",
|
||||
},
|
||||
},
|
||||
email: {
|
||||
enableShareEmailRecipients: {
|
||||
type: "boolean",
|
||||
@@ -80,7 +108,7 @@ const configVariables: ConfigVariables = {
|
||||
shareRecipientsMessage: {
|
||||
type: "text",
|
||||
defaultValue:
|
||||
"Hey!\n\n{creator} shared some files with you, view or download the files with this link: {shareUrl}\n\nThe share will expire {expires}.\n\nNote: {desc}\n\nShared securely with Pingvin Share 🐧",
|
||||
"Hey!\n\n{creator} ({creatorEmail}) shared some files with you. You can view or download the files with this link: {shareUrl}\n\nThe share will expire {expires}.\n\nNote: {desc}\n\nShared securely with Pingvin Share 🐧",
|
||||
},
|
||||
reverseShareSubject: {
|
||||
type: "string",
|
||||
@@ -98,7 +126,7 @@ const configVariables: ConfigVariables = {
|
||||
resetPasswordMessage: {
|
||||
type: "text",
|
||||
defaultValue:
|
||||
"Hey!\n\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\n\nPingvin Share 🐧",
|
||||
"Hey!\n\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in an hour.\n\nPingvin Share 🐧",
|
||||
},
|
||||
inviteSubject: {
|
||||
type: "string",
|
||||
@@ -107,7 +135,7 @@ const configVariables: ConfigVariables = {
|
||||
inviteMessage: {
|
||||
type: "text",
|
||||
defaultValue:
|
||||
"Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYour password is: {password}\n\nPingvin Share 🐧",
|
||||
'Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYou can use the email "{email}" and the password "{password}" to sign in.\n\nPingvin Share 🐧',
|
||||
},
|
||||
},
|
||||
smtp: {
|
||||
@@ -172,24 +200,33 @@ const configVariables: ConfigVariables = {
|
||||
},
|
||||
searchQuery: {
|
||||
type: "string",
|
||||
defaultValue: ""
|
||||
defaultValue: "",
|
||||
},
|
||||
|
||||
adminGroups: {
|
||||
type: "string",
|
||||
defaultValue: ""
|
||||
}
|
||||
defaultValue: "",
|
||||
},
|
||||
|
||||
fieldNameMemberOf: {
|
||||
type: "string",
|
||||
defaultValue: "memberOf",
|
||||
},
|
||||
fieldNameEmail: {
|
||||
type: "string",
|
||||
defaultValue: "userPrincipalName",
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
"allowRegistration": {
|
||||
allowRegistration: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
},
|
||||
"ignoreTotp": {
|
||||
ignoreTotp: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
},
|
||||
"disablePassword": {
|
||||
disablePassword: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
@@ -245,6 +282,10 @@ const configVariables: ConfigVariables = {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"discord-limitedUsers": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"discord-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
@@ -262,6 +303,14 @@ const configVariables: ConfigVariables = {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-signOut": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"oidc-scope": {
|
||||
type: "string",
|
||||
defaultValue: "openid email profile",
|
||||
},
|
||||
"oidc-usernameClaim": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
@@ -288,6 +337,84 @@ const configVariables: ConfigVariables = {
|
||||
obscured: true,
|
||||
},
|
||||
},
|
||||
s3: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
endpoint: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
bucketName: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
bucketPath: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
key: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
secret: true,
|
||||
},
|
||||
secret: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
useChecksum: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
},
|
||||
},
|
||||
legal: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
imprintText: {
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
imprintUrl: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
privacyPolicyText: {
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
privacyPolicyUrl: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
} satisfies ConfigVariables;
|
||||
|
||||
export type YamlConfig = {
|
||||
[Category in keyof typeof configVariables]: {
|
||||
[Key in keyof (typeof configVariables)[Category]]: string;
|
||||
};
|
||||
} & {
|
||||
initUser: {
|
||||
enabled: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
isAdmin: boolean;
|
||||
ldapDN: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConfigVariables = {
|
||||
@@ -311,11 +438,11 @@ const prisma = new PrismaClient({
|
||||
|
||||
async function seedConfigVariables() {
|
||||
for (const [category, configVariablesOfCategory] of Object.entries(
|
||||
configVariables
|
||||
configVariables,
|
||||
)) {
|
||||
let order = 0;
|
||||
for (const [name, properties] of Object.entries(
|
||||
configVariablesOfCategory
|
||||
configVariablesOfCategory,
|
||||
)) {
|
||||
const existingConfigVariable = await prisma.config.findUnique({
|
||||
where: { name_category: { name, category } },
|
||||
@@ -344,7 +471,7 @@ async function migrateConfigVariables() {
|
||||
for (const existingConfigVariable of existingConfigVariables) {
|
||||
const configVariable =
|
||||
configVariables[existingConfigVariable.category]?.[
|
||||
existingConfigVariable.name
|
||||
existingConfigVariable.name
|
||||
];
|
||||
|
||||
// Delete the config variable if it doesn't exist in the seed
|
||||
@@ -361,7 +488,7 @@ async function migrateConfigVariables() {
|
||||
// Update the config variable if it exists in the seed
|
||||
} else {
|
||||
const variableOrder = Object.keys(
|
||||
configVariables[existingConfigVariable.category]
|
||||
configVariables[existingConfigVariable.category],
|
||||
).indexOf(existingConfigVariable.name);
|
||||
await prisma.config.update({
|
||||
where: {
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Module } from "@nestjs/common";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
|
||||
import { CacheModule } from "@nestjs/cache-manager";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { AppCacheModule } from "./cache/cache.module";
|
||||
import { AppController } from "./app.controller";
|
||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||
import { ConfigModule } from "./config/config.module";
|
||||
@@ -20,12 +20,12 @@ import { UserModule } from "./user/user.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
AuthModule,
|
||||
ShareModule,
|
||||
FileModule,
|
||||
EmailModule,
|
||||
PrismaModule,
|
||||
ConfigModule,
|
||||
JobsModule,
|
||||
UserModule,
|
||||
ThrottlerModule.forRoot([
|
||||
@@ -38,9 +38,7 @@ import { UserModule } from "./user/user.module";
|
||||
ClamScanModule,
|
||||
ReverseShareModule,
|
||||
OAuthModule,
|
||||
CacheModule.register({
|
||||
isGlobal: true,
|
||||
}),
|
||||
AppCacheModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -120,7 +120,7 @@ export class AuthController {
|
||||
})
|
||||
@HttpCode(202)
|
||||
async requestResetPassword(@Param("email") email: string) {
|
||||
this.authService.requestResetPassword(email);
|
||||
await this.authService.requestResetPassword(email);
|
||||
}
|
||||
|
||||
@Post("resetPassword")
|
||||
@@ -172,13 +172,25 @@ export class AuthController {
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
await this.authService.signOut(request.cookies.access_token);
|
||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||
const redirectURI = await this.authService.signOut(
|
||||
request.cookies.access_token,
|
||||
);
|
||||
|
||||
const isSecure = this.config.get("general.secureCookies");
|
||||
response.cookie("access_token", "", {
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
response.cookie("refresh_token", "", {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
|
||||
if (typeof redirectURI === "string") {
|
||||
return { redirectURI: redirectURI.toString() };
|
||||
}
|
||||
}
|
||||
|
||||
@Post("totp/enable")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { AuthController } from "./auth.controller";
|
||||
@@ -7,6 +7,7 @@ import { AuthTotpService } from "./authTotp.service";
|
||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||
import { LdapService } from "./ldap.service";
|
||||
import { UserModule } from "../user/user.module";
|
||||
import { OAuthModule } from "../oauth/oauth.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -14,10 +15,11 @@ import { UserModule } from "../user/user.module";
|
||||
global: true,
|
||||
}),
|
||||
EmailModule,
|
||||
forwardRef(() => OAuthModule),
|
||||
UserModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy, LdapService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule { }
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
@@ -14,11 +16,12 @@ import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { OAuthService } from "../oauth/oauth.service";
|
||||
import { GenericOidcProvider } from "../oauth/provider/genericOidc.provider";
|
||||
import { UserSevice } from "../user/user.service";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
import { LdapService } from "./ldap.service";
|
||||
import { inspect } from "util";
|
||||
import { UserSevice } from "../user/user.service";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -29,7 +32,8 @@ export class AuthService {
|
||||
private emailService: EmailService,
|
||||
private ldapService: LdapService,
|
||||
private userService: UserSevice,
|
||||
) { }
|
||||
@Inject(forwardRef(() => OAuthService)) private oAuthService: OAuthService,
|
||||
) {}
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
async signUp(dto: AuthRegisterDTO, ip: string, isAdmin?: boolean) {
|
||||
@@ -66,8 +70,9 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async signIn(dto: AuthSignInDTO, ip: string) {
|
||||
if (!dto.email && !dto.username)
|
||||
if (!dto.email && !dto.username) {
|
||||
throw new BadRequestException("Email or username is required");
|
||||
}
|
||||
|
||||
if (!this.config.get("oauth.disablePassword")) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
@@ -76,18 +81,32 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.password && await argon.verify(user.password, dto.password)) {
|
||||
this.logger.log(`Successful password login for user ${user.email} from IP ${ip}`);
|
||||
if (user?.password && (await argon.verify(user.password, dto.password))) {
|
||||
this.logger.log(
|
||||
`Successful password login for user ${user.email} from IP ${ip}`,
|
||||
);
|
||||
return this.generateToken(user);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.get("ldap.enabled")) {
|
||||
this.logger.debug(`Trying LDAP login for user ${dto.username}`);
|
||||
const ldapUser = await this.ldapService.authenticateUser(dto.username, dto.password);
|
||||
/*
|
||||
* E-mail-like user credentials are passed as the email property
|
||||
* instead of the username. Since the username format does not matter
|
||||
* when searching for users in LDAP, we simply use the username
|
||||
* in whatever format it is provided.
|
||||
*/
|
||||
const ldapUsername = dto.username || dto.email;
|
||||
this.logger.debug(`Trying LDAP login for user ${ldapUsername}`);
|
||||
const ldapUser = await this.ldapService.authenticateUser(
|
||||
ldapUsername,
|
||||
dto.password,
|
||||
);
|
||||
if (ldapUser) {
|
||||
const user = await this.userService.findOrCreateFromLDAP(dto.username, ldapUser);
|
||||
this.logger.log(`Successful LDAP login for user ${user.email} from IP ${ip}`);
|
||||
const user = await this.userService.findOrCreateFromLDAP(dto, ldapUser);
|
||||
this.logger.log(
|
||||
`Successful LDAP login for user ${ldapUsername} (${user.id}) from IP ${ip}`,
|
||||
);
|
||||
return this.generateToken(user);
|
||||
}
|
||||
}
|
||||
@@ -98,13 +117,10 @@ export class AuthService {
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
}
|
||||
|
||||
async generateToken(user: User, isOAuth = false) {
|
||||
async generateToken(user: User, oauth?: { idToken?: string }) {
|
||||
// TODO: Make all old loginTokens invalid when a new one is created
|
||||
// Check if the user has TOTP enabled
|
||||
if (
|
||||
user.totpVerified &&
|
||||
!(isOAuth && this.config.get("oauth.ignoreTotp"))
|
||||
) {
|
||||
if (user.totpVerified && !(oauth && this.config.get("oauth.ignoreTotp"))) {
|
||||
const loginToken = await this.createLoginToken(user.id);
|
||||
|
||||
return { loginToken };
|
||||
@@ -112,6 +128,7 @@ export class AuthService {
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id,
|
||||
oauth?.idToken,
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
@@ -129,6 +146,15 @@ export class AuthService {
|
||||
|
||||
if (!user) return;
|
||||
|
||||
if (user.ldapDN) {
|
||||
this.logger.log(
|
||||
`Failed password reset request for user ${email} because it is an LDAP user`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"This account can't reset its password here. Please contact your administrator.",
|
||||
);
|
||||
}
|
||||
|
||||
// Delete old reset password token
|
||||
if (user.resetPasswordToken) {
|
||||
await this.prisma.resetPasswordToken.delete({
|
||||
@@ -143,7 +169,7 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.emailService.sendResetPasswordEmail(user.email, token);
|
||||
this.emailService.sendResetPasswordEmail(user.email, token);
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
@@ -210,12 +236,54 @@ export class AuthService {
|
||||
}) || {};
|
||||
|
||||
if (refreshTokenId) {
|
||||
const oauthIDToken = await this.prisma.refreshToken
|
||||
.findFirst({
|
||||
select: { oauthIDToken: true },
|
||||
where: { id: refreshTokenId },
|
||||
})
|
||||
.then((refreshToken) => refreshToken?.oauthIDToken)
|
||||
.catch((e) => {
|
||||
// Ignore error if refresh token doesn't exist
|
||||
if (e.code != "P2025") throw e;
|
||||
});
|
||||
await this.prisma.refreshToken
|
||||
.delete({ where: { id: refreshTokenId } })
|
||||
.catch((e) => {
|
||||
// Ignore error if refresh token doesn't exist
|
||||
if (e.code != "P2025") throw e;
|
||||
});
|
||||
|
||||
if (typeof oauthIDToken === "string") {
|
||||
const [providerName, idTokenHint] = oauthIDToken.split(":");
|
||||
const provider = this.oAuthService.availableProviders()[providerName];
|
||||
let signOutFromProviderSupportedAndActivated = false;
|
||||
try {
|
||||
signOutFromProviderSupportedAndActivated = this.config.get(
|
||||
`oauth.${providerName}-signOut`,
|
||||
);
|
||||
} catch (_) {
|
||||
// Ignore error if the provider is not supported or if the provider sign out is not activated
|
||||
}
|
||||
if (
|
||||
provider instanceof GenericOidcProvider &&
|
||||
signOutFromProviderSupportedAndActivated
|
||||
) {
|
||||
const configuration = await provider.getConfiguration();
|
||||
if (URL.canParse(configuration.end_session_endpoint)) {
|
||||
const redirectURI = new URL(configuration.end_session_endpoint);
|
||||
redirectURI.searchParams.append(
|
||||
"post_logout_redirect_uri",
|
||||
this.config.get("general.appUrl"),
|
||||
);
|
||||
redirectURI.searchParams.append("id_token_hint", idTokenHint);
|
||||
redirectURI.searchParams.append(
|
||||
"client_id",
|
||||
this.config.get(`oauth.${providerName}-clientId`),
|
||||
);
|
||||
return redirectURI.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,13 +302,15 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
async createRefreshToken(userId: string) {
|
||||
async createRefreshToken(userId: string, idToken?: string) {
|
||||
const sessionDuration = this.config.get("general.sessionDuration");
|
||||
const { id, token } = await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId,
|
||||
expiresAt: moment()
|
||||
.add(this.config.get("general.sessionDuration"), "hours")
|
||||
.add(sessionDuration.value, sessionDuration.unit)
|
||||
.toDate(),
|
||||
oauthIDToken: idToken,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -262,18 +332,27 @@ export class AuthService {
|
||||
refreshToken?: string,
|
||||
accessToken?: string,
|
||||
) {
|
||||
const isSecure = this.config.get("general.secureCookies");
|
||||
if (accessToken)
|
||||
response.cookie("access_token", accessToken, {
|
||||
sameSite: "lax",
|
||||
secure: isSecure,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
|
||||
});
|
||||
if (refreshToken)
|
||||
if (refreshToken) {
|
||||
const now = moment();
|
||||
const sessionDuration = this.config.get("general.sessionDuration");
|
||||
const maxAge = moment(now)
|
||||
.add(sessionDuration.value, sessionDuration.unit)
|
||||
.diff(now);
|
||||
response.cookie("refresh_token", refreshToken, {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
maxAge: 1000 * 60 * 60 * this.config.get("general.sessionDuration"),
|
||||
secure: isSecure,
|
||||
maxAge,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,4 +372,12 @@ export class AuthService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPassword(user: User, password: string) {
|
||||
if (!user.password && this.config.get("ldap.enabled")) {
|
||||
return !!this.ldapService.authenticateUser(user.username, password);
|
||||
}
|
||||
|
||||
return argon.verify(user.password, password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import * as argon from "argon2";
|
||||
import { authenticator, totp } from "otplib";
|
||||
import * as qrcode from "qrcode-svg";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
@@ -16,6 +16,7 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
export class AuthTotpService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
@@ -63,7 +64,7 @@ export class AuthTotpService {
|
||||
}
|
||||
|
||||
async enableTotp(user: User, password: string) {
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
// Check if we have a secret already
|
||||
@@ -76,13 +77,10 @@ export class AuthTotpService {
|
||||
throw new BadRequestException("TOTP is already enabled");
|
||||
}
|
||||
|
||||
const issuer = this.configService.get("general.appName");
|
||||
const secret = authenticator.generateSecret();
|
||||
|
||||
const otpURL = totp.keyuri(
|
||||
user.username || user.email,
|
||||
"pingvin-share",
|
||||
secret,
|
||||
);
|
||||
const otpURL = totp.keyuri(user.username || user.email, issuer, secret);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
@@ -107,9 +105,8 @@ export class AuthTotpService {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Maybe require a token to verify that the user who started enabling totp is the one who is verifying it?
|
||||
async verifyTotp(user: User, password: string, code: string) {
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
@@ -138,7 +135,7 @@ export class AuthTotpService {
|
||||
}
|
||||
|
||||
async disableTotp(user: User, password: string, code: string) {
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class AuthSignInDTO extends PickType(UserDTO, ["password"] as const) {
|
||||
export class AuthSignInDTO {
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email: string;
|
||||
@@ -10,4 +8,7 @@ export class AuthSignInDTO extends PickType(UserDTO, ["password"] as const) {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}
|
||||
export class EnableTotpDTO {
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -1,154 +1,105 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import * as ldap from "ldapjs";
|
||||
import { AttributeJson, InvalidCredentialsError, SearchCallbackResponse, SearchOptions } from "ldapjs";
|
||||
import { inspect } from "node:util";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
|
||||
type LdapSearchEntry = {
|
||||
objectName: string,
|
||||
attributes: AttributeJson[],
|
||||
};
|
||||
|
||||
async function ldapExecuteSearch(client: ldap.Client, base: string, options: SearchOptions): Promise<LdapSearchEntry[]> {
|
||||
const searchResponse = await new Promise<SearchCallbackResponse>((resolve, reject) => {
|
||||
client.search(base, options, (err, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(res);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return await new Promise<any[]>((resolve, reject) => {
|
||||
const entries: LdapSearchEntry[] = [];
|
||||
searchResponse.on("searchEntry", entry => entries.push({ attributes: entry.pojo.attributes, objectName: entry.pojo.objectName }));
|
||||
searchResponse.once("error", reject);
|
||||
searchResponse.once("end", () => resolve(entries));
|
||||
});
|
||||
}
|
||||
|
||||
async function ldapBindUser(client: ldap.Client, dn: string, password: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
client.bind(dn, password, error => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
async function ldapCreateConnection(logger: Logger, url: string): Promise<ldap.Client> {
|
||||
const ldapClient = ldap.createClient({
|
||||
url: url.split(","),
|
||||
connectTimeout: 10_000,
|
||||
timeout: 10_000
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
ldapClient.once("error", reject);
|
||||
ldapClient.on("setupError", reject);
|
||||
ldapClient.on("socketTimeout", reject);
|
||||
ldapClient.on("connectRefused", () => reject(new Error("connection has been refused")));
|
||||
ldapClient.on("connectTimeout", () => reject(new Error("connect timed out")));
|
||||
ldapClient.on("connectError", reject);
|
||||
|
||||
ldapClient.on("connect", resolve);
|
||||
}).catch(error => {
|
||||
logger.error(`Connect error: ${inspect(error)}`);
|
||||
ldapClient.destroy();
|
||||
throw error;
|
||||
});
|
||||
|
||||
return ldapClient;
|
||||
}
|
||||
|
||||
export type LdapAuthenticateResult = {
|
||||
userDn: string,
|
||||
attributes: Record<string, string[]>
|
||||
};
|
||||
import { Client, Entry, InvalidCredentialsError } from "ldapts";
|
||||
|
||||
@Injectable()
|
||||
export class LdapService {
|
||||
private readonly logger = new Logger(LdapService.name);
|
||||
constructor(
|
||||
@Inject(ConfigService)
|
||||
private readonly serviceConfig: ConfigService,
|
||||
) { }
|
||||
private readonly logger = new Logger(LdapService.name);
|
||||
constructor(
|
||||
@Inject(ConfigService)
|
||||
private readonly serviceConfig: ConfigService,
|
||||
) {}
|
||||
|
||||
private async createLdapConnection(): Promise<ldap.Client> {
|
||||
const ldapUrl = this.serviceConfig.get("ldap.url");
|
||||
if (!ldapUrl) {
|
||||
throw new Error("LDAP server URL is not defined");
|
||||
}
|
||||
|
||||
const ldapClient = await ldapCreateConnection(this.logger, ldapUrl);
|
||||
try {
|
||||
const bindDn = this.serviceConfig.get("ldap.bindDn") || null;
|
||||
if (bindDn) {
|
||||
try {
|
||||
await ldapBindUser(ldapClient, bindDn, this.serviceConfig.get("ldap.bindPassword"))
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to bind to default user: ${error}`);
|
||||
throw new Error("failed to bind to default user");
|
||||
}
|
||||
}
|
||||
|
||||
return ldapClient;
|
||||
} catch (error) {
|
||||
ldapClient.destroy();
|
||||
throw error;
|
||||
}
|
||||
private async createLdapConnection(): Promise<Client> {
|
||||
const ldapUrl = this.serviceConfig.get("ldap.url");
|
||||
if (!ldapUrl) {
|
||||
throw new Error("LDAP server URL is not defined");
|
||||
}
|
||||
|
||||
public async authenticateUser(username: string, password: string): Promise<LdapAuthenticateResult | null> {
|
||||
if (!username.match(/^[a-zA-Z0-0]+$/)) {
|
||||
return null;
|
||||
}
|
||||
const ldapClient = new Client({
|
||||
url: ldapUrl,
|
||||
timeout: 15_000,
|
||||
connectTimeout: 15_000,
|
||||
});
|
||||
|
||||
const searchBase = this.serviceConfig.get("ldap.searchBase");
|
||||
const searchQuery = this.serviceConfig.get("ldap.searchQuery")
|
||||
.replaceAll("%username%", username);
|
||||
|
||||
const ldapClient = await this.createLdapConnection();
|
||||
try {
|
||||
const [result] = await ldapExecuteSearch(ldapClient, searchBase, {
|
||||
filter: searchQuery,
|
||||
scope: "sub"
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
/* user not found */
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await ldapBindUser(ldapClient, result.objectName, password);
|
||||
|
||||
/*
|
||||
* In theory we could query the user attributes now,
|
||||
* but as we must query the user attributes for validation anyways
|
||||
* we'll create a second ldap server connection.
|
||||
*/
|
||||
return {
|
||||
userDn: result.objectName,
|
||||
attributes: Object.fromEntries(result.attributes.map(attribute => [attribute.type, attribute.values])),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidCredentialsError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.warn(`LDAP user bind failure: ${inspect(error)}`);
|
||||
return null;
|
||||
} finally {
|
||||
ldapClient.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`LDAP connect error: ${inspect(error)}`);
|
||||
return null;
|
||||
}
|
||||
const bindDn = this.serviceConfig.get("ldap.bindDn") || null;
|
||||
if (bindDn) {
|
||||
try {
|
||||
await ldapClient.bind(
|
||||
bindDn,
|
||||
this.serviceConfig.get("ldap.bindPassword"),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to bind to default user: ${error}`);
|
||||
throw new Error("failed to bind to default user");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ldapClient;
|
||||
}
|
||||
|
||||
public async authenticateUser(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Entry | null> {
|
||||
if (!username.match(/^[a-zA-Z0-9-_.@]+$/)) {
|
||||
this.logger.verbose(
|
||||
`Username ${username} does not match username pattern. Authentication failed.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchBase = this.serviceConfig.get("ldap.searchBase");
|
||||
const searchQuery = this.serviceConfig
|
||||
.get("ldap.searchQuery")
|
||||
.replaceAll("%username%", username);
|
||||
|
||||
const ldapClient = await this.createLdapConnection();
|
||||
try {
|
||||
const { searchEntries } = await ldapClient.search(searchBase, {
|
||||
filter: searchQuery,
|
||||
scope: "sub",
|
||||
|
||||
attributes: ["*"],
|
||||
returnAttributeValues: true,
|
||||
});
|
||||
|
||||
if (searchEntries.length > 1) {
|
||||
/* too many users found */
|
||||
this.logger.verbose(
|
||||
`Authentication for username ${username} failed. Too many users found with query ${searchQuery}`,
|
||||
);
|
||||
return null;
|
||||
} else if (searchEntries.length == 0) {
|
||||
/* user not found */
|
||||
this.logger.verbose(
|
||||
`Authentication for username ${username} failed. No user found with query ${searchQuery}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetEntity = searchEntries[0];
|
||||
this.logger.verbose(
|
||||
`Trying to authenticate ${username} against LDAP user ${targetEntity.dn}`,
|
||||
);
|
||||
try {
|
||||
await ldapClient.bind(targetEntity.dn, password);
|
||||
return targetEntity;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidCredentialsError) {
|
||||
this.logger.verbose(
|
||||
`Failed to authenticate ${username} against ${targetEntity.dn}. Invalid credentials.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.warn(`User bind failure: ${inspect(error)}`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Connect error: ${inspect(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
backend/src/cache/cache.module.ts
vendored
Normal file
41
backend/src/cache/cache.module.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CacheModule } from "@nestjs/cache-manager";
|
||||
import { CacheableMemory } from "cacheable";
|
||||
import { createKeyv } from "@keyv/redis";
|
||||
import { Keyv } from "keyv";
|
||||
import { ConfigModule } from "src/config/config.module";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
CacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
const useRedis = configService.get("cache.redis-enabled");
|
||||
const ttl = configService.get("cache.ttl");
|
||||
const max = configService.get("cache.maxItems");
|
||||
|
||||
let config = {
|
||||
ttl,
|
||||
max,
|
||||
stores: [],
|
||||
};
|
||||
|
||||
if (useRedis) {
|
||||
const redisUrl = configService.get("cache.redis-url");
|
||||
config.stores = [
|
||||
new Keyv({ store: new CacheableMemory({ ttl, lruSize: 5000 }) }),
|
||||
createKeyv(redisUrl),
|
||||
];
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [CacheModule],
|
||||
})
|
||||
export class AppCacheModule {}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { Config } from "@prisma/client";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ConfigController } from "./config.controller";
|
||||
@@ -16,7 +17,15 @@ import { LogoService } from "./logo.service";
|
||||
},
|
||||
inject: [PrismaService],
|
||||
},
|
||||
ConfigService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useFactory: async (prisma: PrismaService, configVariables: Config[]) => {
|
||||
const configService = new ConfigService(configVariables, prisma);
|
||||
await configService.initialize();
|
||||
return configService;
|
||||
},
|
||||
inject: [PrismaService, "CONFIG_VARIABLES"],
|
||||
},
|
||||
LogoService,
|
||||
],
|
||||
controllers: [ConfigController],
|
||||
|
||||
@@ -2,11 +2,18 @@ import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Config } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import * as argon from "argon2";
|
||||
import { EventEmitter } from "events";
|
||||
import * as fs from "fs";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { stringToTimespan } from "src/utils/date.util";
|
||||
import { parse as yamlParse } from "yaml";
|
||||
import { YamlConfig } from "../../prisma/seed/config.seed";
|
||||
import { CONFIG_FILE } from "src/constants";
|
||||
|
||||
/**
|
||||
* ConfigService extends EventEmitter to allow listening for config updates,
|
||||
@@ -14,6 +21,9 @@ import { EventEmitter } from "events";
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConfigService extends EventEmitter {
|
||||
yamlConfig?: YamlConfig;
|
||||
logger = new Logger(ConfigService.name);
|
||||
|
||||
constructor(
|
||||
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||
private prisma: PrismaService,
|
||||
@@ -21,6 +31,67 @@ export class ConfigService extends EventEmitter {
|
||||
super();
|
||||
}
|
||||
|
||||
// Initialize gets called by the ConfigModule
|
||||
async initialize() {
|
||||
await this.loadYamlConfig();
|
||||
|
||||
if (this.yamlConfig) {
|
||||
await this.migrateInitUser();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadYamlConfig() {
|
||||
let configFile: string = "";
|
||||
try {
|
||||
configFile = fs.readFileSync(CONFIG_FILE, "utf8");
|
||||
} catch (e) {
|
||||
this.logger.log(
|
||||
"Config.yaml is not set. Falling back to UI configuration.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
this.yamlConfig = yamlParse(configFile);
|
||||
|
||||
if (this.yamlConfig) {
|
||||
for (const configVariable of this.configVariables) {
|
||||
const category = this.yamlConfig[configVariable.category];
|
||||
if (!category) continue;
|
||||
configVariable.value = category[configVariable.name];
|
||||
this.emit("update", configVariable.name, configVariable.value);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
"Failed to parse config.yaml. Falling back to UI configuration: ",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateInitUser(): Promise<void> {
|
||||
if (!this.yamlConfig.initUser.enabled) return;
|
||||
|
||||
const userCount = await this.prisma.user.count({
|
||||
where: { isAdmin: true },
|
||||
});
|
||||
if (userCount === 1) {
|
||||
this.logger.log(
|
||||
"Skip initial user creation. Admin user is already existent.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.prisma.user.create({
|
||||
data: {
|
||||
email: this.yamlConfig.initUser.email,
|
||||
username: this.yamlConfig.initUser.username,
|
||||
password: this.yamlConfig.initUser.password
|
||||
? await argon.hash(this.yamlConfig.initUser.password)
|
||||
: null,
|
||||
isAdmin: this.yamlConfig.initUser.isAdmin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get(key: `${string}.${string}`): any {
|
||||
const configVariable = this.configVariables.filter(
|
||||
(variable) => `${variable.category}.${variable.name}` == key,
|
||||
@@ -30,31 +101,31 @@ export class ConfigService extends EventEmitter {
|
||||
|
||||
const value = configVariable.value ?? configVariable.defaultValue;
|
||||
|
||||
if (configVariable.type == "number") return parseInt(value);
|
||||
if (configVariable.type == "number" || configVariable.type == "filesize")
|
||||
return parseInt(value);
|
||||
if (configVariable.type == "boolean") return value == "true";
|
||||
if (configVariable.type == "string" || configVariable.type == "text")
|
||||
return value;
|
||||
if (configVariable.type == "timespan") return stringToTimespan(value);
|
||||
}
|
||||
|
||||
async getByCategory(category: string) {
|
||||
const configVariables = await this.prisma.config.findMany({
|
||||
orderBy: { order: "asc" },
|
||||
where: { category, locked: { equals: false } },
|
||||
});
|
||||
const configVariables = this.configVariables
|
||||
.filter((c) => !c.locked && category == c.category)
|
||||
.sort((c) => c.order);
|
||||
|
||||
return configVariables.map((variable) => {
|
||||
return {
|
||||
...variable,
|
||||
key: `${variable.category}.${variable.name}`,
|
||||
value: variable.value ?? variable.defaultValue,
|
||||
allowEdit: this.isEditAllowed(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async list() {
|
||||
const configVariables = await this.prisma.config.findMany({
|
||||
where: { secret: { equals: false } },
|
||||
});
|
||||
const configVariables = this.configVariables.filter((c) => !c.secret);
|
||||
|
||||
return configVariables.map((variable) => {
|
||||
return {
|
||||
@@ -66,6 +137,11 @@ export class ConfigService extends EventEmitter {
|
||||
}
|
||||
|
||||
async updateMany(data: { key: string; value: string | number | boolean }[]) {
|
||||
if (!this.isEditAllowed())
|
||||
throw new BadRequestException(
|
||||
"You are only allowed to update config variables via the config.yaml file",
|
||||
);
|
||||
|
||||
const response: Config[] = [];
|
||||
|
||||
for (const variable of data) {
|
||||
@@ -76,6 +152,11 @@ export class ConfigService extends EventEmitter {
|
||||
}
|
||||
|
||||
async update(key: string, value: string | number | boolean) {
|
||||
if (!this.isEditAllowed())
|
||||
throw new BadRequestException(
|
||||
"You are only allowed to update config variables via the config.yaml file",
|
||||
);
|
||||
|
||||
const configVariable = await this.prisma.config.findUnique({
|
||||
where: {
|
||||
name_category: {
|
||||
@@ -93,13 +174,16 @@ export class ConfigService extends EventEmitter {
|
||||
} else if (
|
||||
typeof value != configVariable.type &&
|
||||
typeof value == "string" &&
|
||||
configVariable.type != "text"
|
||||
configVariable.type != "text" &&
|
||||
configVariable.type != "timespan"
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Config variable must be of type ${configVariable.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.validateConfigVariable(key, value);
|
||||
|
||||
const updatedVariable = await this.prisma.config.update({
|
||||
where: {
|
||||
name_category: {
|
||||
@@ -116,4 +200,29 @@ export class ConfigService extends EventEmitter {
|
||||
|
||||
return updatedVariable;
|
||||
}
|
||||
|
||||
validateConfigVariable(key: string, value: string | number | boolean) {
|
||||
const validations = [
|
||||
{
|
||||
key: "share.shareIdLength",
|
||||
condition: (value: number) => value >= 2 && value <= 50,
|
||||
message: "Share ID length must be between 2 and 50",
|
||||
},
|
||||
{
|
||||
key: "share.zipCompressionLevel",
|
||||
condition: (value: number) => value >= 0 && value <= 9,
|
||||
message: "Zip compression level must be between 0 and 9",
|
||||
},
|
||||
// TODO add validation for timespan type
|
||||
];
|
||||
|
||||
const validation = validations.find((validation) => validation.key == key);
|
||||
if (validation && !validation.condition(value as any)) {
|
||||
throw new BadRequestException(validation.message);
|
||||
}
|
||||
}
|
||||
|
||||
isEditAllowed(): boolean {
|
||||
return this.yamlConfig === undefined || this.yamlConfig === null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ export class AdminConfigDTO extends ConfigDTO {
|
||||
@Expose()
|
||||
obscured: boolean;
|
||||
|
||||
@Expose()
|
||||
allowEdit: boolean;
|
||||
|
||||
from(partial: Partial<AdminConfigDTO>) {
|
||||
return plainToClass(AdminConfigDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { LogLevel } from "@nestjs/common";
|
||||
|
||||
export const CONFIG_FILE = process.env.CONFIG_FILE || "../config.yaml";
|
||||
|
||||
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
||||
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
|
||||
export const DATABASE_URL =
|
||||
@@ -7,3 +11,7 @@ export const CLAMAV_HOST =
|
||||
process.env.CLAMAV_HOST ||
|
||||
(process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
|
||||
export const CLAMAV_PORT = parseInt(process.env.CLAMAV_PORT) || 3310;
|
||||
|
||||
export const LOG_LEVEL_AVAILABLE: LogLevel[] = ['verbose', 'debug', 'log', 'warn', 'error', 'fatal'];
|
||||
export const LOG_LEVEL_DEFAULT: LogLevel = process.env.NODE_ENV === 'development' ? "verbose" : "log";
|
||||
export const LOG_LEVEL_ENV = `${process.env.PV_LOG_LEVEL || ""}`;
|
||||
@@ -17,14 +17,15 @@ export class EmailService {
|
||||
if (!this.config.get("smtp.enabled"))
|
||||
throw new InternalServerErrorException("SMTP is disabled");
|
||||
|
||||
const username = this.config.get("smtp.username");
|
||||
const password = this.config.get("smtp.password");
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: this.config.get("smtp.host"),
|
||||
port: this.config.get("smtp.port"),
|
||||
secure: this.config.get("smtp.port") == 465,
|
||||
auth: {
|
||||
user: this.config.get("smtp.username"),
|
||||
pass: this.config.get("smtp.password"),
|
||||
},
|
||||
auth:
|
||||
username || password ? { user: username, pass: password } : undefined,
|
||||
tls: {
|
||||
rejectUnauthorized: !this.config.get(
|
||||
"smtp.allowUnauthorizedCertificates",
|
||||
@@ -68,6 +69,7 @@ export class EmailService {
|
||||
.get("email.shareRecipientsMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{creator}", creator?.username ?? "Someone")
|
||||
.replaceAll("{creatorEmail}", creator?.email ?? "")
|
||||
.replaceAll("{shareUrl}", shareUrl)
|
||||
.replaceAll("{desc}", description ?? "No description")
|
||||
.replaceAll(
|
||||
@@ -116,7 +118,8 @@ export class EmailService {
|
||||
this.config
|
||||
.get("email.inviteMessage")
|
||||
.replaceAll("{url}", loginUrl)
|
||||
.replaceAll("{password}", password),
|
||||
.replaceAll("{password}", password)
|
||||
.replaceAll("{email}", recipientEmail),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CreateShareGuard } from "src/share/guard/createShare.guard";
|
||||
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
||||
import { FileService } from "./file.service";
|
||||
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
|
||||
import * as mime from "mime-types";
|
||||
|
||||
@Controller("shares/:shareId/files")
|
||||
export class FileController {
|
||||
@@ -53,13 +54,14 @@ export class FileController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string,
|
||||
) {
|
||||
const zip = this.fileService.getZip(shareId);
|
||||
const zipStream = await this.fileService.getZip(shareId);
|
||||
|
||||
res.set({
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": contentDisposition(`${shareId}.zip`),
|
||||
});
|
||||
|
||||
return new StreamableFile(zip);
|
||||
return new StreamableFile(zipStream);
|
||||
}
|
||||
|
||||
@Get(":fileId")
|
||||
@@ -73,13 +75,18 @@ export class FileController {
|
||||
const file = await this.fileService.get(shareId, fileId);
|
||||
|
||||
const headers = {
|
||||
"Content-Type": file.metaData.mimeType,
|
||||
"Content-Type":
|
||||
mime?.lookup?.(file.metaData.name) || "application/octet-stream",
|
||||
"Content-Length": file.metaData.size,
|
||||
"Content-Security-Policy": "script-src 'none'",
|
||||
"Content-Security-Policy": "sandbox",
|
||||
};
|
||||
|
||||
if (download === "true") {
|
||||
headers["Content-Disposition"] = contentDisposition(file.metaData.name);
|
||||
} else {
|
||||
headers["Content-Disposition"] = contentDisposition(file.metaData.name, {
|
||||
type: "inline",
|
||||
});
|
||||
}
|
||||
|
||||
res.set(headers);
|
||||
|
||||
@@ -4,11 +4,13 @@ import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
|
||||
import { ShareModule } from "src/share/share.module";
|
||||
import { FileController } from "./file.controller";
|
||||
import { FileService } from "./file.service";
|
||||
import { LocalFileService } from "./local.service";
|
||||
import { S3FileService } from "./s3.service";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), ReverseShareModule, ShareModule],
|
||||
controllers: [FileController],
|
||||
providers: [FileService],
|
||||
providers: [FileService, LocalFileService, S3FileService],
|
||||
exports: [FileService],
|
||||
})
|
||||
export class FileModule {}
|
||||
|
||||
@@ -1,149 +1,88 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import * as crypto from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as mime from "mime-types";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { LocalFileService } from "./local.service";
|
||||
import { S3FileService } from "./s3.service";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
import { Readable } from "stream";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService,
|
||||
private localFileService: LocalFileService,
|
||||
private s3FileService: S3FileService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
// Determine which service to use based on the current config value
|
||||
// shareId is optional -> can be used to overwrite a storage provider
|
||||
private getStorageService(
|
||||
storageProvider?: string,
|
||||
): S3FileService | LocalFileService {
|
||||
if (storageProvider != undefined)
|
||||
return storageProvider == "S3"
|
||||
? this.s3FileService
|
||||
: this.localFileService;
|
||||
return this.configService.get("s3.enabled")
|
||||
? this.s3FileService
|
||||
: this.localFileService;
|
||||
}
|
||||
|
||||
async create(
|
||||
data: string,
|
||||
chunk: { index: number; total: number },
|
||||
file: { id?: string; name: string },
|
||||
file: {
|
||||
id?: string;
|
||||
name: string;
|
||||
},
|
||||
shareId: string,
|
||||
) {
|
||||
if (!file.id) file.id = crypto.randomUUID();
|
||||
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
include: { files: true, reverseShare: true },
|
||||
});
|
||||
|
||||
if (share.uploadLocked)
|
||||
throw new BadRequestException("Share is already completed");
|
||||
|
||||
let diskFileSize: number;
|
||||
try {
|
||||
diskFileSize = fs.statSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
).size;
|
||||
} catch {
|
||||
diskFileSize = 0;
|
||||
}
|
||||
|
||||
// If the sent chunk index and the expected chunk index doesn't match throw an error
|
||||
const chunkSize = this.config.get("share.chunkSize");
|
||||
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
|
||||
|
||||
if (expectedChunkIndex != chunk.index)
|
||||
throw new BadRequestException({
|
||||
message: "Unexpected chunk received",
|
||||
error: "unexpected_chunk_index",
|
||||
expectedChunkIndex,
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(data, "base64");
|
||||
|
||||
// Check if share size limit is exceeded
|
||||
const fileSizeSum = share.files.reduce(
|
||||
(n, { size }) => n + parseInt(size),
|
||||
0,
|
||||
);
|
||||
|
||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||
|
||||
if (
|
||||
shareSizeSum > this.config.get("share.maxSize") ||
|
||||
(share.reverseShare?.maxShareSize &&
|
||||
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
|
||||
) {
|
||||
throw new HttpException(
|
||||
"Max share size exceeded",
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
);
|
||||
}
|
||||
|
||||
fs.appendFileSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
buffer,
|
||||
);
|
||||
|
||||
const isLastChunk = chunk.index == chunk.total - 1;
|
||||
if (isLastChunk) {
|
||||
fs.renameSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||
);
|
||||
const fileSize = fs.statSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||
).size;
|
||||
await this.prisma.file.create({
|
||||
data: {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: fileSize.toString(),
|
||||
share: { connect: { id: shareId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return file;
|
||||
const storageService = this.getStorageService();
|
||||
return storageService.create(data, chunk, file, shareId);
|
||||
}
|
||||
|
||||
async get(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
async get(shareId: string, fileId: string): Promise<File> {
|
||||
const share = await this.prisma.share.findFirst({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
const file = fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
return {
|
||||
metaData: {
|
||||
mimeType: mime.contentType(fileMetaData.name.split(".").pop()),
|
||||
...fileMetaData,
|
||||
size: fileMetaData.size,
|
||||
},
|
||||
file,
|
||||
};
|
||||
const storageService = this.getStorageService(share.storageProvider);
|
||||
return storageService.get(shareId, fileId);
|
||||
}
|
||||
|
||||
async remove(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
fs.unlinkSync(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
await this.prisma.file.delete({ where: { id: fileId } });
|
||||
const storageService = this.getStorageService();
|
||||
return storageService.remove(shareId, fileId);
|
||||
}
|
||||
|
||||
async deleteAllFiles(shareId: string) {
|
||||
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
const storageService = this.getStorageService();
|
||||
return storageService.deleteAllFiles(shareId);
|
||||
}
|
||||
|
||||
getZip(shareId: string) {
|
||||
return fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/archive.zip`);
|
||||
async getZip(shareId: string): Promise<Readable> {
|
||||
const storageService = this.getStorageService();
|
||||
return await storageService.getZip(shareId);
|
||||
}
|
||||
|
||||
private async streamToUint8Array(stream: Readable): Promise<Uint8Array> {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
stream.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks))));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface File {
|
||||
metaData: {
|
||||
id: string;
|
||||
size: string;
|
||||
createdAt: Date;
|
||||
mimeType: string | false;
|
||||
name: string;
|
||||
shareId: string;
|
||||
};
|
||||
file: Readable;
|
||||
}
|
||||
|
||||
174
backend/src/file/local.service.ts
Normal file
174
backend/src/file/local.service.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import { createReadStream } from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as mime from "mime-types";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { validate as isValidUUID } from "uuid";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
import { Readable } from "stream";
|
||||
|
||||
@Injectable()
|
||||
export class LocalFileService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
data: string,
|
||||
chunk: { index: number; total: number },
|
||||
file: { id?: string; name: string },
|
||||
shareId: string,
|
||||
) {
|
||||
if (!file.id) {
|
||||
file.id = crypto.randomUUID();
|
||||
} else if (!isValidUUID(file.id)) {
|
||||
throw new BadRequestException("Invalid file ID format");
|
||||
}
|
||||
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
include: { files: true, reverseShare: true },
|
||||
});
|
||||
|
||||
if (share.uploadLocked)
|
||||
throw new BadRequestException("Share is already completed");
|
||||
|
||||
let diskFileSize: number;
|
||||
try {
|
||||
diskFileSize = (
|
||||
await fs.stat(`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`)
|
||||
).size;
|
||||
} catch {
|
||||
diskFileSize = 0;
|
||||
}
|
||||
|
||||
// If the sent chunk index and the expected chunk index doesn't match throw an error
|
||||
const chunkSize = this.config.get("share.chunkSize");
|
||||
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
|
||||
|
||||
if (expectedChunkIndex != chunk.index)
|
||||
throw new BadRequestException({
|
||||
message: "Unexpected chunk received",
|
||||
error: "unexpected_chunk_index",
|
||||
expectedChunkIndex,
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(data, "base64");
|
||||
|
||||
// Check if there is enough space on the server
|
||||
const space = await fs.statfs(SHARE_DIRECTORY);
|
||||
const availableSpace = space.bavail * space.bsize;
|
||||
if (availableSpace < buffer.byteLength) {
|
||||
throw new InternalServerErrorException("Not enough space on the server");
|
||||
}
|
||||
|
||||
// Check if share size limit is exceeded
|
||||
const fileSizeSum = share.files.reduce(
|
||||
(n, { size }) => n + parseInt(size),
|
||||
0,
|
||||
);
|
||||
|
||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||
|
||||
if (
|
||||
shareSizeSum > this.config.get("share.maxSize") ||
|
||||
(share.reverseShare?.maxShareSize &&
|
||||
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
|
||||
) {
|
||||
throw new HttpException(
|
||||
"Max share size exceeded",
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
);
|
||||
}
|
||||
|
||||
await fs.appendFile(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
buffer,
|
||||
);
|
||||
|
||||
const isLastChunk = chunk.index == chunk.total - 1;
|
||||
if (isLastChunk) {
|
||||
await fs.rename(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||
);
|
||||
const fileSize = (
|
||||
await fs.stat(`${SHARE_DIRECTORY}/${shareId}/${file.id}`)
|
||||
).size;
|
||||
await this.prisma.file.create({
|
||||
data: {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: fileSize.toString(),
|
||||
share: { connect: { id: shareId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
async get(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
const file = createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
return {
|
||||
metaData: {
|
||||
mimeType: mime.contentType(fileMetaData.name.split(".").pop()),
|
||||
...fileMetaData,
|
||||
size: fileMetaData.size,
|
||||
},
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
async remove(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
await fs.unlink(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
await this.prisma.file.delete({ where: { id: fileId } });
|
||||
}
|
||||
|
||||
async deleteAllFiles(shareId: string) {
|
||||
await fs.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getZip(shareId: string): Promise<Readable> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const zipStream = createReadStream(
|
||||
`${SHARE_DIRECTORY}/${shareId}/archive.zip`,
|
||||
);
|
||||
|
||||
zipStream.on("error", (err) => {
|
||||
reject(new InternalServerErrorException(err));
|
||||
});
|
||||
|
||||
zipStream.on("open", () => {
|
||||
resolve(zipStream);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
390
backend/src/file/s3.service.ts
Normal file
390
backend/src/file/s3.service.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
UploadPartCommand,
|
||||
UploadPartCommandOutput,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import * as crypto from "crypto";
|
||||
import * as mime from "mime-types";
|
||||
import { File } from "./file.service";
|
||||
import { Readable } from "stream";
|
||||
import { validate as isValidUUID } from "uuid";
|
||||
import * as archiver from "archiver";
|
||||
|
||||
@Injectable()
|
||||
export class S3FileService {
|
||||
private readonly logger = new Logger(S3FileService.name);
|
||||
|
||||
private multipartUploads: Record<
|
||||
string,
|
||||
{
|
||||
uploadId: string;
|
||||
parts: Array<{ ETag: string | undefined; PartNumber: number }>;
|
||||
}
|
||||
> = {};
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
data: string,
|
||||
chunk: { index: number; total: number },
|
||||
file: { id?: string; name: string },
|
||||
shareId: string,
|
||||
) {
|
||||
if (!file.id) {
|
||||
file.id = crypto.randomUUID();
|
||||
} else if (!isValidUUID(file.id)) {
|
||||
throw new BadRequestException("Invalid file ID format");
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(data, "base64");
|
||||
const key = `${this.getS3Path()}${shareId}/${file.name}`;
|
||||
const bucketName = this.config.get("s3.bucketName");
|
||||
const s3Instance = this.getS3Instance();
|
||||
|
||||
try {
|
||||
// Initialize multipart upload if it's the first chunk
|
||||
if (chunk.index === 0) {
|
||||
const multipartInitResponse = await s3Instance.send(
|
||||
new CreateMultipartUploadCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadId = multipartInitResponse.UploadId;
|
||||
if (!uploadId) {
|
||||
throw new Error("Failed to initialize multipart upload.");
|
||||
}
|
||||
|
||||
// Store the uploadId and parts list in memory
|
||||
this.multipartUploads[file.id] = {
|
||||
uploadId,
|
||||
parts: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Get the ongoing multipart upload
|
||||
const multipartUpload = this.multipartUploads[file.id];
|
||||
if (!multipartUpload) {
|
||||
throw new InternalServerErrorException(
|
||||
"Multipart upload session not found.",
|
||||
);
|
||||
}
|
||||
|
||||
const uploadId = multipartUpload.uploadId;
|
||||
|
||||
// Upload the current chunk
|
||||
const partNumber = chunk.index + 1; // Part numbers start from 1
|
||||
|
||||
const uploadPartResponse: UploadPartCommandOutput = await s3Instance.send(
|
||||
new UploadPartCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
PartNumber: partNumber,
|
||||
UploadId: uploadId,
|
||||
Body: buffer,
|
||||
}),
|
||||
);
|
||||
|
||||
// Store the ETag and PartNumber for later completion
|
||||
multipartUpload.parts.push({
|
||||
ETag: uploadPartResponse.ETag,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
|
||||
// Complete the multipart upload if it's the last chunk
|
||||
if (chunk.index === chunk.total - 1) {
|
||||
await s3Instance.send(
|
||||
new CompleteMultipartUploadCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: multipartUpload.parts,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Remove the completed upload from memory
|
||||
delete this.multipartUploads[file.id];
|
||||
}
|
||||
} catch (error) {
|
||||
// Abort the multipart upload if it fails
|
||||
const multipartUpload = this.multipartUploads[file.id];
|
||||
if (multipartUpload) {
|
||||
try {
|
||||
await s3Instance.send(
|
||||
new AbortMultipartUploadCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
UploadId: multipartUpload.uploadId,
|
||||
}),
|
||||
);
|
||||
} catch (abortError) {
|
||||
console.error("Error aborting multipart upload:", abortError);
|
||||
}
|
||||
delete this.multipartUploads[file.id];
|
||||
}
|
||||
this.logger.error(error);
|
||||
throw new Error("Multipart upload failed. The upload has been aborted.");
|
||||
}
|
||||
|
||||
const isLastChunk = chunk.index == chunk.total - 1;
|
||||
if (isLastChunk) {
|
||||
const fileSize: number = await this.getFileSize(shareId, file.name);
|
||||
|
||||
await this.prisma.file.create({
|
||||
data: {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: fileSize.toString(),
|
||||
share: { connect: { id: shareId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
async get(shareId: string, fileId: string): Promise<File> {
|
||||
const fileName = (
|
||||
await this.prisma.file.findUnique({ where: { id: fileId } })
|
||||
).name;
|
||||
|
||||
const s3Instance = this.getS3Instance();
|
||||
const key = `${this.getS3Path()}${shareId}/${fileName}`;
|
||||
const response = await s3Instance.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.config.get("s3.bucketName"),
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
metaData: {
|
||||
id: fileId,
|
||||
size: response.ContentLength?.toString() || "0",
|
||||
name: fileName,
|
||||
shareId: shareId,
|
||||
createdAt: response.LastModified || new Date(),
|
||||
mimeType:
|
||||
mime.contentType(fileId.split(".").pop()) ||
|
||||
"application/octet-stream",
|
||||
},
|
||||
file: response.Body as Readable,
|
||||
} as File;
|
||||
}
|
||||
|
||||
async remove(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
const key = `${this.getS3Path()}${shareId}/${fileMetaData.name}`;
|
||||
const s3Instance = this.getS3Instance();
|
||||
|
||||
try {
|
||||
await s3Instance.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.config.get("s3.bucketName"),
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error("Could not delete file from S3");
|
||||
}
|
||||
|
||||
await this.prisma.file.delete({ where: { id: fileId } });
|
||||
}
|
||||
|
||||
async deleteAllFiles(shareId: string) {
|
||||
const prefix = `${this.getS3Path()}${shareId}/`;
|
||||
const s3Instance = this.getS3Instance();
|
||||
|
||||
try {
|
||||
// List all objects under the given prefix
|
||||
const listResponse = await s3Instance.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.config.get("s3.bucketName"),
|
||||
Prefix: prefix,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!listResponse.Contents || listResponse.Contents.length === 0) {
|
||||
throw new Error(`No files found for share ${shareId}`);
|
||||
}
|
||||
|
||||
// Extract the keys of the files to be deleted
|
||||
const objectsToDelete = listResponse.Contents.map((file) => ({
|
||||
Key: file.Key!,
|
||||
}));
|
||||
|
||||
// Delete all files in a single request (up to 1000 objects at once)
|
||||
await s3Instance.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: this.config.get("s3.bucketName"),
|
||||
Delete: {
|
||||
Objects: objectsToDelete,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error("Could not delete all files from S3");
|
||||
}
|
||||
}
|
||||
|
||||
async getFileSize(shareId: string, fileName: string): Promise<number> {
|
||||
const key = `${this.getS3Path()}${shareId}/${fileName}`;
|
||||
const s3Instance = this.getS3Instance();
|
||||
|
||||
try {
|
||||
// Get metadata of the file using HeadObjectCommand
|
||||
const headObjectResponse = await s3Instance.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: this.config.get("s3.bucketName"),
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
// Return ContentLength which is the file size in bytes
|
||||
return headObjectResponse.ContentLength ?? 0;
|
||||
} catch (error) {
|
||||
throw new Error("Could not retrieve file size");
|
||||
}
|
||||
}
|
||||
|
||||
getS3Instance(): S3Client {
|
||||
const checksumCalculation =
|
||||
this.config.get("s3.useChecksum") === true ? null : "WHEN_REQUIRED";
|
||||
|
||||
return new S3Client({
|
||||
endpoint: this.config.get("s3.endpoint"),
|
||||
region: this.config.get("s3.region"),
|
||||
credentials: {
|
||||
accessKeyId: this.config.get("s3.key"),
|
||||
secretAccessKey: this.config.get("s3.secret"),
|
||||
},
|
||||
forcePathStyle: true,
|
||||
requestChecksumCalculation: checksumCalculation,
|
||||
responseChecksumValidation: checksumCalculation,
|
||||
});
|
||||
}
|
||||
|
||||
getZip(shareId: string) {
|
||||
return new Promise<Readable>(async (resolve, reject) => {
|
||||
const s3Instance = this.getS3Instance();
|
||||
const bucketName = this.config.get("s3.bucketName");
|
||||
const compressionLevel = this.config.get("share.zipCompressionLevel");
|
||||
|
||||
const prefix = `${this.getS3Path()}${shareId}/`;
|
||||
|
||||
try {
|
||||
const listResponse = await s3Instance.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucketName,
|
||||
Prefix: prefix,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!listResponse.Contents || listResponse.Contents.length === 0) {
|
||||
throw new NotFoundException(`No files found for share ${shareId}`);
|
||||
}
|
||||
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: parseInt(compressionLevel) },
|
||||
});
|
||||
|
||||
archive.on("error", (err) => {
|
||||
this.logger.error("Archive error", err);
|
||||
reject(new InternalServerErrorException("Error creating ZIP file"));
|
||||
});
|
||||
|
||||
const fileKeys = listResponse.Contents.filter(
|
||||
(object) => object.Key && object.Key !== prefix,
|
||||
).map((object) => object.Key as string);
|
||||
|
||||
if (fileKeys.length === 0) {
|
||||
throw new NotFoundException(
|
||||
`No valid files found for share ${shareId}`,
|
||||
);
|
||||
}
|
||||
|
||||
let filesAdded = 0;
|
||||
|
||||
const processNextFile = async (index: number) => {
|
||||
if (index >= fileKeys.length) {
|
||||
archive.finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = fileKeys[index];
|
||||
const fileName = key.replace(prefix, "");
|
||||
|
||||
try {
|
||||
const response = await s3Instance.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.Body instanceof Readable) {
|
||||
const fileStream = response.Body;
|
||||
|
||||
fileStream.on("end", () => {
|
||||
filesAdded++;
|
||||
processNextFile(index + 1);
|
||||
});
|
||||
|
||||
fileStream.on("error", (err) => {
|
||||
this.logger.error(`Error streaming file ${fileName}`, err);
|
||||
processNextFile(index + 1);
|
||||
});
|
||||
|
||||
archive.append(fileStream, { name: fileName });
|
||||
} else {
|
||||
processNextFile(index + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing file ${fileName}`, error);
|
||||
processNextFile(index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
resolve(archive);
|
||||
processNextFile(0);
|
||||
} catch (error) {
|
||||
this.logger.error("Error creating ZIP file", error);
|
||||
|
||||
reject(new InternalServerErrorException("Error creating ZIP file"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getS3Path(): string {
|
||||
const configS3Path = this.config.get("s3.bucketPath");
|
||||
return configS3Path ? `${configS3Path}/` : "";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ClassSerializerInterceptor,
|
||||
Logger,
|
||||
LogLevel,
|
||||
ValidationPipe,
|
||||
} from "@nestjs/common";
|
||||
import { NestFactory, Reflector } from "@nestjs/core";
|
||||
@@ -12,10 +13,35 @@ import { NextFunction, Request, Response } from "express";
|
||||
import * as fs from "fs";
|
||||
import { AppModule } from "./app.module";
|
||||
import { ConfigService } from "./config/config.service";
|
||||
import { DATA_DIRECTORY } from "./constants";
|
||||
import {
|
||||
DATA_DIRECTORY,
|
||||
LOG_LEVEL_AVAILABLE,
|
||||
LOG_LEVEL_DEFAULT,
|
||||
LOG_LEVEL_ENV,
|
||||
} from "./constants";
|
||||
|
||||
function generateNestJsLogLevels(): LogLevel[] {
|
||||
if (LOG_LEVEL_ENV) {
|
||||
const levelIndex = LOG_LEVEL_AVAILABLE.indexOf(LOG_LEVEL_ENV as any);
|
||||
if (levelIndex === -1) {
|
||||
throw new Error(`log level ${LOG_LEVEL_ENV} unknown`);
|
||||
}
|
||||
|
||||
return LOG_LEVEL_AVAILABLE.slice(levelIndex, LOG_LEVEL_AVAILABLE.length);
|
||||
} else {
|
||||
const levelIndex = LOG_LEVEL_AVAILABLE.indexOf(LOG_LEVEL_DEFAULT);
|
||||
return LOG_LEVEL_AVAILABLE.slice(levelIndex, LOG_LEVEL_AVAILABLE.length);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const logLevels = generateNestJsLogLevels();
|
||||
Logger.log(`Showing ${logLevels.join(", ")} messages`);
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: logLevels,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||
|
||||
@@ -48,7 +74,9 @@ async function bootstrap() {
|
||||
SwaggerModule.setup("api/swagger", app, document);
|
||||
}
|
||||
|
||||
await app.listen(parseInt(process.env.PORT) || 8080);
|
||||
await app.listen(
|
||||
parseInt(process.env.BACKEND_PORT || process.env.PORT || "8080"),
|
||||
);
|
||||
|
||||
const logger = new Logger("UnhandledAsyncError");
|
||||
process.on("unhandledRejection", (e) => logger.error(e));
|
||||
|
||||
@@ -4,4 +4,5 @@ export interface OAuthSignInDto {
|
||||
providerUsername: string;
|
||||
email: string;
|
||||
isAdmin?: boolean;
|
||||
idToken?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { OAuthController } from "./oauth.controller";
|
||||
import { OAuthService } from "./oauth.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
@@ -51,6 +51,7 @@ import { MicrosoftProvider } from "./provider/microsoft.provider";
|
||||
inject: ["OAUTH_PROVIDERS"],
|
||||
},
|
||||
],
|
||||
imports: [AuthModule],
|
||||
imports: [forwardRef(() => AuthModule)],
|
||||
exports: [OAuthService],
|
||||
})
|
||||
export class OAuthModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { nanoid } from "nanoid";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
@@ -6,14 +6,17 @@ import { ConfigService } from "../config/config.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
|
||||
import { ErrorPageException } from "./exceptions/errorPage.exception";
|
||||
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
private auth: AuthService,
|
||||
@Inject(forwardRef(() => AuthService)) private auth: AuthService,
|
||||
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
||||
@Inject("OAUTH_PROVIDERS")
|
||||
private oAuthProviders: Record<string, OAuthProvider<unknown>>,
|
||||
) {}
|
||||
private readonly logger = new Logger(OAuthService.name);
|
||||
|
||||
@@ -27,6 +30,18 @@ export class OAuthService {
|
||||
.map(([platform, _]) => platform);
|
||||
}
|
||||
|
||||
availableProviders(): Record<string, OAuthProvider<unknown>> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.oAuthProviders)
|
||||
.map(([providerName, provider]) => [
|
||||
[providerName, provider],
|
||||
this.config.get(`oauth.${providerName}-enabled`),
|
||||
])
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([provider, _]) => provider),
|
||||
);
|
||||
}
|
||||
|
||||
async status(user: User) {
|
||||
const oauthUsers = await this.prisma.oAuthUser.findMany({
|
||||
select: {
|
||||
@@ -48,14 +63,14 @@ export class OAuthService {
|
||||
},
|
||||
});
|
||||
if (oauthUser) {
|
||||
await this.updateIsAdmin(user);
|
||||
await this.updateIsAdmin(oauthUser.userId, user.isAdmin);
|
||||
const updatedUser = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: user.email,
|
||||
id: oauthUser.userId,
|
||||
},
|
||||
});
|
||||
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
|
||||
return this.auth.generateToken(updatedUser, true);
|
||||
return this.auth.generateToken(updatedUser, { idToken: user.idToken });
|
||||
}
|
||||
|
||||
return this.signUp(user, ip);
|
||||
@@ -108,8 +123,10 @@ export class OAuthService {
|
||||
}
|
||||
|
||||
private async getAvailableUsername(preferredUsername: string) {
|
||||
// only remove + and - from preferred username for now (maybe not enough)
|
||||
let username = preferredUsername.replace(/[+-]/g, "").substring(0, 20);
|
||||
// Only keep letters, numbers, dots, and underscores. Truncate to 20 characters.
|
||||
let username = preferredUsername
|
||||
.replace(/[^a-zA-Z0-9._]/g, "")
|
||||
.substring(0, 20);
|
||||
while (true) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -153,8 +170,8 @@ export class OAuthService {
|
||||
userId: existingUser.id,
|
||||
},
|
||||
});
|
||||
await this.updateIsAdmin(user);
|
||||
return this.auth.generateToken(existingUser, true);
|
||||
await this.updateIsAdmin(existingUser.id, user.isAdmin);
|
||||
return this.auth.generateToken(existingUser, { idToken: user.idToken });
|
||||
}
|
||||
|
||||
const result = await this.auth.signUp(
|
||||
@@ -179,15 +196,15 @@ export class OAuthService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async updateIsAdmin(user: OAuthSignInDto) {
|
||||
if ("isAdmin" in user)
|
||||
await this.prisma.user.update({
|
||||
where: {
|
||||
email: user.email,
|
||||
},
|
||||
data: {
|
||||
isAdmin: user.isAdmin,
|
||||
},
|
||||
});
|
||||
private async updateIsAdmin(userId: string, isAdmin?: boolean) {
|
||||
if (!isAdmin) return;
|
||||
await this.prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
isAdmin: isAdmin,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,12 +81,17 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
||||
if (guild) {
|
||||
await this.checkLimitedGuild(token, guild);
|
||||
}
|
||||
const limitedUsers = this.config.get("oauth.discord-limitedUsers");
|
||||
if (limitedUsers) {
|
||||
await this.checkLimitedUsers(user, limitedUsers);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "discord",
|
||||
providerId: user.id,
|
||||
providerUsername: user.global_name ?? user.username,
|
||||
email: user.email,
|
||||
idToken: `discord:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,6 +112,12 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
}
|
||||
|
||||
async checkLimitedUsers(user: DiscordUser, userIds: string) {
|
||||
if (!userIds.split(",").includes(user.id)) {
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DiscordToken {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Cache } from "cache-manager";
|
||||
import * as jmespath from "jmespath";
|
||||
import { nanoid } from "nanoid";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||
|
||||
export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
protected discoveryUri: string;
|
||||
@@ -70,7 +70,10 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
new URLSearchParams({
|
||||
client_id: this.config.get(`oauth.${this.name}-clientId`),
|
||||
response_type: "code",
|
||||
scope: "openid profile email",
|
||||
scope:
|
||||
this.name == "oidc"
|
||||
? this.config.get(`oauth.oidc-scope`)
|
||||
: "openid email profile",
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
state,
|
||||
nonce,
|
||||
@@ -116,7 +119,13 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
},
|
||||
): Promise<OAuthSignInDto> {
|
||||
const idTokenData = this.decodeIdToken(token.idToken);
|
||||
// maybe it's not necessary to verify the id token since it's directly obtained from the provider
|
||||
|
||||
if (!idTokenData) {
|
||||
this.logger.error(
|
||||
`Can not get ID Token from response ${JSON.stringify(token.rawToken, undefined, 2)}`,
|
||||
);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
const key = `oauth-${this.name}-nonce-${query.state}`;
|
||||
const nonce = await this.cache.get(key);
|
||||
@@ -138,38 +147,36 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
|
||||
if (roleConfig?.path) {
|
||||
// A path to read roles from the token is configured
|
||||
let roles: string[] | null;
|
||||
let roles: string[] = [];
|
||||
try {
|
||||
roles = jmespath.search(idTokenData, roleConfig.path);
|
||||
const rolesClaim = jmespath.search(idTokenData, roleConfig.path);
|
||||
if (Array.isArray(rolesClaim)) {
|
||||
roles = rolesClaim;
|
||||
}
|
||||
} catch (e) {
|
||||
roles = null;
|
||||
}
|
||||
if (Array.isArray(roles)) {
|
||||
// Roles are found in the token
|
||||
if (
|
||||
roleConfig.generalAccess &&
|
||||
!roles.includes(roleConfig.generalAccess)
|
||||
) {
|
||||
// Role for general access is configured and the user does not have it
|
||||
this.logger.error(
|
||||
`User roles ${roles} do not include ${roleConfig.generalAccess}`,
|
||||
);
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
if (roleConfig.adminAccess) {
|
||||
// Role for admin access is configured
|
||||
isAdmin = roles.includes(roleConfig.adminAccess);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(
|
||||
this.logger.warn(
|
||||
`Roles not found at path ${roleConfig.path} in ID Token ${JSON.stringify(
|
||||
idTokenData,
|
||||
undefined,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
roleConfig.generalAccess &&
|
||||
!roles.includes(roleConfig.generalAccess)
|
||||
) {
|
||||
// Role for general access is configured and the user does not have it
|
||||
this.logger.error(
|
||||
`User roles ${roles} do not include ${roleConfig.generalAccess}`,
|
||||
);
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
if (roleConfig.adminAccess) {
|
||||
// Role for admin access is configured
|
||||
isAdmin = roles.includes(roleConfig.adminAccess);
|
||||
}
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
@@ -191,6 +198,7 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
providerId: idTokenData.sub,
|
||||
providerUsername: username,
|
||||
...(isAdmin !== undefined && { isAdmin }),
|
||||
idToken: `${this.name}:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,6 +253,8 @@ export interface OidcConfiguration {
|
||||
id_token_signing_alg_values_supported: string[];
|
||||
scopes_supported?: string[];
|
||||
claims_supported?: string[];
|
||||
frontchannel_logout_supported?: boolean;
|
||||
end_session_endpoint?: string;
|
||||
}
|
||||
|
||||
export interface OidcJwk {
|
||||
|
||||
@@ -61,6 +61,7 @@ export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
||||
providerId: user.id.toString(),
|
||||
providerUsername: user.name ?? user.login,
|
||||
email,
|
||||
idToken: `github:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { DATABASE_URL } from "../constants";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
datasources: {
|
||||
@@ -12,6 +14,6 @@ export class PrismaService extends PrismaClient {
|
||||
},
|
||||
},
|
||||
});
|
||||
super.$connect().then(() => console.info("Connected to the database"));
|
||||
super.$connect().then(() => this.logger.log("Connected to the database"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,11 @@ export class ReverseShareService {
|
||||
.toDate();
|
||||
|
||||
const parsedExpiration = parseRelativeDateToAbsolute(data.shareExpiration);
|
||||
const maxExpiration = this.config.get("share.maxExpiration");
|
||||
if (
|
||||
this.config.get("share.maxExpiration") !== 0 &&
|
||||
maxExpiration.value !== 0 &&
|
||||
parsedExpiration >
|
||||
moment().add(this.config.get("share.maxExpiration"), "hours").toDate()
|
||||
moment().add(maxExpiration.value, maxExpiration.unit).toDate()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Expiration date exceeds maximum expiration date",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Expose, plainToClass, Type } from "class-transformer";
|
||||
import { ShareDTO } from "./share.dto";
|
||||
import { FileDTO } from "../../file/dto/file.dto";
|
||||
import { OmitType } from "@nestjs/swagger";
|
||||
import { MyShareSecurityDTO } from "./myShareSecurity.dto";
|
||||
|
||||
export class MyShareDTO extends OmitType(ShareDTO, [
|
||||
"files",
|
||||
@@ -21,6 +22,9 @@ export class MyShareDTO extends OmitType(ShareDTO, [
|
||||
@Type(() => OmitType(FileDTO, ["share", "from"] as const))
|
||||
files: Omit<FileDTO, "share" | "from">[];
|
||||
|
||||
@Expose()
|
||||
security?: MyShareSecurityDTO;
|
||||
|
||||
from(partial: Partial<MyShareDTO>) {
|
||||
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
||||
9
backend/src/share/dto/myShareSecurity.dto.ts
Normal file
9
backend/src/share/dto/myShareSecurity.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Expose } from "class-transformer";
|
||||
|
||||
export class MyShareSecurityDTO {
|
||||
@Expose()
|
||||
passwordProtected: boolean;
|
||||
|
||||
@Expose()
|
||||
maxViews: number;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
export class ShareService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private configService: ConfigService,
|
||||
private fileService: FileService,
|
||||
private emailService: EmailService,
|
||||
private config: ConfigService,
|
||||
@@ -55,13 +56,12 @@ export class ShareService {
|
||||
|
||||
const expiresNever = moment(0).toDate() == parsedExpiration;
|
||||
|
||||
const maxExpiration = this.config.get("share.maxExpiration");
|
||||
if (
|
||||
this.config.get("share.maxExpiration") !== 0 &&
|
||||
maxExpiration.value !== 0 &&
|
||||
(expiresNever ||
|
||||
parsedExpiration >
|
||||
moment()
|
||||
.add(this.config.get("share.maxExpiration"), "hours")
|
||||
.toDate())
|
||||
moment().add(maxExpiration.value, maxExpiration.unit).toDate())
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Expiration date exceeds maximum expiration date",
|
||||
@@ -86,6 +86,7 @@ export class ShareService {
|
||||
? share.recipients.map((email) => ({ email }))
|
||||
: [],
|
||||
},
|
||||
storageProvider: this.configService.get("s3.enabled") ? "S3" : "LOCAL",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,6 +106,8 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async createZip(shareId: string) {
|
||||
if (this.config.get("s3.enabled")) return;
|
||||
|
||||
const path = `${SHARE_DIRECTORY}/${shareId}`;
|
||||
|
||||
const files = await this.prisma.file.findMany({ where: { shareId } });
|
||||
@@ -229,7 +232,7 @@ export class ShareService {
|
||||
orderBy: {
|
||||
expiration: "desc",
|
||||
},
|
||||
include: { recipients: true, files: true },
|
||||
include: { recipients: true, files: true, security: true },
|
||||
});
|
||||
|
||||
return shares.map((share) => {
|
||||
@@ -237,6 +240,10 @@ export class ShareService {
|
||||
...share,
|
||||
size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0),
|
||||
recipients: share.recipients.map((recipients) => recipients.email),
|
||||
security: {
|
||||
maxViews: share.security?.maxViews,
|
||||
passwordProtected: !!share.security?.password,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -315,11 +322,21 @@ export class ShareService {
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
share?.security?.password &&
|
||||
!(await argon.verify(share.security.password, password))
|
||||
) {
|
||||
throw new ForbiddenException("Wrong password", "wrong_password");
|
||||
if (share?.security?.password) {
|
||||
if (!password) {
|
||||
throw new ForbiddenException(
|
||||
"This share is password protected",
|
||||
"share_password_required",
|
||||
);
|
||||
}
|
||||
|
||||
const isPasswordValid = await argon.verify(
|
||||
share.security.password,
|
||||
password,
|
||||
);
|
||||
if (!isPasswordValid) {
|
||||
throw new ForbiddenException("Wrong password", "wrong_password");
|
||||
}
|
||||
}
|
||||
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||
@@ -335,12 +352,13 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async generateShareToken(shareId: string) {
|
||||
const { expiration } = await this.prisma.share.findUnique({
|
||||
const { expiration, createdAt } = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
const tokenPayload = {
|
||||
shareId,
|
||||
shareCreatedAt: moment(createdAt).unix(),
|
||||
iat: moment().unix(),
|
||||
};
|
||||
|
||||
@@ -356,7 +374,7 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async verifyShareToken(shareId: string, token: string) {
|
||||
const { expiration } = await this.prisma.share.findUnique({
|
||||
const { expiration, createdAt } = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
@@ -367,7 +385,10 @@ export class ShareService {
|
||||
ignoreExpiration: moment(expiration).isSame(0),
|
||||
});
|
||||
|
||||
return claims.shareId == shareId;
|
||||
return (
|
||||
claims.shareId == shareId &&
|
||||
claims.shareCreatedAt == moment(createdAt).unix()
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ export class UserDTO {
|
||||
totpVerified: boolean;
|
||||
|
||||
from(partial: Partial<UserDTO>) {
|
||||
const result = plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
|
||||
const result = plainToClass(UserDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
result.isLdap = partial.ldapDN?.length > 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -14,6 +15,7 @@ import { Response } from "express";
|
||||
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 "../config/config.service";
|
||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||
import { UpdateOwnUserDTO } from "./dto/updateOwnUser.dto";
|
||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
@@ -22,7 +24,10 @@ import { UserSevice } from "./user.service";
|
||||
|
||||
@Controller("users")
|
||||
export class UserController {
|
||||
constructor(private userService: UserSevice) {}
|
||||
constructor(
|
||||
private userService: UserSevice,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
// Own user operations
|
||||
@Get("me")
|
||||
@@ -44,18 +49,26 @@ export class UserController {
|
||||
}
|
||||
|
||||
@Delete("me")
|
||||
@HttpCode(204)
|
||||
@UseGuards(JwtGuard)
|
||||
async deleteCurrentUser(
|
||||
@GetUser() user: User,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||
await this.userService.delete(user.id);
|
||||
|
||||
const isSecure = this.config.get("general.secureCookies");
|
||||
|
||||
response.cookie("access_token", "accessToken", {
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
response.cookie("refresh_token", "", {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
return new UserDTO().from(await this.userService.delete(user.id));
|
||||
}
|
||||
|
||||
// Global user operations
|
||||
|
||||
@@ -8,6 +8,6 @@ import { FileModule } from "src/file/file.module";
|
||||
imports: [EmailModule, FileModule],
|
||||
providers: [UserSevice],
|
||||
controllers: [UserController],
|
||||
exports: [UserSevice]
|
||||
exports: [UserSevice],
|
||||
})
|
||||
export class UserModule { }
|
||||
export class UserModule {}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import * as crypto from "crypto";
|
||||
import { Entry } from "ldapts";
|
||||
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { inspect } from "util";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { FileService } from "../file/file.service";
|
||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { LdapAuthenticateResult } from "../auth/ldap.service";
|
||||
|
||||
@Injectable()
|
||||
export class UserSevice {
|
||||
private readonly logger = new Logger(UserSevice.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailService: EmailService,
|
||||
private fileService: FileService,
|
||||
private configService: ConfigService,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
async list() {
|
||||
return await this.prisma.user.findMany();
|
||||
@@ -85,6 +89,16 @@ export class UserSevice {
|
||||
});
|
||||
if (!user) throw new BadRequestException("User not found");
|
||||
|
||||
if (user.isAdmin) {
|
||||
const userCount = await this.prisma.user.count({
|
||||
where: { isAdmin: true },
|
||||
});
|
||||
|
||||
if (userCount === 1) {
|
||||
throw new BadRequestException("Cannot delete the last admin user");
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
user.shares.map((share) => this.fileService.deleteAllFiles(share.id)),
|
||||
);
|
||||
@@ -92,31 +106,114 @@ export class UserSevice {
|
||||
return await this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async findOrCreateFromLDAP(username: string, ldap: LdapAuthenticateResult) {
|
||||
const passwordHash = await argon.hash(crypto.randomUUID());
|
||||
const userEmail = ldap.attributes["userPrincipalName"]?.at(0) ?? `${crypto.randomUUID()}@ldap.local`;
|
||||
const adminGroup = this.configService.get("ldap.adminGroups");
|
||||
const isAdmin = ldap.attributes["memberOf"]?.includes(adminGroup) ?? false;
|
||||
async findOrCreateFromLDAP(
|
||||
providedCredentials: AuthSignInDTO,
|
||||
ldapEntry: Entry,
|
||||
) {
|
||||
const fieldNameMemberOf = this.configService.get("ldap.fieldNameMemberOf");
|
||||
const fieldNameEmail = this.configService.get("ldap.fieldNameEmail");
|
||||
|
||||
let isAdmin = false;
|
||||
if (fieldNameMemberOf in ldapEntry) {
|
||||
const adminGroup = this.configService.get("ldap.adminGroups");
|
||||
const entryGroups = Array.isArray(ldapEntry[fieldNameMemberOf])
|
||||
? ldapEntry[fieldNameMemberOf]
|
||||
: [ldapEntry[fieldNameMemberOf]];
|
||||
isAdmin = entryGroups.includes(adminGroup) ?? false;
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Trying to create/update a ldap user but the member field ${fieldNameMemberOf} is not present.`,
|
||||
);
|
||||
}
|
||||
|
||||
let userEmail: string | null = null;
|
||||
if (fieldNameEmail in ldapEntry) {
|
||||
const value = Array.isArray(ldapEntry[fieldNameEmail])
|
||||
? ldapEntry[fieldNameEmail][0]
|
||||
: ldapEntry[fieldNameEmail];
|
||||
if (value) {
|
||||
userEmail = value.toString();
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Trying to create/update a ldap user but the email field ${fieldNameEmail} is not present.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (providedCredentials.email) {
|
||||
/* if LDAP does not provides an users email address, take the user provided email address instead */
|
||||
userEmail = providedCredentials.email;
|
||||
}
|
||||
|
||||
const randomId = crypto.randomUUID();
|
||||
const placeholderUsername = `ldap_user_${randomId}`;
|
||||
const placeholderEMail = `${randomId}@ldap.local`;
|
||||
|
||||
try {
|
||||
return await this.prisma.user.upsert({
|
||||
const user = await this.prisma.user.upsert({
|
||||
create: {
|
||||
username,
|
||||
email: userEmail,
|
||||
password: passwordHash,
|
||||
isAdmin,
|
||||
ldapDN: ldap.userDn,
|
||||
},
|
||||
update: {
|
||||
username,
|
||||
email: userEmail,
|
||||
username: providedCredentials.username ?? placeholderUsername,
|
||||
email: userEmail ?? placeholderEMail,
|
||||
password: await argon.hash(crypto.randomUUID()),
|
||||
|
||||
isAdmin,
|
||||
ldapDN: ldap.userDn,
|
||||
ldapDN: ldapEntry.dn,
|
||||
},
|
||||
update: {
|
||||
isAdmin,
|
||||
ldapDN: ldapEntry.dn,
|
||||
},
|
||||
where: {
|
||||
ldapDN: ldap.userDn
|
||||
}
|
||||
ldapDN: ldapEntry.dn,
|
||||
},
|
||||
});
|
||||
|
||||
if (user.username === placeholderUsername) {
|
||||
/* Give the user a human readable name if the user has been created with a placeholder username */
|
||||
await this.prisma.user
|
||||
.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
username: `user_${user.id}`,
|
||||
},
|
||||
})
|
||||
.then((newUser) => {
|
||||
user.username = newUser.username;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.warn(
|
||||
`Failed to update users ${user.id} placeholder username: ${inspect(error)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (userEmail && userEmail !== user.email) {
|
||||
/* Sync users email if it has changed */
|
||||
await this.prisma.user
|
||||
.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
email: userEmail,
|
||||
},
|
||||
})
|
||||
.then((newUser) => {
|
||||
this.logger.log(
|
||||
`Updated users ${user.id} email from ldap from ${user.email} to ${userEmail}.`,
|
||||
);
|
||||
user.email = newUser.email;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error(
|
||||
`Failed to update users ${user.id} email to ${userEmail}: ${inspect(error)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (e) {
|
||||
if (e instanceof PrismaClientKnownRequestError) {
|
||||
if (e.code == "P2002") {
|
||||
|
||||
@@ -10,3 +10,20 @@ export function parseRelativeDateToAbsolute(relativeDate: string) {
|
||||
)
|
||||
.toDate();
|
||||
}
|
||||
|
||||
type Timespan = {
|
||||
value: number;
|
||||
unit: "minutes" | "hours" | "days" | "weeks" | "months" | "years";
|
||||
};
|
||||
|
||||
export function stringToTimespan(value: string): Timespan {
|
||||
const [time, unit] = value.split(" ");
|
||||
return {
|
||||
value: parseInt(time),
|
||||
unit: unit as Timespan["unit"],
|
||||
};
|
||||
}
|
||||
|
||||
export function timespanToString(timespan: Timespan) {
|
||||
return `${timespan.value} ${timespan.unit}`;
|
||||
}
|
||||
|
||||
237
config.example.yaml
Normal file
237
config.example.yaml
Normal file
@@ -0,0 +1,237 @@
|
||||
#This configuration is pre-filled with the default values.
|
||||
#You can remove keys you don't want to set. If a key is missing, the value set in the UI will be used; if that is also unset, the default value applies.
|
||||
|
||||
general:
|
||||
#Name of the application
|
||||
appName: Pingvin Share
|
||||
#On which URL Pingvin Share is available
|
||||
appUrl: http://localhost:3000
|
||||
#Whether to set the secure flag on cookies. If enabled, the site will not function when accessed over HTTP.
|
||||
secureCookies: "false"
|
||||
#Whether to show the home page
|
||||
showHomePage: "true"
|
||||
#Time after which a user must log in again (default: 3 months).
|
||||
sessionDuration: 3 months
|
||||
share:
|
||||
#Whether registration is allowed
|
||||
allowRegistration: "true"
|
||||
#Whether unauthenticated users can create shares
|
||||
allowUnauthenticatedShares: "false"
|
||||
#Maximum share expiration. Set to 0 to allow unlimited expiration.
|
||||
maxExpiration: 0 days
|
||||
#Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.
|
||||
shareIdLength: "8"
|
||||
#Maximum share size
|
||||
maxSize: "1000000000"
|
||||
#Adjust the level to balance between file size and compression speed. Valid values range from 0 to 9, with 0 being no compression and 9 being maximum compression.
|
||||
zipCompressionLevel: "9"
|
||||
#Adjust the chunk size for your uploads to balance efficiency and reliability according to your internet connection. Smaller chunks can enhance success rates for unstable connections, while larger chunks make uploads faster for stable connections.
|
||||
chunkSize: "10000000"
|
||||
#The share creation modal automatically appears when a user selects files, eliminating the need to manually click the button.
|
||||
autoOpenShareModal: "false"
|
||||
cache:
|
||||
#Normally Pingvin Share caches information in memory. If you run multiple instances of Pingvin Share, you need to enable Redis caching to share the cache between the instances.
|
||||
redis-enabled: "false"
|
||||
#Url to connect to the Redis instance used for caching.
|
||||
redis-url: redis://pingvin-redis:6379
|
||||
#Time in second to keep information inside the cache.
|
||||
ttl: "60"
|
||||
#Maximum number of items inside the cache.
|
||||
maxItems: "1000"
|
||||
email:
|
||||
#Whether to allow email sharing with recipients. Only enable this if SMTP is activated.
|
||||
enableShareEmailRecipients: "false"
|
||||
#Subject of the email which gets sent to the share recipients.
|
||||
shareRecipientsSubject: Files shared with you
|
||||
#Message which gets sent to the share recipients. Available variables:
|
||||
# {creator} - The username of the creator of the share
|
||||
# {creatorEmail} - The email of the creator of the share
|
||||
# {shareUrl} - The URL of the share
|
||||
# {desc} - The description of the share
|
||||
# {expires} - The expiration date of the share
|
||||
# These variables will be replaced with the actual value.
|
||||
shareRecipientsMessage: >-
|
||||
Hey!
|
||||
|
||||
|
||||
{creator} ({creatorEmail}) shared some files with you. You can view or download the
|
||||
files with this link: {shareUrl}
|
||||
|
||||
|
||||
The share will expire {expires}.
|
||||
|
||||
|
||||
Note: {desc}
|
||||
|
||||
|
||||
Shared securely with Pingvin Share 🐧
|
||||
#Subject of the sent email when someone created a share with your reverse share link.
|
||||
reverseShareSubject: Reverse share link used
|
||||
#Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.
|
||||
reverseShareMessage: |-
|
||||
Hey!
|
||||
|
||||
A share was just created with your reverse share link: {shareUrl}
|
||||
|
||||
Shared securely with Pingvin Share 🐧
|
||||
#Subject of the sent email when a user requests a password reset.
|
||||
resetPasswordSubject: Pingvin Share password reset
|
||||
#Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.
|
||||
resetPasswordMessage: >-
|
||||
Hey!
|
||||
|
||||
|
||||
You requested a password reset. Click this link to reset your password:
|
||||
{url}
|
||||
|
||||
The link expires in an hour.
|
||||
|
||||
|
||||
Pingvin Share 🐧
|
||||
#Subject of the sent email when an admin invites a user.
|
||||
inviteSubject: Pingvin Share invite
|
||||
#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.
|
||||
inviteMessage: >-
|
||||
Hey!
|
||||
|
||||
|
||||
You were invited to Pingvin Share. Click this link to accept the invite:
|
||||
{url}
|
||||
|
||||
|
||||
You can use the email "{email}" and the password "{password}" to sign in.
|
||||
|
||||
|
||||
Pingvin Share 🐧
|
||||
smtp:
|
||||
#Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.
|
||||
enabled: "false"
|
||||
#Only set this to true if you need to trust self signed certificates.
|
||||
allowUnauthorizedCertificates: "false"
|
||||
#Host of the SMTP server
|
||||
host: ""
|
||||
#Port of the SMTP server
|
||||
port: "0"
|
||||
#Email address from which the emails get sent
|
||||
email: ""
|
||||
#Username of the SMTP server
|
||||
username: ""
|
||||
#Password of the SMTP server
|
||||
password: ""
|
||||
ldap:
|
||||
#Use LDAP authentication for user login
|
||||
enabled: "false"
|
||||
#URL of the LDAP server
|
||||
url: ""
|
||||
#Default user used to perform the user search
|
||||
bindDn: ""
|
||||
#Password used to perform the user search
|
||||
bindPassword: ""
|
||||
#Base location, where the user search will be performed
|
||||
searchBase: ""
|
||||
#The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.
|
||||
searchQuery: ""
|
||||
#Group required for administrative access.
|
||||
adminGroups: ""
|
||||
#LDAP attribute name for the groups, an user is a member of. This is used when checking for the admin group.
|
||||
fieldNameMemberOf: memberOf
|
||||
#LDAP attribute name for the email of an user.
|
||||
fieldNameEmail: userPrincipalName
|
||||
oauth:
|
||||
#Allow users to register via social login
|
||||
allowRegistration: "true"
|
||||
#Whether to ignore TOTP when user using social login
|
||||
ignoreTotp: "true"
|
||||
#Whether to disable password login
|
||||
#Make sure that an OAuth provider is properly configured before activating this configuration to avoid being locked out.
|
||||
disablePassword: "false"
|
||||
#Whether GitHub login is enabled
|
||||
github-enabled: "false"
|
||||
#Client ID of the GitHub OAuth app
|
||||
github-clientId: ""
|
||||
#Client secret of the GitHub OAuth app
|
||||
github-clientSecret: ""
|
||||
#Whether Google login is enabled
|
||||
google-enabled: "false"
|
||||
#Client ID of the Google OAuth app
|
||||
google-clientId: ""
|
||||
#Client secret of the Google OAuth app
|
||||
google-clientSecret: ""
|
||||
#Whether Microsoft login is enabled
|
||||
microsoft-enabled: "false"
|
||||
#Tenant ID of the Microsoft OAuth app
|
||||
#common: Users with both a personal Microsoft account and a work or school account from Microsoft Entra ID can sign in to the application. organizations: Only users with work or school accounts from Microsoft Entra ID can sign in to the application.
|
||||
#consumers: Only users with a personal Microsoft account can sign in to the application.
|
||||
#domain name of the Microsoft Entra tenant or the tenant ID in GUID format: Only users from a specific Microsoft Entra tenant (directory members with a work or school account or directory guests with a personal Microsoft account) can sign in to the application.
|
||||
microsoft-tenant: common
|
||||
#Client ID of the Microsoft OAuth app
|
||||
microsoft-clientId: ""
|
||||
#Client secret of the Microsoft OAuth app
|
||||
microsoft-clientSecret: ""
|
||||
#Whether Discord login is enabled
|
||||
discord-enabled: "false"
|
||||
#Limit signing in to users in a specific server. Leave it blank to disable.
|
||||
discord-limitedGuild: ""
|
||||
#Limit signing in to specific users by their Discord ID. Leave it blank to disable.
|
||||
discord-limitedUsers: ""
|
||||
#Client ID of the Discord OAuth app
|
||||
discord-clientId: ""
|
||||
#Client secret of the Discord OAuth app
|
||||
discord-clientSecret: ""
|
||||
#Whether OpenID Connect login is enabled
|
||||
oidc-enabled: "false"
|
||||
#Discovery URI of the OpenID Connect OAuth app
|
||||
oidc-discoveryUri: ""
|
||||
#Whether the “Sign out” button will sign out from the OpenID Connect provider
|
||||
oidc-signOut: "false"
|
||||
#Scopes which should be requested from the OpenID Connect provider.
|
||||
oidc-scope: openid email profile
|
||||
#Username claim in OpenID Connect ID token. Leave it blank if you don't know what this config is.
|
||||
oidc-usernameClaim: ""
|
||||
#Must be a valid JMES path referencing an array of roles. Managing access rights using OpenID Connect roles is only recommended if no other identity provider is configured and password login is disabled. Leave it blank if you don't know what this config is.
|
||||
oidc-rolePath: ""
|
||||
#Role required for general access. Must be present in a user’s roles for them to log in. Leave it blank if you don't know what this config is.
|
||||
oidc-roleGeneralAccess: ""
|
||||
#Role required for administrative access. Must be present in a user’s roles for them to access the admin panel. Leave it blank if you don't know what this config is.
|
||||
oidc-roleAdminAccess: ""
|
||||
#Client ID of the OpenID Connect OAuth app
|
||||
oidc-clientId: ""
|
||||
#Client secret of the OpenID Connect OAuth app
|
||||
oidc-clientSecret: ""
|
||||
s3:
|
||||
#Whether S3 should be used to store the shared files instead of the local file system.
|
||||
enabled: "false"
|
||||
#The URL of the S3 bucket.
|
||||
endpoint: ""
|
||||
#The region of the S3 bucket.
|
||||
region: ""
|
||||
#The name of the S3 bucket.
|
||||
bucketName: ""
|
||||
#The default path which should be used to store the files in the S3 bucket.
|
||||
bucketPath: ""
|
||||
#The key which allows you to access the S3 bucket.
|
||||
key: ""
|
||||
#The secret which allows you to access the S3 bucket.
|
||||
secret: ""
|
||||
#Turn off for backends that do not support checksum (e.g. B2).
|
||||
useChecksum: "true"
|
||||
legal:
|
||||
#Whether to show a link to imprint and privacy policy in the footer.
|
||||
enabled: "false"
|
||||
#The text which should be shown in the imprint. Supports Markdown. Leave blank to link to an external imprint page.
|
||||
imprintText: ""
|
||||
#If you already have an imprint page you can link it here instead of using the text field.
|
||||
imprintUrl: ""
|
||||
#The text which should be shown in the privacy policy. Supports Markdown. Leave blank to link to an external privacy policy page.
|
||||
privacyPolicyText: ""
|
||||
#If you already have a privacy policy page you can link it here instead of using the text field.
|
||||
privacyPolicyUrl: ""
|
||||
#This configuration is used to create the initial user when the application is started for the first time.
|
||||
#Make sure to change at least the password as soon as you log in!
|
||||
initUser:
|
||||
enabled: false
|
||||
username: admin
|
||||
email: admin@example.com
|
||||
password: my-secure-password
|
||||
isAdmin: true
|
||||
ldapDN: ""
|
||||
@@ -1,4 +1,4 @@
|
||||
files:
|
||||
- source: /frontend/src/i18n/translations/en-US.ts
|
||||
translation: /%original_path%/%locale%.ts
|
||||
pull_request_title: "chore(translations): update translations via Crowdin"
|
||||
pull_request_title: 'chore(translations): update translations via Crowdin'
|
||||
|
||||
12
docker-compose.local.yml
Normal file
12
docker-compose.local.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
pingvin-share:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3001:3000
|
||||
environment:
|
||||
- TRUST_PROXY=false
|
||||
volumes:
|
||||
- "./data:/opt/app/backend/data"
|
||||
- "./data/images:/opt/app/frontend/public/img"
|
||||
# - "./config.yaml:/opt/app/config.yaml"
|
||||
@@ -1,18 +1,15 @@
|
||||
services:
|
||||
pingvin-share:
|
||||
image: stonith404/pingvin-share
|
||||
image: stonith404/pingvin-share # or ghcr.io/stonith404/pingvin-share
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
- TRUST_PROXY=false # Set to true if a reverse proxy is in front of the container
|
||||
volumes:
|
||||
- "./data:/opt/app/backend/data"
|
||||
- "./data/images:/opt/app/frontend/public/img"
|
||||
# Optional: If you add ClamAV, uncomment the following to have ClamAV start first.
|
||||
# depends_on:
|
||||
# clamav:
|
||||
# condition: service_healthy
|
||||
# Optional: Add ClamAV (see README.md)
|
||||
# ClamAV is currently only available for AMD64 see https://github.com/Cisco-Talos/clamav/issues/482
|
||||
# clamav:
|
||||
# restart: unless-stopped
|
||||
# image: clamav/clamav
|
||||
# - "./config.yaml:/opt/app/config.yaml" # Add this line, if you want to configure pingvin-share via config file and not via UI
|
||||
|
||||
# To add ClamAV, to scan your shares for malicious files,
|
||||
# see https://stonith404.github.io/pingvin-share/setup/integrations/#clamav-docker-only
|
||||
|
||||
@@ -23,7 +23,7 @@ Before you submit the pull request for review please ensure that
|
||||
When `TYPE` can be:
|
||||
|
||||
- **feat** - is a new feature
|
||||
- **doc** - documentation only changes
|
||||
- **docs** - documentation only changes
|
||||
- **fix** - a bug fix
|
||||
- **refactor** - code change that neither fixes a bug nor adds a feature
|
||||
|
||||
|
||||
@@ -4,25 +4,52 @@ id: configuration
|
||||
|
||||
# Configuration
|
||||
|
||||
You can customize Pingvin Share like changing your domain by going to the configuration page in your admin dashboard `/admin/config`.
|
||||
## General configuration
|
||||
|
||||
#### Environment variables
|
||||
There are plenty of settings you can adjust to your needs. Pingvin Share can be configured in two ways:
|
||||
|
||||
### UI
|
||||
|
||||
You can change the settings in the UI (`/admin/config`)
|
||||
|
||||
### YAML file
|
||||
|
||||
You can set the configuration via a YAML file. If you choose this way, you won't be able to change the settings in the UI.
|
||||
|
||||
If you use Docker you can create a `config.yml` file based on the [`config.example.yaml`](https://github.com/stonith404/pingvin-share/blob/main/config.example.yaml) and mount it to `/opt/app/config.yaml` in the container.
|
||||
|
||||
If you run Pingvin Share without Docker, you can create a `config.yml` file based on the [`config.example.yaml`](https://github.com/stonith404/pingvin-share/blob/main/config.example.yaml) in the root directory of the project.
|
||||
|
||||
---
|
||||
|
||||
### Environment variables
|
||||
|
||||
For installation specific configuration, you can use environment variables. The following variables are available:
|
||||
|
||||
##### Backend
|
||||
#### Backend
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
||||
| `PORT` | `8080` | The port on which the backend listens. |
|
||||
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
|
||||
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
|
||||
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
|
||||
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
|
||||
| Variable | Default Value | Description |
|
||||
| ---------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `BACKEND_PORT` | `8080` | The port on which the backend listens. |
|
||||
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
|
||||
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
|
||||
| `CONFIG_FILE` | `../config.yaml` | Path to the configuration file |
|
||||
| `CLAMAV_HOST` | `127.0.0.1` or `clamav` when running with Docker | The IP address of the ClamAV server. See the [ClamAV docs](integrations.md#clamav) for more information. |
|
||||
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
|
||||
|
||||
##### Frontend
|
||||
#### Frontend
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| --------- | ----------------------- | ---------------------------------------- |
|
||||
| `PORT` | `3000` | The port on which the frontend listens. |
|
||||
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
|
||||
|
||||
#### Docker specific
|
||||
|
||||
Environment variables that are only available when running Pingvin Share with Docker.
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| ----------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TRUST_PROXY` | `false` | Whether Pingvin Share is behind a reverse proxy. If set to `true`, the `X-Forwarded-For` header is trusted. |
|
||||
| `CADDY_DISABLED` | `false` | Configures if Pingvin Share is starting built-in Caddy. If set to `true`, Caddy will not be started. If disabled, you must configure your reverse proxy to correctly map all paths. Refer to the [official Caddyfile](https://github.com/stonith404/pingvin-share/blob/main/reverse-proxy/Caddyfile) for guidance. |
|
||||
| `PUID` and `PGID` | `1000` | The user and group ID of the user who should run Pingvin Share inside the Docker container and owns the files that are mounted with the volume. You can get the `PUID` and `GUID` of your user on your host machine by using the command `id`. For more information see [this article](https://docs.linuxserver.io/general/understanding-puid-and-pgid/#using-the-variables). |
|
||||
|
||||
@@ -11,11 +11,21 @@ id: installation
|
||||
|
||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
### Installation with Portainer
|
||||
|
||||
1. In the **Stacks** menu, click the **Add stack** button
|
||||
2. Give you stack a name (ex. pingvinshare)
|
||||
3. In the web editor, paste the content of the [docker-compose](https://github.com/stonith404/pingvin-share/blob/main/docker-compose.yml) file.
|
||||
4. Edit the external port and the environment variables (optional).
|
||||
5. Click on **Deploy the stack**.
|
||||
|
||||
Your container is now listening on `http://localhost:<externalport>`, have fun with Pingvin Share 🐧!
|
||||
|
||||
### Stand-alone Installation
|
||||
|
||||
Required tools:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 22
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
||||
|
||||
@@ -37,9 +47,9 @@ cd ../frontend
|
||||
npm install
|
||||
npm run build
|
||||
API_URL=http://localhost:8080 # Set the URL of the backend, default: http://localhost:8080
|
||||
pm2 start --name="pingvin-share-frontend" .next/standalone/server.js
|
||||
pm2 start npm --name "pingvin-share-frontend" -- run start
|
||||
```
|
||||
|
||||
**Uploading Large Files**: By default, Pingvin Share uses a built-in reverse proxy to reduce the installation steps. However, this reverse proxy is not optimized for uploading large files. If you wish to upload larger files, you can either use the Docker installation or set up your own reverse proxy. An example configuration for Caddy can be found in `./Caddyfile`.
|
||||
**Uploading Large Files**: By default, Pingvin Share uses a built-in reverse proxy to reduce the installation steps. However, this reverse proxy is not optimized for uploading large files. If you wish to upload larger files, you can either use the Docker installation or set up your own reverse proxy. An example configuration for Caddy can be found in `./reverse-proxy/Caddyfile`.
|
||||
|
||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
@@ -4,12 +4,39 @@ id: integrations
|
||||
|
||||
# Integrations
|
||||
|
||||
#### ClamAV (Docker only)
|
||||
## ClamAV
|
||||
|
||||
ClamAV is used to scan shares for malicious files and remove them if found.
|
||||
|
||||
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
|
||||
Please note that ClamAV needs a lot of [resources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||
|
||||
### Docker
|
||||
|
||||
If you are already running ClamAV elsewhere, you can specify the `CLAMAV_HOST` environment variable to point to that instance.
|
||||
|
||||
Else you have to add the ClamAV container to the Pingvin Share Docker Compose stack:
|
||||
|
||||
1. Add the ClamAV container to the Docker Compose stack and start the container.
|
||||
|
||||
```diff
|
||||
services:
|
||||
pingvin-share:
|
||||
image: stonith404/pingvin-share
|
||||
...
|
||||
+ depends_on:
|
||||
+ clamav:
|
||||
+ condition: service_healthy
|
||||
|
||||
+ clamav:
|
||||
+ restart: unless-stopped
|
||||
+ image: clamav/clamav
|
||||
|
||||
```
|
||||
|
||||
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
|
||||
3. The Pingvin Share logs should now log "ClamAV is active"
|
||||
|
||||
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||
### Stand-Alone
|
||||
|
||||
1. Install ClamAV
|
||||
2. Specify the `CLAMAV_HOST` environment variable for the backend and restart the Pingvin Share backend.
|
||||
|
||||
@@ -40,9 +40,11 @@ Redirect URL: `https://<your-domain>/api/oauth/callback/discord`
|
||||
|
||||
### OpenID Connect
|
||||
|
||||
Generic OpenID Connect provider is also supported, we have tested it on Keycloak, Authentik and Casdoor.
|
||||
Generic OpenID Connect provider is also supported, we have tested it on Keycloak, Authentik, Casdoor and [Pocket ID](https://github.com/stonith404/pocket-id).
|
||||
|
||||
Redirect URL: `https://<your-domain>/api/oauth/callback/oidc`
|
||||
Redirect URI: `https://<your-domain>/api/oauth/callback/oidc`
|
||||
|
||||
Post Logout Redirect URI: `https://<your-domain>`
|
||||
|
||||
## Custom your OAuth 2 Provider
|
||||
|
||||
@@ -50,7 +52,7 @@ If our built-in providers don't meet your needs, you can create your own OAuth 2
|
||||
|
||||
### 1. Create config
|
||||
|
||||
Add your config (client id, client secret, etc.) in [`config.seed.ts`](../backend/prisma/seed/config.seed.ts):
|
||||
Add your config (client id, client secret, etc.) in [`config.seed.ts`](https://github.com/stonith404/pingvin-share/blob/main/backend/prisma/seed/config.seed.ts):
|
||||
|
||||
```ts
|
||||
const configVariables: ConfigVariables = {
|
||||
@@ -78,9 +80,9 @@ const configVariables: ConfigVariables = {
|
||||
|
||||
#### Generic OpenID Connect
|
||||
|
||||
If your provider supports OpenID connect, it's extremely easy to extend [`GenericOidcProvider`](../backend/src/oauth/provider/genericOidc.provider.ts) to add a new OpenID Connect provider.
|
||||
If your provider supports OpenID connect, it's extremely easy to extend [`GenericOidcProvider`](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/genericOidc.provider.ts) to add a new OpenID Connect provider.
|
||||
|
||||
The [Google provider](../backend/src/oauth/provider/google.provider.ts) and [Microsoft provider](../backend/src/oauth/provider/microsoft.provider.ts) are good examples.
|
||||
The [Google provider](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/google.provider.ts) and [Microsoft provider](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/microsoft.provider.ts) are good examples.
|
||||
|
||||
Here are some discovery URIs for popular providers:
|
||||
|
||||
@@ -94,13 +96,13 @@ Here are some discovery URIs for popular providers:
|
||||
|
||||
#### OAuth 2
|
||||
|
||||
If your provider only supports OAuth 2, you can implement [`OAuthProvider`](../backend/src/oauth/provider/oauthProvider.interface.ts) interface to add a new OAuth 2 provider.
|
||||
If your provider only supports OAuth 2, you can implement [`OAuthProvider`](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/oauthProvider.interface.ts) interface to add a new OAuth 2 provider.
|
||||
|
||||
The [GitHub provider](../backend/src/oauth/provider/github.provider.ts) and [Discord provider](../backend/src/oauth/provider/discord.provider.ts) are good examples.
|
||||
The [GitHub provider](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/github.provider.ts) and [Discord provider](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/discord.provider.ts) are good examples.
|
||||
|
||||
### 3. Register provider
|
||||
|
||||
Register your provider in [`OAuthModule`](../backend/src/oauth/oauth.module.ts) and [`OAuthSignInDto`](../backend/src/oauth/dto/oauthSignIn.dto.ts):
|
||||
Register your provider in [`OAuthModule`](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/oauth.module.ts) and [`OAuthSignInDto`](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/dto/oauthSignIn.dto.ts):
|
||||
|
||||
```ts
|
||||
@Module({
|
||||
@@ -140,7 +142,7 @@ export interface OAuthSignInDto {
|
||||
|
||||
### 4. Add frontend icon
|
||||
|
||||
Add an icon in [`oauth.util.tsx`](../frontend/src/utils/oauth.util.tsx).
|
||||
Add an icon in [`oauth.util.tsx`](https://github.com/stonith404/pingvin-share/blob/main/frontend/src/utils/oauth.util.tsx).
|
||||
|
||||
```tsx
|
||||
const getOAuthIcon = (provider: string) => {
|
||||
@@ -153,7 +155,7 @@ const getOAuthIcon = (provider: string) => {
|
||||
|
||||
### 5. Add i18n text
|
||||
|
||||
Add keys below to your i18n text in [locale file](../frontend/src/i18n/translations/en-US.ts).
|
||||
Add keys below to your i18n text in [locale file](https://github.com/stonith404/pingvin-share/blob/main/frontend/src/i18n/translations/en-US.ts).
|
||||
|
||||
- `signIn.oauth.YOUR_PROVIDER_NAME`
|
||||
- `account.card.oauth.YOUR_PROVIDER_NAME`
|
||||
|
||||
32
docs/docs/setup/s3.md
Normal file
32
docs/docs/setup/s3.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: s3
|
||||
---
|
||||
|
||||
# S3
|
||||
|
||||
You are able to add your preferred S3 provider, like AWS, DigitalOcean, Exoscale or Infomaniak. However, if you don't
|
||||
want to store your files on a S3 bucket, you don't have to. Consider that this feature is `DISABLED` per default.
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure your S3 provider and bucket by going to the configuration page in your admin dashboard `/admin/config/s3`.
|
||||
|
||||
| Key | Description | Value |
|
||||
|:-----------|:-------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------|
|
||||
| enabled | This property enables the storage location on your configured S3 bucket. | `true` |
|
||||
| endpoint | The host for your S3 bucket. Endpoint formats vary by provider and some may include the bucket name in the FQDN. Ensure this is configured correctly, as an incorrect value may break some features. | `sos-ch-dk-2.exo.io` |
|
||||
| region | This property is the region where the bucket is located. | `sos-ch-dk-2` |
|
||||
| bucketName | This property is the name of your S3 bucket. | `my-bucket` |
|
||||
| bucketPath | This property defines the folder where you want to store your files which are uploaded. Hint: Don't put a slash in the start or end. | `my/custom/path` (or leave it empty for root) |
|
||||
| key | This is the access key you need to access to your bucket. | `key-asdf` |
|
||||
| secret | This is the secret you need to access to your bucket. | `secret-asdf` |
|
||||
|
||||
Don't forget to save the configuration. :)
|
||||
|
||||
## ClamAV
|
||||
|
||||
Consider that ClamAV scans are not available at the moment if you store your files in a S3 bucket.
|
||||
|
||||
## ZIP
|
||||
|
||||
Creating ZIP archives is not currently supported if you store your files in an S3 bucket.
|
||||
@@ -14,6 +14,11 @@ As Pingvin Share is in early stage, see the release notes for breaking changes b
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
### Portainer
|
||||
|
||||
1. In your container page, click on Recreate.
|
||||
2. Check the Re-Pull image toggle.
|
||||
3. Click on Recreate.
|
||||
|
||||
#### Stand-alone
|
||||
|
||||
@@ -39,6 +44,6 @@ docker compose up -d
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run build
|
||||
API_URL=http://localhost:8080 # Set the URL of the backend, default: http://localhost:8080
|
||||
pm2 restart pingvin-share-frontend
|
||||
```
|
||||
Note that environment variables are not picked up when using pm2 restart, if you actually want to change configs, you need to run ````pm2 --update-env restart````
|
||||
|
||||
@@ -7,7 +7,7 @@ const config: Config = {
|
||||
tagline:
|
||||
"Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.",
|
||||
favicon: "img/pingvinshare.svg",
|
||||
|
||||
|
||||
url: "https://stonith404.github.io",
|
||||
baseUrl: "/pingvin-share/",
|
||||
organizationName: "stonith404",
|
||||
@@ -37,6 +37,9 @@ const config: Config = {
|
||||
|
||||
themeConfig: {
|
||||
image: "img/pingvinshare.svg",
|
||||
colorMode: {
|
||||
respectPrefersColorScheme: true,
|
||||
},
|
||||
navbar: {
|
||||
title: "Pingvin Share",
|
||||
logo: {
|
||||
|
||||
828
docs/package-lock.json
generated
828
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"deploy": "GIT_USER=stonith404 docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
@@ -15,19 +15,19 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.4.0",
|
||||
"@docusaurus/preset-classic": "3.4.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
"@docusaurus/core": "3.5.2",
|
||||
"@docusaurus/preset-classic": "3.5.2",
|
||||
"@mdx-js/react": "^3.0.1",
|
||||
"clsx": "^2.1.1",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.4.0",
|
||||
"@docusaurus/tsconfig": "3.4.0",
|
||||
"@docusaurus/types": "3.4.0",
|
||||
"typescript": "~5.2.2"
|
||||
"@docusaurus/module-type-aliases": "3.5.2",
|
||||
"@docusaurus/tsconfig": "3.5.2",
|
||||
"@docusaurus/types": "3.5.2",
|
||||
"typescript": "~5.6.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -36,6 +36,10 @@ const sidebars: SidebarsConfig = {
|
||||
type: "doc",
|
||||
id: "setup/oauth2login",
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "setup/s3",
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "setup/upgrading",
|
||||
@@ -56,6 +60,11 @@ const sidebars: SidebarsConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
label: "Demo",
|
||||
href: "https://pingvin-share.dev.eliasschneider.com",
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
label: "Discord",
|
||||
|
||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@@ -18,7 +18,4 @@ module.exports = withPWA({
|
||||
output: "standalone", env: {
|
||||
VERSION: version,
|
||||
},
|
||||
serverRuntimeConfig: {
|
||||
apiURL: process.env.API_URL ?? 'http://localhost:8080',
|
||||
},
|
||||
});
|
||||
|
||||
3549
frontend/package-lock.json
generated
3549
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pingvin-share-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.13.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -9,7 +9,7 @@
|
||||
"format": "prettier --end-of-line=auto --write \"src/**/*.ts*\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@mantine/core": "^6.0.21",
|
||||
"@mantine/dropzone": "^6.0.21",
|
||||
@@ -18,37 +18,37 @@
|
||||
"@mantine/modals": "^6.0.21",
|
||||
"@mantine/next": "^6.0.21",
|
||||
"@mantine/notifications": "^6.0.21",
|
||||
"axios": "^1.7.2",
|
||||
"cookies-next": "^2.1.2",
|
||||
"axios": "^1.7.7",
|
||||
"cookies-next": "^4.2.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"jose": "^4.15.5",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"markdown-to-jsx": "^7.4.7",
|
||||
"jose": "^5.9.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"markdown-to-jsx": "^7.5.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.30.1",
|
||||
"next": "^14.2.3",
|
||||
"next": "^14.2.26",
|
||||
"next-http-proxy-middleware": "^1.2.6",
|
||||
"next-pwa": "^5.6.0",
|
||||
"p-limit": "^4.0.0",
|
||||
"p-limit": "^6.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-intl": "^6.6.8",
|
||||
"sharp": "^0.33.4",
|
||||
"sharp": "^0.33.5",
|
||||
"yup": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/node": "20.12.12",
|
||||
"@types/react": "18.3.2",
|
||||
"@types/node": "22.5.5",
|
||||
"@types/react": "18.3.7",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@typescript-eslint/parser": "^7.10.0",
|
||||
"axios": "^1.7.2",
|
||||
"@typescript-eslint/parser": "^8.6.0",
|
||||
"axios": "^1.7.7",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "^13.5.6",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"prettier": "^3.2.5",
|
||||
"tar": "^6.2.1",
|
||||
"typescript": "^5.4.5"
|
||||
"eslint-config-next": "^14.2.12",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tar": "^7.4.3",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ const CreateEnableTotpModal = ({
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(options.secret);
|
||||
toast.success("Copied to clipboard");
|
||||
toast.success(t("common.notify.copied"));
|
||||
}}
|
||||
>
|
||||
{options.secret}
|
||||
|
||||
@@ -5,10 +5,9 @@ import { translateOutsideContext } from "../../hooks/useTranslate.hook";
|
||||
const showReverseShareLinkModal = (
|
||||
modals: ModalsContextProps,
|
||||
reverseShareToken: string,
|
||||
appUrl: string,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
const link = `${appUrl}/upload/${reverseShareToken}`;
|
||||
const link = `${window.location.origin}/upload/${reverseShareToken}`;
|
||||
return modals.openModal({
|
||||
title: t("account.reverseShares.modal.reverse-share-link"),
|
||||
children: (
|
||||
|
||||
@@ -11,11 +11,10 @@ import CopyTextField from "../upload/CopyTextField";
|
||||
const showShareInformationsModal = (
|
||||
modals: ModalsContextProps,
|
||||
share: MyShare,
|
||||
appUrl: string,
|
||||
maxShareSize: number,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
const link = `${appUrl}/s/${share.id}`;
|
||||
const link = `${window.location.origin}/s/${share.id}`;
|
||||
|
||||
const formattedShareSize = byteToHumanSizeString(share.size);
|
||||
const formattedMaxShareSize = byteToHumanSizeString(maxShareSize);
|
||||
|
||||
@@ -2,13 +2,9 @@ import { Stack, TextInput } from "@mantine/core";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { translateOutsideContext } from "../../hooks/useTranslate.hook";
|
||||
|
||||
const showShareLinkModal = (
|
||||
modals: ModalsContextProps,
|
||||
shareId: string,
|
||||
appUrl: string,
|
||||
) => {
|
||||
const showShareLinkModal = (modals: ModalsContextProps, shareId: string) => {
|
||||
const t = translateOutsideContext();
|
||||
const link = `${appUrl}/s/${shareId}`;
|
||||
const link = `${window.location.origin}/s/${shareId}`;
|
||||
return modals.openModal({
|
||||
title: t("account.shares.modal.share-link"),
|
||||
children: (
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { AdminConfig, UpdateConfig } from "../../../types/config.type";
|
||||
import { stringToTimespan, timespanToString } from "../../../utils/date.util";
|
||||
import FileSizeInput from "../../core/FileSizeInput";
|
||||
import TimespanInput from "../../core/TimespanInput";
|
||||
|
||||
const AdminConfigInput = ({
|
||||
configVariable,
|
||||
@@ -38,9 +41,11 @@ const AdminConfigInput = ({
|
||||
{configVariable.type == "string" &&
|
||||
(configVariable.obscured ? (
|
||||
<PasswordInput
|
||||
autoComplete="new-password"
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
disabled={!configVariable.allowEdit}
|
||||
{...form.getInputProps("stringValue")}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||
/>
|
||||
@@ -49,6 +54,7 @@ const AdminConfigInput = ({
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
disabled={!configVariable.allowEdit}
|
||||
{...form.getInputProps("stringValue")}
|
||||
placeholder={configVariable.defaultValue}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||
@@ -60,6 +66,7 @@ const AdminConfigInput = ({
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
disabled={!configVariable.allowEdit}
|
||||
autosize
|
||||
{...form.getInputProps("textValue")}
|
||||
placeholder={configVariable.defaultValue}
|
||||
@@ -69,18 +76,40 @@ const AdminConfigInput = ({
|
||||
{configVariable.type == "number" && (
|
||||
<NumberInput
|
||||
{...form.getInputProps("numberValue")}
|
||||
disabled={!configVariable.allowEdit}
|
||||
placeholder={configVariable.defaultValue}
|
||||
onChange={(number) => onValueChange(configVariable, number)}
|
||||
w={201}
|
||||
/>
|
||||
)}
|
||||
{configVariable.type == "filesize" && (
|
||||
<FileSizeInput
|
||||
{...form.getInputProps("numberValue")}
|
||||
disabled={!configVariable.allowEdit}
|
||||
value={parseInt(configVariable.value ?? configVariable.defaultValue)}
|
||||
onChange={(bytes) => onValueChange(configVariable, bytes)}
|
||||
w={201}
|
||||
/>
|
||||
)}
|
||||
{configVariable.type == "boolean" && (
|
||||
<>
|
||||
<Switch
|
||||
disabled={!configVariable.allowEdit}
|
||||
{...form.getInputProps("booleanValue", { type: "checkbox" })}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.checked)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{configVariable.type == "timespan" && (
|
||||
<TimespanInput
|
||||
value={stringToTimespan(configVariable.value)}
|
||||
disabled={!configVariable.allowEdit}
|
||||
onChange={(timespan) =>
|
||||
onValueChange(configVariable, timespanToString(timespan))
|
||||
}
|
||||
w={201}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,21 +13,27 @@ import Link from "next/link";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
TbAt,
|
||||
TbBinaryTree,
|
||||
TbBucket,
|
||||
TbMail,
|
||||
TbScale,
|
||||
TbServerBolt,
|
||||
TbSettings,
|
||||
TbShare,
|
||||
TbSocial,
|
||||
TbSquare,
|
||||
TbBinaryTree,
|
||||
} from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const categories = [
|
||||
{ name: "General", icon: <TbSquare /> },
|
||||
{ name: "General", icon: <TbSettings /> },
|
||||
{ name: "Email", icon: <TbMail /> },
|
||||
{ name: "Share", icon: <TbShare /> },
|
||||
{ name: "SMTP", icon: <TbAt /> },
|
||||
{ name: "OAuth", icon: <TbSocial /> },
|
||||
{ name: "LDAP", icon: <TbBinaryTree /> },
|
||||
{ name: "S3", icon: <TbBucket /> },
|
||||
{ name: "Legal", icon: <TbScale /> },
|
||||
{ name: "Cache", icon: <TbServerBolt /> },
|
||||
];
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
|
||||
@@ -89,15 +89,11 @@ const ManageShareTable = ({
|
||||
onClick={() => {
|
||||
if (window.isSecureContext) {
|
||||
clipboard.copy(
|
||||
`${config.get("general.appUrl")}/s/${share.id}`,
|
||||
`${window.location.origin}/s/${share.id}`,
|
||||
);
|
||||
toast.success(t("common.notify.copied"));
|
||||
toast.success(t("common.notify.copied-link"));
|
||||
} else {
|
||||
showShareLinkModal(
|
||||
modals,
|
||||
share.id,
|
||||
config.get("general.appUrl"),
|
||||
);
|
||||
showShareLinkModal(modals, share.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Container,
|
||||
createStyles,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
@@ -15,7 +16,7 @@ import { useForm, yupResolver } from "@mantine/form";
|
||||
import { showNotification } from "@mantine/notifications";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbInfoCircle } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
@@ -24,8 +25,8 @@ import useUser from "../../hooks/user.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import { getOAuthIcon, getOAuthUrl } from "../../utils/oauth.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
import { safeRedirectPath } from "../../utils/router.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
signInWith: {
|
||||
@@ -74,14 +75,13 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
const { refreshUser } = useUser();
|
||||
const { classes } = useStyles();
|
||||
|
||||
const [oauth, setOAuth] = React.useState<string[]>([]);
|
||||
const [oauthProviders, setOauthProviders] = useState<string[] | null>(null);
|
||||
const [isRedirectingToOauthProvider, setIsRedirectingToOauthProvider] =
|
||||
useState(false);
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
emailOrUsername: yup.string().required(t("common.error.field-required")),
|
||||
password: yup
|
||||
.string()
|
||||
.min(8, t("common.error.too-short", { length: 8 }))
|
||||
.required(t("common.error.field-required")),
|
||||
password: yup.string().required(t("common.error.field-required")),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
@@ -94,7 +94,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
await authService
|
||||
.signIn(email, password)
|
||||
.signIn(email.trim(), password.trim())
|
||||
.then(async (response) => {
|
||||
if (response.data["loginToken"]) {
|
||||
// Prompt the user to enter their totp code
|
||||
@@ -118,15 +118,34 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
.catch(toast.axiosError);
|
||||
};
|
||||
|
||||
const getAvailableOAuth = async () => {
|
||||
const oauth = await authService.getAvailableOAuth();
|
||||
setOAuth(oauth.data);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
getAvailableOAuth().catch(toast.axiosError);
|
||||
useEffect(() => {
|
||||
authService
|
||||
.getAvailableOAuth()
|
||||
.then((providers) => {
|
||||
setOauthProviders(providers.data);
|
||||
if (
|
||||
providers.data.length === 1 &&
|
||||
config.get("oauth.disablePassword")
|
||||
) {
|
||||
setIsRedirectingToOauthProvider(true);
|
||||
router.push(getOAuthUrl(window.location.origin, providers.data[0]));
|
||||
}
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
}, []);
|
||||
|
||||
if (!oauthProviders) return null;
|
||||
|
||||
if (isRedirectingToOauthProvider)
|
||||
return (
|
||||
<Group align="center" position="center">
|
||||
<Loader size="sm" />
|
||||
<Text align="center">
|
||||
<FormattedMessage id="common.text.redirecting" />
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title order={2} align="center" weight={900}>
|
||||
@@ -170,7 +189,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{oauth.length > 0 && (
|
||||
{oauthProviders.length > 0 && (
|
||||
<Stack mt={config.get("oauth.disablePassword") ? undefined : "xl"}>
|
||||
{config.get("oauth.disablePassword") ? (
|
||||
<Group align="center" className={classes.signInWith}>
|
||||
@@ -182,12 +201,12 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
</Group>
|
||||
)}
|
||||
<Group position="center">
|
||||
{oauth.map((provider) => (
|
||||
{oauthProviders.map((provider) => (
|
||||
<Button
|
||||
key={provider}
|
||||
component="a"
|
||||
title={t(`signIn.oauth.${provider}`)}
|
||||
href={getOAuthUrl(config.get("general.appUrl"), provider)}
|
||||
href={getOAuthUrl(window.location.origin, provider)}
|
||||
variant="light"
|
||||
fullWidth
|
||||
>
|
||||
|
||||
@@ -48,7 +48,7 @@ const SignUpForm = () => {
|
||||
|
||||
const signUp = async (email: string, username: string, password: string) => {
|
||||
await authService
|
||||
.signUp(email, username, password)
|
||||
.signUp(email.trim(), username.trim(), password.trim())
|
||||
.then(async () => {
|
||||
const user = await refreshUser();
|
||||
if (user?.isAdmin) {
|
||||
|
||||
81
frontend/src/components/core/FileSizeInput.tsx
Normal file
81
frontend/src/components/core/FileSizeInput.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NativeSelect, NumberInput } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
|
||||
const multipliers = {
|
||||
B: 1,
|
||||
KB: 1000,
|
||||
KiB: 1024,
|
||||
MB: 1000 ** 2,
|
||||
MiB: 1024 ** 2,
|
||||
GB: 1000 ** 3,
|
||||
GiB: 1024 ** 3,
|
||||
TB: 1000 ** 4,
|
||||
TiB: 1024 ** 4,
|
||||
};
|
||||
|
||||
const units = (
|
||||
["B", "KB", "KiB", "MB", "MiB", "GB", "GiB", "TB", "TiB"] as const
|
||||
).map((unit) => ({ label: unit, value: unit }));
|
||||
|
||||
function getLargestApplicableUnit(value: number) {
|
||||
return (
|
||||
units.findLast((unit) => value % multipliers[unit.value] === 0) || units[0]
|
||||
);
|
||||
}
|
||||
|
||||
const FileSizeInput = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
...restProps
|
||||
}: {
|
||||
label?: string;
|
||||
value: number;
|
||||
onChange: (number: number) => void;
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
const [unit, setUnit] = useState(getLargestApplicableUnit(value).value);
|
||||
const [inputValue, setInputValue] = useState(value / multipliers[unit]);
|
||||
const unitSelect = (
|
||||
<NativeSelect
|
||||
data={units}
|
||||
value={unit}
|
||||
rightSectionWidth={28}
|
||||
styles={{
|
||||
input: {
|
||||
fontWeight: 500,
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
width: 76,
|
||||
marginRight: -2,
|
||||
},
|
||||
}}
|
||||
onChange={(event) => {
|
||||
const unit = event.currentTarget
|
||||
.value as (typeof units)[number]["value"];
|
||||
setUnit(unit);
|
||||
onChange(multipliers[unit] * inputValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<NumberInput
|
||||
label={label}
|
||||
value={inputValue}
|
||||
min={1}
|
||||
max={999999}
|
||||
precision={0}
|
||||
rightSection={unitSelect}
|
||||
rightSectionWidth={76}
|
||||
onChange={(value) => {
|
||||
const inputVal = value || 0;
|
||||
setInputValue(inputVal);
|
||||
onChange(multipliers[unit] * inputVal);
|
||||
}}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileSizeInput;
|
||||
88
frontend/src/components/core/TimespanInput.tsx
Normal file
88
frontend/src/components/core/TimespanInput.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from "react";
|
||||
import { Timespan } from "../../types/timespan.type";
|
||||
import { NativeSelect, NumberInput } from "@mantine/core";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
|
||||
const TimespanInput = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
...restProps
|
||||
}: {
|
||||
label?: string;
|
||||
value: Timespan;
|
||||
onChange: (timespan: Timespan) => void;
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
const [unit, setUnit] = useState(value.unit);
|
||||
const [inputValue, setInputValue] = useState(value.value);
|
||||
const t = useTranslate();
|
||||
|
||||
const version = inputValue == 1 ? "singular" : "plural";
|
||||
const unitSelect = (
|
||||
<NativeSelect
|
||||
data={[
|
||||
{
|
||||
value: "minutes",
|
||||
label: t(`upload.modal.expires.minute-${version}`),
|
||||
},
|
||||
{
|
||||
value: "hours",
|
||||
label: t(`upload.modal.expires.hour-${version}`),
|
||||
},
|
||||
{
|
||||
value: "days",
|
||||
label: t(`upload.modal.expires.day-${version}`),
|
||||
},
|
||||
{
|
||||
value: "weeks",
|
||||
label: t(`upload.modal.expires.week-${version}`),
|
||||
},
|
||||
{
|
||||
value: "months",
|
||||
label: t(`upload.modal.expires.month-${version}`),
|
||||
},
|
||||
{
|
||||
value: "years",
|
||||
label: t(`upload.modal.expires.year-${version}`),
|
||||
},
|
||||
]}
|
||||
value={unit}
|
||||
rightSectionWidth={28}
|
||||
styles={{
|
||||
input: {
|
||||
fontWeight: 500,
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
width: 120,
|
||||
marginRight: -2,
|
||||
},
|
||||
}}
|
||||
onChange={(event) => {
|
||||
const unit = event.currentTarget.value as Timespan["unit"];
|
||||
setUnit(unit);
|
||||
onChange({ value: inputValue, unit });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<NumberInput
|
||||
label={label}
|
||||
value={inputValue}
|
||||
min={0}
|
||||
max={999999}
|
||||
precision={0}
|
||||
rightSection={unitSelect}
|
||||
rightSectionWidth={120}
|
||||
onChange={(value) => {
|
||||
const inputVal = value || 0;
|
||||
setInputValue(inputVal);
|
||||
onChange({ value: inputVal, unit });
|
||||
}}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimespanInput;
|
||||
62
frontend/src/components/footer/Footer.tsx
Normal file
62
frontend/src/components/footer/Footer.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Anchor, Footer as MFooter, SimpleGrid, Text } from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
|
||||
const Footer = () => {
|
||||
const t = useTranslate();
|
||||
const config = useConfig();
|
||||
const hasImprint = !!(
|
||||
config.get("legal.imprintUrl") || config.get("legal.imprintText")
|
||||
);
|
||||
const hasPrivacy = !!(
|
||||
config.get("legal.privacyPolicyUrl") ||
|
||||
config.get("legal.privacyPolicyText")
|
||||
);
|
||||
const imprintUrl =
|
||||
(!config.get("legal.imprintText") && config.get("legal.imprintUrl")) ||
|
||||
"/imprint";
|
||||
const privacyUrl =
|
||||
(!config.get("legal.privacyPolicyText") &&
|
||||
config.get("legal.privacyPolicyUrl")) ||
|
||||
"/privacy";
|
||||
|
||||
const isMobile = useMediaQuery("(max-width: 700px)");
|
||||
|
||||
return (
|
||||
<MFooter height="auto" py={6} px="xl" zIndex={100}>
|
||||
<SimpleGrid cols={isMobile ? 2 : 3} m={0}>
|
||||
{!isMobile && <div></div>}
|
||||
<Text size="xs" color="dimmed" align={isMobile ? "left" : "center"}>
|
||||
Powered by{" "}
|
||||
<Anchor
|
||||
size="xs"
|
||||
href="https://github.com/stonith404/pingvin-share"
|
||||
target="_blank"
|
||||
>
|
||||
Pingvin Share
|
||||
</Anchor>
|
||||
</Text>
|
||||
<div>
|
||||
{config.get("legal.enabled") && (
|
||||
<Text size="xs" color="dimmed" align="right">
|
||||
{hasImprint && (
|
||||
<Anchor size="xs" href={imprintUrl}>
|
||||
{t("imprint.title")}
|
||||
</Anchor>
|
||||
)}
|
||||
{hasImprint && hasPrivacy && " • "}
|
||||
{hasPrivacy && (
|
||||
<Anchor size="xs" href={privacyUrl}>
|
||||
{t("privacy.title")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
</MFooter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -11,7 +11,9 @@ import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
import { FileMetaData } from "../../types/File.type";
|
||||
import { Share } from "../../types/share.type";
|
||||
@@ -19,8 +21,6 @@ import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
import TableSortIcon, { TableSort } from "../core/SortIcon";
|
||||
import showFilePreviewModal from "./modals/showFilePreviewModal";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const FileList = ({
|
||||
files,
|
||||
@@ -65,13 +65,13 @@ const FileList = ({
|
||||
};
|
||||
|
||||
const copyFileLink = (file: FileMetaData) => {
|
||||
const link = `${config.get("general.appUrl")}/api/shares/${
|
||||
const link = `${window.location.origin}/api/shares/${
|
||||
share.id
|
||||
}/files/${file.id}`;
|
||||
|
||||
if (window.isSecureContext) {
|
||||
clipboard.copy(link);
|
||||
toast.success(t("common.notify.copied"));
|
||||
toast.success(t("common.notify.copied-link"));
|
||||
} else {
|
||||
modals.openModal({
|
||||
title: t("share.modal.file-link"),
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import Markdown, { MarkdownToJSX } from "markdown-to-jsx";
|
||||
import Link from "next/link";
|
||||
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import api from "../../services/api.service";
|
||||
import Markdown from "markdown-to-jsx";
|
||||
|
||||
const FilePreviewContext = React.createContext<{
|
||||
shareId: string;
|
||||
@@ -132,7 +132,8 @@ const TextPreview = () => {
|
||||
.then((res) => setText(res.data ?? "Preview couldn't be fetched."));
|
||||
}, [shareId, fileId]);
|
||||
|
||||
const options = {
|
||||
const options: MarkdownToJSX.Options = {
|
||||
disableParsingRawHTML: true,
|
||||
overrides: {
|
||||
pre: {
|
||||
props: {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Col, Grid, NumberInput, Select } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
byteToUnitAndSize,
|
||||
unitAndSizeToByte,
|
||||
} from "../../utils/fileSize.util";
|
||||
|
||||
const FileSizeInput = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (number: number) => void;
|
||||
}) => {
|
||||
const [unit, setUnit] = useState("MB");
|
||||
const [size, setSize] = useState(100);
|
||||
|
||||
useEffect(() => {
|
||||
const { unit, size } = byteToUnitAndSize(value);
|
||||
setUnit(unit);
|
||||
setSize(size);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Grid align="flex-end">
|
||||
<Col xs={6}>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={99999}
|
||||
precision={0}
|
||||
variant="filled"
|
||||
label={label}
|
||||
value={size}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
setSize(value);
|
||||
onChange(unitAndSizeToByte(unit, value));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<Select
|
||||
data={[
|
||||
{ label: "B", value: "B" },
|
||||
{ label: "KB", value: "KB" },
|
||||
{ label: "MB", value: "MB" },
|
||||
{ label: "GB", value: "GB" },
|
||||
{ label: "TB", value: "TB" },
|
||||
]}
|
||||
value={unit}
|
||||
onChange={(value) => {
|
||||
setUnit(value!);
|
||||
onChange(unitAndSizeToByte(value!, size));
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileSizeInput;
|
||||
@@ -9,25 +9,27 @@ import {
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { getCookie, setCookie } from "cookies-next";
|
||||
import moment from "moment";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
import useTranslate, {
|
||||
translateOutsideContext,
|
||||
} from "../../../hooks/useTranslate.hook";
|
||||
import shareService from "../../../services/share.service";
|
||||
import { Timespan } from "../../../types/timespan.type";
|
||||
import { getExpirationPreview } from "../../../utils/date.util";
|
||||
import toast from "../../../utils/toast.util";
|
||||
import FileSizeInput from "../FileSizeInput";
|
||||
import FileSizeInput from "../../core/FileSizeInput";
|
||||
import showCompletedReverseShareModal from "./showCompletedReverseShareModal";
|
||||
import { getCookie, setCookie } from "cookies-next";
|
||||
|
||||
const showCreateReverseShareModal = (
|
||||
modals: ModalsContextProps,
|
||||
showSendEmailNotificationOption: boolean,
|
||||
maxExpirationInHours: number,
|
||||
maxExpiration: Timespan,
|
||||
getReverseShares: () => void,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
@@ -37,7 +39,7 @@ const showCreateReverseShareModal = (
|
||||
<Body
|
||||
showSendEmailNotificationOption={showSendEmailNotificationOption}
|
||||
getReverseShares={getReverseShares}
|
||||
maxExpirationInHours={maxExpirationInHours}
|
||||
maxExpiration={maxExpiration}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -46,11 +48,11 @@ const showCreateReverseShareModal = (
|
||||
const Body = ({
|
||||
getReverseShares,
|
||||
showSendEmailNotificationOption,
|
||||
maxExpirationInHours,
|
||||
maxExpiration,
|
||||
}: {
|
||||
getReverseShares: () => void;
|
||||
showSendEmailNotificationOption: boolean;
|
||||
maxExpirationInHours: number;
|
||||
maxExpiration: Timespan;
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
const t = useTranslate();
|
||||
@@ -65,6 +67,16 @@ const Body = ({
|
||||
simplified: !!(getCookie("reverse-share.simplified") ?? false),
|
||||
publicAccess: !!(getCookie("reverse-share.public-access") ?? true),
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
maxUseCount: yup
|
||||
.number()
|
||||
.typeError(t("common.error.invalid-number"))
|
||||
.min(1, t("common.error.number-too-small", { min: 1 }))
|
||||
.max(1000, t("common.error.number-too-large", { max: 1000 }))
|
||||
.required(t("common.error.field-required")),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const onSubmit = form.onSubmit(async (values) => {
|
||||
@@ -80,13 +92,17 @@ const Body = ({
|
||||
) as moment.unitOfTime.DurationConstructor,
|
||||
);
|
||||
if (
|
||||
maxExpirationInHours != 0 &&
|
||||
expirationDate.isAfter(moment().add(maxExpirationInHours, "hours"))
|
||||
maxExpiration.value != 0 &&
|
||||
expirationDate.isAfter(
|
||||
moment().add(maxExpiration.value, maxExpiration.unit),
|
||||
)
|
||||
) {
|
||||
form.setFieldError(
|
||||
"expiration_num",
|
||||
t("upload.modal.expires.error.too-long", {
|
||||
max: moment.duration(maxExpirationInHours, "hours").humanize(),
|
||||
max: moment
|
||||
.duration(maxExpiration.value, maxExpiration.unit)
|
||||
.humanize(),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ActionIcon, TextInput, Tooltip } from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useRef, useState } from "react";
|
||||
import { TbCheck, TbCopy } from "react-icons/tb";
|
||||
import { IoOpenOutline } from "react-icons/io5";
|
||||
import { TbCheck, TbCopy } from "react-icons/tb";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
@@ -18,7 +18,7 @@ function CopyTextField(props: { link: string }) {
|
||||
|
||||
const copyLink = () => {
|
||||
clipboard.copy(props.link);
|
||||
toast.success(t("common.notify.copied"));
|
||||
toast.success(t("common.notify.copied-link"));
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setCheckState(false);
|
||||
|
||||
@@ -13,7 +13,6 @@ import CopyTextField from "../CopyTextField";
|
||||
const showCompletedUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
share: CompletedShare,
|
||||
appUrl: string,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
return modals.openModal({
|
||||
@@ -21,16 +20,18 @@ const showCompletedUploadModal = (
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: t("upload.modal.completed.share-ready"),
|
||||
children: <Body share={share} appUrl={appUrl} />,
|
||||
children: <Body share={share} />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({ share, appUrl }: { share: CompletedShare; appUrl: string }) => {
|
||||
const Body = ({ share }: { share: CompletedShare }) => {
|
||||
const modals = useModals();
|
||||
const router = useRouter();
|
||||
const t = useTranslate();
|
||||
|
||||
const link = `${appUrl}/s/${share.id}`;
|
||||
const isReverseShare = !!router.query["reverseShareToken"];
|
||||
|
||||
const link = `${window.location.origin}/s/${share.id}`;
|
||||
|
||||
return (
|
||||
<Stack align="stretch">
|
||||
@@ -65,7 +66,11 @@ const Body = ({ share, appUrl }: { share: CompletedShare; appUrl: string }) => {
|
||||
<Button
|
||||
onClick={() => {
|
||||
modals.closeAll();
|
||||
router.push("/upload");
|
||||
if (isReverseShare) {
|
||||
router.reload();
|
||||
} else {
|
||||
router.push("/upload");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="common.button.done" />
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import moment from "moment";
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { TbAlertCircle } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
@@ -30,18 +30,18 @@ import shareService from "../../../services/share.service";
|
||||
import { FileUpload } from "../../../types/File.type";
|
||||
import { CreateShare } from "../../../types/share.type";
|
||||
import { getExpirationPreview } from "../../../utils/date.util";
|
||||
import React from "react";
|
||||
import toast from "../../../utils/toast.util";
|
||||
import { Timespan } from "../../../types/timespan.type";
|
||||
|
||||
const showCreateUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
options: {
|
||||
isUserSignedIn: boolean;
|
||||
isReverseShare: boolean;
|
||||
appUrl: string;
|
||||
allowUnauthenticatedShares: boolean;
|
||||
enableEmailRecepients: boolean;
|
||||
maxExpirationInHours: number;
|
||||
maxExpiration: Timespan;
|
||||
shareIdLength: number;
|
||||
simplified: boolean;
|
||||
},
|
||||
files: FileUpload[],
|
||||
@@ -74,18 +74,28 @@ const showCreateUploadModal = (
|
||||
});
|
||||
};
|
||||
|
||||
const generateLink = () =>
|
||||
Buffer.from(Math.random().toString(), "utf8")
|
||||
.toString("base64")
|
||||
.substring(10, 17);
|
||||
const generateShareId = (length: number = 16) => {
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
const randomArray = new Uint8Array(length >= 3 ? length : 3);
|
||||
crypto.getRandomValues(randomArray);
|
||||
randomArray.forEach((number) => {
|
||||
result += chars[number % chars.length];
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const generateAvailableLink = async (times = 10): Promise<string> => {
|
||||
const generateAvailableLink = async (
|
||||
shareIdLength: number,
|
||||
times: number = 10,
|
||||
): Promise<string> => {
|
||||
if (times <= 0) {
|
||||
throw new Error("Could not generate available link");
|
||||
}
|
||||
const _link = generateLink();
|
||||
const _link = generateShareId(shareIdLength);
|
||||
if (!(await shareService.isShareIdAvailable(_link))) {
|
||||
return await generateAvailableLink(times - 1);
|
||||
return await generateAvailableLink(shareIdLength, times - 1);
|
||||
} else {
|
||||
return _link;
|
||||
}
|
||||
@@ -101,16 +111,16 @@ const CreateUploadModalBody = ({
|
||||
options: {
|
||||
isUserSignedIn: boolean;
|
||||
isReverseShare: boolean;
|
||||
appUrl: string;
|
||||
allowUnauthenticatedShares: boolean;
|
||||
enableEmailRecepients: boolean;
|
||||
maxExpirationInHours: number;
|
||||
maxExpiration: Timespan;
|
||||
shareIdLength: number;
|
||||
};
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
const t = useTranslate();
|
||||
|
||||
const generatedLink = generateLink();
|
||||
const generatedLink = generateShareId(options.shareIdLength);
|
||||
|
||||
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
|
||||
|
||||
@@ -171,17 +181,20 @@ const CreateUploadModalBody = ({
|
||||
);
|
||||
|
||||
if (
|
||||
options.maxExpirationInHours != 0 &&
|
||||
options.maxExpiration.value != 0 &&
|
||||
(form.values.never_expires ||
|
||||
expirationDate.isAfter(
|
||||
moment().add(options.maxExpirationInHours, "hours"),
|
||||
moment().add(
|
||||
options.maxExpiration.value,
|
||||
options.maxExpiration.unit,
|
||||
),
|
||||
))
|
||||
) {
|
||||
form.setFieldError(
|
||||
"expiration_num",
|
||||
t("upload.modal.expires.error.too-long", {
|
||||
max: moment
|
||||
.duration(options.maxExpirationInHours, "hours")
|
||||
.duration(options.maxExpiration.value, options.maxExpiration.unit)
|
||||
.humanize(),
|
||||
}),
|
||||
);
|
||||
@@ -232,20 +245,26 @@ const CreateUploadModalBody = ({
|
||||
<Button
|
||||
style={{ flex: "0 0 auto" }}
|
||||
variant="outline"
|
||||
onClick={() => form.setFieldValue("link", generateLink())}
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
"link",
|
||||
generateShareId(options.shareIdLength),
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormattedMessage id="common.button.generate" />
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Text
|
||||
truncate
|
||||
italic
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{`${options.appUrl}/s/${form.values.link}`}
|
||||
{`${window.location.origin}/s/${form.values.link}`}
|
||||
</Text>
|
||||
{!options.isReverseShare && (
|
||||
<>
|
||||
@@ -312,7 +331,7 @@ const CreateUploadModalBody = ({
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
{options.maxExpirationInHours == 0 && (
|
||||
{options.maxExpiration.value == 0 && (
|
||||
<Checkbox
|
||||
label={t("upload.modal.expires.never-long")}
|
||||
{...form.getInputProps("never_expires")}
|
||||
@@ -370,9 +389,8 @@ const CreateUploadModalBody = ({
|
||||
placeholder={t("upload.modal.accordion.email.placeholder")}
|
||||
searchable
|
||||
creatable
|
||||
id="recipient_email"
|
||||
autoComplete="email"
|
||||
type="email"
|
||||
id="recipient-emails"
|
||||
inputMode="email"
|
||||
getCreateLabel={(query) => `+ ${query}`}
|
||||
onCreate={(query) => {
|
||||
if (!query.match(/^\S+@\S+\.\S+$/)) {
|
||||
@@ -392,7 +410,7 @@ const CreateUploadModalBody = ({
|
||||
{...form.getInputProps("recipients")}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Add email on comma or semicolon
|
||||
if (e.key === "," || e.key === ";") {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === ";") {
|
||||
e.preventDefault();
|
||||
const inputValue = (
|
||||
e.target as HTMLInputElement
|
||||
@@ -426,7 +444,7 @@ const CreateUploadModalBody = ({
|
||||
"upload.modal.accordion.security.password.placeholder",
|
||||
)}
|
||||
label={t("upload.modal.accordion.security.password.label")}
|
||||
autoComplete="off"
|
||||
autoComplete="new-password"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<NumberInput
|
||||
@@ -462,10 +480,10 @@ const SimplifiedCreateUploadModalModal = ({
|
||||
options: {
|
||||
isUserSignedIn: boolean;
|
||||
isReverseShare: boolean;
|
||||
appUrl: string;
|
||||
allowUnauthenticatedShares: boolean;
|
||||
enableEmailRecepients: boolean;
|
||||
maxExpirationInHours: number;
|
||||
maxExpiration: Timespan;
|
||||
shareIdLength: number;
|
||||
};
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
@@ -490,10 +508,12 @@ const SimplifiedCreateUploadModalModal = ({
|
||||
});
|
||||
|
||||
const onSubmit = form.onSubmit(async (values) => {
|
||||
const link = await generateAvailableLink().catch(() => {
|
||||
toast.error(t("upload.modal.link.error.taken"));
|
||||
return undefined;
|
||||
});
|
||||
const link = await generateAvailableLink(options.shareIdLength).catch(
|
||||
() => {
|
||||
toast.error(t("upload.modal.link.error.taken"));
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
if (!link) {
|
||||
return;
|
||||
|
||||
42
frontend/src/hooks/confirm-leave.hook.ts
Normal file
42
frontend/src/hooks/confirm-leave.hook.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const useConfirmLeave = ({
|
||||
message,
|
||||
enabled,
|
||||
}: {
|
||||
message: string;
|
||||
enabled: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Show confirmation dialog when route changes
|
||||
const handleRouteChange = () => {
|
||||
const confirmLeave = window.confirm(message);
|
||||
if (!confirmLeave) {
|
||||
router.events.emit("routeChangeError");
|
||||
throw "Route change aborted.";
|
||||
}
|
||||
};
|
||||
|
||||
// Show confirmation when the user tries to leave or reload the page
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = message;
|
||||
return message;
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", handleRouteChange);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
router.events.off("routeChangeStart", handleRouteChange);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [router, message, enabled]);
|
||||
};
|
||||
|
||||
export default useConfirmLeave;
|
||||
@@ -1,11 +1,14 @@
|
||||
import arabic from "./translations/ar-EG";
|
||||
import czech from "./translations/cs-CZ";
|
||||
import danish from "./translations/da-DK";
|
||||
import german from "./translations/de-DE";
|
||||
import greek from "./translations/el-GR";
|
||||
import english from "./translations/en-US";
|
||||
import spanish from "./translations/es-ES";
|
||||
import estonian from "./translations/et-EE";
|
||||
import finnish from "./translations/fi-FI";
|
||||
import french from "./translations/fr-FR";
|
||||
import croatian from "./translations/hr-HR";
|
||||
import hungarian from "./translations/hu-HU";
|
||||
import italian from "./translations/it-IT";
|
||||
import japanese from "./translations/ja-JP";
|
||||
@@ -15,14 +18,15 @@ import polish from "./translations/pl-PL";
|
||||
import portuguese from "./translations/pt-BR";
|
||||
import russian from "./translations/ru-RU";
|
||||
import slovenian from "./translations/sl-SI";
|
||||
import serbian from "./translations/sr-SP";
|
||||
import serbianLatin from "./translations/sr-CS";
|
||||
import serbianCyrillic from "./translations/sr-SP";
|
||||
import swedish from "./translations/sv-SE";
|
||||
import thai from "./translations/th-TH";
|
||||
import turkish from "./translations/tr-TR";
|
||||
import ukrainian from "./translations/uk-UA";
|
||||
import viatnamese from "./translations/vi-VN";
|
||||
import chineseSimplified from "./translations/zh-CN";
|
||||
import chineseTraditional from "./translations/zh-TW";
|
||||
import turkish from "./translations/tr-TR";
|
||||
import czech from "./translations/cs-CZ";
|
||||
|
||||
export const LOCALES = {
|
||||
ENGLISH: {
|
||||
@@ -86,9 +90,14 @@ export const LOCALES = {
|
||||
messages: thai,
|
||||
},
|
||||
SERBIAN: {
|
||||
name: "Srpski",
|
||||
name: "Српски",
|
||||
code: "sr-SP",
|
||||
messages: serbian,
|
||||
messages: serbianCyrillic,
|
||||
},
|
||||
SERBIAN_LATIN: {
|
||||
name: "Srpski",
|
||||
code: "sr-CS",
|
||||
messages: serbianLatin,
|
||||
},
|
||||
DUTCH: {
|
||||
name: "Nederlands",
|
||||
@@ -150,4 +159,19 @@ export const LOCALES = {
|
||||
code: "cs-CZ",
|
||||
messages: czech,
|
||||
},
|
||||
VIATNAMESE: {
|
||||
name: "Tiếng Việt",
|
||||
code: "vi-VN",
|
||||
messages: viatnamese,
|
||||
},
|
||||
CROATIAN: {
|
||||
name: "Hrvatski",
|
||||
code: "hr-HR",
|
||||
messages: croatian,
|
||||
},
|
||||
ESTONIAN: {
|
||||
name: "Eesti",
|
||||
code: "et-EE",
|
||||
messages: estonian,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
"navbar.upload": "رفع",
|
||||
"navbar.signin": "تسجيل الدخول",
|
||||
"navbar.home": "الصفحة الرئيسية",
|
||||
"navbar.signup": "إنشاء حساب",
|
||||
"navbar.signup": "Sign up",
|
||||
"navbar.links.shares": "مشاركاتي",
|
||||
"navbar.links.reverse": "مشاركاتي العكسية",
|
||||
"navbar.avatar.account": "حسابي",
|
||||
@@ -16,9 +16,9 @@ export default {
|
||||
"home.bullet.a.name": "استضافة ذاتية",
|
||||
"home.bullet.a.description": "قم باستضافة Pingvin Share على جهازك.",
|
||||
"home.bullet.b.name": "الخصوصية",
|
||||
"home.bullet.b.description": "ملفاتك تخصّك وحدك فقط، ولا ينبغي أبدًا أن تقع بأيدي طرفٍ ثالث.",
|
||||
"home.bullet.b.description": "Your files are yours and will never be accessed by third parties.",
|
||||
"home.bullet.c.name": "ليس هناك أية قيود على حجم الملفات",
|
||||
"home.bullet.c.description": "ارفع أي ملف تريده مهما كان حجمه كبيرًا. إن مساحة قرصك الصلب هي المحدد الوحيد هنا.",
|
||||
"home.bullet.c.description": "Upload files as big as you want. Only your hard drive will be your limit.",
|
||||
"home.button.start": "ابدأ",
|
||||
"home.button.source": "النص البرمجي المصدري",
|
||||
// END /
|
||||
@@ -58,12 +58,12 @@ export default {
|
||||
// /auth/reset-password
|
||||
"resetPassword.title": "نسيت كلمة سرّك؟",
|
||||
"resetPassword.description": "اكتب بريدك لتعيد تعيين كلمة السر.",
|
||||
"resetPassword.notify.success": "إذا كان هذا البريد مسجلًا لدينا فستصله الآن رسالة فيها رابط لإعادة تعيين كلمة السرّ.",
|
||||
"resetPassword.notify.success": "A message with a link to reset your password has been sent if the provided email exists.",
|
||||
"resetPassword.button.back": "العودة لصفحة تسجيل الدخول",
|
||||
"resetPassword.text.resetPassword": "إعادة تعيين كلمة السر",
|
||||
"resetPassword.text.enterNewPassword": "أدخل كلمة السر الجديدة",
|
||||
"resetPassword.input.password": "كلمة السر الجديدة",
|
||||
"resetPassword.notify.passwordReset": "أعدتَ تعيين كلمة السر بنجاح.",
|
||||
"resetPassword.notify.passwordReset": "Your password has been successfully reset.",
|
||||
// /account
|
||||
"account.title": "حسابي",
|
||||
"account.card.info.title": "معلومات الحساب",
|
||||
@@ -73,7 +73,7 @@ export default {
|
||||
"account.card.password.title": "كلمة السر",
|
||||
"account.card.password.old": "كلمة السر القديمة",
|
||||
"account.card.password.new": "كلمة السر الجديدة",
|
||||
"account.card.password.noPasswordSet": "ليس لحسابك كلمة سر. إذا أردت تسجيل الدخول باستخدام البريد وكلمة سر، فعليك أن تُعيِّن كلمة سر.",
|
||||
"account.card.password.noPasswordSet": "You do not have a password set. To sign in using your email and password, you need to create a password.",
|
||||
"account.notify.password.success": "غيرت كلمة السر بنجاح",
|
||||
"account.card.oauth.title": "الدخول بحساب تواصل اجتماعي",
|
||||
"account.card.oauth.github": "GitHub",
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
"account.card.oauth.unlink": "فك الربط",
|
||||
"account.card.oauth.unlinked": "تم فك الربط",
|
||||
"account.modal.unlink.title": "فك ربط الحساب",
|
||||
"account.modal.unlink.description": "قد يؤدي إلغاء ربط حساباتك الاجتماعية إلى فقدان وصولك لحسابك إذا كنت لا تتذكر اسم المستخدم وكلمة السر الخاصة بك.",
|
||||
"account.modal.unlink.description": "Unlinking your social accounts may cause you to lose your account if you don't remember your login credentials",
|
||||
"account.notify.oauth.unlinked.success": "تم فك الربط بنجاح",
|
||||
"account.card.security.title": "الأمان",
|
||||
"account.card.security.totp.enable.description": "اكتب كلمة سرّك لبدء تمكين TOTP",
|
||||
@@ -121,12 +121,15 @@ export default {
|
||||
"account.shares.table.name": "الاسم",
|
||||
"account.shares.table.description": "الوصف",
|
||||
"account.shares.table.visitors": "الزوار",
|
||||
"account.shares.table.expiresAt": "تاريخ انتهاء الصلاحية",
|
||||
"account.shares.table.createdAt": "تاريخ الإنشاء",
|
||||
"account.shares.table.expiresAt": "Expires on",
|
||||
"account.shares.table.createdAt": "Created on",
|
||||
"account.shares.table.size": "الحجم",
|
||||
"account.shares.table.password-protected": "Password protected",
|
||||
"account.shares.table.visitor-count": "{count} of {max}",
|
||||
"account.shares.table.expiry-never": "Never",
|
||||
"account.shares.modal.share-informations": "معلومات المشاركة",
|
||||
"account.shares.modal.share-link": "رابط المشاركة",
|
||||
"account.shares.modal.delete.title": "حذف المشاركة {share}",
|
||||
"account.shares.modal.delete.title": "Delete share: {share}",
|
||||
"account.shares.modal.delete.description": "هل تريد حذف هذه المشاركة حقاً؟",
|
||||
// END /account/shares
|
||||
// /account/reverseShares
|
||||
@@ -150,12 +153,12 @@ export default {
|
||||
"account.reverseShares.modal.expiration.year-singular": "سنة",
|
||||
"account.reverseShares.modal.expiration.year-plural": "سنوات",
|
||||
"account.reverseShares.modal.max-size.label": "الحد الأقصى لحجم المشاركة",
|
||||
"account.reverseShares.modal.send-email": "أرسل إشعارًا بالبريد",
|
||||
"account.reverseShares.modal.send-email.description": "إرسال إشعار بالبريد الإلكتروني عند إنشاء مشاركة باستخدام رابط المشاركة العكسي هذا.",
|
||||
"account.reverseShares.modal.send-email": "Send email notifications",
|
||||
"account.reverseShares.modal.send-email.description": "Sends you an email notification when a share is created with this reverse share link.",
|
||||
"account.reverseShares.modal.simplified": "Simple mode",
|
||||
"account.reverseShares.modal.simplified.description": "Make it easy for the person uploading the file to share it with you. They will be able to customize only the name and description of the share.",
|
||||
"account.reverseShares.modal.simplified.description": "Make it easy for the person uploading the file to share it with you. They will only be able to customize the name and description of the share.",
|
||||
"account.reverseShares.modal.public-access": "Public access",
|
||||
"account.reverseShares.modal.public-access.description": "Make the created shares with this reverse share public. If disabled, only you and the creator of the share can view it.",
|
||||
"account.reverseShares.modal.public-access.description": "Make the shares created with this reverse share public. If disabled, only you and the share creator will have access to view it.",
|
||||
"account.reverseShares.modal.max-use.label": "الحد الأقصى لعدد الاستخدامات",
|
||||
"account.reverseShares.modal.max-use.description": "أقصى عدد من المرّات التي يمكن فيها استخدام هذا الرابط لإنشاء مشاركة.",
|
||||
"account.reverseShare.never-expires": "لن تنتهي صلاحية هذه المشاركة العكسية أبدًا.",
|
||||
@@ -183,14 +186,14 @@ export default {
|
||||
"admin.users.table.username": "اسم المستخدم",
|
||||
"admin.users.table.email": "البريد",
|
||||
"admin.users.table.admin": "المدير",
|
||||
"admin.users.edit.update.title": "تحديث المستخدم {username}",
|
||||
"admin.users.edit.update.title": "Edit user: {username}",
|
||||
"admin.users.edit.update.admin-privileges": "صلاحيات المدير",
|
||||
"admin.users.edit.update.change-password.title": "تغيير كلمة السر",
|
||||
"admin.users.edit.update.change-password.field": "كلمة السر الجديدة",
|
||||
"admin.users.edit.update.change-password.button": "حفظ كلمة السر الجديدة",
|
||||
"admin.users.edit.update.notify.password.success": "غيرت كلمة السر بنجاح",
|
||||
"admin.users.edit.delete.title": "حذف المستخدم {username}",
|
||||
"admin.users.edit.delete.description": "هل تريد حقاً حذف هذا المستخدم وكل مشاركاته؟",
|
||||
"admin.users.edit.delete.title": "Delete user: {username} ?",
|
||||
"admin.users.edit.delete.description": "Do you really want to delete this user and all their shares?",
|
||||
// showCreateUserModal.tsx
|
||||
"admin.users.modal.create.title": "أنشئ مستخدمًا",
|
||||
"admin.users.modal.create.username": "اسم المستخدم",
|
||||
@@ -206,17 +209,20 @@ export default {
|
||||
"admin.shares.table.id": "معرّف المشاركة",
|
||||
"admin.shares.table.username": "المُنشئ",
|
||||
"admin.shares.table.visitors": "الزوار",
|
||||
"admin.shares.table.expires": "تاريخ انتهاء الصلاحية",
|
||||
"admin.shares.edit.delete.title": "حذف المشاركة {id}",
|
||||
"admin.shares.table.expires": "Expires on",
|
||||
"admin.shares.edit.delete.title": "Delete share: {id}",
|
||||
"admin.shares.edit.delete.description": "هل تريد حذف هذه المشاركة حقاً؟",
|
||||
// END /admin/shares
|
||||
// /upload
|
||||
"upload.title": "رفع",
|
||||
"upload.notify.confirm-leave": "Are you sure you want to leave this page? Your upload will be canceled.",
|
||||
"upload.notify.generic-error": "حدث خطأ أثناء إنهاء مشاركتك.",
|
||||
"upload.notify.count-failed": "فشل رفع {count} ملفات. تجري المحاولة مجددًا.",
|
||||
"upload.reverse-share.error.invalid.title": "Invalid reverse share link",
|
||||
"upload.reverse-share.error.invalid.description": "This reverse share has expired or is invalid.",
|
||||
// Dropzone.tsx
|
||||
"upload.dropzone.title": "رفع الملفات",
|
||||
"upload.dropzone.description": "اسحب الملفات إلى هنا لبدء مشاركتك. يمكننا فقط قبول الملفات التي لا يزيد حجمها عن {maxSize} بالمجمل.",
|
||||
"upload.dropzone.description": "Drag'n'drop files here to start your share. We only accept files up to {maxSize} in total.",
|
||||
"upload.dropzone.notify.file-too-big": "تتجاوز ملفاتك الحجم الأقصى للمشاركة والذي هو {maxSize}.",
|
||||
// FileList.tsx
|
||||
"upload.filelist.name": "الاسم",
|
||||
@@ -228,8 +234,8 @@ export default {
|
||||
"upload.modal.not-signed-in": "لم تقم بتسجيل الدخول",
|
||||
"upload.modal.not-signed-in-description": "لن تتمكن من حذف مشاركتك يدوياً أو عرض عدد الزوار.",
|
||||
"upload.modal.expires.never": "أبدًا",
|
||||
"upload.modal.expires.never-long": "لا تنتهي الصلاحية أبداً",
|
||||
"upload.modal.expires.error.too-long": "انتهاء الصلاحية يتجاوز الحد الأقصى لتاريخ انتهاء الصلاحية والذي هو {max}.",
|
||||
"upload.modal.expires.never-long": "Permanent share",
|
||||
"upload.modal.expires.error.too-long": "Expiration date exceeds the maximum of {max}.",
|
||||
"upload.modal.link.label": "الرابط",
|
||||
"upload.modal.expires.label": "انتهاء الصلاحية",
|
||||
"upload.modal.expires.minute-singular": "دقيقة",
|
||||
@@ -264,6 +270,7 @@ export default {
|
||||
// /share/[id]
|
||||
"share.title": "المشاركة {shareId}",
|
||||
"share.description": "انظر ما الذي شاركته معك!",
|
||||
"share.fileCount": "{count, plural, =1 {# file} other {# files}} · {size} (zip file may be smaller due to compression)",
|
||||
"share.error.visitor-limit-exceeded.title": "تم تجاوز حد المشاهدات",
|
||||
"share.error.visitor-limit-exceeded.description": "تم تجاوز الحد الأقصى لزوار هذه المشاركة.",
|
||||
"share.error.removed.title": "تمت إزالة المشاركة",
|
||||
@@ -272,16 +279,16 @@ 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.modal.password.title": "كلمة السر مطلوبة",
|
||||
"share.modal.password.description": "للوصول إلى هذه المشاركة الرجاء إدخال كلمة سر المشاركة.",
|
||||
"share.modal.password.description": "Please enter the password to access this share.",
|
||||
"share.modal.password": "كلمة السر",
|
||||
"share.modal.error.invalid-password": "كلمة السر غير صحيحة",
|
||||
"share.button.download-all": "تنزيل الكل",
|
||||
"share.notify.download-all-preparing": "يتم تحضير المشاركة. حاول مرة أخرى في بضع دقائق.",
|
||||
"share.notify.download-all-preparing": "The share is being prepared. Please try again in a few minutes.",
|
||||
"share.modal.file-link": "رابط الملف",
|
||||
"share.table.name": "الاسم",
|
||||
"share.table.size": "الحجم",
|
||||
"share.modal.file-preview.error.not-supported.title": "المعاينة غير مدعومة",
|
||||
"share.modal.file-preview.error.not-supported.description": "معاينة هذا النوع من الملفات غير مدعومة. الرجاء تنزيل الملف لعرضه.",
|
||||
"share.modal.file-preview.error.not-supported.description": "Previews are not supported for this type of files. Please download the file to view it.",
|
||||
// END /share/[id]
|
||||
// /share/[id]/edit
|
||||
"share.edit.title": "تحرير {shareId}",
|
||||
@@ -289,10 +296,19 @@ export default {
|
||||
"share.edit.notify.generic-error": "حدث خطأ أثناء إنهاء مشاركتك.",
|
||||
"share.edit.notify.save-success": "تم تحديث المشاركة بنجاح",
|
||||
// END /share/[id]/edit
|
||||
// /imprint
|
||||
"imprint.title": "Imprint",
|
||||
// END /imprint
|
||||
// /privacy
|
||||
"privacy.title": "Privacy Policy",
|
||||
// END /privacy
|
||||
// /admin/config
|
||||
"admin.config.config-file-warning.title": "Configuration file present",
|
||||
"admin.config.config-file-warning.description": "As you have a configured Pingvin Share with a configuration file, you can't change the configuration through the UI.",
|
||||
"admin.config.title": "الإعدادات",
|
||||
"admin.config.category.general": "عام",
|
||||
"admin.config.category.share": "مشاركة",
|
||||
"admin.config.category.cache": "Cache",
|
||||
"admin.config.category.email": "البريد",
|
||||
"admin.config.category.smtp": "بروتوكول نقل البريد البسيط SMTP",
|
||||
"admin.config.category.oauth": "الدخول بحساب تواصل اجتماعي",
|
||||
@@ -300,53 +316,65 @@ export default {
|
||||
"admin.config.general.app-name.description": "اسم التطبيق",
|
||||
"admin.config.general.app-url": "رابط التطبيق",
|
||||
"admin.config.general.app-url.description": "الرابط الذي تكون مشاركة Pingvin صالحة عليه",
|
||||
"admin.config.general.secure-cookies": "Secure cookies",
|
||||
"admin.config.general.secure-cookies.description": "Whether to set the secure flag on cookies. If enabled, the site will not function when accessed over HTTP.",
|
||||
"admin.config.general.show-home-page": "إظهار الصفحة الرئيسية",
|
||||
"admin.config.general.show-home-page.description": "تحديد ما إذا كان سيتم عرض الصفحة الرئيسية",
|
||||
"admin.config.general.session-duration": "مدة الجلسة",
|
||||
"admin.config.general.session-duration.description": "الوقت بالساعات الذي يجب على المستخدم بعده إعادة تسجيل الدخول (الافتراضي: 3 أشهر).",
|
||||
"admin.config.general.session-duration.description": "Time after which a user must log in again (default: 3 months).",
|
||||
"admin.config.general.logo": "الشعار",
|
||||
"admin.config.general.logo.description": "يمكنك تغيير شعارك عن طريق تحميل صورة جديدة. يجب أن تكون الصورة PNG ويجب أن يكون تنسيقها 1:1.",
|
||||
"admin.config.general.logo.placeholder": "اختر صورة",
|
||||
"admin.config.email.enable-share-email-recipients": "تفعيل مستلمي البريد الإلكتروني لهذه المشاركة",
|
||||
"admin.config.email.enable-share-email-recipients.description": "السماح لرسائل البريد بأن تُشارك المستلمين. لا تفعّل هذا الخيار ما لم تفعّل SMTP مسبقًا.",
|
||||
"admin.config.cache.ttl": "TTL",
|
||||
"admin.config.cache.ttl.description": "Time in second to keep information inside the cache.",
|
||||
"admin.config.cache.max-items": "Maximum items",
|
||||
"admin.config.cache.max-items.description": "Maximum number of items inside the cache.",
|
||||
"admin.config.cache.redis-enabled": "Redis enabled",
|
||||
"admin.config.cache.redis-enabled.description": "Normally Pingvin Share caches information in memory. If you run multiple instances of Pingvin Share, you need to enable Redis caching to share the cache between the instances.",
|
||||
"admin.config.cache.redis-url": "Redis URL",
|
||||
"admin.config.cache.redis-url.description": "Url to connect to the Redis instance used for caching.",
|
||||
"admin.config.email.enable-share-email-recipients": "Enable email recipient sharing",
|
||||
"admin.config.email.enable-share-email-recipients.description": "Whether to allow email sharing with recipients. Only enable this if SMTP is activated.",
|
||||
"admin.config.email.share-recipients-subject": "عنوان الرسالة لمستلمي المشاركة",
|
||||
"admin.config.email.share-recipients-subject.description": "عنوان البريد الذي سيُرسَل لمستقبِلي المشاركة.",
|
||||
"admin.config.email.share-recipients-message": "رسالتك لمستقبِلي المشاركة",
|
||||
"admin.config.email.share-recipients-message.description": "الرسالة التي ستُرسل لمستقبِلي المشاركة. يمكنك استخدام هذه المتغيرات:\n{creator} - اسم المستخدم الذي أنشأ المشاركة\n{shareUrl} - رابط المشاركة\n{desc} - وصف المشاركة\n{expires} - تاريخ انتهاء صلاحية المشاركة\nستتم كتابة قيم هذه المتغيرات تلقائيًا.",
|
||||
"admin.config.email.share-recipients-message.description": "Message which gets sent to the share recipients. Available variables:\n {creator} - The username of the creator of the share\n {creatorEmail} - The email of the creator of the share\n {shareUrl} - The URL of the share\n {desc} - The description of the share\n {expires} - The expiration date of the share\n These variables will be replaced with the actual value.",
|
||||
"admin.config.email.reverse-share-subject": "عنوان المشاركة العكسية",
|
||||
"admin.config.email.reverse-share-subject.description": "عنوان البريد الذي سيُرسل عندما يُنشئ شخص ما مشاركةً باستخدام رابط المشاركة العكسية الخاص بك.",
|
||||
"admin.config.email.reverse-share-subject.description": "Subject of the sent email when someone created a share with your reverse share link.",
|
||||
"admin.config.email.reverse-share-message": "رسالة المشاركة العكسية",
|
||||
"admin.config.email.reverse-share-message.description": "الرسالة التي ستُرسل عندما يُنشئ شخص ما مشاركة باستخدام رابط المشاركة الخاص بك. سيُوضع اسم المُنشِئ ورابط المشاركة مكان {shareUrl}.",
|
||||
"admin.config.email.reset-password-subject": "رسالة إعادة تعيين كلمة السر",
|
||||
"admin.config.email.reset-password-subject.description": "عنوان البريد الذي سيُرسل حين يطلب مستخدم ما إعادة تعيين كلمة سرّه.",
|
||||
"admin.config.email.reset-password-subject.description": "Subject of the sent email when a user requests a password reset.",
|
||||
"admin.config.email.reset-password-message": "رسالة إعادة تعيين كلمة السر",
|
||||
"admin.config.email.reset-password-message.description": "الرسالة التي ستُرسل عندما يطلب المستخدم إعادة تعيين كلمة سرّه. سيُوضع رابط إعادة تعيين كلمة السر مكان {url}.",
|
||||
"admin.config.email.invite-subject": "عنوان الدعوة",
|
||||
"admin.config.email.invite-subject.description": "عنوان البريد الذي سيُرسل عندما يقوم المشرف بدعوة مستخدم ما.",
|
||||
"admin.config.email.invite-subject.description": "Subject of the sent email when an admin invites a user.",
|
||||
"admin.config.email.invite-message": "رسالة الدعوة",
|
||||
"admin.config.email.invite-message.description": "الرسالة التي ستُرسل عندما يدعو مشرفٌ مستخدمًا. سيُوضع رابط الدعوة مكان {url} وكلمة السر مكان {password}.",
|
||||
"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.share.allow-registration": "السماح بالتسجيل",
|
||||
"admin.config.share.allow-registration.description": "إتاحة تسجيل حساب جديد",
|
||||
"admin.config.share.allow-unauthenticated-shares": "السماح بالمشاركات غير المصادق عليها",
|
||||
"admin.config.share.allow-unauthenticated-shares.description": "إتاحة إنشاء المشاركات للمستخدمين غير الموثقين",
|
||||
"admin.config.share.max-expiration": "أبعد زمن لانتهاء الصلاحية",
|
||||
"admin.config.share.max-expiration.description": "أطول زمن لانتهاء صلاحية المشاركات بالساعات. الصفر يعني أن المشاركة لن تنتهي صلاحيتها.",
|
||||
"admin.config.share.max-expiration.description": "Maximum share expiration. Set to 0 to allow unlimited expiration.",
|
||||
"admin.config.share.share-id-length": "Default share ID length",
|
||||
"admin.config.share.share-id-length.description": "Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.",
|
||||
"admin.config.share.max-size": "أكبر حجم",
|
||||
"admin.config.share.max-size.description": "أكبر حجم للمشاركة مقيسًا بالبايت",
|
||||
"admin.config.share.max-size.description": "أكبر حجم للمشاركة",
|
||||
"admin.config.share.zip-compression-level": "مستوى ضغط الZip",
|
||||
"admin.config.share.zip-compression-level.description": "ضبط الميزان بين حجم الملف وسرعة الضغط. يمكنك إدخال قيم بين 0 إلى 9، حيث 0 تعني بدون ضغط و9 تعني أقصى ضغط. ",
|
||||
"admin.config.share.chunk-size": "حجم القطعة",
|
||||
"admin.config.share.chunk-size.description": "ضبط حجم القطعة (بالبايت) لملفاتك المرفوعة للموازنة بين الكفاءة والفعالية حسب قوة اتصالك بالإنترنت. القطع الأصغر يمكن أن ترفع معدل النجاح في حال كان اتصالك بالإنترنت غير مستقر، بينما القطع الأكبر يمكنها أن تُسرّع رفع الملفات في حال كان الاتصال بالإنترنت مستقرًا.",
|
||||
"admin.config.share.chunk-size.description": "Adjust the chunk size for your uploads to balance efficiency and reliability according to your internet connection. Smaller chunks can enhance success rates for unstable connections, while larger chunks make uploads faster for stable connections.",
|
||||
"admin.config.share.auto-open-share-modal": "Auto open create share modal",
|
||||
"admin.config.share.auto-open-share-modal.description": "The share creation modal automatically appears when a user selects files, eliminating the need to manually click the button.",
|
||||
"admin.config.smtp.enabled": "مفعل",
|
||||
"admin.config.smtp.enabled": "Enable",
|
||||
"admin.config.smtp.enabled.description": "تفعيل الـSMTP. لا تفعّله إلا إذا قمت بإدخال المضيف، والمنفذ، والبريد الإلكتروني، واسم المستخدم، وكلمة السر لخادم الـSMTP.",
|
||||
"admin.config.smtp.host": "المُضيف",
|
||||
"admin.config.smtp.host.description": "مضيف خادم الـSMTP",
|
||||
"admin.config.smtp.port": "المنفذ",
|
||||
"admin.config.smtp.port.description": "منفذ خادم الـSMTP",
|
||||
"admin.config.smtp.email": "البريد الإلكتروني",
|
||||
"admin.config.smtp.email.description": "عنوان البريد الذي ستُرسَل الرسائل منه",
|
||||
"admin.config.smtp.email.description": "Email address from which the emails get sent",
|
||||
"admin.config.smtp.username": "اسم المستخدم",
|
||||
"admin.config.smtp.username.description": "اسم المستخدم لخادم الـSMTP",
|
||||
"admin.config.smtp.password": "كلمة السر",
|
||||
@@ -382,6 +410,8 @@ export default {
|
||||
"admin.config.oauth.microsoft-client-secret.description": "الرّمز السرّي للعميل لتطبيق Microsoft OAuth",
|
||||
"admin.config.oauth.discord-enabled": "Discord",
|
||||
"admin.config.oauth.discord-enabled.description": "تفعيل خيار الدخول بحساب Discord",
|
||||
"admin.config.oauth.discord-limited-users": "Discord limited users",
|
||||
"admin.config.oauth.discord-limited-users.description": "Limit signing in to specific users by their Discord ID. Leave it blank to disable.",
|
||||
"admin.config.oauth.discord-limited-guild": "مُعرِّف خادم Discord المحدود",
|
||||
"admin.config.oauth.discord-limited-guild.description": "حصر تسجيل الدخول على المستخدمين الموجودين في خادم محدّد. اترك هذا الخيار فارغًا لتعطيله.",
|
||||
"admin.config.oauth.discord-client-id": "Discord Client ID",
|
||||
@@ -392,6 +422,10 @@ export default {
|
||||
"admin.config.oauth.oidc-enabled.description": "تفعيل الدخول باستخدام OpenID Connect",
|
||||
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
|
||||
"admin.config.oauth.oidc-discovery-uri.description": "رابط الاستكشاف لتطبيق OpenID Connect OAuth",
|
||||
"admin.config.oauth.oidc-sign-out": "Sign out from OpenID Connect",
|
||||
"admin.config.oauth.oidc-sign-out.description": "Whether the “Sign out” button will sign out from the OpenID Connect provider",
|
||||
"admin.config.oauth.oidc-scope": "OpenID Connect scope",
|
||||
"admin.config.oauth.oidc-scope.description": "Scopes which should be requested from the OpenID Connect provider.",
|
||||
"admin.config.oauth.oidc-username-claim": "OpenID Connect username claim",
|
||||
"admin.config.oauth.oidc-username-claim.description": "طلب اسم المستخدم في رمز معرف OpenID Connect. إذا كنت لا تعرف معنى هذا الإعداد، اتركه فارغًا.",
|
||||
"admin.config.oauth.oidc-role-path": "Path to roles in OpenID Connect token",
|
||||
@@ -405,19 +439,55 @@ export default {
|
||||
"admin.config.oauth.oidc-client-secret": "OpenID Connect Client secret",
|
||||
"admin.config.oauth.oidc-client-secret.description": "الرّمز السرّي للعميل لتطبيق OpenID Connect OAuth",
|
||||
"admin.config.category.ldap": "LDAP",
|
||||
"admin.config.ldap.enabled": "Enabled LDAP",
|
||||
"admin.config.ldap.enabled": "Enable LDAP",
|
||||
"admin.config.ldap.enabled.description": "Use LDAP authentication for user login",
|
||||
"admin.config.ldap.url": "Server URL",
|
||||
"admin.config.ldap.url.description": "URL of the LDAP server",
|
||||
"admin.config.ldap.bind-dn": "Bind DN",
|
||||
"admin.config.ldap.bind-dn.description": "Default user which will be used to execute the user search",
|
||||
"admin.config.ldap.bind-dn.description": "Default user used to perform the user search",
|
||||
"admin.config.ldap.bind-password": "Bind password",
|
||||
"admin.config.ldap.bind-password.description": "Password for the user search user",
|
||||
"admin.config.ldap.bind-password.description": "Password used to perform the user search",
|
||||
"admin.config.ldap.search-base": "User base",
|
||||
"admin.config.ldap.search-base.description": "Base location, where the user search will be performed",
|
||||
"admin.config.ldap.search-query": "User query",
|
||||
"admin.config.ldap.search-query.description": "The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.",
|
||||
"admin.config.ldap.admin-groups": "Admin group",
|
||||
"admin.config.ldap.admin-groups.description": "Group required for administrative access.",
|
||||
"admin.config.ldap.field-name-member-of": "User groups attribute name",
|
||||
"admin.config.ldap.field-name-member-of.description": "LDAP attribute name for the groups, an user is a member of. This is used when checking for the admin group.",
|
||||
"admin.config.ldap.field-name-email": "User email attribute name",
|
||||
"admin.config.ldap.field-name-email.description": "LDAP attribute name for the email of an user.",
|
||||
"admin.config.notify.success": "Configuration updated successfully.",
|
||||
"admin.config.notify.logo-success": "Logo updated successfully. It may take a few minutes to update on the website.",
|
||||
"admin.config.notify.no-changes": "No changes to save.",
|
||||
"admin.config.category.s3": "S3",
|
||||
"admin.config.s3.enabled": "Enabled",
|
||||
"admin.config.s3.enabled.description": "Whether S3 should be used to store the shared files instead of the local file system.",
|
||||
"admin.config.s3.endpoint": "Endpoint",
|
||||
"admin.config.s3.endpoint.description": "The URL of the S3 bucket.",
|
||||
"admin.config.s3.region": "Region",
|
||||
"admin.config.s3.region.description": "The region of the S3 bucket.",
|
||||
"admin.config.s3.bucket-name": "Bucket name",
|
||||
"admin.config.s3.bucket-name.description": "The name of the S3 bucket.",
|
||||
"admin.config.s3.bucket-path": "Path",
|
||||
"admin.config.s3.bucket-path.description": "The default path which should be used to store the files in the S3 bucket.",
|
||||
"admin.config.s3.key": "Key",
|
||||
"admin.config.s3.key.description": "The key which allows you to access the S3 bucket.",
|
||||
"admin.config.s3.secret": "Secret",
|
||||
"admin.config.s3.secret.description": "The secret which allows you to access the S3 bucket.",
|
||||
"admin.config.s3.use-checksum": "Use checksum",
|
||||
"admin.config.s3.use-checksum.description": "Turn off for backends that do not support checksum (e.g. B2).",
|
||||
"admin.config.category.legal": "Legal",
|
||||
"admin.config.legal.enabled": "Enable legal notices",
|
||||
"admin.config.legal.enabled.description": "Whether to show a link to imprint and privacy policy in the footer.",
|
||||
"admin.config.legal.imprint-text": "Imprint text",
|
||||
"admin.config.legal.imprint-text.description": "The text which should be shown in the imprint. Supports Markdown. Leave blank to link to an external imprint page.",
|
||||
"admin.config.legal.imprint-url": "Imprint URL",
|
||||
"admin.config.legal.imprint-url.description": "If you already have an imprint page you can link it here instead of using the text field.",
|
||||
"admin.config.legal.privacy-policy-text": "Privacy policy text",
|
||||
"admin.config.legal.privacy-policy-text.description": "The text which should be shown in the privacy policy. Supports Markdown. Leave blank to link to an external privacy policy page.",
|
||||
"admin.config.legal.privacy-policy-url": "Privacy policy URL",
|
||||
"admin.config.legal.privacy-policy-url.description": "If you already have a privacy policy page you can link it here instead of using the text field.",
|
||||
// 404
|
||||
"404.description": "هذه الصفحة غير موجودة.",
|
||||
"404.button.home": "أعدني للصفحة الرئيسية",
|
||||
@@ -432,10 +502,10 @@ export default {
|
||||
"error.msg.no_user": "المستخدم المرتبط بهذا الحساب {0} غير موجود.",
|
||||
"error.msg.no_email": "لا يمكن الحصول على عنوان البريد الإلكتروني من هذا الحساب {0}.",
|
||||
"error.msg.already_linked": "حساب {0} هذا مرتبط بالفعل بحساب آخر.",
|
||||
"error.msg.not_linked": "لم يتم ربط حساب {0} هذا بأي حساب حتى الآن.",
|
||||
"error.msg.not_linked": "This {0} account hasn't been linked to any account yet.",
|
||||
"error.msg.unverified_account": "لم يتم التحقق من حساب {0} هذا، يرجى المحاولة مرة أخرى بعد التحقق.",
|
||||
"error.msg.user_not_allowed": "غير مسموح لك بتسجيل الدخول.",
|
||||
"error.msg.cannot_get_user_info": "فشلت عملية جلب معلومات المستخدم الخاصة بك من حساب {0} هذا.",
|
||||
"error.msg.cannot_get_user_info": "Cannot get your user info from this {0} account.",
|
||||
"error.param.provider_github": "GitHub",
|
||||
"error.param.provider_google": "Google",
|
||||
"error.param.provider_microsoft": "Microsoft",
|
||||
@@ -453,17 +523,21 @@ export default {
|
||||
"common.button.generate": "توليد",
|
||||
"common.button.done": "تم",
|
||||
"common.text.link": "الرابط",
|
||||
"common.text.navigate-to-link": "الذهاب إلى الرابط",
|
||||
"common.text.navigate-to-link": "Visit link",
|
||||
"common.text.or": "أو",
|
||||
"common.text.redirecting": "Redirecting...",
|
||||
"common.button.go-back": "العودة",
|
||||
"common.button.go-home": "العودة للصفحة الرئيسية",
|
||||
"common.notify.copied": "تم نسخ الرابط إلى الحافظة",
|
||||
"common.notify.copied-link": "تم نسخ الرابط إلى الحافظة",
|
||||
"common.success": "تم",
|
||||
"common.error": "خطأ",
|
||||
"common.error.unknown": "حدث خطأ غير معروف",
|
||||
"common.error.invalid-email": "عنوان البريد غير صحيح",
|
||||
"common.error.too-short": "يجب أن يكون على الأقل {length} حرفًا",
|
||||
"common.error.too-long": "يجب أن يكون على الأكثر {length} حرفًا",
|
||||
"common.error.number-too-small": "Must be at least {min}",
|
||||
"common.error.number-too-large": "Must be at most {max}",
|
||||
"common.error.exact-length": "يجب أن يكون بالضبط {length} حرفًا",
|
||||
"common.error.invalid-number": "يجب أن يكون رقماً",
|
||||
"common.error.field-required": "هذا الحقل مطلوب"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ export default {
|
||||
"navbar.upload": "Upload",
|
||||
"navbar.signin": "Log ind",
|
||||
"navbar.home": "Hjem",
|
||||
"navbar.signup": "Opret bruger",
|
||||
"navbar.signup": "Sign up",
|
||||
"navbar.links.shares": "Mine delte filer",
|
||||
"navbar.links.reverse": "Omvendt deling",
|
||||
"navbar.avatar.account": "Min bruger",
|
||||
@@ -16,9 +16,9 @@ export default {
|
||||
"home.bullet.a.name": "Self-Hosted",
|
||||
"home.bullet.a.description": "Host Pingvin Share på din egen maskine.",
|
||||
"home.bullet.b.name": "Privatliv",
|
||||
"home.bullet.b.description": "Dine filer er dine filer og bør ikke komme i hænderne på tredjeparter.",
|
||||
"home.bullet.b.description": "Your files are yours and will never be accessed by third parties.",
|
||||
"home.bullet.c.name": "Ingen irriterende grænse for filstørrelse",
|
||||
"home.bullet.c.description": "Upload så store filer, som du vil. Kun din harddisk sætter grænsen.",
|
||||
"home.bullet.c.description": "Upload files as big as you want. Only your hard drive will be your limit.",
|
||||
"home.button.start": "Kom i gang",
|
||||
"home.button.source": "Source code",
|
||||
// END /
|
||||
@@ -34,7 +34,7 @@ export default {
|
||||
"signIn.notify.totp-required.title": "2-faktor login påkrævet",
|
||||
"signIn.notify.totp-required.description": "Indtast den aktuelle engangskode fra din 2-faktor Authenticator",
|
||||
"signIn.oauth.or": "OR",
|
||||
"signIn.oauth.signInWith": "Sign in with",
|
||||
"signIn.oauth.signInWith": "Log ind med",
|
||||
"signIn.oauth.github": "GitHub",
|
||||
"signIn.oauth.google": "Google",
|
||||
"signIn.oauth.microsoft": "Microsoft",
|
||||
@@ -58,12 +58,12 @@ export default {
|
||||
// /auth/reset-password
|
||||
"resetPassword.title": "Glemt din adgangskode?",
|
||||
"resetPassword.description": "Indtast din e-mail for at nulstille din adgangskode.",
|
||||
"resetPassword.notify.success": "A message with a link to reset your password has been sent if the email exists.",
|
||||
"resetPassword.notify.success": "A message with a link to reset your password has been sent if the provided email exists.",
|
||||
"resetPassword.button.back": "Tilbage til login",
|
||||
"resetPassword.text.resetPassword": "Nulstil adgangskode",
|
||||
"resetPassword.text.enterNewPassword": "Indtast din nye adgangskode",
|
||||
"resetPassword.input.password": "Ny adgangskode",
|
||||
"resetPassword.notify.passwordReset": "Adgangskoden er blevet nulstillet.",
|
||||
"resetPassword.notify.passwordReset": "Your password has been successfully reset.",
|
||||
// /account
|
||||
"account.title": "Min bruger",
|
||||
"account.card.info.title": "Brugerinfo",
|
||||
@@ -73,7 +73,7 @@ export default {
|
||||
"account.card.password.title": "Adgangskode",
|
||||
"account.card.password.old": "Gammel adgangskode",
|
||||
"account.card.password.new": "Ny adgangskode",
|
||||
"account.card.password.noPasswordSet": "You don't have a password set. If you want to sign in with email and password you need to set a password.",
|
||||
"account.card.password.noPasswordSet": "You do not have a password set. To sign in using your email and password, you need to create a password.",
|
||||
"account.notify.password.success": "Adgangskoden er ændret",
|
||||
"account.card.oauth.title": "Social login",
|
||||
"account.card.oauth.github": "GitHub",
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
"account.card.oauth.unlink": "Unlink",
|
||||
"account.card.oauth.unlinked": "Unlinked",
|
||||
"account.modal.unlink.title": "Unlink account",
|
||||
"account.modal.unlink.description": "Unlinking your social accounts may cause you to lose your account if you don't remember your username and password.",
|
||||
"account.modal.unlink.description": "Unlinking your social accounts may cause you to lose your account if you don't remember your login credentials",
|
||||
"account.notify.oauth.unlinked.success": "Unlinked successfully",
|
||||
"account.card.security.title": "Sikkerhed",
|
||||
"account.card.security.totp.enable.description": "Indtast din nuværende adgangskode for at begynde opsætningen af 2-faktor login",
|
||||
@@ -121,12 +121,15 @@ export default {
|
||||
"account.shares.table.name": "Navn",
|
||||
"account.shares.table.description": "Beskrivelse",
|
||||
"account.shares.table.visitors": "Besøgende",
|
||||
"account.shares.table.expiresAt": "Udløber d",
|
||||
"account.shares.table.createdAt": "Oprettet d.",
|
||||
"account.shares.table.expiresAt": "Expires on",
|
||||
"account.shares.table.createdAt": "Created on",
|
||||
"account.shares.table.size": "Størrelse",
|
||||
"account.shares.table.password-protected": "Password protected",
|
||||
"account.shares.table.visitor-count": "{count} of {max}",
|
||||
"account.shares.table.expiry-never": "Never",
|
||||
"account.shares.modal.share-informations": "Share informations",
|
||||
"account.shares.modal.share-link": "Del link",
|
||||
"account.shares.modal.delete.title": "Slet share {share}",
|
||||
"account.shares.modal.delete.title": "Delete share: {share}",
|
||||
"account.shares.modal.delete.description": "Ønsker du virkelig at slette denne deling?",
|
||||
// END /account/shares
|
||||
// /account/reverseShares
|
||||
@@ -135,7 +138,7 @@ export default {
|
||||
"account.reverseShares.title.empty": "Der er tomt her 👀",
|
||||
"account.reverseShares.description.empty": "You don't have any reverse shares.",
|
||||
// showCreateReverseShareModal.tsx
|
||||
"account.reverseShares.modal.title": "Create reverse share",
|
||||
"account.reverseShares.modal.title": "Opret omvendt deling",
|
||||
"account.reverseShares.modal.expiration.label": "Udløb",
|
||||
"account.reverseShares.modal.expiration.minute-singular": "Minut",
|
||||
"account.reverseShares.modal.expiration.minute-plural": "Minutter",
|
||||
@@ -150,12 +153,12 @@ export default {
|
||||
"account.reverseShares.modal.expiration.year-singular": "År",
|
||||
"account.reverseShares.modal.expiration.year-plural": "År",
|
||||
"account.reverseShares.modal.max-size.label": "Maksimal størrelse for deling",
|
||||
"account.reverseShares.modal.send-email": "Send e-mail notifikation",
|
||||
"account.reverseShares.modal.send-email.description": "Send en e-mail notifikation, når der oprettes en deling med dette omvendte delingslink.",
|
||||
"account.reverseShares.modal.send-email": "Send email notifications",
|
||||
"account.reverseShares.modal.send-email.description": "Sends you an email notification when a share is created with this reverse share link.",
|
||||
"account.reverseShares.modal.simplified": "Simple mode",
|
||||
"account.reverseShares.modal.simplified.description": "Make it easy for the person uploading the file to share it with you. They will be able to customize only the name and description of the share.",
|
||||
"account.reverseShares.modal.public-access": "Public access",
|
||||
"account.reverseShares.modal.public-access.description": "Make the created shares with this reverse share public. If disabled, only you and the creator of the share can view it.",
|
||||
"account.reverseShares.modal.simplified.description": "Make it easy for the person uploading the file to share it with you. They will only be able to customize the name and description of the share.",
|
||||
"account.reverseShares.modal.public-access": "Offentlig adgang",
|
||||
"account.reverseShares.modal.public-access.description": "Lav filerne i denne omvendte deling offentlige. Hvis deaktiveret kan kun dig og delingens ejer have adgang til filerne.",
|
||||
"account.reverseShares.modal.max-use.label": "Maksimal anvendelser",
|
||||
"account.reverseShares.modal.max-use.description": "Det maksimale antal gange, denne URL kan bruges til at oprette en deling.",
|
||||
"account.reverseShare.never-expires": "Denne omvendte deling udløber aldrig.",
|
||||
@@ -168,7 +171,7 @@ export default {
|
||||
"account.reverseShares.table.max-size": "Maksimal størrelse for deling",
|
||||
"account.reverseShares.table.expires": "Udløber d",
|
||||
"account.reverseShares.modal.reverse-share-link": "Reverse share link",
|
||||
"account.reverseShares.modal.delete.title": "Delete reverse share",
|
||||
"account.reverseShares.modal.delete.title": "Slet omvendt deling",
|
||||
"account.reverseShares.modal.delete.description": "Ønsker du virkelig at slette denne omvendte deling? Hvis du gør det, vil de tilknyttede delinger også blive slettet.",
|
||||
// END /account/reverseShares
|
||||
// /admin
|
||||
@@ -183,14 +186,14 @@ export default {
|
||||
"admin.users.table.username": "Brugernavn",
|
||||
"admin.users.table.email": "E-mail",
|
||||
"admin.users.table.admin": "Admin",
|
||||
"admin.users.edit.update.title": "Opdater bruger {username}",
|
||||
"admin.users.edit.update.title": "Edit user: {username}",
|
||||
"admin.users.edit.update.admin-privileges": "Admin rettigheder",
|
||||
"admin.users.edit.update.change-password.title": "Skift adgangskode",
|
||||
"admin.users.edit.update.change-password.field": "Ny adgangskode",
|
||||
"admin.users.edit.update.change-password.button": "Gem ny adgangskode",
|
||||
"admin.users.edit.update.notify.password.success": "Adgangskoden er ændret",
|
||||
"admin.users.edit.delete.title": "Slet bruger {username}",
|
||||
"admin.users.edit.delete.description": "Er du sikker på du vil slette denne bruger og tilhørende delinger?",
|
||||
"admin.users.edit.delete.title": "Delete user: {username} ?",
|
||||
"admin.users.edit.delete.description": "Do you really want to delete this user and all their shares?",
|
||||
// showCreateUserModal.tsx
|
||||
"admin.users.modal.create.title": "Opret bruger",
|
||||
"admin.users.modal.create.username": "Brugernavn",
|
||||
@@ -204,19 +207,22 @@ export default {
|
||||
// /admin/shares
|
||||
"admin.shares.title": "Share management",
|
||||
"admin.shares.table.id": "Share ID",
|
||||
"admin.shares.table.username": "Creator",
|
||||
"admin.shares.table.username": "Ejer",
|
||||
"admin.shares.table.visitors": "Besøgende",
|
||||
"admin.shares.table.expires": "Expires At",
|
||||
"admin.shares.edit.delete.title": "Delete share {id}",
|
||||
"admin.shares.table.expires": "Udløber",
|
||||
"admin.shares.edit.delete.title": "Delete share: {id}",
|
||||
"admin.shares.edit.delete.description": "Do you really want to delete this share?",
|
||||
// END /admin/shares
|
||||
// /upload
|
||||
"upload.title": "Upload",
|
||||
"upload.notify.confirm-leave": "Are you sure you want to leave this page? Your upload will be canceled.",
|
||||
"upload.notify.generic-error": "Der opstod en fejl under afslutningen af din deling.",
|
||||
"upload.notify.count-failed": "{count} files failed to upload. Trying again.",
|
||||
"upload.reverse-share.error.invalid.title": "Invalid reverse share link",
|
||||
"upload.reverse-share.error.invalid.description": "This reverse share has expired or is invalid.",
|
||||
// Dropzone.tsx
|
||||
"upload.dropzone.title": "Upload filer",
|
||||
"upload.dropzone.description": "Drag'n'drop files here to start your share. We can accept only files that are less than {maxSize} in total.",
|
||||
"upload.dropzone.description": "Drag'n'drop files here to start your share. We only accept files up to {maxSize} in total.",
|
||||
"upload.dropzone.notify.file-too-big": "Your files exceed the maximum share size of {maxSize}.",
|
||||
// FileList.tsx
|
||||
"upload.filelist.name": "Navn",
|
||||
@@ -228,8 +234,8 @@ export default {
|
||||
"upload.modal.not-signed-in": "Du er ikke logget ind",
|
||||
"upload.modal.not-signed-in-description": "Du vil ikke være i stand til at slette din deling manuelt og se antallet af besøgende.",
|
||||
"upload.modal.expires.never": "aldrig",
|
||||
"upload.modal.expires.never-long": "Udløber aldrig",
|
||||
"upload.modal.expires.error.too-long": "Udløbsdatoen overskrider den maksimalt tilladte udløbsdato på {max}.",
|
||||
"upload.modal.expires.never-long": "Permanent share",
|
||||
"upload.modal.expires.error.too-long": "Expiration date exceeds the maximum of {max}.",
|
||||
"upload.modal.link.label": "Link",
|
||||
"upload.modal.expires.label": "Udløb",
|
||||
"upload.modal.expires.minute-singular": "Minut",
|
||||
@@ -244,7 +250,7 @@ export default {
|
||||
"upload.modal.expires.month-plural": "Måneder",
|
||||
"upload.modal.expires.year-singular": "År",
|
||||
"upload.modal.expires.year-plural": "År",
|
||||
"upload.modal.accordion.name-and-description.title": "Name and description",
|
||||
"upload.modal.accordion.name-and-description.title": "Navn og beskrivelse",
|
||||
"upload.modal.accordion.name-and-description.name.placeholder": "Navn",
|
||||
"upload.modal.accordion.name-and-description.description.placeholder": "Note for the recipients of this share",
|
||||
"upload.modal.accordion.email.title": "E-mail modtagere",
|
||||
@@ -257,42 +263,52 @@ export default {
|
||||
"upload.modal.accordion.security.max-views.placeholder": "Ingen begrænsning",
|
||||
// showCompletedUploadModal.tsx
|
||||
"upload.modal.completed.never-expires": "Denne deling vil aldrig udløbe.",
|
||||
"upload.modal.completed.expires-on": "Denne omvendte deling udløber den {expiration}.",
|
||||
"upload.modal.completed.expires-on": "Denne deling udløber den {expiration}.",
|
||||
"upload.modal.completed.share-ready": "Delingen er klar",
|
||||
"upload.modal.completed.notified-reverse-share-creator": "We have notified the creator of the reverse share. You can also manually share this link with them through other means.",
|
||||
// END /upload
|
||||
// /share/[id]
|
||||
"share.title": "Del {shareId}",
|
||||
"share.description": "Se hvad jeg har delt med dig!",
|
||||
"share.fileCount": "{count, plural, =1 {# file} other {# files}} · {size} (zip file may be smaller due to compression)",
|
||||
"share.error.visitor-limit-exceeded.title": "Grænsen for besøgende overskredet",
|
||||
"share.error.visitor-limit-exceeded.description": "Besøgsgrænsen for denne deling er blevet overskredet.",
|
||||
"share.error.removed.title": "Deling fjernet",
|
||||
"share.error.not-found.title": "Delingen blev ikke fundet",
|
||||
"share.error.not-found.description": "Den deling, du leder efter, eksisterer ikke.",
|
||||
"share.error.access-denied.title": "Private share",
|
||||
"share.error.access-denied.title": "Privat deling",
|
||||
"share.error.access-denied.description": "The current account does not have permission to access this share",
|
||||
"share.modal.password.title": "Adgangskode påkrævet",
|
||||
"share.modal.password.description": "For at få adgang til denne deling, indtast venligst adgangskoden til delingen.",
|
||||
"share.modal.password.description": "Please enter the password to access this share.",
|
||||
"share.modal.password": "Adgangskode",
|
||||
"share.modal.error.invalid-password": "Ugyldig adgangskode",
|
||||
"share.button.download-all": "Download alle",
|
||||
"share.notify.download-all-preparing": "Delingen forberedes. Prøv igen om et par minutter.",
|
||||
"share.notify.download-all-preparing": "The share is being prepared. Please try again in a few minutes.",
|
||||
"share.modal.file-link": "Fil link",
|
||||
"share.table.name": "Navn",
|
||||
"share.table.size": "Størrelse",
|
||||
"share.modal.file-preview.error.not-supported.title": "Forhåndsvisning ikke understøttet",
|
||||
"share.modal.file-preview.error.not-supported.description": "A preview for this file type is unsupported. Please download the file to view it.",
|
||||
"share.modal.file-preview.error.not-supported.description": "Previews are not supported for this type of files. Please download the file to view it.",
|
||||
// END /share/[id]
|
||||
// /share/[id]/edit
|
||||
"share.edit.title": "Rediger {shareId}",
|
||||
"share.edit.append-upload": "Append file",
|
||||
"share.edit.notify.generic-error": "An error occurred while finishing your share.",
|
||||
"share.edit.notify.save-success": "Share updated successfully",
|
||||
"share.edit.notify.save-success": "Deling opdateret",
|
||||
// END /share/[id]/edit
|
||||
// /imprint
|
||||
"imprint.title": "Imprint",
|
||||
// END /imprint
|
||||
// /privacy
|
||||
"privacy.title": "Privacy Policy",
|
||||
// END /privacy
|
||||
// /admin/config
|
||||
"admin.config.config-file-warning.title": "Configuration file present",
|
||||
"admin.config.config-file-warning.description": "As you have a configured Pingvin Share with a configuration file, you can't change the configuration through the UI.",
|
||||
"admin.config.title": "Konfiguration",
|
||||
"admin.config.category.general": "Generelt",
|
||||
"admin.config.category.share": "Del",
|
||||
"admin.config.category.cache": "Cache",
|
||||
"admin.config.category.email": "E-mail",
|
||||
"admin.config.category.smtp": "SMTP",
|
||||
"admin.config.category.oauth": "Social Login",
|
||||
@@ -300,53 +316,65 @@ export default {
|
||||
"admin.config.general.app-name.description": "Navnet på applikationen",
|
||||
"admin.config.general.app-url": "App URL",
|
||||
"admin.config.general.app-url.description": "På hvilken URL Pingvin Share er tilgængelig",
|
||||
"admin.config.general.secure-cookies": "Secure cookies",
|
||||
"admin.config.general.secure-cookies.description": "Whether to set the secure flag on cookies. If enabled, the site will not function when accessed over HTTP.",
|
||||
"admin.config.general.show-home-page": "Vis forside",
|
||||
"admin.config.general.show-home-page.description": "Om forsiden skal vises",
|
||||
"admin.config.general.session-duration": "Session Duration",
|
||||
"admin.config.general.session-duration.description": "Time in hours after which a user must log in again (default: 3 months).",
|
||||
"admin.config.general.session-duration.description": "Time after which a user must log in again (default: 3 months).",
|
||||
"admin.config.general.logo": "Logo",
|
||||
"admin.config.general.logo.description": "Skift dit logo ved at uploade et nyt billede. Billedet skal være PNG og skal have formatet 1:1.",
|
||||
"admin.config.general.logo.placeholder": "Vælg billede",
|
||||
"admin.config.email.enable-share-email-recipients": "Aktiver deling til e-mail modtagere",
|
||||
"admin.config.email.enable-share-email-recipients.description": "Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
||||
"admin.config.cache.ttl": "TTL",
|
||||
"admin.config.cache.ttl.description": "Time in second to keep information inside the cache.",
|
||||
"admin.config.cache.max-items": "Maximum items",
|
||||
"admin.config.cache.max-items.description": "Maximum number of items inside the cache.",
|
||||
"admin.config.cache.redis-enabled": "Redis enabled",
|
||||
"admin.config.cache.redis-enabled.description": "Normally Pingvin Share caches information in memory. If you run multiple instances of Pingvin Share, you need to enable Redis caching to share the cache between the instances.",
|
||||
"admin.config.cache.redis-url": "Redis URL",
|
||||
"admin.config.cache.redis-url.description": "Url to connect to the Redis instance used for caching.",
|
||||
"admin.config.email.enable-share-email-recipients": "Enable email recipient sharing",
|
||||
"admin.config.email.enable-share-email-recipients.description": "Whether to allow email sharing with recipients. Only enable this if SMTP is activated.",
|
||||
"admin.config.email.share-recipients-subject": "Share recipients subject",
|
||||
"admin.config.email.share-recipients-subject.description": "Subject of the email which gets sent to the share recipients.",
|
||||
"admin.config.email.share-recipients-message": "Share recipients message",
|
||||
"admin.config.email.share-recipients-message.description": "Message which gets sent to the share recipients. Available variables:\n {creator} - The username of the creator of the share\n {shareUrl} - The URL of the share\n {desc} - The description of the share\n {expires} - The expiration date of the share\n The variables will be replaced with the actual value.",
|
||||
"admin.config.email.share-recipients-message.description": "Message which gets sent to the share recipients. Available variables:\n {creator} - The username of the creator of the share\n {creatorEmail} - The email of the creator of the share\n {shareUrl} - The URL of the share\n {desc} - The description of the share\n {expires} - The expiration date of the share\n These variables will be replaced with the actual value.",
|
||||
"admin.config.email.reverse-share-subject": "Reverse share subject",
|
||||
"admin.config.email.reverse-share-subject.description": "Subject of the email which gets sent when someone created a share with your reverse share link.",
|
||||
"admin.config.email.reverse-share-subject.description": "Subject of the sent email when someone created a share with your reverse share link.",
|
||||
"admin.config.email.reverse-share-message": "Reverse share message",
|
||||
"admin.config.email.reverse-share-message.description": "Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||
"admin.config.email.reset-password-subject": "Reset password subject",
|
||||
"admin.config.email.reset-password-subject.description": "Subject of the email which gets sent when a user requests a password reset.",
|
||||
"admin.config.email.reset-password-subject.description": "Subject of the sent email when a user requests a password reset.",
|
||||
"admin.config.email.reset-password-message": "Nulstil adgangskode besked",
|
||||
"admin.config.email.reset-password-message.description": "Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
|
||||
"admin.config.email.invite-subject": "Invitations emne",
|
||||
"admin.config.email.invite-subject.description": "Emne for den e-mail, der sendes, når en administrator inviterer en ny bruger.",
|
||||
"admin.config.email.invite-subject.description": "Subject of the sent email when an admin invites a user.",
|
||||
"admin.config.email.invite-message": "Invitations besked",
|
||||
"admin.config.email.invite-message.description": "Besked som bliver sendt, når en administrator inviterer en bruger. {url} vil blive erstattet med invitations-URL'en og {password} med adgangskoden.",
|
||||
"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.share.allow-registration": "Tillad oprettelser",
|
||||
"admin.config.share.allow-registration.description": "Om alle skal kunne oprette en bruger",
|
||||
"admin.config.share.allow-unauthenticated-shares": "Tillad uautoriserede delinger",
|
||||
"admin.config.share.allow-unauthenticated-shares.description": "Whether unauthenticated users can create shares",
|
||||
"admin.config.share.max-expiration": "Maks. udløb",
|
||||
"admin.config.share.max-expiration.description": "Maximum share expiration in hours. Set to 0 to allow unlimited expiration.",
|
||||
"admin.config.share.max-expiration.description": "Maximum share expiration. Set to 0 to allow unlimited expiration.",
|
||||
"admin.config.share.share-id-length": "Default share ID length",
|
||||
"admin.config.share.share-id-length.description": "Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.",
|
||||
"admin.config.share.max-size": "Maks. størrelse",
|
||||
"admin.config.share.max-size.description": "Maksimal filstørrelse i bytes",
|
||||
"admin.config.share.max-size.description": "Maksimal filstørrelse",
|
||||
"admin.config.share.zip-compression-level": "Zip compression level",
|
||||
"admin.config.share.zip-compression-level.description": "Adjust the level to balance between file size and compression speed. Valid values range from 0 to 9, with 0 being no compression and 9 being maximum compression. ",
|
||||
"admin.config.share.chunk-size": "Chunk size",
|
||||
"admin.config.share.chunk-size.description": "Adjust the chunk size (in bytes) for your uploads to balance efficiency and reliability according to your internet connection. Smaller chunks can enhance success rates for unstable connections, while larger chunks speed up uploads for stable connections.",
|
||||
"admin.config.share.chunk-size.description": "Adjust the chunk size for your uploads to balance efficiency and reliability according to your internet connection. Smaller chunks can enhance success rates for unstable connections, while larger chunks make uploads faster for stable connections.",
|
||||
"admin.config.share.auto-open-share-modal": "Auto open create share modal",
|
||||
"admin.config.share.auto-open-share-modal.description": "The share creation modal automatically appears when a user selects files, eliminating the need to manually click the button.",
|
||||
"admin.config.smtp.enabled": "Aktiveret",
|
||||
"admin.config.smtp.enabled": "Enable",
|
||||
"admin.config.smtp.enabled.description": "Om SMTP er aktiveret. Aktiver kun SMTP, hvis du har indtastet SMTP-server vært, port, e-mail, bruger og adgangskode.",
|
||||
"admin.config.smtp.host": "Vært",
|
||||
"admin.config.smtp.host.description": "Vært for SMTP serveren",
|
||||
"admin.config.smtp.port": "Port",
|
||||
"admin.config.smtp.port.description": "Porten til SMTP serveren",
|
||||
"admin.config.smtp.email": "E-mail",
|
||||
"admin.config.smtp.email.description": "E-mail adressen som der skal afsendes fra",
|
||||
"admin.config.smtp.email.description": "Email address from which the emails get sent",
|
||||
"admin.config.smtp.username": "Brugernavn",
|
||||
"admin.config.smtp.username.description": "Brugernavnet til SMTP serveren",
|
||||
"admin.config.smtp.password": "Adgangskode",
|
||||
@@ -355,13 +383,13 @@ export default {
|
||||
"admin.config.smtp.allow-unauthorized-certificates": "Trust unauthorized SMTP server certificates",
|
||||
"admin.config.smtp.allow-unauthorized-certificates.description": "Only set this to true if you need to trust self signed certificates.",
|
||||
"admin.config.oauth.allow-registration": "Tillad registrering",
|
||||
"admin.config.oauth.allow-registration.description": "Allow users to register via social login",
|
||||
"admin.config.oauth.allow-registration.description": "Tillad brugere at registrere sig via socialt login",
|
||||
"admin.config.oauth.ignore-totp": "Ignore TOTP",
|
||||
"admin.config.oauth.ignore-totp.description": "Whether to ignore TOTP when user using social login",
|
||||
"admin.config.oauth.disable-password": "Disable password login",
|
||||
"admin.config.oauth.disable-password": "Deaktiver login med password",
|
||||
"admin.config.oauth.disable-password.description": "Whether to disable password login\nMake sure that an OAuth provider is properly configured before activating this configuration to avoid being locked out.",
|
||||
"admin.config.oauth.github-enabled": "GitHub",
|
||||
"admin.config.oauth.github-enabled.description": "Whether GitHub login is enabled",
|
||||
"admin.config.oauth.github-enabled.description": "Om GitHub login er aktiveret",
|
||||
"admin.config.oauth.github-client-id": "GitHub Client ID",
|
||||
"admin.config.oauth.github-client-id.description": "Client ID of the GitHub OAuth app",
|
||||
"admin.config.oauth.github-client-secret": "GitHub Client secret",
|
||||
@@ -382,6 +410,8 @@ export default {
|
||||
"admin.config.oauth.microsoft-client-secret.description": "Client secret of the Microsoft OAuth app",
|
||||
"admin.config.oauth.discord-enabled": "Discord",
|
||||
"admin.config.oauth.discord-enabled.description": "Whether Discord login is enabled",
|
||||
"admin.config.oauth.discord-limited-users": "Discord limited users",
|
||||
"admin.config.oauth.discord-limited-users.description": "Limit signing in to specific users by their Discord ID. Leave it blank to disable.",
|
||||
"admin.config.oauth.discord-limited-guild": "Discord limited server ID",
|
||||
"admin.config.oauth.discord-limited-guild.description": "Limit signing in to users in a specific server. Leave it blank to disable.",
|
||||
"admin.config.oauth.discord-client-id": "Discord Client ID",
|
||||
@@ -392,6 +422,10 @@ export default {
|
||||
"admin.config.oauth.oidc-enabled.description": "Whether OpenID Connect login is enabled",
|
||||
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
|
||||
"admin.config.oauth.oidc-discovery-uri.description": "Discovery URI of the OpenID Connect OAuth app",
|
||||
"admin.config.oauth.oidc-sign-out": "Sign out from OpenID Connect",
|
||||
"admin.config.oauth.oidc-sign-out.description": "Whether the “Sign out” button will sign out from the OpenID Connect provider",
|
||||
"admin.config.oauth.oidc-scope": "OpenID Connect scope",
|
||||
"admin.config.oauth.oidc-scope.description": "Scopes which should be requested from the OpenID Connect provider.",
|
||||
"admin.config.oauth.oidc-username-claim": "OpenID Connect username claim",
|
||||
"admin.config.oauth.oidc-username-claim.description": "Username claim in OpenID Connect ID token. Leave it blank if you don't know what this config is.",
|
||||
"admin.config.oauth.oidc-role-path": "Path to roles in OpenID Connect token",
|
||||
@@ -405,19 +439,55 @@ export default {
|
||||
"admin.config.oauth.oidc-client-secret": "OpenID Connect Client secret",
|
||||
"admin.config.oauth.oidc-client-secret.description": "Client secret of the OpenID Connect OAuth app",
|
||||
"admin.config.category.ldap": "LDAP",
|
||||
"admin.config.ldap.enabled": "Enabled LDAP",
|
||||
"admin.config.ldap.enabled": "Enable LDAP",
|
||||
"admin.config.ldap.enabled.description": "Use LDAP authentication for user login",
|
||||
"admin.config.ldap.url": "Server URL",
|
||||
"admin.config.ldap.url.description": "URL of the LDAP server",
|
||||
"admin.config.ldap.bind-dn": "Bind DN",
|
||||
"admin.config.ldap.bind-dn.description": "Default user which will be used to execute the user search",
|
||||
"admin.config.ldap.bind-dn.description": "Default user used to perform the user search",
|
||||
"admin.config.ldap.bind-password": "Bind password",
|
||||
"admin.config.ldap.bind-password.description": "Password for the user search user",
|
||||
"admin.config.ldap.bind-password.description": "Password used to perform the user search",
|
||||
"admin.config.ldap.search-base": "User base",
|
||||
"admin.config.ldap.search-base.description": "Base location, where the user search will be performed",
|
||||
"admin.config.ldap.search-query": "User query",
|
||||
"admin.config.ldap.search-query.description": "The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.",
|
||||
"admin.config.ldap.admin-groups": "Admin group",
|
||||
"admin.config.ldap.admin-groups": "Admin gruppe",
|
||||
"admin.config.ldap.admin-groups.description": "Gruppe påkrævet for administrativ adgang.",
|
||||
"admin.config.ldap.field-name-member-of": "User groups attribute name",
|
||||
"admin.config.ldap.field-name-member-of.description": "LDAP attribute name for the groups, an user is a member of. This is used when checking for the admin group.",
|
||||
"admin.config.ldap.field-name-email": "User email attribute name",
|
||||
"admin.config.ldap.field-name-email.description": "LDAP attribute name for the email of an user.",
|
||||
"admin.config.notify.success": "Configuration updated successfully.",
|
||||
"admin.config.notify.logo-success": "Logo updated successfully. It may take a few minutes to update on the website.",
|
||||
"admin.config.notify.no-changes": "No changes to save.",
|
||||
"admin.config.category.s3": "S3",
|
||||
"admin.config.s3.enabled": "Enabled",
|
||||
"admin.config.s3.enabled.description": "Whether S3 should be used to store the shared files instead of the local file system.",
|
||||
"admin.config.s3.endpoint": "Endpoint",
|
||||
"admin.config.s3.endpoint.description": "The URL of the S3 bucket.",
|
||||
"admin.config.s3.region": "Region",
|
||||
"admin.config.s3.region.description": "The region of the S3 bucket.",
|
||||
"admin.config.s3.bucket-name": "Bucket name",
|
||||
"admin.config.s3.bucket-name.description": "The name of the S3 bucket.",
|
||||
"admin.config.s3.bucket-path": "Path",
|
||||
"admin.config.s3.bucket-path.description": "The default path which should be used to store the files in the S3 bucket.",
|
||||
"admin.config.s3.key": "Key",
|
||||
"admin.config.s3.key.description": "The key which allows you to access the S3 bucket.",
|
||||
"admin.config.s3.secret": "Secret",
|
||||
"admin.config.s3.secret.description": "The secret which allows you to access the S3 bucket.",
|
||||
"admin.config.s3.use-checksum": "Use checksum",
|
||||
"admin.config.s3.use-checksum.description": "Turn off for backends that do not support checksum (e.g. B2).",
|
||||
"admin.config.category.legal": "Legal",
|
||||
"admin.config.legal.enabled": "Enable legal notices",
|
||||
"admin.config.legal.enabled.description": "Whether to show a link to imprint and privacy policy in the footer.",
|
||||
"admin.config.legal.imprint-text": "Imprint text",
|
||||
"admin.config.legal.imprint-text.description": "The text which should be shown in the imprint. Supports Markdown. Leave blank to link to an external imprint page.",
|
||||
"admin.config.legal.imprint-url": "Imprint URL",
|
||||
"admin.config.legal.imprint-url.description": "If you already have an imprint page you can link it here instead of using the text field.",
|
||||
"admin.config.legal.privacy-policy-text": "Privacy policy text",
|
||||
"admin.config.legal.privacy-policy-text.description": "The text which should be shown in the privacy policy. Supports Markdown. Leave blank to link to an external privacy policy page.",
|
||||
"admin.config.legal.privacy-policy-url": "Privacy policy URL",
|
||||
"admin.config.legal.privacy-policy-url.description": "If you already have a privacy policy page you can link it here instead of using the text field.",
|
||||
// 404
|
||||
"404.description": "Ups! Denne side findes ikke.",
|
||||
"404.button.home": "Gå tilbage",
|
||||
@@ -425,17 +495,17 @@ export default {
|
||||
"error.title": "Fejl",
|
||||
"error.description": "Hovsa!",
|
||||
"error.button.back": "Gå tilbage",
|
||||
"error.msg.default": "Something went wrong.",
|
||||
"error.msg.default": "Noget gik galt.",
|
||||
"error.msg.access_denied": "You canceled the authentication process, please try again.",
|
||||
"error.msg.expired_token": "The authentication process took too long, please try again.",
|
||||
"error.msg.invalid_token": "Intern Fejl",
|
||||
"error.msg.no_user": "User linked to this {0} account doesn't exist.",
|
||||
"error.msg.no_email": "Can't get email address from this {0} account.",
|
||||
"error.msg.already_linked": "This {0} account is already linked to another account.",
|
||||
"error.msg.not_linked": "This {0} account haven't linked to any account yet.",
|
||||
"error.msg.not_linked": "This {0} account hasn't been linked to any account yet.",
|
||||
"error.msg.unverified_account": "This {0} account is unverified, please try again after verification.",
|
||||
"error.msg.user_not_allowed": "Du har ikke tilladelse til at logge ind.",
|
||||
"error.msg.cannot_get_user_info": "Can not get your user info from this {0} account.",
|
||||
"error.msg.cannot_get_user_info": "Cannot get your user info from this {0} account.",
|
||||
"error.param.provider_github": "GitHub",
|
||||
"error.param.provider_google": "Google",
|
||||
"error.param.provider_microsoft": "Microsoft",
|
||||
@@ -453,17 +523,21 @@ export default {
|
||||
"common.button.generate": "Generer",
|
||||
"common.button.done": "Færdig",
|
||||
"common.text.link": "Link",
|
||||
"common.text.navigate-to-link": "Go to the link",
|
||||
"common.text.navigate-to-link": "Visit link",
|
||||
"common.text.or": "eller",
|
||||
"common.text.redirecting": "Omdirigerer...",
|
||||
"common.button.go-back": "Gå tilbage",
|
||||
"common.button.go-home": "Go home",
|
||||
"common.notify.copied": "Linket blev kopieret til udklipsholderen",
|
||||
"common.notify.copied-link": "Linket blev kopieret til udklipsholderen",
|
||||
"common.success": "Success",
|
||||
"common.error": "Fejl",
|
||||
"common.error.unknown": "En ukendt fejl opstod",
|
||||
"common.error.invalid-email": "Ugyldig e-mail",
|
||||
"common.error.too-short": "Skal være på mindst {length} tegn",
|
||||
"common.error.too-long": "Må højst være {length} tegn",
|
||||
"common.error.number-too-small": "Must be at least {min}",
|
||||
"common.error.number-too-large": "Must be at most {max}",
|
||||
"common.error.exact-length": "Skal være præcis {length} tegn",
|
||||
"common.error.invalid-number": "Skal være et tal",
|
||||
"common.error.field-required": "Dette felt er påkrævet"
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
"navbar.home": "Startseite",
|
||||
"navbar.signup": "Registrieren",
|
||||
"navbar.links.shares": "Meine Freigaben",
|
||||
"navbar.links.reverse": "Externe Freigaben",
|
||||
"navbar.links.reverse": "Datei Anfrage",
|
||||
"navbar.avatar.account": "Mein Konto",
|
||||
"navbar.avatar.admin": "Verwaltung",
|
||||
"navbar.avatar.signout": "Abmelden",
|
||||
@@ -16,9 +16,9 @@ export default {
|
||||
"home.bullet.a.name": "Selbst gehostet",
|
||||
"home.bullet.a.description": "Betreibe Pingvin Share auf deinem eigenen Server.",
|
||||
"home.bullet.b.name": "Privatsphäre",
|
||||
"home.bullet.b.description": "Deine Dateien gehören dir und sollten niemals in die Hände Dritter gelangen.",
|
||||
"home.bullet.b.description": "Deine Dateien gehören dir und sind niemals durch Dritte zugänglich.",
|
||||
"home.bullet.c.name": "Keine lästige Dateigrößenbegrenzung",
|
||||
"home.bullet.c.description": "Lade Dateien beliebiger Größe hoch. Nur dein Festplattenspeicher stellt die Grenze dar.",
|
||||
"home.bullet.c.description": "Lade so große Dateien hoch, wie du willst. Nur deine Festplatte ist dein Limit.",
|
||||
"home.button.start": "Lege los",
|
||||
"home.button.source": "Quellcode",
|
||||
// END /
|
||||
@@ -26,13 +26,13 @@ export default {
|
||||
"signin.title": "Willkommen zurück",
|
||||
"signin.description": "Du hast noch kein Konto?",
|
||||
"signin.button.signup": "Registrieren",
|
||||
"signin.input.email-or-username": "Email oder Benutzername",
|
||||
"signin.input.email-or-username.placeholder": "Deine Email Adresse oder Benutzername",
|
||||
"signin.input.email-or-username": "E-Mail oder Benutzername",
|
||||
"signin.input.email-or-username.placeholder": "Deine E-Mail-Adresse oder Benutzername",
|
||||
"signin.input.password": "Passwort",
|
||||
"signin.input.password.placeholder": "Dein Passwort",
|
||||
"signin.button.submit": "Anmelden",
|
||||
"signIn.notify.totp-required.title": "Zwei-Faktor-Authentifizierung benötigt",
|
||||
"signIn.notify.totp-required.description": "Bitte füge deinen Zwei-Faktor-Authentifizierungscode ein",
|
||||
"signIn.notify.totp-required.description": "Bitte gib deinen Zwei-Faktor-Authentifizierungscode ein",
|
||||
"signIn.oauth.or": "ODER",
|
||||
"signIn.oauth.signInWith": "Anmelden mit",
|
||||
"signIn.oauth.github": "GitHub",
|
||||
@@ -47,8 +47,8 @@ export default {
|
||||
"signup.button.signin": "Anmelden",
|
||||
"signup.input.username": "Benutzername",
|
||||
"signup.input.username.placeholder": "Dein Benutzername",
|
||||
"signup.input.email": "Email",
|
||||
"signup.input.email.placeholder": "Deine Emailadresse",
|
||||
"signup.input.email": "E-Mail",
|
||||
"signup.input.email.placeholder": "Deine E-Mail-Adresse",
|
||||
"signup.button.submit": "Lass uns loslegen",
|
||||
// END /auth/signup
|
||||
// /auth/totp
|
||||
@@ -57,7 +57,7 @@ export default {
|
||||
// END /auth/totp
|
||||
// /auth/reset-password
|
||||
"resetPassword.title": "Passwort vergessen?",
|
||||
"resetPassword.description": "Gib deine Email Adresse ein, um dein Passwort zurückzusetzen.",
|
||||
"resetPassword.description": "Gib deine E-Mail-Adresse ein, um dein Passwort zurückzusetzen.",
|
||||
"resetPassword.notify.success": "Wir haben dir einen Link gesendet, unter dem du dein Passwort zurücksetzen kannst.",
|
||||
"resetPassword.button.back": "Zurück zur Anmeldeseite",
|
||||
"resetPassword.text.resetPassword": "Passwort zurücksetzen",
|
||||
@@ -68,7 +68,7 @@ export default {
|
||||
"account.title": "Mein Konto",
|
||||
"account.card.info.title": "Kontoinformationen",
|
||||
"account.card.info.username": "Benutzername",
|
||||
"account.card.info.email": "Email",
|
||||
"account.card.info.email": "E-Mail",
|
||||
"account.notify.info.success": "Konto erfolgreich aktualisiert",
|
||||
"account.card.password.title": "Passwort",
|
||||
"account.card.password.old": "Altes Passwort",
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
"account.card.oauth.unlink": "Verknüpfung aufheben",
|
||||
"account.card.oauth.unlinked": "Verknüpfung aufgehoben",
|
||||
"account.modal.unlink.title": "Kontoverknüpfung aufheben",
|
||||
"account.modal.unlink.description": "Das Entfernen der Verknüpfung mit deinem sozialen Konten kann dazu führen, dass du dein Konto verlierst, wenn du dich nicht an deinen Benutzernamen und dein Passwort erinnerst.",
|
||||
"account.modal.unlink.description": "Die Aufhebung der Verknüpfung mit deinen sozialen Konten kann dazu führen, dass du dein Konto verlierst, wenn du dich nicht an deine Anmeldedaten erinnerst",
|
||||
"account.notify.oauth.unlinked.success": "Verknüpfung erfolgreich aufgehoben",
|
||||
"account.card.security.title": "Sicherheit",
|
||||
"account.card.security.totp.enable.description": "Gib dein aktuelles Passwort ein, um TOTP zu aktivieren",
|
||||
@@ -122,18 +122,21 @@ export default {
|
||||
"account.shares.table.description": "Beschreibung",
|
||||
"account.shares.table.visitors": "Besucher",
|
||||
"account.shares.table.expiresAt": "Läuft ab am",
|
||||
"account.shares.table.createdAt": "Erstellt am",
|
||||
"account.shares.table.createdAt": "Angelegt am",
|
||||
"account.shares.table.size": "Größe",
|
||||
"account.shares.table.password-protected": "Passwortgeschützt",
|
||||
"account.shares.table.visitor-count": "{count} von {max}",
|
||||
"account.shares.table.expiry-never": "nie",
|
||||
"account.shares.modal.share-informations": "Teile deine Information",
|
||||
"account.shares.modal.share-link": "Freigabe teilen",
|
||||
"account.shares.modal.delete.title": "Lösche Freigabe {share}",
|
||||
"account.shares.modal.delete.description": "Möchtest du wirklich diese Freigabe löschen?",
|
||||
"account.shares.modal.delete.title": "Freigabe löschen: {share}",
|
||||
"account.shares.modal.delete.description": "Möchtest du diese Freigabe wirklich löschen?",
|
||||
// END /account/shares
|
||||
// /account/reverseShares
|
||||
"account.reverseShares.title": "Externe Freigaben",
|
||||
"account.reverseShares.description": "Eine externe Freigabe erlaubt dir eine einzigartige URL zu erstellen, die externen Benutzern erlaubt Dateien hochzuladen.",
|
||||
"account.reverseShares.title": "Datei Anfragen",
|
||||
"account.reverseShares.description": "Eine Datei Anfrage erlaubt dir eine einzigartige URL zu erstellen, die externen Benutzern erlaubt, Dateien hochzuladen.",
|
||||
"account.reverseShares.title.empty": "Es ist leer hier 👀",
|
||||
"account.reverseShares.description.empty": "Du hast keine externen Freigaben erstellt.",
|
||||
"account.reverseShares.description.empty": "Du hast keine Datei Anfrage.",
|
||||
// showCreateReverseShareModal.tsx
|
||||
"account.reverseShares.modal.title": "Externe Freigabe erstellen",
|
||||
"account.reverseShares.modal.expiration.label": "Gültig bis",
|
||||
@@ -149,13 +152,13 @@ export default {
|
||||
"account.reverseShares.modal.expiration.month-plural": "Monate",
|
||||
"account.reverseShares.modal.expiration.year-singular": "Jahr",
|
||||
"account.reverseShares.modal.expiration.year-plural": "Jahre",
|
||||
"account.reverseShares.modal.max-size.label": "Max. Freigabengröße",
|
||||
"account.reverseShares.modal.send-email": "Email Benachrichtigung senden",
|
||||
"account.reverseShares.modal.send-email.description": "Sendet eine Email Benachrichtigung, wenn eine Datei auf einer externen Freigabe hochgeladen wurde.",
|
||||
"account.reverseShares.modal.max-size.label": "Maximale Freigabegröße",
|
||||
"account.reverseShares.modal.send-email": "E-Mail Benachrichtigungen senden",
|
||||
"account.reverseShares.modal.send-email.description": "Sendet eine E-Mail Benachrichtigung, wenn eine Datei auf Ihrer externen Freigabe hochgeladen wurde.",
|
||||
"account.reverseShares.modal.simplified": "Einfacher Modus",
|
||||
"account.reverseShares.modal.simplified.description": "Mache es der Person einfach, die die Datei hochlädt, sie mit Dir zu teilen. Sie können nur den Namen und die Beschreibung der Freigabe ändern.",
|
||||
"account.reverseShares.modal.simplified.description": "Mache es der Person, die die Datei hochlädt, einfach, sie mit dir zu teilen. Sie werden nur den Namen und die Beschreibung der Freigabe ändern können.",
|
||||
"account.reverseShares.modal.public-access": "Öffentlicher Zugriff",
|
||||
"account.reverseShares.modal.public-access.description": "Make the created shares with this reverse share public. If disabled, only you and the creator of the share can view it.",
|
||||
"account.reverseShares.modal.public-access.description": "Mache die Freigaben die mit dieser externen Freigabe erstellt werden öffentlich. Wenn dies deaktiviert ist, haben nur du und der Ersteller der Freigabe Zugriff darauf.",
|
||||
"account.reverseShares.modal.max-use.label": "Maximale Nutzungen",
|
||||
"account.reverseShares.modal.max-use.description": "Die maximale Anzahl von Verwendungen der URL, um Dateien hochzuladen.",
|
||||
"account.reverseShare.never-expires": "Diese externe Freigabe wird nicht ablaufen.",
|
||||
@@ -168,55 +171,58 @@ export default {
|
||||
"account.reverseShares.table.max-size": "Maximale Freigabegröße",
|
||||
"account.reverseShares.table.expires": "Läuft ab am",
|
||||
"account.reverseShares.modal.reverse-share-link": "Link zu externer Freigabe",
|
||||
"account.reverseShares.modal.delete.title": "Lösche externe Freigabe",
|
||||
"account.reverseShares.modal.delete.description": "Möchtest du wirklich diese externe Freigabe löschen? In diesem Falle werden auch hiermit verbundene Freigaben gelöscht.",
|
||||
"account.reverseShares.modal.delete.title": "Externe Freigabe löschen",
|
||||
"account.reverseShares.modal.delete.description": "Möchtest du diese externe Freigabe wirklich löschen? In diesem Fall werden auch hiermit verbundene Freigaben gelöscht.",
|
||||
// END /account/reverseShares
|
||||
// /admin
|
||||
"admin.title": "Verwaltung",
|
||||
"admin.button.users": "Benutzerverwaltung",
|
||||
"admin.button.shares": "Freigabenverwaltung",
|
||||
"admin.button.shares": "Freigaben Verwaltung",
|
||||
"admin.button.config": "Konfiguration",
|
||||
"admin.version": "Version",
|
||||
// END /admin
|
||||
// /admin/users
|
||||
"admin.users.title": "Benutzerverwaltung",
|
||||
"admin.users.table.username": "Benutzername",
|
||||
"admin.users.table.email": "Email",
|
||||
"admin.users.table.email": "E-Mail",
|
||||
"admin.users.table.admin": "Administrator",
|
||||
"admin.users.edit.update.title": "Benutzer {username} aktualisieren",
|
||||
"admin.users.edit.update.title": "Benutzer bearbeiten: {username}",
|
||||
"admin.users.edit.update.admin-privileges": "Administratorrechte",
|
||||
"admin.users.edit.update.change-password.title": "Passwort ändern",
|
||||
"admin.users.edit.update.change-password.field": "Neues Passwort",
|
||||
"admin.users.edit.update.change-password.button": "Neues Passwort speichern",
|
||||
"admin.users.edit.update.notify.password.success": "Passwort erfolgreich geändert",
|
||||
"admin.users.edit.delete.title": "Löschen des Nutzers {username}",
|
||||
"admin.users.edit.delete.title": "Benutzer löschen: {username}?",
|
||||
"admin.users.edit.delete.description": "Möchtest du wirklich diesen Benutzer und all seine Freigaben löschen?",
|
||||
// showCreateUserModal.tsx
|
||||
"admin.users.modal.create.title": "Benutzer erstellen",
|
||||
"admin.users.modal.create.username": "Benutzername",
|
||||
"admin.users.modal.create.email": "Email",
|
||||
"admin.users.modal.create.email": "E-Mail",
|
||||
"admin.users.modal.create.password": "Passwort",
|
||||
"admin.users.modal.create.manual-password": "Passwort manuell festlegen",
|
||||
"admin.users.modal.create.manual-password.description": "Wenn nicht aktiviert, erhält der Benutzer eine Email mit einem Link, um sein Passwort festzulegen.",
|
||||
"admin.users.modal.create.manual-password.description": "Wenn nicht aktiviert, erhält der Benutzer eine E-Mail mit einem Link, um sein Passwort festzulegen.",
|
||||
"admin.users.modal.create.admin": "Administratorrechte",
|
||||
"admin.users.modal.create.admin.description": "Wenn aktiviert, kann der Benutzer auf das Administrator-Panel zugreifen.",
|
||||
// END /admin/users
|
||||
// /admin/shares
|
||||
"admin.shares.title": "Freigabenverwaltung",
|
||||
"admin.shares.table.id": "ID teilen",
|
||||
"admin.shares.title": "Freigaben Verwaltung",
|
||||
"admin.shares.table.id": "Freigabe ID",
|
||||
"admin.shares.table.username": "Ersteller",
|
||||
"admin.shares.table.visitors": "Besucher",
|
||||
"admin.shares.table.expires": "Läuft ab am",
|
||||
"admin.shares.edit.delete.title": "Lösche Freigabe {id}",
|
||||
"admin.shares.edit.delete.description": "Möchtest du wirklich diese Freigabe löschen?",
|
||||
"admin.shares.edit.delete.title": "Freigabe löschen: {id}",
|
||||
"admin.shares.edit.delete.description": "Möchtest du diese Freigabe wirklich löschen?",
|
||||
// END /admin/shares
|
||||
// /upload
|
||||
"upload.title": "Upload",
|
||||
"upload.notify.confirm-leave": "Bist du sicher, dass du diese Seite verlassen willst? Dein Upload wird abgebrochen.",
|
||||
"upload.notify.generic-error": "Während der Erstellung der Freigabe ist ein Fehler aufgetreten.",
|
||||
"upload.notify.count-failed": "{count} Dateien konnten nicht hochgeladen werden. Wird erneut versucht.",
|
||||
"upload.reverse-share.error.invalid.title": "Ungültiger Link zu externer Freigabe",
|
||||
"upload.reverse-share.error.invalid.description": "Diese externe Freigabe ist abgelaufen oder ungültig.",
|
||||
// Dropzone.tsx
|
||||
"upload.dropzone.title": "Dateien hochladen",
|
||||
"upload.dropzone.description": "Ziehe Dateien hierher, um deine Freigabe zu starten. Wir können nur Dateien akzeptieren, die insgesamt weniger als {maxSize} groß sind.",
|
||||
"upload.dropzone.description": "Ziehe Dateien per Drag'n'Drop hierher, um deine Freigabe zu starten. Wir akzeptieren nur Dateien mit einer Gesamtgröße von bis zu {maxSize}.",
|
||||
"upload.dropzone.notify.file-too-big": "Ihre Dateien überschreiten die maximale Freigabegröße von {maxSize}.",
|
||||
// FileList.tsx
|
||||
"upload.filelist.name": "Name",
|
||||
@@ -227,9 +233,9 @@ export default {
|
||||
"upload.modal.link.error.taken": "Dieser Link wird bereits verwendet",
|
||||
"upload.modal.not-signed-in": "Du bist nicht angemeldet",
|
||||
"upload.modal.not-signed-in-description": "Du wirst deine Freigabe nicht löschen können oder die Besucheranzahl sehen.",
|
||||
"upload.modal.expires.never": "niemals",
|
||||
"upload.modal.expires.never-long": "Läuft nicht ab",
|
||||
"upload.modal.expires.error.too-long": "Ablauf überschreitet das maximale Ablaufdatum von {max}.",
|
||||
"upload.modal.expires.never": "nie",
|
||||
"upload.modal.expires.never-long": "Permanente Freigabe",
|
||||
"upload.modal.expires.error.too-long": "Das Ablaufdatum überschreitet das Maximum von {max}.",
|
||||
"upload.modal.link.label": "Link",
|
||||
"upload.modal.expires.label": "Gültig bis",
|
||||
"upload.modal.expires.minute-singular": "Minute",
|
||||
@@ -243,13 +249,13 @@ export default {
|
||||
"upload.modal.expires.month-singular": "Monat",
|
||||
"upload.modal.expires.month-plural": "Monate",
|
||||
"upload.modal.expires.year-singular": "Jahr",
|
||||
"upload.modal.expires.year-plural": "Year",
|
||||
"upload.modal.expires.year-plural": "Jahre",
|
||||
"upload.modal.accordion.name-and-description.title": "Name und Beschreibung",
|
||||
"upload.modal.accordion.name-and-description.name.placeholder": "Name",
|
||||
"upload.modal.accordion.name-and-description.description.placeholder": "Hinweis für die Empfänger dieser Freigabe",
|
||||
"upload.modal.accordion.email.title": "Email Empfänger",
|
||||
"upload.modal.accordion.email.placeholder": "Email der Empfänger eingeben",
|
||||
"upload.modal.accordion.email.invalid-email": "Ungültige Emailadresse",
|
||||
"upload.modal.accordion.email.title": "E-Mail-Empfänger",
|
||||
"upload.modal.accordion.email.placeholder": "E-Mail der Empfänger eingeben",
|
||||
"upload.modal.accordion.email.invalid-email": "Ungültige E-Mail-Adresse",
|
||||
"upload.modal.accordion.security.title": "Sicherheitseinstellungen",
|
||||
"upload.modal.accordion.security.password.label": "Passwortschutz",
|
||||
"upload.modal.accordion.security.password.placeholder": "Kein Passwort",
|
||||
@@ -264,6 +270,7 @@ export default {
|
||||
// /share/[id]
|
||||
"share.title": "Freigabe {shareId}",
|
||||
"share.description": "Schau, was ich mit dir geteilt habe!",
|
||||
"share.fileCount": "{count, plural, =1 {# file} other {# files}} · {size} (zip file may be smaller due to compression)",
|
||||
"share.error.visitor-limit-exceeded.title": "Besucher Limit erreicht",
|
||||
"share.error.visitor-limit-exceeded.description": "Die maximale Besucheranzahl für diese Freigabe ist überschritten.",
|
||||
"share.error.removed.title": "Freigabe entfernt",
|
||||
@@ -272,16 +279,16 @@ export default {
|
||||
"share.error.access-denied.title": "Private Freigabe",
|
||||
"share.error.access-denied.description": "Das aktuelle Konto hat keine Berechtigung, um auf diese Freigabe zuzugreifen",
|
||||
"share.modal.password.title": "Passwort erforderlich",
|
||||
"share.modal.password.description": "Um auf diese Freigabe zuzugreifen, gib bitte das Passwort für die Freigabe ein.",
|
||||
"share.modal.password.description": "Bitte gib das Passwort ein, um auf diese Freigabe zuzugreifen.",
|
||||
"share.modal.password": "Passwort",
|
||||
"share.modal.error.invalid-password": "Ungültiges Passwort",
|
||||
"share.button.download-all": "Alles herunterladen",
|
||||
"share.notify.download-all-preparing": "Die Freigabe wird vorbereitet. Versuche es in ein paar Minuten erneut.",
|
||||
"share.notify.download-all-preparing": "Die Freigabe wird vorbereitet. Bitte versuche es in ein paar Minuten erneut.",
|
||||
"share.modal.file-link": "Dateilink",
|
||||
"share.table.name": "Name",
|
||||
"share.table.size": "Größe",
|
||||
"share.modal.file-preview.error.not-supported.title": "Vorschau wird nicht unterstützt",
|
||||
"share.modal.file-preview.error.not-supported.description": "Eine Vorschau für diesen Dateityp wird nicht unterstützt. Bitte lade die Datei herunter, um sie anzusehen.",
|
||||
"share.modal.file-preview.error.not-supported.description": "Vorschaubilder werden für diesen Dateityp nicht unterstützt. Bitte lade die Datei herunter, um sie anzuzeigen.",
|
||||
// END /share/[id]
|
||||
// /share/[id]/edit
|
||||
"share.edit.title": "{shareId} bearbeiten",
|
||||
@@ -289,17 +296,28 @@ export default {
|
||||
"share.edit.notify.generic-error": "Während der Erstellung der Freigabe ist ein Fehler aufgetreten.",
|
||||
"share.edit.notify.save-success": "Freigabe erfolgreich aktualisiert",
|
||||
// END /share/[id]/edit
|
||||
// /imprint
|
||||
"imprint.title": "Impressum",
|
||||
// END /imprint
|
||||
// /privacy
|
||||
"privacy.title": "Datenschutzerklärung",
|
||||
// END /privacy
|
||||
// /admin/config
|
||||
"admin.config.config-file-warning.title": "Konfigurationsdatei aktiv",
|
||||
"admin.config.config-file-warning.description": "Da Pingvin Share mit einer Konfigurationsdatei konfiguriert ist, kann die Konfiguration nicht über die Benutzeroberfläche geändert werden.",
|
||||
"admin.config.title": "Einstellungen",
|
||||
"admin.config.category.general": "Allgemein",
|
||||
"admin.config.category.share": "Freigabe",
|
||||
"admin.config.category.cache": "Cache",
|
||||
"admin.config.category.email": "E-Mail",
|
||||
"admin.config.category.smtp": "SMTP",
|
||||
"admin.config.category.oauth": "OAuth-Anmeldung",
|
||||
"admin.config.category.oauth": "Anmeldung über soziale Netzwerke",
|
||||
"admin.config.general.app-name": "App-Name",
|
||||
"admin.config.general.app-name.description": "Name der Applikation",
|
||||
"admin.config.general.app-url": "App-URL",
|
||||
"admin.config.general.app-url.description": "Auf welcher URL Pingvin Share verfügbar ist",
|
||||
"admin.config.general.secure-cookies": "Sichere Cookies",
|
||||
"admin.config.general.secure-cookies.description": "Gibt an, ob die secure flag bei Cookies gesetzt werden soll. Falls aktiviert, wird die Seite nicht funktionieren, wenn sie über HTTP aufgerufen wird.",
|
||||
"admin.config.general.show-home-page": "Startseite anzeigen",
|
||||
"admin.config.general.show-home-page.description": "Ob die Startseite angezeigt werden soll",
|
||||
"admin.config.general.session-duration": "Session-Dauer",
|
||||
@@ -307,40 +325,50 @@ export default {
|
||||
"admin.config.general.logo": "Logo",
|
||||
"admin.config.general.logo.description": "Ändere dein Logo durch Hochladen eines Bildes. Das Bild muss im PNG-Format vorliegen und sollte mit Seitenverhältnis 1:1 sein.",
|
||||
"admin.config.general.logo.placeholder": "Bild auswählen",
|
||||
"admin.config.email.enable-share-email-recipients": "Erlaube das Teilen der Freigabe via Email",
|
||||
"admin.config.email.enable-share-email-recipients.description": "Gibt an, ob Emails an Freigabe-Empfänger ermöglicht werden sollen. Aktiviere dies nur, wenn du SMTP aktivierst hast.",
|
||||
"admin.config.cache.ttl": "TTL",
|
||||
"admin.config.cache.ttl.description": "Time in second to keep information inside the cache.",
|
||||
"admin.config.cache.max-items": "Maximum items",
|
||||
"admin.config.cache.max-items.description": "Maximum number of items inside the cache.",
|
||||
"admin.config.cache.redis-enabled": "Redis enabled",
|
||||
"admin.config.cache.redis-enabled.description": "Normally Pingvin Share caches information in memory. If you run multiple instances of Pingvin Share, you need to enable Redis caching to share the cache between the instances.",
|
||||
"admin.config.cache.redis-url": "Redis URL",
|
||||
"admin.config.cache.redis-url.description": "Url to connect to the Redis instance used for caching.",
|
||||
"admin.config.email.enable-share-email-recipients": "Erlaube das Teilen der Freigabe via E-Mail",
|
||||
"admin.config.email.enable-share-email-recipients.description": "Gibt an, ob das Teilen von E-Mails mit Empfängern erlaubt werden soll. Aktiviere dies nur, wenn SMTP aktiviert ist.",
|
||||
"admin.config.email.share-recipients-subject": "Betreff für Freigabe-Empfänger",
|
||||
"admin.config.email.share-recipients-subject.description": "Betreff der E-Mail, die an die Freigabe-Empfänger gesendet wird.",
|
||||
"admin.config.email.share-recipients-message": "Nachricht für Freigabe-Empfänger",
|
||||
"admin.config.email.share-recipients-message.description": "Nachricht, die an die Freigabe-Empfänger gesendet wird. Verfügbare Variablen:\n- {creator} - Der Benutzername des Erstellers der Freigabe\n- {shareUrl} - Die URL der Freigabe\n- {desc} - Die Beschreibung der Freigabe\n- {expires} - Das Ablaufdatum der Freigabe\nVariablen werden durch die tatsächlichen Werte ersetzt.",
|
||||
"admin.config.email.share-recipients-message.description": "Nachricht, die an die Freigabe-Empfänger gesendet wird. Verfügbare Variablen:\n- {creator} - Der Benutzername des Erstellers der Freigabe\n- {creatorEmail} - E-Mail-Adresse des Erstellers der Freigabe\n- {shareUrl} - Die URL der Freigabe\n- {desc} - Die Beschreibung der Freigabe\n- {expires} - Das Ablaufdatum der Freigabe\nVariablen werden durch die tatsächlichen Werte ersetzt.",
|
||||
"admin.config.email.reverse-share-subject": "Name der externen Freigabe",
|
||||
"admin.config.email.reverse-share-subject.description": "Betreff der Email, die gesendet wird, wenn jemand eine Datei mit deinem externen Freigabe-Link hochlädt.",
|
||||
"admin.config.email.reverse-share-subject.description": "Betreff der gesendeten E-Mail, wenn jemand eine Freigabe mit Ihrem externen Freigabe-Link erstellt hat.",
|
||||
"admin.config.email.reverse-share-message": "Nachricht für externe Freigabe",
|
||||
"admin.config.email.reverse-share-message.description": "Nachricht, die gesendet wird, wenn jemand eine Freigabe mit deinem externen Freigabe-Link erstellt. {shareUrl} wird durch den Namen des Erstellers und die Freigabe-URL ersetzt.",
|
||||
"admin.config.email.reset-password-subject": "Betreff für Passwortzurücksetzung",
|
||||
"admin.config.email.reset-password-subject.description": "Betreff der E-Mail, die gesendet wird, wenn ein Benutzer eine Passwortzurücksetzung anfordert.",
|
||||
"admin.config.email.reset-password-subject.description": "Betreff der gesendeten E-Mail, wenn ein Benutzer ein Passwort zurücksetzen möchte.",
|
||||
"admin.config.email.reset-password-message": "Nachricht für Passwortzurücksetzung",
|
||||
"admin.config.email.reset-password-message.description": "Nachricht, die gesendet wird, wenn ein Benutzer eine Passwortzurücksetzung anfordert. {url} wird durch die URL für das Zurücksetzen des Passworts ersetzt.",
|
||||
"admin.config.email.invite-subject": "Betreff für Einladung",
|
||||
"admin.config.email.invite-subject.description": "Betreff der E-Mail, die gesendet wird, wenn ein Administrator einen Benutzer einlädt.",
|
||||
"admin.config.email.invite-subject.description": "Betreff der gesendeten E-Mail, wenn ein Administrator einen Benutzer einlädt.",
|
||||
"admin.config.email.invite-message": "Nachricht für Einladung",
|
||||
"admin.config.email.invite-message.description": "Nachricht, die gesendet wird, wenn ein Administrator einen Benutzer einlädt. {url} wird durch die Einladungs-URL und {password} durch das Passwort ersetzt.",
|
||||
"admin.config.email.invite-message.description": "Nachricht, die gesendet wird, wenn ein Administrator einen Benutzer einlädt. {url} wird durch die Einladung-URL ersetzt und {password} durch das Passwort ersetzt.",
|
||||
"admin.config.share.allow-registration": "Registrierung erlauben",
|
||||
"admin.config.share.allow-registration.description": "Gibt an, ob eine Registrierung erlaubt ist",
|
||||
"admin.config.share.allow-unauthenticated-shares": "Nicht authentifizierte Freigaben erlauben",
|
||||
"admin.config.share.allow-unauthenticated-shares.description": "Gibt an, ob nicht authentifizierte Benutzer Freigaben erstellen können",
|
||||
"admin.config.share.max-expiration": "Max. Ablaufdatum",
|
||||
"admin.config.share.max-expiration.description": "Maximale Ablaufzeit in Stunden. Auf 0 setzen, um kein Ablaufdatum zu definieren.",
|
||||
"admin.config.share.max-expiration.description": "Maximale Ablaufzeit. Auf 0 setzen, um kein Ablaufdatum zu definieren.",
|
||||
"admin.config.share.share-id-length": "Standardlänge der Freigabe-ID",
|
||||
"admin.config.share.share-id-length.description": "Standardlänge für die generierte ID einer Freigabe. Dieser Wert wird auch verwendet, um Links für umgekehrte Freigaben zu generieren. Ein Wert unter 8 wird als nicht sicher betrachtet.",
|
||||
"admin.config.share.max-size": "Maximale Größe",
|
||||
"admin.config.share.max-size.description": "Maximale Größe einer Freigabe in Bytes",
|
||||
"admin.config.share.zip-compression-level": "Zip Komprimierungsstufe",
|
||||
"admin.config.share.zip-compression-level.description": "Passe den Wert an, um ein Gleichgewicht zwischen Dateigröße und Komprimierungsgeschwindigkeit herzustellen. Gültige Werte liegen zwischen 0 und 9, wobei 0 für keine Komprimierung und 9 für maximale Komprimierung steht.",
|
||||
"admin.config.share.chunk-size": "Chunkgröße",
|
||||
"admin.config.share.chunk-size.description": "Passe die Chunkgröße (in Bytes) für deine Uploads an, um die Zuverlässigkeit deiner Internetverbindung auszugleichen. Kleinere Chunks können die Erfolgsraten für instabile Verbindungen verbessern, während größere Chunks Uploads für stabile Verbindungen beschleunigen können.",
|
||||
"admin.config.share.max-size.description": "Maximale Größe einer Freigabe",
|
||||
"admin.config.share.zip-compression-level": "ZIP-Kompressionslevel",
|
||||
"admin.config.share.zip-compression-level.description": "Passe den Wert an, um ein Gleichgewicht zwischen Dateigröße und Kompressionsgeschwindigkeit herzustellen. Gültige Werte liegen zwischen 0 und 9, wobei 0 für keine Komprimierung und 9 für maximale Komprimierung steht. ",
|
||||
"admin.config.share.chunk-size": "Chunk Größe",
|
||||
"admin.config.share.chunk-size.description": "Passe die Chunk-Größe für deine Uploads an, um Effizienz und Zuverlässigkeit entsprechend deiner Internetverbindung auszubalancieren. Kleinere Chunks können die Erfolgsraten bei instabilen Verbindungen erhöhen, während größere Chunks Uploads bei stabilen Verbindungen beschleunigen.",
|
||||
"admin.config.share.auto-open-share-modal": "Freigabe-Fenster automatisch öffnen",
|
||||
"admin.config.share.auto-open-share-modal.description": "Das Freigabe-Fenster erscheint automatisch, sobald ein Benutzer Dateien ausgewählt hat, ohne extra auf den Button klicken zu müssen.",
|
||||
"admin.config.smtp.enabled": "Aktiviert",
|
||||
"admin.config.smtp.enabled.description": "Gibt an, ob SMTP aktiviert ist. Aktiviere dies nur, wenn du den Host, den Port, die Email, den Benutzernamen und das Passwort deines SMTP-Servers eingegeben hast.",
|
||||
"admin.config.smtp.enabled": "Aktivieren",
|
||||
"admin.config.smtp.enabled.description": "Gibt an, ob SMTP aktiviert ist. Aktiviere dies nur, wenn du den Host, den Port, die E-Mail, den Benutzernamen und das Passwort deines SMTP-Servers eingegeben hast.",
|
||||
"admin.config.smtp.host": "Host",
|
||||
"admin.config.smtp.host.description": "Host des SMTP-Servers",
|
||||
"admin.config.smtp.port": "Port",
|
||||
@@ -353,50 +381,56 @@ export default {
|
||||
"admin.config.smtp.password.description": "Passwort des SMTP-Servers",
|
||||
"admin.config.smtp.button.test": "Test-E-Mail senden",
|
||||
"admin.config.smtp.allow-unauthorized-certificates": "Vertrauen von nicht authentifizierten SMTP-Server-Zertifikaten",
|
||||
"admin.config.smtp.allow-unauthorized-certificates.description": "Verwenden Sie diese Option nur, wenn Sie selbst signierten Zertifikaten vertrauen müssen.",
|
||||
"admin.config.smtp.allow-unauthorized-certificates.description": "Verwende diese Option nur, wenn du selbst signierten Zertifikaten vertrauen musst.",
|
||||
"admin.config.oauth.allow-registration": "Registrierung erlauben",
|
||||
"admin.config.oauth.allow-registration.description": "Benutzern erlauben, sich über Soziale Netzwerke zu registrieren",
|
||||
"admin.config.oauth.allow-registration.description": "Benutzern erlauben, sich über soziale Netzwerke zu registrieren",
|
||||
"admin.config.oauth.ignore-totp": "TOTP ignorieren",
|
||||
"admin.config.oauth.ignore-totp.description": "Gibt an, ob TOTP ignoriert werden soll, wenn sich der Benutzer über Soziale Netzwerke anmeldet",
|
||||
"admin.config.oauth.ignore-totp.description": "Gibt an, ob TOTP ignoriert werden soll, wenn sich der Benutzer über soziale Netzwerke anmeldet",
|
||||
"admin.config.oauth.disable-password": "Anmelden mit Passwort deaktivieren",
|
||||
"admin.config.oauth.disable-password.description": "Deaktiviert das Anmelden mit Passwort\nStelle vor Aktivierung dieser Konfiguration sicher, dass ein OAuth-Provider korrekt konfiguriert ist, um nicht ausgesperrt zu werden.",
|
||||
"admin.config.oauth.github-enabled": "GitHub",
|
||||
"admin.config.oauth.github-enabled.description": "GitHub Anmeldung erlaubt",
|
||||
"admin.config.oauth.github-client-id": "GitHub Client-ID",
|
||||
"admin.config.oauth.github-client-id.description": "Client-ID der GitHub OAuth-App",
|
||||
"admin.config.oauth.github-client-secret": "GitHub Client-Secret",
|
||||
"admin.config.oauth.github-client-secret.description": "Client-Secret der GitHub OAuth-App",
|
||||
"admin.config.oauth.github-client-secret": "GitHub Client Secret",
|
||||
"admin.config.oauth.github-client-secret.description": "Client Secret der GitHub OAuth App",
|
||||
"admin.config.oauth.google-enabled": "Google",
|
||||
"admin.config.oauth.google-enabled.description": "Google Anmeldung erlaubt",
|
||||
"admin.config.oauth.google-client-id": "Google Client-ID",
|
||||
"admin.config.oauth.google-client-id.description": "Client-ID der Google OAuth-App",
|
||||
"admin.config.oauth.google-client-secret": "Google Client-Secret",
|
||||
"admin.config.oauth.google-client-secret.description": "Client-Secret der Google OAuth-App",
|
||||
"admin.config.oauth.google-client-secret": "Google Client Secret",
|
||||
"admin.config.oauth.google-client-secret.description": "Client Secret der Google OAuth App",
|
||||
"admin.config.oauth.microsoft-enabled": "Microsoft",
|
||||
"admin.config.oauth.microsoft-enabled.description": "Microsoft Anmeldung erlaubt",
|
||||
"admin.config.oauth.microsoft-tenant": "Microsoft Mandant",
|
||||
"admin.config.oauth.microsoft-tenant.description": "Mandant-ID der Microsoft OAuth App\ncommon: Benutzer mit einem persönlichen Microsoft-Konto und einem Arbeits- oder Schulkonto von Microsoft Entra ID können sich in der Anwendung anmelden.\norganizations: Nur Benutzer mit Arbeits- oder Schulkonten von Microsoft Entra ID können sich in der Anwendung anmelden.\nconsumers: Nur Benutzer mit einem persönlichen Microsoft-Konto können sich in der Anwendung anmelden.\nDomänenname des Microsoft Entra Mandanten oder die Mandanten-ID im GUID-Format: Nur Benutzer eines bestimmten Microsoft Entra Mandanten (Verzeichnismitglieder mit einem Arbeits- oder Schulkonto oder Verzeichnis Gäste mit einem persönlichen Microsoft-Konto) können sich anmelden.",
|
||||
"admin.config.oauth.microsoft-client-id": "Microsoft Client-ID",
|
||||
"admin.config.oauth.microsoft-client-id.description": "Client-ID der Microsoft OAuth-App",
|
||||
"admin.config.oauth.microsoft-client-secret": "Microsoft Client-Secret",
|
||||
"admin.config.oauth.microsoft-client-secret.description": "Client-Secret der Microsoft OAuth-App",
|
||||
"admin.config.oauth.microsoft-client-secret": "Microsoft Client Secret",
|
||||
"admin.config.oauth.microsoft-client-secret.description": "Client Secret der Microsoft OAuth App",
|
||||
"admin.config.oauth.discord-enabled": "Discord",
|
||||
"admin.config.oauth.discord-enabled.description": "Discord Anmeldung erlaubt",
|
||||
"admin.config.oauth.discord-limited-users": "Discord limitierte Benutzer",
|
||||
"admin.config.oauth.discord-limited-users.description": "Limitiere die Anmeldung für spezifische Benutzer anhand ihrer Discord ID. Lasse dieses Feld leer, um es zu deaktivieren.",
|
||||
"admin.config.oauth.discord-limited-guild": "Discord Server-ID",
|
||||
"admin.config.oauth.discord-limited-guild.description": "Die Anmeldung auf Benutzer in einem bestimmten Server beschränken. Leer lassen, um zu deaktivieren.",
|
||||
"admin.config.oauth.discord-client-id": "Discord Client-ID",
|
||||
"admin.config.oauth.discord-client-id.description": "Client-ID der Discord OAuth-App",
|
||||
"admin.config.oauth.discord-client-secret": "Discord Client-Secret",
|
||||
"admin.config.oauth.discord-client-secret.description": "Client-Secret der Discord OAuth-App",
|
||||
"admin.config.oauth.discord-client-secret": "Discord Client Secret",
|
||||
"admin.config.oauth.discord-client-secret.description": "Client Secret der Discord OAuth App",
|
||||
"admin.config.oauth.oidc-enabled": "OpenID Connect",
|
||||
"admin.config.oauth.oidc-enabled.description": "OpenID Connect Anmeldung erlaubt",
|
||||
"admin.config.oauth.oidc-discovery-uri": "OpenID Verbindung Discovery URL",
|
||||
"admin.config.oauth.oidc-discovery-uri.description": "Discovery-URL der OpenID OAuth App",
|
||||
"admin.config.oauth.oidc-sign-out": "Abmelden von OpenID Connect",
|
||||
"admin.config.oauth.oidc-sign-out.description": "Wenn aktiviert, wird der Benutzer mit der „Abmelden“-Schaltfläche vom OpenID-Connect-Provider abgemeldet.",
|
||||
"admin.config.oauth.oidc-scope": "OpenID Connect",
|
||||
"admin.config.oauth.oidc-scope.description": "Scopes, die vom OpenID Connect Provider angefordert werden sollen.",
|
||||
"admin.config.oauth.oidc-username-claim": "OpenID Connect Benutzername anfordern",
|
||||
"admin.config.oauth.oidc-username-claim.description": "Benutzername im OpenID Token. Leer lassen, wenn du nicht weißt, was diese Konfiguration bedeutet.",
|
||||
"admin.config.oauth.oidc-role-path": "Path to roles in OpenID Connect token",
|
||||
"admin.config.oauth.oidc-role-path": "Pfad zu den Rollen im OpenID Verbindungs-Token",
|
||||
"admin.config.oauth.oidc-role-path.description": "Muss ein valider JMES-Pfad sein, der zu einem Array an Rollen führt. " + "Die Zugangsverwaltung über Rollen in OpenID Connect ist nur empfohlen, wenn kein anderer Identitätsprovider konfiguriert und die Anmeldung per Password deaktiviert ist. " + "Leer lassen, wenn du nicht weißt, was diese Konfiguration bedeutet.",
|
||||
"admin.config.oauth.oidc-role-general-access": "OpenID Connect role for general access",
|
||||
"admin.config.oauth.oidc-role-general-access": "OpenID Connect Rolle für allgemeinen Zugriff",
|
||||
"admin.config.oauth.oidc-role-general-access.description": "Rolle für generellen Zugriff. Muss Teil der Rollen eines Benutzers sein, damit dieser sich anmelden kann. " + "Leer lassen, wenn du nicht weißt, was diese Konfiguration bedeutet.",
|
||||
"admin.config.oauth.oidc-role-admin-access": "OpenID Connect Rolle für Admin-Zugriff",
|
||||
"admin.config.oauth.oidc-role-admin-access.description": "Rolle für administrativen Zugriff. Muss Teil der Rollen eines Benutzers sein, damit dieser auf das Administrator-Panel zugreifen kann. " + "Leer lassen, wenn du nicht weißt, was diese Konfiguration bedeutet.",
|
||||
@@ -405,19 +439,55 @@ export default {
|
||||
"admin.config.oauth.oidc-client-secret": "OpenID Connect Client-Secret",
|
||||
"admin.config.oauth.oidc-client-secret.description": "Client-Secret der OpenID Connect OAuth-App",
|
||||
"admin.config.category.ldap": "LDAP",
|
||||
"admin.config.ldap.enabled": "Enabled LDAP",
|
||||
"admin.config.ldap.enabled.description": "Use LDAP authentication for user login",
|
||||
"admin.config.ldap.url": "Server URL",
|
||||
"admin.config.ldap.url.description": "URL of the LDAP server",
|
||||
"admin.config.ldap.bind-dn": "Bind DN",
|
||||
"admin.config.ldap.bind-dn.description": "Default user which will be used to execute the user search",
|
||||
"admin.config.ldap.bind-password": "Bind password",
|
||||
"admin.config.ldap.bind-password.description": "Password for the user search user",
|
||||
"admin.config.ldap.search-base": "User base",
|
||||
"admin.config.ldap.search-base.description": "Base location, where the user search will be performed",
|
||||
"admin.config.ldap.search-query": "User query",
|
||||
"admin.config.ldap.search-query.description": "The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.",
|
||||
"admin.config.ldap.admin-groups": "Admin group",
|
||||
"admin.config.ldap.enabled": "LDAP aktivieren",
|
||||
"admin.config.ldap.enabled.description": "LDAP-Authentifizierung für die Benutzeranmeldung verwenden",
|
||||
"admin.config.ldap.url": "Server-URL",
|
||||
"admin.config.ldap.url.description": "URL des LDAP-Servers",
|
||||
"admin.config.ldap.bind-dn": "Binde DN",
|
||||
"admin.config.ldap.bind-dn.description": "Standard-Benutzer, der zur Benutzersuche verwendet wird",
|
||||
"admin.config.ldap.bind-password": "Binde Passwort",
|
||||
"admin.config.ldap.bind-password.description": "Passwort der zur Benutzersuche verwendet wird",
|
||||
"admin.config.ldap.search-base": "Benutzerbasis",
|
||||
"admin.config.ldap.search-base.description": "Basisstandort, an dem die Benutzersuche durchgeführt wird",
|
||||
"admin.config.ldap.search-query": "Benutzerabfrage",
|
||||
"admin.config.ldap.search-query.description": "Die Benutzer Abfrage wird in der \"Benutzerdatenbank\" gesucht für den LDAP Benutzer. %username% kann als Platzhalter für den Benutzer eingegeben werden.",
|
||||
"admin.config.ldap.admin-groups": "Admin-Gruppe",
|
||||
"admin.config.ldap.admin-groups.description": "Gruppe benötigt für den Administrationszugang.",
|
||||
"admin.config.ldap.field-name-member-of": "Benutzergruppen-Attributname",
|
||||
"admin.config.ldap.field-name-member-of.description": "LDAP-Attributname für die Gruppen, in denen ein Benutzer Mitglied ist. Dies wird bei der Überprüfung der Admin-Gruppe verwendet.",
|
||||
"admin.config.ldap.field-name-email": "Attributname für die E-Mail-Adresse des Benutzers",
|
||||
"admin.config.ldap.field-name-email.description": "LDAP-Attributname für die E-Mail-Adresse eines Benutzers.",
|
||||
"admin.config.notify.success": "Konfiguration erfolgreich aktualisiert.",
|
||||
"admin.config.notify.logo-success": "Logo erfolgreich aktualisiert. Es kann einige Minuten dauern, bis es auf der Website aktualisiert wird.",
|
||||
"admin.config.notify.no-changes": "Keine Änderungen zu speichern.",
|
||||
"admin.config.category.s3": "S3",
|
||||
"admin.config.s3.enabled": "Aktiviert",
|
||||
"admin.config.s3.enabled.description": "Ob S3 verwendet werden soll, um die freigegebenen Dateien anstelle des lokalen Dateisystems zu speichern.",
|
||||
"admin.config.s3.endpoint": "Endpunkt",
|
||||
"admin.config.s3.endpoint.description": "Die URL des S3-Buckets.",
|
||||
"admin.config.s3.region": "Region",
|
||||
"admin.config.s3.region.description": "Die Region des S3-Buckets.",
|
||||
"admin.config.s3.bucket-name": "Bucket-Name",
|
||||
"admin.config.s3.bucket-name.description": "Der Name des S3-Buckets.",
|
||||
"admin.config.s3.bucket-path": "Pfad",
|
||||
"admin.config.s3.bucket-path.description": "Der Standardpfad, der zum Speichern der Dateien im S3-Bucket verwendet werden soll.",
|
||||
"admin.config.s3.key": "Schlüssel",
|
||||
"admin.config.s3.key.description": "Der Schlüssel, der den Zugriff auf den S3-Bucket ermöglicht.",
|
||||
"admin.config.s3.secret": "Geheimnis",
|
||||
"admin.config.s3.secret.description": "Das Geheimnis, das den Zugriff auf den S3-Bucket ermöglicht.",
|
||||
"admin.config.s3.use-checksum": "Use checksum",
|
||||
"admin.config.s3.use-checksum.description": "Turn off for backends that do not support checksum (e.g. B2).",
|
||||
"admin.config.category.legal": "Rechtliches",
|
||||
"admin.config.legal.enabled": "Impressum und Datenschutz aktivieren",
|
||||
"admin.config.legal.enabled.description": "Gibt an, ob die Links zum Impressum und zur Datenschutzerklärung im Footer angezeigt werden sollen.",
|
||||
"admin.config.legal.imprint-text": "Impressum-Text",
|
||||
"admin.config.legal.imprint-text.description": "Der Text, der im Impressum angezeigt wird. Unterstützt Markdown. Leer lassen, um auf eine externe Impressumsseite zu verlinken.",
|
||||
"admin.config.legal.imprint-url": "Impressum-URL",
|
||||
"admin.config.legal.imprint-url.description": "Wenn bereits eine Impressumsseite vorhanden ist, kann sie hier verlinkt werden, anstatt den Text einzugeben.",
|
||||
"admin.config.legal.privacy-policy-text": "Datenschutzerklärungstext",
|
||||
"admin.config.legal.privacy-policy-text.description": "Der Text, der in der Datenschutzerklärung angezeigt wird. Unterstützt Markdown. Leer lassen, um auf eine externe Datenschutzerklärungsseite zu verlinken.",
|
||||
"admin.config.legal.privacy-policy-url": "Datenschutzerklärungs-URL",
|
||||
"admin.config.legal.privacy-policy-url.description": "Wenn bereits eine Datenschutzerklärungsseite vorhanden ist, kann sie hier verlinkt werden, anstatt den Text einzugeben.",
|
||||
// 404
|
||||
"404.description": "Ups, diese Seite existiert nicht.",
|
||||
"404.button.home": "Zurück zur Startseite",
|
||||
@@ -425,7 +495,7 @@ export default {
|
||||
"error.title": "Fehler",
|
||||
"error.description": "Ups!",
|
||||
"error.button.back": "Zurück",
|
||||
"error.msg.default": "Etwas ist schief gelaufen.",
|
||||
"error.msg.default": "Etwas ist schiefgelaufen.",
|
||||
"error.msg.access_denied": "Du hast den Authentifizierungsprozess abgebrochen, bitte versuche es erneut.",
|
||||
"error.msg.expired_token": "Der Authentifizierungsprozess hat zu lange gedauert, bitte versuche es erneut.",
|
||||
"error.msg.invalid_token": "Interner Fehler",
|
||||
@@ -435,7 +505,7 @@ export default {
|
||||
"error.msg.not_linked": "Das Konto {0} wurde noch nicht mit einem Konto verknüpft.",
|
||||
"error.msg.unverified_account": "Dieses Konto {0} wurde noch nicht verifiziert, bitte versuche es nach der Verifikation erneut.",
|
||||
"error.msg.user_not_allowed": "Du bist nicht berechtigt, dich anzumelden.",
|
||||
"error.msg.cannot_get_user_info": "Deine Benutzerinformationen können nicht von diesem Konto {0} abgerufen werden.",
|
||||
"error.msg.cannot_get_user_info": "Ihre Benutzerinformationen können von diesem {0} Konto nicht abgerufen werden.",
|
||||
"error.param.provider_github": "GitHub",
|
||||
"error.param.provider_google": "Google",
|
||||
"error.param.provider_microsoft": "Microsoft",
|
||||
@@ -455,15 +525,19 @@ export default {
|
||||
"common.text.link": "Link",
|
||||
"common.text.navigate-to-link": "Link öffnen",
|
||||
"common.text.or": "oder",
|
||||
"common.text.redirecting": "Umleitung...",
|
||||
"common.button.go-back": "Zurück",
|
||||
"common.button.go-home": "Zur Startseite",
|
||||
"common.notify.copied": "Dein Link wurde in die Zwischenablage kopiert",
|
||||
"common.notify.copied-link": "Dein Link wurde in die Zwischenablage kopiert",
|
||||
"common.success": "Erfolg",
|
||||
"common.error": "Fehler",
|
||||
"common.error.unknown": "Ein unbekannter Fehler ist aufgetreten",
|
||||
"common.error.invalid-email": "Ungültige E-Mail-Adresse",
|
||||
"common.error.too-short": "Muss mindestens {length} Zeichen enthalten",
|
||||
"common.error.too-long": "Muss maximal {length} Zeichen enthalten",
|
||||
"common.error.too-long": "Darf maximal {length} Zeichen enthalten",
|
||||
"common.error.number-too-small": "Darf mindestens {min} sein",
|
||||
"common.error.number-too-large": "Darf höchstens {max} sein",
|
||||
"common.error.exact-length": "Muss genau {length} Zeichen lang sein",
|
||||
"common.error.invalid-number": "Muss eine Zahl sein",
|
||||
"common.error.field-required": "Dieses Feld ist erforderlich"
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
"navbar.upload": "Μεταφόρτωση",
|
||||
"navbar.signin": "Είσοδος",
|
||||
"navbar.home": "Αρχική",
|
||||
"navbar.signup": "Εγγραφή",
|
||||
"navbar.signup": "Sign up",
|
||||
"navbar.links.shares": "",
|
||||
"navbar.links.reverse": "Αντίστροφος σύνδεσμος κοινής χρήσης",
|
||||
"navbar.avatar.account": "Ο λογαριασμός μου",
|
||||
@@ -16,9 +16,9 @@ export default {
|
||||
"home.bullet.a.name": "Ιδιωτική εγκατάσταση",
|
||||
"home.bullet.a.description": "Φιλοξενήστε το Pingvin Share στο δικό σας μηχάνημα.",
|
||||
"home.bullet.b.name": "Απόρρητο",
|
||||
"home.bullet.b.description": "Τα αρχεία σας είναι αρχεία σας και δεν πρέπει ποτέ να μπείτε στα χέρια τρίτων.",
|
||||
"home.bullet.b.description": "Your files are yours and will never be accessed by third parties.",
|
||||
"home.bullet.c.name": "Χωρίς όριο μεγέθους αρχείου",
|
||||
"home.bullet.c.description": "Ανεβάστε όσο μεγάλα αρχεία θέλετε. Μόνο ο σκληρός σας δίσκος θα είναι το όριό σας.",
|
||||
"home.bullet.c.description": "Upload files as big as you want. Only your hard drive will be your limit.",
|
||||
"home.button.start": "Ας αρχίσουμε",
|
||||
"home.button.source": "Πηγαίος κώδικας",
|
||||
// END /
|
||||
@@ -34,7 +34,7 @@ export default {
|
||||
"signIn.notify.totp-required.title": "Απαιτείται έλεγχος ταυτότητας δύο παραγόντων.",
|
||||
"signIn.notify.totp-required.description": "Παρακαλώ εισάγετε τον κωδικό 2FA.",
|
||||
"signIn.oauth.or": "Ή",
|
||||
"signIn.oauth.signInWith": "Sign in with",
|
||||
"signIn.oauth.signInWith": "Σύνδεση με",
|
||||
"signIn.oauth.github": "GitHub",
|
||||
"signIn.oauth.google": "Google",
|
||||
"signIn.oauth.microsoft": "Microsoft",
|
||||
@@ -58,12 +58,12 @@ export default {
|
||||
// /auth/reset-password
|
||||
"resetPassword.title": "Ξεχάσατε τον κωδικό σας;",
|
||||
"resetPassword.description": "A message with a link to reset your password has been sent if the email exists.",
|
||||
"resetPassword.notify.success": "Εισάγετε το email σας για επαναφορά κωδικού.",
|
||||
"resetPassword.notify.success": "A message with a link to reset your password has been sent if the provided email exists.",
|
||||
"resetPassword.button.back": "Πίσω στη σελίδα εισόδου",
|
||||
"resetPassword.text.resetPassword": "Επαναφορά κωδικού πρόσβασης",
|
||||
"resetPassword.text.enterNewPassword": "Εισάγετε το νέο σας κωδικό",
|
||||
"resetPassword.input.password": "Νέος κωδικός",
|
||||
"resetPassword.notify.passwordReset": "Η επαναφορά του κωδικού πρόσβασης σας ολοκληρώθηκε με επιτυχία!",
|
||||
"resetPassword.notify.passwordReset": "Your password has been successfully reset.",
|
||||
// /account
|
||||
"account.title": "Ο λογαριασμός μου",
|
||||
"account.card.info.title": "Πληροφορίες λογαριασμού",
|
||||
@@ -73,7 +73,7 @@ export default {
|
||||
"account.card.password.title": "Κωδικόs πρόσβασης",
|
||||
"account.card.password.old": "Παλιός κωδικός",
|
||||
"account.card.password.new": "Νέος κωδικός",
|
||||
"account.card.password.noPasswordSet": "Δεν έχετε ορίσει κωδικό πρόσβασης. Αν θέλετε να συνδεθείτε με email και κωδικό πρόσβασης, πρέπει να ορίσετε έναν κωδικό πρόσβασης.",
|
||||
"account.card.password.noPasswordSet": "You do not have a password set. To sign in using your email and password, you need to create a password.",
|
||||
"account.notify.password.success": "Ο κωδικός πρόσβασης άλλαξε επιτυχώς",
|
||||
"account.card.oauth.title": "Σύνδεση με λογαριασμό μέσων κοινωνικού δικτύου",
|
||||
"account.card.oauth.github": "GitHub",
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
"account.card.oauth.unlink": "Αποσύνδεση",
|
||||
"account.card.oauth.unlinked": "Αποσυνδεδεμένο",
|
||||
"account.modal.unlink.title": "Αποσύνδεση Λογαριασμού",
|
||||
"account.modal.unlink.description": "Η αποσύνδεση των κοινωνικών λογαριασμών σας μπορεί να προκαλέσει απώλεια του λογαριασμού σας αν δε θυμάστε το όνομα χρήστη και τον κωδικό πρόσβασης.",
|
||||
"account.modal.unlink.description": "Unlinking your social accounts may cause you to lose your account if you don't remember your login credentials",
|
||||
"account.notify.oauth.unlinked.success": "Επιτυχής αποσύνδεση",
|
||||
"account.card.security.title": "Ασφάλεια",
|
||||
"account.card.security.totp.enable.description": "Εισάγετε τον τρέχοντα κωδικό σας για να ξεκινήσετε την ενεργοποίηση του TOTP",
|
||||
@@ -121,12 +121,15 @@ export default {
|
||||
"account.shares.table.name": "Όνομα",
|
||||
"account.shares.table.description": "Περιγραφή",
|
||||
"account.shares.table.visitors": "Επισκέπτες",
|
||||
"account.shares.table.expiresAt": "Λήξη",
|
||||
"account.shares.table.createdAt": "Δημιουργήθηκε",
|
||||
"account.shares.table.expiresAt": "Expires on",
|
||||
"account.shares.table.createdAt": "Created on",
|
||||
"account.shares.table.size": "Μέγεθος",
|
||||
"account.shares.table.password-protected": "Password protected",
|
||||
"account.shares.table.visitor-count": "{count} of {max}",
|
||||
"account.shares.table.expiry-never": "Never",
|
||||
"account.shares.modal.share-informations": "Πληροφορίες διαμοιρασμού",
|
||||
"account.shares.modal.share-link": "Κοινοποίηση συνδέσμου",
|
||||
"account.shares.modal.delete.title": "Διαγραφή κοινοποίησης {share}",
|
||||
"account.shares.modal.delete.title": "Delete share: {share}",
|
||||
"account.shares.modal.delete.description": "Θέλετε πραγματικά να διαγράψετε αυτό το διαμοιρασμό;",
|
||||
// END /account/shares
|
||||
// /account/reverseShares
|
||||
@@ -150,12 +153,12 @@ export default {
|
||||
"account.reverseShares.modal.expiration.year-singular": "Έτος",
|
||||
"account.reverseShares.modal.expiration.year-plural": "Έτη",
|
||||
"account.reverseShares.modal.max-size.label": "Μέγιστο μέγεθος κοινοποίησης",
|
||||
"account.reverseShares.modal.send-email": "Αποστολή ειδοποιήσεων με email",
|
||||
"account.reverseShares.modal.send-email.description": "Στείλτε μια ειδοποίηση μέσω ηλεκτρονικού ταχυδρομείου όταν δημιουργείται ένας διαμοιρασμός με αυτόν τον σύνδεσμο ανάστροφης κοινοποίησης.",
|
||||
"account.reverseShares.modal.simplified": "Simple mode",
|
||||
"account.reverseShares.modal.simplified.description": "Make it easy for the person uploading the file to share it with you. They will be able to customize only the name and description of the share.",
|
||||
"account.reverseShares.modal.public-access": "Public access",
|
||||
"account.reverseShares.modal.public-access.description": "Make the created shares with this reverse share public. If disabled, only you and the creator of the share can view it.",
|
||||
"account.reverseShares.modal.send-email": "Send email notifications",
|
||||
"account.reverseShares.modal.send-email.description": "Sends you an email notification when a share is created with this reverse share link.",
|
||||
"account.reverseShares.modal.simplified": "Απλή λειτουργία",
|
||||
"account.reverseShares.modal.simplified.description": "Make it easy for the person uploading the file to share it with you. They will only be able to customize the name and description of the share.",
|
||||
"account.reverseShares.modal.public-access": "Δημόσια πρόσβαση",
|
||||
"account.reverseShares.modal.public-access.description": "Make the shares created with this reverse share public. If disabled, only you and the share creator will have access to view it.",
|
||||
"account.reverseShares.modal.max-use.label": "Μέγιστες χρήσεις",
|
||||
"account.reverseShares.modal.max-use.description": "Ο μέγιστος αριθμός που μπορεί να χρησιμοποιηθεί αυτό το URL για τη δημιουργία ενός διαμοιρασμού.",
|
||||
"account.reverseShare.never-expires": "Αυτός ο αντίστροφος διαμοιρασμός δε λήγει.",
|
||||
@@ -174,7 +177,7 @@ export default {
|
||||
// /admin
|
||||
"admin.title": "Διαχείριση",
|
||||
"admin.button.users": "Διαχείριση χρηστών",
|
||||
"admin.button.shares": "Share management",
|
||||
"admin.button.shares": "Διαχείριση κοινοποιήσεων",
|
||||
"admin.button.config": "Διαμόρφωση",
|
||||
"admin.version": "Έκδοση",
|
||||
// END /admin
|
||||
@@ -183,14 +186,14 @@ export default {
|
||||
"admin.users.table.username": "Όνομα χρήστη",
|
||||
"admin.users.table.email": "E-mail",
|
||||
"admin.users.table.admin": "Διαχειριστής",
|
||||
"admin.users.edit.update.title": "Ενημέρωση χρήστη {username}",
|
||||
"admin.users.edit.update.title": "Edit user: {username}",
|
||||
"admin.users.edit.update.admin-privileges": "Δικαιώματα διαχειριστή",
|
||||
"admin.users.edit.update.change-password.title": "Αλλαγή κωδικού πρόσβασής",
|
||||
"admin.users.edit.update.change-password.field": "Νέος κωδικός πρόσβασης",
|
||||
"admin.users.edit.update.change-password.button": "Αποθήκευση νέου κωδικού πρόσβασης",
|
||||
"admin.users.edit.update.notify.password.success": "Ο κωδικός πρόσβασης άλλαξε επιτυχώς",
|
||||
"admin.users.edit.delete.title": "Διαγραφή χρήστη {username}",
|
||||
"admin.users.edit.delete.description": "Θέλετε να διαγράψετε το χρήστη και όλες τις κοινοποιήσεις του;",
|
||||
"admin.users.edit.delete.title": "Delete user: {username} ?",
|
||||
"admin.users.edit.delete.description": "Do you really want to delete this user and all their shares?",
|
||||
// showCreateUserModal.tsx
|
||||
"admin.users.modal.create.title": "Δημιουργία χρήστη",
|
||||
"admin.users.modal.create.username": "Όνομα χρήστη",
|
||||
@@ -202,21 +205,24 @@ export default {
|
||||
"admin.users.modal.create.admin.description": "Αν ενεργοποιηθεί, ο χρήστης θα μπορεί να έχει πρόσβαση στον πίνακα διαχείρισης.",
|
||||
// END /admin/users
|
||||
// /admin/shares
|
||||
"admin.shares.title": "Share management",
|
||||
"admin.shares.table.id": "Share ID",
|
||||
"admin.shares.table.username": "Creator",
|
||||
"admin.shares.table.visitors": "Visitors",
|
||||
"admin.shares.table.expires": "Expires At",
|
||||
"admin.shares.edit.delete.title": "Delete share {id}",
|
||||
"admin.shares.edit.delete.description": "Do you really want to delete this share?",
|
||||
"admin.shares.title": "Διαχείριση κοινοποιήσεων",
|
||||
"admin.shares.table.id": "Αναγνωριστικό κοινοποίησης",
|
||||
"admin.shares.table.username": "Δημιουργός",
|
||||
"admin.shares.table.visitors": "Επισκέπτες",
|
||||
"admin.shares.table.expires": "Expires on",
|
||||
"admin.shares.edit.delete.title": "Delete share: {id}",
|
||||
"admin.shares.edit.delete.description": "Θέλετε πραγματικά να διαγράψετε αυτή τη κοινοποίηση;",
|
||||
// END /admin/shares
|
||||
// /upload
|
||||
"upload.title": "Μεταφόρτωση",
|
||||
"upload.notify.confirm-leave": "Are you sure you want to leave this page? Your upload will be canceled.",
|
||||
"upload.notify.generic-error": "Παρουσιάστηκε σφάλμα κατά την ολοκλήρωση της κοινής χρήσης σας.",
|
||||
"upload.notify.count-failed": "Τα αρχεία {count} απέτυχαν να μεταφορτώσουν. Δοκιμάστε ξανά.",
|
||||
"upload.reverse-share.error.invalid.title": "Invalid reverse share link",
|
||||
"upload.reverse-share.error.invalid.description": "This reverse share has expired or is invalid.",
|
||||
// Dropzone.tsx
|
||||
"upload.dropzone.title": "Μεταφόρτωση αρχείων",
|
||||
"upload.dropzone.description": "Σύρετε και αποθέστε αρχεία εδώ για να ξεκινήσει η κοινή χρήση σας. Μπορούμε να δεχτούμε μόνο αρχεία που είναι λιγότερο από {maxSize} συνολικά.",
|
||||
"upload.dropzone.description": "Drag'n'drop files here to start your share. We only accept files up to {maxSize} in total.",
|
||||
"upload.dropzone.notify.file-too-big": "Τα αρχεία σας υπερβαίνουν το μέγιστο μέγεθος κοινής χρήσης του {maxSize}.",
|
||||
// FileList.tsx
|
||||
"upload.filelist.name": "Όνομα",
|
||||
@@ -228,8 +234,8 @@ export default {
|
||||
"upload.modal.not-signed-in": "Δεν είστε συνδεδεμένος/η",
|
||||
"upload.modal.not-signed-in-description": "Δεν θα μπορείτε να διαγράψετε την κοινή χρήση σας χειροκίνητα και να δείτε την αρίθμηση επισκεπτών.",
|
||||
"upload.modal.expires.never": "ποτέ",
|
||||
"upload.modal.expires.never-long": "Χωρίς λήξη",
|
||||
"upload.modal.expires.error.too-long": "Η λήξη υπερβαίνει τη μέγιστη ημερομηνία λήξης {max}.",
|
||||
"upload.modal.expires.never-long": "Permanent share",
|
||||
"upload.modal.expires.error.too-long": "Expiration date exceeds the maximum of {max}.",
|
||||
"upload.modal.link.label": "Σύνδεσμος",
|
||||
"upload.modal.expires.label": "Λήξη",
|
||||
"upload.modal.expires.minute-singular": "Λεπτό",
|
||||
@@ -244,9 +250,9 @@ export default {
|
||||
"upload.modal.expires.month-plural": "Μήνες",
|
||||
"upload.modal.expires.year-singular": "Έτος",
|
||||
"upload.modal.expires.year-plural": "Έτη",
|
||||
"upload.modal.accordion.name-and-description.title": "Name and description",
|
||||
"upload.modal.accordion.name-and-description.name.placeholder": "Name",
|
||||
"upload.modal.accordion.name-and-description.description.placeholder": "Note for the recipients of this share",
|
||||
"upload.modal.accordion.name-and-description.title": "Όνομα και περιγραφή",
|
||||
"upload.modal.accordion.name-and-description.name.placeholder": "Όνομα",
|
||||
"upload.modal.accordion.name-and-description.description.placeholder": "Σημείωση για τους παραλήπτες αυτής της κοινοποίησης",
|
||||
"upload.modal.accordion.email.title": "Αποδέκτες email",
|
||||
"upload.modal.accordion.email.placeholder": "Εισάγετε αποδέκτες email",
|
||||
"upload.modal.accordion.email.invalid-email": "Μη έγκυρη διεύθυνση e-mail",
|
||||
@@ -259,29 +265,30 @@ export default {
|
||||
"upload.modal.completed.never-expires": "Αυτός ο διαμοιρασμός δεν λήγει.",
|
||||
"upload.modal.completed.expires-on": "Αυτός ο διαμοιρασμός θα λήξει {expiration}.",
|
||||
"upload.modal.completed.share-ready": "Κοινοποίηση έτοιμου",
|
||||
"upload.modal.completed.notified-reverse-share-creator": "We have notified the creator of the reverse share. You can also manually share this link with them through other means.",
|
||||
"upload.modal.completed.notified-reverse-share-creator": "Έχουμε ειδοποιήσει τον δημιουργό της αντίστροφης κοινής χρήσης. Μπορείτε επίσης να μοιραστείτε χειροκίνητα αυτόν τον σύνδεσμο μαζί τους μέσω άλλων μέσων.",
|
||||
// END /upload
|
||||
// /share/[id]
|
||||
"share.title": "Διαμοιρασμός {shareId}",
|
||||
"share.description": "Σας προωθώ αρχεία προς κοινοποίηση.",
|
||||
"share.fileCount": "{count, plural, =1 {# file} other {# files}} · {size} (zip file may be smaller due to compression)",
|
||||
"share.error.visitor-limit-exceeded.title": "Υπέρβαση ορίου επισκέπτη",
|
||||
"share.error.visitor-limit-exceeded.description": "Ξεπεράστηκε το όριο επισκεπτών σε αυτή την κοινοποίηση.",
|
||||
"share.error.removed.title": "Κοινοποίηση αφαιρέθηκε",
|
||||
"share.error.not-found.title": "Η κοινοποίηση δε βρέθηκε",
|
||||
"share.error.not-found.description": "Η κοινοποίηση που ψάχνετε δεν υπάρχει.",
|
||||
"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.access-denied.title": "Ιδιωτική κοινοποίηση",
|
||||
"share.error.access-denied.description": "Ο τρέχων λογαριασμός δεν έχει δικαίωμα πρόσβασης σε αυτήν την κοινοποίηση",
|
||||
"share.modal.password.title": "Απαιτείται κωδικός",
|
||||
"share.modal.password.description": "Για να αποκτήσετε πρόσβαση σε αυτή την κοινοποίηση εισάγετε τον κωδικό πρόσβασης.",
|
||||
"share.modal.password.description": "Please enter the password to access this share.",
|
||||
"share.modal.password": "Κωδικός πρόσβασης",
|
||||
"share.modal.error.invalid-password": "Μη έγκυρος κωδικός πρόσβασης",
|
||||
"share.button.download-all": "Λήψη όλων",
|
||||
"share.notify.download-all-preparing": "Αυτός ο διαμοιρασμός βρίσκεται σε επεξεργασία. Προσπαθήστε ξανά σε λίγο.",
|
||||
"share.notify.download-all-preparing": "The share is being prepared. Please try again in a few minutes.",
|
||||
"share.modal.file-link": "Σύνδεσμος αρχείου",
|
||||
"share.table.name": "Όνομα",
|
||||
"share.table.size": "Μέγεθος",
|
||||
"share.modal.file-preview.error.not-supported.title": "Η προεπισκόπηση δεν υποστηρίζεται",
|
||||
"share.modal.file-preview.error.not-supported.description": "Η προεπισκόπηση για αυτό τον τύπο αρχείου δεν υποστηρίζεται. Παρακαλώ κατεβάστε το αρχείο για να το δείτε.",
|
||||
"share.modal.file-preview.error.not-supported.description": "Previews are not supported for this type of files. Please download the file to view it.",
|
||||
// END /share/[id]
|
||||
// /share/[id]/edit
|
||||
"share.edit.title": "Ενημέρωση {shareId}",
|
||||
@@ -289,10 +296,19 @@ export default {
|
||||
"share.edit.notify.generic-error": "Παρουσιάστηκε σφάλμα κατά την ολοκλήρωση του διαμοιρασμού.",
|
||||
"share.edit.notify.save-success": "Ο διαμοιρασμός ενημερώθηκε επιτυχώς",
|
||||
// END /share/[id]/edit
|
||||
// /imprint
|
||||
"imprint.title": "Imprint",
|
||||
// END /imprint
|
||||
// /privacy
|
||||
"privacy.title": "Privacy Policy",
|
||||
// END /privacy
|
||||
// /admin/config
|
||||
"admin.config.config-file-warning.title": "Configuration file present",
|
||||
"admin.config.config-file-warning.description": "As you have a configured Pingvin Share with a configuration file, you can't change the configuration through the UI.",
|
||||
"admin.config.title": "Διαμόρφωση",
|
||||
"admin.config.category.general": "Γενικά",
|
||||
"admin.config.category.share": "Διαμοιρασμός",
|
||||
"admin.config.category.cache": "Cache",
|
||||
"admin.config.category.email": "Email",
|
||||
"admin.config.category.smtp": "SMTP",
|
||||
"admin.config.category.oauth": "Σύνδεση με λογαριασμό μέσων κοινωνικού δικτύου",
|
||||
@@ -300,53 +316,65 @@ export default {
|
||||
"admin.config.general.app-name.description": "Ονομασία της εφαρμογής",
|
||||
"admin.config.general.app-url": "URL Εφαρμογής",
|
||||
"admin.config.general.app-url.description": "Η διεύθυνση URL όπου το Pingvin Share είναι διαθέσιμο",
|
||||
"admin.config.general.secure-cookies": "Secure cookies",
|
||||
"admin.config.general.secure-cookies.description": "Whether to set the secure flag on cookies. If enabled, the site will not function when accessed over HTTP.",
|
||||
"admin.config.general.show-home-page": "Εμφάνιση αρχικής σελίδας",
|
||||
"admin.config.general.show-home-page.description": "Εάν θα εμφανίζεται η αρχική σελίδα",
|
||||
"admin.config.general.session-duration": "Session Duration",
|
||||
"admin.config.general.session-duration.description": "Time in hours after which a user must log in again (default: 3 months).",
|
||||
"admin.config.general.session-duration": "Διάρκεια συνεδρίας",
|
||||
"admin.config.general.session-duration.description": "Time after which a user must log in again (default: 3 months).",
|
||||
"admin.config.general.logo": "Λογότυπο",
|
||||
"admin.config.general.logo.description": "Αλλάξτε το λογότυπό σας ανεβάζοντας μια νέα εικόνα. Η εικόνα πρέπει να είναι PNG και αναλογία 1:1.",
|
||||
"admin.config.general.logo.placeholder": "Επιλέξτε εικόνα",
|
||||
"admin.config.email.enable-share-email-recipients": "Ενεργοποίηση κοινής χρήσης μέσω email",
|
||||
"admin.config.email.enable-share-email-recipients.description": "Εάν θα ενεργοποιηθεί η κοινή χρήση μέσω email. Δυνατή μόνον με την ενεργοποίηση SMTP.",
|
||||
"admin.config.cache.ttl": "TTL",
|
||||
"admin.config.cache.ttl.description": "Time in second to keep information inside the cache.",
|
||||
"admin.config.cache.max-items": "Maximum items",
|
||||
"admin.config.cache.max-items.description": "Maximum number of items inside the cache.",
|
||||
"admin.config.cache.redis-enabled": "Redis enabled",
|
||||
"admin.config.cache.redis-enabled.description": "Normally Pingvin Share caches information in memory. If you run multiple instances of Pingvin Share, you need to enable Redis caching to share the cache between the instances.",
|
||||
"admin.config.cache.redis-url": "Redis URL",
|
||||
"admin.config.cache.redis-url.description": "Url to connect to the Redis instance used for caching.",
|
||||
"admin.config.email.enable-share-email-recipients": "Enable email recipient sharing",
|
||||
"admin.config.email.enable-share-email-recipients.description": "Whether to allow email sharing with recipients. Only enable this if SMTP is activated.",
|
||||
"admin.config.email.share-recipients-subject": "Θέμα στο email διαμοιρασμού",
|
||||
"admin.config.email.share-recipients-subject.description": "Το θέμα του email διαμοιρασμού που θα φτάσει στον παραλήπτη.",
|
||||
"admin.config.email.share-recipients-message": "Το θέμα του email για τον διαμοιρασμό που θα φτάσει στον παραλήπτη ",
|
||||
"admin.config.email.share-recipients-message.description": "Μήνυμα που αποστέλλεται στους παραλήπτες κοινής χρήσης. Διαθέσιμες μεταβλητές:\n {creator} - Το όνομα χρήστη του δημιουργού της κοινής χρήσης\n {shareUrl} - Η διεύθυνση URL της κοινής χρήσης\n {desc} - Η περιγραφή της κοινής χρήσης\n {expires} - Η ημερομηνία λήξης της κοινής χρήσης\n Οι μεταβλητές θα αντικατασταθούν με την πραγματική τιμή.",
|
||||
"admin.config.email.share-recipients-message.description": "Message which gets sent to the share recipients. Available variables:\n {creator} - The username of the creator of the share\n {creatorEmail} - The email of the creator of the share\n {shareUrl} - The URL of the share\n {desc} - The description of the share\n {expires} - The expiration date of the share\n These variables will be replaced with the actual value.",
|
||||
"admin.config.email.reverse-share-subject": "Θέμα email αντίστροφου διαμοιρασμού",
|
||||
"admin.config.email.reverse-share-subject.description": "Θέμα του ηλεκτρονικού ταχυδρομείου που αποστέλλεται όταν κάποιος δημιούργησε έναν αντίστροφο διαμοιρασμό.",
|
||||
"admin.config.email.reverse-share-subject.description": "Subject of the sent email when someone created a share with your reverse share link.",
|
||||
"admin.config.email.reverse-share-message": "Μήνυμα email αντίστροφου διαμοιρασμού",
|
||||
"admin.config.email.reverse-share-message.description": "Μήνυμα που αποστέλλεται όταν κάποιος δημιουργεί έναν σύνδεσμο αντίστροφου διαμοιρασμού. Το {shareUrl} θα αντικατασταθεί με το όνομα του δημιουργού και τη διεύθυνση URL κοινής χρήσης.",
|
||||
"admin.config.email.reset-password-subject": "Θέμα μηνύματος επαναφοράς κωδικού πρόσβασης",
|
||||
"admin.config.email.reset-password-subject.description": "Θέμα του email που αποστέλλεται όταν ένας χρήστης ζητήσει επαναφορά κωδικού πρόσβασης.",
|
||||
"admin.config.email.reset-password-subject.description": "Subject of the sent email when a user requests a password reset.",
|
||||
"admin.config.email.reset-password-message": "Κείμενο μηνύματος επαναφοράς κωδικού πρόσβασης",
|
||||
"admin.config.email.reset-password-message.description": "Μήνυμα που αποστέλλεται όταν ένας χρήστης ζητά επαναφορά κωδικού πρόσβασης. Το {url} θα αντικατασταθεί με τη διεύθυνση URL επαναφοράς κωδικού πρόσβασης.",
|
||||
"admin.config.email.invite-subject": "Θέμα μηνύματος πρόσκλησης",
|
||||
"admin.config.email.invite-subject.description": "Θέμα του email που αποστέλλεται όταν ένας διαχειριστής προσκαλεί έναν χρήστη.",
|
||||
"admin.config.email.invite-subject.description": "Subject of the sent email when an admin invites a user.",
|
||||
"admin.config.email.invite-message": "Μήνυμα μηνύματος πρόσκλησης",
|
||||
"admin.config.email.invite-message.description": "Το μήνυμα που αποστέλλεται όταν ένας διαχειριστής προσκαλεί έναν χρήστη. Το {url} θα αντικατασταθεί με το URL πρόσκλησης και το {password} με τον κωδικό πρόσβασης.",
|
||||
"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.share.allow-registration": "Να επιτρέπεται η εγγραφή",
|
||||
"admin.config.share.allow-registration.description": "Αν επιτρέπεται η εγγραφή",
|
||||
"admin.config.share.allow-unauthenticated-shares": "Επιτρέψτε κοινές χρήσεις χωρίς έλεγχο ταυτότητας",
|
||||
"admin.config.share.allow-unauthenticated-shares.description": "Εάν οι χρήστες χωρίς έλεγχο ταυτότητας μπορούν να δημιουργήσουν κοινόχρηστα στοιχεία",
|
||||
"admin.config.share.max-expiration": "Μέγιστη λήξη",
|
||||
"admin.config.share.max-expiration.description": "Μέγιστη λήξη κοινής χρήσης σε ώρες. Ορίστε το 0 για να επιτρέψετε απεριόριστη λήξη.",
|
||||
"admin.config.share.max-expiration.description": "Maximum share expiration. Set to 0 to allow unlimited expiration.",
|
||||
"admin.config.share.share-id-length": "Default share ID length",
|
||||
"admin.config.share.share-id-length.description": "Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.",
|
||||
"admin.config.share.max-size": "Μέγιστο μέγεθος",
|
||||
"admin.config.share.max-size.description": "Μέγιστο μέγεθος κοινοποίησης σε bytes",
|
||||
"admin.config.share.max-size.description": "Μέγιστο μέγεθος κοινοποίησης",
|
||||
"admin.config.share.zip-compression-level": "Βαθμός συμπίεσης ZIP",
|
||||
"admin.config.share.zip-compression-level.description": "Προσαρμόστε το βαθμό συμπίεσης για να εξισορροπηθεί το μέγεθος του αρχείου και η ταχύτητα επεξεργασίας. Έγκυρες τιμές κυμαίνονται από 0 έως 9, με 0 χωρίς συμπίεση και 9 μέγιστη συμπίεση.",
|
||||
"admin.config.share.chunk-size": "Μέγεθος κομματιών",
|
||||
"admin.config.share.chunk-size.description": "Προσαρμόστε το μέγεθος κομματιών (σε bytes) για να εξισορροπήσετε την αποδοτικότητα και την αξιοπιστία του συστήματος σύμφωνα με τη σύνδεσή σας στο διαδίκτυο. Μικρότερα κομμάτια μπορούν να βελτιώσουν τα ποσοστά επιτυχίας σε ασταθείς συνδέσεις, ενώ μεγαλύτερα κομμάτια επιταχύνουν τις μεταφορτώσεις σε σταθερές συνδέσεις.",
|
||||
"admin.config.share.chunk-size.description": "Adjust the chunk size for your uploads to balance efficiency and reliability according to your internet connection. Smaller chunks can enhance success rates for unstable connections, while larger chunks make uploads faster for stable connections.",
|
||||
"admin.config.share.auto-open-share-modal": "Auto open create share modal",
|
||||
"admin.config.share.auto-open-share-modal.description": "The share creation modal automatically appears when a user selects files, eliminating the need to manually click the button.",
|
||||
"admin.config.smtp.enabled": "Ενεργοποιημένο",
|
||||
"admin.config.smtp.enabled": "Enable",
|
||||
"admin.config.smtp.enabled.description": "Εάν η λειτουργία SMTP είναι ενεργοποιημένη. Ενεργοποιήστε τη μόνον όταν ορίσετε σωστά τις παραμέτρους που ακολουθούν.",
|
||||
"admin.config.smtp.host": "Εξυπηρετητής",
|
||||
"admin.config.smtp.host.description": "SMTP εξυπηρετητής",
|
||||
"admin.config.smtp.port": "Θύρα",
|
||||
"admin.config.smtp.port.description": "SMTP θύρα",
|
||||
"admin.config.smtp.email": "Email",
|
||||
"admin.config.smtp.email.description": "Διεύθυνση email από όπου αποστέλλονται τα μηνύματα",
|
||||
"admin.config.smtp.email.description": "Email address from which the emails get sent",
|
||||
"admin.config.smtp.username": "Όνομα χρήστη",
|
||||
"admin.config.smtp.username.description": "Όνομα χρήστη στον SMTP εξυπηρετητή",
|
||||
"admin.config.smtp.password": "Κωδικός πρόσβασης",
|
||||
@@ -382,6 +410,8 @@ export default {
|
||||
"admin.config.oauth.microsoft-client-secret.description": "Μυστικό πελάτη της εφαρμογής Microsoft OAuth",
|
||||
"admin.config.oauth.discord-enabled": "Discord",
|
||||
"admin.config.oauth.discord-enabled.description": "Αν είναι ενεργοποιημένη η σύνδεση στο Discord",
|
||||
"admin.config.oauth.discord-limited-users": "Discord limited users",
|
||||
"admin.config.oauth.discord-limited-users.description": "Limit signing in to specific users by their Discord ID. Leave it blank to disable.",
|
||||
"admin.config.oauth.discord-limited-guild": "Αναγνωριστικό διακομιστή περιορισμένης ισχύος Discord",
|
||||
"admin.config.oauth.discord-limited-guild.description": "Περιορισμός σύνδεσης σε χρήστες σε ένα συγκεκριμένο διακομιστή. Αφήστε κενό για να απενεργοποιήσετε.",
|
||||
"admin.config.oauth.discord-client-id": "Αναγνωριστικό Πελάτη Discord",
|
||||
@@ -392,6 +422,10 @@ export default {
|
||||
"admin.config.oauth.oidc-enabled.description": "Αν είναι ενεργοποιημένη η σύνδεση OpenID",
|
||||
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
|
||||
"admin.config.oauth.oidc-discovery-uri.description": "Discovery URI of the OpenID Connect OAuth app",
|
||||
"admin.config.oauth.oidc-sign-out": "Sign out from OpenID Connect",
|
||||
"admin.config.oauth.oidc-sign-out.description": "Whether the “Sign out” button will sign out from the OpenID Connect provider",
|
||||
"admin.config.oauth.oidc-scope": "OpenID Connect scope",
|
||||
"admin.config.oauth.oidc-scope.description": "Scopes which should be requested from the OpenID Connect provider.",
|
||||
"admin.config.oauth.oidc-username-claim": "OpenID Connect username claim",
|
||||
"admin.config.oauth.oidc-username-claim.description": "Username claim in OpenID Connect ID token. Αφήστε κενό αν δε γνωρίζετε για αυτή τη ρύθμιση",
|
||||
"admin.config.oauth.oidc-role-path": "Path to roles in OpenID Connect token",
|
||||
@@ -405,19 +439,55 @@ export default {
|
||||
"admin.config.oauth.oidc-client-secret": "OpenID Connect Client secret",
|
||||
"admin.config.oauth.oidc-client-secret.description": "Client secret of the OpenID Connect OAuth app",
|
||||
"admin.config.category.ldap": "LDAP",
|
||||
"admin.config.ldap.enabled": "Enabled LDAP",
|
||||
"admin.config.ldap.enabled": "Enable LDAP",
|
||||
"admin.config.ldap.enabled.description": "Use LDAP authentication for user login",
|
||||
"admin.config.ldap.url": "Server URL",
|
||||
"admin.config.ldap.url.description": "URL of the LDAP server",
|
||||
"admin.config.ldap.bind-dn": "Bind DN",
|
||||
"admin.config.ldap.bind-dn.description": "Default user which will be used to execute the user search",
|
||||
"admin.config.ldap.bind-dn.description": "Default user used to perform the user search",
|
||||
"admin.config.ldap.bind-password": "Bind password",
|
||||
"admin.config.ldap.bind-password.description": "Password for the user search user",
|
||||
"admin.config.ldap.bind-password.description": "Password used to perform the user search",
|
||||
"admin.config.ldap.search-base": "User base",
|
||||
"admin.config.ldap.search-base.description": "Base location, where the user search will be performed",
|
||||
"admin.config.ldap.search-query": "User query",
|
||||
"admin.config.ldap.search-query.description": "The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.",
|
||||
"admin.config.ldap.admin-groups": "Admin group",
|
||||
"admin.config.ldap.admin-groups.description": "Group required for administrative access.",
|
||||
"admin.config.ldap.field-name-member-of": "User groups attribute name",
|
||||
"admin.config.ldap.field-name-member-of.description": "LDAP attribute name for the groups, an user is a member of. This is used when checking for the admin group.",
|
||||
"admin.config.ldap.field-name-email": "User email attribute name",
|
||||
"admin.config.ldap.field-name-email.description": "LDAP attribute name for the email of an user.",
|
||||
"admin.config.notify.success": "Configuration updated successfully.",
|
||||
"admin.config.notify.logo-success": "Logo updated successfully. It may take a few minutes to update on the website.",
|
||||
"admin.config.notify.no-changes": "No changes to save.",
|
||||
"admin.config.category.s3": "S3",
|
||||
"admin.config.s3.enabled": "Enabled",
|
||||
"admin.config.s3.enabled.description": "Whether S3 should be used to store the shared files instead of the local file system.",
|
||||
"admin.config.s3.endpoint": "Endpoint",
|
||||
"admin.config.s3.endpoint.description": "The URL of the S3 bucket.",
|
||||
"admin.config.s3.region": "Region",
|
||||
"admin.config.s3.region.description": "The region of the S3 bucket.",
|
||||
"admin.config.s3.bucket-name": "Bucket name",
|
||||
"admin.config.s3.bucket-name.description": "The name of the S3 bucket.",
|
||||
"admin.config.s3.bucket-path": "Path",
|
||||
"admin.config.s3.bucket-path.description": "The default path which should be used to store the files in the S3 bucket.",
|
||||
"admin.config.s3.key": "Key",
|
||||
"admin.config.s3.key.description": "The key which allows you to access the S3 bucket.",
|
||||
"admin.config.s3.secret": "Secret",
|
||||
"admin.config.s3.secret.description": "The secret which allows you to access the S3 bucket.",
|
||||
"admin.config.s3.use-checksum": "Use checksum",
|
||||
"admin.config.s3.use-checksum.description": "Turn off for backends that do not support checksum (e.g. B2).",
|
||||
"admin.config.category.legal": "Legal",
|
||||
"admin.config.legal.enabled": "Enable legal notices",
|
||||
"admin.config.legal.enabled.description": "Whether to show a link to imprint and privacy policy in the footer.",
|
||||
"admin.config.legal.imprint-text": "Imprint text",
|
||||
"admin.config.legal.imprint-text.description": "The text which should be shown in the imprint. Supports Markdown. Leave blank to link to an external imprint page.",
|
||||
"admin.config.legal.imprint-url": "Imprint URL",
|
||||
"admin.config.legal.imprint-url.description": "If you already have an imprint page you can link it here instead of using the text field.",
|
||||
"admin.config.legal.privacy-policy-text": "Privacy policy text",
|
||||
"admin.config.legal.privacy-policy-text.description": "The text which should be shown in the privacy policy. Supports Markdown. Leave blank to link to an external privacy policy page.",
|
||||
"admin.config.legal.privacy-policy-url": "Privacy policy URL",
|
||||
"admin.config.legal.privacy-policy-url.description": "If you already have a privacy policy page you can link it here instead of using the text field.",
|
||||
// 404
|
||||
"404.description": "Ουπς. Αυτή η σελίδα δεν υπάρχει.",
|
||||
"404.button.home": "Πήγαινέ με πίσω",
|
||||
@@ -432,10 +502,10 @@ export default {
|
||||
"error.msg.no_user": "Ο χρήστης που συνδέεται με αυτόν τον λογαριασμό {0} δεν υπάρχει.",
|
||||
"error.msg.no_email": "Δεν είναι δυνατή η λήψη διεύθυνσης ηλεκτρονικού ταχυδρομείου για αυτόν τον λογαριασμό {0}.",
|
||||
"error.msg.already_linked": "Αυτός ο λογαριασμός {0} είναι ήδη συνδεδεμένος με άλλο λογαριασμό.",
|
||||
"error.msg.not_linked": "Αυτός ο λογαριασμός {0} δεν έχει συνδεθεί με κανένα λογαριασμό ακόμα.",
|
||||
"error.msg.not_linked": "This {0} account hasn't been linked to any account yet.",
|
||||
"error.msg.unverified_account": "Αυτός ο λογαριασμός {0} δεν έχει επαληθευτεί, παρακαλώ προσπαθήστε ξανά μετά την επαλήθευση.",
|
||||
"error.msg.user_not_allowed": "Δεν σας επιτρέπεται η σύνδεση.",
|
||||
"error.msg.cannot_get_user_info": "Δεν είναι δυνατή η λήψη των πληροφοριών χρήστη από αυτόν τον λογαριασμό {0}.",
|
||||
"error.msg.cannot_get_user_info": "Cannot get your user info from this {0} account.",
|
||||
"error.param.provider_github": "GitHub",
|
||||
"error.param.provider_google": "Google",
|
||||
"error.param.provider_microsoft": "Microsoft",
|
||||
@@ -453,17 +523,21 @@ export default {
|
||||
"common.button.generate": "Δημιουργία",
|
||||
"common.button.done": "Ολοκληρώθηκε",
|
||||
"common.text.link": "Σύνδεσμος",
|
||||
"common.text.navigate-to-link": "Μεταβείτε στο σύνδεσμο",
|
||||
"common.text.navigate-to-link": "Visit link",
|
||||
"common.text.or": "ή",
|
||||
"common.text.redirecting": "Redirecting...",
|
||||
"common.button.go-back": "Επιστροφή",
|
||||
"common.button.go-home": "Μετάβαση στην αρχική",
|
||||
"common.notify.copied": "Ο σύνδεσμος σας αντιγράφηκε στο πρόχειρο",
|
||||
"common.notify.copied-link": "Ο σύνδεσμος σας αντιγράφηκε στο πρόχειρο",
|
||||
"common.success": "Επιτυχία",
|
||||
"common.error": "Σφάλμα",
|
||||
"common.error.unknown": "Προέκυψε άγνωστο σφάλμα",
|
||||
"common.error.invalid-email": "Μη έγκυρη διεύθυνση e-mail",
|
||||
"common.error.too-short": "Πρέπει να αποτελείται τουλάχιστον {length} χαρακτήρες",
|
||||
"common.error.too-long": "Πρέπει να αποτελείται το πολύ από {length} χαρακτήρες",
|
||||
"common.error.number-too-small": "Must be at least {min}",
|
||||
"common.error.number-too-large": "Must be at most {max}",
|
||||
"common.error.exact-length": "Πρέπει να αποτελείται ακριβώς από {length} χαρακτήρες",
|
||||
"common.error.invalid-number": "Πρέπει να είναι αριθμός",
|
||||
"common.error.field-required": "Αυτό το πεδίο είναι υποχρεωτικό"
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
"navbar.upload": "Upload",
|
||||
"navbar.signin": "Sign in",
|
||||
"navbar.home": "Home",
|
||||
"navbar.signup": "Sign Up",
|
||||
"navbar.signup": "Sign up",
|
||||
|
||||
"navbar.links.shares": "My shares",
|
||||
"navbar.links.reverse": "Reverse shares",
|
||||
@@ -22,10 +22,10 @@ export default {
|
||||
"home.bullet.a.description": "Host Pingvin Share on your own machine.",
|
||||
"home.bullet.b.name": "Privacy",
|
||||
"home.bullet.b.description":
|
||||
"Your files are your files and should never get into the hands of third parties.",
|
||||
"Your files are yours and will never be accessed by third parties.",
|
||||
"home.bullet.c.name": "No annoying file size limit",
|
||||
"home.bullet.c.description":
|
||||
"Upload as big files as you want. Only your hard drive will be your limit.",
|
||||
"Upload files as big as you want. Only your hard drive will be your limit.",
|
||||
|
||||
"home.button.start": "Get started",
|
||||
"home.button.source": "Source code",
|
||||
@@ -75,13 +75,13 @@ export default {
|
||||
"resetPassword.title": "Forgot your password?",
|
||||
"resetPassword.description": "Enter your email to reset your password.",
|
||||
"resetPassword.notify.success":
|
||||
"A message with a link to reset your password has been sent if the email exists.",
|
||||
"A message with a link to reset your password has been sent if the provided email exists.",
|
||||
"resetPassword.button.back": "Back to sign in page",
|
||||
"resetPassword.text.resetPassword": "Reset password",
|
||||
"resetPassword.text.enterNewPassword": "Enter your new password",
|
||||
"resetPassword.input.password": "New password",
|
||||
"resetPassword.notify.passwordReset":
|
||||
"Your password has been reset successfully.",
|
||||
"Your password has been successfully reset.",
|
||||
|
||||
// /account
|
||||
"account.title": "My account",
|
||||
@@ -95,7 +95,7 @@ export default {
|
||||
"account.card.password.old": "Old password",
|
||||
"account.card.password.new": "New password",
|
||||
"account.card.password.noPasswordSet":
|
||||
"You don't have a password set. If you want to sign in with email and password you need to set a password.",
|
||||
"You do not have a password set. To sign in using your email and password, you need to create a password.",
|
||||
"account.notify.password.success": "Password changed successfully",
|
||||
|
||||
"account.card.oauth.title": "Social login",
|
||||
@@ -109,7 +109,7 @@ export default {
|
||||
"account.card.oauth.unlinked": "Unlinked",
|
||||
"account.modal.unlink.title": "Unlink account",
|
||||
"account.modal.unlink.description":
|
||||
"Unlinking your social accounts may cause you to lose your account if you don't remember your username and password.",
|
||||
"Unlinking your social accounts may cause you to lose your account if you don't remember your login credentials",
|
||||
"account.notify.oauth.unlinked.success": "Unlinked successfully",
|
||||
|
||||
"account.card.security.title": "Security",
|
||||
@@ -155,14 +155,17 @@ export default {
|
||||
"account.shares.table.name": "Name",
|
||||
"account.shares.table.description": "Description",
|
||||
"account.shares.table.visitors": "Visitors",
|
||||
"account.shares.table.expiresAt": "Expires at",
|
||||
"account.shares.table.createdAt": "Created at",
|
||||
"account.shares.table.expiresAt": "Expires on",
|
||||
"account.shares.table.createdAt": "Created on",
|
||||
"account.shares.table.size": "Size",
|
||||
"account.shares.table.password-protected": "Password protected",
|
||||
"account.shares.table.visitor-count": "{count} of {max}",
|
||||
"account.shares.table.expiry-never": "Never",
|
||||
|
||||
"account.shares.modal.share-informations": "Share informations",
|
||||
"account.shares.modal.share-link": "Share link",
|
||||
|
||||
"account.shares.modal.delete.title": "Delete share {share}",
|
||||
"account.shares.modal.delete.title": "Delete share: {share}",
|
||||
"account.shares.modal.delete.description":
|
||||
"Do you really want to delete this share?",
|
||||
|
||||
@@ -195,17 +198,17 @@ export default {
|
||||
|
||||
"account.reverseShares.modal.max-size.label": "Max share size",
|
||||
|
||||
"account.reverseShares.modal.send-email": "Send email notification",
|
||||
"account.reverseShares.modal.send-email": "Send email notifications",
|
||||
"account.reverseShares.modal.send-email.description":
|
||||
"Send an email notification when a share is created with this reverse share link.",
|
||||
"Sends you an email notification when a share is created with this reverse share link.",
|
||||
|
||||
"account.reverseShares.modal.simplified": "Simple mode",
|
||||
"account.reverseShares.modal.simplified.description":
|
||||
"Make it easy for the person uploading the file to share it with you. They will be able to customize only the name and description of the share.",
|
||||
"Make it easy for the person uploading the file to share it with you. They will only be able to customize the name and description of the share.",
|
||||
|
||||
"account.reverseShares.modal.public-access": "Public access",
|
||||
"account.reverseShares.modal.public-access.description":
|
||||
"Make the created shares with this reverse share public. If disabled, only you and the creator of the share can view it.",
|
||||
"Make the shares created with this reverse share public. If disabled, only you and the share creator will have access to view it.",
|
||||
|
||||
"account.reverseShares.modal.max-use.label": "Max uses",
|
||||
"account.reverseShares.modal.max-use.description":
|
||||
@@ -244,7 +247,7 @@ export default {
|
||||
"admin.users.table.email": "Email",
|
||||
"admin.users.table.admin": "Admin",
|
||||
|
||||
"admin.users.edit.update.title": "Update user {username}",
|
||||
"admin.users.edit.update.title": "Edit user: {username}",
|
||||
"admin.users.edit.update.admin-privileges": "Admin privileges",
|
||||
"admin.users.edit.update.change-password.title": "Change password",
|
||||
"admin.users.edit.update.change-password.field": "New password",
|
||||
@@ -252,9 +255,9 @@ export default {
|
||||
"admin.users.edit.update.notify.password.success":
|
||||
"Password changed successfully",
|
||||
|
||||
"admin.users.edit.delete.title": "Delete user {username}",
|
||||
"admin.users.edit.delete.title": "Delete user: {username} ?",
|
||||
"admin.users.edit.delete.description":
|
||||
"Do you really want to delete this user and all his shares?",
|
||||
"Do you really want to delete this user and all their shares?",
|
||||
|
||||
// showCreateUserModal.tsx
|
||||
"admin.users.modal.create.title": "Create user",
|
||||
@@ -275,9 +278,9 @@ export default {
|
||||
"admin.shares.table.id": "Share ID",
|
||||
"admin.shares.table.username": "Creator",
|
||||
"admin.shares.table.visitors": "Visitors",
|
||||
"admin.shares.table.expires": "Expires At",
|
||||
"admin.shares.table.expires": "Expires on",
|
||||
|
||||
"admin.shares.edit.delete.title": "Delete share {id}",
|
||||
"admin.shares.edit.delete.title": "Delete share: {id}",
|
||||
"admin.shares.edit.delete.description":
|
||||
"Do you really want to delete this share?",
|
||||
|
||||
@@ -286,14 +289,19 @@ export default {
|
||||
// /upload
|
||||
"upload.title": "Upload",
|
||||
|
||||
"upload.notify.confirm-leave":
|
||||
"Are you sure you want to leave this page? Your upload will be canceled.",
|
||||
"upload.notify.generic-error":
|
||||
"An error occurred while finishing your share.",
|
||||
"upload.notify.count-failed": "{count} files failed to upload. Trying again.",
|
||||
"upload.reverse-share.error.invalid.title": "Invalid reverse share link",
|
||||
"upload.reverse-share.error.invalid.description":
|
||||
"This reverse share has expired or is invalid.",
|
||||
|
||||
// Dropzone.tsx
|
||||
"upload.dropzone.title": "Upload files",
|
||||
"upload.dropzone.description":
|
||||
"Drag'n'drop files here to start your share. We can accept only files that are less than {maxSize} in total.",
|
||||
"Drag'n'drop files here to start your share. We only accept files up to {maxSize} in total.",
|
||||
"upload.dropzone.notify.file-too-big":
|
||||
"Your files exceed the maximum share size of {maxSize}.",
|
||||
|
||||
@@ -311,9 +319,9 @@ export default {
|
||||
"You will be unable to delete your share manually and view the visitor count.",
|
||||
|
||||
"upload.modal.expires.never": "never",
|
||||
"upload.modal.expires.never-long": "Never Expires",
|
||||
"upload.modal.expires.never-long": "Permanent share",
|
||||
"upload.modal.expires.error.too-long":
|
||||
"Expiration exceeds maximum expiration date of {max}.",
|
||||
"Expiration date exceeds the maximum of {max}.",
|
||||
|
||||
"upload.modal.link.label": "Link",
|
||||
"upload.modal.expires.label": "Expiration",
|
||||
@@ -350,13 +358,16 @@ export default {
|
||||
"upload.modal.completed.expires-on":
|
||||
"This share will expire on {expiration}.",
|
||||
"upload.modal.completed.share-ready": "Share ready",
|
||||
"upload.modal.completed.notified-reverse-share-creator": "We have notified the creator of the reverse share. You can also manually share this link with them through other means.",
|
||||
"upload.modal.completed.notified-reverse-share-creator":
|
||||
"We have notified the creator of the reverse share. You can also manually share this link with them through other means.",
|
||||
|
||||
// END /upload
|
||||
|
||||
// /share/[id]
|
||||
"share.title": "Share {shareId}",
|
||||
"share.description": "Look what I've shared with you!",
|
||||
"share.fileCount":
|
||||
"{count, plural, =1 {# file} other {# files}} · {size} (zip file may be smaller due to compression)",
|
||||
"share.error.visitor-limit-exceeded.title": "Visitor limit exceeded",
|
||||
"share.error.visitor-limit-exceeded.description":
|
||||
"The visitor limit from this share has been exceeded.",
|
||||
@@ -365,17 +376,18 @@ export default {
|
||||
"share.error.not-found.description":
|
||||
"The share you're looking for doesn't exist.",
|
||||
"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.access-denied.description":
|
||||
"The current account does not have permission to access this share",
|
||||
|
||||
"share.modal.password.title": "Password required",
|
||||
"share.modal.password.description":
|
||||
"To access this share please enter the password for the share.",
|
||||
"Please enter the password to access this share.",
|
||||
"share.modal.password": "Password",
|
||||
"share.modal.error.invalid-password": "Invalid password",
|
||||
|
||||
"share.button.download-all": "Download all",
|
||||
"share.notify.download-all-preparing":
|
||||
"The share is preparing. Try again in a few minutes.",
|
||||
"The share is being prepared. Please try again in a few minutes.",
|
||||
|
||||
"share.modal.file-link": "File link",
|
||||
"share.table.name": "Name",
|
||||
@@ -383,7 +395,7 @@ export default {
|
||||
|
||||
"share.modal.file-preview.error.not-supported.title": "Preview not supported",
|
||||
"share.modal.file-preview.error.not-supported.description":
|
||||
"A preview for this file type is unsupported. Please download the file to view it.",
|
||||
"Previews are not supported for this type of files. Please download the file to view it.",
|
||||
|
||||
// END /share/[id]
|
||||
|
||||
@@ -395,10 +407,23 @@ export default {
|
||||
"share.edit.notify.save-success": "Share updated successfully",
|
||||
// END /share/[id]/edit
|
||||
|
||||
// /imprint
|
||||
"imprint.title": "Imprint",
|
||||
// END /imprint
|
||||
|
||||
// /privacy
|
||||
"privacy.title": "Privacy Policy",
|
||||
// END /privacy
|
||||
|
||||
// /admin/config
|
||||
"admin.config.config-file-warning.title": "Configuration file present",
|
||||
"admin.config.config-file-warning.description":
|
||||
"As you have a configured Pingvin Share with a configuration file, you can't change the configuration through the UI.",
|
||||
|
||||
"admin.config.title": "Configuration",
|
||||
"admin.config.category.general": "General",
|
||||
"admin.config.category.share": "Share",
|
||||
"admin.config.category.cache": "Cache",
|
||||
"admin.config.category.email": "Email",
|
||||
"admin.config.category.smtp": "SMTP",
|
||||
"admin.config.category.oauth": "Social Login",
|
||||
@@ -408,45 +433,61 @@ export default {
|
||||
"admin.config.general.app-url": "App URL",
|
||||
"admin.config.general.app-url.description":
|
||||
"On which URL Pingvin Share is available",
|
||||
"admin.config.general.secure-cookies": "Secure cookies",
|
||||
"admin.config.general.secure-cookies.description":
|
||||
"Whether to set the secure flag on cookies. If enabled, the site will not function when accessed over HTTP.",
|
||||
"admin.config.general.show-home-page": "Show home page",
|
||||
"admin.config.general.show-home-page.description":
|
||||
"Whether to show the home page",
|
||||
"admin.config.general.session-duration": "Session Duration",
|
||||
"admin.config.general.session-duration.description":
|
||||
"Time in hours after which a user must log in again (default: 3 months).",
|
||||
"Time after which a user must log in again (default: 3 months).",
|
||||
"admin.config.general.logo": "Logo",
|
||||
"admin.config.general.logo.description":
|
||||
"Change your logo by uploading a new image. The image must be a PNG and should have the format 1:1.",
|
||||
"admin.config.general.logo.placeholder": "Pick image",
|
||||
|
||||
"admin.config.cache.ttl": "TTL",
|
||||
"admin.config.cache.ttl.description":
|
||||
"Time in second to keep information inside the cache.",
|
||||
"admin.config.cache.max-items": "Maximum items",
|
||||
"admin.config.cache.max-items.description":
|
||||
"Maximum number of items inside the cache.",
|
||||
"admin.config.cache.redis-enabled": "Redis enabled",
|
||||
"admin.config.cache.redis-enabled.description":
|
||||
"Normally Pingvin Share caches information in memory. If you run multiple instances of Pingvin Share, you need to enable Redis caching to share the cache between the instances.",
|
||||
"admin.config.cache.redis-url": "Redis URL",
|
||||
"admin.config.cache.redis-url.description":
|
||||
"Url to connect to the Redis instance used for caching.",
|
||||
|
||||
"admin.config.email.enable-share-email-recipients":
|
||||
"Enable share email recipients",
|
||||
"Enable email recipient sharing",
|
||||
"admin.config.email.enable-share-email-recipients.description":
|
||||
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
||||
"Whether to allow email sharing with recipients. Only enable this if SMTP is activated.",
|
||||
"admin.config.email.share-recipients-subject": "Share recipients subject",
|
||||
"admin.config.email.share-recipients-subject.description":
|
||||
"Subject of the email which gets sent to the share recipients.",
|
||||
"admin.config.email.share-recipients-message": "Share recipients message",
|
||||
"admin.config.email.share-recipients-message.description":
|
||||
"Message which gets sent to the share recipients. Available variables:\n {creator} - The username of the creator of the share\n {shareUrl} - The URL of the share\n {desc} - The description of the share\n {expires} - The expiration date of the share\n The variables will be replaced with the actual value.",
|
||||
"Message which gets sent to the share recipients. Available variables:\n {creator} - The username of the creator of the share\n {creatorEmail} - The email of the creator of the share\n {shareUrl} - The URL of the share\n {desc} - The description of the share\n {expires} - The expiration date of the share\n These variables will be replaced with the actual value.",
|
||||
"admin.config.email.reverse-share-subject": "Reverse share subject",
|
||||
"admin.config.email.reverse-share-subject.description":
|
||||
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
||||
"Subject of the sent email when someone created a share with your reverse share link.",
|
||||
"admin.config.email.reverse-share-message": "Reverse share message",
|
||||
"admin.config.email.reverse-share-message.description":
|
||||
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||
"admin.config.email.reset-password-subject": "Reset password subject",
|
||||
"admin.config.email.reset-password-subject.description":
|
||||
"Subject of the email which gets sent when a user requests a password reset.",
|
||||
"Subject of the sent email when a user requests a password reset.",
|
||||
"admin.config.email.reset-password-message": "Reset password message",
|
||||
"admin.config.email.reset-password-message.description":
|
||||
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
|
||||
"admin.config.email.invite-subject": "Invite subject",
|
||||
"admin.config.email.invite-subject.description":
|
||||
"Subject of the email which gets sent when an admin invites a user.",
|
||||
"Subject of the sent email when an admin invites a user.",
|
||||
"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 and {password} with the password.",
|
||||
"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.share.allow-registration": "Allow registration",
|
||||
"admin.config.share.allow-registration.description":
|
||||
@@ -457,20 +498,23 @@ export default {
|
||||
"Whether unauthenticated users can create shares",
|
||||
"admin.config.share.max-expiration": "Max expiration",
|
||||
"admin.config.share.max-expiration.description":
|
||||
"Maximum share expiration in hours. Set to 0 to allow unlimited expiration.",
|
||||
"Maximum share expiration. Set to 0 to allow unlimited expiration.",
|
||||
"admin.config.share.share-id-length": "Default share ID length",
|
||||
"admin.config.share.share-id-length.description":
|
||||
"Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.",
|
||||
"admin.config.share.max-size": "Max size",
|
||||
"admin.config.share.max-size.description": "Maximum share size in bytes",
|
||||
"admin.config.share.max-size.description": "Maximum share size",
|
||||
"admin.config.share.zip-compression-level": "Zip compression level",
|
||||
"admin.config.share.zip-compression-level.description":
|
||||
"Adjust the level to balance between file size and compression speed. Valid values range from 0 to 9, with 0 being no compression and 9 being maximum compression. ",
|
||||
"admin.config.share.chunk-size": "Chunk size",
|
||||
"admin.config.share.chunk-size.description":
|
||||
"Adjust the chunk size (in bytes) for your uploads to balance efficiency and reliability according to your internet connection. Smaller chunks can enhance success rates for unstable connections, while larger chunks speed up uploads for stable connections.",
|
||||
"Adjust the chunk size for your uploads to balance efficiency and reliability according to your internet connection. Smaller chunks can enhance success rates for unstable connections, while larger chunks make uploads faster for stable connections.",
|
||||
"admin.config.share.auto-open-share-modal": "Auto open create share modal",
|
||||
"admin.config.share.auto-open-share-modal.description":
|
||||
"The share creation modal automatically appears when a user selects files, eliminating the need to manually click the button.",
|
||||
|
||||
"admin.config.smtp.enabled": "Enabled",
|
||||
"admin.config.smtp.enabled": "Enable",
|
||||
"admin.config.smtp.enabled.description":
|
||||
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
|
||||
"admin.config.smtp.host": "Host",
|
||||
@@ -479,7 +523,7 @@ export default {
|
||||
"admin.config.smtp.port.description": "Port of the SMTP server",
|
||||
"admin.config.smtp.email": "Email",
|
||||
"admin.config.smtp.email.description":
|
||||
"Email address which the emails get sent from",
|
||||
"Email address from which the emails get sent",
|
||||
"admin.config.smtp.username": "Username",
|
||||
"admin.config.smtp.username.description": "Username of the SMTP server",
|
||||
"admin.config.smtp.password": "Password",
|
||||
@@ -532,6 +576,9 @@ export default {
|
||||
"admin.config.oauth.discord-enabled": "Discord",
|
||||
"admin.config.oauth.discord-enabled.description":
|
||||
"Whether Discord login is enabled",
|
||||
"admin.config.oauth.discord-limited-users": "Discord limited users",
|
||||
"admin.config.oauth.discord-limited-users.description":
|
||||
"Limit signing in to specific users by their Discord ID. Leave it blank to disable.",
|
||||
"admin.config.oauth.discord-limited-guild": "Discord limited server ID",
|
||||
"admin.config.oauth.discord-limited-guild.description":
|
||||
"Limit signing in to users in a specific server. Leave it blank to disable.",
|
||||
@@ -547,6 +594,12 @@ export default {
|
||||
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
|
||||
"admin.config.oauth.oidc-discovery-uri.description":
|
||||
"Discovery URI of the OpenID Connect OAuth app",
|
||||
"admin.config.oauth.oidc-sign-out": "Sign out from OpenID Connect",
|
||||
"admin.config.oauth.oidc-sign-out.description":
|
||||
"Whether the “Sign out” button will sign out from the OpenID Connect provider",
|
||||
"admin.config.oauth.oidc-scope": "OpenID Connect scope",
|
||||
"admin.config.oauth.oidc-scope.description":
|
||||
"Scopes which should be requested from the OpenID Connect provider.",
|
||||
"admin.config.oauth.oidc-username-claim": "OpenID Connect username claim",
|
||||
"admin.config.oauth.oidc-username-claim.description":
|
||||
"Username claim in OpenID Connect ID token. Leave it blank if you don't know what this config is.",
|
||||
@@ -555,11 +608,13 @@ export default {
|
||||
"Must be a valid JMES path referencing an array of roles. " +
|
||||
"Managing access rights using OpenID Connect roles is only recommended if no other identity provider is configured and password login is disabled. " +
|
||||
"Leave it blank if you don't know what this config is.",
|
||||
"admin.config.oauth.oidc-role-general-access": "OpenID Connect role for general access",
|
||||
"admin.config.oauth.oidc-role-general-access":
|
||||
"OpenID Connect role for general access",
|
||||
"admin.config.oauth.oidc-role-general-access.description":
|
||||
"Role required for general access. Must be present in a user’s roles for them to log in. " +
|
||||
"Leave it blank if you don't know what this config is.",
|
||||
"admin.config.oauth.oidc-role-admin-access": "OpenID Connect role for admin access",
|
||||
"admin.config.oauth.oidc-role-admin-access":
|
||||
"OpenID Connect role for admin access",
|
||||
"admin.config.oauth.oidc-role-admin-access.description":
|
||||
"Role required for administrative access. Must be present in a user’s roles for them to access the admin panel. " +
|
||||
"Leave it blank if you don't know what this config is.",
|
||||
@@ -571,19 +626,76 @@ export default {
|
||||
"Client secret of the OpenID Connect OAuth app",
|
||||
|
||||
"admin.config.category.ldap": "LDAP",
|
||||
"admin.config.ldap.enabled": "Enabled LDAP",
|
||||
"admin.config.ldap.enabled.description": "Use LDAP authentication for user login",
|
||||
"admin.config.ldap.enabled": "Enable LDAP",
|
||||
"admin.config.ldap.enabled.description":
|
||||
"Use LDAP authentication for user login",
|
||||
"admin.config.ldap.url": "Server URL",
|
||||
"admin.config.ldap.url.description": "URL of the LDAP server",
|
||||
"admin.config.ldap.bind-dn": "Bind DN",
|
||||
"admin.config.ldap.bind-dn.description": "Default user which will be used to execute the user search",
|
||||
"admin.config.ldap.bind-dn.description":
|
||||
"Default user used to perform the user search",
|
||||
"admin.config.ldap.bind-password": "Bind password",
|
||||
"admin.config.ldap.bind-password.description": "Password for the user search user",
|
||||
"admin.config.ldap.bind-password.description":
|
||||
"Password used to perform the user search",
|
||||
"admin.config.ldap.search-base": "User base",
|
||||
"admin.config.ldap.search-base.description": "Base location, where the user search will be performed",
|
||||
"admin.config.ldap.search-base.description":
|
||||
"Base location, where the user search will be performed",
|
||||
"admin.config.ldap.search-query": "User query",
|
||||
"admin.config.ldap.search-query.description": "The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.",
|
||||
"admin.config.ldap.search-query.description":
|
||||
"The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.",
|
||||
"admin.config.ldap.admin-groups": "Admin group",
|
||||
"admin.config.ldap.admin-groups.description":
|
||||
"Group required for administrative access.",
|
||||
"admin.config.ldap.field-name-member-of": "User groups attribute name",
|
||||
"admin.config.ldap.field-name-member-of.description":
|
||||
"LDAP attribute name for the groups, an user is a member of. This is used when checking for the admin group.",
|
||||
"admin.config.ldap.field-name-email": "User email attribute name",
|
||||
"admin.config.ldap.field-name-email.description":
|
||||
"LDAP attribute name for the email of an user.",
|
||||
"admin.config.notify.success": "Configuration updated successfully.",
|
||||
"admin.config.notify.logo-success":
|
||||
"Logo updated successfully. It may take a few minutes to update on the website.",
|
||||
"admin.config.notify.no-changes": "No changes to save.",
|
||||
|
||||
"admin.config.category.s3": "S3",
|
||||
"admin.config.s3.enabled": "Enabled",
|
||||
"admin.config.s3.enabled.description":
|
||||
"Whether S3 should be used to store the shared files instead of the local file system.",
|
||||
"admin.config.s3.endpoint": "Endpoint",
|
||||
"admin.config.s3.endpoint.description": "The URL of the S3 bucket.",
|
||||
"admin.config.s3.region": "Region",
|
||||
"admin.config.s3.region.description": "The region of the S3 bucket.",
|
||||
"admin.config.s3.bucket-name": "Bucket name",
|
||||
"admin.config.s3.bucket-name.description": "The name of the S3 bucket.",
|
||||
"admin.config.s3.bucket-path": "Path",
|
||||
"admin.config.s3.bucket-path.description":
|
||||
"The default path which should be used to store the files in the S3 bucket.",
|
||||
"admin.config.s3.key": "Key",
|
||||
"admin.config.s3.key.description":
|
||||
"The key which allows you to access the S3 bucket.",
|
||||
"admin.config.s3.secret": "Secret",
|
||||
"admin.config.s3.secret.description":
|
||||
"The secret which allows you to access the S3 bucket.",
|
||||
"admin.config.s3.use-checksum": "Use checksum",
|
||||
"admin.config.s3.use-checksum.description":
|
||||
"Turn off for backends that do not support checksum (e.g. B2).",
|
||||
|
||||
"admin.config.category.legal": "Legal",
|
||||
"admin.config.legal.enabled": "Enable legal notices",
|
||||
"admin.config.legal.enabled.description":
|
||||
"Whether to show a link to imprint and privacy policy in the footer.",
|
||||
"admin.config.legal.imprint-text": "Imprint text",
|
||||
"admin.config.legal.imprint-text.description":
|
||||
"The text which should be shown in the imprint. Supports Markdown. Leave blank to link to an external imprint page.",
|
||||
"admin.config.legal.imprint-url": "Imprint URL",
|
||||
"admin.config.legal.imprint-url.description":
|
||||
"If you already have an imprint page you can link it here instead of using the text field.",
|
||||
"admin.config.legal.privacy-policy-text": "Privacy policy text",
|
||||
"admin.config.legal.privacy-policy-text.description":
|
||||
"The text which should be shown in the privacy policy. Supports Markdown. Leave blank to link to an external privacy policy page.",
|
||||
"admin.config.legal.privacy-policy-url": "Privacy policy URL",
|
||||
"admin.config.legal.privacy-policy-url.description":
|
||||
"If you already have a privacy policy page you can link it here instead of using the text field.",
|
||||
|
||||
// 404
|
||||
"404.description": "Oops this page doesn't exist.",
|
||||
@@ -603,13 +715,13 @@ export default {
|
||||
"error.msg.no_email": "Can't get email address from this {0} account.",
|
||||
"error.msg.already_linked":
|
||||
"This {0} account is already linked to another account.",
|
||||
"error.msg.not_linked": "This {0} account haven't linked to any account yet.",
|
||||
"error.msg.not_linked":
|
||||
"This {0} account hasn't been linked to any account yet.",
|
||||
"error.msg.unverified_account":
|
||||
"This {0} account is unverified, please try again after verification.",
|
||||
"error.msg.user_not_allowed":
|
||||
"You are not allowed to sign in.",
|
||||
"error.msg.user_not_allowed": "You are not allowed to sign in.",
|
||||
"error.msg.cannot_get_user_info":
|
||||
"Can not get your user info from this {0} account.",
|
||||
"Cannot get your user info from this {0} account.",
|
||||
"error.param.provider_github": "GitHub",
|
||||
"error.param.provider_google": "Google",
|
||||
"error.param.provider_microsoft": "Microsoft",
|
||||
@@ -628,11 +740,13 @@ export default {
|
||||
"common.button.generate": "Generate",
|
||||
"common.button.done": "Done",
|
||||
"common.text.link": "Link",
|
||||
"common.text.navigate-to-link": "Go to the link",
|
||||
"common.text.navigate-to-link": "Visit link",
|
||||
"common.text.or": "or",
|
||||
"common.text.redirecting": "Redirecting...",
|
||||
"common.button.go-back": "Go back",
|
||||
"common.button.go-home": "Go home",
|
||||
"common.notify.copied": "Your link was copied to the clipboard",
|
||||
"common.notify.copied-link": "Your link was copied to the clipboard",
|
||||
"common.success": "Success",
|
||||
|
||||
"common.error": "Error",
|
||||
@@ -640,6 +754,8 @@ export default {
|
||||
"common.error.invalid-email": "Invalid email address",
|
||||
"common.error.too-short": "Must be at least {length} characters",
|
||||
"common.error.too-long": "Must be at most {length} characters",
|
||||
"common.error.number-too-small": "Must be at least {min}",
|
||||
"common.error.number-too-large": "Must be at most {max}",
|
||||
"common.error.exact-length": "Must be exactly {length} characters",
|
||||
"common.error.invalid-number": "Must be a number",
|
||||
"common.error.field-required": "This field is required",
|
||||
|
||||
@@ -12,13 +12,13 @@ export default {
|
||||
// END navbar
|
||||
// /
|
||||
"home.title": "Una plataforma <h>autoalojada</h> para compartir archivos.",
|
||||
"home.description": "¿En realidad quieres dejar tus archivos personales en manos de terceros como WeTransfer?",
|
||||
"home.description": "¿De verdad quieres dejar tus archivos personales en manos de terceros como WeTransfer?",
|
||||
"home.bullet.a.name": "Autoalojada",
|
||||
"home.bullet.a.description": "Aloja Pingvin Share en tu propio equipo.",
|
||||
"home.bullet.b.name": "Privacidad",
|
||||
"home.bullet.b.description": "Tus archivos son tus archivos y nunca deberían terminar en manos de terceros.",
|
||||
"home.bullet.b.description": "Tus archivos son tuyos y nunca serán accesibles por terceros.",
|
||||
"home.bullet.c.name": "Sin molestos límites de tamaño de archivo",
|
||||
"home.bullet.c.description": "Sube archivos tan grandes como quieras. El único límite es la capacidad de tu disco duro.",
|
||||
"home.bullet.c.description": "Sube archivos del tamaño que desees. Solo el espacio en tu disco duro será tu límite.",
|
||||
"home.button.start": "Comenzar",
|
||||
"home.button.source": "Código fuente",
|
||||
// END /
|
||||
@@ -58,12 +58,12 @@ export default {
|
||||
// /auth/reset-password
|
||||
"resetPassword.title": "¿Olvidaste tu contraseña?",
|
||||
"resetPassword.description": "Ingresa tu correo para restablecer tu contraseña.",
|
||||
"resetPassword.notify.success": "Se ha enviado un mensaje con un enlace para restablecer tu contraseña.",
|
||||
"resetPassword.notify.success": "Se ha enviado un mensaje con un enlace para restablecer tu contraseña si el correo electrónico proporcionado existe.",
|
||||
"resetPassword.button.back": "Volver al inicio de sesión",
|
||||
"resetPassword.text.resetPassword": "Restablecer contraseña",
|
||||
"resetPassword.text.enterNewPassword": "Ingresa tu nueva contraseña",
|
||||
"resetPassword.input.password": "Nueva contraseña",
|
||||
"resetPassword.notify.passwordReset": "Tu contraseña se ha restablecido correctamente.",
|
||||
"resetPassword.notify.passwordReset": "Tu contraseña ha sido restablecida exitosamente.",
|
||||
// /account
|
||||
"account.title": "Mi cuenta",
|
||||
"account.card.info.title": "Información de cuenta",
|
||||
@@ -73,7 +73,7 @@ export default {
|
||||
"account.card.password.title": "Contraseña",
|
||||
"account.card.password.old": "Anterior contraseña",
|
||||
"account.card.password.new": "Nueva contraseña",
|
||||
"account.card.password.noPasswordSet": "No tienes una establecida contraseña. Si deseas iniciar sesión con correo electrónico y una contraseña necesitas crear una contraseña.",
|
||||
"account.card.password.noPasswordSet": "No tienes una contraseña configurada. Para iniciar sesión usando tu correo electrónico y contraseña, necesitas crear una contraseña.",
|
||||
"account.notify.password.success": "Contraseña cambiada correctamente",
|
||||
"account.card.oauth.title": "Inicio de sesión con red social",
|
||||
"account.card.oauth.github": "GitHub",
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
"account.card.oauth.unlink": "Desvincular",
|
||||
"account.card.oauth.unlinked": "Desvinculado",
|
||||
"account.modal.unlink.title": "Desvincular cuenta",
|
||||
"account.modal.unlink.description": "Desvincular tus cuentas sociales puede causar que pierdas tu cuenta si no recuerdas tu nombre de usuario y contraseña.",
|
||||
"account.modal.unlink.description": "Desvincular tus cuentas sociales puede hacer que pierdas acceso a tu cuenta si no recuerdas tus credenciales de inicio de sesión",
|
||||
"account.notify.oauth.unlinked.success": "Desvinculado correctamente",
|
||||
"account.card.security.title": "Seguridad",
|
||||
"account.card.security.totp.enable.description": "Ingrese su contraseña actual para habilitar TOTP",
|
||||
@@ -104,7 +104,7 @@ export default {
|
||||
"account.card.language.description": "El proyecto ha sido traducido por la comunidad. Algunos idiomas pueden estar incompletos.",
|
||||
"account.card.color.title": "Esquema de colores",
|
||||
// ThemeSwitcher.tsx
|
||||
"account.theme.dark": "Oscuro",
|
||||
"account.theme.dark": "Obscuro",
|
||||
"account.theme.light": "Claro",
|
||||
"account.theme.system": "Sistema",
|
||||
"account.button.delete": "Eliminar Cuenta",
|
||||
@@ -121,12 +121,15 @@ export default {
|
||||
"account.shares.table.name": "Nombre",
|
||||
"account.shares.table.description": "Descripción",
|
||||
"account.shares.table.visitors": "Visitas",
|
||||
"account.shares.table.expiresAt": "Expira en",
|
||||
"account.shares.table.createdAt": "Creado en",
|
||||
"account.shares.table.expiresAt": "Vence el",
|
||||
"account.shares.table.createdAt": "Creado el",
|
||||
"account.shares.table.size": "Tamaño",
|
||||
"account.shares.table.password-protected": "Protegido por contraseña",
|
||||
"account.shares.table.visitor-count": "{count} de {max}",
|
||||
"account.shares.table.expiry-never": "Nunca",
|
||||
"account.shares.modal.share-informations": "Información del compartido",
|
||||
"account.shares.modal.share-link": "Enlace",
|
||||
"account.shares.modal.delete.title": "Eliminar compartido {share}",
|
||||
"account.shares.modal.delete.title": "Eliminar enlace compartido: {share}",
|
||||
"account.shares.modal.delete.description": "¿Seguro que quieres eliminar este compartido?",
|
||||
// END /account/shares
|
||||
// /account/reverseShares
|
||||
@@ -150,12 +153,12 @@ export default {
|
||||
"account.reverseShares.modal.expiration.year-singular": "Año",
|
||||
"account.reverseShares.modal.expiration.year-plural": "Años",
|
||||
"account.reverseShares.modal.max-size.label": "Tamaño máximo del compartido",
|
||||
"account.reverseShares.modal.send-email": "Enviar notificación por correo",
|
||||
"account.reverseShares.modal.send-email.description": "Enviar una notificación por correo cuando se comparta algo con este enlace de compartición inversa.",
|
||||
"account.reverseShares.modal.send-email": "Enviar notificaciones por correo electrónico",
|
||||
"account.reverseShares.modal.send-email.description": "Te envía una notificación por correo electrónico cuando se crea un enlace compartido con este enlace de compartición inverso.",
|
||||
"account.reverseShares.modal.simplified": "Modo simple",
|
||||
"account.reverseShares.modal.simplified.description": "Facilita que la persona que suba el archivo comparta el mismo contigo. Serán capaces de personalizar sólo el nombre y la descripción de la compartición.",
|
||||
"account.reverseShares.modal.simplified.description": "Facilita que la persona que sube el archivo lo comparta contigo. Solo podrá personalizar el nombre y la descripción del enlace compartido.",
|
||||
"account.reverseShares.modal.public-access": "Acceso público",
|
||||
"account.reverseShares.modal.public-access.description": "Haz públicas las comparticiones creadas con esta compartición inversa. Si está desactivado, sólo tú y el creador de la compartición pueden verla.",
|
||||
"account.reverseShares.modal.public-access.description": "Haz que los enlaces compartidos creados con este enlace de compartición inverso sean públicos. Si está desactivado, solo tú y el creador del enlace tendrán acceso para verlo.",
|
||||
"account.reverseShares.modal.max-use.label": "Máximo de usos",
|
||||
"account.reverseShares.modal.max-use.description": "Cantidad máxima de veces que esta URL se puede usar para crear un compartido.",
|
||||
"account.reverseShare.never-expires": "Esta compartición inversa nunca expirará.",
|
||||
@@ -183,14 +186,14 @@ export default {
|
||||
"admin.users.table.username": "Nombre de usuario",
|
||||
"admin.users.table.email": "Correo",
|
||||
"admin.users.table.admin": "Administrador",
|
||||
"admin.users.edit.update.title": "Actualizar usuario {username}",
|
||||
"admin.users.edit.update.title": "Editar usuario: {username}",
|
||||
"admin.users.edit.update.admin-privileges": "Privilegios de administrador",
|
||||
"admin.users.edit.update.change-password.title": "Cambiar contraseña",
|
||||
"admin.users.edit.update.change-password.field": "Nueva contraseña",
|
||||
"admin.users.edit.update.change-password.button": "Guardar nueva contraseña",
|
||||
"admin.users.edit.update.notify.password.success": "Contraseña cambiada correctamente",
|
||||
"admin.users.edit.delete.title": "Eliminar usuario {username}",
|
||||
"admin.users.edit.delete.description": "¿Realmente quiere eliminar este usuario y todos sus archivos compartidos?",
|
||||
"admin.users.edit.delete.title": "¿Eliminar usuario: {username} ?",
|
||||
"admin.users.edit.delete.description": "¿Realmente deseas eliminar a este usuario y todos sus enlaces compartidos?",
|
||||
// showCreateUserModal.tsx
|
||||
"admin.users.modal.create.title": "Crear usuario",
|
||||
"admin.users.modal.create.username": "Nombre de usuario",
|
||||
@@ -206,17 +209,20 @@ export default {
|
||||
"admin.shares.table.id": "ID de compartición",
|
||||
"admin.shares.table.username": "Creador",
|
||||
"admin.shares.table.visitors": "Visitantes",
|
||||
"admin.shares.table.expires": "Expira el",
|
||||
"admin.shares.edit.delete.title": "Eliminar compartido {id}",
|
||||
"admin.shares.table.expires": "Vence el",
|
||||
"admin.shares.edit.delete.title": "Eliminar enlace compartido: {id}",
|
||||
"admin.shares.edit.delete.description": "¿Seguro que quieres eliminar este compartido?",
|
||||
// END /admin/shares
|
||||
// /upload
|
||||
"upload.title": "Subir",
|
||||
"upload.notify.confirm-leave": "¿Estás seguro de que quieres salir de esta página? Tu subida será cancelada.",
|
||||
"upload.notify.generic-error": "Ha ocurrido un error mientras se compartía tu archivo.",
|
||||
"upload.notify.count-failed": "No se pudo cargar {count} archivos. Intentando nuevamente.",
|
||||
"upload.reverse-share.error.invalid.title": "Enlace de uso compartido inverso inválido",
|
||||
"upload.reverse-share.error.invalid.description": "Este enlace de uso compartido inverso ha caducado o no es válido.",
|
||||
// Dropzone.tsx
|
||||
"upload.dropzone.title": "Subir archivos",
|
||||
"upload.dropzone.description": "Arrastra archivos aquí para comenzar a compartir. Aceptamos archivos de un tamaño menor a {maxSize} en total.",
|
||||
"upload.dropzone.description": "Arrastra y suelta los archivos aquí para crear tu enlace compartido. Solo aceptamos archivos de hasta {maxSize} en total.",
|
||||
"upload.dropzone.notify.file-too-big": "Tus archivos exceden el tamaño máximo de {maxSize}.",
|
||||
// FileList.tsx
|
||||
"upload.filelist.name": "Nombre",
|
||||
@@ -228,8 +234,8 @@ export default {
|
||||
"upload.modal.not-signed-in": "No has iniciado sesión",
|
||||
"upload.modal.not-signed-in-description": "No podrás eliminar tus compartidos manualmente ni ver el número de visitas.",
|
||||
"upload.modal.expires.never": "nunca",
|
||||
"upload.modal.expires.never-long": "Nunca Expira",
|
||||
"upload.modal.expires.error.too-long": "La caducidad excede la fecha de caducidad máxima de {max}.",
|
||||
"upload.modal.expires.never-long": "Enlace compartido permanente",
|
||||
"upload.modal.expires.error.too-long": "La fecha de expiración excede el máximo de {max}.",
|
||||
"upload.modal.link.label": "Enlace",
|
||||
"upload.modal.expires.label": "Expiración",
|
||||
"upload.modal.expires.minute-singular": "Minuto",
|
||||
@@ -264,6 +270,7 @@ export default {
|
||||
// /share/[id]
|
||||
"share.title": "Compartido {shareId}",
|
||||
"share.description": "¡Mira lo que he compartido contigo!",
|
||||
"share.fileCount": "{count, plural, one {}=1 {# archivo} other {# archivos}} · {size} (el archivo comprimido (zip, 7z) puede ser más pequeño debido a la compresión)",
|
||||
"share.error.visitor-limit-exceeded.title": "Se excedió el límite de visitas",
|
||||
"share.error.visitor-limit-exceeded.description": "Se ha excedido el límite de visitas para este compartido.",
|
||||
"share.error.removed.title": "Compartido eliminado",
|
||||
@@ -272,16 +279,16 @@ export default {
|
||||
"share.error.access-denied.title": "Compartición privada",
|
||||
"share.error.access-denied.description": "La cuenta actual no tiene permiso para acceder a este compartido",
|
||||
"share.modal.password.title": "Se requiere contraseña",
|
||||
"share.modal.password.description": "Por favor ingrese la contraseña para poder acceder a este compartido.",
|
||||
"share.modal.password.description": "Por favor, ingresa la contraseña para acceder a este recurso compartido.",
|
||||
"share.modal.password": "Contraseña",
|
||||
"share.modal.error.invalid-password": "Contraseña inválida",
|
||||
"share.button.download-all": "Descargar todo",
|
||||
"share.notify.download-all-preparing": "Se está preparando el compartido. Intente de nuevo en unos minutos.",
|
||||
"share.notify.download-all-preparing": "El enlace compartido está en preparación. Por favor, inténtalo de nuevo en unos minutos.",
|
||||
"share.modal.file-link": "Enlace del archivo",
|
||||
"share.table.name": "Nombre",
|
||||
"share.table.size": "Tamaño",
|
||||
"share.modal.file-preview.error.not-supported.title": "Vista previa no disponible",
|
||||
"share.modal.file-preview.error.not-supported.description": "La vista previa para este tipo de archivo no está disponible. Por favor descargue el archivo para verlo.",
|
||||
"share.modal.file-preview.error.not-supported.description": "Las vistas previas no son compatibles con este tipo de archivos. Por favor, descargue el archivo para verlo.",
|
||||
// END /share/[id]
|
||||
// /share/[id]/edit
|
||||
"share.edit.title": "Editar {shareId}",
|
||||
@@ -289,10 +296,19 @@ export default {
|
||||
"share.edit.notify.generic-error": "Ha ocurrido un error mientras se compartía tu archivo.",
|
||||
"share.edit.notify.save-success": "Compartir actualizado correctamente",
|
||||
// END /share/[id]/edit
|
||||
// /imprint
|
||||
"imprint.title": "Aviso legal",
|
||||
// END /imprint
|
||||
// /privacy
|
||||
"privacy.title": "Política de privacidad",
|
||||
// END /privacy
|
||||
// /admin/config
|
||||
"admin.config.config-file-warning.title": "Archivo de configuración presente",
|
||||
"admin.config.config-file-warning.description": "Como tienes configurado Pingvin Share con un archivo de configuración, no puedes cambiar la configuración a través de la interfaz gráfica.",
|
||||
"admin.config.title": "Configuración",
|
||||
"admin.config.category.general": "General",
|
||||
"admin.config.category.share": "Compartido",
|
||||
"admin.config.category.cache": "Caché",
|
||||
"admin.config.category.email": "Correo",
|
||||
"admin.config.category.smtp": "SMTP",
|
||||
"admin.config.category.oauth": "Inicio de sesión social",
|
||||
@@ -300,53 +316,65 @@ export default {
|
||||
"admin.config.general.app-name.description": "Nombre de la aplicación",
|
||||
"admin.config.general.app-url": "App URL",
|
||||
"admin.config.general.app-url.description": "En cuál URL está disponible Pingvin Share",
|
||||
"admin.config.general.secure-cookies": "Cookies seguras",
|
||||
"admin.config.general.secure-cookies.description": "Si se establece o no la bandera de seguridad en las cookies. Si se activa, el sitio no funcionará cuando se acceda a través de HTTP.",
|
||||
"admin.config.general.show-home-page": "Mostrar página de inicio",
|
||||
"admin.config.general.show-home-page.description": "Mostrar o no la página de inicio",
|
||||
"admin.config.general.session-duration": "Duración de la sesión",
|
||||
"admin.config.general.session-duration.description": "Tiempo en horas después del cual un usuario debe iniciar sesión de nuevo (por defecto: 3 meses).",
|
||||
"admin.config.general.session-duration.description": "Tiempo después del cual un usuario debe volver a iniciar sesión (por defecto: 3 meses).",
|
||||
"admin.config.general.logo": "Logo",
|
||||
"admin.config.general.logo.description": "Cambia tu logo subiendo una nueva imagen. La imagen debe ser un PNG y debe estar en formato 1:1.",
|
||||
"admin.config.general.logo.placeholder": "Elegir imagen",
|
||||
"admin.config.email.enable-share-email-recipients": "Activar destinatarios por correo",
|
||||
"admin.config.email.enable-share-email-recipients.description": "Si desea permitir a los destinatarios compartir por correo. Activa esto solo si tienes habilitado SMTP.",
|
||||
"admin.config.cache.ttl": "TTL",
|
||||
"admin.config.cache.ttl.description": "Time in second to keep information inside the cache.",
|
||||
"admin.config.cache.max-items": "Maximum items",
|
||||
"admin.config.cache.max-items.description": "Maximum number of items inside the cache.",
|
||||
"admin.config.cache.redis-enabled": "Redis enabled",
|
||||
"admin.config.cache.redis-enabled.description": "Normally Pingvin Share caches information in memory. If you run multiple instances of Pingvin Share, you need to enable Redis caching to share the cache between the instances.",
|
||||
"admin.config.cache.redis-url": "Redis URL",
|
||||
"admin.config.cache.redis-url.description": "Url to connect to the Redis instance used for caching.",
|
||||
"admin.config.email.enable-share-email-recipients": "Habilitar compartir por correo electrónico",
|
||||
"admin.config.email.enable-share-email-recipients.description": "Si desea permitir compartir por correo electrónico. Solo habilita esto si SMTP está activado.",
|
||||
"admin.config.email.share-recipients-subject": "Asunto destinatario",
|
||||
"admin.config.email.share-recipients-subject.description": "Asunto del correo el cual es enviado al destinatario del compartido.",
|
||||
"admin.config.email.share-recipients-message": "Mensaje destinatario",
|
||||
"admin.config.email.share-recipients-message.description": "Mensaje el cual es enviado al destinatario del compartido. Variables disponibles:\n{creator} - Nombre del creador del compartido\n {shareUrl} - URL del compartido\n {desc} - Descripción del compartido\n {expires} - Fecha de expiración del compartido\nLas variables serán remplazadas con los valores reales.",
|
||||
"admin.config.email.share-recipients-message.description": "Mensaje que se envía a los destinatarios del enlace compartido. Variables disponibles:\n{creator} - El nombre de usuario del creador del enlace\n{creatorEmail} - El correo electrónico el creador del enlace\n{shareUrl} - La URL del enlace compartido\n{desc} - La descripción del enlace compartido\n{expires} - La fecha de expiración del enlace\nEstas variables se reemplazarán con el valor real.",
|
||||
"admin.config.email.reverse-share-subject": "Asunto de la compartición inversa",
|
||||
"admin.config.email.reverse-share-subject.description": "Asunto del correo el cual se envía cuando alguien comparte algo con tu enlace de compartición inversa.",
|
||||
"admin.config.email.reverse-share-subject.description": "Asunto del correo electrónico enviado cuando alguien crea un enlace compartido con tu enlace compartido inverso.",
|
||||
"admin.config.email.reverse-share-message": "Mensaje de la compartición inversa",
|
||||
"admin.config.email.reverse-share-message.description": "Mensaje que se envía cuando alguien comparte algo con tu enlace de compartición inversa. {shareUrl} Se remplazará con el nombre del creador y la URL del compartido.",
|
||||
"admin.config.email.reset-password-subject": "Asunto restablecer contraseña",
|
||||
"admin.config.email.reset-password-subject.description": "Asunto del correo que se envía cuando un usuario solicita restablecer la contraseña.",
|
||||
"admin.config.email.reset-password-subject.description": "Asunto del correo electrónico enviado cuando un usuario solicita un restablecimiento de contraseña.",
|
||||
"admin.config.email.reset-password-message": "Mensaje restablecer contraseña",
|
||||
"admin.config.email.reset-password-message.description": "Mensaje que se envía cuando un usuario solicita restablecer la contraseña. {url} se remplazará con la URL para restablecer la contraseña.",
|
||||
"admin.config.email.invite-subject": "Asunto de la invitación",
|
||||
"admin.config.email.invite-subject.description": "Asunto del correo que se envía cuando un administrador invita a un usuario.",
|
||||
"admin.config.email.invite-subject.description": "Asunto del correo electrónico enviado cuando un administrador invita a un usuario.",
|
||||
"admin.config.email.invite-message": "Mensaje de invitación",
|
||||
"admin.config.email.invite-message.description": "Mensaje que se envía cuando un administrador invita a un usuario. {url} Se remplazará con la URL de invitación y {password} con la contraseña.",
|
||||
"admin.config.email.invite-message.description": "Mensaje que se envía cuando un administrador invita a un usuario. {url} se reemplazará con la URL de invitación, {email} con el correo electrónico y {password} con la contraseña del usuario.",
|
||||
"admin.config.share.allow-registration": "Permitir registro",
|
||||
"admin.config.share.allow-registration.description": "Si se permite el registro",
|
||||
"admin.config.share.allow-unauthenticated-shares": "Permitir compartir sin iniciar sesión",
|
||||
"admin.config.share.allow-unauthenticated-shares.description": "Si los usuarios que no han iniciado sesión pueden compartir",
|
||||
"admin.config.share.max-expiration": "Expiración máxima",
|
||||
"admin.config.share.max-expiration.description": "Expiración máxima para compartir en horas. Establezca en 0 para permitir una expiración ilimitada.",
|
||||
"admin.config.share.max-expiration.description": "Plazo máximo de caducidad. Establecer en 0 para enlaces sin caducidad.",
|
||||
"admin.config.share.share-id-length": "Longitud de ID compartido por defecto",
|
||||
"admin.config.share.share-id-length.description": "Longitud predeterminada para el ID generado de un compartido. Este valor también se usa para generar enlaces compartidos de uso inverso. Un valor inferior a 8 no se considera seguro.",
|
||||
"admin.config.share.max-size": "Tamaño máximo",
|
||||
"admin.config.share.max-size.description": "Tamaño máximo de los archivos, en bytes",
|
||||
"admin.config.share.max-size.description": "Tamaño máximo de los archivos",
|
||||
"admin.config.share.zip-compression-level": "Nivel de compresión del Zip",
|
||||
"admin.config.share.zip-compression-level.description": "Ajustar el nivel para equilibrar entre el tamaño del archivo y la velocidad de compresión. Los valores válidos van del 0 al 9, siendo 0 sin compresión y 9 el nivel máximo de compresión. ",
|
||||
"admin.config.share.chunk-size": "Tamaño de los fragmentos",
|
||||
"admin.config.share.chunk-size.description": "Ajusta el tamaño de los fragmentos (en bytes) para tus cargas para equilibrar la eficiencia y la fiabilidad según tu conexión a internet. Fragmentos más pequeños pueden mejorar las tasas de éxito para conexiones inestables, mientras que fragmentos más grandes aceleran las cargas para conexiones estables.",
|
||||
"admin.config.share.chunk-size.description": "Ajusta el tamaño del fragmento para tus subidas y equilibra la eficiencia y la fiabilidad según tu conexión a Internet. Fragmentos más pequeños pueden aumentar las tasas de éxito para conexiones inestables, mientras que fragmentos más grandes hacen que las subidas sean más rápidas para conexiones estables.",
|
||||
"admin.config.share.auto-open-share-modal": "Auto abrir un modal de creación de compartidos",
|
||||
"admin.config.share.auto-open-share-modal.description": "El modal de creación de compartir aparece automáticamente cuando un usuario selecciona archivos, eliminando la necesidad de hacer clic manualmente en el botón.",
|
||||
"admin.config.smtp.enabled": "Habilitado",
|
||||
"admin.config.smtp.enabled": "Habilitar",
|
||||
"admin.config.smtp.enabled.description": "Si SMTP está habilitado. Active solo si ha introducido el host, el puerto, el correo, el usuario y la contraseña de su servidor SMTP.",
|
||||
"admin.config.smtp.host": "Host",
|
||||
"admin.config.smtp.host.description": "Host del servidor SMTP",
|
||||
"admin.config.smtp.port": "Puerto",
|
||||
"admin.config.smtp.port.description": "Puerto del servidor SMTP",
|
||||
"admin.config.smtp.email": "Correo",
|
||||
"admin.config.smtp.email.description": "Dirección de correo desde la cual se envían los correos",
|
||||
"admin.config.smtp.email.description": "Dirección de correo electrónico desde la cual se envían los correos electrónicos",
|
||||
"admin.config.smtp.username": "Usuario",
|
||||
"admin.config.smtp.username.description": "Usuario del servidor SMTP",
|
||||
"admin.config.smtp.password": "Contraseña",
|
||||
@@ -375,49 +403,91 @@ export default {
|
||||
"admin.config.oauth.microsoft-enabled": "Microsoft",
|
||||
"admin.config.oauth.microsoft-enabled.description": "Si el inicio de sesión de Microsoft está habilitado",
|
||||
"admin.config.oauth.microsoft-tenant": "Microsoft Tenant",
|
||||
"admin.config.oauth.microsoft-tenant.description": "Tenant ID of the Microsoft OAuth app\ncommon: Users with both a personal Microsoft account and a work or school account from Microsoft Entra ID can sign in to the application. organizations: Only users with work or school accounts from Microsoft Entra ID can sign in to the application.\nconsumers: Only users with a personal Microsoft account can sign in to the application.\ndomain name of the Microsoft Entra tenant or the tenant ID in GUID format: Only users from a specific Microsoft Entra tenant (directory members with a work or school account or directory guests with a personal Microsoft account) can sign in to the application.",
|
||||
"admin.config.oauth.microsoft-tenant.description": "Tenant ID de la aplicación OAuth de Microsoft\ncomún: Los usuarios con una cuenta personal de Microsoft y una cuenta de trabajo o escuela de Microsoft Entra ID pueden iniciar sesión en la aplicación.\norganizaciones: Solo los usuarios con cuentas de trabajo o escuela de Microsoft Entra ID pueden iniciar sesión en la aplicación.\nconsumidores: Solo los usuarios con una cuenta personal de Microsoft pueden iniciar sesión en la aplicación.\nNombre de dominio del Tenant ID de Microsoft Entra o el Tenant ID en formato GUID: Solo los usuarios de un Tenant específico de Microsoft Entra (miembros del directorio con una cuenta de trabajo o escuela o invitados del directorio con una cuenta personal de Microsoft) pueden iniciar sesión en la aplicación.",
|
||||
"admin.config.oauth.microsoft-client-id": "ID del cliente de Microsoft",
|
||||
"admin.config.oauth.microsoft-client-id.description": "ID de cliente de la app OAuth de Microsoft",
|
||||
"admin.config.oauth.microsoft-client-secret": "Secreto del cliente de Microsoft",
|
||||
"admin.config.oauth.microsoft-client-secret.description": "Client secret of the Microsoft OAuth app",
|
||||
"admin.config.oauth.microsoft-client-secret.description": "Secreto del cliente de la aplicación OAuth de Microsoft",
|
||||
"admin.config.oauth.discord-enabled": "Discord",
|
||||
"admin.config.oauth.discord-enabled.description": "Si el inicio de sesión de Discord está habilitado",
|
||||
"admin.config.oauth.discord-limited-guild": "Discord limited server ID",
|
||||
"admin.config.oauth.discord-limited-users": "Usuarios limitados de Discord",
|
||||
"admin.config.oauth.discord-limited-users.description": "Limitar el inicio de sesión a usuarios específicos por su ID de Discord. Deja en blanco para desactivar.",
|
||||
"admin.config.oauth.discord-limited-guild": "ID de servidor limitado de Discord",
|
||||
"admin.config.oauth.discord-limited-guild.description": "Limitar el inicio de sesión a usuarios en un servidor específico. Déjelo en blanco para desactivarlo.",
|
||||
"admin.config.oauth.discord-client-id": "Discord Client ID",
|
||||
"admin.config.oauth.discord-client-id.description": "Client ID of the Discord OAuth app",
|
||||
"admin.config.oauth.discord-client-secret": "Discord Client secret",
|
||||
"admin.config.oauth.discord-client-secret.description": "Client secret of the Discord OAuth app",
|
||||
"admin.config.oauth.discord-client-id": "ID de cliente de Discord",
|
||||
"admin.config.oauth.discord-client-id.description": "ID de cliente de la aplicación OAuth de Discord",
|
||||
"admin.config.oauth.discord-client-secret": "Secreto de cliente de Discord",
|
||||
"admin.config.oauth.discord-client-secret.description": "Secreto del cliente de la aplicación OAuth de Discord",
|
||||
"admin.config.oauth.oidc-enabled": "Conexión OpenID",
|
||||
"admin.config.oauth.oidc-enabled.description": "Whether OpenID Connect login is enabled",
|
||||
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
|
||||
"admin.config.oauth.oidc-discovery-uri.description": "Discovery URI of the OpenID Connect OAuth app",
|
||||
"admin.config.oauth.oidc-username-claim": "OpenID Connect username claim",
|
||||
"admin.config.oauth.oidc-username-claim.description": "Username claim in OpenID Connect ID token. Leave it blank if you don't know what this config is.",
|
||||
"admin.config.oauth.oidc-role-path": "Path to roles in OpenID Connect token",
|
||||
"admin.config.oauth.oidc-role-path.description": "Debe ser una ruta JMES válida que haga referencia a un array de roles. " + "Managing access rights using OpenID Connect roles is only recommended if no other identity provider is configured and password login is disabled. " + "Déjalo en blanco si no sabe lo que es esta configuración.",
|
||||
"admin.config.oauth.oidc-role-general-access": "OpenID Connect role for general access",
|
||||
"admin.config.oauth.oidc-enabled.description": "Si el inicio de sesión de OpenID Connect está habilitado",
|
||||
"admin.config.oauth.oidc-discovery-uri": "URI de descubrimiento de OpenID Connect",
|
||||
"admin.config.oauth.oidc-discovery-uri.description": "URI de descubrimiento de la aplicación OAuth de OpenID Connect",
|
||||
"admin.config.oauth.oidc-sign-out": "Cerrar sesión de OpenID Connect",
|
||||
"admin.config.oauth.oidc-sign-out.description": "Si el botón \"Cerrar sesión\" cerrará la sesión del proveedor de OpenID Connect",
|
||||
"admin.config.oauth.oidc-scope": "Ámbito de OpenID Connect",
|
||||
"admin.config.oauth.oidc-scope.description": "Ámbitos que se deberían solicitar al proveedor OpenID Connect.",
|
||||
"admin.config.oauth.oidc-username-claim": "Reclamo de nombre de usuario de OpenID Connect",
|
||||
"admin.config.oauth.oidc-username-claim.description": "Reclamo de nombre de usuario en el token de OpenID Connect. Déjalo en blanco si no sabes qué es esta configuración.",
|
||||
"admin.config.oauth.oidc-role-path": "Ruta a los roles en el token de OpenID Connect",
|
||||
"admin.config.oauth.oidc-role-path.description": "Debe ser una ruta JMES válida que haga referencia a un array de roles. " + "Se recomienda gestionar los derechos de acceso utilizando roles de OpenID Connect solo si no se ha configurado ningún otro proveedor de identidad y si el inicio de sesión con contraseña está deshabilitado. " + "Déjalo en blanco si no sabe lo que es esta configuración.",
|
||||
"admin.config.oauth.oidc-role-general-access": "Rol de OpenID Connect para acceso general",
|
||||
"admin.config.oauth.oidc-role-general-access.description": "Rol requerido para acceso general. Debe estar presente en los roles de un usuario para que inicie sesión. " + "Déjalo en blanco si no sabe lo que es esta configuración.",
|
||||
"admin.config.oauth.oidc-role-admin-access": "OpenID Connect role for admin access",
|
||||
"admin.config.oauth.oidc-role-admin-access": "Rol de OpenID Connect para acceso de administrador",
|
||||
"admin.config.oauth.oidc-role-admin-access.description": "Rol requerido para el acceso administrativo. Debe estar presente en los roles de un usuario para acceder al panel de administración. " + "Déjalo en blanco si no sabe lo que es esta configuración.",
|
||||
"admin.config.oauth.oidc-client-id": "OpenID Connect Client ID",
|
||||
"admin.config.oauth.oidc-client-id.description": "Client ID of the OpenID Connect OAuth app",
|
||||
"admin.config.oauth.oidc-client-secret": "OpenID Connect Client secret",
|
||||
"admin.config.oauth.oidc-client-secret.description": "Client secret of the OpenID Connect OAuth app",
|
||||
"admin.config.oauth.oidc-client-id": "ID de Cliente OpenID Connect",
|
||||
"admin.config.oauth.oidc-client-id.description": "ID de cliente de la aplicación OAuth de OpenID Connect",
|
||||
"admin.config.oauth.oidc-client-secret": "Secreto de cliente de OpenID Connect",
|
||||
"admin.config.oauth.oidc-client-secret.description": "Secreto del cliente de la aplicación OAuth de OpenID Connect",
|
||||
"admin.config.category.ldap": "LDAP",
|
||||
"admin.config.ldap.enabled": "Enabled LDAP",
|
||||
"admin.config.ldap.enabled.description": "Use LDAP authentication for user login",
|
||||
"admin.config.ldap.url": "Server URL",
|
||||
"admin.config.ldap.url.description": "URL of the LDAP server",
|
||||
"admin.config.ldap.bind-dn": "Bind DN",
|
||||
"admin.config.ldap.bind-dn.description": "Default user which will be used to execute the user search",
|
||||
"admin.config.ldap.bind-password": "Bind password",
|
||||
"admin.config.ldap.bind-password.description": "Password for the user search user",
|
||||
"admin.config.ldap.search-base": "User base",
|
||||
"admin.config.ldap.search-base.description": "Base location, where the user search will be performed",
|
||||
"admin.config.ldap.search-query": "User query",
|
||||
"admin.config.ldap.search-query.description": "The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.",
|
||||
"admin.config.ldap.admin-groups": "Admin group",
|
||||
"admin.config.ldap.enabled": "Habilitar LDAP",
|
||||
"admin.config.ldap.enabled.description": "Usar autenticación LDAP para el inicio de sesión de usuarios",
|
||||
"admin.config.ldap.url": "URL del servidor",
|
||||
"admin.config.ldap.url.description": "URL del servidor LDAP",
|
||||
"admin.config.ldap.bind-dn": "Usuario LDAP",
|
||||
"admin.config.ldap.bind-dn.description": "Usuario predeterminado utilizado para realizar la búsqueda de usuarios",
|
||||
"admin.config.ldap.bind-password": "Contraseña LDAP",
|
||||
"admin.config.ldap.bind-password.description": "Contraseña utilizada para realizar la búsqueda de usuarios",
|
||||
"admin.config.ldap.search-base": "Base de usuarios",
|
||||
"admin.config.ldap.search-base.description": "Ubicación base, donde se llevará a cabo la búsqueda de usuarios",
|
||||
"admin.config.ldap.search-query": "Consulta de usuario",
|
||||
"admin.config.ldap.search-query.description": "La consulta de usuario se utilizará para buscar en la 'Base de usuarios' al usuario LDAP. Se puede usar %username% como marcador de posición para la entrada del usuario.",
|
||||
"admin.config.ldap.admin-groups": "Grupo de administración",
|
||||
"admin.config.ldap.admin-groups.description": "Grupo requerido para acceso administrativo.",
|
||||
"admin.config.ldap.field-name-member-of": "Atributo grupos de usuarios",
|
||||
"admin.config.ldap.field-name-member-of.description": "Nombre del atributo LDAP para los grupos de los que es miembro un usuario. Esto se utiliza al verificar el grupo de administración.",
|
||||
"admin.config.ldap.field-name-email": "Atributo correo electrónico del usuario",
|
||||
"admin.config.ldap.field-name-email.description": "Nombre del atributo LDAP para el correo electrónico de un usuario.",
|
||||
"admin.config.notify.success": "Configuración actualizada correctamente.",
|
||||
"admin.config.notify.logo-success": "Logo actualizado correctamente. Puede tardar unos minutos en actualizarse en el sitio web.",
|
||||
"admin.config.notify.no-changes": "No hay cambios que guardar.",
|
||||
"admin.config.category.s3": "S3",
|
||||
"admin.config.s3.enabled": "Habilitado",
|
||||
"admin.config.s3.enabled.description": "Si se debería utilizar S3 en lugar del sistema de archivos local para almacenar los archivos compartidos.",
|
||||
"admin.config.s3.endpoint": "Punto de acceso/endpoint",
|
||||
"admin.config.s3.endpoint.description": "La URL del bucket S3.",
|
||||
"admin.config.s3.region": "Región",
|
||||
"admin.config.s3.region.description": "La región del bucket S3.",
|
||||
"admin.config.s3.bucket-name": "Nombre del bucket",
|
||||
"admin.config.s3.bucket-name.description": "El nombre del bucket S3.",
|
||||
"admin.config.s3.bucket-path": "Ruta",
|
||||
"admin.config.s3.bucket-path.description": "La ruta predeterminada que se debe usar para almacenar los archivos en el bucket S3.",
|
||||
"admin.config.s3.key": "Clave",
|
||||
"admin.config.s3.key.description": "La clave que permite el acceso al bucket S3.",
|
||||
"admin.config.s3.secret": "Secreto",
|
||||
"admin.config.s3.secret.description": "El secreto que permite acceder al bucket S3.",
|
||||
"admin.config.s3.use-checksum": "Use checksum",
|
||||
"admin.config.s3.use-checksum.description": "Apagar para los backends que no soporten checksums (ej: B2).",
|
||||
"admin.config.category.legal": "Legal",
|
||||
"admin.config.legal.enabled": "Habilitar avisos legales",
|
||||
"admin.config.legal.enabled.description": "Mostrar un enlace al aviso legal y a la política de privacidad en el pie de página.",
|
||||
"admin.config.legal.imprint-text": "Texto del aviso legal",
|
||||
"admin.config.legal.imprint-text.description": "El texto que debería mostrarse en el aviso legal. Soporta Markdown. Dejar en blanco para enlazar a un enlace externo.",
|
||||
"admin.config.legal.imprint-url": "URL del aviso legal",
|
||||
"admin.config.legal.imprint-url.description": "Si ya tiene una página de aviso legal, puede enlazarlo aquí en lugar de utilizar el campo de texto.",
|
||||
"admin.config.legal.privacy-policy-text": "Texto de la política de privacidad",
|
||||
"admin.config.legal.privacy-policy-text.description": "El texto que debe mostrarse en la política de privacidad. Compatible con Markdown. Dejar en blanco para enlazar a una página de política de privacidad externa.",
|
||||
"admin.config.legal.privacy-policy-url": "URL de la política de privacidad",
|
||||
"admin.config.legal.privacy-policy-url.description": "Si ya tiene una página de política de privacidad puede enlazarla aquí en lugar de usar el campo de texto.",
|
||||
// 404
|
||||
"404.description": "Oops esta página no existe.",
|
||||
"404.button.home": "Regrésame al inicio",
|
||||
@@ -435,7 +505,7 @@ export default {
|
||||
"error.msg.not_linked": "Esta cuenta {0} aún no ha sido vinculada a ninguna cuenta.",
|
||||
"error.msg.unverified_account": "Esta cuenta {0} no está verificada, por favor inténtalo de nuevo después de la verificación.",
|
||||
"error.msg.user_not_allowed": "No tienes permitido iniciar sesion.",
|
||||
"error.msg.cannot_get_user_info": "No se puede obtener la información de usuario de la cuenta {0}.",
|
||||
"error.msg.cannot_get_user_info": "No se puede obtener tu información de usuario de esta cuenta {0}.",
|
||||
"error.param.provider_github": "GitHub",
|
||||
"error.param.provider_google": "Google",
|
||||
"error.param.provider_microsoft": "Microsoft",
|
||||
@@ -453,17 +523,21 @@ export default {
|
||||
"common.button.generate": "Generar",
|
||||
"common.button.done": "Listo",
|
||||
"common.text.link": "Enlace",
|
||||
"common.text.navigate-to-link": "Ir al enlace",
|
||||
"common.text.navigate-to-link": "Visitar enlace",
|
||||
"common.text.or": "o",
|
||||
"common.text.redirecting": "Redirigiendo...",
|
||||
"common.button.go-back": "Volver",
|
||||
"common.button.go-home": "Página de inicio",
|
||||
"common.notify.copied": "Tu enlace se ha copiado al portapapeles",
|
||||
"common.notify.copied-link": "Tu enlace se ha copiado al portapapeles",
|
||||
"common.success": "Éxito",
|
||||
"common.error": "Error",
|
||||
"common.error.unknown": "Ocurrió un error desconocido",
|
||||
"common.error.invalid-email": "Correo electrónico no válido",
|
||||
"common.error.too-short": "Debe tener al menos {length} caracteres",
|
||||
"common.error.too-long": "Debe tener como máximo {length} caracteres",
|
||||
"common.error.number-too-small": "Debe ser al menos {min}",
|
||||
"common.error.number-too-large": "Debe ser como máximo {max}",
|
||||
"common.error.exact-length": "Debe tener exactamente {length} caracteres",
|
||||
"common.error.invalid-number": "Debe ser un número",
|
||||
"common.error.field-required": "Este campo es requerido"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user