Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be66856034 | ||
|
|
80e3027107 | ||
|
|
362ca53efb | ||
|
|
f0eafb1162 | ||
|
|
ad5baa7d6f | ||
|
|
5840e30afb | ||
|
|
8f95606dd7 | ||
|
|
ca9bc7ae94 | ||
|
|
288a40e697 | ||
|
|
fa91e8c289 | ||
|
|
4ab5fe4c6f | ||
|
|
d87782d180 | ||
|
|
1c97c97fa1 | ||
|
|
2426166676 | ||
|
|
6f6c1c7c52 | ||
|
|
7b9cb2ecb9 | ||
|
|
fc029af654 | ||
|
|
110a69dc4b | ||
|
|
a61791fdb2 | ||
|
|
c01440f9d4 | ||
|
|
39584dd7a1 | ||
|
|
d9badeacba | ||
|
|
d4cde2ba9c | ||
|
|
824ef4528e | ||
|
|
feb06a85ae | ||
|
|
cfad645e33 | ||
|
|
675f8dec20 | ||
|
|
11e85b613c | ||
|
|
eb82833c7e | ||
|
|
399a3e09de | ||
|
|
1a735bda42 | ||
|
|
991196cc30 | ||
|
|
5f48903a73 | ||
|
|
5dfe1b758e | ||
|
|
d24e534828 | ||
|
|
80bdca138e | ||
|
|
0a2ca8c0b6 | ||
|
|
921a355ae0 | ||
|
|
eff7b24b7a | ||
|
|
c328620f3d | ||
|
|
a49b6d6573 | ||
|
|
baffd2efaf | ||
|
|
292693a1ed | ||
|
|
cdd01e695c | ||
|
|
614a9e057c | ||
|
|
24d90bb750 | ||
|
|
c2726e6b18 | ||
|
|
a746ce9169 | ||
|
|
c00b967179 | ||
|
|
f6e749ecf9 | ||
|
|
8f5e0686c8 | ||
|
|
b9f0d9279b | ||
|
|
cd69f0c82f | ||
|
|
884746f3a5 | ||
|
|
9c4d862297 | ||
|
|
cc87cf68b5 | ||
|
|
db1a0235ed | ||
|
|
2cd6a7cae0 | ||
|
|
bd80fdc960 | ||
|
|
7c5d57ddc1 | ||
|
|
5653ff18c8 | ||
|
|
83ea444675 | ||
|
|
966edfa85a | ||
|
|
30b9fc7765 | ||
|
|
95a60e9a96 | ||
|
|
9327331adc | ||
|
|
e8baa441cd | ||
|
|
61d142afbd | ||
|
|
46022e8711 | ||
|
|
e59f3c46fd | ||
|
|
7fcb3f2c31 | ||
|
|
a2243585a0 | ||
|
|
6231002043 | ||
|
|
33eb476009 | ||
|
|
ebb0a4c7e5 | ||
|
|
d010ed6bc5 | ||
|
|
1941e29175 | ||
|
|
6e8932ff52 | ||
|
|
f9bfae9b9d | ||
|
|
2fca928cf0 | ||
|
|
8f3a362a3a | ||
|
|
62a6189788 | ||
|
|
2a6d631719 | ||
|
|
4942591d7f | ||
|
|
1760c1b852 | ||
|
|
86d7ab9864 | ||
|
|
f201e4593a | ||
|
|
71129e556e | ||
|
|
0e7ebb79db | ||
|
|
727d9eb57c | ||
|
|
228ff75468 | ||
|
|
80018cac08 | ||
|
|
b2c19e7ed0 | ||
|
|
a64a80910d | ||
|
|
4eeeef3427 | ||
|
|
657586ddff | ||
|
|
797b287899 | ||
|
|
db2d95f2c5 | ||
|
|
16a8f259ab | ||
|
|
ee1cd8d08d | ||
|
|
e53046324c | ||
|
|
dd71977317 | ||
|
|
bee0da1454 | ||
|
|
b60feaa447 | ||
|
|
743e54614a | ||
|
|
bd96713af4 | ||
|
|
8f7f9b457a | ||
|
|
4c0d68eab8 | ||
|
|
8211407cf2 | ||
|
|
5fb6c7d36a | ||
|
|
30b1cf4493 | ||
|
|
f4789ac5fe | ||
|
|
5fd826ae71 | ||
|
|
22ce08f06c | ||
|
|
696c88f03f | ||
|
|
9d5d87e47b | ||
|
|
e32539e02c | ||
|
|
46ba931a6c | ||
|
|
4bac5143d6 | ||
|
|
bcb1674491 | ||
|
|
3cbd9677c2 | ||
|
|
b216ee1a80 | ||
|
|
32ec482449 | ||
|
|
5074748055 | ||
|
|
6fa11f834b | ||
|
|
25c7702127 | ||
|
|
b546556d08 | ||
|
|
379a85f6f6 | ||
|
|
92d2c14d6a | ||
|
|
a6c1f7d3f2 | ||
|
|
6fa6187cc4 | ||
|
|
848e7a1609 | ||
|
|
3e8c0a2896 | ||
|
|
11900b1500 | ||
|
|
85044fe3e0 | ||
|
|
c0499d40ea | ||
|
|
8ee44ffea1 | ||
|
|
5668a1411f | ||
|
|
6db60f111d | ||
|
|
3d695709d5 | ||
|
|
5c682addc9 | ||
|
|
24810cc818 | ||
|
|
8caeb11da5 | ||
|
|
5e07b446fc | ||
|
|
f29444a219 | ||
|
|
bce1c5d9b8 | ||
|
|
5b980fb382 | ||
|
|
e63e62d3e0 | ||
|
|
53f82792d8 | ||
|
|
760388307c | ||
|
|
eebd8500ee | ||
|
|
12112afa37 | ||
|
|
424fa581b9 | ||
|
|
cddb0a7e05 | ||
|
|
83ef0ee74e | ||
|
|
21b317e4f1 | ||
|
|
7d818206b0 | ||
|
|
0216c40bfe | ||
|
|
ecf80280f1 | ||
|
|
6e9568b4fb | ||
|
|
8d968e7d63 | ||
|
|
bb264f0b9b | ||
|
|
be4f770c35 | ||
|
|
17d955dd69 | ||
|
|
3f31e85c06 | ||
|
|
5a0c230ead | ||
|
|
e10350268d | ||
|
|
969a48bef2 | ||
|
|
8158934cb9 | ||
|
|
670f6a1974 | ||
|
|
dd1f791b3e | ||
|
|
91f25bc25d | ||
|
|
d8e9e2dac3 | ||
|
|
67a89a12ac | ||
|
|
faaa4033bd | ||
|
|
f3eedf6f30 | ||
|
|
29718d2a0c | ||
|
|
c7e6bd8b72 | ||
|
|
a67ff12786 | ||
|
|
c92e671622 | ||
|
|
2bd25353da | ||
|
|
e1bee19c18 | ||
|
|
0ec49af72a | ||
|
|
6d4eb836ab | ||
|
|
0c3cfecb1d | ||
|
|
f9186e7354 | ||
|
|
bc603a08cf | ||
|
|
da5c65d5a6 | ||
|
|
077aada056 | ||
|
|
4ed5f9872d | ||
|
|
2d6be20b30 | ||
|
|
be8f3bd98c | ||
|
|
a3a20bee4a | ||
|
|
09a1e75acf | ||
|
|
ce6bdd01ef | ||
|
|
a03a1d635f | ||
|
|
419b6d4891 | ||
|
|
b4840318d5 | ||
|
|
bf81f437d1 | ||
|
|
81a4d33df1 | ||
|
|
baab364b56 | ||
|
|
585ee041ec | ||
|
|
da3d4350cc | ||
|
|
df9390847f | ||
|
|
2a1acbfde8 | ||
|
|
16ef8507b0 | ||
|
|
df5d4e336c | ||
|
|
ad2015329b | ||
|
|
4cc4bed785 | ||
|
|
6c506e6907 | ||
|
|
977f9344c7 | ||
|
|
2adb2f3b68 | ||
|
|
40a6e9e69f | ||
|
|
e1d1a3fb9a | ||
|
|
75120c0a56 | ||
|
|
33f1ef3bb3 | ||
|
|
b6e742333c | ||
|
|
9f8c66fc20 | ||
|
|
3b93257890 | ||
|
|
1104e2a074 | ||
|
|
3e950652e8 | ||
|
|
98e40466a0 | ||
|
|
324d80a735 | ||
|
|
6770be63b6 | ||
|
|
89aa33ebd8 | ||
|
|
619c32f09d | ||
|
|
c471423775 | ||
|
|
c589b9b8b8 | ||
|
|
bae9ba0973 | ||
|
|
7bfc5c1d9b | ||
|
|
8bd3b0d731 | ||
|
|
06cd4de141 | ||
|
|
33eb2df023 | ||
|
|
540ecd4b60 | ||
|
|
237418f009 | ||
|
|
4447180e9e | ||
|
|
f111946592 | ||
|
|
475e404fae | ||
|
|
07ce5215d4 | ||
|
|
9f4895fd63 | ||
|
|
c18a9da0d3 | ||
|
|
8e3eb32631 | ||
|
|
f0598b3f59 | ||
|
|
011120fdf9 | ||
|
|
ff175e1edd | ||
|
|
3f56008c39 | ||
|
|
5df95bf7c1 | ||
|
|
23d52d0447 | ||
|
|
795a127998 | ||
|
|
fd73d383f9 | ||
|
|
ee285df7b5 | ||
|
|
7f7f8e642f | ||
|
|
d9997ff5e9 | ||
|
|
36adcb323a | ||
|
|
5302060f44 | ||
|
|
d19a46bee6 | ||
|
|
d504866aad | ||
|
|
601dae4909 | ||
|
|
08f2ad0521 | ||
|
|
3b356e33e7 |
@@ -1,37 +1,31 @@
|
||||
# Environnement
|
||||
NODE_ENV=production
|
||||
NODE_ENV=development
|
||||
|
||||
# Base de données
|
||||
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
|
||||
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
||||
|
||||
# Projet
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://app.portabase.io
|
||||
PROJECT_URL=http://localhost:8887
|
||||
PROJECT_SECRET=
|
||||
|
||||
# SMTP (email)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
#SMTP_SECURE="true"
|
||||
#Port 587 -> secure: false
|
||||
#Port 465 -> secure: true
|
||||
#By default : true
|
||||
|
||||
# Google
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
AUTH_GOOGLE_METHOD=
|
||||
|
||||
# S3
|
||||
S3_ENDPOINT=http://app.s3.portabase.io
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_BUCKET_NAME=portabase
|
||||
S3_PORT=9000
|
||||
S3_USE_SSL=true
|
||||
|
||||
# Storage Type (s3, local)
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
RETENTION_CRON="* * * * *"
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Please take a moment to review this guide. It will help you understand how to co
|
||||
|
||||
2. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/Soluce-Technologies/portabase
|
||||
git clone https://github.com/Portabase/portabase
|
||||
```
|
||||
|
||||
3. **Set up the development environment**
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: portabase
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
We take security seriously and aim to support the following versions of the project with security updates:
|
||||
|
||||
| Version | Supported |
|
||||
|-----------|--------------------|
|
||||
| 1.x | ✅ Fully Supported |
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| Latest | ✅ Fully Supported |
|
||||
|
||||
---
|
||||
|
||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,122 @@
|
||||
name: Docker Publish
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_name:
|
||||
required: false
|
||||
type: string
|
||||
default: 'portabase/portabase'
|
||||
add_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
target:
|
||||
required: false
|
||||
type: string
|
||||
default: 'prod'
|
||||
dockerfile:
|
||||
required: false
|
||||
type: string
|
||||
default: './docker/dockerfile/Dockerfile'
|
||||
secrets:
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_PASSWORD:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
name: Build and push Docker images
|
||||
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ linux/amd64, linux/arm64 ]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Docker
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Set image tag
|
||||
id: set-tags
|
||||
run: |
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
if [ "${{ matrix.platform }}" = "linux/amd64" ]; then
|
||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-amd64"
|
||||
else
|
||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-arm64"
|
||||
fi
|
||||
echo "image=$IMAGE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.image }}
|
||||
target: ${{ inputs.target }}
|
||||
|
||||
- name: Prepare artifact name
|
||||
id: artifact
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
|
||||
echo "${{ steps.set-tags.outputs.image }}" > image.txt
|
||||
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
with:
|
||||
name: image-${{ steps.artifact.outputs.safe_platform }}
|
||||
path: image.txt
|
||||
if-no-files-found: warn
|
||||
compression-level: 6
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
|
||||
create-manifest:
|
||||
name: Create multi-arch Docker manifest (Portabase)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
with:
|
||||
name: image-linux-amd64
|
||||
path: /tmp/digests/amd64
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
with:
|
||||
name: image-linux-arm64
|
||||
path: /tmp/digests/arm64
|
||||
|
||||
- name: Login to Docker
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
MANIFEST_IMAGE="${{ inputs.image_name }}:$REF_NAME"
|
||||
|
||||
docker buildx imagetools create $DOCKER_IMAGES -t $MANIFEST_IMAGE
|
||||
docker buildx imagetools inspect $MANIFEST_IMAGE
|
||||
|
||||
if [ "${{ inputs.add_latest }}" = "true" ]; then
|
||||
docker buildx imagetools create $DOCKER_IMAGES -t ${{ inputs.image_name }}:latest
|
||||
docker buildx imagetools inspect ${{ inputs.image_name }}:latest
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
name: GitHub Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
prerelease:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
make_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
discord_title:
|
||||
required: true
|
||||
type: string
|
||||
discord_color:
|
||||
required: true
|
||||
type: number
|
||||
discord_footer:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
DISCORD_WEBHOOK:
|
||||
required: true
|
||||
GH_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v5
|
||||
with:
|
||||
mode: "COMMIT"
|
||||
configurationJson: |
|
||||
{
|
||||
"template": "#{{CHANGELOG}}",
|
||||
"categories": [
|
||||
{
|
||||
"title": "## Feature",
|
||||
"labels": ["feat", "feature"]
|
||||
},
|
||||
{
|
||||
"title": "## Fix",
|
||||
"labels": ["fix", "bug"]
|
||||
},
|
||||
{
|
||||
"title": "## Other",
|
||||
"labels": []
|
||||
}
|
||||
],
|
||||
"label_extractor": [
|
||||
{
|
||||
"pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)",
|
||||
"on_property": "title",
|
||||
"target": "$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: false
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
make_latest: ${{ inputs.make_latest }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Send Discord Notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
|
||||
|
||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
|
||||
|
||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
||||
|
||||
AUTHOR_NAME="Portabase"
|
||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$RELEASE_TITLE" \
|
||||
--arg description "$RELEASE_BODY" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg author "$AUTHOR_NAME" \
|
||||
--arg icon "$AUTHOR_ICON" \
|
||||
--arg discord_title "${{ inputs.discord_title }}" \
|
||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
||||
--argjson discord_color ${{ inputs.discord_color }} \
|
||||
'{
|
||||
content: $discord_title,
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
description: $description,
|
||||
color: $discord_color,
|
||||
author: {
|
||||
name: $author,
|
||||
icon_url: $icon
|
||||
},
|
||||
footer: {
|
||||
text: $discord_footer
|
||||
}
|
||||
}]
|
||||
}'
|
||||
)
|
||||
|
||||
curl -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$DISCORD_WEBHOOK"
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Publish Docker image for release candidate
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*-rc*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
docker_publish:
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
add_latest: false
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
github_release:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
with:
|
||||
prerelease: true
|
||||
make_latest: false
|
||||
discord_title: "||@release-dashboard|| New release candidate published"
|
||||
discord_color: 16776960
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,50 +1,33 @@
|
||||
name: Publish Docker image for release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
- '!*-*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
docker_publish:
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
add_latest: true
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: solucetechnologies/portabase
|
||||
|
||||
- name: Set tags
|
||||
id: set-tags
|
||||
run: |
|
||||
echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
TAGS="solucetechnologies/portabase:${GITHUB_REF#refs/tags/}"
|
||||
if [ "${{ github.event.release.target_commitish }}" = "main" ]; then
|
||||
TAGS="$TAGS,solucetechnologies/portabase:latest"
|
||||
fi
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/dockerfile/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.tags }}
|
||||
target: prod
|
||||
github_release:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
with:
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
discord_title: "||@release-dashboard|| New release published"
|
||||
discord_color: 5814783
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -24,5 +24,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history for gitleaks
|
||||
- uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||
with:
|
||||
config-path: .gitleaks.toml # Optional, if you have a custom config
|
||||
@@ -1,5 +1,6 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
.idea
|
||||
.vscode
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
@@ -39,5 +40,8 @@ public/uploads/*
|
||||
!public/uploads/
|
||||
|
||||
private/uploads/*
|
||||
private/keys/*
|
||||
|
||||
/.env
|
||||
|
||||
certificates
|
||||
@@ -1,8 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/portabase.iml" filepath="$PROJECT_DIR$/.idea/portabase.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"WillLuke.nextjs.hasPrompted": true
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
cff-version: 1.2.0
|
||||
title: Portabase
|
||||
message: "If you use this software, please cite it as below."
|
||||
type: software
|
||||
authors:
|
||||
- family-names: Gauthereau
|
||||
given-names: Charles
|
||||
- family-names: Larcher
|
||||
given-names: Killian
|
||||
repository-code: https://github.com/Portabase/portabase
|
||||
url: https://portabase.io
|
||||
abstract: "Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently."
|
||||
keywords:
|
||||
- docker
|
||||
- kubernetes
|
||||
- backups
|
||||
- postgresql
|
||||
- mysql
|
||||
- mariadb
|
||||
- devops
|
||||
- database
|
||||
- monitoring
|
||||
- s3
|
||||
- self-hosted
|
||||
- system-administration
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.2.6-rc.1
|
||||
date-released: "2026-02-13"
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2024 Soluce Technologies
|
||||
Copyright 2024 Portabase
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,291 +1,74 @@
|
||||
<br />
|
||||
<div align="center">
|
||||
<a href="https://portabase.io">
|
||||
<img src="/public/images/logo.png" alt="Logo" width="80" height="80">
|
||||
<img src="/.github/assets/logo.png" alt="Logo" width="80" height="80">
|
||||
</a>
|
||||
|
||||
<h3 align="center">Portabase</h3>
|
||||
<p>
|
||||
Free, open-source, and self-hosted solution for automated backup and restoration of your database instances.
|
||||
|
||||
<p align="center" style="margin-top: 20px; font-style: italic;">
|
||||
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent">Portabase agents</a> for managing operations securely and efficiently.</i>
|
||||
</p>
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
||||
[](https://github.com/RostislavDugin/postgresus)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://www.buymeacoffee.com/portabase)
|
||||
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://github.com/RostislavDugin/postgresus)
|
||||
[](https://www.mongodb.com/)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
|
||||
|
||||
[![NextJS][NextJS]][NextJS-url]
|
||||
[![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||
[![Drizzle][Drizzle]][Drizzle-url]
|
||||
[![ShadcnUI][ShadcnUI]][ShadcnUI-url]
|
||||
[![Docker][Docker]][Docker-url]
|
||||
|
||||
<p>
|
||||
<strong>
|
||||
<a href="https://portabase.io">Documentation</a> •
|
||||
<a href="https://www.youtube.com/watch?v=D9uFrGxLc4s">Demo</a> •
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#contributing">Contributing</a> •
|
||||
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
||||
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
<a href="https://portabase.io">Website</a> •
|
||||
<a href="https://portabase.io/docs">Documentation</a> •
|
||||
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
|
||||
<a href="https://portabase.io/docs/dashboard/setup">Installation</a> •
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## 📚 Table of Contents
|
||||
## Installation
|
||||
|
||||
- [About The Project](#-about-the-project)
|
||||
- [Getting Started](#-getting-started)
|
||||
- [Usage](#-usage)
|
||||
- [Roadmap](#-roadmap)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
- [Contact](#-contact)
|
||||
- [Acknowledgments](#-acknowledgments)
|
||||
You have 4 ways to install Portabase:
|
||||
|
||||
---
|
||||
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
||||
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker)
|
||||
- Kubernetes with Helm (soon)
|
||||
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
|
||||
|
||||
## ✨ About The Project
|
||||
|
||||
**Portabase** is a server dashboard tool designed to simplify the backup and restoration of your database instances. It
|
||||
integrates seamlessly with Portabase agents for managing operations securely and efficiently.
|
||||
|
||||
GitHub Repository: [Portabase](https://github.com/Soluce-Technologies/portabase)
|
||||
|
||||
### 🔧 Built With
|
||||
|
||||
- [![NextJS][NextJS]][NextJS-url] (v16 with App Router)
|
||||
- [![Drizzle][Drizzle]][Drizzle-url]
|
||||
- [![ShadcnUI][ShadcnUI]][ShadcnUI-url]
|
||||
- [![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||
- [![Docker][Docker]][Docker-url]
|
||||
**Ensure Docker is installed on your machine before getting started.**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
## Contributors
|
||||
|
||||
### Installation
|
||||
[](https://github.com/Portabase/portabase/graphs/contributors)
|
||||
|
||||
Ensure Docker is installed on your machine before getting started.
|
||||
[](https://www.buymeacoffee.com/portabase)
|
||||
|
||||
### Option 1: Docker Compose Setup
|
||||
|
||||
Create a `docker-compose.yml` file with the following configuration:
|
||||
|
||||
```yaml
|
||||
name: portabase
|
||||
|
||||
services:
|
||||
|
||||
portabase:
|
||||
image: solucetechnologies/portabase:latest
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
- TIME_ZONE="Europe/Paris"
|
||||
volumes:
|
||||
- portabase-private:/app/private
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
container_name: portabase-app
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=<your_database>
|
||||
- POSTGRES_USER=<database_user>
|
||||
- POSTGRES_PASSWORD=<database_password>
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U <database_user> -d <your_database>" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
portabase-private:
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If you use reverse proxy like
|
||||
Traefik : [Check this link](https://portabase.io/docs/portabase/advanced-topics/reverse-proxy)
|
||||
|
||||
### Option 2: Locally (Development)
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Soluce-Technologies/portabase
|
||||
cd portabase
|
||||
```
|
||||
2. Start the development environment:
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Usage
|
||||
|
||||
Portabase provides a web dashboard to manage your database instances and backups.
|
||||
It currently supports:
|
||||
|
||||
- **PostgreSQL**
|
||||
- **MySQL**
|
||||
|
||||
### Process
|
||||
|
||||
1. **Access the dashboard** – Open `http://localhost:8887` in your browser.
|
||||
2. **Sign up** – Register the first user, who will automatically have the **Admin** role in the default workspace.
|
||||
3. **Add your first agent** – Follow [this guide](https://github.com/Soluce-Technologies/agent-portabase) for setup
|
||||
instructions.
|
||||
4. **Create organizations and projects** – Link your databases to projects to enable backups and restores.
|
||||
5. **Configure backup policies** – Define schedules (hourly, daily, weekly, or monthly) and retention rules.
|
||||
6. **Choose a storage provider** – Select where backups will be stored (local, S3, etc.).
|
||||
7. **Save and start** – Portabase validates your configuration and starts automated backups based on your defined
|
||||
policies.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- [ ] Add changelog
|
||||
- [ ] Enhance documentation
|
||||
- [ ] Implement testing procedures
|
||||
- [ ] Include release file
|
||||
- [ ] Improve security
|
||||
- [ ] Enhance UX/UI
|
||||
- [ ] Improve workspace management system
|
||||
- [x] Migrate to Drizzle ORM
|
||||
- [ ] Add multi-language support
|
||||
- [ ] Extend multi-database support:
|
||||
- [x] PostgreSQL
|
||||
- [ ] MongoDB
|
||||
- [x] MySQL
|
||||
- [x] MariaDB
|
||||
|
||||
Check out [open issues](https://github.com/Soluce-Technologies/portabase/issues) for more.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome and appreciated! Here's how to get started:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a new branch:
|
||||
```bash
|
||||
git checkout -b feature/YourFeature
|
||||
```
|
||||
3. Commit your changes:
|
||||
```bash
|
||||
git commit -m "Add YourFeature"
|
||||
```
|
||||
4. Push to the branch:
|
||||
```bash
|
||||
git push origin feature/YourFeature
|
||||
```
|
||||
5. Open a pull request
|
||||
|
||||
Give the project a ⭐ if you like it!
|
||||
|
||||
### Top Contributors
|
||||
|
||||
[](https://github.com/Soluce-Technologies/portabase/graphs/contributors)
|
||||
|
||||
---
|
||||
|
||||
## Developer Notes
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```yml
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
|
||||
|
||||
# Project Info
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://app.portabase.io
|
||||
PROJECT_SECRET=
|
||||
|
||||
# SMTP (Email)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
|
||||
# Google OAuth
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
AUTH_GOOGLE_METHOD=
|
||||
|
||||
# S3/MinIO Configuration
|
||||
S3_ENDPOINT=http://app.s3.portabase.io
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_BUCKET_NAME=portabase
|
||||
S3_PORT=9000
|
||||
S3_USE_SSL=true
|
||||
|
||||
# Storage Backend: 'local' or 's3'
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
```
|
||||
|
||||
To get more information about env variables, check
|
||||
that [link](https://portabase.io/docs/portabase/advanced-topics/environment)
|
||||
|
||||
### Semantic Versioning
|
||||
|
||||
Use the following format for Docker image versioning:
|
||||
|
||||
```bash
|
||||
major.minor.patch-rc.release
|
||||
# Example: 1.0.0-rc.1
|
||||
|
||||
major.minor.patch-rc.release-tag
|
||||
# Example: 1.0.0-rc.1-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
## License
|
||||
|
||||
Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
|
||||
---
|
||||
|
||||
## 📬 Contact
|
||||
|
||||
- Killian Larcher - killian.larcher@soluce-technologies.com
|
||||
- Charles Gauthereau - charles.gauthereau@soluce-technologies.com
|
||||
- Project Link: [Portabase GitHub](https://github.com/Soluce-Technologies/portabase)
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Thanks to all contributors and the open-source community!
|
||||
|
||||
[Docker]: https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=for-the-badge
|
||||
|
||||
@@ -304,5 +87,6 @@ Thanks to all contributors and the open-source community!
|
||||
[Drizzle-url]: https://orm.drizzle.team/
|
||||
|
||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ForgotPasswordForm } from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {ForgotPasswordForm} from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="w-full">
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Reset password</h1>
|
||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a
|
||||
link to reset your password.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ForgotPasswordForm />
|
||||
<ForgotPasswordForm/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { GuardForm } from "@/components/wrappers/auth/guard/guard-form";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {GuardForm} from "@/components/wrappers/auth/guard/guard-form";
|
||||
import {cookies} from "next/headers";
|
||||
import {redirect} from "next/navigation";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export default async function GuardPage() {
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("better-auth.two_factor")?.value;
|
||||
|
||||
const token = cookieStore.get("better-auth.two_factor")?.value || cookieStore.get("__Secure-better-auth.two_factor")?.value;
|
||||
|
||||
if (!token) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Two-factor verification</h1>
|
||||
<p className="text-balance text-muted-foreground">Please enter the verification code generated by your authentication app.</p>
|
||||
<p className="text-balance text-muted-foreground">Please enter the verification code generated
|
||||
by your authentication app.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GuardForm />
|
||||
<GuardForm/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
|
||||
import {env} from "@/env.mjs";
|
||||
import {Heart} from "lucide-react";
|
||||
|
||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||
|
||||
@@ -12,13 +14,18 @@ export default async function Layout({children}: { children: React.ReactNode })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<AuthLogoSection/>
|
||||
<div>{children}</div>
|
||||
<div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<AuthLogoSection/>
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
|
||||
Powered by <span className="font-medium">Soluce Technologies</span>
|
||||
<footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
|
||||
<p className="flex items-center justify-center gap-1">
|
||||
Made with <Heart className="size-3 fill-red-500 text-red-500" /> by <span className="font-medium text-foreground">Portabase</span>
|
||||
</p>
|
||||
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,9 +3,10 @@ import {Metadata} from "next";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
||||
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
@@ -15,7 +16,7 @@ export default async function SignInPage() {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="w-full">
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Login</h1>
|
||||
@@ -23,18 +24,16 @@ export default async function SignInPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LoginForm />
|
||||
<LoginForm/>
|
||||
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual).length > 0 && (
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
|
||||
<>
|
||||
|
||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||
<Separator/>
|
||||
<div className="px-2 text-center bg-card text-sm">OR</div>
|
||||
<div className="px-2 text-center text-sm">OR</div>
|
||||
<Separator/>
|
||||
</div>
|
||||
|
||||
<SocialAuthButtons />
|
||||
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -45,11 +44,9 @@ export default async function SignInPage() {
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,60 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Metadata} from "next";
|
||||
import {ResetPasswordSection} from "@/components/wrappers/auth/reset-password/reset-password-section";
|
||||
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Reset Password",
|
||||
};
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
||||
|
||||
const { token } = await props.searchParams;
|
||||
|
||||
if (!token) {
|
||||
return redirect(`/login?error=invalid_or_expired_token`);
|
||||
}
|
||||
|
||||
const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${token}`);
|
||||
|
||||
if (!verification || verification.expiresAt < new Date()) {
|
||||
return redirect(`/login?error=invalid_or_expired_token`);
|
||||
}
|
||||
|
||||
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<ResetPasswordSection/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
|
||||
<p className="text-sm text-muted-foreground text-balance">Please enter your new password below.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<Avatar className="relative flex h-16 w-16 shrink-0 overflow-hidden rounded-full border">
|
||||
<AvatarImage src={user!.image ?? ""} alt={user!.name} className="aspect-square h-full w-full object-cover" />
|
||||
<AvatarFallback className="flex h-full w-full items-center justify-center rounded-full bg-muted text-2xl font-medium text-muted-foreground">
|
||||
{user!.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="font-semibold text-lg">{user!.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{user!.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<ResetPasswordForm />
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {notFound} from "next/navigation";
|
||||
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||
});
|
||||
|
||||
const storageChannels = await db.query.storageChannel.findMany({
|
||||
with: {
|
||||
organizations: true
|
||||
},
|
||||
where: isNull(drizzleDb.schemas.storageChannel.organizationId),
|
||||
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
|
||||
}) as StorageChannelWith[]
|
||||
|
||||
if (!settings || !storageChannels ) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">System settings</PageTitle>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<SettingsTabs storageChannels={storageChannels} settings={settings} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/users/columns-users";
|
||||
import {isNull} from "drizzle-orm";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
|
||||
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
@@ -11,7 +11,14 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
accounts: true
|
||||
}
|
||||
},
|
||||
orderBy: (fields) => desc(fields.createdAt),
|
||||
|
||||
});
|
||||
const organizations = await db.query.organization.findMany({
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -19,14 +26,16 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">Active users</PageTitle>
|
||||
<PageActions>
|
||||
<AdminUserAddModal organizations={organizations}/>
|
||||
</PageActions>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<DataTable
|
||||
enableSelect={false}
|
||||
columns={usersColumnsAdmin}
|
||||
data={users}/>
|
||||
<AdminUserList users={users}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
|
||||
export default async function RoutePage(
|
||||
props: PageParams<{
|
||||
agentId: string;
|
||||
}>
|
||||
) {
|
||||
const { agentId } = await props.params;
|
||||
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: (fields, { eq }) => eq(fields.id, agentId),
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Edit {agent.name}</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<AgentForm defaultValues={agent} agentId={agent.id} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
||||
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {notFound} from "next/navigation";
|
||||
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {Server} from "lucide-react";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
||||
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
||||
import {AgentType} from "@/features/agents/agents.schema";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||
|
||||
@@ -34,62 +28,33 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
}
|
||||
|
||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`/dashboard/agents/${agent.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
<PageActions className="justify-between">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</PageActions>
|
||||
</div>
|
||||
|
||||
{agent.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Databases</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{agent.databases.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
|
||||
<p className="text-xs text-muted-foreground">Last contact with agent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
<Card className="w-full sm:w-auto flex-1 ">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
Edge Key
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AgentCardKey
|
||||
edgeKey={edgeKey}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardsWithPagination cardsPerPage={4} numberOfColumns={2} data={agent.databases}
|
||||
cardItem={DatabaseCard}/>
|
||||
<AgentContentPage
|
||||
agent={agent}
|
||||
edgeKey={edgeKey}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {AgentForm} from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
||||
import {Metadata} from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Agent",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Create new agent</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<AgentForm/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq, not} from "drizzle-orm";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {desc, eq, not} from "drizzle-orm";
|
||||
import {Metadata} from "next";
|
||||
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Agents",
|
||||
@@ -21,12 +19,10 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
|
||||
with: {
|
||||
databases: true
|
||||
}
|
||||
},
|
||||
orderBy: (fields) => desc(fields.lastContact),
|
||||
});
|
||||
|
||||
console.log(agents);
|
||||
|
||||
|
||||
if (!agents) {
|
||||
notFound();
|
||||
}
|
||||
@@ -37,9 +33,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageTitle>Agents</PageTitle>
|
||||
{agents.length > 0 && (
|
||||
<PageActions>
|
||||
<Link href={"/dashboard/agents/new"}>
|
||||
<Button>+ Create Agent</Button>
|
||||
</Link>
|
||||
<AgentDialog typeTrigger={"create"} />
|
||||
</PageActions>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -47,12 +41,9 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
{agents.length > 0 ? (
|
||||
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
url={"/dashboard/agents/new"}
|
||||
text={"Create new Agent"}
|
||||
/>
|
||||
<AgentDialog typeTrigger="empty"/>
|
||||
)}
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Metadata} from "next";
|
||||
import {
|
||||
NotificationChannelsSection
|
||||
} from "@/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section";
|
||||
import {db} from "@/db";
|
||||
import {notificationChannel, NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
|
||||
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
|
||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Notification Channels",
|
||||
@@ -19,6 +18,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
with: {
|
||||
organizations: true
|
||||
},
|
||||
where: isNull(drizzleDb.schemas.notificationChannel.organizationId),
|
||||
orderBy: desc(notificationChannel.createdAt)
|
||||
}) as NotificationChannelWith[]
|
||||
|
||||
@@ -35,11 +35,14 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageHeader>
|
||||
<PageTitle>Notification channels</PageTitle>
|
||||
<PageActions>
|
||||
<NotifierAddEditModal adminView={false}/>
|
||||
{/*<NotifierAddEditModal adminView={false}/>*/}
|
||||
<ChannelAddEditModal kind="notification" adminView={false}/>
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>
|
||||
{/*<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>*/}
|
||||
<ChannelsSection kind="notification" organizations={organizations}
|
||||
channels={notificationChannels}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Metadata} from "next";
|
||||
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
|
||||
import {db} from "@/db";
|
||||
import {desc, eq, isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Storage Channels",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const storageChannels = await db.query.storageChannel.findMany({
|
||||
with: {
|
||||
organizations: true
|
||||
},
|
||||
where: isNull(drizzleDb.schemas.storageChannel.organizationId),
|
||||
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
|
||||
}) as StorageChannelWith[]
|
||||
|
||||
const organizations = await db.query.organization.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Storage channels</PageTitle>
|
||||
<PageActions>
|
||||
<ChannelAddEditModal kind={"storage"} adminView={false}/>
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<ChannelsSection defaultStorageChannelId={settings?.defaultStorageChannelId} kind={"storage"} organizations={organizations} channels={storageChannels}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
// import {RestoreForm} from "@/components/wrappers/dashboard/database/restore-form";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
|
||||
export default async function RoutePage(
|
||||
props: PageParams<{
|
||||
databaseId: string;
|
||||
}>
|
||||
) {
|
||||
const {databaseId} = await props.params;
|
||||
|
||||
const user = await currentUser();
|
||||
if (!user) notFound();
|
||||
|
||||
const [dbToRestore] = await db.select().from(drizzleDb.schemas.database).where(eq(drizzleDb.schemas.database.id, databaseId));
|
||||
|
||||
if (!dbToRestore) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const dbsOfSameType = await db.select().from(drizzleDb.schemas.database).where(eq(drizzleDb.schemas.database.dbms!, dbToRestore.dbms!));
|
||||
const successfulBackups = await db.select().from(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.status, "success"));
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Restore {dbToRestore.name}</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
{/*<RestoreForm databaseToRestore={dbToRestore} databases={dbsOfSameType} backups={successfulBackups}/>*/}
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {notFound, redirect} from "next/navigation";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
import {Page} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {eq, and, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {getOrganizationProjectDatabases} from "@/lib/services";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string;
|
||||
@@ -38,7 +32,8 @@ export default async function RoutePage(props: PageParams<{
|
||||
with: {
|
||||
project: true,
|
||||
retentionPolicy: true,
|
||||
alertPolicies: true
|
||||
alertPolicies: true,
|
||||
storagePolicies: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,6 +45,11 @@ export default async function RoutePage(props: PageParams<{
|
||||
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
|
||||
with: {
|
||||
restorations: true,
|
||||
storages: {
|
||||
with: {
|
||||
storageChannel: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
||||
});
|
||||
@@ -59,7 +59,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
orderBy: (r, {desc}) => [desc(r.createdAt)],
|
||||
});
|
||||
|
||||
const isAlreadyBackup = backups.some((b) => b.status === "waiting");
|
||||
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
|
||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
||||
|
||||
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
|
||||
@@ -83,8 +83,6 @@ export default async function RoutePage(props: PageParams<{
|
||||
notFound();
|
||||
}
|
||||
|
||||
const organizationChannels = await getOrganizationChannels(organization.id);
|
||||
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
|
||||
|
||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||
|
||||
@@ -92,43 +90,22 @@ export default async function RoutePage(props: PageParams<{
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(dbItem.name)}
|
||||
</div>
|
||||
{!isMember && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Do not delete*/}
|
||||
{/*<EditButton/>*/}
|
||||
<RetentionPolicySheet database={dbItem}/>
|
||||
<CronButton database={dbItem}/>
|
||||
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
|
||||
organizationId={organization.id}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{dbItem.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
||||
totalBackups={totalBackups}/>
|
||||
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
backups={backups}
|
||||
restorations={restorations}/>
|
||||
</PageContent>
|
||||
<BackupModalProvider>
|
||||
<DatabaseContent
|
||||
activeMember={activeMember}
|
||||
settings={settings}
|
||||
database={dbItem}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
restorations={restorations}
|
||||
backups={backups}
|
||||
totalBackups={totalBackups}
|
||||
availableBackups={availableBackups}
|
||||
successRate={successRate}
|
||||
organizationId={organization.id}
|
||||
activeOrganizationChannels={[]}
|
||||
activeOrganizationStorageChannels={[]}
|
||||
/>
|
||||
</BackupModalProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import {notFound} from "next/navigation";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ projectId: string }>) {
|
||||
const {projectId} = await props.params;
|
||||
|
||||
const proj = await db.query.project.findFirst({
|
||||
where: eq(drizzleDb.schemas.project.id, projectId),
|
||||
with: {
|
||||
databases: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proj) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, "default"),
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const availableDatabases = (
|
||||
await db.query.database.findMany({
|
||||
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
|
||||
with: {
|
||||
agent: true,
|
||||
project: true,
|
||||
backups: true,
|
||||
restorations: true,
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
)
|
||||
// .filter((db) => db.project !== null) as DatabaseWith[];
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Edit {proj.name}</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<ProjectForm
|
||||
organization={org}
|
||||
databases={availableDatabases as DatabaseWith[]}
|
||||
defaultValues={{...proj, databases: proj.databases.map((db) => db.id)}}
|
||||
projectId={proj.id}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import {Page, PageContent, PageTitle} from "@/features/layout/page";
|
||||
import {
|
||||
ButtonDeleteProject
|
||||
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
||||
@@ -15,6 +12,11 @@ import {eq} from "drizzle-orm";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {ProjectDialog} from "@/features/projects/components/project.dialog";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import {ProjectWith} from "@/db/schema/06_project";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string
|
||||
@@ -23,6 +25,10 @@ export default async function RoutePage(props: PageParams<{
|
||||
projectId
|
||||
} = await props.params;
|
||||
|
||||
if (!isUuidv4(projectId)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const organization = await getOrganization({});
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
@@ -50,27 +56,45 @@ export default async function RoutePage(props: PageParams<{
|
||||
redirect("/dashboard/projects");
|
||||
}
|
||||
|
||||
const availableDatabases = (
|
||||
await db.query.database.findMany({
|
||||
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
|
||||
with: {
|
||||
agent: true,
|
||||
project: true,
|
||||
backups: true,
|
||||
restorations: true,
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
) as DatabaseWith[];
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
{capitalizeFirstLetter(proj.name)}
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(proj.name)}
|
||||
</div>
|
||||
{!isMember && (
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`/dashboard/projects/${proj.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProjectDialog
|
||||
databases={availableDatabases}
|
||||
organization={org}
|
||||
project={proj as ProjectWith}
|
||||
isEdit={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
{!isMember && (
|
||||
<PageActions className="justify-between">
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||
</PageActions>
|
||||
)}
|
||||
</div>
|
||||
<PageDescription>The list of associated databases</PageDescription>
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
{proj.databases.length > 0 ? (
|
||||
<CardsWithPagination
|
||||
@@ -78,8 +102,8 @@ export default async function RoutePage(props: PageParams<{
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
cardsPerPage={4}
|
||||
numberOfColumns={1}
|
||||
cardsPerPage={6}
|
||||
numberOfColumns={3}
|
||||
extendedProps={proj}
|
||||
/>
|
||||
) : (
|
||||
@@ -91,4 +115,4 @@ export default async function RoutePage(props: PageParams<{
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const organization = await getOrganization({});
|
||||
|
||||
if (!organization) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const availableDatabases = (
|
||||
await db.query.database.findMany({
|
||||
where: (db, {isNull}) => isNull(db.projectId),
|
||||
with: {
|
||||
agent: true,
|
||||
project: true,
|
||||
backups: true,
|
||||
restorations: true,
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
).filter((db) => db.project == null) as DatabaseWith[];
|
||||
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
|
||||
});
|
||||
|
||||
if (!org) notFound();
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Create new project</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<ProjectForm databases={availableDatabases} organization={org}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import {PageParams} from "@/types/next";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
|
||||
import {db} from "@/db";
|
||||
@@ -10,6 +7,8 @@ import {notFound} from "next/navigation";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {Metadata} from "next";
|
||||
import {ProjectDialog} from "@/features/projects/components/project.dialog";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Projects",
|
||||
@@ -36,6 +35,19 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
});
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
const availableDatabases = (
|
||||
await db.query.database.findMany({
|
||||
where: (db, {isNull}) => isNull(db.projectId),
|
||||
with: {
|
||||
agent: true,
|
||||
project: true,
|
||||
backups: true,
|
||||
restorations: true,
|
||||
},
|
||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||
})
|
||||
).filter((db) => db.project == null) as DatabaseWith[];
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -43,9 +55,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageTitle>Projects</PageTitle>
|
||||
{(projects.length > 0 && !isMember) && (
|
||||
<PageActions>
|
||||
<Link href={`/dashboard/projects/new`}>
|
||||
<Button>+ Create Project</Button>
|
||||
</Link>
|
||||
<ProjectDialog databases={availableDatabases} organization={organization}/>
|
||||
</PageActions>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -56,15 +66,15 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
organizationSlug={organization.slug}
|
||||
data={projects}
|
||||
cardItem={ProjectCard}
|
||||
cardsPerPage={4}
|
||||
numberOfColumns={1}
|
||||
cardsPerPage={9}
|
||||
numberOfColumns={3}
|
||||
/>
|
||||
) : isMember ? (
|
||||
<EmptyStatePlaceholder text="No project available"/>
|
||||
) : (
|
||||
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/>
|
||||
<ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
|
||||
)}
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/organization-form/organization-form";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {db} from "@/db";
|
||||
import {isNull} from "drizzle-orm";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
|
||||
}>) {
|
||||
const user = await currentUser();
|
||||
|
||||
const organization = await getOrganization({});
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt)
|
||||
});
|
||||
|
||||
if (!user || !users || !organization || organization.slug == "default") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return(
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>
|
||||
Edit {organization.name}
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<OrganizationForm currentUser={user} users={users} defaultValues={organization}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -3,14 +3,19 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {notFound} from "next/navigation";
|
||||
import {
|
||||
DeleteOrganizationButton
|
||||
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
|
||||
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
|
||||
import {Metadata} from "next";
|
||||
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
||||
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {db} from "@/db";
|
||||
import {isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
@@ -21,38 +26,64 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
if (!organization || !activeMember) {
|
||||
if (!organization || !activeMember || !user) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
||||
|
||||
const storageChannels = await getOrganizationStorageChannels(organization.id)
|
||||
const permissions = computeOrganizationPermissions(activeMember);
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt)
|
||||
});
|
||||
|
||||
const organizationWithMembers = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!organizationWithMembers) notFound();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex items-center">
|
||||
Organization settings
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditButtonSettings/>
|
||||
)}
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
Organization settings
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditOrganizationDialog
|
||||
organization={organizationWithMembers}
|
||||
users={users}
|
||||
currentUser={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
|
||||
<PageActions>
|
||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<OrganizationTabs
|
||||
activeMember={activeMember}
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
storageChannels={storageChannels}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,9 +78,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
|
||||
const Placeholder = ({text}: { text: string }) => (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">{text}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -125,31 +122,12 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Evolution of the number of backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedBackupsEvolution.length > 0 ? (
|
||||
<EvolutionLineChart data={sortedBackupsEvolution}/>
|
||||
) : (
|
||||
<Placeholder text="No backup data available"/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EvolutionLineChart
|
||||
data={sortedBackupsEvolution}
|
||||
/>
|
||||
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Success rate of backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{backupsRate.length > 0 ? (
|
||||
<PercentageLineChart data={backupsRate}/>
|
||||
) : (
|
||||
<Placeholder text="No backup rate data available"/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PercentageLineChart
|
||||
data={backupsRate}/>
|
||||
</div>
|
||||
</PageContent>
|
||||
</Page>
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import React from "react";
|
||||
import React, {ReactNode} from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
|
||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
|
||||
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||
import {Header} from "@/features/layout/Header";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
|
||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
|
||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||
export default async function Layout({children}: { children: ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<ThemeMetaUpdater/>
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +1,52 @@
|
||||
import fs from "node:fs";
|
||||
import forge from "node-forge";
|
||||
import {EventPayload} from "@/features/notifications/types";
|
||||
import {dispatchNotification} from "@/features/notifications/dispatch";
|
||||
import {Database, DatabaseWith} from "@/db/schema/07_database";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
export function withAgentCheck(handler: Function) {
|
||||
return async (request: Request, context: { params: Promise<{ agentId: string }> }) => {
|
||||
try {
|
||||
const agentId = (await context.params).agentId;
|
||||
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json(
|
||||
{ error: "Agent not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return handler(request, { ...context, agent });
|
||||
} catch (err) {
|
||||
console.error("Error in agent middleware:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> {
|
||||
const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8");
|
||||
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||
|
||||
// Decrypt AES key with RSA-OAEP
|
||||
const encryptedAesKey = forge.util.hexToBytes(aesKeyHex);
|
||||
const aesKey = privateKey.decrypt(encryptedAesKey, "RSA-OAEP", {
|
||||
md: forge.md.sha256.create(),
|
||||
mgf1: {md: forge.md.sha256.create()},
|
||||
export async function getDatabaseOrThrow(generatedId: string) {
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
with: {
|
||||
project: true,
|
||||
alertPolicies: true,
|
||||
storagePolicies: true
|
||||
}
|
||||
});
|
||||
|
||||
// Read encrypted file content
|
||||
const encryptedBuffer = Buffer.from(await file.arrayBuffer());
|
||||
const iv = forge.util.hexToBytes(ivHex);
|
||||
|
||||
// AES decryption
|
||||
const decipher = forge.cipher.createDecipher("AES-CBC", aesKey);
|
||||
decipher.start({iv});
|
||||
decipher.update(forge.util.createBuffer(encryptedBuffer.toString("binary")));
|
||||
const success = decipher.finish();
|
||||
|
||||
if (!success) {
|
||||
throw new Error("Decryption failed");
|
||||
if (!database) {
|
||||
throw NextResponse.json(
|
||||
{ error: "Database associated with generatedId not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const decryptedBytes = decipher.output.getBytes();
|
||||
const decryptedBuffer = Buffer.from(decryptedBytes, "binary");
|
||||
|
||||
// Return a File so you can use file.arrayBuffer() later
|
||||
return new File(
|
||||
[decryptedBuffer],
|
||||
file.name.replace(/\.enc$/, fileExtension),
|
||||
{type: "application/octet-stream"}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function getFileExtension(dbType: string) {
|
||||
switch (dbType) {
|
||||
case "postgresql":
|
||||
return ".dump";
|
||||
case "mysql":
|
||||
return ".sql";
|
||||
default:
|
||||
return ".dump";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return database;
|
||||
}
|
||||
@@ -1,87 +1,63 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {eventEmitter} from "../../../events/route";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {Backup} from "@/db/schema/07_database";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {env} from "@/env.mjs";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db as dbClient, db} from "@/db";
|
||||
import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
|
||||
import {Backup} from "@/db/schema/07_database";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {decryptedDump, getFileExtension} from "./helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {EventKind} from "@/features/notifications/types";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{params}: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
export type BodyPost = {
|
||||
method: "manual" | "automatic"
|
||||
generatedId: string
|
||||
}
|
||||
|
||||
export type BodyPatch = {
|
||||
backupId: string
|
||||
status: "success" | "failed"
|
||||
size: number
|
||||
generatedId: string
|
||||
}
|
||||
|
||||
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
params: Promise<{ agentId: string }>,
|
||||
agent: any
|
||||
}) => {
|
||||
try {
|
||||
const contentType = request.headers.get("Content-Type");
|
||||
|
||||
if (!contentType || !contentType.includes("multipart/form-data")) {
|
||||
return NextResponse.json(
|
||||
{error: "Unsupported or missing Content-Type"},
|
||||
{status: 400}
|
||||
);
|
||||
}
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
const agentId = (await params).agentId;
|
||||
const formData = await request.formData();
|
||||
const aesKeyHex = formData.get("aes_key") as string;
|
||||
const ivHex = formData.get("iv") as string;
|
||||
const generatedId = formData.get("generatedId") as string | null;
|
||||
const method = formData.get("method") as string | null;
|
||||
|
||||
if (!generatedId || !isUuidv4(generatedId)) {
|
||||
return NextResponse.json(
|
||||
{error: "generatedId is not a valid UUID"},
|
||||
{status: 400}
|
||||
);
|
||||
}
|
||||
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json(
|
||||
{error: "Agent not found"},
|
||||
{status: 404}
|
||||
);
|
||||
}
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
with: {
|
||||
project: true,
|
||||
alertPolicies: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!database) {
|
||||
return NextResponse.json(
|
||||
{error: "Database associated with generatedId not found"},
|
||||
{status: 404}
|
||||
);
|
||||
}
|
||||
const body: BodyPost = await request.json();
|
||||
const method = body.method
|
||||
const database = await getDatabaseOrThrow(body.generatedId);
|
||||
|
||||
let backup: Backup | null | undefined = null;
|
||||
|
||||
if (method === "automatic") {
|
||||
[backup] = await db
|
||||
.insert(drizzleDb.schemas.backup)
|
||||
.values({
|
||||
status: 'ongoing',
|
||||
databaseId: database.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const ongoingBackup = await db.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.status, 'ongoing'),
|
||||
eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!backup) {
|
||||
if (!ongoingBackup) {
|
||||
[backup] = await db
|
||||
.insert(drizzleDb.schemas.backup)
|
||||
.values({
|
||||
status: 'ongoing',
|
||||
databaseId: database.id,
|
||||
})
|
||||
.returning();
|
||||
if (!backup) {
|
||||
return NextResponse.json(
|
||||
{error: "Unable to create an automatic backup"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{error: "Unable to create an automatic backup"},
|
||||
{error: "A backup is already ongoing"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
@@ -93,7 +69,6 @@ export async function POST(
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
if (!backup) {
|
||||
return NextResponse.json(
|
||||
{error: "Unable to find the corresponding backup"},
|
||||
@@ -102,92 +77,77 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
const status = formData.get("status") as string | null;
|
||||
|
||||
if (status === "success") {
|
||||
const file = formData.get("file") as File | null;
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
if (!aesKeyHex || !ivHex) {
|
||||
return NextResponse.json({error: "Missing fields"}, {status: 400});
|
||||
}
|
||||
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{error: "File is required for successful backup"},
|
||||
{status: 400}
|
||||
);
|
||||
}
|
||||
const fileExtension = getFileExtension(database.dbms)
|
||||
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
|
||||
const uuid = uuidv4();
|
||||
const fileName = `${uuid}${fileExtension}`;
|
||||
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
||||
|
||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||
if (!settings) {
|
||||
throw new Error("System settings not found.");
|
||||
}
|
||||
|
||||
let success: boolean, message: string, filePath: string;
|
||||
|
||||
const result =
|
||||
settings.storage === "local"
|
||||
? await uploadLocalPrivate(fileName, buffer)
|
||||
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
|
||||
|
||||
({success, message, filePath} = result);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{error: message},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({
|
||||
file: fileName,
|
||||
status: 'success',
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
await sendNotificationsBackupRestore(database, "success_backup");
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Backup successfully uploaded",
|
||||
},
|
||||
{status: 200}
|
||||
);
|
||||
} else {
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({status: 'failed'}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
await sendNotificationsBackupRestore(database, "error_backup");
|
||||
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Backup successfully updated with status failed",
|
||||
},
|
||||
{status: 200}
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Init backup success",
|
||||
backup: backup,
|
||||
},
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in POST handler:", error);
|
||||
console.error("Error in POST for INIT backup:", error);
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
params: Promise<{ agentId: string }>,
|
||||
agent: any
|
||||
}) => {
|
||||
try {
|
||||
const body: BodyPatch = await request.json();
|
||||
|
||||
const status = body.status
|
||||
const backupId = body.backupId
|
||||
const backupSize = body.size
|
||||
|
||||
const database = await getDatabaseOrThrow(body.generatedId);
|
||||
|
||||
const backup = await db.query.backup.findFirst({
|
||||
where: eq(drizzleDb.schemas.backup.id, backupId),
|
||||
});
|
||||
|
||||
if (!backup) {
|
||||
return NextResponse.json(
|
||||
{error: "No backup found"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
const [backupUpdated] = await dbClient
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({
|
||||
status: status,
|
||||
fileSize: backupSize
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id))
|
||||
.returning();
|
||||
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
await sendNotificationsBackupRestore(database, `${status}_backup` as EventKind);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Backup successfully updated",
|
||||
backup: backupUpdated,
|
||||
},
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
|
||||
console.error("Error in PATCH backup:", error);
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
|
||||
export type Body = {
|
||||
generatedId: string
|
||||
storageChannelId: string
|
||||
backupId: string
|
||||
}
|
||||
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
params: Promise<{ agentId: string }>,
|
||||
agent: any
|
||||
}) => {
|
||||
try {
|
||||
const body: Body = await request.json();
|
||||
|
||||
console.log("body", body);
|
||||
|
||||
const generatedId = body.generatedId;
|
||||
const storageChannelId = body.storageChannelId;
|
||||
const backupId = body.backupId;
|
||||
|
||||
if (!generatedId || !isUuidv4(generatedId)) {
|
||||
return NextResponse.json(
|
||||
{error: "generatedId is not a valid UUID"},
|
||||
{status: 400}
|
||||
);
|
||||
}
|
||||
|
||||
const database = await getDatabaseOrThrow(generatedId);
|
||||
|
||||
const backup = await db.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.id, backupId),
|
||||
eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!backup) {
|
||||
return NextResponse.json(
|
||||
{error: "Unable to find the corresponding backup"},
|
||||
{status: 404}
|
||||
);
|
||||
}
|
||||
|
||||
const [backupStorage] = await db
|
||||
.insert(drizzleDb.schemas.backupStorage)
|
||||
.values({
|
||||
backupId: backup.id,
|
||||
storageChannelId: storageChannelId,
|
||||
status: "pending",
|
||||
})
|
||||
.returning();
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Backup storage successfully created",
|
||||
backupStorage: backupStorage
|
||||
},
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in POST for INIT backup:", error);
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db as dbClient, db} from "@/db";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
|
||||
export type Body = {
|
||||
generatedId: string
|
||||
status: "success" | "failed"
|
||||
backupStorageId: string
|
||||
path: string
|
||||
size: number
|
||||
backupId: string
|
||||
}
|
||||
export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
params: Promise<{ agentId: string }>,
|
||||
agent: any
|
||||
}) => {
|
||||
try {
|
||||
const body: Body = await request.json();
|
||||
const generatedId = body.generatedId;
|
||||
const status = body.status;
|
||||
const filePath = body.path;
|
||||
const fileSize = body.size;
|
||||
const backupStorageId = body.backupStorageId;
|
||||
const backupId = body.backupId;
|
||||
|
||||
|
||||
console.log("body", body);
|
||||
|
||||
const database = await getDatabaseOrThrow(generatedId);
|
||||
|
||||
const backup = await db.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.id, backupId),
|
||||
eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||
),
|
||||
with: {
|
||||
storages: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!backup) {
|
||||
return NextResponse.json(
|
||||
{error: "Unable to find the corresponding backup"},
|
||||
{status: 404}
|
||||
);
|
||||
}
|
||||
|
||||
const [backupStorage] = await dbClient
|
||||
.update(drizzleDb.schemas.backupStorage)
|
||||
.set(withUpdatedAt({
|
||||
status: status,
|
||||
path: filePath,
|
||||
size: fileSize
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorageId))
|
||||
.returning();
|
||||
|
||||
|
||||
if (backup.storages.length > 0) {
|
||||
const hasSuccessfulStorage = backup.storages.some(
|
||||
(storage) => storage.status === "success"
|
||||
);
|
||||
|
||||
if (hasSuccessfulStorage && backup.status !== "success") {
|
||||
await db
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set({
|
||||
status: "success",
|
||||
fileSize: fileSize,
|
||||
})
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
}
|
||||
}
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Backup status successfully updated",
|
||||
backupStorage: backupStorage
|
||||
},
|
||||
{status: 200}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in POST for INIT backup:", error);
|
||||
return NextResponse.json(
|
||||
{error: "Internal server error"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {eventEmitter} from "../../../events/route";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
|
||||
export type BodyResultRestore = {
|
||||
generatedId: string
|
||||
@@ -19,12 +19,10 @@ export async function POST(
|
||||
) {
|
||||
|
||||
try {
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
const agentId = (await params).agentId
|
||||
const body: BodyResultRestore = await request.json();
|
||||
|
||||
console.log(body)
|
||||
|
||||
if (!isUuidv4(body.generatedId)) {
|
||||
return NextResponse.json(
|
||||
@@ -73,7 +71,6 @@ export async function POST(
|
||||
details: "Restoration successfully updated"
|
||||
}
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
return Response.json(response, {status: 200})
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {Body} from "./route";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {Database, DatabaseWith} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db as dbClient} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {SafeActionResult} from "next-safe-action";
|
||||
import {ZodString} from "zod";
|
||||
import {db, db as dbClient} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
|
||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
|
||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
|
||||
const databasesResponse = [];
|
||||
|
||||
const formatDatabase = (database: Database, backupAction: boolean, restoreAction: boolean, UrlBackup: string) => ({
|
||||
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({
|
||||
generatedId: database.agentDatabaseId,
|
||||
dbms: database.dbms,
|
||||
storages: storages,
|
||||
encrypt: settings.encryption,
|
||||
data: {
|
||||
backup: {
|
||||
action: backupAction,
|
||||
@@ -27,6 +28,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
restore: {
|
||||
action: restoreAction,
|
||||
file: UrlBackup,
|
||||
metaFile: urlMeta
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -34,12 +36,16 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
for (const db of body.databases) {
|
||||
|
||||
const existingDatabase = await dbClient.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId)
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId),
|
||||
with: {
|
||||
project: true
|
||||
}
|
||||
});
|
||||
|
||||
let backupAction: boolean = false
|
||||
let restoreAction: boolean = false
|
||||
let urlBackup: string = ""
|
||||
let urlBackup: string | null = null;
|
||||
let urlMeta: string | null = null
|
||||
|
||||
if (!existingDatabase) {
|
||||
if (!isUuidv4(db.generatedId)) {
|
||||
@@ -48,6 +54,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
|
||||
console.log(`Database type not available: ${db.dbms}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [databaseCreated] = await dbClient
|
||||
.insert(drizzleDb.schemas.database)
|
||||
.values({
|
||||
@@ -59,8 +71,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
if (databaseCreated) {
|
||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup));
|
||||
const storages = await getDatabaseStorageChannels(databaseCreated.id)
|
||||
|
||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -74,77 +89,77 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
||||
.returning();
|
||||
|
||||
|
||||
const backup = await dbClient.query.backup.findFirst({
|
||||
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
|
||||
const activeBackup = await dbClient.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
||||
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
const restoration = await dbClient.query.restoration.findFirst({
|
||||
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting"))
|
||||
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
|
||||
with: {
|
||||
backupStorage: true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if (backup) {
|
||||
if (activeBackup && activeBackup.status == "waiting") {
|
||||
backupAction = true
|
||||
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({status: "ongoing"}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
.where(eq(drizzleDb.schemas.backup.id, activeBackup.id));
|
||||
}
|
||||
|
||||
if (restoration) {
|
||||
restoreAction = true
|
||||
|
||||
|
||||
const backupToRestore = await dbClient.query.backup.findFirst({
|
||||
where: eq(drizzleDb.schemas.backup.id, restoration.backupId),
|
||||
with: {
|
||||
database: {
|
||||
with: {
|
||||
project: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||
if (!settings) {
|
||||
return NextResponse.json(
|
||||
{error: "Unable to find settings"},
|
||||
{status: 500}
|
||||
);
|
||||
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
|
||||
restoreAction = false
|
||||
continue;
|
||||
}
|
||||
|
||||
const input: StorageInput = {
|
||||
action: "get",
|
||||
data: {
|
||||
path: restoration.backupStorage.path,
|
||||
signedUrl: true,
|
||||
},
|
||||
metadata: {
|
||||
storageId: restoration.backupStorage.storageChannelId,
|
||||
fileKind: "backups"
|
||||
}
|
||||
};
|
||||
|
||||
const fileName = backupToRestore?.file
|
||||
const inputMeta: StorageInput = {
|
||||
action: "get",
|
||||
data: {
|
||||
path: `${restoration.backupStorage.path}.meta`,
|
||||
signedUrl: true,
|
||||
},
|
||||
metadata: {
|
||||
storageId: restoration.backupStorage.storageChannelId,
|
||||
fileKind: "backups"
|
||||
}
|
||||
};
|
||||
|
||||
let data: SafeActionResult<string, ZodString, readonly [], {
|
||||
_errors?: string[] | undefined;
|
||||
}, readonly [], ServerActionResult<string>, object> | undefined
|
||||
|
||||
try {
|
||||
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
|
||||
const resultMeta = await dispatchStorage(inputMeta, undefined, restoration.backupStorage.storageChannelId);
|
||||
|
||||
if (settings.storage == "local") {
|
||||
data = await getFileUrlPresignedLocal({fileName: fileName!})
|
||||
} else if (settings.storage == "s3") {
|
||||
|
||||
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
|
||||
}
|
||||
|
||||
if (data?.data?.success) {
|
||||
urlBackup = data.data.value ?? "";
|
||||
if (result.success) {
|
||||
urlBackup = result.url ?? null;
|
||||
urlMeta = resultMeta.url ?? null
|
||||
} else {
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "failed"})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
// @ts-ignore
|
||||
const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
|
||||
const errorMessage = "Failed to get backup URL";
|
||||
console.error("Restoration failed: ", errorMessage);
|
||||
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -153,17 +168,81 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "failed"})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "ongoing"})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
}
|
||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup));
|
||||
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
|
||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
|
||||
}
|
||||
}
|
||||
|
||||
return databasesResponse;
|
||||
}
|
||||
|
||||
|
||||
type PingDatabaseStorageChannels = {
|
||||
id: string;
|
||||
config: any
|
||||
provider: string
|
||||
}
|
||||
|
||||
async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatabaseStorageChannels[]> {
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||
with: {
|
||||
project: true,
|
||||
retentionPolicy: true,
|
||||
alertPolicies: true,
|
||||
storagePolicies: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!database) {
|
||||
return []
|
||||
}
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||
with: {storageChannel: true},
|
||||
});
|
||||
|
||||
const defaultStorageChannel: PingDatabaseStorageChannels[] = settings?.storageChannel
|
||||
? [{
|
||||
id: settings.storageChannel.id,
|
||||
provider: settings.storageChannel.provider,
|
||||
config: settings.storageChannel.config,
|
||||
}]
|
||||
: [];
|
||||
|
||||
|
||||
const enabledDatabaseStorageChannels = await Promise.all(
|
||||
(database.storagePolicies ?? [])
|
||||
.filter(p => p.enabled)
|
||||
.map(async policy => {
|
||||
const storageChannel = await db.query.storageChannel.findFirst({
|
||||
where: eq(drizzleDb.schemas.storageChannel.id, policy.storageChannelId),
|
||||
});
|
||||
|
||||
if (!storageChannel) return null;
|
||||
|
||||
return {
|
||||
id: storageChannel.id,
|
||||
config: storageChannel.config,
|
||||
provider: storageChannel.provider,
|
||||
} as PingDatabaseStorageChannels;
|
||||
})
|
||||
);
|
||||
|
||||
const filteredChannels: PingDatabaseStorageChannels[] = enabledDatabaseStorageChannels.filter(
|
||||
(c): c is PingDatabaseStorageChannels => c !== null
|
||||
);
|
||||
|
||||
return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
|
||||
import {handleDatabases} from "./helpers";
|
||||
import {eventEmitter} from "../../../events/route";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {notFound} from "next/navigation";
|
||||
|
||||
export type databaseAgent = {
|
||||
name: string,
|
||||
@@ -20,13 +20,6 @@ export type Body = {
|
||||
databases: databaseAgent[]
|
||||
}
|
||||
|
||||
// Function to test the get file url presigned local
|
||||
export async function GET(request: Request) {
|
||||
const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
|
||||
return Response.json({
|
||||
message: url
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -34,14 +27,14 @@ export async function POST(
|
||||
) {
|
||||
try {
|
||||
const agentId = (await params).agentId
|
||||
console.log(agentId)
|
||||
const body: Body = await request.json();
|
||||
const lastContact = new Date();
|
||||
let message: string
|
||||
|
||||
|
||||
if (!isUuidv4(agentId)) {
|
||||
message = "agentId is not a valid uuid"
|
||||
console.log(message)
|
||||
console.error(message)
|
||||
return NextResponse.json(
|
||||
{error: "agentId is not a valid uuid"},
|
||||
{status: 500}
|
||||
@@ -54,11 +47,16 @@ export async function POST(
|
||||
|
||||
if (!agent) {
|
||||
message = "Agent not found"
|
||||
console.log(message)
|
||||
return NextResponse.json({error: message}, {status: 404})
|
||||
}
|
||||
const databasesResponse = await handleDatabases(body, agent, lastContact)
|
||||
|
||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||
if (!settings) {
|
||||
return NextResponse.json({error: "An error occured"}, {status: 404})
|
||||
}
|
||||
|
||||
|
||||
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.agent)
|
||||
@@ -68,7 +66,6 @@ export async function POST(
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
const response = {
|
||||
agent: {
|
||||
@@ -77,7 +74,7 @@ export async function POST(
|
||||
},
|
||||
databases: databasesResponse
|
||||
}
|
||||
console.log(response)
|
||||
|
||||
|
||||
return Response.json(response)
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {EventEmitter} from 'events';
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {NextResponse} from "next/server";
|
||||
|
||||
export const eventEmitter = new EventEmitter();
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -19,7 +17,6 @@ export async function GET(request: Request) {
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
console.log('Stream started');
|
||||
|
||||
const handleModification = (data: any) => {
|
||||
console.log('Modification event triggered:', data);
|
||||
controller.enqueue(`event: modification\n`);
|
||||
@@ -28,7 +25,6 @@ export async function GET(request: Request) {
|
||||
|
||||
eventEmitter.on('modification', handleModification);
|
||||
|
||||
// Handle client disconnect
|
||||
request.signal.addEventListener('abort', () => {
|
||||
console.log('Client disconnected');
|
||||
controller.close();
|
||||
@@ -45,15 +41,4 @@ export async function GET(request: Request) {
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// export async function POST(request: Request) {
|
||||
// console.log('POST request received');
|
||||
// const data = await request.json();
|
||||
// console.log('Data received:', data);
|
||||
//
|
||||
// // Emit the event to all connected clients
|
||||
// eventEmitter.emit('modification', data);
|
||||
//
|
||||
// return new Response('Event sent', {status: 200});
|
||||
// }
|
||||
}
|
||||
@@ -1,29 +1,46 @@
|
||||
import * as fs from "node:fs";
|
||||
import {NextResponse} from "next/server";
|
||||
import path from "path";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import {Readable} from "node:stream";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{params}: { params: Promise<{ fileName: string }> }
|
||||
) {
|
||||
|
||||
const {searchParams} = new URL(request.url);
|
||||
const token = searchParams.get('token');
|
||||
const expires = searchParams.get('expires');
|
||||
const fileName = (await params).fileName
|
||||
const pathFromUrl = searchParams.get('path');
|
||||
const storageId = searchParams.get('storageId');
|
||||
|
||||
const uploadsDir = "private/uploads/files/";
|
||||
const uploadPath = path.join(uploadsDir, fileName);
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
let filePath = null;
|
||||
if (fs.existsSync(uploadPath)) {
|
||||
filePath = uploadPath;
|
||||
} else {
|
||||
return NextResponse.json({error: "File not found"}, {status: 404})
|
||||
if (!pathFromUrl || !storageId) {
|
||||
return NextResponse.json({error: "Missing search params"}, {status: 404})
|
||||
}
|
||||
|
||||
const input: StorageInput = {
|
||||
action: "get",
|
||||
data: {
|
||||
path: pathFromUrl,
|
||||
signedUrl: true,
|
||||
},
|
||||
metadata: {
|
||||
storageId: storageId,
|
||||
fileKind: "backups",
|
||||
}
|
||||
};
|
||||
|
||||
console.debug(input);
|
||||
|
||||
const result = await dispatchStorage(input, undefined, storageId);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({error: "Enable to get file from provided storage channel, an error occurred !"})
|
||||
}
|
||||
|
||||
const fileName = path.basename(pathFromUrl);
|
||||
|
||||
const crypto = require('crypto');
|
||||
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
|
||||
if (token !== expectedToken) {
|
||||
return NextResponse.json(
|
||||
@@ -40,7 +57,15 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
if (!result.file || !(result.file instanceof Readable)) {
|
||||
return NextResponse.json(
|
||||
{error: "Invalid file payload"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
const fileStream = Readable.from(result.file as Readable);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
fileStream.on('data', (chunk) => controller.enqueue(chunk));
|
||||
@@ -0,0 +1,88 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import {Readable} from "node:stream";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{params}: { params: Promise<{ fileName: string }> }
|
||||
) {
|
||||
const {searchParams} = new URL(req.url);
|
||||
const fileName = (await params).fileName;
|
||||
const storageId = searchParams.get('storageId');
|
||||
|
||||
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
||||
|
||||
const session = await auth.api.getSession({headers: await headers()});
|
||||
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||
|
||||
|
||||
if (!storageId) {
|
||||
return NextResponse.json({error: "Missing storageId in search params"}, {status: 404})
|
||||
}
|
||||
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const contentType =
|
||||
ext === "png"
|
||||
? "image/png"
|
||||
: ext === "jpg" || ext === "jpeg"
|
||||
? "image/jpeg"
|
||||
: ext === "gif"
|
||||
? "image/gif"
|
||||
: ext === "webp"
|
||||
? "image/webp"
|
||||
: "application/octet-stream";
|
||||
|
||||
try {
|
||||
|
||||
const path = `images/${fileName}`;
|
||||
|
||||
const input: StorageInput = {
|
||||
action: "get",
|
||||
data: {
|
||||
path: path,
|
||||
},
|
||||
metadata: {
|
||||
storageId: storageId,
|
||||
fileKind: "images"
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dispatchStorage(input, undefined, storageId);
|
||||
|
||||
if (!result.file || !(result.file instanceof Readable)) {
|
||||
console.error(`An error occurred while getting file :`, result);
|
||||
return NextResponse.json(
|
||||
{error: "Invalid file payload"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
const fileStream = Readable.from(result.file as Readable);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
fileStream.on('data', (chunk) => controller.enqueue(chunk));
|
||||
fileStream.on('end', () => controller.close());
|
||||
fileStream.on('error', (err) => controller.error(err));
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Disposition': `inline; filename="${fileName}"`,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error streaming image:", err);
|
||||
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
|
||||
const success = Boolean(code);
|
||||
|
||||
return new Response(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>${success ? "Success" : "Failed"}</h1>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
status: success ? 200 : 400,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
|
||||
import {env} from "@/env.mjs";
|
||||
import * as stream from "node:stream";
|
||||
import path from "path";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import fs from "fs/promises";
|
||||
|
||||
function nodeStreamToWebStream(nodeStream: stream.Readable) {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
nodeStream.on("data", chunk => controller.enqueue(chunk));
|
||||
nodeStream.on("end", () => controller.close());
|
||||
nodeStream.on("error", err => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
nodeStream.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const privateS3ImageDir = "images/";
|
||||
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{params}: { params: Promise<{ fileName: string }> }
|
||||
) {
|
||||
const fileName = (await params).fileName;
|
||||
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
||||
|
||||
const session = await auth.api.getSession({headers: await headers()});
|
||||
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||
|
||||
const [settings] = await db
|
||||
.select()
|
||||
.from(drizzleDb.schemas.setting)
|
||||
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||
.limit(1);
|
||||
|
||||
if (!settings) throw new Error("System settings not found.");
|
||||
|
||||
const storageType = settings.storage; // "local" or "s3"
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const contentType =
|
||||
ext === "png"
|
||||
? "image/png"
|
||||
: ext === "jpg" || ext === "jpeg"
|
||||
? "image/jpeg"
|
||||
: ext === "gif"
|
||||
? "image/gif"
|
||||
: ext === "webp"
|
||||
? "image/webp"
|
||||
: "application/octet-stream";
|
||||
|
||||
try {
|
||||
if (storageType === "local") {
|
||||
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
|
||||
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
const file = await fs.readFile(filePath);
|
||||
|
||||
return new NextResponse(file, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": `inline; filename="${fileName}"`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// if not found locally, fallback to S3
|
||||
}
|
||||
}
|
||||
|
||||
const exists = await checkFileExistsInBucket({
|
||||
bucketName: env.S3_BUCKET_NAME!,
|
||||
fileName: `${privateS3ImageDir}${fileName}`,
|
||||
});
|
||||
if (!exists) return NextResponse.json({error: "File not found"}, {status: 404});
|
||||
|
||||
const nodeStream = await getObjectFromClient({
|
||||
bucketName: env.S3_BUCKET_NAME!,
|
||||
fileName: `${privateS3ImageDir}${fileName}`,
|
||||
});
|
||||
const webStream = nodeStreamToWebStream(nodeStream);
|
||||
|
||||
return new NextResponse(webStream, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": `inline; filename="${fileName}"`,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error streaming image:", err);
|
||||
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const event = body.Event
|
||||
const headers = event.HTTPRequest.Header
|
||||
const uploadLength = headers["X-File-Size"]?.[0];
|
||||
const uploadOffset = headers["Upload-Offset"]?.[0];
|
||||
const status = headers["X-Status"]?.[0];
|
||||
|
||||
console.log(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
|
||||
|
||||
if (status === "success") {
|
||||
if (
|
||||
body.Type === "post-receive" &&
|
||||
event.Upload.SizeIsDeferred === false &&
|
||||
event.Upload.Offset === event.Upload.Size
|
||||
) {
|
||||
const id = event.Upload.ID;
|
||||
const fileName = headers["X-File-Name"]?.[0];
|
||||
const filePath = headers["X-File-Path"]?.[0];
|
||||
|
||||
if (!filePath) {
|
||||
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
|
||||
}
|
||||
|
||||
|
||||
const uploadDir = path.join(process.cwd(), "/private/uploads/");
|
||||
|
||||
const oldFilePath = path.join(uploadDir, "tmp", id);
|
||||
const newFilePath = path.join(uploadDir, filePath);
|
||||
|
||||
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
|
||||
|
||||
let retries = 10;
|
||||
while (!fs.existsSync(oldFilePath)) {
|
||||
if (retries-- === 0) {
|
||||
return NextResponse.json({error: `Upload file not found: ${oldFilePath}`}, {status: 500});
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
fs.renameSync(oldFilePath, newFilePath);
|
||||
|
||||
const infoFilePath = `${oldFilePath}.info`;
|
||||
if (fs.existsSync(infoFilePath)) {
|
||||
fs.unlinkSync(infoFilePath);
|
||||
}
|
||||
|
||||
const metadataHeaderB64 = headers["Upload-Metadata"]?.[0];
|
||||
|
||||
if (metadataHeaderB64) {
|
||||
const metadataHeader = Buffer.from(metadataHeaderB64, "base64").toString("utf-8");
|
||||
if (metadataHeader) {
|
||||
const tomlContent = metadataHeader
|
||||
.split(",")
|
||||
.map((pair) => {
|
||||
const [key, value] = pair.split(" ");
|
||||
const escapedValue = value.replace(/"/g, '\\"');
|
||||
return `${key} = "${escapedValue}"`;
|
||||
})
|
||||
.join("\n");
|
||||
const metaFilePath = `${newFilePath}.meta`;
|
||||
fs.writeFileSync(metaFilePath, tomlContent, "utf-8");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return NextResponse.json({});
|
||||
} catch (error) {
|
||||
console.error("Hook error:", error);
|
||||
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -6,8 +6,11 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans: var(--font-poppins), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-title: var(--font-poppins), var(--font-poppins), sans-serif;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-author: var(--font-author);
|
||||
--font-poppins: var(--font-poppins);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -71,7 +74,7 @@
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary: oklch(0.611 0.204 42.149);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
@@ -82,7 +85,7 @@
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--ring: oklch(0.611 0.204 42.149);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
@@ -90,12 +93,12 @@
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary: oklch(0.611 0.204 42.149);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-ring: oklch(0.611 0.204 42.149);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -105,8 +108,8 @@
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--primary: oklch(0.704 0.191 47.132);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
@@ -116,7 +119,7 @@
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--ring: oklch(0.704 0.191 47.132);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
@@ -124,12 +127,12 @@
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary: oklch(0.704 0.191 47.132);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-ring: oklch(0.704 0.191 47.132);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -137,6 +140,18 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-title;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -4,7 +4,7 @@ import "./globals.css";
|
||||
import {Providers} from "./providers";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
||||
import {inter} from "@/fonts/fonts";
|
||||
import {author, geistMono, poppins} from "@/fonts/fonts";
|
||||
|
||||
const title = process.env.PROJECT_NAME ?? "App Title";
|
||||
|
||||
@@ -16,19 +16,22 @@ export const metadata: Metadata = {
|
||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content="Portabase"/>
|
||||
<meta name="apple-mobile-web-app-title" content={title}/>
|
||||
</head>
|
||||
<body className={cn(inter.className, "h-full")}>
|
||||
<body className={cn(poppins.variable, author.variable, geistMono.variable, "font-sans h-full")}>
|
||||
<ConsoleSilencer/>
|
||||
<Providers>{children}</Providers>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren, Suspense } from "react";
|
||||
import { ThemeProvider } from "@/features/theme/theme-provider";
|
||||
import {PropsWithChildren, Suspense} from "react";
|
||||
import {ThemeProvider} from "@/features/theme/theme-provider";
|
||||
|
||||
import {Toaster} from "@/components/ui/sonner";
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
export type ProviderProps = PropsWithChildren<{}>;
|
||||
const queryClient = new QueryClient();
|
||||
@@ -12,10 +14,15 @@ const queryClient = new QueryClient();
|
||||
export const Providers = (props: ProviderProps) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
<ThemeMetaUpdaterRoot/>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster />
|
||||
{props.children}
|
||||
<Toaster/>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
|
||||
@@ -3,11 +3,11 @@ name: portabase-prod
|
||||
services:
|
||||
|
||||
app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: solucetechnologies/portabase:1.1.4-rc.2
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
image: portabase/portabase:1.2.5-rc.9
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: portabase-dev
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
@@ -17,5 +17,19 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
tusd:
|
||||
image: tusproject/tusd:v2.8.0
|
||||
ports:
|
||||
- "1080:8080"
|
||||
command: >
|
||||
-upload-dir /data/uploads/tmp
|
||||
-hooks-http http://localhost:8887/api/tus/hooks
|
||||
-max-size 21474836480
|
||||
-base-path /tus/files/
|
||||
extra_hosts:
|
||||
- "localhost:host-gateway"
|
||||
volumes:
|
||||
- ./private/uploads/tmp:/data/uploads/tmp
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
@@ -1,113 +1,107 @@
|
||||
FROM node:22-alpine AS base
|
||||
#FROM node:22-bullseye AS base
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
||||
|
||||
ENV YARN_VERSION=4.9.1
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
ca-certificates \
|
||||
bash \
|
||||
tzdata \
|
||||
build-base \
|
||||
libc6-compat \
|
||||
openssl \
|
||||
make \
|
||||
libc6 \
|
||||
build-essential \
|
||||
g++ \
|
||||
jpeg-dev \
|
||||
cairo-dev \
|
||||
giflib-dev \
|
||||
pango-dev \
|
||||
libtool \
|
||||
make \
|
||||
autoconf \
|
||||
automake \
|
||||
libpng
|
||||
|
||||
RUN corepack enable && corepack prepare yarn@${YARN_VERSION}
|
||||
RUN yarn set version ${YARN_VERSION}
|
||||
libtool \
|
||||
git \
|
||||
nginx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
FROM base AS deps
|
||||
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
|
||||
|
||||
ARG TUSD_VERSION=2.8.0
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN git clone https://github.com/tus/tusd.git . \
|
||||
&& git checkout v${TUSD_VERSION}
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
|
||||
go build -ldflags="-s -w" -o /tusddist/tusd ./cmd/tusd
|
||||
|
||||
FROM scratch AS tusd-dist
|
||||
COPY --from=tusd-base /tusddist/tusd /tusd
|
||||
|
||||
|
||||
FROM base AS build-env
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
FROM build-env AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY .yarn ./.yarn
|
||||
COPY .yarnrc.yml ./
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
FROM base AS dev
|
||||
|
||||
WORKDIR /app
|
||||
FROM build-env AS dev
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=tusd-base /usr/local/bin/tusd /usr/local/bin/tusd
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
USER root
|
||||
|
||||
RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
FROM build-env AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
FROM base AS prod
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=80
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
RUN mkdir -p /app/private/uploads
|
||||
RUN chown -R nextjs:nodejs /app/private
|
||||
RUN chown -R nextjs:nodejs /app/public
|
||||
|
||||
|
||||
COPY --from=builder /app/next.config.ts ./
|
||||
COPY --from=builder /app/portabase.config.ts ./
|
||||
COPY --from=builder /app/drizzle.config.ts ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --chown=nextjs:nodejs src/db ./src/db
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
||||
COPY --chown=1001:1001 src/db ./src/db
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
|
||||
RUN chmod +x /usr/local/bin/tusd
|
||||
|
||||
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
|
||||
RUN mkdir -p .next /app/private/uploads \
|
||||
&& chown -R nextjs:nodejs .next /app/private /app/public
|
||||
|
||||
USER root
|
||||
|
||||
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
||||
RUN chmod +x /app/app-prod-entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENV PORT=80
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
USER nextjs
|
||||
|
||||
#USER nextjs
|
||||
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
echo "▶ Running Drizzle codegen..."
|
||||
npx drizzle-kit generate
|
||||
pnpm drizzle-kit generate
|
||||
|
||||
echo "▶ Applying migrations..."
|
||||
npx drizzle-kit migrate
|
||||
pnpm drizzle-kit migrate
|
||||
|
||||
echo "▶ Starting Next.js dev server..."
|
||||
exec npm run dev
|
||||
exec pnpm dev
|
||||
@@ -7,8 +7,14 @@ else
|
||||
echo "[WARN] No TZ provided, using default container timezone"
|
||||
fi
|
||||
|
||||
mkdir -p /app/private/uploads/tmp
|
||||
echo "▶ Starting tusd server..."
|
||||
tusd --base-path /tus/files/ --upload-dir /app/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
|
||||
|
||||
node server.js
|
||||
echo "▶ Starting Next.js server..."
|
||||
PORT=3000 node server.js &
|
||||
|
||||
echo "▶ Starting nginx..."
|
||||
exec nginx -g "daemon off;"
|
||||
|
||||
exec "$@"
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
events {}
|
||||
|
||||
http {
|
||||
client_max_body_size 20G;
|
||||
ignore_invalid_headers off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location /tus/ {
|
||||
proxy_pass http://127.0.0.1:1080/tus/;
|
||||
|
||||
proxy_pass_request_headers on;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {NextConfig} from "next";
|
||||
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
|
||||
function buildCSPHeader(): string {
|
||||
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY;
|
||||
@@ -43,9 +45,21 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "50mb",
|
||||
bodySizeLimit: "10gb",
|
||||
},
|
||||
proxyClientMaxBodySize: '10gb',
|
||||
},
|
||||
async rewrites() {
|
||||
if (!isDev) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/tus/:path*",
|
||||
destination: "http://localhost:1080/tus/:path*",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.1.5",
|
||||
"version": "1.2.6-rc.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -14,109 +14,118 @@
|
||||
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.10",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.13",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.9",
|
||||
"@radix-ui/react-checkbox": "^1.3.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.10",
|
||||
"@radix-ui/react-context-menu": "^2.2.14",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-hover-card": "^1.1.13",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-menubar": "^1.1.14",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.12",
|
||||
"@radix-ui/react-popover": "^1.1.13",
|
||||
"@radix-ui/react-progress": "^1.1.6",
|
||||
"@radix-ui/react-radio-group": "^1.3.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.6",
|
||||
"@radix-ui/react-slider": "^1.3.4",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-switch": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-toast": "^1.2.13",
|
||||
"@radix-ui/react-toggle": "^1.1.8",
|
||||
"@radix-ui/react-toggle-group": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-email/components": "^0.0.41",
|
||||
"@t3-oss/env-nextjs": "^0.13.4",
|
||||
"@tanstack/react-query": "^5.76.1",
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/nodemailer": "^6.4.22",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
"argon2": "^0.43.0",
|
||||
"argon2": "^0.43.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.4.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dockerode": "^4.0.6",
|
||||
"dotenv": "^16.5.0",
|
||||
"dockerode": "^4.0.9",
|
||||
"dotenv": "^16.6.1",
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"express": "^5.2.1",
|
||||
"googleapis": "^170.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
"minio": "^8.0.5",
|
||||
"motion": "^12.23.24",
|
||||
"next": "16.0.10",
|
||||
"minio": "^8.0.6",
|
||||
"motion": "^12.34.0",
|
||||
"next": "16.1.5",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-forge": "^1.3.1",
|
||||
"nodemailer": "^7.0.3",
|
||||
"npm-check-updates": "^18.0.1",
|
||||
"pg": "^8.16.0",
|
||||
"react": "^19.2.0",
|
||||
"node-forge": "^1.3.3",
|
||||
"nodemailer": "^7.0.13",
|
||||
"npm-check-updates": "^18.3.1",
|
||||
"pg": "^8.18.0",
|
||||
"prettier": "^3.8.1",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "9.7.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-email": "^4.0.13",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-email": "^4.3.2",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"react-twc": "^1.4.2",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-twc": "^1.5.1",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^2.15.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"recharts": "^2.15.4",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swiper": "^12.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.18.2",
|
||||
"zod": "^3.24.4"
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@react-email/preview-server": "4.3.2",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@react-email/render": "^2.0.4",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/node-forge": "^1",
|
||||
"@types/pg": "^8.15.2",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@zenstackhq/openapi": "^2.14.2",
|
||||
"@zenstackhq/tanstack-query": "^2.14.2",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tsx": "^4.19.4",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5.8.3",
|
||||
"@types/node": "^22.19.11",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@zenstackhq/openapi": "^2.22.1",
|
||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"framer-motion": "^12.34.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1"
|
||||
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@prisma/client'
|
||||
- '@prisma/engines'
|
||||
- argon2
|
||||
- bcrypt
|
||||
- cpu-features
|
||||
- esbuild
|
||||
- prisma
|
||||
- protobufjs
|
||||
- sharp
|
||||
- ssh2
|
||||
- unrs-resolver
|
||||
- zenstack
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Chrome, KeyRound, LucideIcon} from "lucide-react";
|
||||
|
||||
|
||||
export interface AuthProviderConfig {
|
||||
id: "google" | "github" | "credential";
|
||||
icon: LucideIcon;
|
||||
isActive: boolean;
|
||||
icon: string;
|
||||
isManual?: boolean;
|
||||
credentials?: {
|
||||
clientId: string;
|
||||
@@ -80,26 +80,27 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
||||
{
|
||||
id: "google",
|
||||
icon: Chrome,
|
||||
icon: "hugeicons:chrome",
|
||||
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
|
||||
// isManual: true,
|
||||
credentials: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||
clientId: process.env.AUTH_GOOGLE_ID || "",
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
|
||||
},
|
||||
},
|
||||
/*
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
icon: Github,
|
||||
description: "Connexion via GitHub",
|
||||
icon: "iconoir:github",
|
||||
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
|
||||
credentials: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID || "",
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
|
||||
clientId: process.env.AUTH_GITHUB_ID || "",
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
|
||||
},
|
||||
},*/
|
||||
},
|
||||
{
|
||||
id: "credential",
|
||||
icon: KeyRound,
|
||||
isActive: true,
|
||||
icon: "proicons:key",
|
||||
isManual: true,
|
||||
credentials: {
|
||||
clientId: "",
|
||||
|
||||
@@ -53,12 +53,15 @@ function checkRouteExists(pathname: string) {
|
||||
const routePatterns = [
|
||||
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||
/^\/api\/files\/[^/]+\/?$/,
|
||||
/^\/api\/images\/[^/]+\/?$/,
|
||||
/^\/api\/files\/images\/[^/]+\/?$/,
|
||||
/^\/api\/files\/backups\/?$/,
|
||||
/^\/api\/tus\/hooks\/?$/,
|
||||
/^\/api\/events\/?$/,
|
||||
/^\/api\/init\/?$/,
|
||||
/^\/api\/config\/?$/,
|
||||
/^\/api\/google\/drive\/callback\/?$/,
|
||||
];
|
||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: ./release <version>"
|
||||
echo "Example: ./release v1.0.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
if [ "$CURRENT_BRANCH" = "main" ]; then
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: On 'main' branch, only release tags (X.Y.Z) are allowed."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
echo "Error: On branch '$CURRENT_BRANCH', only RC tags matching X.Y.Z-rc.W are allowed (e.g., 1.0.0-rc.1)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
CLEAN_VERSION=${VERSION#v}
|
||||
CURRENT_DATE=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Preparing release $VERSION..."
|
||||
|
||||
|
||||
# package.json
|
||||
if [ -f package.json ]; then
|
||||
echo "Updating package.json..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
|
||||
else
|
||||
sed -i '' "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
|
||||
fi
|
||||
fi
|
||||
|
||||
# pyproject.toml
|
||||
if [ -f pyproject.toml ]; then
|
||||
echo "Updating pyproject.toml..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
|
||||
else
|
||||
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cargo.toml
|
||||
if [ -f Cargo.toml ]; then
|
||||
echo "Updating Cargo.toml..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
|
||||
else
|
||||
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
|
||||
fi
|
||||
fi
|
||||
|
||||
# CITATION.cff
|
||||
if [ -f CITATION.cff ]; then
|
||||
echo "Updating CITATION.cff..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
|
||||
sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
|
||||
else
|
||||
sed -i '' "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
|
||||
sed -i '' "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
|
||||
fi
|
||||
fi
|
||||
|
||||
git add .
|
||||
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "Committing changes..."
|
||||
git commit -m "chore(release): $VERSION"
|
||||
else
|
||||
echo "No changes to commit. Proceeding to tag..."
|
||||
fi
|
||||
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "Tag $VERSION already exists. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating tag $VERSION..."
|
||||
git tag -a "$VERSION" -m "Release $VERSION"
|
||||
|
||||
echo "Pushing changes and tags to remote..."
|
||||
git push
|
||||
git push origin "$VERSION"
|
||||
|
||||
echo "Successfully released $VERSION!"
|
||||
@@ -0,0 +1,6 @@
|
||||
import express from "express";
|
||||
import router from "../src/features/api/router";
|
||||
|
||||
export function mountApi(app: express.Express) {
|
||||
app.use("/services/v1", router);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import next from "next";
|
||||
import express from "express";
|
||||
import {mountApi} from "./api";
|
||||
import http from "http";
|
||||
|
||||
const port = Number(process.env.PORT) || 8887;
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
|
||||
async function start() {
|
||||
const nextApp = next({
|
||||
dev,
|
||||
hostname: "0.0.0.0",
|
||||
turbopack: dev,
|
||||
port
|
||||
});
|
||||
|
||||
const handle = nextApp.getRequestHandler();
|
||||
|
||||
await nextApp.prepare();
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use((req, res, next) => {
|
||||
req.setTimeout(0);
|
||||
res.setTimeout(0);
|
||||
next();
|
||||
});
|
||||
|
||||
mountApi(app);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.startsWith('/services/v1')) return next();
|
||||
handle(req, res).catch((err) => {
|
||||
console.error('Next.js App Router error:', err);
|
||||
res.status(500).send('Internal Server Error');
|
||||
});
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.setTimeout(0);
|
||||
server.headersTimeout = 0;
|
||||
server.requestTimeout = 0;
|
||||
server.keepAliveTimeout = 0;
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`NEXT APP → http://localhost:${port}`);
|
||||
console.log(`API → http://localhost:${port}/services/v1`);
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "../email-layout";
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const EmailForgotPassword = ({firstname, token}: EmailCreateUserProps) => {
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase - Forgot Password">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Hello <strong>{firstname}</strong>,
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
We received a request to reset the password for your account associated with this email.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
If you did not request this password reset, please ignore this email. Your current password
|
||||
will remain unchanged. If this seems suspicious, please contact your administrator.
|
||||
</Text>
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={`${baseUrl}/reset-password?token=${token}`}
|
||||
>
|
||||
Reset my password
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailForgotPassword;
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "../email-layout";
|
||||
import {Heading, Text} from "@react-email/components";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
os: string;
|
||||
browser: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export const EmailNewLogin = ({firstname, ipAddress, os, browser}: EmailCreateUserProps) => {
|
||||
return (
|
||||
<EmailLayout preview="Portabase - New Login">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Hello <strong>{firstname}</strong>,
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
We detected a new login to your account.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Login details:</strong>
|
||||
<br/>
|
||||
Device: {os} - {browser}
|
||||
<br/>
|
||||
IP Address: {ipAddress}
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailNewLogin;
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {env} from "@/env.mjs";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase Dashboard">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Your account on {env.PROJECT_NAME} has just been created!
|
||||
</Heading>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Email: </strong>{email}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Default password: </strong>{password}
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={baseUrl}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailCreateUser;
|
||||
@@ -10,11 +10,19 @@ export const EmailLayout = ({ children, preview }: PropsWithChildren<{ preview?:
|
||||
<Tailwind>
|
||||
<Html>
|
||||
<Head />
|
||||
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
|
||||
<Body className="bg-gray-100 py-4" style={{ fontFamily: "Arial, sans-serif" }}>
|
||||
<Container className="bg-white border border-gray-200 p-12">
|
||||
<Img src={`${baseUrl}/images/logo-black.png`} width="200" height="auto" alt="Logo" />
|
||||
<Section>{children}</Section>
|
||||
<Body className="mx-auto my-auto bg-white px-2 font-sans">
|
||||
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
|
||||
<Container className="mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={`${baseUrl}/images/logo.png`}
|
||||
width="50"
|
||||
height="auto"
|
||||
alt="Logo"
|
||||
className="mx-auto my-0"
|
||||
/>
|
||||
</Section>
|
||||
{children}
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
import {Text, Section, Button} from "@react-email/components";
|
||||
|
||||
export interface EmailResetPasswordProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const EmailResetPassword = ({url}: EmailResetPasswordProps) => {
|
||||
return (
|
||||
<EmailLayout preview="Email for password reset of your Portabase account">
|
||||
<Text className="text-base font-bold ">Hello !</Text>
|
||||
<Text className="text-base font-light ">You are receiving this email because we
|
||||
received a password reset request for your account.</Text>{" "}
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={url}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-base font-light ">If you did not request a password reset, no
|
||||
further action is required.</Text>
|
||||
<Text className="text-base font-light ">Regards,<br/>Portabase</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailResetPassword;
|
||||
@@ -46,7 +46,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: R
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
// @ts-ignore
|
||||
IconLeft: ({ className, children: _children, ...props }) => <ChevronLeftIcon className={cn("size-4", className)} {...props} />,
|
||||
// @ts-ignore
|
||||
IconRight: ({ className, children: _children, ...props }) => <ChevronRightIcon className={cn("size-4", className)} {...props} />,
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@@ -58,7 +58,7 @@ function DialogContent({
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"max-h-[90vh] overflow-y-auto",
|
||||
"max-h-[90vh] overflow-y-auto overflow-x-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
"use client"
|
||||
import {cn} from "@/lib/utils";
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useId,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Accept,
|
||||
FileRejection,
|
||||
useDropzone as rootUseDropzone,
|
||||
} from "react-dropzone";
|
||||
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
||||
|
||||
type DropzoneResult<TUploadRes, TUploadError> =
|
||||
| {
|
||||
status: "pending";
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
error: TUploadError;
|
||||
}
|
||||
| {
|
||||
status: "success";
|
||||
result: TUploadRes;
|
||||
};
|
||||
|
||||
export type FileStatus<TUploadRes, TUploadError> = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
file: File;
|
||||
tries: number;
|
||||
} & (
|
||||
| {
|
||||
status: "pending";
|
||||
result?: undefined;
|
||||
error?: undefined;
|
||||
}
|
||||
| {
|
||||
status: "error";
|
||||
error: TUploadError;
|
||||
result?: undefined;
|
||||
}
|
||||
| {
|
||||
status: "success";
|
||||
result: TUploadRes;
|
||||
error?: undefined;
|
||||
}
|
||||
);
|
||||
|
||||
const fileStatusReducer = <TUploadRes, TUploadError>(
|
||||
state: FileStatus<TUploadRes, TUploadError>[],
|
||||
action:
|
||||
| {
|
||||
type: "add";
|
||||
id: string;
|
||||
fileName: string;
|
||||
file: File;
|
||||
}
|
||||
| {
|
||||
type: "remove";
|
||||
id: string;
|
||||
}
|
||||
| ({
|
||||
type: "update-status";
|
||||
id: string;
|
||||
} & DropzoneResult<TUploadRes, TUploadError>),
|
||||
): FileStatus<TUploadRes, TUploadError>[] => {
|
||||
switch (action.type) {
|
||||
case "add":
|
||||
return [
|
||||
...state,
|
||||
{
|
||||
id: action.id,
|
||||
fileName: action.fileName,
|
||||
file: action.file,
|
||||
status: "pending",
|
||||
tries: 1,
|
||||
},
|
||||
];
|
||||
case "remove":
|
||||
return state.filter((fileStatus) => fileStatus.id !== action.id);
|
||||
case "update-status":
|
||||
return state.map((fileStatus) => {
|
||||
if (fileStatus.id === action.id) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {id, type, ...rest} = action;
|
||||
return {
|
||||
...fileStatus,
|
||||
...rest,
|
||||
tries:
|
||||
action.status === "pending"
|
||||
? fileStatus.tries + 1
|
||||
: fileStatus.tries,
|
||||
} as FileStatus<TUploadRes, TUploadError>;
|
||||
}
|
||||
return fileStatus;
|
||||
});
|
||||
}
|
||||
};
|
||||
type DropZoneErrorCode = (typeof dropZoneErrorCodes)[number];
|
||||
const dropZoneErrorCodes = [
|
||||
"file-invalid-type",
|
||||
"file-too-large",
|
||||
"file-too-small",
|
||||
"too-many-files",
|
||||
] as const;
|
||||
|
||||
const getDropZoneErrorCodes = (fileRejections: FileRejection[]) => {
|
||||
const errors = fileRejections.map((rejection) => {
|
||||
return rejection.errors
|
||||
.filter((error) =>
|
||||
dropZoneErrorCodes.includes(error.code as DropZoneErrorCode),
|
||||
)
|
||||
.map((error) => error.code) as DropZoneErrorCode[];
|
||||
});
|
||||
return Array.from(new Set(errors.flat()));
|
||||
};
|
||||
|
||||
const getRootError = (
|
||||
errorCodes: DropZoneErrorCode[],
|
||||
limits: {
|
||||
accept?: Accept;
|
||||
maxSize?: number;
|
||||
minSize?: number;
|
||||
maxFiles?: number;
|
||||
},
|
||||
) => {
|
||||
const errors = errorCodes.map((error) => {
|
||||
switch (error) {
|
||||
case "file-invalid-type":
|
||||
const acceptedTypes = Object.values(limits.accept ?? {})
|
||||
.flat()
|
||||
.join(", ");
|
||||
return `only ${acceptedTypes} are allowed`;
|
||||
case "file-too-large":
|
||||
const maxMb = limits.maxSize
|
||||
? (limits.maxSize / (1024 * 1024)).toFixed(2)
|
||||
: "infinite?";
|
||||
return `max size is ${maxMb}MB`;
|
||||
case "file-too-small":
|
||||
const roundedMinSize = limits.minSize
|
||||
? (limits.minSize / (1024 * 1024)).toFixed(2)
|
||||
: "negative?";
|
||||
return `min size is ${roundedMinSize}MB`;
|
||||
case "too-many-files":
|
||||
return `max ${limits.maxFiles} files`;
|
||||
}
|
||||
});
|
||||
const joinedErrors = errors.join(", ");
|
||||
return joinedErrors.charAt(0).toUpperCase() + joinedErrors.slice(1);
|
||||
};
|
||||
|
||||
type UseDropzoneProps<TUploadRes, TUploadError> = {
|
||||
onDropFile: (
|
||||
file: File,
|
||||
) => Promise<
|
||||
Exclude<DropzoneResult<TUploadRes, TUploadError>, { status: "pending" }>
|
||||
>;
|
||||
onRemoveFile?: (id: string) => void | Promise<void>;
|
||||
onFileUploaded?: (result: TUploadRes) => void;
|
||||
onFileUploadError?: (error: TUploadError) => void;
|
||||
onAllUploaded?: () => void;
|
||||
onRootError?: (error: string | undefined) => void;
|
||||
maxRetryCount?: number;
|
||||
autoRetry?: boolean;
|
||||
validation?: {
|
||||
accept?: Accept;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
maxFiles?: number;
|
||||
};
|
||||
shiftOnMaxFiles?: boolean;
|
||||
} & (TUploadError extends string
|
||||
? {
|
||||
shapeUploadError?: (error: TUploadError) => string | void;
|
||||
}
|
||||
: {
|
||||
shapeUploadError: (error: TUploadError) => string | void;
|
||||
});
|
||||
|
||||
interface UseDropzoneReturn<TUploadRes, TUploadError> {
|
||||
getRootProps: ReturnType<typeof rootUseDropzone>["getRootProps"];
|
||||
getInputProps: ReturnType<typeof rootUseDropzone>["getInputProps"];
|
||||
onRemoveFile: (id: string) => Promise<void>;
|
||||
onRetry: (id: string) => Promise<void>;
|
||||
canRetry: (id: string) => boolean;
|
||||
fileStatuses: FileStatus<TUploadRes, TUploadError>[];
|
||||
isInvalid: boolean;
|
||||
isDragActive: boolean;
|
||||
rootError: string | undefined;
|
||||
inputId: string;
|
||||
rootMessageId: string;
|
||||
rootDescriptionId: string;
|
||||
getFileMessageId: (id: string) => string;
|
||||
}
|
||||
|
||||
const useDropzone = <TUploadRes, TUploadError = string>(
|
||||
props: UseDropzoneProps<TUploadRes, TUploadError>,
|
||||
): UseDropzoneReturn<TUploadRes, TUploadError> => {
|
||||
const {
|
||||
onDropFile: pOnDropFile,
|
||||
onRemoveFile: pOnRemoveFile,
|
||||
shapeUploadError: pShapeUploadError,
|
||||
onFileUploaded: pOnFileUploaded,
|
||||
onFileUploadError: pOnFileUploadError,
|
||||
onAllUploaded: pOnAllUploaded,
|
||||
onRootError: pOnRootError,
|
||||
maxRetryCount,
|
||||
autoRetry,
|
||||
validation,
|
||||
shiftOnMaxFiles,
|
||||
} = props;
|
||||
|
||||
const inputId = useId();
|
||||
const rootMessageId = `${inputId}-root-message`;
|
||||
const rootDescriptionId = `${inputId}-description`;
|
||||
const [rootError, _setRootError] = useState<string | undefined>(undefined);
|
||||
|
||||
const setRootError = useCallback(
|
||||
(error: string | undefined) => {
|
||||
_setRootError(error);
|
||||
if (pOnRootError !== undefined) {
|
||||
pOnRootError(error);
|
||||
}
|
||||
},
|
||||
[pOnRootError, _setRootError],
|
||||
);
|
||||
|
||||
const [fileStatuses, dispatch] = useReducer(fileStatusReducer, []);
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
return (
|
||||
fileStatuses.filter((file) => file.status === "error").length > 0 ||
|
||||
rootError !== undefined
|
||||
);
|
||||
}, [fileStatuses, rootError]);
|
||||
|
||||
const _uploadFile = useCallback(
|
||||
async (file: File, id: string, tries = 0) => {
|
||||
const result = await pOnDropFile(file);
|
||||
|
||||
if (result.status === "error") {
|
||||
if (autoRetry === true && tries < (maxRetryCount ?? Infinity)) {
|
||||
dispatch({type: "update-status", id, status: "pending"});
|
||||
return _uploadFile(file, id, tries + 1);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "update-status",
|
||||
id,
|
||||
status: "error",
|
||||
error:
|
||||
pShapeUploadError !== undefined
|
||||
? pShapeUploadError(result.error)
|
||||
: result.error,
|
||||
});
|
||||
if (pOnFileUploadError !== undefined) {
|
||||
pOnFileUploadError(result.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pOnFileUploaded !== undefined) {
|
||||
pOnFileUploaded(result.result);
|
||||
}
|
||||
dispatch({
|
||||
type: "update-status",
|
||||
id,
|
||||
...result,
|
||||
});
|
||||
},
|
||||
[
|
||||
autoRetry,
|
||||
maxRetryCount,
|
||||
pOnDropFile,
|
||||
pShapeUploadError,
|
||||
pOnFileUploadError,
|
||||
pOnFileUploaded,
|
||||
],
|
||||
);
|
||||
|
||||
const onRemoveFile = useCallback(
|
||||
async (id: string) => {
|
||||
await pOnRemoveFile?.(id);
|
||||
dispatch({type: "remove", id});
|
||||
},
|
||||
[pOnRemoveFile],
|
||||
);
|
||||
|
||||
const canRetry = useCallback(
|
||||
(id: string) => {
|
||||
const fileStatus = fileStatuses.find((file) => file.id === id);
|
||||
return (
|
||||
fileStatus?.status === "error" &&
|
||||
fileStatus.tries < (maxRetryCount ?? Infinity)
|
||||
);
|
||||
},
|
||||
[fileStatuses, maxRetryCount],
|
||||
);
|
||||
|
||||
const onRetry = useCallback(
|
||||
async (id: string) => {
|
||||
if (!canRetry(id)) {
|
||||
return;
|
||||
}
|
||||
dispatch({type: "update-status", id, status: "pending"});
|
||||
const fileStatus = fileStatuses.find((file) => file.id === id);
|
||||
if (!fileStatus || fileStatus.status !== "error") {
|
||||
return;
|
||||
}
|
||||
await _uploadFile(fileStatus.file, id);
|
||||
},
|
||||
[canRetry, fileStatuses, _uploadFile],
|
||||
);
|
||||
|
||||
const getFileMessageId = (id: string) => `${inputId}-${id}-message`;
|
||||
|
||||
const dropzone = rootUseDropzone({
|
||||
accept: validation?.accept,
|
||||
minSize: validation?.minSize,
|
||||
maxSize: validation?.maxSize,
|
||||
onDropAccepted: async (newFiles) => {
|
||||
setRootError(undefined);
|
||||
|
||||
// useDropzone hook only checks max file count per group of uploaded files, allows going over if in multiple batches
|
||||
const fileCount = fileStatuses.length;
|
||||
const maxNewFiles =
|
||||
validation?.maxFiles === undefined
|
||||
? Infinity
|
||||
: validation?.maxFiles - fileCount;
|
||||
|
||||
if (maxNewFiles < newFiles.length) {
|
||||
if (shiftOnMaxFiles === true) {
|
||||
} else {
|
||||
setRootError(getRootError(["too-many-files"], validation ?? {}));
|
||||
}
|
||||
}
|
||||
|
||||
const slicedNewFiles =
|
||||
shiftOnMaxFiles === true ? newFiles : newFiles.slice(0, maxNewFiles);
|
||||
|
||||
const onDropFilePromises = slicedNewFiles.map(async (file, index) => {
|
||||
if (fileCount + 1 > maxNewFiles) {
|
||||
await onRemoveFile(fileStatuses[index].id);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
dispatch({type: "add", fileName: file.name, file, id});
|
||||
await _uploadFile(file, id);
|
||||
});
|
||||
|
||||
await Promise.all(onDropFilePromises);
|
||||
if (pOnAllUploaded !== undefined) {
|
||||
pOnAllUploaded();
|
||||
}
|
||||
},
|
||||
onDropRejected: (fileRejections) => {
|
||||
const errorMessage = getRootError(
|
||||
getDropZoneErrorCodes(fileRejections),
|
||||
validation ?? {}
|
||||
);
|
||||
setRootError(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
getRootProps: dropzone.getRootProps,
|
||||
getInputProps: dropzone.getInputProps,
|
||||
inputId,
|
||||
rootMessageId,
|
||||
rootDescriptionId,
|
||||
getFileMessageId,
|
||||
onRemoveFile,
|
||||
onRetry,
|
||||
canRetry,
|
||||
fileStatuses: fileStatuses as FileStatus<TUploadRes, TUploadError>[],
|
||||
isInvalid,
|
||||
rootError,
|
||||
isDragActive: dropzone.isDragActive,
|
||||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const DropZoneContext = createContext<UseDropzoneReturn<any, any>>({
|
||||
getRootProps: () => ({}) as never,
|
||||
getInputProps: () => ({}) as never,
|
||||
onRemoveFile: async () => {
|
||||
},
|
||||
onRetry: async () => {
|
||||
},
|
||||
canRetry: () => false,
|
||||
fileStatuses: [],
|
||||
isInvalid: false,
|
||||
isDragActive: false,
|
||||
rootError: undefined,
|
||||
inputId: "",
|
||||
rootMessageId: "",
|
||||
rootDescriptionId: "",
|
||||
getFileMessageId: () => "",
|
||||
});
|
||||
|
||||
const useDropzoneContext = <TUploadRes, TUploadError>() => {
|
||||
return useContext(DropZoneContext) as UseDropzoneReturn<
|
||||
TUploadRes,
|
||||
TUploadError
|
||||
>;
|
||||
};
|
||||
|
||||
interface DropzoneProps<TUploadRes, TUploadError>
|
||||
extends UseDropzoneReturn<TUploadRes, TUploadError> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Dropzone = <TUploadRes, TUploadError>(
|
||||
props: DropzoneProps<TUploadRes, TUploadError>,
|
||||
) => {
|
||||
const {children, ...rest} = props;
|
||||
return (
|
||||
<DropZoneContext.Provider value={rest}>{children}</DropZoneContext.Provider>
|
||||
);
|
||||
};
|
||||
Dropzone.displayName = "Dropzone";
|
||||
|
||||
interface DropZoneAreaProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
const DropZoneArea = forwardRef<HTMLDivElement, DropZoneAreaProps>(
|
||||
({className, children, ...props}, forwardedRef) => {
|
||||
const context = useDropzoneContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("DropzoneArea must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const {onFocus, onBlur, onDragEnter, onDragLeave, onDrop, ref} =
|
||||
context.getRootProps();
|
||||
|
||||
return (
|
||||
// A11y behavior is handled through Trigger. All of these are only relevant to drag and drop which means this should be fine?
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
ref={(instance) => {
|
||||
// TODO: test if this actually works?
|
||||
ref.current = instance;
|
||||
if (typeof forwardedRef === "function") {
|
||||
forwardedRef(instance);
|
||||
} else if (forwardedRef) {
|
||||
forwardedRef.current = instance;
|
||||
}
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
{...props}
|
||||
aria-label="dropzone"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
context.isDragActive && "animate-pulse bg-black/5",
|
||||
context.isInvalid && "border-destructive",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropZoneArea.displayName = "DropZoneArea";
|
||||
|
||||
export interface DropzoneDescriptionProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneDescription = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
DropzoneDescriptionProps
|
||||
>((props, ref) => {
|
||||
const {className, ...rest} = props;
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneDescription must be used within a Dropzone");
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.rootDescriptionId}
|
||||
{...rest}
|
||||
className={cn("pb-1 text-sm text-muted-foreground", className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DropzoneDescription.displayName = "DropzoneDescription";
|
||||
|
||||
interface DropzoneFileListContext<TUploadRes, TUploadError> {
|
||||
onRemoveFile: () => Promise<void>;
|
||||
onRetry: () => Promise<void>;
|
||||
fileStatus: FileStatus<TUploadRes, TUploadError>;
|
||||
canRetry: boolean;
|
||||
dropzoneId: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
const DropzoneFileListContext = createContext<
|
||||
DropzoneFileListContext<unknown, unknown>
|
||||
>({
|
||||
onRemoveFile: async () => {
|
||||
},
|
||||
onRetry: async () => {
|
||||
},
|
||||
fileStatus: {} as FileStatus<unknown, unknown>,
|
||||
canRetry: false,
|
||||
dropzoneId: "",
|
||||
messageId: "",
|
||||
});
|
||||
|
||||
const useDropzoneFileListContext = () => {
|
||||
return useContext(DropzoneFileListContext);
|
||||
};
|
||||
|
||||
interface DropZoneFileListProps
|
||||
extends React.OlHTMLAttributes<HTMLOListElement> {
|
||||
}
|
||||
|
||||
const DropzoneFileList = forwardRef<HTMLOListElement, DropZoneFileListProps>(
|
||||
(props, ref) => {
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneFileList must be used within a Dropzone");
|
||||
}
|
||||
return (
|
||||
<ol
|
||||
ref={ref}
|
||||
aria-label="dropzone-file-list"
|
||||
{...props}
|
||||
className={cn("flex flex-col gap-4", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneFileList.displayName = "DropzoneFileList";
|
||||
|
||||
interface DropzoneFileListItemProps<TUploadRes, TUploadError>
|
||||
extends React.LiHTMLAttributes<HTMLLIElement> {
|
||||
file: FileStatus<TUploadRes, TUploadError>;
|
||||
}
|
||||
|
||||
const DropzoneFileListItem = forwardRef<
|
||||
HTMLLIElement,
|
||||
DropzoneFileListItemProps<unknown, unknown>
|
||||
>(({className, ...props}, ref) => {
|
||||
const fileId = props.file.id;
|
||||
const {
|
||||
onRemoveFile: cOnRemoveFile,
|
||||
onRetry: cOnRetry,
|
||||
getFileMessageId: cGetFileMessageId,
|
||||
canRetry: cCanRetry,
|
||||
inputId: cInputId,
|
||||
} = useDropzoneContext();
|
||||
|
||||
const onRemoveFile = useCallback(
|
||||
() => cOnRemoveFile(fileId),
|
||||
[fileId, cOnRemoveFile],
|
||||
);
|
||||
const onRetry = useCallback(() => cOnRetry(fileId), [fileId, cOnRetry]);
|
||||
const messageId = cGetFileMessageId(fileId);
|
||||
const isInvalid = props.file.status === "error";
|
||||
const canRetry = useMemo(() => cCanRetry(fileId), [fileId, cCanRetry]);
|
||||
return (
|
||||
<DropzoneFileListContext.Provider
|
||||
value={{
|
||||
onRemoveFile,
|
||||
onRetry,
|
||||
fileStatus: props.file,
|
||||
canRetry,
|
||||
dropzoneId: cInputId,
|
||||
messageId,
|
||||
}}
|
||||
>
|
||||
<li
|
||||
ref={ref}
|
||||
aria-label="dropzone-file-list-item"
|
||||
aria-describedby={isInvalid ? messageId : undefined}
|
||||
className={cn(
|
||||
"flex flex-col justify-center gap-2 rounded-md bg-muted/40 px-4 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</li>
|
||||
</DropzoneFileListContext.Provider>
|
||||
);
|
||||
});
|
||||
DropzoneFileListItem.displayName = "DropzoneFileListItem";
|
||||
|
||||
interface DropzoneFileMessageProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneFileMessage = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
DropzoneFileMessageProps
|
||||
>((props, ref) => {
|
||||
const {children, ...rest} = props;
|
||||
const context = useDropzoneFileListContext();
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneFileMessage must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
|
||||
const body =
|
||||
context.fileStatus.status === "error"
|
||||
? String(context.fileStatus.error)
|
||||
: children;
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.messageId}
|
||||
{...rest}
|
||||
className={cn(
|
||||
"h-5 text-[0.8rem] font-medium text-destructive",
|
||||
rest.className,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
DropzoneFileMessage.displayName = "DropzoneFileMessage";
|
||||
|
||||
interface DropzoneMessageProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
}
|
||||
|
||||
const DropzoneMessage = forwardRef<HTMLParagraphElement, DropzoneMessageProps>(
|
||||
(props, ref) => {
|
||||
const {children, ...rest} = props;
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneRootMessage must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const body = context.rootError ? String(context.rootError) : children;
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={context.rootMessageId}
|
||||
{...rest}
|
||||
className={cn(
|
||||
"h-5 text-[0.8rem] font-medium text-destructive",
|
||||
rest.className,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneMessage.displayName = "DropzoneMessage";
|
||||
|
||||
interface DropzoneRemoveFileProps extends ButtonVariantsProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DropzoneRemoveFile = forwardRef<
|
||||
HTMLButtonElement,
|
||||
DropzoneRemoveFileProps
|
||||
>(({className, ...props}, ref) => {
|
||||
const context = useDropzoneFileListContext();
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneRemoveFile must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
onClick={context.onRemoveFile}
|
||||
type="button"
|
||||
size="icon"
|
||||
{...props}
|
||||
className={cn(
|
||||
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
<span className="sr-only">Remove file</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
DropzoneRemoveFile.displayName = "DropzoneRemoveFile";
|
||||
|
||||
interface DropzoneRetryFileProps extends ButtonVariantsProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DropzoneRetryFile = forwardRef<HTMLButtonElement, DropzoneRetryFileProps>(
|
||||
({className, ...props}, ref) => {
|
||||
const context = useDropzoneFileListContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"DropzoneRetryFile must be used within a DropzoneFileListItem",
|
||||
);
|
||||
}
|
||||
|
||||
const canRetry = context.canRetry;
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
aria-disabled={!canRetry}
|
||||
aria-label="retry"
|
||||
onClick={context.onRetry}
|
||||
type="button"
|
||||
size="icon"
|
||||
{...props}
|
||||
className={cn(
|
||||
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
<span className="sr-only">Retry</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneRetryFile.displayName = "DropzoneRetryFile";
|
||||
|
||||
interface DropzoneTriggerProps
|
||||
extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
}
|
||||
|
||||
const DropzoneTrigger = forwardRef<HTMLLabelElement, DropzoneTriggerProps>(
|
||||
({className, children, ...props}, ref) => {
|
||||
const context = useDropzoneContext();
|
||||
if (!context) {
|
||||
throw new Error("DropzoneTrigger must be used within a Dropzone");
|
||||
}
|
||||
|
||||
const {fileStatuses, getFileMessageId} = context;
|
||||
|
||||
const fileMessageIds = useMemo(
|
||||
() =>
|
||||
fileStatuses
|
||||
.filter((file) => file.status === "error")
|
||||
.map((file) => getFileMessageId(file.id)),
|
||||
[fileStatuses, getFileMessageId],
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-sm bg-secondary px-4 py-2 font-medium ring-offset-background transition-colors focus-within:outline-none hover:bg-secondary/80 has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring has-[input:focus-visible]:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<input
|
||||
{...context.getInputProps({
|
||||
style: {
|
||||
display: undefined,
|
||||
},
|
||||
className: "sr-only",
|
||||
tabIndex: undefined,
|
||||
})}
|
||||
aria-describedby={
|
||||
context.isInvalid
|
||||
? [context.rootMessageId, ...fileMessageIds].join(" ")
|
||||
: undefined
|
||||
}
|
||||
aria-invalid={context.isInvalid}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropzoneTrigger.displayName = "DropzoneTrigger";
|
||||
|
||||
interface InfiniteProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
status: "pending" | "success" | "error";
|
||||
}
|
||||
|
||||
const valueTextMap = {
|
||||
pending: "indeterminate",
|
||||
success: "100%",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const InfiniteProgress = forwardRef<HTMLDivElement, InfiniteProgressProps>(
|
||||
({className, ...props}, ref) => {
|
||||
const done = props.status === "success" || props.status === "error";
|
||||
const error = props.status === "error";
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuetext={valueTextMap[props.status]}
|
||||
{...props}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
// TODO: add proper done transition
|
||||
className={cn(
|
||||
"h-full w-full rounded-full bg-primary",
|
||||
done ? "translate-x-0" : "animate-infinite-progress",
|
||||
error && "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
InfiniteProgress.displayName = "InfiniteProgress";
|
||||
|
||||
export {
|
||||
Dropzone,
|
||||
DropZoneArea,
|
||||
DropzoneDescription,
|
||||
DropzoneFileList,
|
||||
DropzoneFileListItem,
|
||||
DropzoneFileMessage,
|
||||
DropzoneMessage,
|
||||
DropzoneRemoveFile,
|
||||
DropzoneRetryFile,
|
||||
DropzoneTrigger,
|
||||
InfiniteProgress,
|
||||
useDropzone,
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'mo
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const githubButtonVariants = cva(
|
||||
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
|
||||
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||