From ff257d9c1766cfd1c131baa6f045b0897bdb446b Mon Sep 17 00:00:00 2001 From: Salem Ba-Rabuod Date: Wed, 28 Oct 2026 20:51:36 +0100 Subject: [PATCH] feat: swager docs and annotation for all controller --- pom.xml | 7 + .../taglens/configuration/SecurityConfig.java | 1 + .../taglens/controller/AuthController.java | 21 +- .../taglens/controller/ImageController.java | 35 +- src/main/resources/static/app.js | 172 ++++++++++ src/main/resources/static/index.html | 153 +++++---- src/main/resources/static/script.js | 151 --------- src/main/resources/static/style.css | 310 +++++++----------- 8 files changed, 436 insertions(+), 414 deletions(-) create mode 100644 src/main/resources/static/app.js delete mode 100644 src/main/resources/static/script.js diff --git a/pom.xml b/pom.xml index a639f7f..308dd72 100644 --- a/pom.xml +++ b/pom.xml @@ -116,6 +116,13 @@ + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 3.0.2 + + + diff --git a/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java b/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java index df987ec..34942bf 100644 --- a/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java +++ b/src/main/java/dev/barboud/taglens/configuration/SecurityConfig.java @@ -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); diff --git a/src/main/java/dev/barboud/taglens/controller/AuthController.java b/src/main/java/dev/barboud/taglens/controller/AuthController.java index fb1256f..0ae4770 100644 --- a/src/main/java/dev/barboud/taglens/controller/AuthController.java +++ b/src/main/java/dev/barboud/taglens/controller/AuthController.java @@ -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; @@ -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 @@ -26,6 +31,9 @@ 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 createUser(@Valid @RequestBody UserRegistrationDto registrationDto) { authService.registerUser(registrationDto); @@ -33,6 +41,11 @@ public ResponseEntity createUser(@Valid @RequestBody UserRegistrationDto r } + @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 login(@Valid @RequestBody UserLoginDto loginDto) { UUID sessionId = authService.login(loginDto); @@ -52,8 +65,11 @@ public ResponseEntity 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 logout(@CookieValue(name = "session_id", required = false) UUID sessionId, + public ResponseEntity logout(@Parameter(hidden = true) + @CookieValue(name = "session_id", required = false) UUID sessionId, HttpServletResponse response) { if (sessionId != null) { @@ -74,6 +90,9 @@ public ResponseEntity 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 getCurrentUser(Authentication authentication) { UUID userId = (UUID) authentication.getPrincipal(); diff --git a/src/main/java/dev/barboud/taglens/controller/ImageController.java b/src/main/java/dev/barboud/taglens/controller/ImageController.java index ec08962..472e299 100644 --- a/src/main/java/dev/barboud/taglens/controller/ImageController.java +++ b/src/main/java/dev/barboud/taglens/controller/ImageController.java @@ -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; @@ -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 @@ -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 uploadImage( + @Parameter(description = "Image file to upload") @RequestParam("file") MultipartFile file, + @Parameter(hidden = true) @CookieValue("session_id") UUID sessionId ) throws Exception { @@ -33,9 +46,15 @@ public ResponseEntity 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> getImageLink( + @Parameter(description = "Image ID") @PathVariable UUID id, + @Parameter(hidden = true) @CookieValue("session_id") UUID sessionId ) { String url = imageService.getImageLink(id, sessionId); @@ -43,24 +62,38 @@ public ResponseEntity> getImageLink( } + @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 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> getMyImages( + @Parameter(hidden = true) @CookieValue("session_id") UUID sessionId ) { List 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 browseImages( + @Parameter(description = "Free-text search on tags, e.g. `dog`") @RequestParam(value = "search", required = false) String search ) { if (search != null && !search.isBlank()) { diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js new file mode 100644 index 0000000..a5e9792 --- /dev/null +++ b/src/main/resources/static/app.js @@ -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 = ` + + + +
+ +

${image.filename}

+ +
+ + ${ + image.tags.tags + .map(tag=>`${tag}`) + .join("") + } + +
+ +
+ + `; + + 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(); \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 611f67e..6457a04 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -1,95 +1,92 @@ - + - Tag Lens + TagLens + -
-
Tag_Lens
+
- -
-
Checking session…
+ - - +
+ + + + + + + + + + +
+ + +
+ +

AI Image Hosting

+ +

+ Upload images. Let AI describe them. Search anything. +

+ +
+ + + + + +
+ + + + + +
+ + + - + \ No newline at end of file diff --git a/src/main/resources/static/script.js b/src/main/resources/static/script.js deleted file mode 100644 index 5a0a661..0000000 --- a/src/main/resources/static/script.js +++ /dev/null @@ -1,151 +0,0 @@ -const BASE = '/auth'; // change this if the backend runs on a different port/domain, e.g. http://localhost:8080/auth - -function log(message, isError = false) { - const body = document.getElementById('logBody'); - const line = document.createElement('div'); - line.className = 'log-line'; - const time = new Date().toLocaleTimeString('en-GB'); - line.innerHTML = `${time}${message}`; - body.prepend(line); -} - -function showView(view) { - document.getElementById('loadingView').classList.add('hidden'); - document.getElementById('guestView').classList.add('hidden'); - document.getElementById('userView').classList.add('hidden'); - document.getElementById(view).classList.remove('hidden'); -} - -function renderUser(user) { - document.getElementById('userEmail').textContent = user.email; - document.getElementById('userId').textContent = user.id; - document.getElementById('userCreatedAt').textContent = user.created_at || user.createdAt || '—'; - showView('userView'); -} - -// Run on every page load: ask the backend if we're logged in, and render the right view. -async function checkSession() { - try { - const res = await fetch(`${BASE}/me`, { - method: 'GET', - credentials: 'include' - }); - if (res.ok) { - const data = await res.json(); - log(`Session active (${data.email})`); - renderUser(data); - } else { - log('No active session'); - showView('guestView'); - } - } catch (e) { - log(`Connection error: ${e.message}`, true); - showView('guestView'); - } -} - -async function register() { - const email = document.getElementById('regEmail').value; - const password = document.getElementById('regPassword').value; - try { - const res = await fetch(`${BASE}/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }) - }); - if (res.status === 201) { - log(`Account created (${email})`); - } else { - const text = await res.text(); - log(`Registration failed [${res.status}]: ${text}`, true); - } - } catch (e) { - log(`Connection error: ${e.message}`, true); - } -} - -async function login() { - const email = document.getElementById('loginEmail').value; - const password = document.getElementById('loginPassword').value; - try { - const res = await fetch(`${BASE}/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', // required so the cookie gets stored and sent - body: JSON.stringify({ email, password }) - }); - if (res.ok) { - log(`Login successful (${email})`); - checkSession(); // refresh view with real user data from /me - } else { - const text = await res.text(); - log(`Login failed [${res.status}]: ${text}`, true); - } - } catch (e) { - log(`Connection error: ${e.message}`, true); - } -} - -async function logout() { - try { - const res = await fetch(`${BASE}/logout`, { - method: 'POST', - credentials: 'include' - }); - if (res.ok) { - log('Logged out successfully'); - showView('guestView'); - } else { - log(`Logout failed [${res.status}]`, true); - } - } catch (e) { - log(`Connection error: ${e.message}`, true); - } -} - -// Kick things off as soon as the page loads -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 36982d2..32aaff7 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -1,229 +1,173 @@ -:root { - --bg: #0d1117; - --panel: #161b22; - --border: #30363d; - --text: #e6edf3; - --muted: #8b949e; - --accent: #3fb950; - --accent-dim: #2ea043; - --danger: #f85149; - --mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; -} - -* { box-sizing: border-box; } +*{ + margin:0; + padding:0; + box-sizing:border-box; +} -body { - margin: 0; - min-height: 100vh; - background: var(--bg); - color: var(--text); - font-family: var(--mono); - display: flex; - align-items: center; - justify-content: center; - padding: 24px; -} - -.app { - width: 100%; - max-width: 480px; -} - -.title { - font-size: 13px; - color: var(--muted); - letter-spacing: 0.08em; - text-transform: uppercase; - margin-bottom: 8px; -} - -.title span { color: var(--accent); } +body{ -h1 { - font-size: 22px; - margin: 0 0 24px; - font-weight: 600; -} + font-family:Arial,Helvetica,sans-serif; + background:#f4f7fb; + color:#222; +} -.status { - display: flex; - align-items: center; - gap: 8px; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 14px 16px; - margin-bottom: 20px; - font-size: 13px; -} +header{ -.dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--danger); - flex-shrink: 0; - transition: background 0.2s; -} - -.dot.on { background: var(--accent); } - -.status-text { color: var(--muted); } -.status-text b { color: var(--text); } - -.panel { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 20px; - margin-bottom: 16px; -} + display:flex; + justify-content:space-between; + align-items:center; + + padding:20px 40px; + + background:white; -.panel-label { - font-size: 11px; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.06em; - margin-bottom: 14px; + box-shadow:0 2px 10px rgba(0,0,0,.08); } -label { - display: block; - font-size: 12px; - color: var(--muted); - margin-bottom: 6px; +.logo{ + + font-size:26px; + font-weight:bold; } -input { - width: 100%; - background: #0d1117; - border: 1px solid var(--border); - border-radius: 6px; - color: var(--text); - font-family: var(--mono); - font-size: 13px; - padding: 10px 12px; - margin-bottom: 14px; - outline: none; - transition: border-color 0.15s; +nav{ + + display:flex; + gap:12px; } -input:focus { border-color: var(--accent-dim); } +button{ -.row { display: flex; gap: 10px; } + cursor:pointer; -button { - font-family: var(--mono); - font-size: 13px; - font-weight: 600; - border: none; - border-radius: 6px; - padding: 10px 16px; - cursor: pointer; - transition: opacity 0.15s; -} + border:none; + + border-radius:8px; + + padding:10px 18px; + + background:#4b6fff; -button:hover { opacity: 0.85; } -button:active { opacity: 0.7; } + color:white; -.btn-primary { - background: var(--accent-dim); - color: #0d1117; - flex: 1; + transition:.25s; } -.btn-secondary { - background: transparent; - color: var(--text); - border: 1px solid var(--border); - flex: 1; +button:hover{ + + background:#3157ef; } -.btn-danger { - background: transparent; - color: var(--danger); - border: 1px solid var(--danger); - width: 100%; +.hero{ + + text-align:center; + + margin:40px; } -.log { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 14px 16px; - font-size: 12px; - color: var(--muted); - max-height: 160px; - overflow-y: auto; +.hero h1{ + + font-size:42px; + + margin-bottom:12px; } -.log-label { - font-size: 11px; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.06em; - margin-bottom: 10px; +.search{ + + display:flex; + + justify-content:center; + + gap:10px; + + margin-bottom:25px; } -.log-line { - padding: 4px 0; - border-bottom: 1px solid #21262d; - display: flex; - gap: 8px; +.search input{ + + width:400px; + + padding:12px; + + border-radius:8px; + + border:1px solid #ccc; } -.log-line:last-child { border-bottom: none; } +.upload{ + + display:flex; -.log-line .ok { color: var(--accent); } -.log-line .err { color: var(--danger); } -.log-line .time { color: #484f58; flex-shrink: 0; } + justify-content:center; -.hidden { display: none; } + gap:15px; -.center-text { - text-align: center; - color: var(--muted); + margin-bottom:35px; } -.field { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 0; - border-bottom: 1px solid #21262d; +.gallery{ + + display:grid; + + grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); + + gap:25px; + + padding:30px; } -.field:last-child { border-bottom: none; } +.card{ -.field-label { - font-size: 12px; - color: var(--muted); + background:white; + + border-radius:12px; + + overflow:hidden; + + box-shadow:0 8px 20px rgba(0,0,0,.08); + + transition:.2s; } -.field-value { - font-size: 13px; - color: var(--text); - font-weight: 600; +.card:hover{ + + transform:translateY(-4px); } -.mono-small { - font-size: 11px; - font-weight: 400; - color: var(--muted); +.card img{ + + width:100%; + + height:220px; + + object-fit:cover; } -input[type="file"] { - padding: 8px; - font-size: 12px; +.content{ + + padding:18px; } -#uploadPreview { - margin-top: 14px; +.tags{ + + display:flex; + + flex-wrap:wrap; + + gap:8px; + + margin-top:10px; } -#uploadPreview img { - width: 100%; - border-radius: 6px; - border: 1px solid var(--border); - display: block; +.tag{ + + background:#edf2ff; + + color:#3157ef; + + border-radius:20px; + + padding:5px 12px; + + font-size:13px; } \ No newline at end of file