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 diff --git a/README.md b/README.md index 18a0e4f..d80e359 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 /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/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/controller/ImageController.java b/src/main/java/dev/barboud/taglens/controller/ImageController.java new file mode 100644 index 0000000..1688ba7 --- /dev/null +++ b/src/main/java/dev/barboud/taglens/controller/ImageController.java @@ -0,0 +1,47 @@ +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.UUID; + +@RestController +@RequestMapping("/images") +@RequiredArgsConstructor +public class ImageController { + + private final ImageService imageService; + + + @PostMapping("/upload") + 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") +// 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/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/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..15071ce --- /dev/null +++ b/src/main/java/dev/barboud/taglens/model/Image.java @@ -0,0 +1,16 @@ +package dev.barboud.taglens.model; + +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/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")); 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..54d4530 --- /dev/null +++ b/src/main/java/dev/barboud/taglens/service/ImageService.java @@ -0,0 +1,76 @@ +package dev.barboud.taglens.service; + +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 java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ImageService { + + private final SessionRepository sessionRepository; + private final ImageRepository imageRepository; + private final StorageService fileService; + + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + + public ImageResponseDto uploadImage( + MultipartFile file, + UUID sessionId + ) throws Exception { + + // 1. Check if session is valid + Session session = sessionRepository + .findValidSession(sessionId) + .orElseThrow(() -> + new RuntimeException("Unauthorized")); + + // 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" + ); + } + + // 4. Check file type + String contentType = file.getContentType(); + + 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 5b00e5b..2be0e11 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,4 +4,16 @@ 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} + + +spring.servlet.multipart.max-file-size=15MB +spring.servlet.multipart.max-request-size=15MB \ 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 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