From da0fec662457f257cc66114a6fc1e2d9228525a6 Mon Sep 17 00:00:00 2001 From: Rostislav Dugin Date: Sun, 16 Nov 2025 23:38:20 +0300 Subject: [PATCH] FEATURE (azure): Add Azure Blob Storage --- .github/workflows/ci-release.yml | 5 + backend/.env.development.example | 4 +- backend/docker-compose.yml.example | 8 + backend/go.mod | 18 +- backend/go.sum | 50 ++-- backend/internal/config/config.go | 7 + backend/internal/features/storages/enums.go | 1 + backend/internal/features/storages/model.go | 8 + .../internal/features/storages/model_test.go | 86 ++++++ .../storages/models/azure_blob/model.go | 269 ++++++++++++++++++ .../internal/features/storages/repository.go | 23 +- .../features/users/models/secret_key.go | 2 +- .../20251116195618_add_azure_blob_storage.sql | 28 ++ frontend/public/icons/storages/azure.svg | 9 + frontend/src/entity/storages/index.ts | 1 + .../storages/models/AzureBlobStorage.ts | 9 + .../src/entity/storages/models/Storage.ts | 2 + .../src/entity/storages/models/StorageType.ts | 1 + .../storages/models/getStorageLogoFromType.ts | 2 + .../storages/models/getStorageNameFromType.ts | 2 + .../storages/ui/edit/EditStorageComponent.tsx | 46 +++ .../EditAzureBlobStorageComponent.tsx | 226 +++++++++++++++ .../storages/ui/show/ShowStorageComponent.tsx | 7 + .../ShowAzureBlobStorageComponent.tsx | 56 ++++ 24 files changed, 843 insertions(+), 27 deletions(-) create mode 100644 backend/internal/features/storages/models/azure_blob/model.go create mode 100644 backend/migrations/20251116195618_add_azure_blob_storage.sql create mode 100644 frontend/public/icons/storages/azure.svg create mode 100644 frontend/src/entity/storages/models/AzureBlobStorage.ts create mode 100644 frontend/src/features/storages/ui/edit/storages/EditAzureBlobStorageComponent.tsx create mode 100644 frontend/src/features/storages/ui/show/storages/ShowAzureBlobStorageComponent.tsx diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 7620b87..6601068 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -137,6 +137,8 @@ jobs: # testing S3 TEST_MINIO_PORT=9000 TEST_MINIO_CONSOLE_PORT=9001 + # testing Azure Blob + TEST_AZURITE_BLOB_PORT=10000 # testing NAS TEST_NAS_PORT=7006 # testing Telegram @@ -165,6 +167,9 @@ jobs: # Wait for MinIO timeout 60 bash -c 'until nc -z localhost 9000; do sleep 2; done' + # Wait for Azurite + timeout 60 bash -c 'until nc -z localhost 10000; do sleep 2; done' + - name: Create data and temp directories run: | # Create directories that are used for backups and restore diff --git a/backend/.env.development.example b/backend/.env.development.example index 0509869..fd3fd8d 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -31,4 +31,6 @@ TEST_MINIO_CONSOLE_PORT=9001 TEST_NAS_PORT=7006 # testing Telegram TEST_TELEGRAM_BOT_TOKEN= -TEST_TELEGRAM_CHAT_ID= \ No newline at end of file +TEST_TELEGRAM_CHAT_ID= +# testing Azure Blob Storage +TEST_AZURITE_BLOB_PORT=10000 \ No newline at end of file diff --git a/backend/docker-compose.yml.example b/backend/docker-compose.yml.example index 2c5785d..97f90e1 100644 --- a/backend/docker-compose.yml.example +++ b/backend/docker-compose.yml.example @@ -31,6 +31,14 @@ services: container_name: test-minio command: server /data --console-address ":9001" + # Test Azurite container + test-azurite: + image: mcr.microsoft.com/azure-storage/azurite + ports: + - "${TEST_AZURITE_BLOB_PORT:-10000}:10000" + container_name: test-azurite + command: azurite-blob --blobHost 0.0.0.0 + # Test PostgreSQL containers test-postgres-12: image: postgres:12 diff --git a/backend/go.mod b/backend/go.mod index e983961..9366ac4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,8 @@ module postgresus-backend go 1.23.3 require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 github.com/gin-contrib/cors v1.7.5 github.com/gin-contrib/gzip v1.2.3 github.com/gin-gonic/gin v1.10.0 @@ -15,16 +17,18 @@ require ( github.com/lib/pq v1.10.9 github.com/minio/minio-go/v7 v7.0.92 github.com/shirou/gopsutil/v4 v4.25.5 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.4 - golang.org/x/crypto v0.39.0 + golang.org/x/crypto v0.41.0 golang.org/x/time v0.12.0 gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.26.1 ) +require github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + require ( cloud.google.com/go/auth v0.16.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect @@ -99,12 +103,12 @@ require ( go.opentelemetry.io/otel/metric v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect golang.org/x/arch v0.17.0 // indirect - golang.org/x/net v0.41.0 // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect - golang.org/x/tools v0.33.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.35.0 // indirect google.golang.org/api v0.239.0 google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index a4a1375..45f30c8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,6 +6,18 @@ cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeO cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 h1:KpMC6LFL7mqpExyMC9jVOYRiVhLmamjeZfRsUpB7l4s= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8= +github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= @@ -80,6 +92,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -131,6 +145,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -159,6 +175,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= @@ -180,8 +198,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= @@ -216,25 +234,25 @@ golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -247,8 +265,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -257,15 +275,15 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo= google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 5022160..10f6a01 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -44,6 +44,8 @@ type EnvVariables struct { TestMinioPort string `env:"TEST_MINIO_PORT"` TestMinioConsolePort string `env:"TEST_MINIO_CONSOLE_PORT"` + TestAzuriteBlobPort string `env:"TEST_AZURITE_BLOB_PORT"` + TestNASPort string `env:"TEST_NAS_PORT"` // oauth @@ -184,6 +186,11 @@ func loadEnvVariables() { os.Exit(1) } + if env.TestAzuriteBlobPort == "" { + log.Error("TEST_AZURITE_BLOB_PORT is empty") + os.Exit(1) + } + if env.TestNASPort == "" { log.Error("TEST_NAS_PORT is empty") os.Exit(1) diff --git a/backend/internal/features/storages/enums.go b/backend/internal/features/storages/enums.go index f9856e1..aa96a95 100644 --- a/backend/internal/features/storages/enums.go +++ b/backend/internal/features/storages/enums.go @@ -7,4 +7,5 @@ const ( StorageTypeS3 StorageType = "S3" StorageTypeGoogleDrive StorageType = "GOOGLE_DRIVE" StorageTypeNAS StorageType = "NAS" + StorageTypeAzureBlob StorageType = "AZURE_BLOB" ) diff --git a/backend/internal/features/storages/model.go b/backend/internal/features/storages/model.go index c422999..0e70137 100644 --- a/backend/internal/features/storages/model.go +++ b/backend/internal/features/storages/model.go @@ -4,6 +4,7 @@ import ( "errors" "io" "log/slog" + azure_blob_storage "postgresus-backend/internal/features/storages/models/azure_blob" google_drive_storage "postgresus-backend/internal/features/storages/models/google_drive" local_storage "postgresus-backend/internal/features/storages/models/local" nas_storage "postgresus-backend/internal/features/storages/models/nas" @@ -24,6 +25,7 @@ type Storage struct { S3Storage *s3_storage.S3Storage `json:"s3Storage" gorm:"foreignKey:StorageID"` GoogleDriveStorage *google_drive_storage.GoogleDriveStorage `json:"googleDriveStorage" gorm:"foreignKey:StorageID"` NASStorage *nas_storage.NASStorage `json:"nasStorage" gorm:"foreignKey:StorageID"` + AzureBlobStorage *azure_blob_storage.AzureBlobStorage `json:"azureBlobStorage" gorm:"foreignKey:StorageID"` } func (s *Storage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error { @@ -88,6 +90,10 @@ func (s *Storage) Update(incoming *Storage) { if s.NASStorage != nil && incoming.NASStorage != nil { s.NASStorage.Update(incoming.NASStorage) } + case StorageTypeAzureBlob: + if s.AzureBlobStorage != nil && incoming.AzureBlobStorage != nil { + s.AzureBlobStorage.Update(incoming.AzureBlobStorage) + } } } @@ -101,6 +107,8 @@ func (s *Storage) getSpecificStorage() StorageFileSaver { return s.GoogleDriveStorage case StorageTypeNAS: return s.NASStorage + case StorageTypeAzureBlob: + return s.AzureBlobStorage default: panic("invalid storage type: " + string(s.Type)) } diff --git a/backend/internal/features/storages/model_test.go b/backend/internal/features/storages/model_test.go index 18833ef..41c167e 100644 --- a/backend/internal/features/storages/model_test.go +++ b/backend/internal/features/storages/model_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "postgresus-backend/internal/config" + azure_blob_storage "postgresus-backend/internal/features/storages/models/azure_blob" google_drive_storage "postgresus-backend/internal/features/storages/models/google_drive" local_storage "postgresus-backend/internal/features/storages/models/local" nas_storage "postgresus-backend/internal/features/storages/models/nas" @@ -17,6 +18,7 @@ import ( "testing" "time" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" "github.com/google/uuid" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" @@ -32,6 +34,15 @@ type S3Container struct { region string } +type AzuriteContainer struct { + endpoint string + accountName string + accountKey string + containerNameKey string + containerNameStr string + connectionString string +} + func Test_Storage_BasicOperations(t *testing.T) { ctx := context.Background() @@ -41,6 +52,10 @@ func Test_Storage_BasicOperations(t *testing.T) { s3Container, err := setupS3Container(ctx) require.NoError(t, err, "Failed to setup S3 container") + // Setup Azurite connection + azuriteContainer, err := setupAzuriteContainer(ctx) + require.NoError(t, err, "Failed to setup Azurite container") + // Setup test file testFilePath, err := setupTestFile() require.NoError(t, err, "Failed to setup test file") @@ -88,6 +103,26 @@ func Test_Storage_BasicOperations(t *testing.T) { Path: "test-files", }, }, + { + name: "AzureBlobStorage_AccountKey", + storage: &azure_blob_storage.AzureBlobStorage{ + StorageID: uuid.New(), + AuthMethod: azure_blob_storage.AuthMethodAccountKey, + AccountName: azuriteContainer.accountName, + AccountKey: azuriteContainer.accountKey, + ContainerName: azuriteContainer.containerNameKey, + Endpoint: azuriteContainer.endpoint, + }, + }, + { + name: "AzureBlobStorage_ConnectionString", + storage: &azure_blob_storage.AzureBlobStorage{ + StorageID: uuid.New(), + AuthMethod: azure_blob_storage.AuthMethodConnectionString, + ConnectionString: azuriteContainer.connectionString, + ContainerName: azuriteContainer.containerNameStr, + }, + }, } // Add Google Drive storage test only if environment variables are available @@ -230,8 +265,59 @@ func setupS3Container(ctx context.Context) (*S3Container, error) { }, nil } +func setupAzuriteContainer(ctx context.Context) (*AzuriteContainer, error) { + env := config.GetEnv() + + accountName := "devstoreaccount1" + // this is real testing key for azurite, it's not a real key + accountKey := "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==" + serviceURL := fmt.Sprintf("http://127.0.0.1:%s/%s", env.TestAzuriteBlobPort, accountName) + containerNameKey := "test-container-key" + containerNameStr := "test-container-connstr" + + // Build explicit connection string for Azurite + connectionString := fmt.Sprintf( + "DefaultEndpointsProtocol=http;AccountName=%s;AccountKey=%s;BlobEndpoint=http://127.0.0.1:%s/%s", + accountName, + accountKey, + env.TestAzuriteBlobPort, + accountName, + ) + + // Create client using connection string to set up containers + client, err := azblob.NewClientFromConnectionString(connectionString, nil) + if err != nil { + return nil, fmt.Errorf("failed to create azblob client: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + // Create container for account key auth + _, err = client.CreateContainer(ctx, containerNameKey, nil) + if err != nil { + // Container might already exist, that's okay + } + + // Create container for connection string auth + _, err = client.CreateContainer(ctx, containerNameStr, nil) + if err != nil { + // Container might already exist, that's okay + } + + return &AzuriteContainer{ + endpoint: serviceURL, + accountName: accountName, + accountKey: accountKey, + containerNameKey: containerNameKey, + containerNameStr: containerNameStr, + connectionString: connectionString, + }, nil +} + func validateEnvVariables(t *testing.T) { env := config.GetEnv() assert.NotEmpty(t, env.TestMinioPort, "TEST_MINIO_PORT is empty") + assert.NotEmpty(t, env.TestAzuriteBlobPort, "TEST_AZURITE_BLOB_PORT is empty") assert.NotEmpty(t, env.TestNASPort, "TEST_NAS_PORT is empty") } diff --git a/backend/internal/features/storages/models/azure_blob/model.go b/backend/internal/features/storages/models/azure_blob/model.go new file mode 100644 index 0000000..e801c95 --- /dev/null +++ b/backend/internal/features/storages/models/azure_blob/model.go @@ -0,0 +1,269 @@ +package azure_blob_storage + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/google/uuid" +) + +type AuthMethod string + +const ( + AuthMethodConnectionString AuthMethod = "CONNECTION_STRING" + AuthMethodAccountKey AuthMethod = "ACCOUNT_KEY" +) + +type AzureBlobStorage struct { + StorageID uuid.UUID `json:"storageId" gorm:"primaryKey;type:uuid;column:storage_id"` + AuthMethod AuthMethod `json:"authMethod" gorm:"not null;type:text;column:auth_method"` + ConnectionString string `json:"connectionString" gorm:"type:text;column:connection_string"` + AccountName string `json:"accountName" gorm:"type:text;column:account_name"` + AccountKey string `json:"accountKey" gorm:"type:text;column:account_key"` + ContainerName string `json:"containerName" gorm:"not null;type:text;column:container_name"` + Endpoint string `json:"endpoint" gorm:"type:text;column:endpoint"` + Prefix string `json:"prefix" gorm:"type:text;column:prefix"` +} + +func (s *AzureBlobStorage) TableName() string { + return "azure_blob_storages" +} + +func (s *AzureBlobStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error { + client, err := s.getClient() + if err != nil { + return err + } + + blobName := s.buildBlobName(fileID.String()) + + _, err = client.UploadStream( + context.TODO(), + s.ContainerName, + blobName, + file, + nil, + ) + if err != nil { + return fmt.Errorf("failed to upload blob to Azure: %w", err) + } + + return nil +} + +func (s *AzureBlobStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) { + client, err := s.getClient() + if err != nil { + return nil, err + } + + blobName := s.buildBlobName(fileID.String()) + + response, err := client.DownloadStream( + context.TODO(), + s.ContainerName, + blobName, + nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to download blob from Azure: %w", err) + } + + return response.Body, nil +} + +func (s *AzureBlobStorage) DeleteFile(fileID uuid.UUID) error { + client, err := s.getClient() + if err != nil { + return err + } + + blobName := s.buildBlobName(fileID.String()) + + _, err = client.DeleteBlob( + context.TODO(), + s.ContainerName, + blobName, + nil, + ) + if err != nil { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && respErr.StatusCode == 404 { + return nil + } + return fmt.Errorf("failed to delete blob from Azure: %w", err) + } + + return nil +} + +func (s *AzureBlobStorage) Validate() error { + if s.ContainerName == "" { + return errors.New("container name is required") + } + + switch s.AuthMethod { + case AuthMethodConnectionString: + if s.ConnectionString == "" { + return errors.New( + "connection string is required when using CONNECTION_STRING auth method", + ) + } + case AuthMethodAccountKey: + if s.AccountName == "" { + return errors.New("account name is required when using ACCOUNT_KEY auth method") + } + if s.AccountKey == "" { + return errors.New("account key is required when using ACCOUNT_KEY auth method") + } + default: + return fmt.Errorf("invalid auth method: %s", s.AuthMethod) + } + + _, err := s.getClient() + if err != nil { + return fmt.Errorf("invalid Azure Blob configuration: %w", err) + } + + return nil +} + +func (s *AzureBlobStorage) TestConnection() error { + client, err := s.getClient() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + containerClient := client.ServiceClient().NewContainerClient(s.ContainerName) + _, err = containerClient.GetProperties(ctx, nil) + if err != nil { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) { + if respErr.StatusCode == 404 { + return fmt.Errorf("container '%s' does not exist", s.ContainerName) + } + } + if errors.Is(err, context.DeadlineExceeded) { + return errors.New("failed to connect to Azure Blob Storage. Please check params") + } + return fmt.Errorf("failed to connect to Azure Blob Storage: %w", err) + } + + testBlobName := s.buildBlobName(uuid.New().String() + "-test") + testData := []byte("test connection") + + _, err = client.UploadStream( + ctx, + s.ContainerName, + testBlobName, + bytes.NewReader(testData), + nil, + ) + if err != nil { + return fmt.Errorf("failed to upload test blob to Azure: %w", err) + } + + _, err = client.DeleteBlob( + ctx, + s.ContainerName, + testBlobName, + nil, + ) + if err != nil { + return fmt.Errorf("failed to delete test blob from Azure: %w", err) + } + + return nil +} + +func (s *AzureBlobStorage) HideSensitiveData() { + s.ConnectionString = "" + s.AccountKey = "" +} + +func (s *AzureBlobStorage) Update(incoming *AzureBlobStorage) { + s.AuthMethod = incoming.AuthMethod + s.ContainerName = incoming.ContainerName + s.Endpoint = incoming.Endpoint + + if incoming.ConnectionString != "" { + s.ConnectionString = incoming.ConnectionString + } + + if incoming.AccountName != "" { + s.AccountName = incoming.AccountName + } + + if incoming.AccountKey != "" { + s.AccountKey = incoming.AccountKey + } +} + +func (s *AzureBlobStorage) buildBlobName(fileName string) string { + if s.Prefix == "" { + return fileName + } + + prefix := s.Prefix + prefix = strings.TrimPrefix(prefix, "/") + + if !strings.HasSuffix(prefix, "/") { + prefix = prefix + "/" + } + + return prefix + fileName +} + +func (s *AzureBlobStorage) getClient() (*azblob.Client, error) { + var client *azblob.Client + var err error + + switch s.AuthMethod { + case AuthMethodConnectionString: + client, err = azblob.NewClientFromConnectionString(s.ConnectionString, nil) + if err != nil { + return nil, fmt.Errorf( + "failed to create Azure Blob client from connection string: %w", + err, + ) + } + case AuthMethodAccountKey: + accountURL := s.buildAccountURL() + credential, credErr := azblob.NewSharedKeyCredential(s.AccountName, s.AccountKey) + if credErr != nil { + return nil, fmt.Errorf("failed to create Azure shared key credential: %w", credErr) + } + + client, err = azblob.NewClientWithSharedKeyCredential(accountURL, credential, nil) + if err != nil { + return nil, fmt.Errorf("failed to create Azure Blob client with shared key: %w", err) + } + default: + return nil, fmt.Errorf("unsupported auth method: %s", s.AuthMethod) + } + + return client, nil +} + +func (s *AzureBlobStorage) buildAccountURL() string { + if s.Endpoint != "" { + endpoint := s.Endpoint + if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { + endpoint = "https://" + endpoint + } + return endpoint + } + + return fmt.Sprintf("https://%s.blob.core.windows.net/", s.AccountName) +} diff --git a/backend/internal/features/storages/repository.go b/backend/internal/features/storages/repository.go index afc1d18..9b0269f 100644 --- a/backend/internal/features/storages/repository.go +++ b/backend/internal/features/storages/repository.go @@ -30,17 +30,21 @@ func (r *StorageRepository) Save(storage *Storage) (*Storage, error) { if storage.NASStorage != nil { storage.NASStorage.StorageID = storage.ID } + case StorageTypeAzureBlob: + if storage.AzureBlobStorage != nil { + storage.AzureBlobStorage.StorageID = storage.ID + } } if storage.ID == uuid.Nil { if err := tx.Create(storage). - Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage"). + Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage", "AzureBlobStorage"). Error; err != nil { return err } } else { if err := tx.Save(storage). - Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage"). + Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage", "AzureBlobStorage"). Error; err != nil { return err } @@ -75,6 +79,13 @@ func (r *StorageRepository) Save(storage *Storage) (*Storage, error) { return err } } + case StorageTypeAzureBlob: + if storage.AzureBlobStorage != nil { + storage.AzureBlobStorage.StorageID = storage.ID // Ensure ID is set + if err := tx.Save(storage.AzureBlobStorage).Error; err != nil { + return err + } + } } return nil @@ -96,6 +107,7 @@ func (r *StorageRepository) FindByID(id uuid.UUID) (*Storage, error) { Preload("S3Storage"). Preload("GoogleDriveStorage"). Preload("NASStorage"). + Preload("AzureBlobStorage"). Where("id = ?", id). First(&s).Error; err != nil { return nil, err @@ -113,6 +125,7 @@ func (r *StorageRepository) FindByWorkspaceID(workspaceID uuid.UUID) ([]*Storage Preload("S3Storage"). Preload("GoogleDriveStorage"). Preload("NASStorage"). + Preload("AzureBlobStorage"). Where("workspace_id = ?", workspaceID). Order("name ASC"). Find(&storages).Error; err != nil { @@ -150,6 +163,12 @@ func (r *StorageRepository) Delete(s *Storage) error { return err } } + case StorageTypeAzureBlob: + if s.AzureBlobStorage != nil { + if err := tx.Delete(s.AzureBlobStorage).Error; err != nil { + return err + } + } } // Delete the main storage diff --git a/backend/internal/features/users/models/secret_key.go b/backend/internal/features/users/models/secret_key.go index 031c2f8..8652f81 100644 --- a/backend/internal/features/users/models/secret_key.go +++ b/backend/internal/features/users/models/secret_key.go @@ -1,7 +1,7 @@ package users_models type SecretKey struct { - Secret string `gorm:"column:secret"` + Secret string `gorm:"column:secret" json:"-"` } func (SecretKey) TableName() string { diff --git a/backend/migrations/20251116195618_add_azure_blob_storage.sql b/backend/migrations/20251116195618_add_azure_blob_storage.sql new file mode 100644 index 0000000..41fd28c --- /dev/null +++ b/backend/migrations/20251116195618_add_azure_blob_storage.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE azure_blob_storages ( + storage_id UUID PRIMARY KEY, + auth_method TEXT NOT NULL, + connection_string TEXT, + account_name TEXT, + account_key TEXT, + container_name TEXT NOT NULL, + endpoint TEXT, + prefix TEXT +); + +ALTER TABLE azure_blob_storages + ADD CONSTRAINT fk_azure_blob_storages_storage + FOREIGN KEY (storage_id) + REFERENCES storages (id) + ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS azure_blob_storages; + +-- +goose StatementEnd diff --git a/frontend/public/icons/storages/azure.svg b/frontend/public/icons/storages/azure.svg new file mode 100644 index 0000000..0a68919 --- /dev/null +++ b/frontend/public/icons/storages/azure.svg @@ -0,0 +1,9 @@ + + + + path21 + + + + + \ No newline at end of file diff --git a/frontend/src/entity/storages/index.ts b/frontend/src/entity/storages/index.ts index b79f166..935193c 100644 --- a/frontend/src/entity/storages/index.ts +++ b/frontend/src/entity/storages/index.ts @@ -7,3 +7,4 @@ export { type NASStorage } from './models/NASStorage'; export { getStorageLogoFromType } from './models/getStorageLogoFromType'; export { getStorageNameFromType } from './models/getStorageNameFromType'; export { type GoogleDriveStorage } from './models/GoogleDriveStorage'; +export { type AzureBlobStorage } from './models/AzureBlobStorage'; diff --git a/frontend/src/entity/storages/models/AzureBlobStorage.ts b/frontend/src/entity/storages/models/AzureBlobStorage.ts new file mode 100644 index 0000000..788f72e --- /dev/null +++ b/frontend/src/entity/storages/models/AzureBlobStorage.ts @@ -0,0 +1,9 @@ +export interface AzureBlobStorage { + authMethod: 'CONNECTION_STRING' | 'ACCOUNT_KEY'; + connectionString: string; + accountName: string; + accountKey: string; + containerName: string; + endpoint?: string; + prefix?: string; +} diff --git a/frontend/src/entity/storages/models/Storage.ts b/frontend/src/entity/storages/models/Storage.ts index 5e7e1fe..2181628 100644 --- a/frontend/src/entity/storages/models/Storage.ts +++ b/frontend/src/entity/storages/models/Storage.ts @@ -1,3 +1,4 @@ +import type { AzureBlobStorage } from './AzureBlobStorage'; import type { GoogleDriveStorage } from './GoogleDriveStorage'; import type { LocalStorage } from './LocalStorage'; import type { NASStorage } from './NASStorage'; @@ -16,4 +17,5 @@ export interface Storage { s3Storage?: S3Storage; googleDriveStorage?: GoogleDriveStorage; nasStorage?: NASStorage; + azureBlobStorage?: AzureBlobStorage; } diff --git a/frontend/src/entity/storages/models/StorageType.ts b/frontend/src/entity/storages/models/StorageType.ts index 9b341c3..80f7270 100644 --- a/frontend/src/entity/storages/models/StorageType.ts +++ b/frontend/src/entity/storages/models/StorageType.ts @@ -3,4 +3,5 @@ export enum StorageType { S3 = 'S3', GOOGLE_DRIVE = 'GOOGLE_DRIVE', NAS = 'NAS', + AZURE_BLOB = 'AZURE_BLOB', } diff --git a/frontend/src/entity/storages/models/getStorageLogoFromType.ts b/frontend/src/entity/storages/models/getStorageLogoFromType.ts index de675ce..922ccda 100644 --- a/frontend/src/entity/storages/models/getStorageLogoFromType.ts +++ b/frontend/src/entity/storages/models/getStorageLogoFromType.ts @@ -10,6 +10,8 @@ export const getStorageLogoFromType = (type: StorageType) => { return '/icons/storages/google-drive.svg'; case StorageType.NAS: return '/icons/storages/nas.svg'; + case StorageType.AZURE_BLOB: + return '/icons/storages/azure.svg'; default: return ''; } diff --git a/frontend/src/entity/storages/models/getStorageNameFromType.ts b/frontend/src/entity/storages/models/getStorageNameFromType.ts index fd6f1b7..ae7ac4a 100644 --- a/frontend/src/entity/storages/models/getStorageNameFromType.ts +++ b/frontend/src/entity/storages/models/getStorageNameFromType.ts @@ -10,6 +10,8 @@ export const getStorageNameFromType = (type: StorageType) => { return 'Google Drive'; case StorageType.NAS: return 'NAS'; + case StorageType.AZURE_BLOB: + return 'Azure Blob Storage'; default: return ''; } diff --git a/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx b/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx index 72d1c36..d6fa95e 100644 --- a/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx +++ b/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx @@ -8,6 +8,7 @@ import { storageApi, } from '../../../../entity/storages'; import { ToastHelper } from '../../../../shared/toast'; +import { EditAzureBlobStorageComponent } from './storages/EditAzureBlobStorageComponent'; import { EditGoogleDriveStorageComponent } from './storages/EditGoogleDriveStorageComponent'; import { EditNASStorageComponent } from './storages/EditNASStorageComponent'; import { EditS3StorageComponent } from './storages/EditS3StorageComponent'; @@ -80,6 +81,7 @@ export function EditStorageComponent({ storage.localStorage = undefined; storage.s3Storage = undefined; storage.googleDriveStorage = undefined; + storage.azureBlobStorage = undefined; if (type === StorageType.LOCAL) { storage.localStorage = {}; @@ -115,6 +117,18 @@ export function EditStorageComponent({ }; } + if (type === StorageType.AZURE_BLOB) { + storage.azureBlobStorage = { + authMethod: 'ACCOUNT_KEY', + connectionString: '', + accountName: '', + accountKey: '', + containerName: '', + endpoint: '', + prefix: '', + }; + } + setStorage( JSON.parse( JSON.stringify({ @@ -197,6 +211,26 @@ export function EditStorageComponent({ ); } + if (storage.type === StorageType.AZURE_BLOB) { + if (storage.id) { + return storage.azureBlobStorage?.containerName; + } + + const isContainerNameFilled = storage.azureBlobStorage?.containerName; + + if (storage.azureBlobStorage?.authMethod === 'CONNECTION_STRING') { + return isContainerNameFilled && storage.azureBlobStorage?.connectionString; + } + + if (storage.azureBlobStorage?.authMethod === 'ACCOUNT_KEY') { + return ( + isContainerNameFilled && + storage.azureBlobStorage?.accountName && + storage.azureBlobStorage?.accountKey + ); + } + } + return false; }; @@ -231,6 +265,7 @@ export function EditStorageComponent({ { label: 'S3', value: StorageType.S3 }, { label: 'Google Drive', value: StorageType.GOOGLE_DRIVE }, { label: 'NAS', value: StorageType.NAS }, + { label: 'Azure Blob Storage', value: StorageType.AZURE_BLOB }, ]} onChange={(value) => { setStorageType(value); @@ -278,6 +313,17 @@ export function EditStorageComponent({ }} /> )} + + {storage?.type === StorageType.AZURE_BLOB && ( + { + setIsUnsaved(true); + setIsTestConnectionSuccess(false); + }} + /> + )}
diff --git a/frontend/src/features/storages/ui/edit/storages/EditAzureBlobStorageComponent.tsx b/frontend/src/features/storages/ui/edit/storages/EditAzureBlobStorageComponent.tsx new file mode 100644 index 0000000..6021175 --- /dev/null +++ b/frontend/src/features/storages/ui/edit/storages/EditAzureBlobStorageComponent.tsx @@ -0,0 +1,226 @@ +import { DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant-design/icons'; +import { Input, Radio, Tooltip } from 'antd'; +import { useState } from 'react'; + +import type { Storage } from '../../../../../entity/storages'; + +interface Props { + storage: Storage; + setStorage: (storage: Storage) => void; + setUnsaved: () => void; +} + +export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved }: Props) { + const hasAdvancedValues = + !!storage?.azureBlobStorage?.prefix || !!storage?.azureBlobStorage?.endpoint; + const [showAdvanced, setShowAdvanced] = useState(hasAdvancedValues); + + return ( + <> +
+
Auth method
+ { + if (!storage?.azureBlobStorage) return; + + setStorage({ + ...storage, + azureBlobStorage: { + ...storage.azureBlobStorage, + authMethod: e.target.value, + }, + }); + setUnsaved(); + }} + size="small" + > + Account key + Connection string + +
+ + {storage?.azureBlobStorage?.authMethod === 'CONNECTION_STRING' && ( +
+
Connection
+ { + if (!storage?.azureBlobStorage) return; + + setStorage({ + ...storage, + azureBlobStorage: { + ...storage.azureBlobStorage, + connectionString: e.target.value.trim(), + }, + }); + setUnsaved(); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="DefaultEndpointsProtocol=https;AccountName=..." + /> + + + + +
+ )} + + {storage?.azureBlobStorage?.authMethod === 'ACCOUNT_KEY' && ( + <> +
+
Account name
+ { + if (!storage?.azureBlobStorage) return; + + setStorage({ + ...storage, + azureBlobStorage: { + ...storage.azureBlobStorage, + accountName: e.target.value.trim(), + }, + }); + setUnsaved(); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="mystorageaccount" + /> +
+ +
+
Account key
+ { + if (!storage?.azureBlobStorage) return; + + setStorage({ + ...storage, + azureBlobStorage: { + ...storage.azureBlobStorage, + accountKey: e.target.value.trim(), + }, + }); + setUnsaved(); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="your-account-key" + /> +
+ + )} + +
+
Container name
+ { + if (!storage?.azureBlobStorage) return; + + setStorage({ + ...storage, + azureBlobStorage: { + ...storage.azureBlobStorage, + containerName: e.target.value.trim(), + }, + }); + setUnsaved(); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="my-container" + /> +
+ +
+
setShowAdvanced(!showAdvanced)} + > + Advanced settings + + {showAdvanced ? ( + + ) : ( + + )} +
+
+ + {showAdvanced && ( + <> + {storage?.azureBlobStorage?.authMethod === 'ACCOUNT_KEY' && ( +
+
Endpoint
+ { + if (!storage?.azureBlobStorage) return; + + setStorage({ + ...storage, + azureBlobStorage: { + ...storage.azureBlobStorage, + endpoint: e.target.value.trim(), + }, + }); + setUnsaved(); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="https://myaccount.blob.core.windows.net (optional)" + /> + + + + +
+ )} + +
+
Blob prefix
+ { + if (!storage?.azureBlobStorage) return; + + setStorage({ + ...storage, + azureBlobStorage: { + ...storage.azureBlobStorage, + prefix: e.target.value.trim(), + }, + }); + setUnsaved(); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="my-prefix/ (optional)" + /> + + + + +
+ + )} + +
+ + ); +} diff --git a/frontend/src/features/storages/ui/show/ShowStorageComponent.tsx b/frontend/src/features/storages/ui/show/ShowStorageComponent.tsx index bf81758..94b5d64 100644 --- a/frontend/src/features/storages/ui/show/ShowStorageComponent.tsx +++ b/frontend/src/features/storages/ui/show/ShowStorageComponent.tsx @@ -1,6 +1,7 @@ import { type Storage, StorageType } from '../../../../entity/storages'; import { getStorageLogoFromType } from '../../../../entity/storages/models/getStorageLogoFromType'; import { getStorageNameFromType } from '../../../../entity/storages/models/getStorageNameFromType'; +import { ShowAzureBlobStorageComponent } from './storages/ShowAzureBlobStorageComponent'; import { ShowGoogleDriveStorageComponent } from './storages/ShowGoogleDriveStorageComponent'; import { ShowNASStorageComponent } from './storages/ShowNASStorageComponent'; import { ShowS3StorageComponent } from './storages/ShowS3StorageComponent'; @@ -37,6 +38,12 @@ export function ShowStorageComponent({ storage }: Props) {
{storage?.type === StorageType.NAS && }
+ +
+ {storage?.type === StorageType.AZURE_BLOB && ( + + )} +
); } diff --git a/frontend/src/features/storages/ui/show/storages/ShowAzureBlobStorageComponent.tsx b/frontend/src/features/storages/ui/show/storages/ShowAzureBlobStorageComponent.tsx new file mode 100644 index 0000000..58d5dfa --- /dev/null +++ b/frontend/src/features/storages/ui/show/storages/ShowAzureBlobStorageComponent.tsx @@ -0,0 +1,56 @@ +import type { Storage } from '../../../../../entity/storages'; + +interface Props { + storage: Storage; +} + +export function ShowAzureBlobStorageComponent({ storage }: Props) { + return ( + <> +
+
Auth method
+ {storage?.azureBlobStorage?.authMethod === 'CONNECTION_STRING' + ? 'Connection string' + : 'Account key'} +
+ + {storage?.azureBlobStorage?.authMethod === 'CONNECTION_STRING' && ( +
+
Connection string
+ {'*************'} +
+ )} + + {storage?.azureBlobStorage?.authMethod === 'ACCOUNT_KEY' && ( + <> +
+
Account name
+ {storage?.azureBlobStorage?.accountName || '-'} +
+ +
+
Account key
+ {'*************'} +
+ +
+
Endpoint
+ {storage?.azureBlobStorage?.endpoint || '-'} +
+ + )} + +
+
Container name
+ {storage?.azureBlobStorage?.containerName || '-'} +
+ + {storage?.azureBlobStorage?.prefix && ( +
+
Prefix
+ {storage.azureBlobStorage.prefix} +
+ )} + + ); +}