Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@
</dependency>


<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>3.0.2</version>
</dependency>


</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers("/", "/*.html", "/*.css", "/*.js").permitAll()
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/images").permitAll()
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated() // everything else: must be logged in
)
.addFilterBefore(sessionAuthFilter, UsernamePasswordAuthenticationFilter.class);
Expand Down
21 changes: 20 additions & 1 deletion src/main/java/dev/barboud/taglens/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
import dev.barboud.taglens.dto.user.UserRegistrationDto;
import dev.barboud.taglens.dto.user.UserResponseDto;
import dev.barboud.taglens.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand All @@ -18,6 +22,7 @@
import java.time.Duration;
import java.util.UUID;

@Tag(name = "Authentication", description = "Register, log in and log out using a session cookie")
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
Expand All @@ -26,13 +31,21 @@ public class AuthController {
private final AuthService authService;


@Operation(summary = "Register a new user", description = "Creates a new account with the given details.")
@ApiResponse(responseCode = "201", description = "User created")
@ApiResponse(responseCode = "400", description = "Validation failed (invalid or missing fields)")
@PostMapping("/register")
public ResponseEntity<Void> createUser(@Valid @RequestBody UserRegistrationDto registrationDto) {
authService.registerUser(registrationDto);
return ResponseEntity.status(HttpStatus.CREATED).build();
}


@Operation(summary = "Log in",
description = "Checks the credentials and sets an HttpOnly `session_id` cookie (valid for 7 days).")
@ApiResponse(responseCode = "200", description = "Logged in, session cookie set")
@ApiResponse(responseCode = "400", description = "Validation failed (invalid or missing fields)")
@ApiResponse(responseCode = "401", description = "Invalid email or password")
@PostMapping("/login")
public ResponseEntity<Void> login(@Valid @RequestBody UserLoginDto loginDto) {
UUID sessionId = authService.login(loginDto);
Expand All @@ -52,8 +65,11 @@ public ResponseEntity<Void> login(@Valid @RequestBody UserLoginDto loginDto) {



@Operation(summary = "Log out", description = "Deletes the session (if any) and clears the `session_id` cookie.")
@ApiResponse(responseCode = "200", description = "Logged out, session cookie cleared")
@PostMapping("/logout")
public ResponseEntity<Void> logout(@CookieValue(name = "session_id", required = false) UUID sessionId,
public ResponseEntity<Void> logout(@Parameter(hidden = true)
@CookieValue(name = "session_id", required = false) UUID sessionId,
HttpServletResponse response) {

if (sessionId != null) {
Expand All @@ -74,6 +90,9 @@ public ResponseEntity<Void> logout(@CookieValue(name = "session_id", required =
}


@Operation(summary = "Get the current user", description = "Returns the profile of the logged-in user.")
@ApiResponse(responseCode = "200", description = "Current user returned")
@ApiResponse(responseCode = "401", description = "Not logged in or session expired")
@GetMapping("/me")
public ResponseEntity<UserResponseDto> getCurrentUser(Authentication authentication) {
UUID userId = (UUID) authentication.getPrincipal();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
import dev.barboud.taglens.dto.image.ImageResponseDto;
import dev.barboud.taglens.model.Image;
import dev.barboud.taglens.service.ImageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -14,6 +18,7 @@
import java.util.Map;
import java.util.UUID;

@Tag(name = "Images", description = "Upload, view, delete and search images (AI tags are generated after upload)")
@RestController
@RequestMapping("/images")
@RequiredArgsConstructor
Expand All @@ -22,9 +27,17 @@ public class ImageController {
private final ImageService imageService;


@PostMapping("/upload")
@Operation(summary = "Upload an image",
description = "Uploads an image file to storage. AI tags are generated asynchronously afterwards. Requires login.")
@ApiResponse(responseCode = "204", description = "Image uploaded")
@ApiResponse(responseCode = "400", description = "File is missing or is not an image")
@ApiResponse(responseCode = "401", description = "Not logged in or session expired")
@ApiResponse(responseCode = "413", description = "File is too large")
@PostMapping(value = "/upload", consumes = "multipart/form-data")
public ResponseEntity<Void> uploadImage(
@Parameter(description = "Image file to upload")
@RequestParam("file") MultipartFile file,
@Parameter(hidden = true)
@CookieValue("session_id") UUID sessionId
) throws Exception {

Expand All @@ -33,34 +46,54 @@ public ResponseEntity<Void> uploadImage(
return ResponseEntity.noContent().build();
}

@Operation(summary = "Get an image link", description = "Returns a URL for viewing the image. Requires login.")
@ApiResponse(responseCode = "200", description = "Link returned, e.g. {\"url\": \"https://...\"}")
@ApiResponse(responseCode = "401", description = "Not logged in or session expired")
@ApiResponse(responseCode = "404", description = "Image not found")
@GetMapping("/{id}")
public ResponseEntity<Map<String, String>> getImageLink(
@Parameter(description = "Image ID")
@PathVariable UUID id,
@Parameter(hidden = true)
@CookieValue("session_id") UUID sessionId
) {
String url = imageService.getImageLink(id, sessionId);
return ResponseEntity.ok(Map.of("url", url));
}


@Operation(summary = "Delete an image", description = "Deletes one of your images. Requires login.")
@ApiResponse(responseCode = "204", description = "Image deleted")
@ApiResponse(responseCode = "401", description = "Not logged in or session expired")
@ApiResponse(responseCode = "404", description = "Image not found")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteImage(
@Parameter(description = "Image ID")
@PathVariable UUID id,
@Parameter(hidden = true)
@CookieValue("session_id") UUID sessionId) {
imageService.deleteImage(id, sessionId);
return ResponseEntity.noContent().build();
}

@Operation(summary = "List my images", description = "Returns all images uploaded by the logged-in user.")
@ApiResponse(responseCode = "200", description = "List of your images")
@ApiResponse(responseCode = "401", description = "Not logged in or session expired")
@GetMapping("/my")
public ResponseEntity<List<ImageResponseDto>> getMyImages(
@Parameter(hidden = true)
@CookieValue("session_id") UUID sessionId
) {
List<ImageResponseDto> imageResponseDto = imageService.getMyImages(sessionId);
return ResponseEntity.ok(imageResponseDto);
}

@Operation(summary = "Browse or search images",
description = "Public. Without `search` it returns the 50 most recent images; with `search` it does a free-text search on the AI tags.")
@ApiResponse(responseCode = "200", description = "List of matching images")
@GetMapping
public List<ImageResponseDto> browseImages(
@Parameter(description = "Free-text search on tags, e.g. `dog`")
@RequestParam(value = "search", required = false) String search
) {
if (search != null && !search.isBlank()) {
Expand Down
172 changes: 172 additions & 0 deletions src/main/resources/static/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
const API = location.hostname === "localhost" ? "http://localhost:8080" : location.origin;

const gallery = document.getElementById("gallery");

async function loadImages(search = "") {

let url = API + "/images";

if (search !== "")
url += "?search=" + encodeURIComponent(search);

const response = await fetch(url);

const images = await response.json();

gallery.innerHTML = "";

images.forEach(createCard);

}

function createCard(image) {

const card = document.createElement("div");

card.className = "card";

card.innerHTML = `

<img src="${image.url}">

<div class="content">

<h3>${image.filename}</h3>

<div class="tags">

${
image.tags.tags
.map(tag=>`<span class="tag">${tag}</span>`)
.join("")
}

</div>

</div>

`;

gallery.appendChild(card);

}

document
.getElementById("searchBtn")
.onclick = ()=>{

loadImages(
document.getElementById("searchInput").value
);

};

document
.getElementById("homeBtn")
.onclick = ()=>loadImages();


document
.getElementById("uploadBtn")
.onclick = async ()=>{

const file =
document.getElementById("imageInput").files[0];

if(!file)
return;

const form = new FormData();

form.append("file",file);

const response = await fetch(API+"/images/upload",{

method:"POST",

credentials:"include",

body:form

});

if(response.ok){

alert("Uploaded!");

loadImages();

}

else{

alert("Upload failed");

}

};


async function login(email,password){

await fetch(API+"/auth/login",{

method:"POST",

credentials:"include",

headers:{

"Content-Type":"application/json"

},

body:JSON.stringify({

email,

password

})

});

}

async function register(email,password){

await fetch(API+"/auth/register",{

method:"POST",

headers:{

"Content-Type":"application/json"

},

body:JSON.stringify({

email,

password

})

});

}

async function logout(){

await fetch(API+"/auth/logout",{

method:"POST",

credentials:"include"

});

}

loadImages();
Loading
Loading