diff --git a/.env.example b/.env.example index 67b3049..a8e355c 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,12 @@ DB_PASSWORD=your-password # Server Configuration SERVER_PORT=8080 + +# JWT Configuration +JWT_SECRET=secret_key +JWT_EXPIRATION= +REFRESH_TOKEN_EXPIRATION= + +# Supabase Configurations +SUPABASE_URL= +SUPABASE_SERVICE_KEY= diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e1021dc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,55 @@ +version: 2 + +updates: + # Maven / Spring Boot dependencies + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: "weekly" + day: "sunday" + time: "09:00" + open-pull-requests-limit: 10 + rebase-strategy: "auto" + + # Group updates to reduce noise in dev + groups: + spring: + patterns: + - "org.springframework*" + - "org.springframework.boot*" + testing: + patterns: + - "org.junit*" + - "org.mockito*" + - "org.assertj*" + - "org.testcontainers*" + build-plugins: + patterns: + - "org.apache.maven.plugins*" + - "io.spring.javaformat*" + - "com.diffplug.spotless*" + - "org.sonarsource.scanner.maven*" + misc: + patterns: + - "*" + + # Avoid risky breaking changes automatically (you upgrade these intentionally) + ignore: + - dependency-name: "org.springframework.boot" + update-types: ["version-update:semver-major"] + - dependency-name: "org.springframework" + update-types: ["version-update:semver-major"] + - dependency-name: "org.springframework.security" + update-types: ["version-update:semver-major"] + - dependency-name: "org.hibernate.orm" + update-types: ["version-update:semver-major"] + + # GitHub Actions used in workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "sunday" + time: "09:00" + open-pull-requests-limit: 5 + rebase-strategy: "auto" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..ae6f2c3 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,57 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "33 19 * * 4" + +jobs: + analyze: + name: Analyze (Java 21) + runs-on: ubuntu-latest + + permissions: + security-events: write + actions: read + contents: read + packages: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: java-kotlin + build-mode: manual + queries: security-extended,security-and-quality + + # Maven (Spring Initializr usually uses Maven) + - name: Build with Maven + if: hashFiles('pom.xml') != '' + run: | + chmod +x mvnw || true + ./mvnw -B -DskipTests clean package || mvn -B -DskipTests clean package + + # Gradle (only runs if Gradle files exist) + - name: Build with Gradle + if: hashFiles('build.gradle', 'build.gradle.kts') != '' + run: | + chmod +x gradlew || true + ./gradlew build -x test + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:java-kotlin" diff --git a/database/facial_recognition_tables.sql b/database/facial_recognition_tables.sql new file mode 100644 index 0000000..aa7142b --- /dev/null +++ b/database/facial_recognition_tables.sql @@ -0,0 +1,267 @@ +-- ==================================================================== +-- Facial Recognition System - Database Schema +-- ==================================================================== +-- This schema supports the facial recognition feature for CrimeLinkAnalyzer +-- Created: December 11, 2025 +-- ==================================================================== + +-- Enable required PostgreSQL extension for EXCLUDE constraints +-- The btree_gist extension is required for using EXCLUDE constraints with equality operators +-- This allows us to enforce "one primary photo per criminal" at the database level +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- Drop existing tables if they exist (for clean setup) +DROP TABLE IF EXISTS facial_recognition_logs CASCADE; +DROP TABLE IF EXISTS suspect_photos CASCADE; +DROP TABLE IF EXISTS criminals CASCADE; + +-- ==================================================================== +-- CRIMINALS TABLE +-- ==================================================================== +-- Stores criminal records with biometric data +CREATE TABLE criminals ( + criminal_id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + nic VARCHAR(20) UNIQUE, + alias VARCHAR(255), + date_of_birth DATE, + gender VARCHAR(10) CHECK (gender IN ('Male', 'Female', 'Other')), + address TEXT, + nationality VARCHAR(100) DEFAULT 'Sri Lankan', + + -- Crime information stored as JSONB for flexibility + crime_history JSONB DEFAULT '[]'::jsonb, + + -- Primary photo reference + primary_photo_url VARCHAR(500), + + -- Face embedding - stored as BYTEA (binary) + -- This is the average embedding from all photos + face_embedding BYTEA, + embedding_model VARCHAR(50) DEFAULT 'buffalo_sc', + embedding_dimension INTEGER DEFAULT 512, + + -- Status tracking + status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'archived')), + risk_level VARCHAR(20) DEFAULT 'medium' CHECK (risk_level IN ('low', 'medium', 'high', 'critical')), + + -- Audit fields + created_by INTEGER, -- User ID who created this record + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Metadata + notes TEXT, + last_seen_location VARCHAR(255), + last_seen_date DATE +); + +-- ==================================================================== +-- SUSPECT_PHOTOS TABLE +-- ==================================================================== +-- Stores multiple photos per criminal for better accuracy +CREATE TABLE suspect_photos ( + photo_id SERIAL PRIMARY KEY, + criminal_id INTEGER NOT NULL REFERENCES criminals(criminal_id) ON DELETE CASCADE, + + -- Photo storage + photo_url VARCHAR(500) NOT NULL, + photo_hash VARCHAR(64) UNIQUE, -- SHA-256 hash to prevent duplicates + file_size_bytes INTEGER, + + -- Face detection metadata + face_embedding BYTEA NOT NULL, -- Individual photo embedding + face_confidence DECIMAL(5,2), -- Detection confidence (0-100) + face_bbox JSONB, -- Bounding box coordinates {x, y, width, height} + + -- Photo metadata + is_primary BOOLEAN DEFAULT FALSE, + photo_quality VARCHAR(20) CHECK (photo_quality IN ('low', 'medium', 'high', 'excellent')), + image_width INTEGER, + image_height INTEGER, + + -- Source tracking + source VARCHAR(100), -- e.g., 'manual_upload', 'cctv', 'arrest_record' + source_date DATE, + + -- Audit + uploaded_by INTEGER, -- User ID + uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Constraints + CONSTRAINT only_one_primary_per_criminal + EXCLUDE USING gist (criminal_id WITH =) + WHERE (is_primary = true) +); + +-- ==================================================================== +-- FACIAL_RECOGNITION_LOGS TABLE +-- ==================================================================== +-- Audit trail for all facial recognition requests +CREATE TABLE facial_recognition_logs ( + log_id SERIAL PRIMARY KEY, + + -- Request details + analysis_type VARCHAR(50) DEFAULT 'suspect_match', -- 'suspect_match', 'criminal_registration' + uploaded_image_url VARCHAR(500), + uploaded_image_hash VARCHAR(64), + + -- Face detection results + face_detected BOOLEAN DEFAULT FALSE, + face_count INTEGER DEFAULT 0, + face_quality VARCHAR(20), + + -- Matching results + matches_found INTEGER DEFAULT 0, + best_match_criminal_id INTEGER REFERENCES criminals(criminal_id), + best_match_similarity DECIMAL(5,2), -- Percentage (0-100) + match_threshold DECIMAL(5,2) DEFAULT 75.00, + + -- All matches stored as JSONB for detailed analysis + all_matches JSONB DEFAULT '[]'::jsonb, + + -- Performance metrics + processing_time_ms INTEGER, + model_version VARCHAR(50), + + -- Security & Audit + requested_by INTEGER, -- User ID + user_role VARCHAR(50), + ip_address INET, + user_agent TEXT, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Investigation reference + case_id VARCHAR(100), + investigation_notes TEXT +); + +-- ==================================================================== +-- INDEXES FOR PERFORMANCE +-- ==================================================================== + +-- Criminal table indexes +CREATE INDEX idx_criminal_nic ON criminals(nic); +CREATE INDEX idx_criminal_name ON criminals USING gin(to_tsvector('english', name)); +CREATE INDEX idx_criminal_status ON criminals(status) WHERE status = 'active'; +CREATE INDEX idx_criminal_risk_level ON criminals(risk_level); +CREATE INDEX idx_criminal_created_at ON criminals(created_at DESC); + +-- Suspect photos indexes +CREATE INDEX idx_suspect_photos_criminal_id ON suspect_photos(criminal_id); +CREATE INDEX idx_suspect_photos_primary ON suspect_photos(criminal_id, is_primary) WHERE is_primary = true; +CREATE INDEX idx_suspect_photos_hash ON suspect_photos(photo_hash); + +-- Facial recognition logs indexes +CREATE INDEX idx_fr_logs_created_at ON facial_recognition_logs(created_at DESC); +CREATE INDEX idx_fr_logs_user ON facial_recognition_logs(requested_by); +CREATE INDEX idx_fr_logs_best_match ON facial_recognition_logs(best_match_criminal_id) WHERE best_match_criminal_id IS NOT NULL; +CREATE INDEX idx_fr_logs_case_id ON facial_recognition_logs(case_id) WHERE case_id IS NOT NULL; + +-- ==================================================================== +-- TRIGGERS FOR AUTO-UPDATE +-- ==================================================================== + +-- Automatically update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_criminals_updated_at + BEFORE UPDATE ON criminals + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ==================================================================== +-- UTILITY FUNCTIONS +-- ==================================================================== + +-- Function to calculate average embedding from multiple photos +CREATE OR REPLACE FUNCTION calculate_average_embedding(p_criminal_id INTEGER) +RETURNS BYTEA AS $$ +DECLARE + avg_embedding BYTEA; +BEGIN + -- This will be called from Python after uploading multiple photos + -- Python will handle the actual embedding averaging logic + -- This function is a placeholder for future stored procedure implementation + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Function to search similar faces (placeholder - actual search done in Python) +CREATE OR REPLACE FUNCTION search_similar_faces( + p_embedding BYTEA, + p_threshold DECIMAL DEFAULT 0.75, + p_limit INTEGER DEFAULT 10 +) +RETURNS TABLE ( + criminal_id INTEGER, + name VARCHAR, + similarity DECIMAL +) AS $$ +BEGIN + -- Actual similarity search is performed in Python using numpy + -- This is a placeholder for documentation + RETURN QUERY SELECT NULL::INTEGER, NULL::VARCHAR, NULL::DECIMAL LIMIT 0; +END; +$$ LANGUAGE plpgsql; + +-- ==================================================================== +-- INITIAL DATA / SEED DATA +-- ==================================================================== + +-- Insert sample criminal record for testing +INSERT INTO criminals ( + name, + nic, + date_of_birth, + gender, + crime_history, + status, + risk_level, + notes +) VALUES ( + 'Test Suspect One', + '199012345678', + '1990-05-15', + 'Male', + '[{"crime_type": "Theft", "date": "2023-03-10", "status": "Convicted", "sentence": "2 years"}]'::jsonb, + 'active', + 'medium', + 'Sample criminal record for testing facial recognition system' +); + +-- ==================================================================== +-- PERMISSIONS & SECURITY +-- ==================================================================== + +-- Grant appropriate permissions (adjust based on your user roles) +-- GRANT SELECT, INSERT, UPDATE ON criminals TO crimelink_app_user; +-- GRANT SELECT, INSERT ON suspect_photos TO crimelink_app_user; +-- GRANT INSERT ON facial_recognition_logs TO crimelink_app_user; + +-- ==================================================================== +-- COMMENTS FOR DOCUMENTATION +-- ==================================================================== + +COMMENT ON TABLE criminals IS 'Stores criminal records with biometric face embeddings for facial recognition'; +COMMENT ON COLUMN criminals.face_embedding IS 'Average face embedding vector stored as binary data (512-dimensional float32 array)'; +COMMENT ON COLUMN criminals.crime_history IS 'JSON array of crime records: [{crime_type, date, status, sentence}]'; + +COMMENT ON TABLE suspect_photos IS 'Multiple photos per criminal for improved recognition accuracy'; +COMMENT ON COLUMN suspect_photos.face_embedding IS 'Individual face embedding for this specific photo'; +COMMENT ON COLUMN suspect_photos.photo_hash IS 'SHA-256 hash to prevent duplicate photo uploads'; + +COMMENT ON TABLE facial_recognition_logs IS 'Audit trail for all facial recognition analysis requests'; +COMMENT ON COLUMN facial_recognition_logs.all_matches IS 'JSON array of all matches: [{criminal_id, similarity, confidence}]'; + +-- ==================================================================== +-- END OF SCHEMA +-- ==================================================================== diff --git a/pom.xml b/pom.xml index 4451b0c..0d25261 100644 --- a/pom.xml +++ b/pom.xml @@ -1,161 +1,185 @@ - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 3.5.8 - - - com.crimeLink - analyzer - 0.0.1-SNAPSHOT - analyzer - Demo project for Spring Boot - - - - - - - - - - - - - - - 21 - - - - org.springframework.boot - spring-boot-starter-web - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - - com.h2database - h2 - runtime - - - - org.postgresql - postgresql - runtime - - - - me.paulschwarz - spring-dotenv - 4.0.0 - - - - org.springframework.boot - spring-boot-starter-security - - - - io.jsonwebtoken - jjwt-api - 0.12.5 - - - - org.springframework.boot - spring-boot-starter-validation - - - - io.jsonwebtoken - jjwt-impl - 0.12.5 - runtime - - - - io.jsonwebtoken - jjwt-jackson - 0.12.5 - runtime - - - - org.springframework.boot - spring-boot-devtools - runtime - true - - - - org.springframework.boot - spring-boot-configuration-processor - true - - - - org.projectlombok - lombok - true - - - - org.springframework.boot - spring-boot-starter-test - test - - - - com.github.librepdf - openpdf - 1.3.40 - - - - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - org.projectlombok - lombok - - - - - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.projectlombok - lombok - 1.18.34 - - - org.springframework.boot - spring-boot-configuration-processor - 3.5.8 - - - - - - + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.8 + + + com.crimeLink + analyzer + 0.0.1-SNAPSHOT + analyzer + Demo project for Spring Boot + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + com.h2database + h2 + runtime + + + + org.postgresql + postgresql + runtime + + + + me.paulschwarz + spring-dotenv + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-security + + + + io.jsonwebtoken + jjwt-api + 0.12.5 + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-json + + + + io.jsonwebtoken + jjwt-impl + 0.12.5 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.12.5 + runtime + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.github.librepdf + openpdf + 1.3.40 + + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.34 + + + org.springframework.boot + spring-boot-configuration-processor + 3.5.8 + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.5.0 + + + add-source + generate-test-sources + + add-test-source + + + + src/main/java + + + + + + + diff --git a/src/main/java/com/crimeLink/analyzer/config/JwtAuthenticationFilter.java b/src/main/java/com/crimeLink/analyzer/config/JwtAuthenticationFilter.java index 006703e..162ee8e 100644 --- a/src/main/java/com/crimeLink/analyzer/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/crimeLink/analyzer/config/JwtAuthenticationFilter.java @@ -1,10 +1,7 @@ package com.crimeLink.analyzer.config; -import com.crimeLink.analyzer.service.JwtService; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -15,7 +12,12 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import java.io.IOException; +import com.crimeLink.analyzer.service.JwtService; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @@ -30,8 +32,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal( @NonNull HttpServletRequest request, @NonNull HttpServletResponse response, - @NonNull FilterChain filterChain - ) throws ServletException, IOException { + @NonNull FilterChain filterChain) throws ServletException, IOException { // ✅ Allow preflight if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { @@ -40,10 +41,12 @@ protected void doFilterInternal( } String path = request.getServletPath(); + System.out.println("🔍 JwtAuthFilter - Path: " + path); // ✅ Public endpoints (do not try to parse JWT) - if (path.startsWith("/api/auth") - || path.startsWith("/api/mobile/auth") + if (path.startsWith("/api/auth/login") + || path.startsWith("/api/auth/refresh") + || path.startsWith("/api/mobile/auth/login") || path.startsWith("/api/health") || path.startsWith("/api/duties") || path.startsWith("/api/leaves")){ @@ -52,9 +55,12 @@ protected void doFilterInternal( } final String authHeader = request.getHeader("Authorization"); + System.out.println("🔍 Auth Header: " + + (authHeader != null ? authHeader.substring(0, Math.min(20, authHeader.length())) + "..." : "NULL")); // ✅ No token -> continue (SecurityConfig will decide permit/deny) if (authHeader == null || !authHeader.startsWith("Bearer ")) { + System.out.println("❌ No Bearer token found"); filterChain.doFilter(request, response); return; } @@ -67,20 +73,26 @@ protected void doFilterInternal( UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); if (jwtService.isTokenValid(jwt, userDetails)) { - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities()); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); + + // 🔍 DEBUG: Log authentication success + System.out.println("✅ JWT Auth Success: " + userEmail); + System.out.println(" Authorities: " + userDetails.getAuthorities()); + System.out.println(" Accessing: " + path); + } else { + System.out.println("❌ JWT Invalid for user: " + userEmail); } } } catch (Exception ex) { // ✅ DO NOT block request just because token is bad // Let SecurityConfig handle authorization + System.out.println("⚠️ JWT parsing error: " + ex.getMessage()); } filterChain.doFilter(request, response); diff --git a/src/main/java/com/crimeLink/analyzer/config/RestTemplateConfig.java b/src/main/java/com/crimeLink/analyzer/config/RestTemplateConfig.java new file mode 100644 index 0000000..0c21599 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/config/RestTemplateConfig.java @@ -0,0 +1,23 @@ +package com.crimeLink.analyzer.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +/** + * Configuration for RestTemplate bean used to communicate with ML microservices. + * Part of the hybrid monolith + microservices architecture. + */ +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + // Set timeout for ML service calls (30 seconds for heavy processing) + factory.setConnectTimeout(10000); // 10 seconds connection timeout + factory.setReadTimeout(30000); // 30 seconds read timeout (ML processing can be slow) + return new RestTemplate(factory); + } +} diff --git a/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java b/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java index 7940fb1..41191fe 100644 --- a/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java +++ b/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java @@ -1,9 +1,13 @@ package com.crimeLink.analyzer.config; +import java.util.Arrays; +import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; @@ -16,14 +20,12 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Arrays; -import java.util.List; - @Configuration @EnableWebSecurity @EnableMethodSecurity @@ -41,34 +43,54 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(csrf -> csrf.disable()) // CRITICAL FIX: Enable CORS using the bean configuration .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .cors(cors -> cors.configure(http)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/health").permitAll() .requestMatchers("/api/admin/health").permitAll() + .requestMatchers("/api/facial/health").permitAll() // ML service health check + .requestMatchers("/api/call-analysis/health").permitAll() // ML service health check + + // ML Service endpoints - Investigator role only + .requestMatchers("/api/call-analysis/**").hasRole("Investigator") + .requestMatchers("/api/facial/**").hasRole("Investigator") + .requestMatchers("/api/database/**").permitAll() - .requestMatchers("/api/vehicles/**").permitAll() - .requestMatchers("/api/mobile/auth/**").permitAll() - .requestMatchers("/api/duty-schedules/**").hasRole("OIC") - .requestMatchers("/api/mobile/**").hasRole("FieldOfficer") .requestMatchers("/api/test").permitAll() - .requestMatchers("/api/leaves/**").permitAll() + .requestMatchers("/api/debug/**").permitAll() // 🔍 Debug endpoints + .requestMatchers("/error").permitAll() // Allow error page without auth - // Allow duty schedule operations for OIC - .requestMatchers("/api/duty-schedules/**").hasRole("OIC") + // Public endpoints + .requestMatchers("/api/vehicle**").permitAll() + .requestMatchers("/api/mobile/auth/**").permitAll() + .requestMatchers("/api/duties/**").permitAll() + .requestMatchers("/api/crime-reports/map").permitAll() + .requestMatchers(HttpMethod.GET, "/api/crime-reports").permitAll() + .requestMatchers("/api/crime-reports/upload-evidence").authenticated() + .requestMatchers("/api/crime-reports/**").hasAnyRole("OIC", "Admin") + // Field Officer routes + .requestMatchers("/api/officers/me/**").hasRole("FieldOfficer") + .requestMatchers("/api/mobile/**").hasRole("FieldOfficer") + .requestMatchers("/api/leaves/**").permitAll() - // Allow duty schedule operations for OIC + // OIC-only routes .requestMatchers("/api/duty-schedules/**").hasRole("OIC") - - // Allow weapon operations for OIC .requestMatchers("/api/weapon/**").hasRole("OIC") .requestMatchers("/api/weapon-issue/**").hasRole("OIC") - .requestMatchers("/api/duties/**").permitAll() - .requestMatchers("/duties/**").permitAll() + + // Admin/OIC routes (officer data, locations, users) + .requestMatchers("/api/users/field-officers").hasAnyRole("Admin", "OIC") + .requestMatchers("/api/admin/**").hasAnyRole("OIC", "Admin") .anyRequest().authenticated()) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType("application/json"); + response.getWriter().write("{\"message\":\"Access denied\"}"); + })) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authenticationProvider(authenticationProvider()) diff --git a/src/main/java/com/crimeLink/analyzer/controller/AuthController.java b/src/main/java/com/crimeLink/analyzer/controller/AuthController.java index 2a5be49..4741a85 100644 --- a/src/main/java/com/crimeLink/analyzer/controller/AuthController.java +++ b/src/main/java/com/crimeLink/analyzer/controller/AuthController.java @@ -44,25 +44,34 @@ public ResponseEntity login( public ResponseEntity refreshToken(@RequestBody TokenRefreshRequest request) { String refreshTokenStr = request.getRefreshToken(); - return refreshTokenService.findByToken(refreshTokenStr) - .map(refreshTokenService::verifyExpiration) - .map(RefreshToken::getUser) - .map(user -> { + if (refreshTokenStr == null || refreshTokenStr.isBlank()) { + return ResponseEntity.badRequest().body(new TokenRefreshResponse( + false, + "Refresh token is required", + null, + null + )); + } + + return refreshTokenService.findValidToken(refreshTokenStr) + .map(validToken -> { + RefreshToken rotated = refreshTokenService.rotateRefreshToken(validToken); + User user = rotated.getUser(); String accessToken = jwtService.generateToken(user); + return ResponseEntity.ok(new TokenRefreshResponse( true, "Token refreshed successfully", accessToken, - refreshTokenStr + rotated.getToken() )); }) - .orElseGet(() -> ResponseEntity.status(401) - .body(new TokenRefreshResponse( - false, - "Invalid refresh token", - null, - null - ))); + .orElseGet(() -> ResponseEntity.status(401).body(new TokenRefreshResponse( + false, + "Invalid or expired refresh token", + null, + null + ))); } @PostMapping("/logout") diff --git a/src/main/java/com/crimeLink/analyzer/controller/CallAnalysisController.java b/src/main/java/com/crimeLink/analyzer/controller/CallAnalysisController.java index d625885..f5865f4 100644 --- a/src/main/java/com/crimeLink/analyzer/controller/CallAnalysisController.java +++ b/src/main/java/com/crimeLink/analyzer/controller/CallAnalysisController.java @@ -1,114 +1,164 @@ package com.crimeLink.analyzer.controller; -import com.crimeLink.analyzer.dto.CallAnalysisResultDTO; import com.crimeLink.analyzer.service.CallAnalysisService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; +import com.crimeLink.analyzer.util.LogSanitizer; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.util.HashMap; +import java.util.Arrays; +import java.util.List; import java.util.Map; +/** + * REST Controller for Call Analysis operations. + * Acts as API Gateway layer, routing requests to the Python ML microservice. + * + * Architecture Pattern: Hybrid Monolith + Microservices + * - Spring Boot handles authentication, authorization, and request routing + * - Python FastAPI handles ML inference (call record analysis, NLP) + * + * Endpoints: + * - POST /api/call-analysis/analyze - Analyze single call record PDF + * - POST /api/call-analysis/analyze/batch - Analyze multiple call record PDFs + * - GET /api/call-analysis/health - Check ML service health + */ @RestController -@RequestMapping("/api/investigator/call-analysis") -@PreAuthorize("hasAnyRole('Investigator', 'OIC', 'Admin')") +@RequestMapping("/api/call-analysis") +@RequiredArgsConstructor +@Slf4j public class CallAnalysisController { - @Autowired - private CallAnalysisService callAnalysisService; + private final CallAnalysisService callAnalysisService; /** - * Upload PDF file for call record analysis + * Analyze a single call record PDF. + * * @param file PDF file containing call records - * @return Analysis ID for tracking results + * @return Analysis results with crime indicators, entity graph, etc. */ - @PostMapping("/upload") - public ResponseEntity> uploadCallRecords(@RequestParam("file") MultipartFile file) { + @PostMapping("/analyze") + public ResponseEntity analyzeCallRecord( + @RequestParam("file") MultipartFile file) { + try { - // Validate file - if (file.isEmpty()) { - Map error = new HashMap<>(); - error.put("error", "No file provided"); - return ResponseEntity.badRequest().body(error); - } + log.info("Call record analysis requested: {}", LogSanitizer.sanitize(file.getOriginalFilename())); - if (!file.getOriginalFilename().toLowerCase().endsWith(".pdf")) { - Map error = new HashMap<>(); - error.put("error", "Only PDF files are supported"); - return ResponseEntity.badRequest().body(error); + // Validate file + ResponseEntity validationError = validatePdfFile(file, 10 * 1024 * 1024); // 10MB + if (validationError != null) { + return validationError; } - // Send to Python service - String analysisId = callAnalysisService.analyzeCallRecords(file); - - Map response = new HashMap<>(); - response.put("analysis_id", analysisId); - response.put("status", "processing"); - response.put("message", "Analysis started successfully"); - - return ResponseEntity.ok(response); + // Forward to ML service + JsonNode result = callAnalysisService.analyzeCallRecord(file); + + return ResponseEntity.ok(result); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("error", "Failed to process file: " + e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } catch (RuntimeException e) { + log.error("Call record analysis failed: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); } } /** - * Get analysis results by ID - * @param analysisId Analysis ID returned from upload - * @return Complete analysis results including network graph and criminal matches + * Analyze multiple call record PDFs in batch. + * + * @param files Array of PDF files containing call records + * @return Batch analysis results */ - @GetMapping("/results/{analysisId}") - public ResponseEntity getAnalysisResults(@PathVariable String analysisId) { + @PostMapping("/analyze/batch") + public ResponseEntity analyzeBatch( + @RequestParam("files") MultipartFile[] files) { + try { - CallAnalysisResultDTO result = callAnalysisService.getAnalysisResults(analysisId); + log.info("Batch call analysis requested: {} files", files.length); + + // Validate files + if (files.length == 0) { + return ResponseEntity.badRequest() + .body(Map.of("error", "No files provided")); + } - if (result == null) { - return ResponseEntity.notFound().build(); + for (MultipartFile file : files) { + ResponseEntity validationError = validatePdfFile(file, 10 * 1024 * 1024); // 10MB + if (validationError != null) { + return validationError; + } } + // Forward to ML service + List fileList = Arrays.asList(files); + JsonNode result = callAnalysisService.analyzeBatch(fileList); + return ResponseEntity.ok(result); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } catch (RuntimeException e) { + log.error("Batch call analysis failed: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); } } /** - * Get all analysis history - * @return List of all analyses + * Health check endpoint for the call analysis ML service. + * Public endpoint for monitoring. + * + * @return Health status */ - @GetMapping("/history") - public ResponseEntity> getAnalysisHistory() { - try { - Map history = callAnalysisService.getAllAnalyses(); - return ResponseEntity.ok(history); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("error", "Failed to retrieve history: " + e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + @GetMapping("/health") + public ResponseEntity checkHealth() { + JsonNode health = callAnalysisService.checkHealth(); + + String status = health.has("status") ? health.get("status").asText() : "unknown"; + + if ("healthy".equals(status) || "ok".equals(status)) { + return ResponseEntity.ok(health); + } else { + return ResponseEntity.status(503).body(health); } } /** - * Check Python service health - * @return Health status of call analysis service + * Validate PDF file for call analysis. + * Checks: file not empty, content type is PDF, file size within limit. + * + * @param file The file to validate + * @param maxSizeBytes Maximum allowed file size in bytes + * @return ResponseEntity with error if validation fails, null if valid */ - @GetMapping("/health") - public ResponseEntity> checkServiceHealth() { - try { - Map health = callAnalysisService.checkPythonServiceHealth(); - return ResponseEntity.ok(health); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("status", "unhealthy"); - error.put("error", e.getMessage()); - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error); + private ResponseEntity validatePdfFile(MultipartFile file, long maxSizeBytes) { + if (file == null || file.isEmpty()) { + log.warn("Validation failed: Empty file"); + return ResponseEntity.badRequest() + .body(Map.of("error", "No file provided")); } + + String contentType = file.getContentType(); + if (contentType == null || !contentType.equals("application/pdf")) { + String safeContentType = contentType == null ? "null" : LogSanitizer.sanitize(contentType); + String safeFilename = LogSanitizer.sanitize(file.getOriginalFilename()); + log.warn("Validation failed: Invalid content type '{}' for file '{}'", + safeContentType, safeFilename); + return ResponseEntity.badRequest() + .body(Map.of("error", "Invalid file type: " + safeFilename + + ". Only PDF files are allowed.")); + } + + long fileSize = file.getSize(); + if (fileSize > maxSizeBytes) { + String safeFilename = LogSanitizer.sanitize(file.getOriginalFilename()); + log.warn("Validation failed: File size {} exceeds limit {} for file '{}'", + fileSize, maxSizeBytes, safeFilename); + return ResponseEntity.badRequest() + .body(Map.of("error", "File size exceeds maximum limit of " + + (maxSizeBytes / (1024 * 1024)) + "MB: " + safeFilename)); + } + + return null; // Validation passed } } diff --git a/src/main/java/com/crimeLink/analyzer/controller/CrimeReportController.java b/src/main/java/com/crimeLink/analyzer/controller/CrimeReportController.java index 48a7fc5..ee18472 100644 --- a/src/main/java/com/crimeLink/analyzer/controller/CrimeReportController.java +++ b/src/main/java/com/crimeLink/analyzer/controller/CrimeReportController.java @@ -10,11 +10,15 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import com.crimeLink.analyzer.dto.CrimeLocationDTO; import com.crimeLink.analyzer.dto.CrimeReportDTO; +import com.crimeLink.analyzer.dto.EvidenceDTO; import com.crimeLink.analyzer.service.CrimeReportService; +import com.crimeLink.analyzer.service.SupabaseService; import lombok.AllArgsConstructor; @@ -25,6 +29,7 @@ public class CrimeReportController { private final CrimeReportService crimeReportService; + private final SupabaseService supabaseService; @PostMapping public ResponseEntity saveCrimeReport(@RequestBody CrimeReportDTO crimeReportDTO) { @@ -48,4 +53,23 @@ public ResponseEntity getCrimeReportById(@PathVariable("id") Lon public List getCrimeMapLocations() { return crimeReportService.getCrimeMapLocations(); } + + @PostMapping("/upload-evidence") + public ResponseEntity uploadEvidence(@RequestParam("file") MultipartFile file) throws Exception { + + String fileUrl = supabaseService.uploadFile(file); + return ResponseEntity.ok(fileUrl); + } + + @GetMapping("/download/{reportId}") + public ResponseEntity> downloadEvidence(@PathVariable Long reportId) { + CrimeReportDTO report = crimeReportService.getCrimeReportById(reportId); + + if (report.getEvidences() == null || report.getEvidences().isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + return ResponseEntity.ok(report.getEvidences()); + } + } diff --git a/src/main/java/com/crimeLink/analyzer/controller/DutyScheduleController.java b/src/main/java/com/crimeLink/analyzer/controller/DutyScheduleController.java index 40519fa..7135074 100644 --- a/src/main/java/com/crimeLink/analyzer/controller/DutyScheduleController.java +++ b/src/main/java/com/crimeLink/analyzer/controller/DutyScheduleController.java @@ -34,6 +34,10 @@ public ResponseEntity> getOfficersForDate( List rows = dutyService.getOfficerRowsForDate(date); return ResponseEntity.ok(rows); } + @GetMapping("/locations") + public ResponseEntity> getDutyLocations() { + return ResponseEntity.ok(dutyService.getDutyLocations()); + } // 2) Create / Save a duty (upsert via service.saveDuty) @PostMapping public ResponseEntity createDuty(@RequestBody DutyScheduleRequest request) { diff --git a/src/main/java/com/crimeLink/analyzer/controller/FacialRecognitionController.java b/src/main/java/com/crimeLink/analyzer/controller/FacialRecognitionController.java new file mode 100644 index 0000000..d8438fe --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/controller/FacialRecognitionController.java @@ -0,0 +1,253 @@ +package com.crimeLink.analyzer.controller; + +import com.crimeLink.analyzer.service.FacialRecognitionService; +import com.crimeLink.analyzer.util.LogSanitizer; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +/** + * REST Controller for Facial Recognition operations. + * Acts as API Gateway layer, routing requests to the Python ML microservice. + * + * Architecture Pattern: Hybrid Monolith + Microservices + * - Spring Boot handles authentication, authorization, and request routing + * - Python FastAPI handles ML inference (facial recognition) + * + * Endpoints: + * - POST /api/facial/analyze - Analyze suspect image for matches + * - POST /api/facial/register - Register new criminal face + * - GET /api/facial/criminals - List registered criminals + * - GET /api/facial/history - Get recognition history + * - GET /api/facial/health - Check ML service health + */ +@RestController +@RequestMapping("/api/facial") +@RequiredArgsConstructor +@Slf4j +public class FacialRecognitionController { + + private final FacialRecognitionService facialRecognitionService; + + /** + * Analyze a suspect image for facial recognition matches. + * Requires authentication - user ID is extracted from JWT token. + * + * @param image The image file to analyze (multipart) + * @param threshold Similarity threshold (0-100), default 45 + * @param caseId Optional case ID for linking to investigation + * @return Analysis results with matched criminals + */ + @PostMapping("/analyze") + public ResponseEntity analyzeImage( + @RequestParam("image") MultipartFile image, + @RequestParam(value = "threshold", required = false, defaultValue = "45") Float threshold, + @RequestParam(value = "case_id", required = false) String caseId) { + + try { + // Get authenticated user ID from security context + String userId = getCurrentUserId(); + log.info("Facial recognition analysis requested by user: {}", userId); + + // Validate image + ResponseEntity validationError = validateImageFile(image, 10 * 1024 * 1024); // 10MB + if (validationError != null) { + return validationError; + } + + // Validate threshold range + if (threshold != null && (threshold < 0 || threshold > 100)) { + log.warn("Validation failed: Threshold {} out of range [0-100]", threshold); + return ResponseEntity.badRequest() + .body(Map.of("error", "Threshold must be between 0 and 100")); + } + + // Forward to ML service + JsonNode result = facialRecognitionService.analyzeImage(image, threshold, userId, caseId); + + return ResponseEntity.ok(result); + + } catch (RuntimeException e) { + log.error("Facial recognition analysis failed: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); + } + } + + /** + * Register a new criminal with their photo for facial recognition. + * Requires authentication. + * + * @param photo Photo of the criminal + * @param criminalId Optional existing criminal ID to link + * @param name Criminal's name + * @param nic National ID Card number + * @param riskLevel Risk level (high, medium, low) + * @return Registration result with criminal details + */ + @PostMapping("/register") + public ResponseEntity registerCriminal( + @RequestParam("photo") MultipartFile photo, + @RequestParam(value = "criminal_id", required = false) String criminalId, + @RequestParam("name") String name, + @RequestParam("nic") String nic, + @RequestParam(value = "risk_level", required = false, defaultValue = "medium") String riskLevel) { + + try { + log.info("Criminal registration requested: {} ({})", LogSanitizer.sanitize(name), LogSanitizer.sanitize(nic)); + + // Validate required text fields + ResponseEntity nameValidation = validateRequiredText(name, "name"); + if (nameValidation != null) { + return nameValidation; + } + + ResponseEntity nicValidation = validateRequiredText(nic, "nic"); + if (nicValidation != null) { + return nicValidation; + } + + // Validate photo + ResponseEntity photoValidation = validateImageFile(photo, 10 * 1024 * 1024); // 10MB + if (photoValidation != null) { + return photoValidation; + } + + // Forward to ML service + JsonNode result = facialRecognitionService.registerCriminal( + photo, criminalId, name, nic, riskLevel); + + return ResponseEntity.ok(result); + + } catch (RuntimeException e) { + log.error("Criminal registration failed: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); + } + } + + /** + * Get list of all registered criminals with face embeddings. + * + * @return List of criminals + */ + @GetMapping("/criminals") + public ResponseEntity getCriminals() { + try { + JsonNode result = facialRecognitionService.getCriminals(); + return ResponseEntity.ok(result); + } catch (RuntimeException e) { + log.error("Failed to fetch criminals: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); + } + } + + /** + * Get facial recognition history/audit logs. + * + * @param limit Maximum number of records (default 50) + * @return Recognition history + */ + @GetMapping("/history") + public ResponseEntity getRecognitionHistory( + @RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) { + try { + JsonNode result = facialRecognitionService.getRecognitionHistory(limit); + return ResponseEntity.ok(result); + } catch (RuntimeException e) { + log.error("Failed to fetch history: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); + } + } + + /** + * Health check endpoint for the facial recognition ML service. + * Public endpoint for monitoring. + * + * @return Health status + */ + @GetMapping("/health") + public ResponseEntity checkHealth() { + JsonNode health = facialRecognitionService.checkHealth(); + + String status = health.has("status") ? health.get("status").asText() : "unknown"; + + if ("healthy".equals(status)) { + return ResponseEntity.ok(health); + } else { + return ResponseEntity.status(503).body(health); + } + } + + /** + * Validate image file for facial recognition. + * Checks: file not empty, content type is image/*, file size within limit. + * + * @param file The file to validate + * @param maxSizeBytes Maximum allowed file size in bytes + * @return ResponseEntity with error if validation fails, null if valid + */ + private ResponseEntity validateImageFile(MultipartFile file, long maxSizeBytes) { + if (file == null || file.isEmpty()) { + log.warn("Validation failed: Empty image file"); + return ResponseEntity.badRequest() + .body(Map.of("error", "No image provided")); + } + + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + log.warn("Validation failed: Invalid content type '{}' for file '{}'", + LogSanitizer.sanitize(contentType), LogSanitizer.sanitize(file.getOriginalFilename())); + return ResponseEntity.badRequest() + .body(Map.of("error", "Invalid file type. Please upload an image.")); + } + + long fileSize = file.getSize(); + if (fileSize > maxSizeBytes) { + log.warn("Validation failed: Image size {} exceeds limit {} for file '{}'", + fileSize, maxSizeBytes, LogSanitizer.sanitize(file.getOriginalFilename())); + return ResponseEntity.badRequest() + .body(Map.of("error", "File size exceeds maximum limit of " + + (maxSizeBytes / (1024 * 1024)) + "MB: " + file.getOriginalFilename())); + } + + return null; // Validation passed + } + + /** + * Validate required text field. + * Checks: not null, not blank after trimming. + * + * @param value The value to validate + * @param fieldName Name of the field (for error message) + * @return ResponseEntity with error if validation fails, null if valid + */ + private ResponseEntity validateRequiredText(String value, String fieldName) { + if (value == null || value.trim().isEmpty()) { + log.warn("Validation failed: Required field '{}' is missing or empty", fieldName); + return ResponseEntity.badRequest() + .body(Map.of("error", fieldName + " is required")); + } + return null; // Validation passed + } + + /** + * Extract current user ID from security context. + */ + private String getCurrentUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) { + return auth.getName(); + } + return "unknown"; + } +} diff --git a/src/main/java/com/crimeLink/analyzer/controller/LocationController.java b/src/main/java/com/crimeLink/analyzer/controller/LocationController.java new file mode 100644 index 0000000..df953b1 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/controller/LocationController.java @@ -0,0 +1,85 @@ +package com.crimeLink.analyzer.controller; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.crimeLink.analyzer.dto.LocationPointDTO; +import com.crimeLink.analyzer.entity.User; +import com.crimeLink.analyzer.service.impl.LocationServiceImpl; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class LocationController { + private final LocationServiceImpl service; + + @PostMapping("/officers/me/locations/bulk") + public void uploadMyLocations(@AuthenticationPrincipal User user, @RequestBody List points) { + System.out.println("Received locations: " + points.size()); // REMOVE: for testing + if (user == null) { + throw new RuntimeException("Unauthorized"); + } + + if (!"FieldOfficer".equalsIgnoreCase(user.getRole())) { + throw new RuntimeException("Only field officers can upload locations"); + } + + String officerBadgeNo = user.getBadgeNo(); + if (officerBadgeNo == null || officerBadgeNo.isBlank()) { + throw new RuntimeException("Badge number missing"); + } + service.saveBulk(officerBadgeNo, points); + } + + @GetMapping("/admin/officers/{officerBadgeNo}/locations") + public Object history( + @AuthenticationPrincipal User user, + @PathVariable String officerBadgeNo, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) { + System.out.println("📍 LocationController.history() called"); + System.out.println(" Badge: " + officerBadgeNo); + System.out.println(" From: " + from + ", To: " + to); + System.out.println(" User: " + (user != null ? user.getEmail() : "NULL")); + System.out.println(" Role: " + (user != null ? user.getRole() : "NULL")); + System.out.println(" Authorities: " + (user != null ? user.getAuthorities() : "NULL")); + return service.getHistory(officerBadgeNo, from, to); + } + + @GetMapping("/debug/whoami") + public Map whoAmI(@AuthenticationPrincipal User user) { + Map info = new HashMap<>(); + if (user != null) { + info.put("email", user.getEmail()); + info.put("name", user.getName()); + info.put("role", user.getRole()); + info.put("authorities", user.getAuthorities().stream() + .map(auth -> auth.getAuthority()) + .toList()); + info.put("userId", user.getUserId()); + info.put("badgeNo", user.getBadgeNo()); + } else { + info.put("error", "No authenticated user"); + } + return info; + } + + @GetMapping("/admin/officers/{officerBadgeNo}/locations/last") + public Object lastLocation(@PathVariable String officerBadgeNo) { + return service.getLastLocation(officerBadgeNo); + } +} diff --git a/src/main/java/com/crimeLink/analyzer/dto/CallAnalysisResultDTO.java b/src/main/java/com/crimeLink/analyzer/dto/CallAnalysisResultDTO.java deleted file mode 100644 index 37d8f9e..0000000 --- a/src/main/java/com/crimeLink/analyzer/dto/CallAnalysisResultDTO.java +++ /dev/null @@ -1,387 +0,0 @@ -package com.crimeLink.analyzer.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import java.util.Map; - -public class CallAnalysisResultDTO { - - @JsonProperty("analysis_id") - private String analysisId; - - private String status; - private String timestamp; - - @JsonProperty("file_name") - private String fileName; - - @JsonProperty("total_calls") - private int totalCalls; - - @JsonProperty("unique_numbers") - private List uniqueNumbers; - - @JsonProperty("call_frequency") - private Map callFrequency; - - @JsonProperty("time_pattern") - private Map timePattern; - - @JsonProperty("common_contacts") - private List commonContacts; - - @JsonProperty("network_graph") - private NetworkGraph networkGraph; - - @JsonProperty("criminal_matches") - private List criminalMatches; - - @JsonProperty("risk_score") - private int riskScore; - - // Inner classes for nested structures - - public static class CommonContact { - private String phone; - private int count; - - public String getPhone() { - return phone; - } - - public void setPhone(String phone) { - this.phone = phone; - } - - public int getCount() { - return count; - } - - public void setCount(int count) { - this.count = count; - } - } - - public static class NetworkGraph { - private List nodes; - private List edges; - - @JsonProperty("total_nodes") - private int totalNodes; - - @JsonProperty("total_edges") - private int totalEdges; - - private double density; - - public static class Node { - private String id; - private String label; - private String type; - private int size; - private double centrality; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getLabel() { - return label; - } - - public void setLabel(String label) { - this.label = label; - } - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public int getSize() { - return size; - } - - public void setSize(int size) { - this.size = size; - } - - public double getCentrality() { - return centrality; - } - - public void setCentrality(double centrality) { - this.centrality = centrality; - } - } - - public static class Edge { - private String source; - private String target; - private int weight; - private String label; - - public String getSource() { - return source; - } - - public void setSource(String source) { - this.source = source; - } - - public String getTarget() { - return target; - } - - public void setTarget(String target) { - this.target = target; - } - - public int getWeight() { - return weight; - } - - public void setWeight(int weight) { - this.weight = weight; - } - - public String getLabel() { - return label; - } - - public void setLabel(String label) { - this.label = label; - } - } - - public List getNodes() { - return nodes; - } - - public void setNodes(List nodes) { - this.nodes = nodes; - } - - public List getEdges() { - return edges; - } - - public void setEdges(List edges) { - this.edges = edges; - } - - public int getTotalNodes() { - return totalNodes; - } - - public void setTotalNodes(int totalNodes) { - this.totalNodes = totalNodes; - } - - public int getTotalEdges() { - return totalEdges; - } - - public void setTotalEdges(int totalEdges) { - this.totalEdges = totalEdges; - } - - public double getDensity() { - return density; - } - - public void setDensity(double density) { - this.density = density; - } - } - - public static class CriminalMatch { - private String phone; - - @JsonProperty("criminal_id") - private String criminalId; - - private String name; - private String nic; - - @JsonProperty("crime_history") - private List crimeHistory; - - public static class Crime { - @JsonProperty("crime_type") - private String crimeType; - - private String date; - private String status; - - public String getCrimeType() { - return crimeType; - } - - public void setCrimeType(String crimeType) { - this.crimeType = crimeType; - } - - public String getDate() { - return date; - } - - public void setDate(String date) { - this.date = date; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } - } - - public String getPhone() { - return phone; - } - - public void setPhone(String phone) { - this.phone = phone; - } - - public String getCriminalId() { - return criminalId; - } - - public void setCriminalId(String criminalId) { - this.criminalId = criminalId; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getNic() { - return nic; - } - - public void setNic(String nic) { - this.nic = nic; - } - - public List getCrimeHistory() { - return crimeHistory; - } - - public void setCrimeHistory(List crimeHistory) { - this.crimeHistory = crimeHistory; - } - } - - // Getters and Setters - - public String getAnalysisId() { - return analysisId; - } - - public void setAnalysisId(String analysisId) { - this.analysisId = analysisId; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } - - public String getTimestamp() { - return timestamp; - } - - public void setTimestamp(String timestamp) { - this.timestamp = timestamp; - } - - public String getFileName() { - return fileName; - } - - public void setFileName(String fileName) { - this.fileName = fileName; - } - - public int getTotalCalls() { - return totalCalls; - } - - public void setTotalCalls(int totalCalls) { - this.totalCalls = totalCalls; - } - - public List getUniqueNumbers() { - return uniqueNumbers; - } - - public void setUniqueNumbers(List uniqueNumbers) { - this.uniqueNumbers = uniqueNumbers; - } - - public Map getCallFrequency() { - return callFrequency; - } - - public void setCallFrequency(Map callFrequency) { - this.callFrequency = callFrequency; - } - - public Map getTimePattern() { - return timePattern; - } - - public void setTimePattern(Map timePattern) { - this.timePattern = timePattern; - } - - public List getCommonContacts() { - return commonContacts; - } - - public void setCommonContacts(List commonContacts) { - this.commonContacts = commonContacts; - } - - public NetworkGraph getNetworkGraph() { - return networkGraph; - } - - public void setNetworkGraph(NetworkGraph networkGraph) { - this.networkGraph = networkGraph; - } - - public List getCriminalMatches() { - return criminalMatches; - } - - public void setCriminalMatches(List criminalMatches) { - this.criminalMatches = criminalMatches; - } - - public int getRiskScore() { - return riskScore; - } - - public void setRiskScore(int riskScore) { - this.riskScore = riskScore; - } -} diff --git a/src/main/java/com/crimeLink/analyzer/dto/CrimeReportDTO.java b/src/main/java/com/crimeLink/analyzer/dto/CrimeReportDTO.java index 393ebcd..3c14483 100644 --- a/src/main/java/com/crimeLink/analyzer/dto/CrimeReportDTO.java +++ b/src/main/java/com/crimeLink/analyzer/dto/CrimeReportDTO.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; @@ -18,4 +19,5 @@ public class CrimeReportDTO { private LocalDate dateReported; private LocalTime timeReported; private String crimeType; + private List evidences; } diff --git a/src/main/java/com/crimeLink/analyzer/dto/EvidenceDTO.java b/src/main/java/com/crimeLink/analyzer/dto/EvidenceDTO.java new file mode 100644 index 0000000..358eb24 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/dto/EvidenceDTO.java @@ -0,0 +1,21 @@ +package com.crimeLink.analyzer.dto; + +import java.util.UUID; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class EvidenceDTO { + private UUID evidenceId; + private String fileName; + private String fileType; + private Long fileSize; + + private String downloadUrl; +} diff --git a/src/main/java/com/crimeLink/analyzer/dto/LocationPointDTO.java b/src/main/java/com/crimeLink/analyzer/dto/LocationPointDTO.java new file mode 100644 index 0000000..7e3fec1 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/dto/LocationPointDTO.java @@ -0,0 +1,16 @@ +package com.crimeLink.analyzer.dto; + +import java.time.Instant; +import java.util.Map; + +public record LocationPointDTO( + Instant ts, + double latitude, + double longitude, + Float accuracyM, + Float speedMps, + Float headingDeg, + String provider, + Map meta) { + +} diff --git a/src/main/java/com/crimeLink/analyzer/entity/CallAnalysisRecord.java b/src/main/java/com/crimeLink/analyzer/entity/CallAnalysisRecord.java deleted file mode 100644 index 0adb79a..0000000 --- a/src/main/java/com/crimeLink/analyzer/entity/CallAnalysisRecord.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.crimeLink.analyzer.entity; - -import jakarta.persistence.*; -import java.time.LocalDateTime; - -@Entity -@Table(name = "call_analysis_records") -public class CallAnalysisRecord { - - @Id - @Column(name = "analysis_id", length = 255) - private String analysisId; - - @Column(name = "file_name", length = 500) - private String fileName; - - @Column(name = "uploaded_by", length = 100) - private String uploadedBy; - - @Column(name = "total_calls") - private Integer totalCalls; - - @Column(name = "unique_numbers_count") - private Integer uniqueNumbersCount; - - @Column(name = "criminal_matches_count") - private Integer criminalMatchesCount; - - @Column(name = "risk_score") - private Integer riskScore; - - @Column(name = "status", length = 50) - private String status; // processing, completed, failed - - @Column(name = "analysis_data", columnDefinition = "TEXT") - private String analysisData; // JSON string of full analysis - - @Column(name = "created_at") - private LocalDateTime createdAt; - - @Column(name = "completed_at") - private LocalDateTime completedAt; - - @PrePersist - protected void onCreate() { - if (createdAt == null) { - createdAt = LocalDateTime.now(); - } - if (status == null) { - status = "processing"; - } - } - - // Constructors - public CallAnalysisRecord() {} - - public CallAnalysisRecord(String analysisId, String fileName, String uploadedBy) { - this.analysisId = analysisId; - this.fileName = fileName; - this.uploadedBy = uploadedBy; - } - - // Getters and Setters - public String getAnalysisId() { - return analysisId; - } - - public void setAnalysisId(String analysisId) { - this.analysisId = analysisId; - } - - public String getFileName() { - return fileName; - } - - public void setFileName(String fileName) { - this.fileName = fileName; - } - - public String getUploadedBy() { - return uploadedBy; - } - - public void setUploadedBy(String uploadedBy) { - this.uploadedBy = uploadedBy; - } - - public Integer getTotalCalls() { - return totalCalls; - } - - public void setTotalCalls(Integer totalCalls) { - this.totalCalls = totalCalls; - } - - public Integer getUniqueNumbersCount() { - return uniqueNumbersCount; - } - - public void setUniqueNumbersCount(Integer uniqueNumbersCount) { - this.uniqueNumbersCount = uniqueNumbersCount; - } - - public Integer getCriminalMatchesCount() { - return criminalMatchesCount; - } - - public void setCriminalMatchesCount(Integer criminalMatchesCount) { - this.criminalMatchesCount = criminalMatchesCount; - } - - public Integer getRiskScore() { - return riskScore; - } - - public void setRiskScore(Integer riskScore) { - this.riskScore = riskScore; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } - - public String getAnalysisData() { - return analysisData; - } - - public void setAnalysisData(String analysisData) { - this.analysisData = analysisData; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(LocalDateTime createdAt) { - this.createdAt = createdAt; - } - - public LocalDateTime getCompletedAt() { - return completedAt; - } - - public void setCompletedAt(LocalDateTime completedAt) { - this.completedAt = completedAt; - } -} diff --git a/src/main/java/com/crimeLink/analyzer/entity/CrimeReport.java b/src/main/java/com/crimeLink/analyzer/entity/CrimeReport.java index 141dafd..25e55aa 100644 --- a/src/main/java/com/crimeLink/analyzer/entity/CrimeReport.java +++ b/src/main/java/com/crimeLink/analyzer/entity/CrimeReport.java @@ -2,7 +2,9 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.util.List; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -10,6 +12,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Getter; @@ -47,4 +50,7 @@ public class CrimeReport { @Enumerated(EnumType.STRING) @Column(name = "crime_type", nullable = false) private CrimeType crimeType; + + @OneToMany(mappedBy = "crimeReport", cascade = CascadeType.ALL, orphanRemoval = true) + private List evidences; } diff --git a/src/main/java/com/crimeLink/analyzer/entity/Evidence.java b/src/main/java/com/crimeLink/analyzer/entity/Evidence.java new file mode 100644 index 0000000..4ab8429 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/entity/Evidence.java @@ -0,0 +1,40 @@ +package com.crimeLink.analyzer.entity; + +import java.time.LocalDateTime; +import java.util.UUID; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Setter +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Entity +@Table(name = "evidence") +public class Evidence { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + private String bucket; + private String filePath; + private String fileName; + private String fileType; + private Long fileSize; + + private LocalDateTime uploadTime; + + @ManyToOne + @JoinColumn(name = "report_id", nullable = false) + private CrimeReport crimeReport; +} diff --git a/src/main/java/com/crimeLink/analyzer/entity/LocationPoint.java b/src/main/java/com/crimeLink/analyzer/entity/LocationPoint.java new file mode 100644 index 0000000..8df5ffd --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/entity/LocationPoint.java @@ -0,0 +1,49 @@ +package com.crimeLink.analyzer.entity; + +import java.time.Instant; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import com.fasterxml.jackson.databind.JsonNode; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Table(name = "location_points", indexes = { + @Index(name = "idx_location_points_officer_ts", columnList = "officer_badge_no, ts") }) +public class LocationPoint { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "officer_badge_no", nullable = false, length = 20) + private String officerBadgeNo; + private Instant ts; + private double latitude; + private double longitude; + + private Float accuracyM; + private Float speedMps; + private Float headingDeg; + + private String provider; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "meta", columnDefinition = "jsonb") + private JsonNode meta; +} diff --git a/src/main/java/com/crimeLink/analyzer/mapper/CrimeReportMapper.java b/src/main/java/com/crimeLink/analyzer/mapper/CrimeReportMapper.java index f07036c..e960333 100644 --- a/src/main/java/com/crimeLink/analyzer/mapper/CrimeReportMapper.java +++ b/src/main/java/com/crimeLink/analyzer/mapper/CrimeReportMapper.java @@ -1,25 +1,31 @@ package com.crimeLink.analyzer.mapper; +import java.util.List; +import java.util.stream.Collectors; + import com.crimeLink.analyzer.dto.CrimeReportDTO; +import com.crimeLink.analyzer.dto.EvidenceDTO; import com.crimeLink.analyzer.entity.CrimeReport; import com.crimeLink.analyzer.entity.CrimeType; public class CrimeReportMapper { - public static CrimeReport mapToCrimeReport(CrimeReportDTO dto){ + public static CrimeReport mapToCrimeReport(CrimeReportDTO dto) { CrimeReport report = new CrimeReport(); + report.setLongitude(dto.getLongitude()); report.setLatitude(dto.getLatitude()); report.setDescription(dto.getDescription()); report.setDateReported(dto.getDateReported()); report.setTimeReported(dto.getTimeReported()); report.setCrimeType(CrimeType.valueOf(dto.getCrimeType())); - + return report; } public static CrimeReportDTO mapToCrimeReportDTO(CrimeReport entity) { CrimeReportDTO dto = new CrimeReportDTO(); + dto.setReportId(entity.getReportId()); dto.setLongitude(entity.getLongitude()); dto.setLatitude(entity.getLatitude()); @@ -28,6 +34,16 @@ public static CrimeReportDTO mapToCrimeReportDTO(CrimeReport entity) { dto.setTimeReported(entity.getTimeReported()); dto.setCrimeType(entity.getCrimeType().name()); + if (entity.getEvidences() != null) { + List evidenceDTOs = entity.getEvidences().stream().map(e -> new EvidenceDTO( + e.getId(), + e.getFileName(), + e.getFileType(), + e.getFileSize(), + null)).collect(Collectors.toList()); + dto.setEvidences(evidenceDTOs); + } + return dto; } } diff --git a/src/main/java/com/crimeLink/analyzer/repository/CallAnalysisRepository.java b/src/main/java/com/crimeLink/analyzer/repository/CallAnalysisRepository.java deleted file mode 100644 index 9a617a9..0000000 --- a/src/main/java/com/crimeLink/analyzer/repository/CallAnalysisRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.crimeLink.analyzer.repository; - -import com.crimeLink.analyzer.entity.CallAnalysisRecord; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface CallAnalysisRepository extends JpaRepository { - - List findByUploadedByOrderByCreatedAtDesc(String uploadedBy); - - List findByStatusOrderByCreatedAtDesc(String status); - - List findTop10ByOrderByCreatedAtDesc(); -} diff --git a/src/main/java/com/crimeLink/analyzer/repository/DutyScheduleRepository.java b/src/main/java/com/crimeLink/analyzer/repository/DutyScheduleRepository.java index 1095667..fdc97bc 100644 --- a/src/main/java/com/crimeLink/analyzer/repository/DutyScheduleRepository.java +++ b/src/main/java/com/crimeLink/analyzer/repository/DutyScheduleRepository.java @@ -2,6 +2,7 @@ import com.crimeLink.analyzer.entity.DutySchedule; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.time.LocalDate; @@ -38,4 +39,13 @@ long countByAssignedOfficer_UserIdAndDateBetween( LocalDate start, LocalDate end ); + + @Query(""" + SELECT DISTINCT d.location + FROM DutySchedule d + WHERE d.location IS NOT NULL + AND LENGTH(TRIM(d.location)) > 0 + ORDER BY d.location + """) + List findDistinctLocations(); } diff --git a/src/main/java/com/crimeLink/analyzer/repository/LocationPointRepository.java b/src/main/java/com/crimeLink/analyzer/repository/LocationPointRepository.java new file mode 100644 index 0000000..1108beb --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/repository/LocationPointRepository.java @@ -0,0 +1,15 @@ +package com.crimeLink.analyzer.repository; + +import java.time.Instant; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.crimeLink.analyzer.entity.LocationPoint; + +public interface LocationPointRepository extends JpaRepository { + List findByOfficerBadgeNoAndTsBetweenOrderByTsAsc(String officerBadgeNo, Instant from, Instant to); + + List findByOfficerBadgeNoOrderByTsDesc(String officerBadgeNo, Pageable pageable); +} diff --git a/src/main/java/com/crimeLink/analyzer/repository/RefreshTokenRepository.java b/src/main/java/com/crimeLink/analyzer/repository/RefreshTokenRepository.java index d72cd31..76956d3 100644 --- a/src/main/java/com/crimeLink/analyzer/repository/RefreshTokenRepository.java +++ b/src/main/java/com/crimeLink/analyzer/repository/RefreshTokenRepository.java @@ -13,6 +13,9 @@ @Repository public interface RefreshTokenRepository extends JpaRepository { Optional findByToken(String token); + + @Query("SELECT rt FROM RefreshToken rt JOIN FETCH rt.user WHERE rt.token = ?1") + Optional findByTokenWithUser(String token); @Modifying @Query("DELETE FROM RefreshToken rt WHERE rt.expiryDate < ?1") diff --git a/src/main/java/com/crimeLink/analyzer/service/CallAnalysisService.java b/src/main/java/com/crimeLink/analyzer/service/CallAnalysisService.java index fc2e07a..e0897e7 100644 --- a/src/main/java/com/crimeLink/analyzer/service/CallAnalysisService.java +++ b/src/main/java/com/crimeLink/analyzer/service/CallAnalysisService.java @@ -1,114 +1,178 @@ package com.crimeLink.analyzer.service; -import com.crimeLink.analyzer.dto.CallAnalysisResultDTO; +import com.crimeLink.analyzer.util.LogSanitizer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; -import java.util.HashMap; -import java.util.Map; +import java.io.IOException; +import java.util.List; +/** + * Service for communicating with the Call Analysis ML microservice. + * Acts as a proxy/gateway layer, forwarding requests from the Spring Boot + * monolith to the Python FastAPI microservice. + * + * Architecture: Frontend -> Spring Boot (this service) -> Python ML Service + */ @Service +@Slf4j public class CallAnalysisService { - @Value("${python.call-analysis.url:http://localhost:5001}") - private String pythonServiceUrl; - private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${python.call-analysis.url:http://localhost:5001}") + private String callAnalysisServiceUrl; - public CallAnalysisService() { - this.restTemplate = new RestTemplate(); + public CallAnalysisService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + this.objectMapper = new ObjectMapper(); } /** - * Send PDF file to Python service for analysis - * @param file PDF file - * @return Analysis ID + * Analyze a single call record PDF. + * + * @param file PDF file containing call records + * @return JSON response with analysis results */ - public String analyzeCallRecords(MultipartFile file) throws Exception { - String url = pythonServiceUrl + "/analyze"; - - // Prepare multipart request - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.MULTIPART_FORM_DATA); - - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", new ByteArrayResource(file.getBytes()) { - @Override - public String getFilename() { - return file.getOriginalFilename(); + public JsonNode analyzeCallRecord(MultipartFile file) { + log.info("Forwarding call record analysis to ML service: {}", LogSanitizer.sanitize(file.getOriginalFilename())); + + String url = callAnalysisServiceUrl + "/analyze"; + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + + // Extract file bytes with explicit error handling + byte[] fileBytes; + try { + fileBytes = file.getBytes(); + } catch (IOException e) { + log.error("Failed to read file bytes from uploaded file '{}': {}", + LogSanitizer.sanitize(file.getOriginalFilename()), e.getMessage()); + throw new RuntimeException("Failed to read uploaded file contents", e); } - }); - - HttpEntity> requestEntity = new HttpEntity<>(body, headers); - - // Call Python service - ResponseEntity response = restTemplate.postForEntity(url, requestEntity, Map.class); - - if (response.getStatusCode() == HttpStatus.OK) { - Map responseBody = response.getBody(); - return (String) responseBody.get("analysis_id"); - } else { - throw new Exception("Failed to analyze call records: " + response.getStatusCode()); + + body.add("file", new ByteArrayResource(fileBytes) { + @Override + public String getFilename() { + return file.getOriginalFilename(); + } + }); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + requestEntity, + String.class + ); + + log.info("ML service responded with status: {}", response.getStatusCode()); + return objectMapper.readTree(response.getBody()); + + } catch (RestClientException e) { + log.error("Failed to communicate with call analysis service: {}", e.getMessage()); + throw new RuntimeException("Call analysis service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to process response from ML service: {}", e.getMessage()); + throw new RuntimeException("Failed to process ML service response: " + e.getMessage(), e); } } /** - * Get analysis results from Python service - * @param analysisId Analysis ID - * @return Analysis results DTO + * Analyze multiple call record PDFs in batch. + * + * @param files List of PDF files + * @return JSON response with batch analysis results */ - public CallAnalysisResultDTO getAnalysisResults(String analysisId) throws Exception { - String url = pythonServiceUrl + "/results/" + analysisId; - - ResponseEntity response = restTemplate.getForEntity( - url, - CallAnalysisResultDTO.class - ); - - if (response.getStatusCode() == HttpStatus.OK) { - return response.getBody(); - } else if (response.getStatusCode() == HttpStatus.NOT_FOUND) { - return null; - } else { - throw new Exception("Failed to retrieve analysis results"); - } - } + public JsonNode analyzeBatch(List files) { + log.info("Forwarding batch call analysis to ML service: {} files", files.size()); + + String url = callAnalysisServiceUrl + "/analyze/batch"; + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + + for (MultipartFile file : files) { + // Extract file bytes with explicit error handling + byte[] fileBytes; + try { + fileBytes = file.getBytes(); + } catch (IOException e) { + log.error("Failed to read file bytes from uploaded file '{}': {}", + LogSanitizer.sanitize(file.getOriginalFilename()), e.getMessage()); + throw new RuntimeException("Failed to read uploaded file: " + file.getOriginalFilename(), e); + } + + final String originalFilename = file.getOriginalFilename(); + body.add("files", new ByteArrayResource(fileBytes) { + @Override + public String getFilename() { + return originalFilename; + } + }); + } - /** - * Get all analysis history - * @return List of analyses - */ - public Map getAllAnalyses() throws Exception { - String url = pythonServiceUrl + "/results"; + HttpEntity> requestEntity = new HttpEntity<>(body, headers); - ResponseEntity response = restTemplate.getForEntity(url, Map.class); + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + requestEntity, + String.class + ); - if (response.getStatusCode() == HttpStatus.OK) { - return response.getBody(); - } else { - throw new Exception("Failed to retrieve analysis history"); + log.info("Batch analysis completed successfully"); + return objectMapper.readTree(response.getBody()); + + } catch (RestClientException e) { + log.error("Failed to communicate with call analysis service: {}", e.getMessage()); + throw new RuntimeException("Call analysis service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to process files: {}", e.getMessage()); + throw new RuntimeException("Failed to process files: " + e.getMessage(), e); } } /** - * Check if Python service is healthy - * @return Health status + * Check health status of the call analysis ML service. + * + * @return Health status JSON */ - public Map checkPythonServiceHealth() throws Exception { - String url = pythonServiceUrl + "/health"; - - ResponseEntity response = restTemplate.getForEntity(url, Map.class); - - if (response.getStatusCode() == HttpStatus.OK) { - return response.getBody(); - } else { - throw new Exception("Python service is not responding"); + public JsonNode checkHealth() { + String url = callAnalysisServiceUrl + "/health"; + + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + return objectMapper.readTree(response.getBody()); + } catch (RestClientException e) { + log.warn("Call analysis service health check failed: {}", e.getMessage()); + return objectMapper.createObjectNode() + .put("status", "unhealthy") + .put("error", e.getMessage()); + } catch (IOException e) { + return objectMapper.createObjectNode() + .put("status", "unhealthy") + .put("error", "Invalid response"); } } + } diff --git a/src/main/java/com/crimeLink/analyzer/service/CrimeReportService.java b/src/main/java/com/crimeLink/analyzer/service/CrimeReportService.java index 2c5faae..ad3f071 100644 --- a/src/main/java/com/crimeLink/analyzer/service/CrimeReportService.java +++ b/src/main/java/com/crimeLink/analyzer/service/CrimeReportService.java @@ -7,7 +7,9 @@ import com.crimeLink.analyzer.dto.CrimeLocationDTO; import com.crimeLink.analyzer.dto.CrimeReportDTO; +import com.crimeLink.analyzer.dto.EvidenceDTO; import com.crimeLink.analyzer.entity.CrimeReport; +import com.crimeLink.analyzer.entity.Evidence; import com.crimeLink.analyzer.mapper.CrimeReportMapper; import com.crimeLink.analyzer.repository.CrimeReportRepository; @@ -18,43 +20,88 @@ public class CrimeReportService { private final CrimeReportRepository crimeReportRepository; + private final SupabaseService supabaseService; public CrimeReportDTO saveCrimeReport(CrimeReportDTO reportDTO) { // if (reportDTO.getCrimeType() == null) { - // throw new IllegalArgumentException("Crime type cannot be null"); + // throw new IllegalArgumentException("Crime type cannot be null"); // } // if (reportDTO.getLatitude() == null) { - // throw new IllegalArgumentException("Latitude cannot be null"); + // throw new IllegalArgumentException("Latitude cannot be null"); // } // if (reportDTO.getLongitude() == null) { - // throw new IllegalArgumentException("Longitude cannot be null"); + // throw new IllegalArgumentException("Longitude cannot be null"); // } // if (reportDTO.getDateReported() == null) { - // throw new IllegalArgumentException("Date reported cannot be null"); + // throw new IllegalArgumentException("Date reported cannot be null"); // } // if (reportDTO.getTimeReported() == null) { - // throw new IllegalArgumentException("Time reported cannot be null"); + // throw new IllegalArgumentException("Time reported cannot be null"); // } // if (reportDTO.getDescription() == null) { - // throw new IllegalArgumentException("Description cannot be null"); + // throw new IllegalArgumentException("Description cannot be null"); // } CrimeReport report = CrimeReportMapper.mapToCrimeReport(reportDTO); CrimeReport savedReport = crimeReportRepository.save(report); + + if (reportDTO.getEvidences() != null) { + List evidences = reportDTO.getEvidences().stream() + .map(e -> { + Evidence evidence = new Evidence(); + evidence.setFileName(e.getFileName()); + evidence.setFileType(e.getFileType()); + evidence.setFileSize(e.getFileSize()); + evidence.setCrimeReport(savedReport); + return evidence; + }).collect(Collectors.toList()); + savedReport.setEvidences(evidences); + crimeReportRepository.save(savedReport); + } + return CrimeReportMapper.mapToCrimeReportDTO(savedReport); } - public List getAllCrimeReports(){ - List crimeReports = crimeReportRepository.findAll(); - return crimeReports.stream().map((report) -> CrimeReportMapper.mapToCrimeReportDTO(report)).collect(Collectors.toList()); + public List getAllCrimeReports() { + return crimeReportRepository.findAll().stream().map(this::convertToListDTO) + .collect(Collectors.toList()); + } + + private CrimeReportDTO convertToListDTO(CrimeReport report) { + CrimeReportDTO dto = new CrimeReportDTO(); + dto.setReportId(report.getReportId()); + dto.setLongitude(report.getLongitude()); + dto.setLatitude(report.getLatitude()); + dto.setDescription(report.getDescription()); + dto.setDateReported(report.getDateReported()); + dto.setTimeReported(report.getTimeReported()); + dto.setCrimeType(report.getCrimeType() != null ? report.getCrimeType().name() : null); + dto.setEvidences(List.of()); + return dto; } - public CrimeReportDTO getCrimeReportById(Long reportId){ - CrimeReport report = crimeReportRepository.findById(reportId).orElseThrow(() -> new RuntimeException("Report not found")); - return CrimeReportMapper.mapToCrimeReportDTO(report); + public CrimeReportDTO getCrimeReportById(Long reportId) { + CrimeReport report = crimeReportRepository.findById(reportId) + .orElseThrow(() -> new RuntimeException("Report not found")); + return convertToDTOWithSignedUrls(report); + } + + private CrimeReportDTO convertToDTOWithSignedUrls(CrimeReport report) { + CrimeReportDTO dto = CrimeReportMapper.mapToCrimeReportDTO(report); + + if (report.getEvidences() != null) { + List evidenceDTOs = report.getEvidences().stream() + .map(e -> EvidenceDTO.builder().evidenceId(e.getId()).fileName(e.getFileName()) + .fileType(e.getFileType()).fileSize(e.getFileSize()) + .downloadUrl(supabaseService.getFileUrl(e.getFileName())).build()) + .collect(Collectors.toList()); + + dto.setEvidences(evidenceDTOs); + } + return dto; } - public List getCrimeMapLocations(){ + public List getCrimeMapLocations() { return crimeReportRepository.findCrimeLocations(); } } diff --git a/src/main/java/com/crimeLink/analyzer/service/DutyScheduleService.java b/src/main/java/com/crimeLink/analyzer/service/DutyScheduleService.java index 9a84021..3b48ed4 100644 --- a/src/main/java/com/crimeLink/analyzer/service/DutyScheduleService.java +++ b/src/main/java/com/crimeLink/analyzer/service/DutyScheduleService.java @@ -31,6 +31,10 @@ public class DutyScheduleService { private final UserRepository userRepo; private final OfficerPerformanceRepository performanceRepo; + private static final List DEFAULT_DUTY_LOCATIONS = List.of( + "Matara", "Hakmana", "Weligama", "Akuressa" + ); + public DutyScheduleService(DutyScheduleRepository dutyRepo, UserRepository userRepo, OfficerPerformanceRepository performanceRepo) { this.dutyRepo = dutyRepo; this.userRepo = userRepo; @@ -238,6 +242,10 @@ private void updateOfficerPerformanceAfterDuty(User officer, DutySchedule duty) performanceRepo.save(perf); } + public List getDutyLocations() { + List dbLocations = dutyRepo.findDistinctLocations(); + return dbLocations.isEmpty() ? DEFAULT_DUTY_LOCATIONS : dbLocations; + } // Range queries & PDF public List getDutiesBetween(LocalDate start, LocalDate end) { return dutyRepo.findByDateBetween(start, end); diff --git a/src/main/java/com/crimeLink/analyzer/service/FacialRecognitionService.java b/src/main/java/com/crimeLink/analyzer/service/FacialRecognitionService.java new file mode 100644 index 0000000..f125a1a --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/service/FacialRecognitionService.java @@ -0,0 +1,249 @@ +package com.crimeLink.analyzer.service; + +import com.crimeLink.analyzer.util.LogSanitizer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Map; + +/** + * Service for communicating with the Facial Recognition ML microservice. + * Acts as a proxy/gateway layer, forwarding requests from the Spring Boot + * monolith to the Python FastAPI microservice. + * + * Architecture: Frontend -> Spring Boot (this service) -> Python ML Service + */ +@Service +@Slf4j +public class FacialRecognitionService { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${python.facial-recognition.url:http://localhost:5002}") + private String facialRecognitionServiceUrl; + + public FacialRecognitionService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + this.objectMapper = new ObjectMapper(); + } + + /** + * Analyze a suspect image for facial recognition matches. + * Forwards the request to Python ML service and returns the response. + * + * @param image The image file to analyze + * @param threshold Similarity threshold (0-100) + * @param userId User ID making the request (for audit logging) + * @param caseId Optional case ID for linking analysis to investigation + * @return JSON response from ML service containing matches + */ + public JsonNode analyzeImage(MultipartFile image, Float threshold, String userId, String caseId) { + log.info("Forwarding facial recognition request to ML service for user: {}", userId); + + String url = facialRecognitionServiceUrl + "/analyze"; + + try { + // Build multipart request + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + + // Add image file with explicit error handling + byte[] imageBytes; + try { + imageBytes = image.getBytes(); + } catch (IOException e) { + log.error("Failed to read image bytes from uploaded file '{}': {}", + LogSanitizer.sanitize(image.getOriginalFilename()), e.getMessage()); + throw new RuntimeException("Failed to read uploaded image contents", e); + } + + body.add("image", new ByteArrayResource(imageBytes) { + @Override + public String getFilename() { + return image.getOriginalFilename(); + } + }); + + // Add optional parameters + if (threshold != null) { + body.add("threshold", threshold.toString()); + } + if (userId != null) { + body.add("user_id", userId); + } + if (caseId != null) { + body.add("case_id", caseId); + } + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + log.debug("Sending request to: {}", url); + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + requestEntity, + String.class + ); + + log.info("ML service responded with status: {}", response.getStatusCode()); + return objectMapper.readTree(response.getBody()); + + } catch (RestClientException e) { + log.error("Failed to communicate with facial recognition service: {}", e.getMessage()); + throw new RuntimeException("Facial recognition service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to process response from ML service: {}", e.getMessage()); + throw new RuntimeException("Failed to process ML service response: " + e.getMessage(), e); + } + } + + /** + * Register a new criminal with their photo for facial recognition. + * + * @param photo Photo of the criminal + * @param criminalId Existing criminal ID to link + * @param name Criminal's name + * @param nic National ID Card number + * @param riskLevel Risk level (high, medium, low) + * @return JSON response from ML service + */ + public JsonNode registerCriminal(MultipartFile photo, String criminalId, String name, + String nic, String riskLevel) { + log.info("Forwarding criminal registration to ML service: {} ({})", LogSanitizer.sanitize(name), LogSanitizer.sanitize(nic)); + + String url = facialRecognitionServiceUrl + "/register"; + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + + // Extract photo bytes with explicit error handling + byte[] photoBytes; + try { + photoBytes = photo.getBytes(); + } catch (IOException e) { + log.error("Failed to read photo bytes from uploaded file '{}': {}", + LogSanitizer.sanitize(photo.getOriginalFilename()), e.getMessage()); + throw new RuntimeException("Failed to read uploaded photo contents", e); + } + + body.add("photo", new ByteArrayResource(photoBytes) { + @Override + public String getFilename() { + return photo.getOriginalFilename(); + } + }); + + if (criminalId != null) body.add("criminal_id", criminalId); + body.add("name", name); + body.add("nic", nic); + if (riskLevel != null) body.add("risk_level", riskLevel); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + requestEntity, + String.class + ); + + log.info("Criminal registered successfully"); + return objectMapper.readTree(response.getBody()); + + } catch (RestClientException e) { + log.error("Failed to register criminal: {}", e.getMessage()); + throw new RuntimeException("Facial recognition service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to process response from ML service: {}", e.getMessage()); + throw new RuntimeException("Failed to process ML service response: " + e.getMessage(), e); + } + } + + /** + * Get list of all registered criminals with face embeddings. + * + * @return JSON array of criminals + */ + public JsonNode getCriminals() { + log.debug("Fetching criminals list from ML service"); + + String url = facialRecognitionServiceUrl + "/criminals"; + + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + return objectMapper.readTree(response.getBody()); + } catch (RestClientException e) { + log.error("Failed to fetch criminals: {}", e.getMessage()); + throw new RuntimeException("Facial recognition service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to parse response: {}", e.getMessage()); + throw new RuntimeException("Invalid response from service: " + e.getMessage(), e); + } + } + + /** + * Get facial recognition history/audit logs. + * + * @param limit Maximum number of records + * @return JSON array of recognition history + */ + public JsonNode getRecognitionHistory(Integer limit) { + log.debug("Fetching recognition history from ML service"); + + String url = facialRecognitionServiceUrl + "/history"; + if (limit != null) { + url += "?limit=" + limit; + } + + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + return objectMapper.readTree(response.getBody()); + } catch (RestClientException e) { + log.error("Failed to fetch history: {}", e.getMessage()); + throw new RuntimeException("Facial recognition service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to parse response: {}", e.getMessage()); + throw new RuntimeException("Invalid response from service: " + e.getMessage(), e); + } + } + + /** + * Check health status of the facial recognition ML service. + * + * @return Health status JSON + */ + public JsonNode checkHealth() { + String url = facialRecognitionServiceUrl + "/health"; + + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + return objectMapper.readTree(response.getBody()); + } catch (RestClientException e) { + log.warn("Facial recognition service health check failed: {}", e.getMessage()); + return objectMapper.createObjectNode() + .put("status", "unhealthy") + .put("error", e.getMessage()); + } catch (IOException e) { + return objectMapper.createObjectNode() + .put("status", "unhealthy") + .put("error", "Invalid response"); + } + } +} diff --git a/src/main/java/com/crimeLink/analyzer/service/LocationService.java b/src/main/java/com/crimeLink/analyzer/service/LocationService.java new file mode 100644 index 0000000..0b520c0 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/service/LocationService.java @@ -0,0 +1,15 @@ +package com.crimeLink.analyzer.service; + +import java.time.Instant; +import java.util.List; + +import com.crimeLink.analyzer.dto.LocationPointDTO; +import com.crimeLink.analyzer.entity.LocationPoint; + +public interface LocationService { + public void saveBulk(String officerBadgeNo, List points); + + public List getHistory(String officerBadgeNo, Instant from, Instant to); + + public LocationPoint getLastLocation(String officerBadgeNo); +} diff --git a/src/main/java/com/crimeLink/analyzer/service/RefreshTokenService.java b/src/main/java/com/crimeLink/analyzer/service/RefreshTokenService.java index 02f7d6d..2129e11 100644 --- a/src/main/java/com/crimeLink/analyzer/service/RefreshTokenService.java +++ b/src/main/java/com/crimeLink/analyzer/service/RefreshTokenService.java @@ -29,6 +29,10 @@ public RefreshToken createRefreshToken(Integer userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new RuntimeException("User not found")); + return createRefreshToken(user); + } + + public RefreshToken createRefreshToken(User user) { RefreshToken refreshToken = new RefreshToken(); refreshToken.setUser(user); refreshToken.setToken(UUID.randomUUID().toString()); @@ -42,12 +46,29 @@ public Optional findByToken(String token) { return refreshTokenRepository.findByToken(token); } - public RefreshToken verifyExpiration(RefreshToken token) { - if (token.isExpired() || token.getRevoked()) { - refreshTokenRepository.delete(token); - throw new RuntimeException("Refresh token expired or revoked"); + public Optional findValidToken(String token) { + Optional existing = refreshTokenRepository.findByTokenWithUser(token); + if (existing.isEmpty()) return Optional.empty(); + + RefreshToken rt = existing.get(); + if (rt.isExpired()) { + refreshTokenRepository.delete(rt); + return Optional.empty(); + } + + if (Boolean.TRUE.equals(rt.getRevoked())) { + return Optional.empty(); } - return token; + + return Optional.of(rt); + } + + @Transactional + public RefreshToken rotateRefreshToken(RefreshToken currentToken) { + currentToken.setRevoked(true); + refreshTokenRepository.save(currentToken); + + return createRefreshToken(currentToken.getUser()); } @Transactional diff --git a/src/main/java/com/crimeLink/analyzer/service/SupabaseService.java b/src/main/java/com/crimeLink/analyzer/service/SupabaseService.java new file mode 100644 index 0000000..610984c --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/service/SupabaseService.java @@ -0,0 +1,116 @@ +package com.crimeLink.analyzer.service; + +import java.io.IOException; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Service +public class SupabaseService { + + @Value("${supabase.url}") + private String supabaseUrl; + + @Value("${supabase.service-key}") + private String supabaseServiceKey; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final String bucket = "crime-evidence"; + + public String uploadFile(MultipartFile file) throws IOException { + if (file.isEmpty()) { + throw new RuntimeException("File is empty"); + } + + if (file.getSize() > 10 * 1024 * 1024) { + throw new RuntimeException("File size exceeds the limit of 10MB"); + } + + String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); + + String uploadUrl = supabaseUrl + "/storage/v1/object/" + bucket + "/" + fileName; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + supabaseServiceKey); + headers.set("apikey", supabaseServiceKey); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + + HttpEntity request = new HttpEntity<>(file.getBytes(), headers); + + restTemplate.exchange(uploadUrl, HttpMethod.PUT, request, String.class); + + return fileName; + } + + public String getFileUrl(String fileName) { + String url = supabaseUrl + "/storage/v1/object/sign/" + bucket + "/" + fileName; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + supabaseServiceKey); + headers.set("apikey", supabaseServiceKey); + headers.setContentType(MediaType.APPLICATION_JSON); + + String body = "{\"expiresIn\":300}"; + + HttpEntity request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, request, String.class); + String responseBody = response.getBody(); + + if (responseBody == null || responseBody.isBlank()) { + throw new RuntimeException("Supabase did not return a signed URL"); + } + + try { + JsonNode jsonNode = objectMapper.readTree(responseBody); + String signedUrlPath = null; + + if (jsonNode.hasNonNull("signedURL")) { + signedUrlPath = jsonNode.get("signedURL").asText(); + } else if (jsonNode.hasNonNull("signedUrl")) { + signedUrlPath = jsonNode.get("signedUrl").asText(); + } + + if (signedUrlPath == null || signedUrlPath.isBlank()) { + throw new RuntimeException("Supabase signed URL field is missing in response"); + } + + return toAbsoluteSignedUrl(signedUrlPath.trim()); + } catch (IOException exception) { + throw new RuntimeException("Failed to parse Supabase signed URL response", exception); + } + } + + private String toAbsoluteSignedUrl(String signedUrlPath) { + if (signedUrlPath.startsWith("http://") || signedUrlPath.startsWith("https://")) { + return signedUrlPath; + } + + String normalizedSupabaseUrl = supabaseUrl.endsWith("/") + ? supabaseUrl.substring(0, supabaseUrl.length() - 1) + : supabaseUrl; + + if (signedUrlPath.startsWith("/storage/v1/")) { + return normalizedSupabaseUrl + signedUrlPath; + } + + if (signedUrlPath.startsWith("/")) { + return normalizedSupabaseUrl + "/storage/v1" + signedUrlPath; + } + + return normalizedSupabaseUrl + "/storage/v1/" + signedUrlPath; + } +} diff --git a/src/main/java/com/crimeLink/analyzer/service/impl/LocationServiceImpl.java b/src/main/java/com/crimeLink/analyzer/service/impl/LocationServiceImpl.java new file mode 100644 index 0000000..965467f --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/service/impl/LocationServiceImpl.java @@ -0,0 +1,60 @@ +package com.crimeLink.analyzer.service.impl; + +import com.crimeLink.analyzer.dto.LocationPointDTO; +import com.crimeLink.analyzer.entity.LocationPoint; +import com.crimeLink.analyzer.repository.LocationPointRepository; + +import org.springframework.stereotype.Service; + +import com.crimeLink.analyzer.service.LocationService; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +import java.time.Instant; +import java.util.List; + +import org.springframework.data.domain.PageRequest; + +@Service +@RequiredArgsConstructor +public class LocationServiceImpl implements LocationService { + private final LocationPointRepository repo; + private final ObjectMapper mapper; + + @Override + public void saveBulk(String officerBadgeNo, List points) { + var entities = points.stream().filter(p -> p.ts() != null) + .filter(p -> p.accuracyM() == null || p.accuracyM() <= 50) + .map(p -> { + var e = new LocationPoint(); + e.setOfficerBadgeNo(officerBadgeNo); + e.setTs(p.ts()); + e.setLatitude(p.latitude()); + e.setLongitude(p.longitude()); + e.setAccuracyM(p.accuracyM()); + e.setSpeedMps(p.speedMps()); + e.setHeadingDeg(p.headingDeg()); + e.setProvider(p.provider()); + + try { + e.setMeta(p.meta() == null ? null : mapper.valueToTree(p.meta())); + } catch (Exception er) { + e.setMeta(null); + } + return e; + }).toList(); + repo.saveAll(entities); + } + + @Override + public List getHistory(String officerBadgeNo, Instant from, Instant to) { + return repo.findByOfficerBadgeNoAndTsBetweenOrderByTsAsc(officerBadgeNo, from, to); + } + + @Override + public LocationPoint getLastLocation(String officerBadgeNo) { + var list = repo.findByOfficerBadgeNoOrderByTsDesc(officerBadgeNo, PageRequest.of(0, 1)); + return list.isEmpty() ? null : list.get(0); + } +} diff --git a/src/main/java/com/crimeLink/analyzer/util/LogSanitizer.java b/src/main/java/com/crimeLink/analyzer/util/LogSanitizer.java new file mode 100644 index 0000000..f5e39e2 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/util/LogSanitizer.java @@ -0,0 +1,87 @@ +package com.crimeLink.analyzer.util; + +/** + * Utility class for sanitizing user-controlled input before logging. + *

