35 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
37 changed files with 1310 additions and 460 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"]
+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 -97
View File
@@ -45,118 +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{{.Image}}\\t{{.Status}}\\t{{.Ports}}'
echo 'Step 2: Force cleanup old application...' 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'
docker rmi app-backend:latest 2>/dev/null || echo 'Image not found'
# Удаляем все образы app-backend echo 'COMPOSE_COMPATIBILITY=true' > .env
docker images --filter 'reference=app-backend*' -q | xargs -r docker rmi 2>/dev/null || echo 'No images to remove'
echo 'Step 3: Verify copied files...' echo '========================================'
echo 'Files in directory:' echo 'Step 3: Force cleanup old application...'
ls -la echo '========================================'
echo ''
echo 'Checking Java sources:'
find . -name '*.java' | head -2
echo 'Step 4: Check if PostgreSQL is running...' docker-compose --compatibility down 2>/dev/null || echo 'No previous compose stack'
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 5: Build application with forced rebuild...' docker images --filter 'reference=*app-backend*' -q | xargs -r docker rmi -f 2>/dev/null || echo 'No images to remove'
echo 'Checking Dockerfile exists:'
ls -la Dockerfile 2>/dev/null || echo 'Dockerfile not found, creating simple one'
# Если нет Dockerfile, создаем простой echo '========================================'
if [ ! -f Dockerfile ]; then echo 'Step 4: Verify files and fix compose...'
echo 'Creating simple Dockerfile...' echo '========================================'
cat > Dockerfile << 'EOF'
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY . /app/
CMD ["java", "-jar", "app.jar"]
EOF
fi
# Собираем с подробным выводом if [ ! -f docker-compose.yaml ]; then
echo 'Building Docker image...' echo 'ERROR: docker-compose.yaml not found!'
docker build --no-cache --progress=plain -t app-backend:latest . 2>&1 | tail -50 ls -la
echo 'Step 6: Verify new image...'
echo 'Current app-backend images:'
docker images | grep app-backend
echo 'Image creation time:'
docker inspect app-backend:latest --format='{{.Created}}' 2>/dev/null || echo 'Cannot inspect image'
echo 'Step 7: Starting application with alias...'
docker run -d \\
--name app-backend \\
--network app-network \\
--network-alias app \\
-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 8: Checking deployment...'
sleep 10
echo 'Containers status:'
docker ps --filter 'name=postgres|app-backend' --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.CreatedAt}}'
echo 'Checking image inside container:'
docker exec app-backend ls -la /app/ 2>/dev/null || echo 'Cannot check container files'
echo 'Application logs (last 10 lines):'
docker logs --tail=10 app-backend 2>/dev/null || echo 'Logs not available yet'
echo 'Step 9: Health check...'
if docker ps | grep -q app-backend; then
echo 'Container is running'
echo 'Testing application health...'
for i in {1..5}; do
if curl -s -f http://localhost:80/health > /dev/null 2>&1; then
echo 'Health check passed'
echo 'Deployment successful'
echo 'Application URL: http://${params.SERVER}:80'
exit 0
fi
echo 'Waiting for application to start... attempt ' \$i
sleep 5
done
echo 'Health check failed, but container is running'
echo 'Application might be starting slowly'
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 '========================================'
" "
""" """
} }
@@ -169,6 +213,8 @@ EOF
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) {
@@ -19,11 +19,10 @@ import ru.soune.nocopy.dto.file.ChunkUploadResponse;
import ru.soune.nocopy.dto.file.CompleteUploadResponse; import ru.soune.nocopy.dto.file.CompleteUploadResponse;
import ru.soune.nocopy.dto.file.FileEntityResponse; import ru.soune.nocopy.dto.file.FileEntityResponse;
import ru.soune.nocopy.dto.file.UploadProgress; import ru.soune.nocopy.dto.file.UploadProgress;
import ru.soune.nocopy.entity.AuthToken; 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.entity.file.UploadStatus;
import ru.soune.nocopy.exception.NotFoundAuthToken; import ru.soune.nocopy.exception.*;
import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.exception.ValidationException;
import ru.soune.nocopy.handler.*; import ru.soune.nocopy.handler.*;
import ru.soune.nocopy.repository.AuthTokenRepository; import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.service.file.FileEntityService; import ru.soune.nocopy.service.file.FileEntityService;
@@ -89,29 +88,34 @@ public class ApiController {
@PostMapping("/v{version}/files/chunk") @PostMapping("/v{version}/files/chunk")
public ResponseEntity<BaseResponse> uploadChunk( public ResponseEntity<BaseResponse> uploadChunk(
@PathVariable("version") int version, @PathVariable("version") int version,
@RequestParam("upload_id") String uploadId, @RequestParam(value = "upload_id", required = false) String uploadId,
@RequestParam("chunk_number") Integer chunkNumber, @RequestParam(value = "chunk_number", required = false) Integer chunkNumber,
@RequestParam("chunk") MultipartFile chunk) { @RequestParam(value = "chunk", required = false) MultipartFile chunk) {
log.info("Uploading chunk {} for session {}, file size: {} bytes, version: {}",
chunkNumber, uploadId, chunk.getSize(), version);
if (chunk.isEmpty()) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Chunk file is empty", null));
}
if (uploadId == null || uploadId.isBlank()) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Upload ID is required", null));
}
if (chunkNumber == null || chunkNumber < 0) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Valid chunk number is required", null));
}
try { 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); fileUploadService.uploadChunk(uploadId, chunkNumber, chunk);
ChunkUploadResponse responseBody = ChunkUploadResponse.builder() ChunkUploadResponse responseBody = ChunkUploadResponse.builder()
@@ -123,7 +127,6 @@ public class ApiController {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(), return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(),
"Chunk uploaded successfully", responseBody)); "Chunk uploaded successfully", responseBody));
} catch (Exception e) { } catch (Exception e) {
log.error("Error uploading chunk", e); log.error("Error uploading chunk", e);
@@ -237,29 +240,67 @@ public class ApiController {
@GetMapping("/v{version}/files/download/{fileId}") @GetMapping("/v{version}/files/download/{fileId}")
public ResponseEntity<Resource> downloadFile( public ResponseEntity<?> downloadFile(
@PathVariable String fileId, @PathVariable(required = false) String fileId,
@PathVariable Integer version, @PathVariable(required = false) Integer version,
@RequestHeader("Authorization") String tokenHeader) { @RequestHeader(value = "Authorization", required = false) String tokenHeader) {
try { 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); Long userId = getUserIdFromToken(tokenHeader);
FileEntityResponse fileInfo = fileEntityService.getById(fileId, version); FileEntityResponse entityResponse = fileEntityService.getById(fileId, version);
if (!fileInfo.getUserId().equals(userId)) { if (!entityResponse.getUserId().equals(userId)) {
return ResponseEntity.status(403).build(); 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 (!fileInfo.isExistsOnDisk()) { if (entityResponse.getStatus().equals(FileStatus.DELETED)) {
return ResponseEntity.status(404) Map<String, Object> errorData = new HashMap<>();
.body(null); errorData.put("file_status", entityResponse.getStatus());
return ResponseEntity.ok().body(new BaseResponse(20004,
MessageCode.FILE_DOWNLOAD_ERROR.getCode(),
MessageCode.FILE_DOWNLOAD_ERROR.getDescription(),
errorData));
} }
Path filePath = Paths.get(fileInfo.getFilePath()); 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()); Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists()) { if (!resource.exists()) {
return ResponseEntity.status(404).build(); 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); String contentType = determineContentType(filePath);
@@ -267,85 +308,30 @@ public class ApiController {
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType)) .contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION, .header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + fileInfo.getOriginalFileName() + "\"") "attachment; filename=\"" + entityResponse.getOriginalFileName() + "\"")
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileInfo.getFileSize())) .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(entityResponse.getFileSize()))
.body(resource); .body(resource);
} catch (FileEntityNotFoundException e) {
Map<String, Object> errorData = new HashMap<>();
errorData.put("fileId", fileId);
} catch (NotFoundAuthToken e) { return ResponseEntity.ok().body(new BaseResponse(20004,
return ResponseEntity.status(401).build(); MessageCode.FILE_NOT_FOUND.getCode(),
} catch (Exception e) { MessageCode.FILE_NOT_FOUND.getDescription(),
log.error("Error downloading file", e); errorData));
return ResponseEntity.status(500).build(); } 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));
} }
} }
@GetMapping("/v{version}/files/info/{fileId}")
public ResponseEntity<FileEntityResponse> getFileInfo(
@PathVariable String fileId,
@PathVariable Integer version,
@RequestHeader("Authorization") String tokenHeader) {
try {
Long userId = getUserIdFromToken(tokenHeader);
FileEntityResponse fileInfo = fileEntityService.getById(fileId, version);
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();
}
}
@GetMapping("/v{version}/files/preview/{fileId}")
public ResponseEntity<Resource> previewFile(
@PathVariable String fileId,
@PathVariable Integer version,
@RequestHeader("Authorization") String tokenHeader) {
try {
Long userId = getUserIdFromToken(tokenHeader);
FileEntityResponse fileInfo = fileEntityService.getById(fileId, version);
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();
}
}
private ResponseEntity<BaseResponse> createValidationErrorResponse(BindingResult bindingResult, Integer msgId) { private ResponseEntity<BaseResponse> createValidationErrorResponse(BindingResult bindingResult, Integer msgId) {
List<Map<String, String>> fieldErrors = bindingResult.getFieldErrors() List<Map<String, String>> fieldErrors = bindingResult.getFieldErrors()
.stream() .stream()
@@ -387,11 +373,11 @@ public class ApiController {
return false; return false;
} }
return mimeType.startsWith("image/") || return mimeType.startsWith("image") ||
mimeType.startsWith("text/") || mimeType.startsWith("text") ||
mimeType.equals("application/pdf") || mimeType.equals("pdf") ||
mimeType.startsWith("video/") || mimeType.startsWith("video") ||
mimeType.startsWith("audio/"); mimeType.startsWith("audio");
} }
private Long getUserIdFromToken(String tokenHeader) { private Long getUserIdFromToken(String tokenHeader) {
@@ -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,10 +0,0 @@
package ru.soune.nocopy.dto;
import java.time.LocalDateTime;
public record AuthResponse (
boolean success,
String message,
String token,
LocalDateTime expiresAt
) {}
@@ -8,6 +8,8 @@ public enum MessageCode {
INVALID_TOKEN(2, "Invalid token"), INVALID_TOKEN(2, "Invalid token"),
INVALID_ACTION(2, "Invalid action"), INVALID_ACTION(2, "Invalid action"),
FILE_UPLOAD_ERROR(2, "File upload error"), 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"), INVALID_JSON_BODY(2, "Invalid fields in JSON object"),
INCOMPLETE_UPLOAD(2, "Not load all chunks"), INCOMPLETE_UPLOAD(2, "Not load all chunks"),
MSG_ID_NOT_FOUND(4, "Message id not found"), MSG_ID_NOT_FOUND(4, "Message id not found"),
@@ -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;
}
+17 -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")
@@ -90,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());
} }
} }
@@ -86,7 +86,7 @@ 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) { if (this.chunksUploaded == null) {
this.chunksUploaded = 0; this.chunksUploaded = 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));
} }
} }
@@ -6,12 +6,18 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import ru.soune.nocopy.dto.*; import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.dto.file.*; import ru.soune.nocopy.dto.file.*;
import ru.soune.nocopy.entity.AuthToken; 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.exception.NotFoundAuthToken;
import ru.soune.nocopy.repository.AuthTokenRepository; 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.FileEntityService;
import ru.soune.nocopy.service.file.FileStatsService;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
@Slf4j @Slf4j
@Component @Component
@@ -19,9 +25,15 @@ import java.util.Arrays;
public class FileEntityHandler implements RequestHandler { public class FileEntityHandler implements RequestHandler {
private final FileEntityService fileEntityService; private final FileEntityService fileEntityService;
private final FileStatsService fileStatsService;
private final AuthTokenRepository authTokenRepository; private final AuthTokenRepository authTokenRepository;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final FileEntityRepository fileEntityRepository;
@Override @Override
public BaseResponse handle(BaseRequest request) { public BaseResponse handle(BaseRequest request) {
try { try {
@@ -31,15 +43,17 @@ public class FileEntityHandler implements RequestHandler {
String action = fileRequest.getAction(); String action = fileRequest.getAction();
switch (action) { switch (action) {
case "get_file_info": case "file_info":
return handleGetFileInfo(request, fileRequest); return handleGetFileInfo(request, fileRequest);
case "get_file_by_session": case "user_files_info":
return handleGetFilesUserInfo(request, fileRequest);
case "file_by_session":
return handleGetFileBySession(request, fileRequest); return handleGetFileBySession(request, fileRequest);
case "get_user_files": case "user_files":
return handleGetUserFiles(request, fileRequest); return handleGetUserFiles(request, fileRequest);
case "search_files": case "search_files":
return handleSearchFiles(request, fileRequest); return handleSearchFiles(request, fileRequest);
case "get_storage_usage": case "storage_usage":
return handleGetStorageUsage(request, fileRequest); return handleGetStorageUsage(request, fileRequest);
case "delete_file": case "delete_file":
return handleDeleteFile(request, fileRequest); return handleDeleteFile(request, fileRequest);
@@ -47,8 +61,8 @@ public class FileEntityHandler implements RequestHandler {
ActionResponse response = ActionResponse.builder() ActionResponse response = ActionResponse.builder()
.action(action) .action(action)
.availableActions(Arrays.asList( .availableActions(Arrays.asList(
"get_file_info", "get_file_by_session", "get_user_files", "file_info", "file_by_session", "user_files",
"search_files", "get_storage_usage", "delete_file")) "search_files", "storage_usage", "delete_file"))
.build(); .build();
return new BaseResponse(request.getMsgId(), return new BaseResponse(request.getMsgId(),
@@ -65,6 +79,16 @@ public class FileEntityHandler implements RequestHandler {
} }
} }
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) { private BaseResponse handleGetFileInfo(BaseRequest request, FileEntityRequest fileRequest) {
try { try {
Long userId = getUserIdFromToken(fileRequest.getToken()); Long userId = getUserIdFromToken(fileRequest.getToken());
@@ -168,17 +192,22 @@ public class FileEntityHandler implements RequestHandler {
FileResponse allFiles = fileEntityService.getUserFiles(userId, 1, 1000, request.getVersion()); FileResponse allFiles = fileEntityService.getUserFiles(userId, 1, 1000, request.getVersion());
var filteredFiles = allFiles.getFiles().stream() var filteredFiles = allFiles.getFiles().stream()
.filter(f -> f.getOriginalFileName().toLowerCase().contains(fileRequest.getQuery().toLowerCase())) .filter(f -> (f.getOriginalFileName() + f.getFileExtension()).toLowerCase()
.contains(fileRequest.getQuery().toLowerCase()))
.toList(); .toList();
int start = (page - 1) * pageSize; int start = (page - 1) * pageSize;
int end = Math.min(start + pageSize, filteredFiles.size()); 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() FileListResponse response = FileListResponse.builder()
.files(filteredFiles.subList(start, Math.min(end, filteredFiles.size()))) .files(fileEntityResponses)
.totalCount(filteredFiles.size()) .totalCount(filteredFiles.size())
.totalSize(allFiles.getTotalSize()) .totalSize(sumSize)
.formattedTotalSize(allFiles.getFormattedTotalSize()) .formattedTotalSize(fileEntityService.formatFileSize(sumSize))
.page(page) .page(page)
.pageSize(pageSize) .pageSize(pageSize)
.build(); .build();
@@ -187,18 +216,13 @@ public class FileEntityHandler implements RequestHandler {
MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), MessageCode.SUCCESS.getDescription(),
response); response);
} catch (NotFoundAuthToken e) { } catch (NotFoundAuthToken e) {
return new BaseResponse(request.getMsgId(), return new BaseResponse(request.getMsgId(), MessageCode.INVALID_TOKEN.getCode(),
MessageCode.INVALID_TOKEN.getCode(), "Authentication required", null);
"Authentication required",
null);
} catch (Exception e) { } catch (Exception e) {
log.error("Error searching files", e); log.error("Error searching files", e);
return new BaseResponse(request.getMsgId(), return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(),
MessageCode.FILE_UPLOAD_ERROR.getCode(), "Failed to search files: " + e.getMessage(), null);
"Failed to search files: " + e.getMessage(),
null);
} }
} }
@@ -235,7 +259,9 @@ public class FileEntityHandler implements RequestHandler {
private BaseResponse handleDeleteFile(BaseRequest request, FileEntityRequest fileRequest) { private BaseResponse handleDeleteFile(BaseRequest request, FileEntityRequest fileRequest) {
try { try {
Long userId = getUserIdFromToken(fileRequest.getToken()); Long userId = getUserIdFromToken(fileRequest.getToken());
FileEntityResponse fileInfo = fileEntityService.getById(fileRequest.getFileId(), request.getVersion()); String fileId = fileRequest.getFileId();
FileEntityResponse fileInfo = fileEntityService.getById(fileId, request.getVersion());
DeleteFileResponse response;
if (!fileInfo.getUserId().equals(userId)) { if (!fileInfo.getUserId().equals(userId)) {
return new BaseResponse(request.getMsgId(), return new BaseResponse(request.getMsgId(),
@@ -244,18 +270,27 @@ public class FileEntityHandler implements RequestHandler {
null); null);
} }
fileEntityService.markAsDeleted(fileRequest.getFileId()); FileEntity fileEntity = fileEntityRepository.findById(fileId)
.orElseThrow(() -> new FileEntityNotFoundException(fileId));
DeleteFileResponse response = DeleteFileResponse.builder() if (fileEntity.getStatus().equals(FileStatus.DELETED)) {
.fileId(fileRequest.getFileId()) fileEntityService.deleteFromDisk(fileEntity);
.message("File marked as deleted") response = DeleteFileResponse.builder()
.build(); .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(), return new BaseResponse(request.getMsgId(),
MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getCode(),
"File marked as deleted", "File marked as deleted",
response); response);
} catch (NotFoundAuthToken e) { } catch (NotFoundAuthToken e) {
return new BaseResponse(request.getMsgId(), return new BaseResponse(request.getMsgId(),
MessageCode.INVALID_TOKEN.getCode(), MessageCode.INVALID_TOKEN.getCode(),
@@ -3,12 +3,14 @@ package ru.soune.nocopy.handler;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindingResult; import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import ru.soune.nocopy.dto.*; import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.dto.file.*; import ru.soune.nocopy.dto.file.*;
import ru.soune.nocopy.entity.AuthToken; import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.entity.file.FileType; import ru.soune.nocopy.entity.file.FileType;
import ru.soune.nocopy.entity.file.FileUploadSession; import ru.soune.nocopy.entity.file.FileUploadSession;
import ru.soune.nocopy.exception.*; import ru.soune.nocopy.exception.*;
@@ -17,21 +19,26 @@ import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.FileUploadSessionRepository; import ru.soune.nocopy.repository.FileUploadSessionRepository;
import ru.soune.nocopy.service.file.FileUploadService; import ru.soune.nocopy.service.file.FileUploadService;
import java.util.Arrays; import java.util.*;
import java.util.HashMap; import java.util.stream.Collectors;
import java.util.Map;
import java.util.Optional;
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class FileUploadHandler implements RequestHandler { public class FileUploadHandler implements RequestHandler {
private final FileUploadService fileUploadService; private final FileUploadService fileUploadService;
private final AuthTokenRepository authTokenRepository; private final AuthTokenRepository authTokenRepository;
private final FileUploadSessionRepository fileUploadSessionRepository; private final FileUploadSessionRepository fileUploadSessionRepository;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final FileUploadRequestValidator fileUploadRequestValidator; private final FileUploadRequestValidator fileUploadRequestValidator;
@Value("${file.storage.max-file-size}")
private long maxFileSize;
@Override @Override
public BaseResponse handle(BaseRequest request) { public BaseResponse handle(BaseRequest request) {
try { try {
@@ -45,18 +52,18 @@ public class FileUploadHandler implements RequestHandler {
return handleInitUpload(request, fileRequest); return handleInitUpload(request, fileRequest);
case "file_types": case "file_types":
return handleGetFileTypes(request); return handleGetFileTypes(request);
case "file_extension":
return handleGetFileExtensions(fileRequest, request);
case "chunks": case "chunks":
return handleGetChunkStatus(request, fileRequest); return handleGetChunkStatus(request, fileRequest);
case "cancel": case "cancel":
return handleCancelUpload(request, fileRequest); return handleCancelUpload(request, fileRequest);
case "progress": case "progress":
return handleGetProgress(request, fileRequest); return handleGetProgress(request, fileRequest);
case "retry":
return handleRetryUpload(request, fileRequest);
default: default:
ActionResponse response = ActionResponse.builder() ActionResponse response = ActionResponse.builder()
.action(action) .action(action)
.availableActions(Arrays.asList("init", "file_types", "chunks", "cancel", "progress", "retry")) .availableActions(Arrays.asList("init", "file_types", "chunks", "cancel", "progress"))
.build(); .build();
return new BaseResponse(request.getMsgId(), MessageCode.INVALID_ACTION.getCode(), return new BaseResponse(request.getMsgId(), MessageCode.INVALID_ACTION.getCode(),
@@ -70,18 +77,31 @@ public class FileUploadHandler implements RequestHandler {
} }
private BaseResponse handleInitUpload(BaseRequest request, FileUploadRequest fileRequest) { private BaseResponse handleInitUpload(BaseRequest request, FileUploadRequest fileRequest) {
try {
String token = fileRequest.getToken(); String token = fileRequest.getToken();
Optional<AuthToken> tokenOptional = authTokenRepository.findByToken(token); 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")); AuthToken authToken = tokenOptional.orElseThrow(() -> new NotFoundAuthToken("Token not found"));
BindingResult bindingResult = new BeanPropertyBindingResult(fileRequest, "fileRequest"); BindingResult bindingResult = new BeanPropertyBindingResult(fileRequest, "fileRequest");
fileUploadRequestValidator.validate(fileRequest, bindingResult); fileUploadRequestValidator.validate(fileRequest, bindingResult);
if (bindingResult.hasErrors()) { if (bindingResult.hasErrors()) {
throw new ValidationException(bindingResult, request.getMsgId()); 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( FileUploadSession session = fileUploadService.initUpload(
@@ -101,15 +121,6 @@ public class FileUploadHandler implements RequestHandler {
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), fileResponse); MessageCode.SUCCESS.getDescription(), fileResponse);
} catch (NotFoundAuthToken e) {
InitFileResponse initFileResponse = InitFileResponse.builder()
.build();
throw new NotValidFieldException("Invalid or expired token: " + fileRequest.getToken(),
new BaseResponse(request.getMsgId(), MessageCode.INVALID_TOKEN.getCode(),
MessageCode.INVALID_TOKEN.getDescription(), initFileResponse));
}
} }
private BaseResponse handleGetProgress(BaseRequest request, FileUploadRequest fileRequest) { private BaseResponse handleGetProgress(BaseRequest request, FileUploadRequest fileRequest) {
@@ -240,4 +251,24 @@ public class FileUploadHandler implements RequestHandler {
"Failed to get file types", null); "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);
}
}
} }
@@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import ru.soune.nocopy.dto.*; import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.entity.AuthToken; import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.exception.NotValidFieldException; import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.repository.UserRepository; import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.AuthService; import ru.soune.nocopy.service.AuthService;
@@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import ru.soune.nocopy.dto.*; import ru.soune.nocopy.dto.*;
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.NotValidFieldException; import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.repository.AuthTokenRepository; import ru.soune.nocopy.repository.AuthTokenRepository;
@@ -7,7 +7,7 @@ import org.springframework.stereotype.Component;
import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindingResult; import org.springframework.validation.BindingResult;
import ru.soune.nocopy.dto.*; import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.entity.AuthToken; import ru.soune.nocopy.entity.auth.AuthToken;
import ru.soune.nocopy.exception.NotValidFieldException; import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.exception.ValidationException; import ru.soune.nocopy.exception.ValidationException;
import ru.soune.nocopy.handler.validator.RegRequestValidator; import ru.soune.nocopy.handler.validator.RegRequestValidator;
@@ -7,9 +7,12 @@ import ru.soune.nocopy.dto.file.FileUploadRequest;
import ru.soune.nocopy.entity.file.FileType; import ru.soune.nocopy.entity.file.FileType;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
@Component @Component
public class FileUploadRequestValidator implements Validator { public class FileUploadRequestValidator implements Validator {
private final List<FileType> supportedFileTypes = Arrays.asList(FileType.values());
@Override @Override
public boolean supports(Class<?> clazz) { public boolean supports(Class<?> clazz) {
return FileUploadRequest.class.isAssignableFrom(clazz); return FileUploadRequest.class.isAssignableFrom(clazz);
@@ -21,7 +24,7 @@ public class FileUploadRequestValidator implements Validator {
validateType(request.getFileType(), errors); validateType(request.getFileType(), errors);
validateFileName(request.getFileName(), errors); validateFileName(request.getFileName(), errors);
validateExtension(request.getExtension(), errors); validateExtension(request.getExtension(), request.getFileType(), errors);
} }
private void validateType(String fileType, Errors errors) { private void validateType(String fileType, Errors errors) {
@@ -31,7 +34,12 @@ public class FileUploadRequestValidator implements Validator {
} }
try { try {
FileType.valueOf(fileType.toUpperCase()); 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) { } catch (IllegalArgumentException e) {
errors.rejectValue("fileType", "fileType.invalid", errors.rejectValue("fileType", "fileType.invalid",
"Invalid file type. Valid types: " + Arrays.toString(FileType.values())); "Invalid file type. Valid types: " + Arrays.toString(FileType.values()));
@@ -50,10 +58,34 @@ public class FileUploadRequestValidator implements Validator {
} }
} }
private void validateExtension(String extension, Errors errors) { private void validateExtension(String extension, String fileType, Errors errors) {
if (extension == null || extension.isBlank()) { if (extension == null || extension.isBlank()) {
errors.rejectValue("extension", "extension.required", "Extension is required"); errors.rejectValue("fileType", "fileType.required", "File type is required");
return; 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();
} }
} }
@@ -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
);
}
@@ -2,7 +2,10 @@ package ru.soune.nocopy.repository;
import jakarta.validation.constraints.Size; 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;
@@ -10,4 +13,10 @@ 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); 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
);
} }
@@ -6,7 +6,7 @@ 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.*; import ru.soune.nocopy.dto.*;
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.NotValidFieldException; import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.repository.AuthTokenRepository; import ru.soune.nocopy.repository.AuthTokenRepository;
@@ -87,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();
}
}
@@ -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;
@@ -40,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()
@@ -50,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)
@@ -168,15 +168,24 @@ public class FileEntityService {
} }
@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)
@@ -217,7 +226,7 @@ public class FileEntityService {
.build(); .build();
} }
private String formatFileSize(long size) { public 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,90 +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 || session.getStatus() == UploadStatus.CANCELLED) {
throw new FileUploadException("Cannot cancel completed or cancelled 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); //
// } catch (Exception e) {
sessionRepository.save(session); // 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,7 +9,7 @@ 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.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.NotValidationPasswordException; import ru.soune.nocopy.exception.NotValidationPasswordException;
import ru.soune.nocopy.exception.UserNotFoundException; import ru.soune.nocopy.exception.UserNotFoundException;