From 699821ba944dc74d9f1a8786a9177b3e11f2234d Mon Sep 17 00:00:00 2001 From: Salem Ba-Rabuod Date: Sun, 26 Jul 2026 16:42:56 +0200 Subject: [PATCH 1/6] chore: create table for images --- README.md | 14 +++++++++++++- .../db/migration/V4__create_images_table.sql | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V4__create_images_table.sql diff --git a/README.md b/README.md index 18a0e4f..bfda1dc 100644 --- a/README.md +++ b/README.md @@ -38,4 +38,16 @@ **Header:** `Cookie: session_id=` -**→ 200:** returns user (`id`, `email`, `created_at`) \ No newline at end of file +**→ 200:** returns user (`id`, `email`, `created_at`) + + + +# Image Endpoints + + + +- `POST /api/images` Upload image +- `GET /api/images/my` My uploaded images +- `GET /api/images` Home / search images **_Public_** +- `GET /api/images/{id}/content` Display image **_Public_** +- `DELETE /api/images/{id}` Delete my image \ No newline at end of file diff --git a/src/main/resources/db/migration/V4__create_images_table.sql b/src/main/resources/db/migration/V4__create_images_table.sql new file mode 100644 index 0000000..7126e9d --- /dev/null +++ b/src/main/resources/db/migration/V4__create_images_table.sql @@ -0,0 +1,14 @@ +-- Create images table +CREATE TABLE images ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + storage_key TEXT NOT NULL, + content_type TEXT NOT NULL, + file_size BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT fk_images_user + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE +); \ No newline at end of file From 44403a7a9d1d01d00efa5009e4b83f0dce7113e2 Mon Sep 17 00:00:00 2001 From: Salem Ba-Rabuod Date: Sun, 26 Jul 2026 16:50:10 +0200 Subject: [PATCH 2/6] refactor: changes package structure --- .../dev/barboud/taglens/controller/AuthController.java | 8 ++++---- .../taglens/dto/{LoginDto.java => user/UserLoginDto.java} | 4 ++-- .../taglens/dto/{ => user}/UserRegistrationDto.java | 2 +- .../barboud/taglens/dto/{ => user}/UserResponseDto.java | 2 +- src/main/java/dev/barboud/taglens/model/Image.java | 8 ++++++++ .../java/dev/barboud/taglens/service/AuthService.java | 8 ++++---- 6 files changed, 20 insertions(+), 12 deletions(-) rename src/main/java/dev/barboud/taglens/dto/{LoginDto.java => user/UserLoginDto.java} (86%) rename src/main/java/dev/barboud/taglens/dto/{ => user}/UserRegistrationDto.java (92%) rename src/main/java/dev/barboud/taglens/dto/{ => user}/UserResponseDto.java (85%) create mode 100644 src/main/java/dev/barboud/taglens/model/Image.java diff --git a/src/main/java/dev/barboud/taglens/controller/AuthController.java b/src/main/java/dev/barboud/taglens/controller/AuthController.java index 5b8476b..fb1256f 100644 --- a/src/main/java/dev/barboud/taglens/controller/AuthController.java +++ b/src/main/java/dev/barboud/taglens/controller/AuthController.java @@ -1,9 +1,9 @@ package dev.barboud.taglens.controller; -import dev.barboud.taglens.dto.LoginDto; -import dev.barboud.taglens.dto.UserRegistrationDto; -import dev.barboud.taglens.dto.UserResponseDto; +import dev.barboud.taglens.dto.user.UserLoginDto; +import dev.barboud.taglens.dto.user.UserRegistrationDto; +import dev.barboud.taglens.dto.user.UserResponseDto; import dev.barboud.taglens.service.AuthService; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -34,7 +34,7 @@ public ResponseEntity createUser(@Valid @RequestBody UserRegistrationDto r @PostMapping("/login") - public ResponseEntity login(@Valid @RequestBody LoginDto loginDto) { + public ResponseEntity login(@Valid @RequestBody UserLoginDto loginDto) { UUID sessionId = authService.login(loginDto); ResponseCookie cookie = ResponseCookie.from("session_id", sessionId.toString()) diff --git a/src/main/java/dev/barboud/taglens/dto/LoginDto.java b/src/main/java/dev/barboud/taglens/dto/user/UserLoginDto.java similarity index 86% rename from src/main/java/dev/barboud/taglens/dto/LoginDto.java rename to src/main/java/dev/barboud/taglens/dto/user/UserLoginDto.java index ef80ab2..ce42a2b 100644 --- a/src/main/java/dev/barboud/taglens/dto/LoginDto.java +++ b/src/main/java/dev/barboud/taglens/dto/user/UserLoginDto.java @@ -1,4 +1,4 @@ -package dev.barboud.taglens.dto; +package dev.barboud.taglens.dto.user; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -6,7 +6,7 @@ import lombok.Data; @Data -public class LoginDto { +public class UserLoginDto { @NotBlank(message = "Email is required") @Email(message = "Invalid email format") diff --git a/src/main/java/dev/barboud/taglens/dto/UserRegistrationDto.java b/src/main/java/dev/barboud/taglens/dto/user/UserRegistrationDto.java similarity index 92% rename from src/main/java/dev/barboud/taglens/dto/UserRegistrationDto.java rename to src/main/java/dev/barboud/taglens/dto/user/UserRegistrationDto.java index 8c24c0e..3f16abf 100644 --- a/src/main/java/dev/barboud/taglens/dto/UserRegistrationDto.java +++ b/src/main/java/dev/barboud/taglens/dto/user/UserRegistrationDto.java @@ -1,4 +1,4 @@ -package dev.barboud.taglens.dto; +package dev.barboud.taglens.dto.user; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/dev/barboud/taglens/dto/UserResponseDto.java b/src/main/java/dev/barboud/taglens/dto/user/UserResponseDto.java similarity index 85% rename from src/main/java/dev/barboud/taglens/dto/UserResponseDto.java rename to src/main/java/dev/barboud/taglens/dto/user/UserResponseDto.java index d459beb..af51a84 100644 --- a/src/main/java/dev/barboud/taglens/dto/UserResponseDto.java +++ b/src/main/java/dev/barboud/taglens/dto/user/UserResponseDto.java @@ -1,4 +1,4 @@ -package dev.barboud.taglens.dto; +package dev.barboud.taglens.dto.user; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/dev/barboud/taglens/model/Image.java b/src/main/java/dev/barboud/taglens/model/Image.java new file mode 100644 index 0000000..efc6fe3 --- /dev/null +++ b/src/main/java/dev/barboud/taglens/model/Image.java @@ -0,0 +1,8 @@ +package dev.barboud.taglens.model; + +import lombok.Data; + +@Data +public class Image { + +} diff --git a/src/main/java/dev/barboud/taglens/service/AuthService.java b/src/main/java/dev/barboud/taglens/service/AuthService.java index c0c4002..3933915 100644 --- a/src/main/java/dev/barboud/taglens/service/AuthService.java +++ b/src/main/java/dev/barboud/taglens/service/AuthService.java @@ -1,8 +1,8 @@ package dev.barboud.taglens.service; -import dev.barboud.taglens.dto.LoginDto; -import dev.barboud.taglens.dto.UserRegistrationDto; -import dev.barboud.taglens.dto.UserResponseDto; +import dev.barboud.taglens.dto.user.UserLoginDto; +import dev.barboud.taglens.dto.user.UserRegistrationDto; +import dev.barboud.taglens.dto.user.UserResponseDto; import dev.barboud.taglens.model.Session; import dev.barboud.taglens.model.User; import dev.barboud.taglens.repository.SessionRepository; @@ -41,7 +41,7 @@ public void registerUser(UserRegistrationDto registrationDto) { } - public UUID login(LoginDto loginDto) { + public UUID login(UserLoginDto loginDto) { User user = userRepository.findUserByEmail(loginDto.getEmail()) .orElseThrow(() -> new BadCredentialsException("Invalid email or password")); From 691ffb956c4bd53fa0e16c52dadbe170b6032310 Mon Sep 17 00:00:00 2001 From: Salem Ba-Rabuod Date: Sun, 26 Jul 2026 18:39:55 +0200 Subject: [PATCH 3/6] feat: upload image to S3 --- README.md | 10 +-- pom.xml | 9 ++- .../taglens/configuration/B2Config.java | 41 ++++++++++++ .../taglens/configuration/SecurityConfig.java | 1 + .../taglens/controller/ImageController.java | 38 +++++++++++ .../barboud/taglens/service/ImageService.java | 66 +++++++++++++++++++ src/main/resources/application.properties | 10 ++- 7 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 src/main/java/dev/barboud/taglens/configuration/B2Config.java create mode 100644 src/main/java/dev/barboud/taglens/controller/ImageController.java create mode 100644 src/main/java/dev/barboud/taglens/service/ImageService.java diff --git a/README.md b/README.md index bfda1dc..d80e359 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ -- `POST /api/images` Upload image -- `GET /api/images/my` My uploaded images -- `GET /api/images` Home / search images **_Public_** -- `GET /api/images/{id}/content` Display image **_Public_** -- `DELETE /api/images/{id}` Delete my image \ No newline at end of file +- `POST /images/upload` Upload image +- `GET /images/my` My uploaded images +- `GET /images` Home / search images **_Public_** +- `GET /images/{id}/content` Display image **_Public_** +- `DELETE /images/{id}` Delete my image \ No newline at end of file diff --git a/pom.xml b/pom.xml index d65f40b..8ff9c6b 100644 --- a/pom.xml +++ b/pom.xml @@ -89,13 +89,20 @@ - org.springframework.boot spring-boot-starter-security + + + software.amazon.awssdk + s3 + 2.25.0 + + + diff --git a/src/main/java/dev/barboud/taglens/configuration/B2Config.java b/src/main/java/dev/barboud/taglens/configuration/B2Config.java new file mode 100644 index 0000000..9f385de --- /dev/null +++ b/src/main/java/dev/barboud/taglens/configuration/B2Config.java @@ -0,0 +1,41 @@ +package dev.barboud.taglens.configuration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +@Configuration +public class B2Config { + + @Value("${b2.endpoint}") private String endpoint; + @Value("${b2.region}") private String region; + @Value("${b2.access-key}") private String accessKey; + @Value("${b2.secret-key}") private String secretKey; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .endpointOverride(URI.create(endpoint)) + .region(Region.of(region)) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .endpointOverride(URI.create(endpoint)) + .region(Region.of(region)) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java b/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java index a7221c5..0839153 100644 --- a/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java +++ b/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java @@ -22,6 +22,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/login", "/auth/register").permitAll() // login & register: public .requestMatchers("/", "/*.html", "/*.css", "/*.js").permitAll() + .requestMatchers("/images/**").permitAll() .anyRequest().authenticated() // everything else: must be logged in ) .addFilterBefore(sessionAuthFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/dev/barboud/taglens/controller/ImageController.java b/src/main/java/dev/barboud/taglens/controller/ImageController.java new file mode 100644 index 0000000..15478bb --- /dev/null +++ b/src/main/java/dev/barboud/taglens/controller/ImageController.java @@ -0,0 +1,38 @@ +package dev.barboud.taglens.controller; + + +import dev.barboud.taglens.service.ImageService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +@RestController +@RequestMapping("/images") +@RequiredArgsConstructor +public class ImageController { + + private final ImageService imageService; + + + @PostMapping("/upload") + public ResponseEntity> uploadImage( + @RequestParam("file") MultipartFile file) throws Exception { + String storageKey = imageService.uploadImage(file); + return ResponseEntity.ok(Map.of("storage_key", storageKey)); + } + +// @GetMapping("/link") +// public ResponseEntity> getImageLink(@RequestParam String key) { +// return ResponseEntity.ok(Map.of("url", imageService.getImageLink(key, 15))); +// } +// +// @DeleteMapping +// public ResponseEntity deleteImage(@RequestParam String key) { +// imageService.deleteImage(key); +// return ResponseEntity.noContent().build(); +// } + +} diff --git a/src/main/java/dev/barboud/taglens/service/ImageService.java b/src/main/java/dev/barboud/taglens/service/ImageService.java new file mode 100644 index 0000000..afdbdff --- /dev/null +++ b/src/main/java/dev/barboud/taglens/service/ImageService.java @@ -0,0 +1,66 @@ +package dev.barboud.taglens.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.time.Duration; +import java.util.UUID; + +@Service +public class ImageService { + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + + @Value("${b2.bucket}") + private String bucket; + + public ImageService(S3Client s3Client, S3Presigner s3Presigner) { + this.s3Client = s3Client; + this.s3Presigner = s3Presigner; + } + + // Upload — returns the key to save in your database + public String uploadImage(MultipartFile file) throws Exception { + String originalFilename = file.getOriginalFilename(); + String extension = ""; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + + String storageKey = "images/" + UUID.randomUUID() + extension; + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket).key(storageKey) + .contentType(file.getContentType()).build(), + RequestBody.fromInputStream(file.getInputStream(), file.getSize()) + ); + return storageKey; + } + +// // Presigned URL — expires after given minutes +// public String getImageLink(String key, int minutes) { +// return s3Presigner.presignGetObject( +// GetObjectPresignRequest.builder() +// .signatureDuration(Duration.ofMinutes(minutes)) +// .getObjectRequest(r -> r.bucket(bucket).key(key)) +// .build() +// ).url().toString(); +// } +// +// // Delete +// public void deleteImage(String key) { +// s3Client.deleteObject( +// DeleteObjectRequest.builder() +// .bucket(bucket).key(key).build() +// ); +// } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5b00e5b..1363e45 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,4 +4,12 @@ spring.application.name=TagLens spring.datasource.url=${DB_URL} spring.datasource.username=${DB_USERNAME} -spring.datasource.password=${DB_PASSWORD} \ No newline at end of file +spring.datasource.password=${DB_PASSWORD} + + + +b2.endpoint=https://s3.eu-central-003.backblazeb2.com +b2.region=us-west-004 +b2.access-key=${B2_ACCESS_KEY} +b2.secret-key=${B2_SECRET_KEY} +b2.bucket=${B2_BUCKET_NAME} \ No newline at end of file From 6b412ea1e86b888b6d8c689363b02dafa2ac6df2 Mon Sep 17 00:00:00 2001 From: Salem Ba-Rabuod Date: Sun, 26 Jul 2026 19:58:08 +0200 Subject: [PATCH 4/6] feat: upload image only for valid session --- .../taglens/configuration/SecurityConfig.java | 1 - .../taglens/controller/ImageController.java | 19 ++- .../taglens/dto/image/ImageResponseDto.java | 15 +++ .../java/dev/barboud/taglens/model/Image.java | 10 +- .../taglens/repository/ImageRepository.java | 29 +++++ .../taglens/repository/SessionRepository.java | 3 +- .../taglens/repository/UserRepository.java | 5 +- .../barboud/taglens/service/ImageService.java | 108 ++++++++++-------- .../taglens/service/StorageService.java | 65 +++++++++++ src/main/resources/application.properties | 6 +- 10 files changed, 198 insertions(+), 63 deletions(-) create mode 100644 src/main/java/dev/barboud/taglens/dto/image/ImageResponseDto.java create mode 100644 src/main/java/dev/barboud/taglens/repository/ImageRepository.java create mode 100644 src/main/java/dev/barboud/taglens/service/StorageService.java diff --git a/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java b/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java index 0839153..a7221c5 100644 --- a/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java +++ b/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java @@ -22,7 +22,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/login", "/auth/register").permitAll() // login & register: public .requestMatchers("/", "/*.html", "/*.css", "/*.js").permitAll() - .requestMatchers("/images/**").permitAll() .anyRequest().authenticated() // everything else: must be logged in ) .addFilterBefore(sessionAuthFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/dev/barboud/taglens/controller/ImageController.java b/src/main/java/dev/barboud/taglens/controller/ImageController.java index 15478bb..1688ba7 100644 --- a/src/main/java/dev/barboud/taglens/controller/ImageController.java +++ b/src/main/java/dev/barboud/taglens/controller/ImageController.java @@ -1,13 +1,16 @@ package dev.barboud.taglens.controller; +import dev.barboud.taglens.dto.image.ImageResponseDto; +import dev.barboud.taglens.model.Image; import dev.barboud.taglens.service.ImageService; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.util.Map; +import java.util.UUID; @RestController @RequestMapping("/images") @@ -18,10 +21,16 @@ public class ImageController { @PostMapping("/upload") - public ResponseEntity> uploadImage( - @RequestParam("file") MultipartFile file) throws Exception { - String storageKey = imageService.uploadImage(file); - return ResponseEntity.ok(Map.of("storage_key", storageKey)); + public ResponseEntity uploadImage( + @RequestParam("file") MultipartFile file, + @CookieValue("session_id") UUID sessionId + ) throws Exception { + + ImageResponseDto imageResponseDto = imageService.uploadImage(file, sessionId); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(imageResponseDto); } // @GetMapping("/link") diff --git a/src/main/java/dev/barboud/taglens/dto/image/ImageResponseDto.java b/src/main/java/dev/barboud/taglens/dto/image/ImageResponseDto.java new file mode 100644 index 0000000..9c8375f --- /dev/null +++ b/src/main/java/dev/barboud/taglens/dto/image/ImageResponseDto.java @@ -0,0 +1,15 @@ +package dev.barboud.taglens.dto.image; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.UUID; + +@Data +@AllArgsConstructor +public class ImageResponseDto { + UUID id; + String contentType; + Long fileSize; + private String storageKey; +} diff --git a/src/main/java/dev/barboud/taglens/model/Image.java b/src/main/java/dev/barboud/taglens/model/Image.java index efc6fe3..15071ce 100644 --- a/src/main/java/dev/barboud/taglens/model/Image.java +++ b/src/main/java/dev/barboud/taglens/model/Image.java @@ -2,7 +2,15 @@ import lombok.Data; +import java.time.Instant; +import java.util.UUID; + @Data public class Image { - + private UUID id; + private UUID userId; + private String storageKey; + private String contentType; + private Long fileSize; + private Instant createdAt; } diff --git a/src/main/java/dev/barboud/taglens/repository/ImageRepository.java b/src/main/java/dev/barboud/taglens/repository/ImageRepository.java new file mode 100644 index 0000000..3faa835 --- /dev/null +++ b/src/main/java/dev/barboud/taglens/repository/ImageRepository.java @@ -0,0 +1,29 @@ +package dev.barboud.taglens.repository; + +import dev.barboud.taglens.model.Image; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ImageRepository { + + private final JdbcTemplate jdbcTemplate; + + public void saveImage(Image image) { + + jdbcTemplate.update(""" + INSERT INTO images + (id, user_id, storage_key, content_type, file_size) + VALUES (?, ?, ?, ?, ?) + """, + + image.getId(), + image.getUserId(), + image.getStorageKey(), + image.getContentType(), + image.getFileSize() + ); + } +} \ No newline at end of file diff --git a/src/main/java/dev/barboud/taglens/repository/SessionRepository.java b/src/main/java/dev/barboud/taglens/repository/SessionRepository.java index 0a063bc..a2ffcce 100644 --- a/src/main/java/dev/barboud/taglens/repository/SessionRepository.java +++ b/src/main/java/dev/barboud/taglens/repository/SessionRepository.java @@ -13,6 +13,7 @@ @Repository @RequiredArgsConstructor public class SessionRepository { + private final JdbcTemplate jdbcTemplate; public void createSession(Session session) { @@ -22,7 +23,6 @@ public void createSession(Session session) { ); } - public Optional findValidSession(UUID sessionId) { String sql = "SELECT id, user_id, expires_at FROM sessions WHERE id = ? AND expires_at > NOW()"; List results = jdbcTemplate.query(sql, (rs, rowNum) -> { @@ -36,7 +36,6 @@ public Optional findValidSession(UUID sessionId) { return results.stream().findFirst(); } - public void deleteSession(UUID sessionId) { jdbcTemplate.update("DELETE FROM sessions WHERE id = ?", sessionId); } diff --git a/src/main/java/dev/barboud/taglens/repository/UserRepository.java b/src/main/java/dev/barboud/taglens/repository/UserRepository.java index 1f38ea5..852aac8 100644 --- a/src/main/java/dev/barboud/taglens/repository/UserRepository.java +++ b/src/main/java/dev/barboud/taglens/repository/UserRepository.java @@ -12,8 +12,8 @@ @Repository @RequiredArgsConstructor public class UserRepository { - private final JdbcTemplate jdbcTemplate; + private final JdbcTemplate jdbcTemplate; public void registerUser(User user) { jdbcTemplate.update( @@ -22,7 +22,6 @@ public void registerUser(User user) { ); } - public Optional findUserByEmail(String email) { String sql = "SELECT id, email, password_hash FROM users WHERE email = ?"; List results = jdbcTemplate.query(sql, (rs, rowNum) -> { @@ -36,8 +35,6 @@ public Optional findUserByEmail(String email) { return results.stream().findFirst(); } - - public Optional findUserById(UUID id) { String sql = "SELECT id, email, password_hash, created_at FROM users WHERE id = ?"; List results = jdbcTemplate.query(sql, (rs, rowNum) -> { diff --git a/src/main/java/dev/barboud/taglens/service/ImageService.java b/src/main/java/dev/barboud/taglens/service/ImageService.java index afdbdff..54d4530 100644 --- a/src/main/java/dev/barboud/taglens/service/ImageService.java +++ b/src/main/java/dev/barboud/taglens/service/ImageService.java @@ -1,66 +1,76 @@ package dev.barboud.taglens.service; -import org.springframework.beans.factory.annotation.Value; +import dev.barboud.taglens.dto.image.ImageResponseDto; +import dev.barboud.taglens.model.Image; +import dev.barboud.taglens.model.Session; +import dev.barboud.taglens.repository.ImageRepository; +import dev.barboud.taglens.repository.SessionRepository; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; - -import java.time.Duration; + + import java.util.UUID; @Service +@RequiredArgsConstructor public class ImageService { - private final S3Client s3Client; - private final S3Presigner s3Presigner; + private final SessionRepository sessionRepository; + private final ImageRepository imageRepository; + private final StorageService fileService; - @Value("${b2.bucket}") - private String bucket; + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB - public ImageService(S3Client s3Client, S3Presigner s3Presigner) { - this.s3Client = s3Client; - this.s3Presigner = s3Presigner; - } + public ImageResponseDto uploadImage( + MultipartFile file, + UUID sessionId + ) throws Exception { + + // 1. Check if session is valid + Session session = sessionRepository + .findValidSession(sessionId) + .orElseThrow(() -> + new RuntimeException("Unauthorized")); - // Upload — returns the key to save in your database - public String uploadImage(MultipartFile file) throws Exception { - String originalFilename = file.getOriginalFilename(); - String extension = ""; - if (originalFilename != null && originalFilename.contains(".")) { - extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + // 2. Get user ID + UUID userId = session.getUserId(); + + // 3. Check file size + if (file.getSize() > MAX_FILE_SIZE) { + throw new RuntimeException( + "File size must not exceed 10 MB" + ); } - String storageKey = "images/" + UUID.randomUUID() + extension; - s3Client.putObject( - PutObjectRequest.builder() - .bucket(bucket).key(storageKey) - .contentType(file.getContentType()).build(), - RequestBody.fromInputStream(file.getInputStream(), file.getSize()) - ); - return storageKey; - } + // 4. Check file type + String contentType = file.getContentType(); -// // Presigned URL — expires after given minutes -// public String getImageLink(String key, int minutes) { -// return s3Presigner.presignGetObject( -// GetObjectPresignRequest.builder() -// .signatureDuration(Duration.ofMinutes(minutes)) -// .getObjectRequest(r -> r.bucket(bucket).key(key)) -// .build() -// ).url().toString(); -// } -// -// // Delete -// public void deleteImage(String key) { -// s3Client.deleteObject( -// DeleteObjectRequest.builder() -// .bucket(bucket).key(key).build() -// ); -// } + if (contentType == null || !contentType.startsWith("image/")) { + throw new RuntimeException("Only image files are allowed"); + } + + // 5. Upload image to Object Storage + String storageKey = + fileService.upload(file); + + // 6. Create Image object + Image image = new Image(); + + image.setId(UUID.randomUUID()); + image.setUserId(userId); + image.setStorageKey(storageKey); + image.setContentType(file.getContentType()); + image.setFileSize(file.getSize()); + // 7. Save image metadata in Database + imageRepository.saveImage(image); + + return new ImageResponseDto( + image.getId(), + image.getContentType(), + image.getFileSize(), + image.getStorageKey() + ); + } } diff --git a/src/main/java/dev/barboud/taglens/service/StorageService.java b/src/main/java/dev/barboud/taglens/service/StorageService.java new file mode 100644 index 0000000..092fc17 --- /dev/null +++ b/src/main/java/dev/barboud/taglens/service/StorageService.java @@ -0,0 +1,65 @@ +package dev.barboud.taglens.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.time.Duration; +import java.util.UUID; + +@Service +public class StorageService { + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + + @Value("${b2.bucket}") + private String bucket; + + public StorageService(S3Client s3Client, S3Presigner s3Presigner) { + this.s3Client = s3Client; + this.s3Presigner = s3Presigner; + } + + // Upload — returns the key to save in your database + public String upload(MultipartFile file) throws Exception { + String originalFilename = file.getOriginalFilename(); + String extension = ""; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + + String storageKey = "images/" + UUID.randomUUID() + extension; + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket).key(storageKey) + .contentType(file.getContentType()).build(), + RequestBody.fromInputStream(file.getInputStream(), file.getSize()) + ); + return storageKey; + } + + // Presigned URL — expires after given minutes + public String getLink(String key, int minutes) { + return s3Presigner.presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(minutes)) + .getObjectRequest(r -> r.bucket(bucket).key(key)) + .build() + ).url().toString(); + } + + // Delete + public void delete(String key) { + s3Client.deleteObject( + DeleteObjectRequest.builder() + .bucket(bucket).key(key).build() + ); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1363e45..2be0e11 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,4 +12,8 @@ b2.endpoint=https://s3.eu-central-003.backblazeb2.com b2.region=us-west-004 b2.access-key=${B2_ACCESS_KEY} b2.secret-key=${B2_SECRET_KEY} -b2.bucket=${B2_BUCKET_NAME} \ No newline at end of file +b2.bucket=${B2_BUCKET_NAME} + + +spring.servlet.multipart.max-file-size=15MB +spring.servlet.multipart.max-request-size=15MB \ No newline at end of file From 4b11024b8bbeaaf1a382b7c5d9b1260ff7df9a8c Mon Sep 17 00:00:00 2001 From: Salem Ba-Rabuod Date: Sun, 26 Jul 2026 20:12:21 +0200 Subject: [PATCH 5/6] style: upload via browser index.html --- src/main/resources/static/index.html | 12 ++++++-- src/main/resources/static/script.js | 46 +++++++++++++++++++++++++++- src/main/resources/static/style.css | 16 ++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 727e70d..611f67e 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -3,7 +3,7 @@ - TagLens + Tag Lens @@ -18,7 +18,7 @@ diff --git a/src/main/resources/static/script.js b/src/main/resources/static/script.js index 8dff042..5a0a661 100644 --- a/src/main/resources/static/script.js +++ b/src/main/resources/static/script.js @@ -104,4 +104,48 @@ async function logout() { } // Kick things off as soon as the page loads -checkSession(); \ No newline at end of file +checkSession(); + +async function uploadImage() { + const fileInput = document.getElementById('imageFile'); + const file = fileInput.files[0]; + + if (!file) { + log('Pick a file first', true); + return; + } + + if (file.size > 10 * 1024 * 1024) { + log('File too large — max 10MB', true); + return; + } + + const formData = new FormData(); + formData.append('file', file); + + try { + const res = await fetch('/images/upload', { + method: 'POST', + credentials: 'include', + body: formData + // no Content-Type header — the browser sets the multipart boundary itself + }); + + if (res.ok) { + const data = await res.json(); + log(`Image uploaded (id: ${data.id || 'ok'})`); + + // Show a quick local preview of what was just uploaded + const preview = document.getElementById('uploadPreview'); + preview.innerHTML = ''; + const img = document.createElement('img'); + img.src = URL.createObjectURL(file); + preview.appendChild(img); + } else { + const text = await res.text(); + log(`Upload failed [${res.status}]: ${text}`, true); + } + } catch (e) { + log(`Connection error: ${e.message}`, true); + } +} \ No newline at end of file diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css index ac119c8..36982d2 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -210,4 +210,20 @@ button:active { opacity: 0.7; } font-size: 11px; font-weight: 400; color: var(--muted); +} + +input[type="file"] { + padding: 8px; + font-size: 12px; +} + +#uploadPreview { + margin-top: 14px; +} + +#uploadPreview img { + width: 100%; + border-radius: 6px; + border: 1px solid var(--border); + display: block; } \ No newline at end of file From 2a8d6489879e3297d3db05671cac9e367b59d04e Mon Sep 17 00:00:00 2001 From: Salem Ba-Rabuod Date: Sun, 26 Jul 2026 20:55:14 +0200 Subject: [PATCH 6/6] fix: add test for S3 env in ci.yml --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68802a5..b7b31ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,9 @@ jobs: DB_URL: jdbc:postgresql://localhost:5432/testdb DB_USERNAME: postgres DB_PASSWORD: postgres - RESEND_API: test-key-not-real + B2_ACCESS_KEY: test-key-not-real + B2_SECRET_KEY: test-secret-not-real + B2_BUCKET_NAME: test-bucket build-and-push: runs-on: ubuntu-latest