+ * Prevents Log Injection (CWE-117) by stripping or escaping + * carriage-return, line-feed, and other ASCII control characters that an + * attacker could use to forge log entries. + *

+ * Usage: + *

+ *   log.info("Processing file: {}", LogSanitizer.sanitize(file.getOriginalFilename()));
+ * 
+ */ +public final class LogSanitizer { + + /** Maximum length of a sanitized value written to a log line. */ + private static final int MAX_LENGTH = 200; + + private LogSanitizer() { + // utility class – no instances + } + + /** + * Sanitize a {@code String} value so it is safe to include in a log message. + *
    + *
  • {@code null} → the literal string {@code "null"}
  • + *
  • CR ({@code \r}) → the two-character escape {@code \r}
  • + *
  • LF ({@code \n}) → the two-character escape {@code \n}
  • + *
  • TAB ({@code \t}) → the two-character escape {@code \t}
  • + *
  • Any other ASCII control character ({@code U+0000–U+001F}, {@code U+007F}) + * except space → {@code ?}
  • + *
  • Values longer than {@value #MAX_LENGTH} characters (after escaping) + * are truncated with a {@code …(truncated)} suffix
  • + *
+ * + * @param input the raw, potentially attacker-controlled string + * @return a sanitized string safe for logging; never {@code null} + */ + public static String sanitize(String input) { + if (input == null) { + return "null"; + } + + StringBuilder sb = new StringBuilder(Math.min(input.length(), MAX_LENGTH + 20)); + for (int i = 0; i < input.length(); i++) { + char ch = input.charAt(i); + if (ch == '\r') { + sb.append("\\r"); + } else if (ch == '\n') { + sb.append("\\n"); + } else if (ch == '\t') { + sb.append("\\t"); + } else if ((ch >= '\u0000' && ch <= '\u001F') || ch == '\u007F') { + sb.append('?'); + } else { + sb.append(ch); + } + + // Early exit when we already exceed the cap + if (sb.length() > MAX_LENGTH) { + break; + } + } + + if (sb.length() > MAX_LENGTH) { + sb.setLength(MAX_LENGTH); + sb.append("…(truncated)"); + } + + return sb.toString(); + } + + /** + * Convenience overload that accepts any {@link Object}. + * Calls {@link Object#toString()} before sanitizing. + * + * @param input the object whose string representation should be sanitized + * @return a sanitized string safe for logging; never {@code null} + */ + public static String sanitize(Object input) { + if (input == null) { + return "null"; + } + return sanitize(input.toString()); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8a3bce8..80d1aa1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -38,3 +38,11 @@ jwt.refresh-expiration=${REFRESH_TOKEN_EXPIRATION:604800000} python.call-analysis.url=${PYTHON_CALL_ANALYSIS_URL:http://localhost:5001} python.facial-recognition.url=${PYTHON_FACIAL_RECOGNITION_URL:http://localhost:5002} +#Supabase Configuration +supabase.url=${SUPABASE_URL} +supabase.service-key=${SUPABASE_SERVICE_KEY} + +# File Upload Configuration +spring.servlet.multipart.enabled=true +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=50MB