62 Commits
Author SHA1 Message Date
vladp f4e5ea2976 work version vk auth without buisness acc
Test Workflow / test (push) Successful in 10s
2026-01-08 18:53:00 +07:00
vladp bf4d1ca450 add endpoint with files user info
Test Workflow / test (push) Successful in 4s
2026-01-06 12:50:39 +07:00
vladp bd9ecd4f84 change chunk size
Test Workflow / test (push) Successful in 3s
2025-12-30 12:25:22 +07:00
vladp 0f73c8319e add ram size
Test Workflow / test (push) Successful in 4s
2025-12-30 12:08:23 +07:00
vladp 28b0356b03 fix check status deleted
Test Workflow / test (push) Successful in 3s
2025-12-30 02:19:07 +07:00
vladp 72a4516b7d set correct file types
Test Workflow / test (push) Successful in 4s
2025-12-30 01:05:59 +07:00
vladp 851676b895 set chunk-size to 5
Test Workflow / test (push) Successful in 2s
2025-12-26 20:32:15 +07:00
vladp 254fdc0fbd set chunk-size to 5
Test Workflow / test (push) Successful in 8s
2025-12-26 20:30:38 +07:00
vladp a33c53fa6b set chunk-size to 5
Test Workflow / test (push) Successful in 3s
2025-12-26 20:18:18 +07:00
vladp 86e7371ffe set chunk-size to 1000000
Test Workflow / test (push) Successful in 2s
2025-12-26 19:50:32 +07:00
vladp 9541e89651 add post size
Test Workflow / test (push) Successful in 2s
2025-12-26 19:40:08 +07:00
vladp 291586dbf4 add post size
Test Workflow / test (push) Successful in 2s
2025-12-26 19:29:47 +07:00
vladp 91e9b0c785 add post size
Test Workflow / test (push) Successful in 2s
2025-12-26 19:26:39 +07:00
vladp 4fa6b83a10 add post size
Test Workflow / test (push) Successful in 3s
2025-12-26 19:15:45 +07:00
vladp 2c1a2aeb16 add post size
Test Workflow / test (push) Successful in 3s
2025-12-26 19:10:43 +07:00
vladp 2a9f33068a add logic with hard delete
Test Workflow / test (push) Successful in 2s
2025-12-26 16:23:17 +07:00
vladp 1ebe8b852f add file extension to search
Test Workflow / test (push) Successful in 2s
2025-12-26 14:51:02 +07:00
vladp 84993dec00 fix page_size and filtered values
Test Workflow / test (push) Successful in 3s
2025-12-26 13:52:53 +07:00
vladp a2c20ae645 add exceptions
Test Workflow / test (push) Successful in 2s
2025-12-26 12:22:40 +07:00
vladp 18ec9b51f3 add clean logic for uploadsessions
Test Workflow / test (push) Successful in 3s
2025-12-25 15:27:45 +07:00
vladp 45b7acc774 fix after test
Test Workflow / test (push) Successful in 2s
2025-12-25 13:04:49 +07:00
vladp b2cf06a512 update name controllers ends
Test Workflow / test (push) Successful in 3s
2025-12-24 17:26:13 +07:00
vladp d842af2c62 add file size
Test Workflow / test (push) Waiting to run
2025-12-24 16:55:07 +07:00
vladp c5ef58ddd4 fix after test
Test Workflow / test (push) Successful in 3s
2025-12-24 14:20:49 +07:00
vladp f6d4ebfe0e fix after test
Test Workflow / test (push) Successful in 3s
2025-12-24 13:58:46 +07:00
vladp a77a51f519 delete photo
Test Workflow / test (push) Successful in 1s
2025-12-23 18:33:53 +07:00
vladp 1aef1dcd96 add chunk size
Test Workflow / test (push) Successful in 1s
2025-12-23 16:31:09 +07:00
backdev 7dc32d055b Merge pull request 'NCBACK-26 add docker container with storage' (#11) from NCBACK-26 into dev
Test Workflow / test (push) Successful in 0s
Reviewed-on: #11
2025-12-23 17:10:36 +08:00
vladp 19d8f61e1d NCBACK-26 add docker container with storage
Test Workflow / test (push) Successful in 1s
2025-12-23 16:05:51 +07:00
vladp 8fe4d854a6 add create uploads filedir
Test Workflow / test (push) Successful in 1s
2025-12-23 13:10:36 +07:00
vladp 4c607f5bf9 fix check format
Test Workflow / test (push) Successful in 6s
2025-12-23 12:46:59 +07:00
vladp ce879fea18 add format for all office,os
Test Workflow / test (push) Successful in 1s
2025-12-23 12:03:49 +07:00
vladp 33479ea9af add check extensions
Test Workflow / test (push) Successful in 3s
2025-12-22 17:17:48 +07:00
vladp 76e8f4ab1d add image type
Test Workflow / test (push) Successful in 3s
2025-12-22 10:59:36 +07:00
backdev c03ca2566d Merge pull request 'add bone for file system' (#9) from NCBACK-15 into dev
Test Workflow / test (push) Successful in 9s
Reviewed-on: #9
2025-12-21 17:19:34 +08:00
vladp 1932784264 NCBACK-15
Test Workflow / test (push) Successful in 4s
2025-12-21 16:19:12 +07:00
vladp bd78b88f33 Merge branch 'refs/heads/dev' into NCBACK-15
# Conflicts:
#	src/main/java/ru/soune/nocopy/controller/ApiController.java
#	src/main/java/ru/soune/nocopy/dto/MessageCode.java
2025-12-21 16:17:42 +07:00
backdev 22c2eed8a1 Merge pull request 'NCBACK-13' (#8) from NCBACK-13 into dev
Test Workflow / test (push) Waiting to run
Reviewed-on: #8
2025-12-21 17:15:16 +08:00
vladp f3266ee0e5 NCBACK-13
Test Workflow / test (push) Successful in 3s
2025-12-18 17:50:06 +07:00
vladp 280bd91257 Merge branch 'dev' into NCBACK-13 2025-12-18 17:43:35 +07:00
vladp 31b90f942c add fileentity controller
Test Workflow / test (push) Successful in 4s
2025-12-18 14:40:27 +07:00
vladp d167e92d90 add fileentity controller
Test Workflow / test (push) Successful in 4s
2025-12-18 11:23:42 +07:00
vladp f3aa6324c2 add fileentity controller
Test Workflow / test (push) Successful in 3s
2025-12-18 01:13:19 +07:00
vladp e2747fc999 Merge branch 'NCBACK-18' into NCBACK-15
# Conflicts:
#	src/main/java/ru/soune/nocopy/controller/AuthController.java
#	src/main/java/ru/soune/nocopy/controller/file/FileUploadController.java
#	src/main/java/ru/soune/nocopy/dto/AuthResponse.java
#	src/main/java/ru/soune/nocopy/dto/LoginResponse.java
#	src/main/java/ru/soune/nocopy/dto/RegisterRequest.java
#	src/main/java/ru/soune/nocopy/exception/UserAlreadyExistsException.java
#	src/main/java/ru/soune/nocopy/service/AuthService.java
#	src/test/java/ru/soune/nocopy/service/AuthServiceTest.java
2025-12-17 22:41:01 +07:00
vladp a030837d45 update controller chunk endpoints
Test Workflow / test (push) Successful in 3s
2025-12-17 22:31:30 +07:00
vladp 651b92cb59 fix job
Test Workflow / test (push) Successful in 2s
2025-12-17 15:40:38 +07:00
vladp 11a7ab6a73 Изменения в FileType.java
Test Workflow / test (push) Successful in 3s
2025-12-17 13:49:22 +07:00
vladp 641e983b89 change package
Test Workflow / test (push) Waiting to run
2025-12-17 13:41:05 +07:00
backdev 8b6f0f1072 Merge pull request 'NCBACK-9' (#7) from NCBACK-9 into dev
Test Workflow / test (push) Successful in 3s
Reviewed-on: #7
2025-12-17 13:41:27 +08:00
vladp 6797a47df5 check unique phone
Test Workflow / test (push) Successful in 3s
2025-12-16 16:43:20 +07:00
vladp fade754c7c fix check email
Test Workflow / test (push) Successful in 2s
2025-12-16 12:41:55 +07:00
vladp f164370989 add check adress
Test Workflow / test (push) Successful in 4s
2025-12-12 13:07:46 +07:00
vladp 015f2f516c delete need full name
Test Workflow / test (push) Successful in 2s
2025-12-11 20:53:28 +07:00
vladp d34a304763 add logout request,delete old controller
Test Workflow / test (push) Successful in 3s
2025-12-11 13:00:01 +07:00
vladp 0635b9b4ac add check jsonbody for request
Test Workflow / test (push) Successful in 4s
2025-12-10 19:25:45 +07:00
vladp a30b0488e8 change package,change message_code
Test Workflow / test (push) Waiting to run
2025-12-10 19:12:54 +07:00
vladp 76da2e94c9 change code message
Test Workflow / test (push) Successful in 3s
2025-12-10 17:17:48 +07:00
vladp 420734ff87 fix work controller code 20001,20002
Test Workflow / test (push) Successful in 3s
2025-12-10 15:27:14 +07:00
vladp 9ef192ed32 add strategy for controller
Test Workflow / test (push) Successful in 3s
2025-12-10 12:17:08 +07:00
vladp 8a33bd33a2 NCBACK-9 add login
Test Workflow / test (push) Successful in 4s
2025-12-10 04:51:07 +07:00
vladp b5b8bebb69 NCBACK-9 work controller for register
Test Workflow / test (push) Successful in 7s
2025-12-10 04:22:09 +07:00
vladp c2ffe08e7d NCBACK-9 add Validator,Add new enums with codes
Test Workflow / test (push) Successful in 3s
2025-12-10 02:37:31 +07:00
73 changed files with 3184 additions and 1097 deletions
+2
View File
@@ -12,6 +12,8 @@ WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar COPY --from=build /app/build/libs/*.jar app.jar
RUN mkdir -p /data/uploads && chmod 755 /data/uploads
EXPOSE 8080 EXPOSE 8080
CMD ["java", "-jar", "app.jar"] CMD ["java", "-jar", "app.jar"]
+7 -1
View File
@@ -29,13 +29,19 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.security:spring-security-crypto:6.5.3' implementation 'org.springframework.security:spring-security-crypto:6.5.3'
implementation 'org.mapstruct:mapstruct:1.5.5.Final' implementation 'org.mapstruct:mapstruct:1.5.5.Final'
implementation 'commons-validator:commons-validator:1.7'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
annotationProcessor 'org.projectlombok:lombok'
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc' runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc'
runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mockito:mockito-core:5.3.1' testImplementation 'org.mockito:mockito-core:5.3.1'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+86 -4
View File
@@ -1,13 +1,39 @@
version: '3.9' version: '3.9'
services: services:
storage:
image: alpine:latest
container_name: file-storage
deploy:
resources:
limits:
memory: 128M
reservations:
memory: 64M
networks:
- app-network
volumes:
- uploads_data:/storage:rw
command: tail -f /dev/null
restart: unless-stopped
db: db:
image: postgres:17 image: postgres:17
restart: always restart: always
deploy:
resources:
limits:
cpus: '2.0'
memory: 2G
reservations:
cpus: '1.0'
memory: 1G
environment: environment:
POSTGRES_DB: no_copy_ POSTGRES_DB: no_copy_
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_SHARED_BUFFERS: 512MB
POSTGRES_EFFECTIVE_CACHE_SIZE: 1536MB
ports: ports:
- "54320:5432" - "54320:5432"
volumes: volumes:
@@ -21,15 +47,24 @@ services:
app: app:
build: . build: .
container_name: app-backend container_name: app-backend
deploy:
resources:
limits:
cpus: '1.5'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
environment: environment:
FILE_STORAGE_PATH: /data/uploads FILE_STORAGE_PATH: /data/uploads
MAX_FILE_SIZE: 1073741824 MAX_FILE_SIZE: 10737418240
FILE_CHUNK_SIZE: 5242880 FILE_CHUNK_SIZE: 1048576
POSTGRES_DB: no_copy_ POSTGRES_DB: no_copy_
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_PORT: 5432 POSTGRES_PORT: 5432
POSTGRES_HOST: db POSTGRES_HOST: db
STORAGE_SERVICE_URL: http://storage:8081
depends_on: depends_on:
- db - db
ports: ports:
@@ -41,11 +76,18 @@ services:
- backend - backend
- api - api
volumes: volumes:
- ./uploads:/data/uploads:rw - uploads_data:/data/uploads:rw
grafana: grafana:
image: grafana/grafana:10.3.1 image: grafana/grafana:10.3.1
container_name: grafana container_name: grafana
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
memory: 256M
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:
@@ -62,6 +104,8 @@ services:
GF_SECURITY_ADMIN_USER: admin GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin GF_SECURITY_ADMIN_PASSWORD: admin
GF_METRICS_ENABLED: "true" GF_METRICS_ENABLED: "true"
GF_DATABASE_MAX_IDLE_CONN: "2"
GF_DATABASE_MAX_OPEN_CONN: "10"
volumes: volumes:
- ./infrastructure/grafana/provisioning:/etc/grafana/provisioning - ./infrastructure/grafana/provisioning:/etc/grafana/provisioning
- ./infrastructure/grafana/dashboards:/var/lib/grafana/dashboards - ./infrastructure/grafana/dashboards:/var/lib/grafana/dashboards
@@ -76,10 +120,23 @@ services:
prometheus: prometheus:
image: prom/prometheus:latest image: prom/prometheus:latest
container_name: prometheus container_name: prometheus
deploy: # ← ДОБАВЛЕНО
resources:
limits:
cpus: '0.5'
memory: 1G
reservations:
memory: 512M
ports: ports:
- "9090:9090" - "9090:9090"
volumes: volumes:
- ./infrastructure/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - ./infrastructure/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=15d' # Удерживать 15 дней
- '--web.enable-lifecycle'
healthcheck: healthcheck:
test: [ "CMD", "wget", "--spider", "http://localhost:9090/-/healthy" ] test: [ "CMD", "wget", "--spider", "http://localhost:9090/-/healthy" ]
interval: 10s interval: 10s
@@ -91,10 +148,20 @@ services:
loki: loki:
image: grafana/loki:2.9.2 image: grafana/loki:2.9.2
container_name: loki container_name: loki
deploy: # ← ДОБАВЛЕНО
resources:
limits:
cpus: '1.0'
memory: 2G
reservations:
memory: 1G
ports: ports:
- "3100:3100" - "3100:3100"
volumes: volumes:
- ./infrastructure/loki/loki-config.yaml:/etc/loki/loki-config.yaml:ro - ./infrastructure/loki/loki-config.yaml:/etc/loki/loki-config.yaml:ro
- loki_data:/loki
command:
- -config.file=/etc/loki/loki-config.yaml
healthcheck: healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:3100/ready" ] test: [ "CMD", "wget", "--spider", "-q", "http://localhost:3100/ready" ]
interval: 10s interval: 10s
@@ -106,6 +173,13 @@ services:
tempo: tempo:
image: grafana/tempo:2.4.1 image: grafana/tempo:2.4.1
container_name: tempo container_name: tempo
deploy: # ← ДОБАВЛЕНО
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
memory: 256M
command: [ "-config.file=/etc/tempo/tempo.yaml" ] command: [ "-config.file=/etc/tempo/tempo.yaml" ]
volumes: volumes:
- ./infrastructure/tempo/tempo.yaml:/etc/tempo/tempo.yaml - ./infrastructure/tempo/tempo.yaml:/etc/tempo/tempo.yaml
@@ -118,6 +192,13 @@ services:
alloy: alloy:
image: grafana/alloy:latest image: grafana/alloy:latest
container_name: alloy container_name: alloy
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
reservations:
memory: 128M
user: root user: root
ports: ports:
- "9080:9080" - "9080:9080"
@@ -146,7 +227,8 @@ volumes:
loki_chunks: loki_chunks:
loki_index: loki_index:
loki_rules: loki_rules:
# uploads_volume: uploads_data:
prometheus_data:
networks: networks:
app-network: app-network:
+143 -44
View File
@@ -45,65 +45,162 @@ pipeline {
echo "Deploying branch: ${params.BRANCH}" echo "Deploying branch: ${params.BRANCH}"
echo "Copying files to server..." echo "Copying files to server..."
sshpass -p '$SSH_PASS' ssh $SSH_USER@$SERVER "mkdir -p /opt/deployments/${params.BRANCH}" sshpass -p '$SSH_PASS' ssh -o StrictHostKeyChecking=no $SSH_USER@$SERVER "mkdir -p /opt/deployments/${params.BRANCH}"
sshpass -p '$SSH_PASS' scp -r ./* $SSH_USER@$SERVER:/opt/deployments/${params.BRANCH}/ sshpass -p '$SSH_PASS' scp -r -o StrictHostKeyChecking=no ./* $SSH_USER@$SERVER:/opt/deployments/${params.BRANCH}/
sshpass -p '$SSH_PASS' ssh $SSH_USER@$SERVER " sshpass -p '$SSH_PASS' ssh -o StrictHostKeyChecking=no $SSH_USER@$SERVER "
cd /opt/deployments/${params.BRANCH} cd /opt/deployments/${params.BRANCH}
echo '========================================'
echo 'Step 1: Checking current state...' echo 'Step 1: Checking current state...'
echo '========================================'
# Смотрим что запущено docker ps --filter 'name=postgres|app-backend|storage|grafana|prometheus|loki|tempo|alloy' --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}'
docker ps --filter 'name=postgres|app-backend' --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}'
echo 'Step 2: Stopping only application (keeping DB)...' echo '========================================'
echo 'Step 2: Setup environment for compatibility...'
echo '========================================'
# Останавливаем только приложение, БД оставляем работать docker network create app-network 2>/dev/null || echo 'Network app-network already exists'
docker stop app-backend 2>/dev/null || echo 'App not running'
docker rm app-backend 2>/dev/null || echo 'App not found'
# Проверяем что БД работает, если нет - запускаем echo 'COMPOSE_COMPATIBILITY=true' > .env
if ! docker ps | grep -q postgres; then
echo 'Starting PostgreSQL...'
docker-compose up -d db
sleep 10
else
echo 'PostgreSQL already running'
fi
echo 'Step 3: Building new application image...' echo '========================================'
docker build --no-cache -t app-backend:latest . echo 'Step 3: Force cleanup old application...'
echo '========================================'
echo 'Step 4: Starting application...' docker-compose --compatibility down 2>/dev/null || echo 'No previous compose stack'
docker run -d \\
--name app-backend \\
--network app-network \\
-p 80:8080 \\
-e POSTGRES_DB=no_copy_ \\
-e POSTGRES_USER=postgres \\
-e POSTGRES_PASSWORD=postgres \\
-e POSTGRES_PORT=5432 \\
-e POSTGRES_HOST=db \\
--restart unless-stopped \\
app-backend:latest
echo 'Step 5: Checking deployment...' docker images --filter 'reference=*app-backend*' -q | xargs -r docker rmi -f 2>/dev/null || echo 'No images to remove'
sleep 10
echo 'Containers status:' echo '========================================'
docker ps --filter 'name=postgres|app-backend' --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}' echo 'Step 4: Verify files and fix compose...'
echo '========================================'
echo 'Application logs (last 5 lines):' if [ ! -f docker-compose.yaml ]; then
docker logs --tail=5 app-backend 2>/dev/null || echo 'Logs not available yet' echo 'ERROR: docker-compose.yaml not found!'
ls -la
if docker ps | grep -q app-backend; then
echo 'Deployment successful'
echo 'Application URL: http://${params.SERVER}:80'
else
echo 'Application failed to start'
docker logs app-backend
exit 1 exit 1
fi fi
echo 'Checking docker-compose.yaml syntax...'
docker-compose --compatibility config 2>&1 | head -20 || echo 'Config check output'
echo '========================================'
echo 'Step 5: Start infrastructure services...'
echo '========================================'
echo 'Starting PostgreSQL with resource limits...'
docker-compose --compatibility up -d db
echo 'Starting storage service...'
docker-compose --compatibility up -d storage
echo 'Waiting for PostgreSQL to be ready...'
for i in {1..30}; do
if docker-compose --compatibility exec db pg_isready -U postgres 2>/dev/null; then
echo 'PostgreSQL is ready'
break
fi
echo 'Waiting... attempt ' \$i
sleep 2
done
echo '========================================'
echo 'Step 6: Build application image...'
echo '========================================'
if [ ! -f Dockerfile ]; then
echo 'ERROR: Dockerfile not found!'
exit 1
fi
echo 'Building Docker image...'
docker build --no-cache -t app-backend:latest . 2>&1 | tail -30
echo 'Image created successfully'
docker images app-backend:latest --format 'table {{.Repository}}\\t{{.Tag}}\\t{{.Size}}\\t{{.CreatedAt}}'
echo '========================================'
echo 'Step 7: Start application with resource limits...'
echo '========================================'
docker volume create uploads_data 2>/dev/null || echo 'Volume uploads_data already exists'
echo 'Starting main application...'
docker-compose --compatibility up -d app
echo 'Waiting for application to start...'
sleep 15
echo '========================================'
echo 'Step 8: Start monitoring stack...'
echo '========================================'
docker-compose --compatibility up -d grafana prometheus loki tempo alloy
echo '========================================'
echo 'Step 9: Verify deployment...'
echo '========================================'
echo 'All containers status:'
docker-compose --compatibility ps
echo ''
echo 'Resource limits check:'
echo 'App backend limits:'
docker inspect app-backend --format='{{.HostConfig.Memory}} {{.HostConfig.CpuQuota}} {{.HostConfig.CpuPeriod}}' 2>/dev/null || echo 'Cannot inspect'
echo ''
echo 'PostgreSQL limits:'
docker inspect postgres --format='{{.HostConfig.Memory}} {{.HostConfig.CpuQuota}} {{.HostConfig.CpuPeriod}}' 2>/dev/null || echo 'Cannot inspect'
echo ''
echo 'Container resource usage:'
docker stats --no-stream --format 'table {{.Name}}\\t{{.CPUPerc}}\\t{{.MemUsage}}\\t{{.MemPerc}}' postgres app-backend storage 2>/dev/null || echo 'Stats not available'
echo '========================================'
echo 'Step 10: Health checks...'
echo '========================================'
echo 'Application health check...'
for i in {1..10}; do
if curl -s -f http://localhost:80/health 2>/dev/null; then
echo '✓ Application health check PASSED'
break
elif [ \$i -eq 10 ]; then
echo '✗ Application health check FAILED'
echo 'Last logs:'
docker-compose --compatibility logs --tail=20 app
else
echo 'Waiting for application... attempt ' \$i
sleep 5
fi
done
echo 'Checking monitoring services...'
if curl -s -f http://localhost:3000/api/health 2>/dev/null; then
echo '✓ Grafana is healthy'
else
echo '⚠ Grafana may be starting'
fi
if curl -s -f http://localhost:9090/-/healthy 2>/dev/null; then
echo '✓ Prometheus is healthy'
fi
echo '========================================'
echo 'Deployment Summary:'
echo '========================================'
echo 'Application URL: http://${params.SERVER}:80'
echo 'Grafana: http://${params.SERVER}:3000 (admin/admin)'
echo 'Prometheus: http://${params.SERVER}:9090'
echo 'Loki: http://${params.SERVER}:3100'
echo ''
echo 'To view logs: docker-compose logs -f app'
echo 'To view all containers: docker-compose ps'
echo 'To stop: docker-compose down'
echo '========================================'
" "
""" """
} }
@@ -116,6 +213,8 @@ pipeline {
success { success {
echo "Deployment successful" echo "Deployment successful"
echo "Application URL: http://${params.SERVER}:80" echo "Application URL: http://${params.SERVER}:80"
echo "Grafana: http://${params.SERVER}:3000"
echo "Prometheus: http://${params.SERVER}:9090"
} }
failure { failure {
echo "Deployment failed for branch ${params.BRANCH}" echo "Deployment failed for branch ${params.BRANCH}"
@@ -2,8 +2,10 @@ package ru.soune.nocopy;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling
public class NoCopyApplication { public class NoCopyApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -0,0 +1,30 @@
package ru.soune.nocopy.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import ru.soune.nocopy.handler.*;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class HandlerConfig {
@Bean
public Map<Integer, RequestHandler> handlers(
RegRequestHandler reg,
LoginRequestHandler login,
FileUploadHandler upload,
FileEntityHandler file,
LogoutRequestHandler logoutHandler
) {
Map<Integer, RequestHandler> map = new HashMap<>();
map.put(20001, login);
map.put(20002, reg);
map.put(20004, upload);
map.put(20005, file);
map.put(20006, logoutHandler);
return map;
}
}
@@ -0,0 +1,389 @@
package ru.soune.nocopy.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import ru.soune.nocopy.dto.BaseRequest;
import ru.soune.nocopy.dto.BaseResponse;
import ru.soune.nocopy.dto.MessageCode;
import ru.soune.nocopy.dto.RegAnswer;
import ru.soune.nocopy.dto.file.ChunkUploadResponse;
import ru.soune.nocopy.dto.file.CompleteUploadResponse;
import ru.soune.nocopy.dto.file.FileEntityResponse;
import ru.soune.nocopy.dto.file.UploadProgress;
import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.entity.file.UploadStatus;
import ru.soune.nocopy.exception.*;
import ru.soune.nocopy.handler.*;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.service.file.FileEntityService;
import ru.soune.nocopy.service.file.FileUploadService;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ApiController {
private final FileUploadService fileUploadService;
private final Map<Integer, RequestHandler> handlers;
private final FileEntityService fileEntityService;
private final AuthTokenRepository authTokenRepository;
@PostMapping("/v{version}/data")
public ResponseEntity<?> handlePostRequest(@RequestBody BaseRequest request,
@PathVariable("version") int version) {
Integer msgId = request.getMsgId();
BaseResponse response;
try {
RequestHandler handler = handlers.get(msgId);
if (handler == null) {
response = new BaseResponse(msgId,
MessageCode.MSG_ID_NOT_FOUND.getCode(),
MessageCode.MSG_ID_NOT_FOUND.getDescription(),
new HashMap<>());
} else {
response = handler.handle(request);
}
return ResponseEntity.ok().body(response);
} catch (ValidationException e) {
return createValidationErrorResponse(e.getBindingResult(), e.getMsgId());
} catch (NotValidFieldException e) {
throw e;
} catch (Exception e) {
log.error("Handler execution failed for msgId: {}", msgId, e);
BaseResponse errorResponse = new BaseResponse(msgId,
MessageCode.INVALID_JSON_BODY.getCode(),
MessageCode.INVALID_JSON_BODY.getDescription(),
new HashMap<>());
return ResponseEntity.ok().body(errorResponse);
}
}
@PostMapping("/v{version}/files/chunk")
public ResponseEntity<BaseResponse> uploadChunk(
@PathVariable("version") int version,
@RequestParam(value = "upload_id", required = false) String uploadId,
@RequestParam(value = "chunk_number", required = false) Integer chunkNumber,
@RequestParam(value = "chunk", required = false) MultipartFile chunk) {
try {
if (chunk == null || chunk.isEmpty()) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Chunk file null or empty", ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build()));
}
if (uploadId == null || uploadId.isBlank()) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Upload ID is required", ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build()));
}
if (chunkNumber == null || chunkNumber < 0) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Valid chunk number is required", ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build()));
}
fileUploadService.uploadChunk(uploadId, chunkNumber, chunk);
ChunkUploadResponse responseBody = ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.chunkSize(chunk.getSize())
.message("Chunk uploaded successfully")
.build();
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(),
"Chunk uploaded successfully", responseBody));
} catch (Exception e) {
log.error("Error uploading chunk", e);
ChunkUploadResponse responseBody = ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build();
return ResponseEntity.ok().body(new BaseResponse(
20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to upload chunk: " + e.getMessage(), responseBody));
}
}
@GetMapping("/v{version}/files/progress/{uploadId}")
public ResponseEntity<BaseResponse> getUploadProgress(
@PathVariable("version") int version,
@PathVariable String uploadId) {
log.info("Getting progress for upload session: {}, version: {}", uploadId, version);
try {
var progress = fileUploadService.getUploadProgress(uploadId);
UploadProgress responseBody = UploadProgress.builder()
.uploadId(progress.getUploadId())
.fileName(progress.getFileName())
.totalChunks(progress.getTotalChunks())
.uploadedChunks(progress.getUploadedChunks())
.status(progress.getStatus().toString())
.progressPercentage(progress.getProgressPercentage())
.filePath(progress.getFilePath())
.remainingChunks(progress.getTotalChunks() - progress.getUploadedChunks())
.build();
return ResponseEntity.ok().body(new BaseResponse(
20004, MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getDescription(), responseBody));
} catch (Exception e) {
log.error("Error getting progress for upload: {}", uploadId, e);
UploadProgress responseBody = UploadProgress.builder()
.uploadId(uploadId)
.build();
return ResponseEntity.ok().body(new BaseResponse(
20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to get upload progress: " + e.getMessage(), responseBody));
}
}
@PostMapping("/v{version}/files/{uploadId}/complete")
public ResponseEntity<BaseResponse> completeUpload(
@PathVariable("version") int version,
@PathVariable String uploadId) {
log.info("Completing upload session: {}, version: {}", uploadId, version);
try {
var progress = fileUploadService.getUploadProgress(uploadId);
if (progress.getStatus() == UploadStatus.COMPLETED) {
CompleteUploadResponse responseBody = CompleteUploadResponse.builder()
.uploadId(progress.getUploadId())
.status(progress.getStatus().toString())
.uploadedChunks(progress.getUploadedChunks())
.totalChunks(progress.getTotalChunks())
.message("Upload already completed")
.filePath(progress.getFilePath())
.build();
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(),
"Upload already completed", responseBody));
}
if (progress.getUploadedChunks() < progress.getTotalChunks()) {
CompleteUploadResponse responseBody = CompleteUploadResponse.builder()
.uploadId(progress.getUploadId())
.status(progress.getStatus().toString())
.uploadedChunks(progress.getUploadedChunks())
.totalChunks(progress.getTotalChunks())
.message("Not all chunks uploaded")
.build();
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.INCOMPLETE_UPLOAD.getCode(),
"Not all chunks uploaded", responseBody));
}
CompleteUploadResponse responseBody = CompleteUploadResponse.builder()
.uploadId(progress.getUploadId())
.status(progress.getStatus().toString())
.uploadedChunks(progress.getUploadedChunks())
.totalChunks(progress.getTotalChunks())
.message("File assembly in progress")
.build();
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(),
"File assembly in progress", responseBody));
} catch (Exception e) {
log.error("Error completing upload: {}", uploadId, e);
CompleteUploadResponse responseBody = CompleteUploadResponse.builder()
.uploadId(uploadId)
.build();
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to complete upload: " + e.getMessage(), responseBody));
}
}
@GetMapping("/v{version}/files/download/{fileId}")
public ResponseEntity<?> downloadFile(
@PathVariable(required = false) String fileId,
@PathVariable(required = false) Integer version,
@RequestHeader(value = "Authorization", required = false) String tokenHeader) {
try {
if (tokenHeader == null) {
Map<String, Object> errorData = new HashMap<>();
errorData.put("token", tokenHeader);
errorData.put("fileId", fileId);
errorData.put("version", version);
return ResponseEntity.ok().body(new BaseResponse(20004,
MessageCode.FILE_DOWNLOAD_ERROR_NOT_CORRECT_FIELD.getCode(),
MessageCode.FILE_DOWNLOAD_ERROR_NOT_CORRECT_FIELD.getDescription(),
errorData));
}
Long userId = getUserIdFromToken(tokenHeader);
FileEntityResponse entityResponse = fileEntityService.getById(fileId, version);
if (!entityResponse.getUserId().equals(userId)) {
Map<String, Object> errorData = new HashMap<>();
errorData.put("token", tokenHeader);
return ResponseEntity.ok().body(new BaseResponse(20004,
MessageCode.FILE_DOWNLOAD_ERROR.getCode(),
MessageCode.FILE_DOWNLOAD_ERROR.getDescription(),
errorData));
}
if (entityResponse.getStatus().equals(FileStatus.DELETED)) {
Map<String, Object> errorData = new HashMap<>();
errorData.put("file_status", entityResponse.getStatus());
return ResponseEntity.ok().body(new BaseResponse(20004,
MessageCode.FILE_DOWNLOAD_ERROR.getCode(),
MessageCode.FILE_DOWNLOAD_ERROR.getDescription(),
errorData));
}
if (!entityResponse.isExistsOnDisk()) {
Map<String, Object> errorData = new HashMap<>();
errorData.put("onDisk", entityResponse.isExistsOnDisk());
return ResponseEntity.ok().body(new BaseResponse(20004,
MessageCode.FILE_DOWNLOAD_ERROR.getCode(),
MessageCode.FILE_DOWNLOAD_ERROR.getDescription(),
errorData));
}
Path filePath = Paths.get(entityResponse.getFilePath());
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists()) {
Map<String, Object> errorData = new HashMap<>();
errorData.put("resource", resource.exists());
return ResponseEntity.ok().body(new BaseResponse(20004,
MessageCode.FILE_DOWNLOAD_ERROR.getCode(),
MessageCode.FILE_DOWNLOAD_ERROR.getDescription(),
errorData));
}
String contentType = determineContentType(filePath);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + entityResponse.getOriginalFileName() + "\"")
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(entityResponse.getFileSize()))
.body(resource);
} catch (FileEntityNotFoundException e) {
Map<String, Object> errorData = new HashMap<>();
errorData.put("fileId", fileId);
return ResponseEntity.ok().body(new BaseResponse(20004,
MessageCode.FILE_NOT_FOUND.getCode(),
MessageCode.FILE_NOT_FOUND.getDescription(),
errorData));
} catch (NotFoundAuthToken | IOException e) {
Map<String, Object> errorData = new HashMap<>();
errorData.put("token", tokenHeader);
errorData.put("fileId", fileId);
errorData.put("version", version);
return ResponseEntity.ok().body(new BaseResponse(20004,
MessageCode.FILE_DOWNLOAD_ERROR_NOT_CORRECT_FIELD.getCode(),
MessageCode.FILE_DOWNLOAD_ERROR_NOT_CORRECT_FIELD.getDescription(),
errorData));
}
}
private ResponseEntity<BaseResponse> createValidationErrorResponse(BindingResult bindingResult, Integer msgId) {
List<Map<String, String>> fieldErrors = bindingResult.getFieldErrors()
.stream()
.map(this::createErrorDetail)
.collect(Collectors.toList());
RegAnswer regAnswer = new RegAnswer();
regAnswer.setFieldErrors(fieldErrors);
return ResponseEntity.ok().body(new BaseResponse(msgId, MessageCode.INVALID_FIELD.getCode(),
MessageCode.INVALID_FIELD.getDescription(), regAnswer));
}
private Map<String, String> createErrorDetail(FieldError fieldError) {
Map<String, String> errorDetail = new HashMap<>();
errorDetail.put("field", fieldError.getField());
errorDetail.put("code", fieldError.getCode() != null ? fieldError.getCode() : "VALIDATION_ERROR");
errorDetail.put("message", fieldError.getDefaultMessage());
if (fieldError.getRejectedValue() != null &&
!fieldError.getField().toLowerCase().contains("password")) {
errorDetail.put("rejected_value", fieldError.getRejectedValue().toString());
}
return errorDetail;
}
private String determineContentType(Path filePath) throws IOException {
String contentType = Files.probeContentType(filePath);
if (contentType == null) {
contentType = "application/octet-stream";
}
return contentType;
}
private boolean isPreviewSupported(String mimeType) {
if (mimeType == null) {
return false;
}
return mimeType.startsWith("image") ||
mimeType.startsWith("text") ||
mimeType.equals("pdf") ||
mimeType.startsWith("video") ||
mimeType.startsWith("audio");
}
private Long getUserIdFromToken(String tokenHeader) {
String token = tokenHeader.replace("Bearer ", "");
AuthToken authToken = authTokenRepository.findByToken(token)
.orElseThrow(() -> new NotFoundAuthToken("Token not found"));
return authToken.getUser().getId();
}
}
@@ -1,58 +0,0 @@
package ru.soune.nocopy.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ru.soune.nocopy.dto.AuthResponse;
import ru.soune.nocopy.dto.LoginRequest;
import ru.soune.nocopy.dto.LoginResponse;
import ru.soune.nocopy.dto.RegisterRequest;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.exception.TokenNotFoundException;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.service.AuthService;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("v1/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final AuthTokenRepository authTokenRepository;
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest registerRequest) {
AuthToken authToken = authService.register(registerRequest);
return ResponseEntity.ok(new AuthResponse(true, "success.user.register", authToken.getToken(),
authToken.getExpiresAt()));
}
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
AuthToken login = authService.login(request);
return ResponseEntity.ok(new LoginResponse(true, login.getUser().getEmail(),
login.getToken(),login.getExpiresAt().toString()));
}
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestHeader("Authorization") String tokenHeader) {
String token = tokenHeader.replace("Bearer ", "");
Optional<AuthToken> opToken = authTokenRepository.findByToken(token);
if (opToken.isEmpty()) {
throw new TokenNotFoundException("Token not found");
}
authService.logout(token);
return ResponseEntity.ok(Map.of("success", true));
}
}
@@ -4,7 +4,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("check/api")
public class HealtCheckController { public class HealtCheckController {
@GetMapping("/healt") @GetMapping("/healt")
@@ -6,7 +6,7 @@ import org.springframework.web.bind.annotation.*;
import ru.soune.nocopy.dto.ChangePasswordRequest; import ru.soune.nocopy.dto.ChangePasswordRequest;
import ru.soune.nocopy.dto.UserDTO; import ru.soune.nocopy.dto.UserDTO;
import ru.soune.nocopy.dto.UserRequest; import ru.soune.nocopy.dto.UserRequest;
import ru.soune.nocopy.entity.AuthToken; import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.entity.User; import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.exception.InvalidUserEmail; import ru.soune.nocopy.exception.InvalidUserEmail;
import ru.soune.nocopy.exception.NotFoundAuthToken; import ru.soune.nocopy.exception.NotFoundAuthToken;
@@ -0,0 +1,93 @@
package ru.soune.nocopy.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ru.soune.nocopy.dto.BaseResponse;
import ru.soune.nocopy.dto.LoginAnswer;
import ru.soune.nocopy.dto.MessageCode;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.service.AuthService;
import ru.soune.nocopy.service.VkOAuthService;
import ru.soune.nocopy.service.auth.ExternalAuthUserService;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Slf4j
public class VkAuthController {
private final VkOAuthService vkOAuthService;
private final AuthService authService;
private final ExternalAuthUserService externalAuthUserService;
@GetMapping("/v{version}/vk/authorization/{code}/{state}/{codeVerifier}/{deviceId}")
public BaseResponse vkAuth(@PathVariable("code") String code,
@PathVariable("state") String state,
@PathVariable("codeVerifier") String codeVerifier,
@PathVariable("deviceId") String deviceId) {
try {
Map<String, Object> authMap = vkOAuthService.authTokenMap(code, state, codeVerifier, deviceId);
ObjectMapper mapper = new ObjectMapper();
String bodyJson = (String) authMap.get("body");
Map<String, Object> body = mapper.readValue(bodyJson, Map.class);
String accessToken = (String) body.get("access_token");
long vkUserId = ((Number) body.get("user_id")).longValue();
Integer expiresIn = (Integer) body.get("expires_in");
String refreshToken = (String) body.get("refresh_token");
Map<String, Object> vkUserInfo = vkOAuthService.userVkInfo(accessToken);
log.info("vkUserInfo {}", vkUserInfo);
String userInfoBodyJson = (String) vkUserInfo.get("body");
Map<String, Object> userInfoBody = mapper.readValue(userInfoBodyJson, Map.class);
Map<String, Object> user = (Map<String, Object>) userInfoBody.get("user");
String firstName = (String) user.get("first_name");
String lastName = (String) user.get("last_name");
String fullName = firstName + " " + lastName;
User userEntity = externalAuthUserService.findOrCreateUserFromVK(Long.toString(vkUserId), fullName,
null, accessToken, refreshToken, expiresIn);
AuthToken ourToken = authService.createTokenForUser(userEntity);
LoginAnswer loginAnswer = new LoginAnswer();
loginAnswer.setToken(ourToken.getToken());
loginAnswer.setFieldErrors(new ArrayList<>());
return new BaseResponse(20009, MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), loginAnswer);
} catch (Exception e) {
log.error("VK auth error: ", e);
LoginAnswer loginAnswer = new LoginAnswer();
loginAnswer.setToken(null);
List<Map<String, String>> errors = new ArrayList<>();
Map<String, String> error = new HashMap<>();
error.put("field", "vk_auth");
error.put("message", e.getMessage());
errors.add(error);
loginAnswer.setFieldErrors(errors);
return new BaseResponse(20009, MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), loginAnswer);
}
}
}
@@ -1,172 +0,0 @@
package ru.soune.nocopy.controller.file;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ru.soune.nocopy.dto.file.FileEntityResponse;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.exception.NotFoundAuthToken;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.service.file.FileEntityService;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@Slf4j
@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileDownloadController {
private final FileEntityService fileEntityService;
private final AuthTokenRepository authTokenRepository;
/**
* Скачать файл по ID FileEntity
*/
@GetMapping("/download/{fileId}")
public ResponseEntity<Resource> downloadFile(
@PathVariable String fileId,
@RequestHeader("Authorization") String tokenHeader) {
try {
Long userId = getUserIdFromToken(tokenHeader);
FileEntityResponse fileInfo = fileEntityService.getById(fileId);
if (!fileInfo.getUserId().equals(userId)) {
return ResponseEntity.status(403).build();
}
if (!fileInfo.isExistsOnDisk()) {
return ResponseEntity.status(404)
.body(null);
}
Path filePath = Paths.get(fileInfo.getFilePath());
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists()) {
return ResponseEntity.status(404).build();
}
String contentType = determineContentType(filePath);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + fileInfo.getOriginalFileName() + "\"")
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileInfo.getFileSize()))
.body(resource);
} catch (NotFoundAuthToken e) {
return ResponseEntity.status(401).build();
} catch (Exception e) {
log.error("Error downloading file", e);
return ResponseEntity.status(500).build();
}
}
/**
* Получить предпросмотр файла (если поддерживается)
*/
@GetMapping("/preview/{fileId}")
public ResponseEntity<Resource> previewFile(
@PathVariable String fileId,
@RequestHeader("Authorization") String tokenHeader) {
try {
Long userId = getUserIdFromToken(tokenHeader);
FileEntityResponse fileInfo = fileEntityService.getById(fileId);
if (!fileInfo.getUserId().equals(userId)) {
return ResponseEntity.status(403).build();
}
if (!isPreviewSupported(fileInfo.getMimeType())) {
return ResponseEntity.status(415)
.body(null);
}
Path filePath = Paths.get(fileInfo.getFilePath());
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists()) {
return ResponseEntity.status(404).build();
}
String contentType = determineContentType(filePath);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION,
"inline; filename=\"" + fileInfo.getOriginalFileName() + "\"")
.body(resource);
} catch (NotFoundAuthToken e) {
return ResponseEntity.status(401).build();
} catch (Exception e) {
log.error("Error previewing file", e);
return ResponseEntity.status(500).build();
}
}
/**
* Получить информацию о файле для фронтенда
*/
@GetMapping("/info/{fileId}")
public ResponseEntity<FileEntityResponse> getFileInfo(
@PathVariable String fileId,
@RequestHeader("Authorization") String tokenHeader) {
try {
Long userId = getUserIdFromToken(tokenHeader);
FileEntityResponse fileInfo = fileEntityService.getById(fileId);
if (!fileInfo.getUserId().equals(userId)) {
return ResponseEntity.status(403).build();
}
return ResponseEntity.ok(fileInfo);
} catch (NotFoundAuthToken e) {
return ResponseEntity.status(401).build();
} catch (Exception e) {
log.error("Error getting file info", e);
return ResponseEntity.status(500).build();
}
}
private String determineContentType(Path filePath) throws IOException {
String contentType = Files.probeContentType(filePath);
if (contentType == null) {
contentType = "application/octet-stream";
}
return contentType;
}
private boolean isPreviewSupported(String mimeType) {
if (mimeType == null) {
return false;
}
return mimeType.startsWith("image/") ||
mimeType.startsWith("text/") ||
mimeType.equals("application/pdf") ||
mimeType.startsWith("video/") ||
mimeType.startsWith("audio/");
}
private Long getUserIdFromToken(String tokenHeader) {
String token = tokenHeader.replace("Bearer ", "");
AuthToken authToken = authTokenRepository.findByToken(token)
.orElseThrow(() -> new NotFoundAuthToken("Token not found"));
return authToken.getUser().getId();
}
}
@@ -1,210 +0,0 @@
package ru.soune.nocopy.controller.file;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ru.soune.nocopy.dto.file.FileApiResponse;
import ru.soune.nocopy.dto.file.FileEntityResponse;
import ru.soune.nocopy.dto.file.FileListResponse;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.exception.NotFoundAuthToken;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.service.file.FileEntityService;
@Slf4j
@RestController
@RequestMapping("/api/file-entities")
@RequiredArgsConstructor
public class FileEntityController {
private final FileEntityService fileEntityService;
private final AuthTokenRepository authTokenRepository;
/**
* Получить информацию о файле по ID
*/
@GetMapping("/{fileId}")
public ResponseEntity<FileApiResponse<FileEntityResponse>> getFileInfo(
@PathVariable String fileId,
@RequestHeader("Authorization") String tokenHeader) {
try {
Long userId = getUserIdFromToken(tokenHeader);
FileEntityResponse fileInfo = fileEntityService.getById(fileId);
if (!fileInfo.getUserId().equals(userId)) {
return ResponseEntity.status(403)
.body(FileApiResponse.error("Access denied"));
}
return ResponseEntity.ok(FileApiResponse.success(fileInfo));
} catch (NotFoundAuthToken e) {
return ResponseEntity.status(401)
.body(FileApiResponse.error("Authentication required"));
} catch (Exception e) {
log.error("Error getting file info", e);
return ResponseEntity.badRequest()
.body(FileApiResponse.error("Failed to get file info: " + e.getMessage()));
}
}
/**
* Получить информацию о файле по ID сессии загрузки
*/
@GetMapping("/by-session/{uploadSessionId}")
public ResponseEntity<FileApiResponse<FileEntityResponse>> getFileByUploadSession(
@PathVariable String uploadSessionId,
@RequestHeader("Authorization") String tokenHeader) {
try {
Long userId = getUserIdFromToken(tokenHeader);
FileEntityResponse fileInfo = fileEntityService.getByUploadSessionId(uploadSessionId);
if (!fileInfo.getUserId().equals(userId)) {
return ResponseEntity.status(403)
.body(FileApiResponse.error("Access denied"));
}
return ResponseEntity.ok(FileApiResponse.success(fileInfo));
} catch (NotFoundAuthToken e) {
return ResponseEntity.status(401)
.body(FileApiResponse.error("Authentication required"));
} catch (Exception e) {
log.error("Error getting file by session", e);
return ResponseEntity.badRequest()
.body(FileApiResponse.error("Failed to get file: " + e.getMessage()));
}
}
/**
* Получить все файлы пользователя
*/
@GetMapping("/my-files")
public ResponseEntity<FileApiResponse<FileListResponse>> getUserFiles(
@RequestHeader("Authorization") String tokenHeader,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
try {
Long userId = getUserIdFromToken(tokenHeader);
FileListResponse files = fileEntityService.getUserFiles(userId, page, pageSize);
return ResponseEntity.ok(FileApiResponse.success(files));
} catch (NotFoundAuthToken e) {
return ResponseEntity.status(401)
.body(FileApiResponse.error("Authentication required"));
} catch (Exception e) {
log.error("Error getting user files", e);
return ResponseEntity.badRequest()
.body(FileApiResponse.error("Failed to get files: " + e.getMessage()));
}
}
/**
* Поиск файлов пользователя
*/
@GetMapping("/search")
public ResponseEntity<FileApiResponse<FileListResponse>> searchFiles(
@RequestParam String query,
@RequestHeader("Authorization") String tokenHeader,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
try {
Long userId = getUserIdFromToken(tokenHeader);
FileListResponse allFiles = fileEntityService.getUserFiles(userId, 1, 1000);
var filteredFiles = allFiles.getFiles().stream()
.filter(f -> f.getOriginalFileName().toLowerCase().contains(query.toLowerCase()))
.toList();
int start = (page - 1) * pageSize;
int end = Math.min(start + pageSize, filteredFiles.size());
FileListResponse response = FileListResponse.builder()
.files(filteredFiles.subList(start, Math.min(end, filteredFiles.size())))
.totalCount(filteredFiles.size())
.totalSize(allFiles.getTotalSize())
.formattedTotalSize(allFiles.getFormattedTotalSize())
.page(page)
.pageSize(pageSize)
.build();
return ResponseEntity.ok(FileApiResponse.success(response));
} catch (NotFoundAuthToken e) {
return ResponseEntity.status(401)
.body(FileApiResponse.error("Authentication required"));
} catch (Exception e) {
log.error("Error searching files", e);
return ResponseEntity.badRequest()
.body(FileApiResponse.error("Failed to search files: " + e.getMessage()));
}
}
/**
* Получить общий размер файлов пользователя
*/
@GetMapping("/storage/usage")
public ResponseEntity<FileApiResponse<Long>> getStorageUsage(
@RequestHeader("Authorization") String tokenHeader) {
try {
Long userId = getUserIdFromToken(tokenHeader);
long usage = fileEntityService.getUserStorageUsed(userId);
return ResponseEntity.ok(FileApiResponse.success(usage));
} catch (NotFoundAuthToken e) {
return ResponseEntity.status(401)
.body(FileApiResponse.error("Authentication required"));
} catch (Exception e) {
log.error("Error getting storage usage", e);
return ResponseEntity.badRequest()
.body(FileApiResponse.error("Failed to get storage usage: " + e.getMessage()));
}
}
/**
* Пометить файл как удаленный
*/
@DeleteMapping("/{fileId}")
public ResponseEntity<FileApiResponse<Void>> deleteFile(
@PathVariable String fileId,
@RequestHeader("Authorization") String tokenHeader) {
try {
Long userId = getUserIdFromToken(tokenHeader);
FileEntityResponse fileInfo = fileEntityService.getById(fileId);
if (!fileInfo.getUserId().equals(userId)) {
return ResponseEntity.status(403)
.body(FileApiResponse.error("Access denied"));
}
fileEntityService.markAsDeleted(fileId);
return ResponseEntity.ok(FileApiResponse.success(
"File marked as deleted", null));
} catch (NotFoundAuthToken e) {
return ResponseEntity.status(401)
.body(FileApiResponse.error("Authentication required"));
} catch (Exception e) {
log.error("Error deleting file", e);
return ResponseEntity.badRequest()
.body(FileApiResponse.error("Failed to delete file: " + e.getMessage()));
}
}
private Long getUserIdFromToken(String tokenHeader) {
String token = tokenHeader.replace("Bearer ", "");
AuthToken authToken = authTokenRepository.findByToken(token)
.orElseThrow(() -> new NotFoundAuthToken("Token not found"));
return authToken.getUser().getId();
}
}
@@ -1,224 +0,0 @@
package ru.soune.nocopy.controller.file;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import ru.soune.nocopy.dto.file.FileApiResponse;
import ru.soune.nocopy.dto.file.InitUploadRequest;
import ru.soune.nocopy.dto.file.UploadProgressResponse;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.file.FileType;
import ru.soune.nocopy.entity.file.FileUploadSession;
import ru.soune.nocopy.entity.file.UploadStatus;
import ru.soune.nocopy.exception.FileUploadException;
import ru.soune.nocopy.exception.NotFoundAuthToken;
import ru.soune.nocopy.exception.UploadSessionNotFoundException;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.FileUploadSessionRepository;
import ru.soune.nocopy.service.file.FileUploadService;
import java.util.*;
@Slf4j
@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileUploadController {
private final FileUploadService fileUploadService;
private final AuthTokenRepository authTokenRepository;
private final FileUploadSessionRepository fileUploadSessionRepository;
//TODO ADD TEMPLATE JSON, RESPONSE OK,ADD MESSAGE CODE
@PostMapping("/init")
public ResponseEntity<FileApiResponse<FileUploadSession>> initUpload(@Valid @RequestBody InitUploadRequest request,
@RequestHeader("Authorization") String tokenHeader) {
String token = tokenHeader.replace("Bearer ", "");
Optional<AuthToken> tokenOptional = authTokenRepository.findByToken(token);
AuthToken authToken = tokenOptional.orElseThrow(() -> new NotFoundAuthToken("Token: " + token + "not found"));
try {
FileUploadSession session = fileUploadService.initUpload(authToken.getUser().getId(), request.getFileName(),
request.getFileType(), request.getExtension(), request.getFileSize());
return ResponseEntity.ok(FileApiResponse.success(session));
} catch (Exception e) {
log.error("Error initializing upload", e);
return ResponseEntity.badRequest()
.body(FileApiResponse.error(e.getMessage()));
}
}
@GetMapping("/file-types")
public ResponseEntity<List<FileType>> getFileTypes() {
return ResponseEntity.ok(Arrays.asList(FileType.values()));
}
@PostMapping("/chunk")
public ResponseEntity<FileApiResponse<UploadProgressResponse>> uploadChunk(
@RequestParam("uploadId") String uploadId,
@RequestParam("chunkNumber") Integer chunkNumber,
@RequestParam("chunk") MultipartFile chunk) {
log.info("Uploading chunk {} for session {}, file size: {} bytes",
chunkNumber, uploadId, chunk.getSize());
if (chunk.isEmpty()) {
return ResponseEntity.badRequest()
.body(FileApiResponse.error("Chunk file is empty"));
}
if (uploadId == null || uploadId.isBlank()) {
return ResponseEntity.badRequest()
.body(FileApiResponse.error("Upload ID is required"));
}
try {
UploadProgressResponse progress = fileUploadService.uploadChunk(
uploadId, chunkNumber, chunk);
return ResponseEntity.ok(FileApiResponse.success(progress));
} catch (Exception e) {
log.error("Error uploading chunk", e);
return ResponseEntity.badRequest()
.body(FileApiResponse.error(e.getMessage()));
}
}
@GetMapping("/progress/{uploadId}")
public ResponseEntity<FileApiResponse<UploadProgressResponse>> getProgress(
@PathVariable String uploadId) {
try {
UploadProgressResponse progress = fileUploadService.getUploadProgress(uploadId);
return ResponseEntity.ok(FileApiResponse.success(progress));
} catch (UploadSessionNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(FileApiResponse.error("Upload session not found"));
} catch (Exception e) {
log.error("Error getting progress", e);
return ResponseEntity.badRequest()
.body(FileApiResponse.error("Failed to get progress"));
}
}
@PostMapping("/{uploadId}/complete")
public ResponseEntity<FileApiResponse<UploadProgressResponse>> completeUpload(
@PathVariable String uploadId) {
log.info("Manual completion requested for session: {}", uploadId);
try {
UploadProgressResponse progress = fileUploadService.getUploadProgress(uploadId);
if (progress.getStatus() == UploadStatus.COMPLETED) {
return ResponseEntity.ok(FileApiResponse.success(
"Upload already completed", progress));
}
if (progress.getUploadedChunks() < progress.getTotalChunks()) {
return ResponseEntity.badRequest()
.body(FileApiResponse.error(
String.format("Not all chunks uploaded: %d/%d",
progress.getUploadedChunks(), progress.getTotalChunks())));
}
return ResponseEntity.ok(FileApiResponse.success(
"File assembly in progress", progress));
} catch (UploadSessionNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(FileApiResponse.error("Upload session not found"));
} catch (Exception e) {
log.error("Error completing upload", e);
return ResponseEntity.internalServerError()
.body(FileApiResponse.error("Failed to complete upload"));
}
}
@PostMapping("/{uploadId}/cancel")
public ResponseEntity<FileApiResponse<Void>> cancelUpload(
@PathVariable String uploadId) {
log.info("Cancelling upload session: {}", uploadId);
try {
fileUploadService.cancelUpload(uploadId);
return ResponseEntity.ok(FileApiResponse.success(
"Upload cancelled successfully", null));
} catch (UploadSessionNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(FileApiResponse.error("Upload session not found"));
} catch (FileUploadException e) {
return ResponseEntity.badRequest()
.body(FileApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("Error cancelling upload", e);
return ResponseEntity.internalServerError()
.body(FileApiResponse.error("Failed to cancel upload"));
}
}
@PostMapping("/{uploadId}/retry")
public ResponseEntity<FileApiResponse<UploadProgressResponse>> retryUpload(
@PathVariable String uploadId) {
log.info("Retry requested for failed upload: {}", uploadId);
try {
fileUploadService.retryFailedUpload(uploadId);
UploadProgressResponse progress = fileUploadService.getUploadProgress(uploadId);
return ResponseEntity.ok(FileApiResponse.success(
"Upload retry initiated", progress));
} catch (UploadSessionNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(FileApiResponse.error("Upload session not found"));
} catch (FileUploadException e) {
return ResponseEntity.badRequest()
.body(FileApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("Error retrying upload", e);
return ResponseEntity.internalServerError()
.body(FileApiResponse.error("Failed to retry upload"));
}
}
@GetMapping("/{uploadId}/chunks")
public ResponseEntity<FileApiResponse<Map<Integer, Boolean>>> getChunkStatus(
@PathVariable String uploadId) {
try {
FileUploadSession session = fileUploadSessionRepository.findById(uploadId)
.orElseThrow(() -> new UploadSessionNotFoundException(uploadId));
Map<Integer, Boolean> chunkStatus = new HashMap<>();
for (int i = 0; i < session.getTotalChunks(); i++) {
chunkStatus.put(i, session.getChunkPaths().containsKey(i));
}
return ResponseEntity.ok(FileApiResponse.success(chunkStatus));
} catch (UploadSessionNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(FileApiResponse.error("Upload session not found"));
} catch (Exception e) {
log.error("Error getting chunk status", e);
return ResponseEntity.internalServerError()
.body(FileApiResponse.error("Failed to get chunk status"));
}
}
}
@@ -1,10 +0,0 @@
package ru.soune.nocopy.dto;
import java.time.LocalDateTime;
public record AuthResponse (
boolean success,
String message,
String token,
LocalDateTime expiresAt
) {}
@@ -0,0 +1,16 @@
package ru.soune.nocopy.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class BaseRequest {
@JsonProperty("version")
Integer version;
@JsonProperty("msg_id")
Integer msgId;
@JsonProperty("message_body")
Object messageBody;
}
@@ -0,0 +1,21 @@
package ru.soune.nocopy.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class BaseResponse {
@JsonProperty("msg_id")
private Integer msgId;
@JsonProperty("message_code")
private Integer messageCode;
@JsonProperty("message_desc")
private String messageDesc;
@JsonProperty("message_body")
private Object messageBody;
}
@@ -2,12 +2,11 @@ package ru.soune.nocopy.dto;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor;
@Data @Data
@AllArgsConstructor @AllArgsConstructor
public class LoginResponse { @NoArgsConstructor
private boolean success; public class LogOutAnswer {
private String email; private String email;
private String token;
private String expiresAt;
} }
@@ -0,0 +1,17 @@
package ru.soune.nocopy.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginAnswer {
private String Token;
private List<Map<String, String>> fieldErrors;
}
@@ -0,0 +1,19 @@
package ru.soune.nocopy.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LogoutRequest {
@NotBlank(message = "error.not.blank") @Email(message = "error.not.email") @Size(max = 128)
private String email;
@NotBlank(message = "error.not.blank")
private String token;
}
@@ -0,0 +1,40 @@
package ru.soune.nocopy.dto;
public enum MessageCode {
SUCCESS(0, "Operation successful"),
REG_EMAIL_EXISTS(1, "Email already registered"),
REG_EMAIL_OR_PHONE_EXISTS(1, "Email or phone already registered"),
INVALID_FIELD(2, "Invalid field"),
INVALID_TOKEN(2, "Invalid token"),
INVALID_ACTION(2, "Invalid action"),
FILE_UPLOAD_ERROR(2, "File upload error"),
FILE_DOWNLOAD_ERROR(2, "File download error"),
FILE_DOWNLOAD_ERROR_NOT_CORRECT_FIELD(2, "Not correct field"),
INVALID_JSON_BODY(2, "Invalid fields in JSON object"),
INCOMPLETE_UPLOAD(2, "Not load all chunks"),
MSG_ID_NOT_FOUND(4, "Message id not found"),
FILE_ENTITY_ERROR(2, "File entity error"),
ACCESS_DENIED(2, "Access denied"),
AUTH_EMAIL_NOT_FOUND(4, "Email not found"),
AUTH_EMAIL_OR_TOKEN_NOT_FOUND(4, "Email or Token not found "),
AUTH_TOKEN_MISMATCH(4, "Token mismatch"),
FILE_NOT_FOUND(4, "File not found"),
AUTH_PASSWORD_NOT_MATCHES(2, "Password does not match");
private final Integer code;
private final String description;
MessageCode(Integer code, String description) {
this.code = code;
this.description = description;
}
public Integer getCode() {
return code;
}
public String getDescription() {
return description;
}
}
@@ -0,0 +1,17 @@
package ru.soune.nocopy.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RegAnswer {
private String Token;
private List<Map<String, String>> fieldErrors;
}
@@ -0,0 +1,25 @@
package ru.soune.nocopy.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
@Data
public class RegRequest {
@NotEmpty(message = "Full name is required")
private String fullName;
private String companyName;
@Size(min = 11, max = 14, message = "Phone must be 11-14 digits")
private String phone;
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
@Size(max = 128, message = "Email too long")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;
}
@@ -1,40 +0,0 @@
package ru.soune.nocopy.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Past;
import jakarta.validation.constraints.Size;
import org.springframework.format.annotation.DateTimeFormat;
import ru.soune.nocopy.entity.GenderType;
import ru.soune.nocopy.entity.SubscriptionType;
import java.time.LocalDate;
public record RegisterRequest(
@NotBlank(message = "error.name.length")
String fullName,
String companyName,
@Size(min = 11, max = 14)
String phone,
@NotBlank(message = "error.not.blank")
@Email(message = "error.not.email")
@Size(max = 128)
String email,
@DateTimeFormat(pattern = "dd-MM-yyyy")
@JsonFormat(pattern = "dd-MM-yyyy")
@Past
LocalDate birthday,
@NotBlank(message = "error.not.blank")
@Size(min = 8)
String password,
SubscriptionType subscriptionType,
GenderType genderType
) {}
@@ -0,0 +1,17 @@
package ru.soune.nocopy.dto.file;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ActionResponse {
private List<String> availableActions;
private String action;
}
@@ -0,0 +1,15 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class CanceledUploadResponse {
@JsonProperty("upload_id")
private String uploadId;
@JsonProperty("message")
private String message;
}
@@ -0,0 +1,28 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
@Data
@Builder
public class ChunkStatusResponse {
@JsonProperty("upload_id")
private String uploadId;
@JsonProperty("total_chunks")
private Integer totalChunks;
@JsonProperty("chunk_status")
private Map<String, Boolean> chunkStatus;
@JsonProperty("uploaded_chunks")
private Integer uploadedChunks;
@JsonProperty("missing_chunks")
private Integer missingChunks;
}
@@ -0,0 +1,25 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChunkUploadResponse {
@JsonProperty("upload_id")
private String uploadId;
@JsonProperty("chunk_number")
private Integer chunkNumber;
@JsonProperty("chunk_size")
private Long chunkSize;
@JsonProperty("message")
private String message;
}
@@ -0,0 +1,31 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CompleteUploadResponse {
@JsonProperty("upload_id")
private String uploadId;
@JsonProperty("status")
private String status;
@JsonProperty("uploaded_chunks")
private Integer uploadedChunks;
@JsonProperty("total_chunks")
private Integer totalChunks;
@JsonProperty("message")
private String message;
@JsonProperty("file_path")
private String filePath;
}
@@ -0,0 +1,19 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class DeleteFileResponse {
@JsonProperty("file_id")
private String fileId;
@JsonProperty("message")
private String message;
}
@@ -0,0 +1,34 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class FileEntityRequest {
@JsonProperty("action")
private String action;
@JsonProperty("file_id")
private String fileId;
@JsonProperty("upload_session_id")
private String uploadSessionId;
@JsonProperty("query")
private String query;
@JsonProperty("page")
private Integer page;
@JsonProperty("page_size")
private Integer pageSize;
@JsonProperty("token")
private String token;
}
@@ -0,0 +1,20 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class FileExtensionResponse {
@JsonProperty("file_extension")
private List<String> extension;
@JsonProperty("count")
private Integer count;
@JsonProperty("max_file_size")
private Long maxFileSize;
}
@@ -0,0 +1,61 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FileInfoUserResponse {
@JsonProperty("all_files_size")
private Long allFileSize;
@JsonProperty("all_files_quantity")
private Integer fileCount;
@JsonProperty("all_files_check")
private Integer filesCheck;
@JsonProperty("all_files_violation")
private Integer filesViolation;
@JsonProperty("images_size")
private Long imagesSize;
@JsonProperty("images_quantity")
private Integer imagesCount;
@JsonProperty("images_check")
private Integer imagesCheck;
@JsonProperty("images_violations")
private Integer imagesViolations;
@JsonProperty("videos_size")
private Long videosSize;
@JsonProperty("videos_quantity")
private Integer videosCount;
@JsonProperty("videos_check")
private Integer videosCheck;
@JsonProperty("videos_violations")
private Integer videosViolations;
@JsonProperty("audios_size")
private Long audiosSize;
@JsonProperty("audios_quantity")
private Integer audiosCount;
@JsonProperty("audios_check")
private Integer audiosCheck;
@JsonProperty("audios_violations")
private Integer audiosViolations;
}
@@ -1,5 +1,6 @@
package ru.soune.nocopy.dto.file; package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@@ -9,14 +10,24 @@ import java.util.List;
@Data @Data
@Builder @Builder
@NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor
public class FileListResponse { public class FileListResponse {
@JsonProperty("files")
private List<FileEntityResponse> files; private List<FileEntityResponse> files;
private int totalCount;
private long totalSize; @JsonProperty("total_count")
private Integer totalCount;
@JsonProperty("total_size")
private Long totalSize;
@JsonProperty("formatted_total_size")
private String formattedTotalSize; private String formattedTotalSize;
private int page;
private int pageSize; @JsonProperty("page")
private Integer page;
@JsonProperty("page_size")
private Integer pageSize;
} }
@@ -0,0 +1,22 @@
package ru.soune.nocopy.dto.file;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FileResponse {
private List<FileEntityResponse> files;
private int totalCount;
private long totalSize;
private String formattedTotalSize;
private int page;
private int pageSize;
}
@@ -0,0 +1,19 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import ru.soune.nocopy.entity.file.FileType;
import java.util.List;
@Data
@Builder
public class FileTypesResponse {
@JsonProperty("file_types")
private List<FileType> fileTypes;
@JsonProperty("count")
private Integer count;
}
@@ -0,0 +1,31 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class FileUploadRequest {
@JsonProperty("upload_id")
private String uploadId;
@JsonProperty("file_name")
private String fileName;
@JsonProperty("file_type")
private String fileType;
@JsonProperty("extension")
private String extension;
@JsonProperty("file_size")
private Long fileSize;
@JsonProperty("chunk_number")
private Integer chunkNumber;
@JsonProperty("action")
private String action;
@JsonProperty("token")
private String token;
}
@@ -0,0 +1,30 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class InitFileResponse {
@JsonProperty("upload_id")
private String uploadId;
@JsonProperty("file_name")
private String fileName;
@JsonProperty("total_chunks")
private Integer totalChunks;
@JsonProperty("chunk_size")
private Long chunkSize;
@JsonProperty("status")
private String status;
@JsonProperty("field_errors")
private List<Map<String, String>> fieldErrors;
}
@@ -0,0 +1,18 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class RetryUploadResponse {
@JsonProperty("upload_id")
private String uploadId;
@JsonProperty("message")
private String message;
@JsonProperty("status")
private String status;
}
@@ -0,0 +1,22 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class StorageUsageResponse {
@JsonProperty("user_id")
private Long userId;
@JsonProperty("storage_used")
private Long storageUsed;
@JsonProperty("formatted_storage_used")
private String formattedStorageUsed;
}
@@ -0,0 +1,40 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UploadProgress {
@JsonProperty("upload_id")
private String uploadId;
@JsonProperty("file_name")
private String fileName;
@JsonProperty("total_chunks")
private Integer totalChunks;
@JsonProperty("uploaded_chunks")
private Integer uploadedChunks;
@JsonProperty("status")
private String status;
@JsonProperty("progress_percentage")
private Integer progressPercentage;
@JsonProperty("file_path")
private String filePath;
@JsonProperty("remaining_chunks")
private Integer remainingChunks;
@JsonProperty("message")
private String message;
}
+18 -3
View File
@@ -6,11 +6,14 @@ import jakarta.validation.constraints.Size;
import lombok.*; import lombok.*;
import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener; import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.entity.auth.ExternalAuthProvider;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional;
@Entity @Entity
@Table(name = "users") @Table(name = "users")
@@ -19,18 +22,19 @@ import java.util.List;
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@ToString @ToString
@Builder
@EntityListeners(AuditingEntityListener.class) @EntityListeners(AuditingEntityListener.class)
public class User { public class User {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id; private Long id;
@Column(name = "full_name", nullable = false) @Column(name = "full_name", nullable = false)
private String fullName; private String fullName;
@Size(max = 1024) @Size(max = 255)
@Column(name = "email", nullable = false, length = 1024, unique = true) @Column(name = "email", nullable = false, length = 255, unique = true)
private String email; private String email;
@Column(name = "company") @Column(name = "company")
@@ -42,6 +46,7 @@ public class User {
private String password; private String password;
@Size(min = 11, max = 14) @Size(min = 11, max = 14)
@Column(unique = true)
private String phone; private String phone;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@@ -89,4 +94,14 @@ public class User {
@JsonIgnore @JsonIgnore
@ToString.Exclude @ToString.Exclude
private List<ImageProtection> imageProtections = new ArrayList<>(); private List<ImageProtection> imageProtections = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@ToString.Exclude
private List<ExternalAuthProvider> externalAuthProviders = new ArrayList<>();
public Optional<ExternalAuthProvider> getProvider(ExternalAuthProvider.ProviderType providerType) {
return externalAuthProviders.stream()
.filter(p -> p.getProviderType() == providerType)
.findFirst();
}
} }
@@ -1,9 +1,10 @@
package ru.soune.nocopy.entity; package ru.soune.nocopy.entity.auth;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener; import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import ru.soune.nocopy.entity.User;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -30,7 +31,7 @@ public class AuthToken {
private String token; private String token;
@Column(name = "expires_at", nullable = false) @Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); private LocalDateTime expiresAt = LocalDateTime.now().plusHours(1);
@CreatedDate @CreatedDate
@Column(name = "created_at", updatable = false, nullable = false) @Column(name = "created_at", updatable = false, nullable = false)
@@ -0,0 +1,89 @@
package ru.soune.nocopy.entity.auth;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import ru.soune.nocopy.entity.User;
import java.time.LocalDateTime;
@Entity
@Table(name = "external_auth_providers",
uniqueConstraints = @UniqueConstraint(
columnNames = {"provider_type", "provider_user_id"}
),
indexes = {
@Index(name = "idx_provider_type_user_id",
columnList = "provider_type, provider_user_id"),
@Index(name = "idx_user_id_provider_type",
columnList = "user_id, provider_type")
})
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class ExternalAuthProvider {
public enum ProviderType {
VK, YANDEX
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_extauth_user"))
@ToString.Exclude
private User user;
@Enumerated(EnumType.STRING)
@Column(name = "provider_type", nullable = false, length = 20)
private ProviderType providerType;
@Column(name = "provider_user_id", nullable = false, length = 255)
private String providerUserId;
@Column(name = "access_token", length = 2048)
private String accessToken;
@Column(name = "refresh_token", length = 2048)
private String refreshToken;
@Column(name = "token_expires_at")
private LocalDateTime tokenExpiresAt;
@Column(name = "email", length = 255)
private String email;
@Column(name = "phone", length = 20)
private String phoneNumber;
@CreatedDate
@Column(name = "linked_at", updatable = false)
private LocalDateTime linkedAt;
@Column(name = "last_used_at")
private LocalDateTime lastUsedAt;
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
@PrePersist
public void prePersist() {
if (this.linkedAt == null) {
this.linkedAt = LocalDateTime.now();
}
if (this.lastUsedAt == null) {
this.lastUsedAt = LocalDateTime.now();
}
}
@PreUpdate
public void preUpdate() {
this.lastUsedAt = LocalDateTime.now();
}
}
@@ -4,5 +4,7 @@ public enum FileStatus {
ACTIVE, ACTIVE,
DELETED, DELETED,
PROCESSING, PROCESSING,
VIOLATION,
CHECKED,
ERROR ERROR
} }
@@ -2,20 +2,44 @@ package ru.soune.nocopy.entity.file;
import lombok.Getter; import lombok.Getter;
import java.util.Arrays;
import java.util.List;
@Getter @Getter
public enum FileType { public enum FileType {
PHOTO("photo"), IMAGE("image", Arrays.asList("jpg", "jpeg", "png", "gif", "bmp", "webp")),
VIDEO("video"), VIDEO("video", Arrays.asList("mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v", "mpg", "mpeg",
AUDIO("audio"), "3gp", "3g2", "f4v", "m2ts", "mts", "vob", "ogv", "divx")),
DOCUMENT("document"); AUDIO("audio", Arrays.asList("mp3", "wav", "flac")),
DOCUMENT("document", Arrays.asList(
"pdf", "txt", "rtf",
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "pps", "ppsx", "dot", "dotx", "xlt", "xltx", "pot", "potx",
"odt", "ods", "odp", "odg", "odf", "odb", "odc", "odi", "odm", "ott", "ots", "otp", "otg", "oth",
"sxw", "sxc", "sxi", "sxd", "sxg", "stc", "sti", "stw", "sxm",
"pages", "numbers", "key",
"csv", "tsv", "xml", "html", "htm", "tex", "md", "markdown",
"epub", "mobi", "azw", "azw3", "fb2",
"wps", "wpt", "et", "dps", "vsd", "vsdx",
"java", "py", "cpp", "c", "h", "js", "css", "php", "sql", "json", "yaml", "yml", "sh", "bat",
"one", "note"));
private final String code; private final String displayName;
private final List<String> allowedExtensions;
FileType(String code) { FileType(String displayName, List<String> allowedExtensions) {
this.code = code; this.displayName = displayName;
this.allowedExtensions = allowedExtensions;
} }
public String getCode() { public String getDisplayName() {
return code; return displayName;
}
public List<String> getAllowedExtensions() {
return allowedExtensions;
}
public boolean supportsExtension(String extension) {
return allowedExtensions.contains(extension.toLowerCase());
} }
} }
@@ -33,6 +33,9 @@ public class FileUploadSession {
@Column(name = "file_size") @Column(name = "file_size")
private Long fileSize; private Long fileSize;
@Column(name = "chunk_size")
private Long chunkSize;
@Column(name = "total_chunks") @Column(name = "total_chunks")
private Integer totalChunks; private Integer totalChunks;
@@ -83,10 +86,15 @@ public class FileUploadSession {
@PrePersist @PrePersist
public void prePersist() { public void prePersist() {
this.createdAt = LocalDateTime.now(); this.createdAt = LocalDateTime.now();
this.expiresAt = LocalDateTime.now().plusHours(24); this.expiresAt = LocalDateTime.now().plusMinutes(1);
if (this.chunksUploaded == null) {
this.chunksUploaded = 0; this.chunksUploaded = 0;
}
if (this.status == null) { if (this.status == null) {
this.status = UploadStatus.INITIATED; this.status = UploadStatus.INITIATED;
} }
if (this.retryCount == null) {
this.retryCount = 0;
}
} }
} }
@@ -2,6 +2,6 @@ package ru.soune.nocopy.exception;
public class ChunkSizeExceededException extends RuntimeException { public class ChunkSizeExceededException extends RuntimeException {
public ChunkSizeExceededException(long actualSize, long maxSize) { public ChunkSizeExceededException(long actualSize, long maxSize) {
super(String.format("Chunk size exceeded: %d > %d", actualSize, maxSize)); super(String.format("Chunk size not valid: " + actualSize));
} }
} }
@@ -0,0 +1,18 @@
package ru.soune.nocopy.exception;
import lombok.Getter;
import ru.soune.nocopy.dto.BaseResponse;
@Getter
public class NotValidFieldException extends RuntimeException {
private final String message;
private final BaseResponse baseResponse;
public NotValidFieldException(String message, BaseResponse baseResponse) {
super(message);
this.message = message;
this.baseResponse = baseResponse;
}
}
@@ -1,7 +0,0 @@
package ru.soune.nocopy.exception;
public class UserAlreadyExistsException extends RuntimeException {
public UserAlreadyExistsException(String message) {
super(message);
}
}
@@ -0,0 +1,16 @@
package ru.soune.nocopy.exception;
import lombok.Getter;
import org.springframework.validation.BindingResult;
@Getter
public class ValidationException extends RuntimeException {
private final BindingResult bindingResult;
private final Integer msgId;
public ValidationException(BindingResult bindingResult, Integer msgId) {
super("Validation failed");
this.bindingResult = bindingResult;
this.msgId = msgId;
}
}
@@ -0,0 +1,328 @@
package ru.soune.nocopy.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.dto.file.*;
import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.exception.FileEntityNotFoundException;
import ru.soune.nocopy.exception.NotFoundAuthToken;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.service.file.FileEntityService;
import ru.soune.nocopy.service.file.FileStatsService;
import java.util.Arrays;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class FileEntityHandler implements RequestHandler {
private final FileEntityService fileEntityService;
private final FileStatsService fileStatsService;
private final AuthTokenRepository authTokenRepository;
private final ObjectMapper objectMapper;
private final FileEntityRepository fileEntityRepository;
@Override
public BaseResponse handle(BaseRequest request) {
try {
FileEntityRequest fileRequest = objectMapper.convertValue(
request.getMessageBody(), FileEntityRequest.class);
String action = fileRequest.getAction();
switch (action) {
case "file_info":
return handleGetFileInfo(request, fileRequest);
case "user_files_info":
return handleGetFilesUserInfo(request, fileRequest);
case "file_by_session":
return handleGetFileBySession(request, fileRequest);
case "user_files":
return handleGetUserFiles(request, fileRequest);
case "search_files":
return handleSearchFiles(request, fileRequest);
case "storage_usage":
return handleGetStorageUsage(request, fileRequest);
case "delete_file":
return handleDeleteFile(request, fileRequest);
default:
ActionResponse response = ActionResponse.builder()
.action(action)
.availableActions(Arrays.asList(
"file_info", "file_by_session", "user_files",
"search_files", "storage_usage", "delete_file"))
.build();
return new BaseResponse(request.getMsgId(),
MessageCode.INVALID_ACTION.getCode(),
"Invalid action: " + action,
response);
}
} catch (Exception e) {
log.error("Error in FileEntityHandler", e);
return new BaseResponse(request.getMsgId(),
MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Handler error: " + e.getMessage(),
null);
}
}
private BaseResponse handleGetFilesUserInfo(BaseRequest request, FileEntityRequest fileRequest) {
Long userId = getUserIdFromToken(fileRequest.getToken());
FileInfoUserResponse userFileStats = fileStatsService.getUserFileStats(userId);
return new BaseResponse(request.getMsgId(),
MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(),
userFileStats);
}
private BaseResponse handleGetFileInfo(BaseRequest request, FileEntityRequest fileRequest) {
try {
Long userId = getUserIdFromToken(fileRequest.getToken());
FileEntityResponse fileInfo = fileEntityService.getById(fileRequest.getFileId(), request.getVersion());
if (!fileInfo.getUserId().equals(userId)) {
return new BaseResponse(request.getMsgId(),
MessageCode.ACCESS_DENIED.getCode(),
"Access denied",
null);
}
return new BaseResponse(request.getMsgId(),
MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(),
fileInfo);
} catch (NotFoundAuthToken e) {
return new BaseResponse(request.getMsgId(),
MessageCode.INVALID_TOKEN.getCode(),
"Authentication required",
null);
} catch (Exception e) {
log.error("Error getting file info", e);
return new BaseResponse(request.getMsgId(),
MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to get file info: " + e.getMessage(),
null);
}
}
private BaseResponse handleGetFileBySession(BaseRequest request, FileEntityRequest fileRequest) {
try {
Long userId = getUserIdFromToken(fileRequest.getToken());
FileEntityResponse fileInfo = fileEntityService.getByUploadSessionId(fileRequest.getUploadSessionId(),
request.getVersion());
if (!fileInfo.getUserId().equals(userId)) {
return new BaseResponse(request.getMsgId(),
MessageCode.ACCESS_DENIED.getCode(),
"Access denied",
null);
}
return new BaseResponse(request.getMsgId(),
MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(),
fileInfo);
} catch (NotFoundAuthToken e) {
return new BaseResponse(request.getMsgId(),
MessageCode.INVALID_TOKEN.getCode(),
"Authentication required",
null);
} catch (Exception e) {
log.error("Error getting file by session", e);
return new BaseResponse(request.getMsgId(),
MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to get file: " + e.getMessage(),
null);
}
}
private BaseResponse handleGetUserFiles(BaseRequest request, FileEntityRequest fileRequest) {
try {
Long userId = getUserIdFromToken(fileRequest.getToken());
int page = fileRequest.getPage() != null ? fileRequest.getPage() : 1;
int pageSize = fileRequest.getPageSize() != null ? fileRequest.getPageSize() : 20;
FileResponse files = fileEntityService.getUserFiles(userId, page, pageSize, request.getVersion());
return new BaseResponse(request.getMsgId(),
MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(),
files);
} catch (NotFoundAuthToken e) {
return new BaseResponse(request.getMsgId(),
MessageCode.INVALID_TOKEN.getCode(),
"Authentication required",
null);
} catch (Exception e) {
log.error("Error getting user files", e);
return new BaseResponse(request.getMsgId(),
MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to get files: " + e.getMessage(),
null);
}
}
private BaseResponse handleSearchFiles(BaseRequest request, FileEntityRequest fileRequest) {
try {
Long userId = getUserIdFromToken(fileRequest.getToken());
int page = fileRequest.getPage() != null ? fileRequest.getPage() : 1;
int pageSize = fileRequest.getPageSize() != null ? fileRequest.getPageSize() : 20;
if (fileRequest.getQuery() == null || fileRequest.getQuery().isBlank()) {
return handleGetUserFiles(request, fileRequest);
}
FileResponse allFiles = fileEntityService.getUserFiles(userId, 1, 1000, request.getVersion());
var filteredFiles = allFiles.getFiles().stream()
.filter(f -> (f.getOriginalFileName() + f.getFileExtension()).toLowerCase()
.contains(fileRequest.getQuery().toLowerCase()))
.toList();
int start = (page - 1) * pageSize;
int end = Math.min(start + pageSize, filteredFiles.size());
List<FileEntityResponse> fileEntityResponses = filteredFiles.subList(start, Math.min(end, filteredFiles.size()));
long sumSize = fileEntityResponses.stream()
.mapToLong(FileEntityResponse::getFileSize)
.sum();
FileListResponse response = FileListResponse.builder()
.files(fileEntityResponses)
.totalCount(filteredFiles.size())
.totalSize(sumSize)
.formattedTotalSize(fileEntityService.formatFileSize(sumSize))
.page(page)
.pageSize(pageSize)
.build();
return new BaseResponse(request.getMsgId(),
MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(),
response);
} catch (NotFoundAuthToken e) {
return new BaseResponse(request.getMsgId(), MessageCode.INVALID_TOKEN.getCode(),
"Authentication required", null);
} catch (Exception e) {
log.error("Error searching files", e);
return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to search files: " + e.getMessage(), null);
}
}
private BaseResponse handleGetStorageUsage(BaseRequest request, FileEntityRequest fileRequest) {
try {
Long userId = getUserIdFromToken(fileRequest.getToken());
long usage = fileEntityService.getUserStorageUsed(userId);
StorageUsageResponse response = StorageUsageResponse.builder()
.userId(userId)
.storageUsed(usage)
.formattedStorageUsed(formatFileSize(usage))
.build();
return new BaseResponse(request.getMsgId(),
MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(),
response);
} catch (NotFoundAuthToken e) {
return new BaseResponse(request.getMsgId(),
MessageCode.INVALID_TOKEN.getCode(),
"Authentication required",
null);
} catch (Exception e) {
log.error("Error getting storage usage", e);
return new BaseResponse(request.getMsgId(),
MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to get storage usage: " + e.getMessage(),
null);
}
}
private BaseResponse handleDeleteFile(BaseRequest request, FileEntityRequest fileRequest) {
try {
Long userId = getUserIdFromToken(fileRequest.getToken());
String fileId = fileRequest.getFileId();
FileEntityResponse fileInfo = fileEntityService.getById(fileId, request.getVersion());
DeleteFileResponse response;
if (!fileInfo.getUserId().equals(userId)) {
return new BaseResponse(request.getMsgId(),
MessageCode.ACCESS_DENIED.getCode(),
"Access denied",
null);
}
FileEntity fileEntity = fileEntityRepository.findById(fileId)
.orElseThrow(() -> new FileEntityNotFoundException(fileId));
if (fileEntity.getStatus().equals(FileStatus.DELETED)) {
fileEntityService.deleteFromDisk(fileEntity);
response = DeleteFileResponse.builder()
.fileId(fileRequest.getFileId())
.message("File deleted from disk")
.build();
} else {
fileEntityService.markAsDeleted(fileEntity);
response = DeleteFileResponse.builder()
.fileId(fileRequest.getFileId())
.message("File marked as deleted")
.build();
}
return new BaseResponse(request.getMsgId(),
MessageCode.SUCCESS.getCode(),
"File marked as deleted",
response);
} catch (NotFoundAuthToken e) {
return new BaseResponse(request.getMsgId(),
MessageCode.INVALID_TOKEN.getCode(),
"Authentication required",
null);
} catch (Exception e) {
log.error("Error deleting file", e);
return new BaseResponse(request.getMsgId(),
MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to delete file: " + e.getMessage(),
null);
}
}
private Long getUserIdFromToken(String token) {
if (token == null || token.isBlank()) {
throw new NotFoundAuthToken("Token is required");
}
if (token.startsWith("Bearer ")) {
token = token.replace("Bearer ", "");
}
AuthToken authToken = authTokenRepository.findByToken(token)
.orElseThrow(() -> new NotFoundAuthToken("Token not found"));
return authToken.getUser().getId();
}
private String formatFileSize(long size) {
if (size < 1024) return size + " B";
int exp = (int) (Math.log(size) / Math.log(1024));
String pre = "KMGTPE".charAt(exp-1) + "";
return String.format("%.1f %sB", size / Math.pow(1024, exp), pre);
}
}
@@ -0,0 +1,274 @@
package ru.soune.nocopy.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.dto.file.*;
import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.entity.file.FileType;
import ru.soune.nocopy.entity.file.FileUploadSession;
import ru.soune.nocopy.exception.*;
import ru.soune.nocopy.handler.validator.FileUploadRequestValidator;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.FileUploadSessionRepository;
import ru.soune.nocopy.service.file.FileUploadService;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Component
@RequiredArgsConstructor
public class FileUploadHandler implements RequestHandler {
private final FileUploadService fileUploadService;
private final AuthTokenRepository authTokenRepository;
private final FileUploadSessionRepository fileUploadSessionRepository;
private final ObjectMapper objectMapper;
private final FileUploadRequestValidator fileUploadRequestValidator;
@Value("${file.storage.max-file-size}")
private long maxFileSize;
@Override
public BaseResponse handle(BaseRequest request) {
try {
FileUploadRequest fileRequest = objectMapper.convertValue(
request.getMessageBody(), FileUploadRequest.class);
String action = fileRequest.getAction();
switch (action) {
case "init":
return handleInitUpload(request, fileRequest);
case "file_types":
return handleGetFileTypes(request);
case "file_extension":
return handleGetFileExtensions(fileRequest, request);
case "chunks":
return handleGetChunkStatus(request, fileRequest);
case "cancel":
return handleCancelUpload(request, fileRequest);
case "progress":
return handleGetProgress(request, fileRequest);
default:
ActionResponse response = ActionResponse.builder()
.action(action)
.availableActions(Arrays.asList("init", "file_types", "chunks", "cancel", "progress"))
.build();
return new BaseResponse(request.getMsgId(), MessageCode.INVALID_ACTION.getCode(),
"Invalid action: " + action, response);
}
} catch (Exception e) {
log.error("Error in FileUploadHandler", e);
return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Handler error: " + e.getMessage(), null);
}
}
private BaseResponse handleInitUpload(BaseRequest request, FileUploadRequest fileRequest) {
String token = fileRequest.getToken();
Optional<AuthToken> tokenOptional = authTokenRepository.findByToken(token);
if (tokenOptional.isEmpty()) {
return new BaseResponse(request.getMsgId(), MessageCode.INVALID_TOKEN.getCode(),
MessageCode.INVALID_TOKEN.getDescription(), Map.of("token", token));
}
AuthToken authToken = tokenOptional.orElseThrow(() -> new NotFoundAuthToken("Token not found"));
BindingResult bindingResult = new BeanPropertyBindingResult(fileRequest, "fileRequest");
fileUploadRequestValidator.validate(fileRequest, bindingResult);
if (bindingResult.hasErrors()) {
Map<String, String> fieldErrors = bindingResult.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
fieldError -> fieldError.getDefaultMessage() != null
? fieldError.getDefaultMessage()
: "Validation error"));
return new BaseResponse(request.getMsgId(), MessageCode.INVALID_FIELD.getCode(),
MessageCode.INVALID_FIELD.getDescription(), fieldErrors);
}
FileUploadSession session = fileUploadService.initUpload(
authToken.getUser().getId(),
fileRequest.getFileName(),
fileRequest.getFileType(),
fileRequest.getExtension(),
fileRequest.getFileSize());
InitFileResponse fileResponse = InitFileResponse.builder()
.uploadId(session.getUploadId())
.fileName(session.getFileName())
.totalChunks(session.getTotalChunks())
.chunkSize(session.getChunkSize())
.status(session.getStatus().toString())
.build();
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), fileResponse);
}
private BaseResponse handleGetProgress(BaseRequest request, FileUploadRequest fileRequest) {
try {
ru.soune.nocopy.dto.file.UploadProgressResponse progress =
fileUploadService.getUploadProgress(fileRequest.getUploadId());
UploadProgressResponse responseDto = UploadProgressResponse.builder()
.uploadId(progress.getUploadId())
.fileName(progress.getFileName())
.totalChunks(progress.getTotalChunks())
.uploadedChunks(progress.getUploadedChunks())
.status(progress.getStatus())
.progressPercentage(progress.getProgressPercentage())
.filePath(progress.getFilePath())
.build();
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), responseDto);
} catch (UploadSessionNotFoundException e) {
return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(),
MessageCode.FILE_NOT_FOUND.getDescription(), null);
} catch (Exception e) {
log.error("Error getting progress", e);
return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(),
MessageCode.FILE_UPLOAD_ERROR.getDescription(), null);
}
}
private BaseResponse handleCancelUpload(BaseRequest request, FileUploadRequest fileRequest) {
try {
fileUploadService.cancelUpload(fileRequest.getUploadId());
CanceledUploadResponse response = CanceledUploadResponse.builder()
.uploadId(fileRequest.getUploadId())
.message("Upload cancelled successfully")
.build();
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
"Upload cancelled successfully", response);
} catch (UploadSessionNotFoundException e) {
return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(),
"Upload session not found", null);
} catch (FileUploadException e) {
return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), e.getMessage(), null);
} catch (Exception e) {
log.error("Error cancelling upload", e);
return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to cancel upload", null);
}
}
private BaseResponse handleRetryUpload(BaseRequest request, FileUploadRequest fileRequest) {
try {
fileUploadService.retryFailedUpload(fileRequest.getUploadId());
ru.soune.nocopy.dto.file.UploadProgressResponse progress =
fileUploadService.getUploadProgress(fileRequest.getUploadId());
RetryUploadResponse response = RetryUploadResponse.builder()
.uploadId(progress.getUploadId())
.message("Upload retry initiated")
.status(progress.getStatus().toString())
.build();
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), "Upload retry initiated",
response);
} catch (UploadSessionNotFoundException e) {
return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(),
"Upload session not found", null);
} catch (FileUploadException e) {
return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), e.getMessage(),
null);
} catch (Exception e) {
log.error("Error retrying upload", e);
return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to retry upload", null);
}
}
private BaseResponse handleGetChunkStatus(BaseRequest request, FileUploadRequest fileRequest) {
try {
FileUploadSession session = fileUploadSessionRepository.findById(fileRequest.getUploadId())
.orElseThrow(() -> new UploadSessionNotFoundException(fileRequest.getUploadId()));
Map<String, Boolean> chunkStatus = new HashMap<>();
for (int i = 0; i < session.getTotalChunks(); i++) {
chunkStatus.put("chunk_" + i, session.getChunkPaths().containsKey(i));
}
ChunkStatusResponse response = ChunkStatusResponse.builder()
.uploadId(fileRequest.getUploadId())
.totalChunks(session.getTotalChunks())
.uploadedChunks(session.getChunksUploaded())
.missingChunks(session.getTotalChunks() - session.getChunksUploaded())
.chunkStatus(chunkStatus)
.build();
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), response);
} catch (UploadSessionNotFoundException e) {
return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(),
"Upload session not found", null);
} catch (Exception e) {
log.error("Error getting chunk status", e);
return new BaseResponse(request.getMsgId(),
MessageCode.FILE_UPLOAD_ERROR.getCode(), "Failed to get chunk status",
null);
}
}
private BaseResponse handleGetFileTypes(BaseRequest request) {
try {
FileTypesResponse response = FileTypesResponse.builder()
.fileTypes(Arrays.asList(FileType.values()))
.count(FileType.values().length)
.build();
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), response);
} catch (Exception e) {
log.error("Error getting file types", e);
return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to get file types", null);
}
}
private BaseResponse handleGetFileExtensions(FileUploadRequest fileRequest, BaseRequest request) {
try {
FileType fileType = FileType.valueOf(fileRequest.getFileType().toUpperCase());
List<String> allowedExtensions = fileType.getAllowedExtensions();
FileExtensionResponse response = FileExtensionResponse.builder()
.extension(allowedExtensions)
.count(allowedExtensions.size())
.maxFileSize(maxFileSize)
.build();
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), response);
} catch (Exception e) {
log.error("Error getting file extensions", e);
return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to get file extensions", null);
}
}
}
@@ -29,15 +29,13 @@ public class GlobalExceptionHandler {
"message" ,message)); "message" ,message));
} }
@ExceptionHandler(UserAlreadyExistsException.class) @ExceptionHandler(NotValidFieldException.class)
@ResponseStatus(HttpStatus.CONFLICT) @ResponseStatus(HttpStatus.OK)
public ResponseEntity<?> handleUserContainsException(UserAlreadyExistsException ex) { public ResponseEntity<?> handleUserContainsException(NotValidFieldException ex) {
return ResponseEntity.
badRequest() return ResponseEntity
.body(Map.of( .ok()
"success", false, .body(ex.getBaseResponse());
"message" ,ex.getMessage()
));
} }
@ExceptionHandler(UserNotFoundException.class) @ExceptionHandler(UserNotFoundException.class)
@@ -0,0 +1,44 @@
package ru.soune.nocopy.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.AuthService;
import java.util.Arrays;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class LoginRequestHandler implements RequestHandler {
private final UserRepository userRepository;
private final AuthService authService;
private final ObjectMapper objectMapper;
@Override
public BaseResponse handle(BaseRequest request) {
LoginRequest loginRequest = objectMapper.convertValue(request.getMessageBody(), LoginRequest.class);
if (!userRepository.existsByEmail(loginRequest.getEmail())) {
LoginAnswer loginAnswer = new LoginAnswer();
loginAnswer.setFieldErrors(Arrays.asList(Map.of("email", loginRequest.getEmail())));
throw new NotValidFieldException("User with email not found: " + loginRequest.getEmail(),
new BaseResponse(request.getMsgId(), MessageCode.AUTH_EMAIL_NOT_FOUND.getCode(),
MessageCode.AUTH_EMAIL_NOT_FOUND.getDescription(), loginAnswer));
}
AuthToken authToken = authService.login(loginRequest);
LoginAnswer loginAnswer = new LoginAnswer();
loginAnswer.setToken(authToken.getToken());
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), loginAnswer);
}
}
@@ -0,0 +1,56 @@
package ru.soune.nocopy.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.AuthService;
import java.util.Objects;
import java.util.Optional;
@Component
@RequiredArgsConstructor
public class LogoutRequestHandler implements RequestHandler {
private final AuthService authService;
private final AuthTokenRepository authTokenRepository;
private final ObjectMapper objectMapper;
private final UserRepository userRepository;
@Override
public BaseResponse handle(BaseRequest request) {
LogoutRequest logoutRequest = objectMapper.convertValue(request.getMessageBody(), LogoutRequest.class);
Optional<AuthToken> opToken = authTokenRepository.findByToken(logoutRequest.getToken());
Optional<User> user = userRepository.findByEmail(logoutRequest.getEmail());
if (opToken.isEmpty() || user.isEmpty()) {
throw new NotValidFieldException("User with email or token not found",
new BaseResponse(request.getMsgId(), MessageCode.AUTH_EMAIL_OR_TOKEN_NOT_FOUND.getCode(),
MessageCode.AUTH_EMAIL_OR_TOKEN_NOT_FOUND.getDescription(),
new LogoutRequest(logoutRequest.getEmail(), logoutRequest.getToken())));
}
if (!Objects.equals(opToken.get().getUser().getId(), user.get().getId())) {
throw new NotValidFieldException("User token mismatch with user",
new BaseResponse(request.getMsgId(), MessageCode.AUTH_TOKEN_MISMATCH.getCode(),
MessageCode.AUTH_TOKEN_MISMATCH.getDescription(),
new LogoutRequest(logoutRequest.getEmail(), logoutRequest.getToken())));
}
authService.logout(logoutRequest.getToken());
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getDescription(),
new LogOutAnswer(logoutRequest.getEmail()));
}
}
@@ -0,0 +1,62 @@
package ru.soune.nocopy.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindingResult;
import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.exception.ValidationException;
import ru.soune.nocopy.handler.validator.RegRequestValidator;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.AuthService;
import java.util.Arrays;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class RegRequestHandler implements RequestHandler {
private final UserRepository userRepository;
private final RegRequestValidator regRequestValidator;
private final AuthService authService;
private final ObjectMapper objectMapper;
@Override
public BaseResponse handle(BaseRequest request) throws ValidationException {
RegRequest regRequest = objectMapper.convertValue(request.getMessageBody(), RegRequest.class);
if (userRepository.existsByEmail(regRequest.getEmail()) || userRepository.existsByPhone(regRequest.getPhone())) {
RegAnswer regAnswer = new RegAnswer();
regAnswer.setFieldErrors(Arrays.asList(Map.of("email", regRequest.getEmail())));
regAnswer.setFieldErrors(Arrays.asList(Map.of("phone", regRequest.getPhone())));
throw new NotValidFieldException("User already exists with email:" + regRequest.getEmail() + " or phone: " +
regRequest.getPhone(), new BaseResponse(request.getMsgId(),
MessageCode.REG_EMAIL_OR_PHONE_EXISTS.getCode(),
MessageCode.REG_EMAIL_OR_PHONE_EXISTS.getDescription(), regAnswer));
}
BindingResult bindingResult = new BeanPropertyBindingResult(regRequest, "regRequest");
regRequestValidator.validate(regRequest, bindingResult);
if (bindingResult.hasErrors()) {
throw new ValidationException(bindingResult, request.getMsgId());
}
AuthToken authToken = authService.register(regRequest);
RegAnswer regAnswer = new RegAnswer();
regAnswer.setToken(authToken.getToken());
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), regAnswer);
}
}
@@ -0,0 +1,8 @@
package ru.soune.nocopy.handler;
import ru.soune.nocopy.dto.BaseRequest;
import ru.soune.nocopy.dto.BaseResponse;
public interface RequestHandler {
BaseResponse handle(BaseRequest request) throws Exception;
}
@@ -0,0 +1,91 @@
package ru.soune.nocopy.handler.validator;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import ru.soune.nocopy.dto.file.FileUploadRequest;
import ru.soune.nocopy.entity.file.FileType;
import java.util.Arrays;
import java.util.List;
@Component
public class FileUploadRequestValidator implements Validator {
private final List<FileType> supportedFileTypes = Arrays.asList(FileType.values());
@Override
public boolean supports(Class<?> clazz) {
return FileUploadRequest.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
FileUploadRequest request = (FileUploadRequest) target;
validateType(request.getFileType(), errors);
validateFileName(request.getFileName(), errors);
validateExtension(request.getExtension(), request.getFileType(), errors);
}
private void validateType(String fileType, Errors errors) {
if (fileType == null || fileType.isBlank()) {
errors.rejectValue("fileType", "fileType.required", "File type is required");
return;
}
try {
FileType parsedType = FileType.valueOf(fileType.toUpperCase());
if (!supportedFileTypes.contains(parsedType)) {
errors.rejectValue("fileType", "fileType.unsupported",
"Unsupported file type. Valid types: " + Arrays.toString(FileType.values()));
}
} catch (IllegalArgumentException e) {
errors.rejectValue("fileType", "fileType.invalid",
"Invalid file type. Valid types: " + Arrays.toString(FileType.values()));
}
}
private void validateFileName(String fileName, Errors errors) {
if (fileName == null || fileName.isBlank()) {
errors.rejectValue("fileName", "fileName.required", "File name is required");
return;
}
if (fileName.length() > 255) {
errors.rejectValue("fileName", "fileName.too.long",
"File name too long, max 255 characters");
}
}
private void validateExtension(String extension, String fileType, Errors errors) {
if (extension == null || extension.isBlank()) {
errors.rejectValue("fileType", "fileType.required", "File type is required");
return;
}
if (extension.contains(".")) {
errors.rejectValue("extension", "extension.required", "Extension contains comma");
return;
}
try {
FileType parsedType = FileType.valueOf(fileType.toUpperCase());
if (!parsedType.supportsExtension(extension)) {
errors.rejectValue("fileName", "fileType.extension.mismatch",
String.format("File extension '%s' does not match file type '%s'. Allowed extensions for %s: %s",
extension, parsedType.getDisplayName(), parsedType.getDisplayName(),
parsedType.getAllowedExtensions()));
}
} catch (IllegalArgumentException e) {
errors.rejectValue("fileType", "fileType.invalid",
"Invalid file type. Valid types: " + Arrays.toString(FileType.values()));
}
}
private String getFileExtension(String fileName) {
return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
}
}
@@ -0,0 +1,225 @@
package ru.soune.nocopy.handler.validator;
import org.apache.commons.validator.routines.DomainValidator;
import org.apache.commons.validator.routines.EmailValidator;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import ru.soune.nocopy.dto.RegRequest;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.*;
@Component
public class RegRequestValidator implements Validator {
private static final String COMPANY_REGEX = "^[a-zA-Zа-яА-ЯёЁ0-9\\s\\-&.,'()]{0,200}$";
private static final String NAME_REGEX = "^[a-zA-Zа-яА-ЯёЁ\\s\\-'.]{2,100}$";
@Override
public boolean supports(Class<?> clazz) {
return RegRequest.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
RegRequest request = (RegRequest) target;
validatePhone(request.getPhone(), errors);
validatePassword(request.getPassword(), errors);
validateEmail(request.getEmail(), errors);
validateCompanyName(request.getCompanyName(), errors);
validateFullName(request.getFullName(), errors);
}
private void validateFullName(String fullName, Errors errors) {
if (fullName == null || fullName.trim().isEmpty()) {
errors.rejectValue("fullName", "fullName.required",
"Full name is required");
return;
}
String trimmedName = fullName.trim();
if (trimmedName.length() < 2) {
errors.rejectValue("fullName", "fullName.too.short",
"Full name must be at least 2 characters");
}
if (trimmedName.length() > 100) {
errors.rejectValue("fullName", "fullName.too.long",
"Full name must be less than 100 characters");
}
if (!trimmedName.matches(NAME_REGEX)) {
errors.rejectValue("fullName", "fullName.invalid.chars",
"Name can only contain letters, spaces, hyphens and apostrophes");
}
}
private void validateCompanyName(String companyName, Errors errors) {
if (companyName == null || companyName.trim().isEmpty()) {
return;
}
String trimmedCompany = companyName.trim();
if (trimmedCompany.length() > 200) {
errors.rejectValue("companyName", "companyName.too.long",
"Company name must be less than 200 characters");
}
if (!trimmedCompany.matches(COMPANY_REGEX)) {
errors.rejectValue("companyName", "companyName.invalid.chars",
"Company name contains invalid characters");
}
validateForbiddenWords(trimmedCompany, "companyName", errors,
Arrays.asList("admin", "root", "system", "test"));
}
private void validateForbiddenWords(String text, String fieldName,
Errors errors, List<String> forbiddenWords) {
String lowerText = text.toLowerCase();
for (String word : forbiddenWords) {
if (lowerText.contains(word)) {
errors.rejectValue(fieldName, fieldName + ".forbidden.word",
"Contains forbidden word: " + word);
break;
}
}
}
private void validateEmail(String email, Errors errors) {
if (email == null || email.trim().isEmpty()) {
errors.rejectValue("email", "email.is.empty", "Email must not be empty");
return;
}
String trimmedEmail = email.trim().toLowerCase();
if (trimmedEmail.length() > 254) {
errors.rejectValue("email", "email.too.long", "Email is too long");
return;
}
EmailValidator emailValidator = EmailValidator.getInstance(true, true);
if (!emailValidator.isValid(trimmedEmail)) {
errors.rejectValue("email", "email.invalid", "Invalid email address");
return;
}
}
private void validatePhone(String phone, Errors errors) {
if (phone == null || phone.trim().isEmpty()) {
errors.rejectValue("phone", "phone.empty",
"Test phone numbers are not allowed");
return;
}
String trimmedPhone = phone.trim();
String digitsOnly = trimmedPhone.replaceAll("[^0-9+]", "");
if (digitsOnly.length() < 11 || digitsOnly.length() > 14) {
errors.rejectValue("phone", "phone.invalid.length",
"Phone number must contain 11-14 digits");
return;
}
if (!digitsOnly.matches("^(\\+7|8|7)[0-9]{10}$")) {
errors.rejectValue("phone", "phone.invalid.format",
"Invalid phone number format");
return;
}
}
private void validatePassword(String password, Errors errors) {
if (password == null || password.isEmpty()) {
errors.rejectValue("password", "password.empty",
"Password are not allowed");
return;
}
if (password.length() < 8) {
errors.rejectValue("password", "password.too.short",
"Password must be at least 8 characters");
}
if (password.length() > 50) {
errors.rejectValue("password", "password.too.long",
"Password must be less than 50 characters");
}
if (password.contains(" ")) {
errors.rejectValue("password", "password.contains.spaces",
"Password cannot contain spaces");
}
// recomment if need complexity
// checkPasswordComplexity(password, errors);
// reccoment if need check simply standart password
// checkCommonPasswords(password, errors);
// reccoment if need check simply standart password
// checkForSequences(password, errors);
}
private void checkPasswordComplexity(String password, Errors errors) {
boolean hasUpper = false;
boolean hasLower = false;
boolean hasDigit = false;
boolean hasSpecial = false;
for (char c : password.toCharArray()) {
if (Character.isUpperCase(c)) hasUpper = true;
if (Character.isLowerCase(c)) hasLower = true;
if (Character.isDigit(c)) hasDigit = true;
if ("!@#$%^&*()_+-=[]{}|;:,.<>?".indexOf(c) >= 0) hasSpecial = true;
}
List<String> missing = new ArrayList<>();
if (!hasUpper) missing.add("uppercase letter");
if (!hasLower) missing.add("lowercase letter");
if (!hasDigit) missing.add("digit");
if (!hasSpecial) missing.add("special character");
if (!missing.isEmpty()) {
String message = "Password must contain: " + String.join(", ", missing);
errors.rejectValue("password", "password.complexity.missing", message);
}
}
private void checkCommonPasswords(String password, Errors errors) {
Set<String> commonPasswords = Set.of(
"password", "12345678", "qwerty123", "admin123",
"welcome1", "password1", "123456789", "qwertyuiop"
);
if (commonPasswords.contains(password.toLowerCase())) {
errors.rejectValue("password", "password.too.common",
"This password is too common, please choose another");
}
}
private void checkForSequences(String password, Errors errors) {
String lower = password.toLowerCase();
if (lower.matches(".*123456.*") || lower.matches(".*987654.*")) {
errors.rejectValue("password", "password.sequence.numbers",
"Password contains predictable number sequence");
}
if (lower.matches(".*abcdef.*") || lower.matches(".*qwerty.*")) {
errors.rejectValue("password", "password.sequence.letters",
"Password contains predictable letter sequence");
}
if (lower.matches(".*(.)\\1{3,}.*")) {
errors.rejectValue("password", "password.repeating.chars",
"Password contains too many repeating characters");
}
}
}
@@ -2,7 +2,7 @@ package ru.soune.nocopy.repository;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import ru.soune.nocopy.entity.AuthToken; import ru.soune.nocopy.entity.auth.AuthToken;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Optional; import java.util.Optional;
@@ -0,0 +1,25 @@
package ru.soune.nocopy.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.entity.auth.ExternalAuthProvider;
import java.util.List;
import java.util.Optional;
@Repository
public interface ExternalAuthProviderRepository extends JpaRepository<ExternalAuthProvider, Long> {
Optional<ExternalAuthProvider> findByProviderTypeAndProviderUserId(
ExternalAuthProvider.ProviderType providerType,
String providerUserId
);
List<ExternalAuthProvider> findByUser(User user);
boolean existsByProviderTypeAndProviderUserId(
ExternalAuthProvider.ProviderType providerType,
String providerUserId
);
}
@@ -1,11 +1,22 @@
package ru.soune.nocopy.repository; package ru.soune.nocopy.repository;
import jakarta.validation.constraints.Size;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import ru.soune.nocopy.entity.User; import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.entity.auth.ExternalAuthProvider;
import java.util.Optional; import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); Optional<User> findByEmail(String email);
boolean existsByEmail(String email); boolean existsByEmail(String email);
boolean existsByPhone(@Size(min = 11, max = 14) String phone);
@Query("SELECT u FROM User u JOIN u.externalAuthProviders p " +
"WHERE p.providerType = :providerType AND p.providerUserId = :providerUserId")
Optional<User> findByExternalProvider(
@Param("providerType") ExternalAuthProvider.ProviderType providerType,
@Param("providerUserId") String providerUserId
);
} }
@@ -5,19 +5,18 @@ import org.springframework.context.MessageSource;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.LoginRequest; import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.dto.RegisterRequest; import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.User; import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.exception.NotValidationPasswordException; import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.exception.UserAlreadyExistsException;
import ru.soune.nocopy.exception.UserNotFoundException;
import ru.soune.nocopy.repository.AuthTokenRepository; import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.UserRepository; import ru.soune.nocopy.repository.UserRepository;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
@Service @Service
@@ -35,22 +34,18 @@ public class AuthService {
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
@Transactional @Transactional
public AuthToken register(RegisterRequest registerRequest) { public AuthToken register(RegRequest registerRequest) {
if (userRepository.existsByEmail(registerRequest.email())) {
throw new UserAlreadyExistsException("User already exists with email: " + registerRequest.email());
}
User user = new User(); User user = new User();
user.setFullName(registerRequest.fullName()); user.setFullName(registerRequest.getFullName());
user.setEmail(registerRequest.email()); user.setEmail(registerRequest.getEmail());
user.setPassword(passwordEncoder.encode(registerRequest.password())); user.setPassword(passwordEncoder.encode(registerRequest.getPassword()));
if (registerRequest.companyName() != null) { if (registerRequest.getCompanyName() != null) {
user.setCompany(registerRequest.companyName()); user.setCompany(registerRequest.getCompanyName());
} }
if (registerRequest.phone() != null) { if (registerRequest.getPhone() != null) {
user.setPhone(registerRequest.phone()); user.setPhone(registerRequest.getPhone());
} }
User savedUser = userRepository.save(user); User savedUser = userRepository.save(user);
@@ -65,15 +60,15 @@ public class AuthService {
@Transactional @Transactional
public AuthToken login(LoginRequest request) { public AuthToken login(LoginRequest request) {
Optional<User> userOpt = userRepository.findByEmail(request.getEmail()); Optional<User> userOpt = userRepository.findByEmail(request.getEmail());
if (userOpt.isEmpty()) {
throw new UserNotFoundException("User with email " + request.getEmail() + " not found");
}
User user = userOpt.get(); User user = userOpt.get();
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new NotValidationPasswordException("Invalid password"); LoginAnswer loginAnswer = new LoginAnswer();
loginAnswer.setFieldErrors(Arrays.asList(Map.of("password", request.getPassword())));
throw new NotValidFieldException("Invalid password", new BaseResponse(20003,
MessageCode.AUTH_PASSWORD_NOT_MATCHES.getCode(),
MessageCode.AUTH_PASSWORD_NOT_MATCHES.getDescription(), loginAnswer));
} }
user.setLastLoginAt(LocalDateTime.now()); user.setLastLoginAt(LocalDateTime.now());
@@ -92,6 +87,20 @@ public class AuthService {
.ifPresent(authTokenRepository::delete); .ifPresent(authTokenRepository::delete);
} }
@Transactional
public AuthToken createTokenForUser(User user) {
AuthToken authToken = new AuthToken();
authToken.setToken(generateAuthToken());
authToken.setUser(user);
authToken.setExpiresAt(LocalDateTime.now().plusHours(1));
authToken.setIsActive(true);
user.setLastLoginAt(LocalDateTime.now());
userRepository.save(user);
return authTokenRepository.save(authToken);
}
private String generateAuthToken() { private String generateAuthToken() {
byte[] bytes = new byte[32]; byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes); secureRandom.nextBytes(bytes);
@@ -0,0 +1,69 @@
package ru.soune.nocopy.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
@Service
public class VkOAuthService {
@Value("${auth.vk.oauth2}")
String oauthVkPath;
@Value("${auth.vk.user_info}")
String vkUserInfo;
@Value("${auth.vk.client_id}")
String clientId;
@Value("${auth.vk.redirect_uri}")
String redirectUri;
public Map<String, Object> authTokenMap(String code, String state, String codeVerifier, String deviceId) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("code_verifier", codeVerifier);
params.add("redirect_uri", redirectUri);
params.add("code", code);
params.add("client_id", clientId);
params.add("device_id", deviceId);
params.add("state", state);
params.add("scope", "email,phone");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
return sendRequest(params, headers,oauthVkPath);
}
public Map<String, Object> userVkInfo(String accessToken) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("access_token", accessToken);
params.add("client_id", clientId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
return sendRequest(params, headers, vkUserInfo);
}
private Map<String, Object> sendRequest(MultiValueMap<String, String> params, HttpHeaders headers, String url) {
Map<String, Object> info = new HashMap<>();
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST,
new HttpEntity<>(params, headers), String.class);
info.put("status", response.getStatusCodeValue());
info.put("body", response.getBody());
info.put("headers", response.getHeaders());
info.put("success", response.getStatusCode().is2xxSuccessful());
return info;
}
}
@@ -0,0 +1,102 @@
package ru.soune.nocopy.service.auth;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.entity.auth.ExternalAuthProvider;
import ru.soune.nocopy.repository.ExternalAuthProviderRepository;
import ru.soune.nocopy.repository.UserRepository;
import java.time.LocalDateTime;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class ExternalAuthUserService {
private final UserRepository userRepository;
private final ExternalAuthProviderRepository externalAuthProviderRepository;
@Transactional
public User findOrCreateUserFromVK(String vkUserId, String fullName, String email,
String vkAccessToken, String vkRefreshToken, Integer expiresIn) {
Optional<User> existingByVkId = userRepository.findByExternalProvider(ExternalAuthProvider.ProviderType.VK,
vkUserId);
if (existingByVkId.isPresent()) {
User user = existingByVkId.get();
updateVKProvider(user, vkAccessToken, vkRefreshToken, expiresIn, email);
return user;
}
if (email != null && !email.isBlank()) {
Optional<User> existingByEmail = userRepository.findByEmail(email);
if (existingByEmail.isPresent()) {
User user = existingByEmail.get();
linkVKProvider(user, vkUserId, vkAccessToken, vkRefreshToken, expiresIn, email);
return user;
}
}
return createNewUserWithVK(vkUserId, fullName, email, vkAccessToken, vkRefreshToken, expiresIn);
}
private User createNewUserWithVK(String vkUserId, String fullName, String email,
String vkAccessToken, String vkRefreshToken, Integer expiresIn) {
User newUser = User.builder()
.fullName(fullName)
.email(email)
.password("generateRandomPassword()")
.isActive(true)
.build();
User savedUser = userRepository.save(newUser);
linkVKProvider(savedUser, vkUserId, vkAccessToken, vkRefreshToken, expiresIn, email);
return savedUser;
}
private void linkVKProvider(User user, String vkUserId, String vkAccessToken, String vkRefreshToken,
Integer expiresIn, String email) {
ExternalAuthProvider provider = ExternalAuthProvider.builder()
.user(user)
.providerType(ExternalAuthProvider.ProviderType.VK)
.providerUserId(vkUserId)
.accessToken(vkAccessToken)
.refreshToken(vkRefreshToken)
.tokenExpiresAt(LocalDateTime.now().plusSeconds(expiresIn))
.email(email)
.isActive(true)
.build();
externalAuthProviderRepository.save(provider);
user.getExternalAuthProviders().add(provider);
}
private void updateVKProvider(User user, String vkAccessToken, String vkRefreshToken,
Integer expiresIn, String email) {
Optional<ExternalAuthProvider> existingProvider = user.getProvider(ExternalAuthProvider.ProviderType.VK);
if (existingProvider.isPresent()) {
ExternalAuthProvider provider = existingProvider.get();
provider.setAccessToken(vkAccessToken);
provider.setTokenExpiresAt(LocalDateTime.now().plusSeconds(expiresIn));
provider.setEmail(email);
provider.setRefreshToken(vkRefreshToken);
provider.setLastUsedAt(LocalDateTime.now());
externalAuthProviderRepository.save(provider);
}
}
private String generateRandomPassword() {
return java.util.UUID.randomUUID().toString();
}
}
@@ -5,7 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.file.FileEntityResponse; import ru.soune.nocopy.dto.file.FileEntityResponse;
import ru.soune.nocopy.dto.file.FileListResponse; import ru.soune.nocopy.dto.file.FileResponse;
import ru.soune.nocopy.entity.file.FileEntity; import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.FileStatus; import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.entity.file.FileUploadSession; import ru.soune.nocopy.entity.file.FileUploadSession;
@@ -17,6 +17,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -27,9 +28,6 @@ public class FileEntityService {
private final FileEntityRepository fileEntityRepository; private final FileEntityRepository fileEntityRepository;
/**
* Создает FileEntity на основе завершенной сессии загрузки
*/
@Transactional @Transactional
public FileEntity createFromUploadSession(FileUploadSession session, String checksum) { public FileEntity createFromUploadSession(FileUploadSession session, String checksum) {
log.info("Creating FileEntity for upload session: {}", session.getUploadId()); log.info("Creating FileEntity for upload session: {}", session.getUploadId());
@@ -43,7 +41,6 @@ public class FileEntityService {
long fileSize = Files.size(filePath); long fileSize = Files.size(filePath);
String originalName = session.getFileName(); String originalName = session.getFileName();
String extension = extractFileExtension(originalName);
String storedName = filePath.getFileName().toString(); String storedName = filePath.getFileName().toString();
FileEntity fileEntity = FileEntity.builder() FileEntity fileEntity = FileEntity.builder()
@@ -53,7 +50,7 @@ public class FileEntityService {
.filePath(session.getFilePath()) .filePath(session.getFilePath())
.fileSize(fileSize) .fileSize(fileSize)
.mimeType(session.getFileType()) .mimeType(session.getFileType())
.fileExtension(extension) .fileExtension(session.getExtension())
.checksum(checksum) .checksum(checksum)
.uploadSessionId(session.getUploadId()) .uploadSessionId(session.getUploadId())
.status(FileStatus.ACTIVE) .status(FileStatus.ACTIVE)
@@ -72,9 +69,7 @@ public class FileEntityService {
} }
} }
/**
* Извлекает расширение файла из имени
*/
private String extractFileExtension(String fileName) { private String extractFileExtension(String fileName) {
int dotIndex = fileName.lastIndexOf('.'); int dotIndex = fileName.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < fileName.length() - 1) { if (dotIndex > 0 && dotIndex < fileName.length() - 1) {
@@ -83,57 +78,46 @@ public class FileEntityService {
return ""; return "";
} }
/**
* Получает FileEntity по ID
*/
@Transactional(readOnly = true) @Transactional(readOnly = true)
public FileEntityResponse getById(String fileId) { public FileEntityResponse getById(String fileId, int version) {
FileEntity fileEntity = fileEntityRepository.findById(fileId) FileEntity fileEntity = fileEntityRepository.findById(fileId)
.orElseThrow(() -> new FileEntityNotFoundException(fileId)); .orElseThrow(() -> new FileEntityNotFoundException(fileId));
return convertToResponse(fileEntity); return convertToResponse(fileEntity, version);
} }
/**
* Получает FileEntity по ID сессии загрузки
*/
@Transactional(readOnly = true) @Transactional(readOnly = true)
public FileEntityResponse getByUploadSessionId(String uploadSessionId) { public FileEntityResponse getByUploadSessionId(String uploadSessionId, int version) {
FileEntity fileEntity = fileEntityRepository.findByUploadSessionId(uploadSessionId) FileEntity fileEntity = fileEntityRepository.findByUploadSessionId(uploadSessionId)
.orElseThrow(() -> new FileEntityNotFoundException( .orElseThrow(() -> new FileEntityNotFoundException(
"Not found for upload session: " + uploadSessionId)); "Not found for upload session: " + uploadSessionId));
return convertToResponse(fileEntity); return convertToResponse(fileEntity, version);
} }
/**
* Получает FileEntity по пути файла
*/
@Transactional(readOnly = true) @Transactional(readOnly = true)
public FileEntityResponse getByFilePath(String filePath) { public FileEntityResponse getByFilePath(String filePath, int version) {
FileEntity fileEntity = fileEntityRepository.findByFilePath(filePath) FileEntity fileEntity = fileEntityRepository.findByFilePath(filePath)
.orElseThrow(() -> new FileEntityNotFoundException("Path: " + filePath)); .orElseThrow(() -> new FileEntityNotFoundException("Path: " + filePath));
return convertToResponse(fileEntity); return convertToResponse(fileEntity, version);
} }
/**
* Получает все файлы пользователя
*/
@Transactional(readOnly = true) @Transactional(readOnly = true)
public FileListResponse getAllUserFiles(Long userId) { public FileResponse getAllUserFiles(Long userId, int version) {
List<FileEntity> fileEntities = fileEntityRepository.findByUserIdAndStatus( List<FileEntity> fileEntities = fileEntityRepository.findByUserIdAndStatus(
userId, FileStatus.ACTIVE); userId, FileStatus.ACTIVE);
List<FileEntityResponse> files = fileEntities.stream() List<FileEntityResponse> files = fileEntities.stream()
.map(this::convertToResponse) .map(file -> convertToResponse(file, version))
.collect(Collectors.toList()); .collect(Collectors.toList());
long totalSize = fileEntities.stream() long totalSize = fileEntities.stream()
.mapToLong(FileEntity::getFileSize) .mapToLong(FileEntity::getFileSize)
.sum(); .sum();
return FileListResponse.builder() return FileResponse.builder()
.files(files) .files(files)
.totalCount(files.size()) .totalCount(files.size())
.totalSize(totalSize) .totalSize(totalSize)
@@ -143,11 +127,9 @@ public class FileEntityService {
.build(); .build();
} }
/**
* Получает файлы пользователя с пагинацией
*/
@Transactional(readOnly = true) @Transactional(readOnly = true)
public FileListResponse getUserFiles(Long userId, int page, int pageSize) { public FileResponse getUserFiles(Long userId, int page, int pageSize, int version) {
List<FileEntity> allFiles = fileEntityRepository.findByUserIdAndStatus( List<FileEntity> allFiles = fileEntityRepository.findByUserIdAndStatus(
userId, FileStatus.ACTIVE); userId, FileStatus.ACTIVE);
@@ -155,7 +137,7 @@ public class FileEntityService {
int end = Math.min(start + pageSize, allFiles.size()); int end = Math.min(start + pageSize, allFiles.size());
if (start >= allFiles.size()) { if (start >= allFiles.size()) {
return FileListResponse.builder() return FileResponse.builder()
.files(List.of()) .files(List.of())
.totalCount(allFiles.size()) .totalCount(allFiles.size())
.totalSize(0) .totalSize(0)
@@ -168,14 +150,14 @@ public class FileEntityService {
List<FileEntity> pageFiles = allFiles.subList(start, end); List<FileEntity> pageFiles = allFiles.subList(start, end);
List<FileEntityResponse> files = pageFiles.stream() List<FileEntityResponse> files = pageFiles.stream()
.map(this::convertToResponse) .map(file -> convertToResponse(file, version))
.collect(Collectors.toList()); .collect(Collectors.toList());
long totalSize = allFiles.stream() long totalSize = allFiles.stream()
.mapToLong(FileEntity::getFileSize) .mapToLong(FileEntity::getFileSize)
.sum(); .sum();
return FileListResponse.builder() return FileResponse.builder()
.files(files) .files(files)
.totalCount(allFiles.size()) .totalCount(allFiles.size())
.totalSize(totalSize) .totalSize(totalSize)
@@ -185,46 +167,33 @@ public class FileEntityService {
.build(); .build();
} }
/**
* Помечает файл как удаленный (мягкое удаление)
*/
@Transactional @Transactional
public void markAsDeleted(String fileId) { public void markAsDeleted(FileEntity fileEntity) {
FileEntity fileEntity = fileEntityRepository.findById(fileId)
.orElseThrow(() -> new FileEntityNotFoundException(fileId));
fileEntity.setStatus(FileStatus.DELETED); fileEntity.setStatus(FileStatus.DELETED);
fileEntity.setUpdatedAt(LocalDateTime.now()); fileEntity.setUpdatedAt(LocalDateTime.now());
fileEntityRepository.save(fileEntity);
log.info("FileEntity marked as deleted: {}", fileId); fileEntityRepository.save(fileEntity);
}
@Transactional
public boolean deleteFromDisk(FileEntity fileEntity) throws IOException {
Path path = Paths.get(fileEntity.getFilePath());
if (!Files.exists(path)) {
return true;
}
Files.delete(path);
return true;
} }
/**
* Получает общий размер файлов пользователя
*/
@Transactional(readOnly = true) @Transactional(readOnly = true)
public long getUserStorageUsed(Long userId) { public long getUserStorageUsed(Long userId) {
Long totalSize = fileEntityRepository.getTotalSizeByUserId(userId); Long totalSize = fileEntityRepository.getTotalSizeByUserId(userId);
return totalSize != null ? totalSize : 0L; return totalSize != null ? totalSize : 0L;
} }
/**
* Поиск файлов пользователя по имени
*/
@Transactional(readOnly = true)
public List<FileEntityResponse> searchFiles(Long userId, String query) {
List<FileEntity> files = fileEntityRepository.searchByFileName(userId, query);
return files.stream()
.filter(f -> f.getStatus() == FileStatus.ACTIVE)
.map(this::convertToResponse)
.collect(Collectors.toList());
}
/**
* Проверяет, существует ли файл на диске
*/
private boolean checkFileExistsOnDisk(String filePath) { private boolean checkFileExistsOnDisk(String filePath) {
try { try {
return Files.exists(Paths.get(filePath)); return Files.exists(Paths.get(filePath));
@@ -234,10 +203,7 @@ public class FileEntityService {
} }
} }
/** private FileEntityResponse convertToResponse(FileEntity fileEntity, int version) {
* Конвертирует FileEntity в DTO
*/
private FileEntityResponse convertToResponse(FileEntity fileEntity) {
boolean existsOnDisk = checkFileExistsOnDisk(fileEntity.getFilePath()); boolean existsOnDisk = checkFileExistsOnDisk(fileEntity.getFilePath());
return FileEntityResponse.builder() return FileEntityResponse.builder()
@@ -255,15 +221,12 @@ public class FileEntityService {
.createdAt(fileEntity.getCreatedAt()) .createdAt(fileEntity.getCreatedAt())
.updatedAt(fileEntity.getUpdatedAt()) .updatedAt(fileEntity.getUpdatedAt())
.formattedSize(formatFileSize(fileEntity.getFileSize())) .formattedSize(formatFileSize(fileEntity.getFileSize()))
.downloadUrl("/api/files/download/" + fileEntity.getId()) .downloadUrl("/api/v" + version + "/files/download/" + fileEntity.getId())
.existsOnDisk(existsOnDisk) .existsOnDisk(existsOnDisk)
.build(); .build();
} }
/** public String formatFileSize(long size) {
* Форматирует размер файла в читаемый вид
*/
private String formatFileSize(long size) {
if (size < 1024) { if (size < 1024) {
return size + " B"; return size + " B";
} else if (size < 1024 * 1024) { } else if (size < 1024 * 1024) {
@@ -0,0 +1,93 @@
package ru.soune.nocopy.service.file;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import ru.soune.nocopy.dto.file.FileInfoUserResponse;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.entity.file.FileType;
import ru.soune.nocopy.repository.FileEntityRepository;
import java.util.List;
@Service
@RequiredArgsConstructor
public class FileStatsService {
private final FileEntityRepository fileEntityRepository;
public FileInfoUserResponse getUserFileStats(Long userId) {
List<FileEntity> userFiles = fileEntityRepository.findByUserId(userId);
return calculateStats(userFiles);
}
public FileInfoUserResponse calculateStats(List<FileEntity> files) {
return FileInfoUserResponse.builder()
.allFileSize(calculateTotalSize(files))
.fileCount(calculateTotalCount(files))
.filesCheck(calculateByStatus(files, FileStatus.CHECKED))
.filesViolation(calculateByStatus(files, FileStatus.VIOLATION))
.imagesSize(calculateMediaSize(files, FileType.IMAGE))
.imagesCount(calculateMediaCount(files, FileType.IMAGE))
.imagesCheck(calculateMediaByStatus(files, FileType.IMAGE, FileStatus.CHECKED))
.imagesViolations(calculateMediaByStatus(files, FileType.IMAGE, FileStatus.VIOLATION))
.videosSize(calculateMediaSize(files, FileType.VIDEO))
.videosCount(calculateMediaCount(files, FileType.VIDEO))
.videosCheck(calculateMediaByStatus(files, FileType.VIDEO, FileStatus.CHECKED))
.videosViolations(calculateMediaByStatus(files, FileType.VIDEO, FileStatus.VIOLATION))
.audiosSize(calculateMediaSize(files, FileType.AUDIO))
.audiosCount(calculateMediaCount(files, FileType.AUDIO))
.audiosCheck(calculateMediaByStatus(files, FileType.AUDIO, FileStatus.CHECKED))
.audiosViolations(calculateMediaByStatus(files, FileType.AUDIO, FileStatus.VIOLATION))
.build();
}
private Long calculateTotalSize(List<FileEntity> files) {
return files.stream()
.filter(file -> file.getStatus() != FileStatus.DELETED)
.mapToLong(FileEntity::getFileSize)
.sum();
}
private Integer calculateTotalCount(List<FileEntity> files) {
return (int) files.stream()
.filter(file -> file.getStatus() != FileStatus.DELETED)
.count();
}
private Integer calculateByStatus(List<FileEntity> files, FileStatus status) {
return (int) files.stream()
.filter(file -> file.getStatus() == status)
.count();
}
private Long calculateMediaSize(List<FileEntity> files, FileType fileType) {
return files.stream()
.filter(file -> file.getStatus() != FileStatus.DELETED)
.filter(file -> isFileType(file, fileType))
.mapToLong(FileEntity::getFileSize)
.sum();
}
private Integer calculateMediaCount(List<FileEntity> files, FileType fileType) {
return (int) files.stream()
.filter(file -> file.getStatus() != FileStatus.DELETED)
.filter(file -> isFileType(file, fileType))
.count();
}
private Integer calculateMediaByStatus(List<FileEntity> files, FileType fileType, FileStatus status) {
return (int) files.stream()
.filter(file -> isFileType(file, fileType))
.filter(file -> file.getStatus() == status)
.count();
}
private boolean isFileType(FileEntity file, FileType fileType) {
if (file.getFileExtension() == null) return false;
String extension = file.getFileExtension().toLowerCase().replace(".", "");
return fileType.supportsExtension(extension);
}
}
@@ -15,7 +15,7 @@ public interface FileUploadService {
UploadProgressResponse getUploadProgress(String uploadId); UploadProgressResponse getUploadProgress(String uploadId);
void cleanupExpiredSessions(); void handleExpiredSession(FileUploadSession session);
public void retryFailedUpload(String uploadId); public void retryFailedUpload(String uploadId);
@@ -102,12 +102,14 @@ public class FileUploadServiceImpl implements FileUploadService {
int totalChunks = (int) Math.ceil((double) fileSize / chunkSize); int totalChunks = (int) Math.ceil((double) fileSize / chunkSize);
log.debug("File will be split into {} chunks (chunk size: {} bytes)", log.debug("File will be split into {} chunks (chunk size: {} bytes)",
totalChunks, chunkSize); totalChunks, chunkSize);
Long chunkSize = totalChunks == 1 ? fileSize : 1048576L;
FileUploadSession session = FileUploadSession.builder() FileUploadSession session = FileUploadSession.builder()
.userId(userId) .userId(userId)
.extension(extension) .extension(extension)
.fileName(fileName) .fileName(fileName)
.fileType(fileType) .fileType(fileType)
.chunkSize(chunkSize)
.fileSize(fileSize) .fileSize(fileSize)
.totalChunks(totalChunks) .totalChunks(totalChunks)
.chunksUploaded(0) .chunksUploaded(0)
@@ -143,17 +145,103 @@ public class FileUploadServiceImpl implements FileUploadService {
if (chunkNumber < 0 || chunkNumber >= session.getTotalChunks()) { if (chunkNumber < 0 || chunkNumber >= session.getTotalChunks()) {
throw new FileUploadException( throw new FileUploadException(
String.format("Invalid chunk number %d. Expected 0-%d", String.format("Invalid chunk number %d. Expected number: %d",
chunkNumber, session.getTotalChunks() - 1)); chunkNumber, session.getTotalChunks() - 1));
} }
if (chunkFile.getSize() > chunkSize) { if (chunkNumber == 0 && chunkFile.getSize() > chunkSize || chunkNumber + 1 == (session.getTotalChunks())
&& chunkFile.getSize() > chunkSize) {
throw new ChunkSizeExceededException(chunkFile.getSize(), chunkSize);
}
if (chunkNumber > 0 && chunkNumber + 1 < session.getTotalChunks() && chunkFile.getSize() != chunkSize) {
throw new ChunkSizeExceededException(chunkFile.getSize(), chunkSize); throw new ChunkSizeExceededException(chunkFile.getSize(), chunkSize);
} }
return processChunk(session, chunkNumber, chunkFile); return processChunk(session, chunkNumber, chunkFile);
} }
@Override
@Transactional
public void handleExpiredSession(FileUploadSession session) {
session.setStatus(UploadStatus.FAILED);
session.setLastError("Upload session expired");
sessionRepository.save(session);
CompletableFuture.runAsync(() -> cleanupSessionFiles(session));
}
@Async("fileUploadTaskExecutor")
@Transactional
public void assembleFileAsync(FileUploadSession session) {
try {
assembleFile(session);
log.info("File assembly completed successfully for session: {}",
session.getUploadId());
} catch (Exception e) {
log.error("Failed to assemble file for session {}: {}",
session.getUploadId(), e.getMessage(), e);
handleAssemblyFailure(session, e);
}
}
@Override
@Transactional
public void cancelUpload(String uploadId) {
FileUploadSession session = sessionRepository.findById(uploadId)
.orElseThrow(() -> new UploadSessionNotFoundException(uploadId));
if (session.getStatus() == UploadStatus.COMPLETED || session.getStatus() == UploadStatus.CANCELLED) {
throw new FileUploadException("Cannot cancel completed or cancelled upload");
}
session.setStatus(UploadStatus.CANCELLED);
sessionRepository.save(session);
CompletableFuture.runAsync(() -> cleanupSessionFiles(session));
log.info("Upload cancelled: {}", uploadId);
}
@Override
@Transactional
public void retryFailedUpload(String uploadId) {
FileUploadSession session = sessionRepository.findById(uploadId)
.orElseThrow(() -> new UploadSessionNotFoundException(uploadId));
if (session.getStatus() != UploadStatus.FAILED) {
throw new FileUploadException("Only failed uploads can be retried");
}
if (session.getRetryCount() >= maxRetryAttempts) {
throw new FileUploadException("Max retry attempts exceeded");
}
session.setStatus(UploadStatus.UPLOADING);
session.setRetryCount(session.getRetryCount() + 1);
session.setLastError(null);
sessionRepository.save(session);
if (session.getChunksUploaded().equals(session.getTotalChunks())) {
log.info("Retrying file assembly for session: {}", uploadId);
assembleFileAsync(session);
}
log.info("Upload retry initiated for session: {} (attempt {})",
uploadId, session.getRetryCount());
}
@Override
public UploadProgressResponse getUploadProgress(String uploadId) {
FileUploadSession session = sessionRepository.findById(uploadId)
.orElseThrow(() -> new UploadSessionNotFoundException(uploadId));
return UploadProgressResponse.fromSession(session);
}
private void validateSession(FileUploadSession session) { private void validateSession(FileUploadSession session) {
UploadStatus status = session.getStatus(); UploadStatus status = session.getStatus();
@@ -183,14 +271,6 @@ public class FileUploadServiceImpl implements FileUploadService {
} }
} }
private void handleExpiredSession(FileUploadSession session) {
session.setStatus(UploadStatus.FAILED);
session.setLastError("Upload session expired");
sessionRepository.save(session);
CompletableFuture.runAsync(() -> cleanupSessionFiles(session));
}
private UploadProgressResponse processChunk(FileUploadSession session, private UploadProgressResponse processChunk(FileUploadSession session,
Integer chunkNumber, Integer chunkNumber,
MultipartFile chunkFile) { MultipartFile chunkFile) {
@@ -206,6 +286,7 @@ public class FileUploadServiceImpl implements FileUploadService {
session.getChunkPaths().put(chunkNumber, chunkPath); session.getChunkPaths().put(chunkNumber, chunkPath);
session.setChunksUploaded(session.getChunksUploaded() + 1); session.setChunksUploaded(session.getChunksUploaded() + 1);
session.setStatus(UploadStatus.UPLOADING); session.setStatus(UploadStatus.UPLOADING);
session.setExpiresAt(LocalDateTime.now().plusMinutes(1));
log.debug("Chunk {} saved successfully. Uploaded: {}/{}", log.debug("Chunk {} saved successfully. Uploaded: {}/{}",
chunkNumber, session.getChunksUploaded(), session.getTotalChunks()); chunkNumber, session.getChunksUploaded(), session.getTotalChunks());
@@ -316,52 +397,6 @@ public class FileUploadServiceImpl implements FileUploadService {
} }
} }
@Async("fileUploadTaskExecutor")
@Transactional
public void assembleFileAsync(FileUploadSession session) {
try {
assembleFile(session);
log.info("File assembly completed successfully for session: {}",
session.getUploadId());
} catch (Exception e) {
log.error("Failed to assemble file for session {}: {}",
session.getUploadId(), e.getMessage(), e);
handleAssemblyFailure(session, e);
}
}
// private void assembleFile(FileUploadSession session) throws IOException {
// log.info("Starting file assembly for session: {} ({})",
// session.getUploadId(), session.getFileName());
//
// Path finalFilePath = null;
//
// try {
// finalFilePath = prepareFinalFile(session);
//
// validateAllChunksExist(session);
//
// mergeChunksToFile(session, finalFilePath);
//
// validateFinalFile(session, finalFilePath);
//
// updateSessionOnSuccess(session, finalFilePath);
//
// cleanupSessionFiles(session);
//
// log.info("File assembly completed: {} -> {} ({} bytes)",
// session.getFileName(), finalFilePath, session.getFileSize());
//
// } catch (Exception e) {
// if (finalFilePath != null) {
// Files.deleteIfExists(finalFilePath);
// }
// throw e;
// }
// }
private void assembleFile(FileUploadSession session) throws IOException { private void assembleFile(FileUploadSession session) throws IOException {
log.info("Starting file assembly for session: {} ({})", log.info("Starting file assembly for session: {} ({})",
session.getUploadId(), session.getFileName()); session.getUploadId(), session.getFileName());
@@ -526,14 +561,6 @@ public class FileUploadServiceImpl implements FileUploadService {
log.debug("Final file checksum: {}", checksum); log.debug("Final file checksum: {}", checksum);
} }
private void updateSessionOnSuccess(FileUploadSession session, Path finalPath) {
session.setFilePath(finalPath.toString());
session.setChecksum(calculateChecksum(finalPath));
session.setStatus(UploadStatus.COMPLETED);
session.setCompletedAt(LocalDateTime.now());
sessionRepository.save(session);
}
private void cleanupSessionFiles(FileUploadSession session) { private void cleanupSessionFiles(FileUploadSession session) {
try { try {
Path chunkDir = getChunkDirectory(session.getUserId(), session.getUploadId()); Path chunkDir = getChunkDirectory(session.getUserId(), session.getUploadId());
@@ -570,89 +597,37 @@ public class FileUploadServiceImpl implements FileUploadService {
session.getUploadId(), e.getMessage()); session.getUploadId(), e.getMessage());
} }
@Override
@Transactional
public void cancelUpload(String uploadId) {
FileUploadSession session = sessionRepository.findById(uploadId)
.orElseThrow(() -> new UploadSessionNotFoundException(uploadId));
if (session.getStatus() == UploadStatus.COMPLETED) {
throw new FileUploadException("Cannot cancel completed upload");
}
session.setStatus(UploadStatus.CANCELLED); // @Override
sessionRepository.save(session); // @Transactional
// public void cleanupExpiredSessions() {
CompletableFuture.runAsync(() -> cleanupSessionFiles(session)); // log.info("Starting cleanup of expired upload sessions");
//
log.info("Upload cancelled: {}", uploadId); // LocalDateTime expiryThreshold = LocalDateTime.now().minusHours(sessionExpiryHours);
} //
// try {
@Override // var expiredSessions = sessionRepository.findByStatusInAndCreatedAtBefore(
@Transactional // Set.of(UploadStatus.INITIATED, UploadStatus.UPLOADING, UploadStatus.FAILED),
public void retryFailedUpload(String uploadId) { // expiryThreshold
FileUploadSession session = sessionRepository.findById(uploadId) // );
.orElseThrow(() -> new UploadSessionNotFoundException(uploadId)); //
// int cleanedCount = 0;
if (session.getStatus() != UploadStatus.FAILED) { // for (FileUploadSession session : expiredSessions) {
throw new FileUploadException("Only failed uploads can be retried"); // session.setStatus(UploadStatus.FAILED);
} // session.setLastError("Session expired during cleanup");
// sessionRepository.save(session);
if (session.getRetryCount() >= maxRetryAttempts) { //
throw new FileUploadException("Max retry attempts exceeded"); // CompletableFuture.runAsync(() -> cleanupSessionFiles(session));
} // cleanedCount++;
// }
session.setStatus(UploadStatus.UPLOADING); //
session.setRetryCount(session.getRetryCount() + 1); // log.info("Cleaned up {} expired upload sessions", cleanedCount);
session.setLastError(null); //
sessionRepository.save(session); // } catch (Exception e) {
// log.error("Error during session cleanup", e);
if (session.getChunksUploaded().equals(session.getTotalChunks())) { // }
log.info("Retrying file assembly for session: {}", uploadId); // }
assembleFileAsync(session);
}
log.info("Upload retry initiated for session: {} (attempt {})",
uploadId, session.getRetryCount());
}
@Override
public UploadProgressResponse getUploadProgress(String uploadId) {
FileUploadSession session = sessionRepository.findById(uploadId)
.orElseThrow(() -> new UploadSessionNotFoundException(uploadId));
return UploadProgressResponse.fromSession(session);
}
@Override
@Transactional
public void cleanupExpiredSessions() {
log.info("Starting cleanup of expired upload sessions");
LocalDateTime expiryThreshold = LocalDateTime.now().minusHours(sessionExpiryHours);
try {
var expiredSessions = sessionRepository.findByStatusInAndCreatedAtBefore(
Set.of(UploadStatus.INITIATED, UploadStatus.UPLOADING, UploadStatus.FAILED),
expiryThreshold
);
int cleanedCount = 0;
for (FileUploadSession session : expiredSessions) {
session.setStatus(UploadStatus.FAILED);
session.setLastError("Session expired during cleanup");
sessionRepository.save(session);
CompletableFuture.runAsync(() -> cleanupSessionFiles(session));
cleanedCount++;
}
log.info("Cleaned up {} expired upload sessions", cleanedCount);
} catch (Exception e) {
log.error("Error during session cleanup", e);
}
}
private Path getChunkDirectory(Long userId, String uploadId) { private Path getChunkDirectory(Long userId, String uploadId) {
return storageRoot.resolve("temp") return storageRoot.resolve("temp")
@@ -0,0 +1,35 @@
package ru.soune.nocopy.service.file;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.entity.file.FileUploadSession;
import ru.soune.nocopy.entity.file.UploadStatus;
import ru.soune.nocopy.repository.FileUploadSessionRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class UploadSessionCleanupService {
@Autowired
private FileUploadSessionRepository fileUploadSessionRepository;
@Autowired
private FileUploadService fileUploadService;
@Transactional
@Scheduled(fixedDelay = 30000)
public void cleanupSessions() {
List<FileUploadSession> expiredSessions = fileUploadSessionRepository.findExpiredSessions(
LocalDateTime.now(), Set.of(UploadStatus.INITIATED, UploadStatus.UPLOADING));
for (FileUploadSession session : expiredSessions) {
fileUploadService.handleExpiredSession(session);
}
}
}
+9 -1
View File
@@ -24,13 +24,21 @@ spring:
file: file:
storage: storage:
base-path: ${FILE_STORAGE_PATH:/data/uploads} base-path: ${FILE_STORAGE_PATH:/data/uploads}
chunk-size: ${FILE_CHUNK_SIZE:5242880} # 5MB # chunk-size: ${FILE_CHUNK_SIZE:5242880} # 5MB
chunk-size: ${FILE_CHUNK_SIZE:1048576} # 15MB
max-file-size: ${MAX_FILE_SIZE:10737418240} # 10GB max-file-size: ${MAX_FILE_SIZE:10737418240} # 10GB
max-retry-attempts: ${MAX_RETRY_ATTEMPTS:3} max-retry-attempts: ${MAX_RETRY_ATTEMPTS:3}
chunk-timeout-ms: ${CHUNK_TIMEOUT_MS:300000} # 5 минут chunk-timeout-ms: ${CHUNK_TIMEOUT_MS:300000} # 5 минут
temp-ttl-hours: ${TEMP_TTL_HOURS:72} # 3 дня temp-ttl-hours: ${TEMP_TTL_HOURS:72} # 3 дня
session-expiry-hours: ${SESSION_EXPIRY_HOURS:24} session-expiry-hours: ${SESSION_EXPIRY_HOURS:24}
auth:
vk:
oauth2: "https://id.vk.ru/oauth2/auth"
user_info: "https://id.vk.ru/oauth2/user_info"
client_id: "54389559"
redirect_uri: "http://localhost"
security: security:
cors: cors:
allowed-origins: "*" allowed-origins: "*"
@@ -9,21 +9,17 @@ import org.mockito.MockitoAnnotations;
import org.springframework.context.MessageSource; import org.springframework.context.MessageSource;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import ru.soune.nocopy.dto.LoginRequest; import ru.soune.nocopy.dto.LoginRequest;
import ru.soune.nocopy.dto.RegisterRequest; import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.User; import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.exception.NotValidationPasswordException; import ru.soune.nocopy.exception.NotValidationPasswordException;
import ru.soune.nocopy.exception.UserAlreadyExistsException;
import ru.soune.nocopy.exception.UserNotFoundException; import ru.soune.nocopy.exception.UserNotFoundException;
import ru.soune.nocopy.repository.AuthTokenRepository; import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.UserRepository; import ru.soune.nocopy.repository.UserRepository;
import java.util.Locale;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -49,43 +45,6 @@ public class AuthServiceTest {
MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this);
} }
@Test
void registerSuccess() {
RegisterRequest request = new RegisterRequest("John","A.","124124",
"john@example.com", null,"password123", null, null);
when(userRepository.existsByEmail(request.email())).thenReturn(false);
when(passwordEncoder.encode(request.password())).thenReturn("hashed");
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(authTokenRepository.save(any(AuthToken.class))).thenAnswer(invocation -> invocation.getArgument(0));
AuthToken token = authService.register(request);
User user = token.getUser();
String email = user.getEmail();
assertNotNull(token);
assertNotNull(token.getToken());
assertNotNull(token.getUser());
assertEquals("john@example.com", email);
assertNotNull(token.getExpiresAt());
}
@Test
void registerExistingEmailThrows() {
RegisterRequest request = new RegisterRequest("John","A.","124124",
"john@example.com", null,"password123", null, null);
when(userRepository.existsByEmail(request.email()))
.thenReturn(true);
when(messageSource.getMessage(anyString(), any(), any(Locale.class)))
.thenReturn("User exists");
UserAlreadyExistsException ex = assertThrows(UserAlreadyExistsException.class,
() -> authService.register(request));
assertEquals("User exists", ex.getMessage());
}
@Test @Test
void login_success() { void login_success() {
LoginRequest request = new LoginRequest("test@mail.com", "password"); LoginRequest request = new LoginRequest("test@mail.com", "password");