diff --git a/pom.xml b/pom.xml index 0a14c9b..30a51d3 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,11 @@ 2.6.0 + + org.springframework.boot + spring-boot-starter-validation + + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/com/hacktober/blog/config/FirebaseConfig.java b/src/main/java/com/hacktober/blog/config/FirebaseConfig.java new file mode 100644 index 0000000..fb0aad6 --- /dev/null +++ b/src/main/java/com/hacktober/blog/config/FirebaseConfig.java @@ -0,0 +1,56 @@ +package com.hacktober.blog.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.firestore.Firestore; +import com.google.cloud.firestore.FirestoreOptions; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.FileInputStream; +import java.io.IOException; + +@Configuration +public class FirebaseConfig { + private static final Logger log = LoggerFactory.getLogger(FirebaseConfig.class); + + @Value("${firebase.credentials.path:${FIREBASE_ADMIN_SA_PATH:}}") + private String credentialsPath; + + @Value("${firebase.project-id:${FIREBASE_PROJECT_ID:}}") + private String projectId; + + @Bean + public FirebaseApp firebaseApp() throws IOException { + if (!FirebaseApp.getApps().isEmpty()) return FirebaseApp.getInstance(); + + FirebaseOptions.Builder builder = FirebaseOptions.builder(); + + if (credentialsPath != null && !credentialsPath.isBlank()) { + try (FileInputStream in = new FileInputStream(credentialsPath)) { + builder.setCredentials(GoogleCredentials.fromStream(in)); + log.info("Firebase: using service account from {}", credentialsPath); + } + } else { + builder.setCredentials(GoogleCredentials.getApplicationDefault()); + log.info("Firebase: using application default credentials"); + } + + if (projectId != null && !projectId.isBlank()) builder.setProjectId(projectId); + + FirebaseApp app = FirebaseApp.initializeApp(builder.build()); + log.info("Firebase initialized (projectId={})", projectId); + return app; + } + + @Bean + public Firestore firestore(FirebaseApp app) { + FirestoreOptions.Builder fo = FirestoreOptions.newBuilder(); + if (projectId != null && !projectId.isBlank()) fo.setProjectId(projectId); + return fo.build().getService(); + } +} diff --git a/src/main/java/com/hacktober/blog/config/FirestoreService.java b/src/main/java/com/hacktober/blog/config/FirestoreService.java deleted file mode 100644 index 59a3774..0000000 --- a/src/main/java/com/hacktober/blog/config/FirestoreService.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.hacktober.blog.config; - -import java.io.FileInputStream; -import java.io.IOException; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import com.google.auth.oauth2.GoogleCredentials; -import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; - -@SuppressWarnings("deprecation") -@Configuration -public class FirestoreService { - - @Bean - public FirebaseApp initFirebaseApp() { - - try { - - FileInputStream serviceAccount = new FileInputStream("/etc/secrets/firebaseServiceAccountKey.json"); - FirebaseOptions options = new FirebaseOptions.Builder().setCredentials(GoogleCredentials.fromStream(serviceAccount)) - .build(); - return FirebaseApp.initializeApp(options); - - } catch (IOException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - return null; - - } - -} diff --git a/src/main/java/com/hacktober/blog/config/RedisConfig.java b/src/main/java/com/hacktober/blog/config/RedisConfig.java index 9dd5357..a9998cb 100644 --- a/src/main/java/com/hacktober/blog/config/RedisConfig.java +++ b/src/main/java/com/hacktober/blog/config/RedisConfig.java @@ -3,27 +3,40 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; - import redis.clients.jedis.DefaultJedisClientConfig; -import redis.clients.jedis.JedisClientConfig; -import redis.clients.jedis.UnifiedJedis; import redis.clients.jedis.HostAndPort; +import redis.clients.jedis.UnifiedJedis; @Configuration public class RedisConfig { - - @Value("${redis.password}") - private String password; - @Bean + @Value("${redis.host:127.0.0.1}") + private String host; + + @Value("${redis.port:6379}") + private int port; + + @Value("${redis.password:}") + private String password; + + @Value("${redis.ssl:false}") + private boolean ssl; + + @Value("${redis.timeout-ms:2000}") + private int timeoutMs; + + @Value("${redis.client-name:hacktoberblog-backend}") + private String clientName; + + @Bean(destroyMethod = "close") public UnifiedJedis unifiedJedis() { - JedisClientConfig config = DefaultJedisClientConfig.builder() - .user("default") - .password(password) + DefaultJedisClientConfig cfg = DefaultJedisClientConfig.builder() + .password((password != null && !password.isBlank()) ? password : null) + .ssl(ssl) + .connectionTimeoutMillis(timeoutMs) + .socketTimeoutMillis(timeoutMs) + .clientName(clientName) .build(); - return new UnifiedJedis( - new HostAndPort("redis-14860.crce182.ap-south-1-1.ec2.redns.redis-cloud.com", 14860), - config - ); + return new UnifiedJedis(new HostAndPort(host, port), cfg); } } diff --git a/src/main/java/com/hacktober/blog/email/EmailController.java b/src/main/java/com/hacktober/blog/email/EmailController.java index f8261bb..afafaaa 100644 --- a/src/main/java/com/hacktober/blog/email/EmailController.java +++ b/src/main/java/com/hacktober/blog/email/EmailController.java @@ -1,6 +1,7 @@ package com.hacktober.blog.email; import com.hacktober.blog.utils.ApiResponse; +import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -21,7 +22,7 @@ public EmailController(EmailService emailService) { @PostMapping("/send") @Operation(summary = "Send email", description = "Dispatch an email message using the configured SMTP provider.") - public ResponseEntity> sendMail(@RequestBody EmailRequest request) { + public ResponseEntity> sendMail(@Valid @RequestBody EmailRequest request) { emailService.sendEmail(request.getTo(), request.getSubject(), request.getBody()); String message = "Email sent successfully to " + request.getTo(); return ResponseEntity.ok(ApiResponse.success(message, "Email sent successfully")); diff --git a/src/main/java/com/hacktober/blog/email/EmailRequest.java b/src/main/java/com/hacktober/blog/email/EmailRequest.java index 5dae6ad..886212e 100644 --- a/src/main/java/com/hacktober/blog/email/EmailRequest.java +++ b/src/main/java/com/hacktober/blog/email/EmailRequest.java @@ -1,17 +1,22 @@ package com.hacktober.blog.email; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor public class EmailRequest { + + @NotBlank + @Email String to; + @NotBlank String subject; + @NotBlank String body; - - public EmailRequest(String to, String subject, String body) { - super(); - this.to = to; - this.subject = subject; - this.body = body; - } - + public String getTo() { return to; } diff --git a/src/main/java/com/hacktober/blog/email/EmailService.java b/src/main/java/com/hacktober/blog/email/EmailService.java index ea2689b..9a94b6a 100644 --- a/src/main/java/com/hacktober/blog/email/EmailService.java +++ b/src/main/java/com/hacktober/blog/email/EmailService.java @@ -23,4 +23,10 @@ public void sendEmail(String to, String subject, String text) { message.setText(text); mailSender.send(message); } + public void sendPasswordResetOtp(String to, String otp) { + String subject = "Your HacktoberBlog password reset code"; + String body = "Use this code to reset your password: " + otp + "\nIt expires in 10 minutes."; + sendEmail(to, subject, body); + } + } diff --git a/src/main/java/com/hacktober/blog/user/UserService.java b/src/main/java/com/hacktober/blog/user/UserService.java index 3b3443c..14170a5 100644 --- a/src/main/java/com/hacktober/blog/user/UserService.java +++ b/src/main/java/com/hacktober/blog/user/UserService.java @@ -1,9 +1,11 @@ package com.hacktober.blog.user; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutionException; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import com.google.api.core.ApiFuture; @@ -19,15 +21,18 @@ public class UserService { private static final String USERNAMES_DOC = "usernames"; private final EmailService emailService; + private final PasswordEncoder passwordEncoder; + // Inject EmailService through constructor - public UserService(EmailService emailService) { + public UserService(EmailService emailService, PasswordEncoder passwordEncoder) { this.emailService = emailService; + this.passwordEncoder = passwordEncoder; } /** Create User + Send Email */ public String create(User user) throws InterruptedException, ExecutionException { Firestore db = FirestoreClient.getFirestore(); - user.setPassword(Utils.encode(user.getPassword())); // Encrypt password + user.setPassword(passwordEncoder.encode(user.getPassword())); // Save user to Firestore ApiFuture result = db.collection(COLLECTION_NAME).document(user.getUsername()).set(user); @@ -68,7 +73,7 @@ public List getAll() throws InterruptedException, ExecutionException { public String update(User user) throws InterruptedException, ExecutionException { Firestore db = FirestoreClient.getFirestore(); if (user.getPassword() != null) { - user.setPassword(Utils.encode(user.getPassword())); + user.setPassword(passwordEncoder.encode(user.getPassword())); } ApiFuture result = db.collection(COLLECTION_NAME).document(user.getUsername()).set(user); return result.get().getUpdateTime().toString(); @@ -82,45 +87,66 @@ public String delete(String username) throws InterruptedException, ExecutionExce removeUsername(username); return result.get().getUpdateTime().toString(); } - + public List getAllUsernames() throws InterruptedException, ExecutionException { - Firestore db = FirestoreClient.getFirestore(); - DocumentReference docRef = db.collection(COLLECTION_NAME).document(USERNAMES_DOC); - DocumentSnapshot snapshot = docRef.get().get(); - - if (snapshot.exists() && snapshot.contains("usernames")) { - System.out.println(snapshot.get("usernames")); - return (List) snapshot.get("usernames"); - } - return new ArrayList<>(); - } - - /** Add or remove username from usernames array */ - public String updateUsernames(String username, boolean add) throws InterruptedException, ExecutionException { - Firestore db = FirestoreClient.getFirestore(); - DocumentReference docRef = db.collection("usernames").document("usernames"); - - List currentUsernames = getAllUsernames(); - if (add) { - if (!currentUsernames.contains(username)) { - currentUsernames.add(username); - } - } else { - currentUsernames.remove(username); - } - - ApiFuture result = docRef.update("usernames", currentUsernames); - return result.get().getUpdateTime().toString(); - } - - /** Helper: add a username */ - private void addUsername(String username) throws InterruptedException, ExecutionException { - updateUsernames(username, true); - } - - /** Helper: remove a username */ - public void removeUsername(String username) throws InterruptedException, ExecutionException { - updateUsernames(username, false); - } + Firestore db = FirestoreClient.getFirestore(); + DocumentReference docRef = db.collection(COLLECTION_NAME).document(USERNAMES_DOC); // users/usernames + DocumentSnapshot snapshot = docRef.get().get(); + + if (snapshot.exists() && snapshot.contains("usernames")) { + return (List) snapshot.get("usernames"); + } + return new ArrayList<>(); + } + + + /** Add or remove username using atomic array ops; creates doc if missing */ + public String updateUsernames(String username, boolean add) throws InterruptedException, ExecutionException { + Firestore db = FirestoreClient.getFirestore(); + DocumentReference docRef = db.collection(COLLECTION_NAME).document(USERNAMES_DOC); // users/usernames + + // Ensure the doc exists without clobbering existing fields + docRef.set(Collections.singletonMap("usernames", Collections.emptyList()), SetOptions.merge()).get(); + + ApiFuture write = add + ? docRef.update("usernames", FieldValue.arrayUnion(username)) + : docRef.update("usernames", FieldValue.arrayRemove(username)); + + return write.get().getUpdateTime().toString(); + } + + /** Helper: add a username */ + private void addUsername(String username) throws InterruptedException, ExecutionException { + updateUsernames(username, true); + } + + /** Helper: remove a username */ + public void removeUsername(String username) throws InterruptedException, ExecutionException { + updateUsernames(username, false); + } + + /** Reset password by email (returns true if a user was updated) */ + public boolean resetPasswordByEmail(String email, String rawNewPassword) { + try { + Firestore db = FirestoreClient.getFirestore(); + // Find the user doc by email (username is the doc id, but we don't know it here) + ApiFuture future = db.collection(COLLECTION_NAME) + .whereEqualTo("email", email) + .limit(1) + .get(); + + List docs = future.get().getDocuments(); + if (docs.isEmpty()) { + return false; + } + + DocumentReference docRef = docs.get(0).getReference(); + String hash = passwordEncoder.encode(rawNewPassword); + docRef.update("password", hash).get(); + return true; + } catch (Exception e) { + throw new RuntimeException("Failed to reset password", e); + } + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a8714d5..f5cad74 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,10 +1,24 @@ +# --- Firebase (local dev) --- +firebase.credentials.path=/ABSOLUTE/PATH/TO/firebaseServiceAccountKey.json +firebase.project-id=YOUR_FIREBASE_PROJECT_ID + +# --- App --- spring.application.name=blog + +# --- Mail (use env var for password) --- spring.mail.host=smtp.gmail.com spring.mail.port=587 -spring.mail.username=${GMAIL_ACCOUNT} -spring.mail.password=${GMAIL_APP_KEY} +spring.mail.username=your_email@gmail.com +spring.mail.password=${MAIL_PASSWORD:} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true spring.mail.properties.mail.smtp.starttls.required=true -redis.password = ${REDIS_PASSWORD} -notifications.enabled=${NOTIFICATIONS_ENABLED:true} \ No newline at end of file + +# --- Redis (local dev) --- +redis.host=127.0.0.1 +redis.port=6379 +redis.password=${REDIS_PASSWORD:} +redis.ssl=false + +# --- Feature flags --- +notifications.enabled=${NOTIFICATIONS_ENABLED:true}