This commit is contained in:
Anthony Stirling 2023-08-13 01:12:29 +01:00
parent ad5f057733
commit e791fee38b
15 changed files with 480 additions and 56 deletions

2
.gitignore vendored
View file

@ -116,7 +116,7 @@ watchedFolders/
*.zip *.zip
*.tar.gz *.tar.gz
*.rar *.rar
*.db
/build /build
/.vscode /.vscode

View file

@ -70,6 +70,8 @@ dependencies {
implementation group: 'com.google.zxing', name: 'core', version: '3.5.1' implementation group: 'com.google.zxing', name: 'core', version: '3.5.1'
// https://mvnrepository.com/artifact/org.commonmark/commonmark // https://mvnrepository.com/artifact/org.commonmark/commonmark
implementation 'org.commonmark:commonmark:0.21.0' implementation 'org.commonmark:commonmark:0.21.0'
// https://mvnrepository.com/artifact/com.github.vladimir-bukhtoyarov/bucket4j-core
implementation 'com.github.vladimir-bukhtoyarov:bucket4j-core:7.6.0'
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")

View file

@ -14,9 +14,10 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import stirling.software.SPDF.utils.GeneralUtils; import stirling.software.SPDF.utils.GeneralUtils;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
@SpringBootApplication @SpringBootApplication
@EnableWebSecurity(debug = true) @EnableWebSecurity()
@EnableGlobalMethodSecurity(prePostEnabled = true)
//@EnableScheduling //@EnableScheduling
public class SPdfApplication { public class SPdfApplication {

View file

@ -1,5 +1,6 @@
package stirling.software.SPDF.config; package stirling.software.SPDF.config;
import java.time.Duration;
import java.util.Locale; import java.util.Locale;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@ -10,9 +11,22 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import org.springframework.web.servlet.i18n.SessionLocaleResolver; import org.springframework.web.servlet.i18n.SessionLocaleResolver;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.Refill;
@Configuration @Configuration
public class Beans implements WebMvcConfigurer { public class Beans implements WebMvcConfigurer {
@Bean
public Bucket createRateLimitBucket() {
Refill refill = Refill.of(1000, Duration.ofDays(1));
Bandwidth limit = Bandwidth.classic(1000, refill).withInitialTokens(1000);
return Bucket4j.builder().addLimit(limit).build();
}
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor()); registry.addInterceptor(localeChangeInterceptor());

View file

@ -1,12 +1,20 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.config.security.UserService; import stirling.software.SPDF.config.security.UserService;
@Controller @Controller
@ -25,4 +33,41 @@ public class UserController {
userService.saveUser(username, password); userService.saveUser(username, password);
return "redirect:/login?registered=true"; return "redirect:/login?registered=true";
} }
@PostMapping("/updateUserSettings")
public String updateUserSettings(HttpServletRequest request, Principal principal) {
Map<String, String[]> paramMap = request.getParameterMap();
Map<String, String> updates = new HashMap<>();
System.out.println("Received parameter map: " + paramMap);
for (Map.Entry<String, String[]> entry : paramMap.entrySet()) {
updates.put(entry.getKey(), entry.getValue()[0]);
}
System.out.println("Processed updates: " + updates);
// Assuming you have a method in userService to update the settings for a user
userService.updateUserSettings(principal.getName(), updates);
return "redirect:/account"; // Redirect to a page of your choice after updating
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/saveUser")
public String saveUser(@RequestParam String username, @RequestParam String password, @RequestParam String role) {
userService.saveUser(username, password, role);
return "redirect:/addUsers"; // Redirect to account page after adding the user
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@GetMapping("/admin/deleteUser/{username}")
public String deleteUser(@PathVariable String username) {
userService.deleteUser(username);
return "redirect:/addUsers";
}
} }

View file

@ -4,11 +4,13 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.security.Principal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -16,16 +18,23 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader; import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.support.ResourcePatternUtils; import org.springframework.core.io.support.ResourcePatternUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.config.security.UserService;
import stirling.software.SPDF.model.User;
import stirling.software.SPDF.repository.UserRepository;
@Controller @Controller
@Tag(name = "General", description = "General APIs") @Tag(name = "General", description = "General APIs")
public class GeneralWebController { public class GeneralWebController {
@ -48,6 +57,68 @@ public class GeneralWebController {
return "login"; return "login";
} }
@Autowired
private UserRepository userRepository; // Assuming you have a repository for user operations
@Autowired
private UserService userService; // Assuming you have a repository for user operations
@PreAuthorize("hasRole('ROLE_ADMIN')")
@GetMapping("/addUsers")
public String showAddUserForm(Model model) {
List<User> allUsers = userRepository.findAll();
model.addAttribute("users", allUsers);
return "addUsers";
}
@GetMapping("/account")
public String account(HttpServletRequest request, Model model, Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return "redirect:/";
}
if (authentication != null && authentication.isAuthenticated()) {
Object principal = authentication.getPrincipal();
if (principal instanceof UserDetails) {
// Cast the principal object to UserDetails
UserDetails userDetails = (UserDetails) principal;
// Retrieve username and other attributes
String username = userDetails.getUsername();
// Fetch user details from the database
Optional<User> user = userRepository.findByUsername(username); // Assuming findByUsername method exists
if (!user.isPresent()) {
// Handle error appropriately
return "redirect:/error"; // Example redirection in case of error
}
// Convert settings map to JSON string
ObjectMapper objectMapper = new ObjectMapper();
String settingsJson;
try {
settingsJson = objectMapper.writeValueAsString(user.get().getSettings());
} catch (JsonProcessingException e) {
// Handle JSON conversion error
e.printStackTrace();
return "redirect:/error"; // Example redirection in case of error
}
// Add attributes to the model
model.addAttribute("username", username);
model.addAttribute("role", user.get().getRolesAsString());
model.addAttribute("settings", settingsJson);
}
} else {
return "redirect:/";
}
return "account";
}
@GetMapping("/pipeline") @GetMapping("/pipeline")
@Hidden @Hidden

View file

@ -13,6 +13,18 @@ import jakarta.persistence.Table;
@Table(name = "authorities") @Table(name = "authorities")
public class Authority { public class Authority {
public Authority() {
}
public Authority(String authority, User user) {
this.authority = authority;
this.user = user;
user.getAuthorities().add(this);
}
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;

View file

@ -0,0 +1,11 @@
package stirling.software.SPDF.model;
public final class Role {
public static final String ADMIN = "ROLE_ADMIN";
public static final String USER = "ROLE_USER";
public static final String LIMITED_API_USER = "ROLE_LIMITED_API_USER";
public static final String WEB_ONLY_USER = "ROLE_WEB_ONLY_USER";
}

View file

@ -1,15 +1,22 @@
package stirling.software.SPDF.model; package stirling.software.SPDF.model;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
import jakarta.persistence.CascadeType; import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.MapKeyColumn;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.JoinColumn;
import java.util.Map;
import java.util.HashMap;
import java.util.HashSet;
@Entity @Entity
@Table(name = "users") @Table(name = "users")
public class User { public class User {
@ -25,7 +32,23 @@ public class User {
private boolean enabled; private boolean enabled;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user") @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
private Set<Authority> authorities; private Set<Authority> authorities = new HashSet<>();
@ElementCollection
@MapKeyColumn(name = "setting_key")
@Column(name = "setting_value")
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "username"))
private Map<String, String> settings = new HashMap<>(); // Key-value pairs of settings.
public Map<String, String> getSettings() {
return settings;
}
public void setSettings(Map<String, String> settings) {
this.settings = settings;
}
public String getUsername() { public String getUsername() {
return username; return username;
@ -59,4 +82,18 @@ public class User {
this.authorities = authorities; this.authorities = authorities;
} }
public void addAuthorities(Set<Authority> authorities) {
this.authorities.addAll(authorities);
}
public void addAuthority(Authority authorities) {
this.authorities.add(authorities);
}
public String getRolesAsString() {
return this.authorities.stream()
.map(Authority::getAuthority)
.collect(Collectors.joining(", "));
}
} }

View file

@ -16,9 +16,9 @@ server.error.include-stacktrace=always
server.error.include-exception=true server.error.include-exception=true
server.error.include-message=always server.error.include-message=always
logging.level.org.springframework.web=DEBUG #logging.level.org.springframework.web=DEBUG
logging.level.org.springframework=DEBUG #logging.level.org.springframework=DEBUG
logging.level.org.springframework.security=DEBUG #logging.level.org.springframework.security=DEBUG
login.enabled=true login.enabled=true

View file

@ -55,10 +55,12 @@ settings.downloadOption.2=Open in new window
settings.downloadOption.3=Download file settings.downloadOption.3=Download file
settings.zipThreshold=Zip files when the number of downloaded files exceeds settings.zipThreshold=Zip files when the number of downloaded files exceeds
settings.accountSettings=Account Settings
settings.adminSettings=Admin - View/Add Users
settings.userSettings=User Settings settings.userSettings=User Settings
settings.changeUsername=New Username settings.changeUsername=New Username
settings.changeUsernameButton=Change Username settings.changeUsernameButton=Change Username
settings.password=Password settings.password=Confirmation Password
settings.oldPassword=Old password settings.oldPassword=Old password
settings.newPassword=New Password settings.newPassword=New Password
settings.changePasswordButton=Change Password settings.changePasswordButton=Change Password

View file

@ -0,0 +1,194 @@
<!DOCTYPE html>
<html th:lang="${#locale.toString()}" th:lang-direction="#{language.direction}" xmlns:th="http://www.thymeleaf.org">
<th:block th:insert="~{fragments/common :: head(title=#{settings.userSettings})}"></th:block>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<div th:insert="~{fragments/navbar.html :: navbar}"></div>
<br> <br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<!-- User Settings Title -->
<h2 class="text-center" th:text="#{settings.accountSettings}">User Settings</h2>
<hr>
<!-- At the top of the user settings -->
<h3 class="text-center">Welcome <span th:text="${username}">User</span>!</h3>
<!-- Change Username Form -->
<h4>Change username?</h4>
<form action="/change-username" method="post">
<div class="form-group">
<label for="newUsername" th:text="#{settings.changeUsername}">Change Username</label>
<input type="text" class="form-control" name="newUsername" id="newUsername" placeholder="New Username">
</div>
<div class="form-group">
<label for="password" th:text="#{settings.password}">Password</label>
<input type="password" class="form-control" name="password" id="password" placeholder="Password">
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary" th:text="#{settings.changeUsernameButton}">Change Username</button>
</div>
</form>
<hr> <!-- Separator Line -->
<!-- Change Password Form -->
<h4>Change Password?</h4>
<form action="/change-password" method="post">
<div class="form-group">
<label for="oldPassword" th:text="#{settings.oldPassword}">Old Password</label>
<input type="password" class="form-control" name="oldPassword" id="oldPassword" placeholder="Old Password">
</div>
<div class="form-group">
<label for="newPassword" th:text="#{settings.newPassword}">New Password</label>
<input type="password" class="form-control" name="newPassword" id="newPassword" placeholder="New Password">
</div>
<div class="form-group">
<label for="confirmNewPassword" th:text="#{settings.confirmNewPassword}">Confirm New Password</label>
<input type="password" class="form-control" name="confirmNewPassword" id="confirmNewPassword" placeholder="Confirm New Password">
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary" th:text="#{settings.changePasswordButton}">Change Password</button>
</div>
</form>
<hr> <!-- Separator Line -->
<h4>Sync browser settings with Account</h4>
<div class="container mt-4">
<h3>Settings Comparison:</h3>
<table id="settingsTable" class="table table-bordered table-sm table-striped">
<thead>
<tr>
<th>Property</th>
<th>Account Setting</th>
<th>Web Browser Setting</th>
</tr>
</thead>
<tbody>
<!-- This will be dynamically populated by JavaScript -->
</tbody>
</table>
<div class="buttons-container mt-3 text-center">
<button id="syncToBrowser" class="btn btn-primary btn-sm">Sync Account to Web Browser</button>
<button id="syncToAccount" class="btn btn-secondary btn-sm">Sync Web Browser to Account</button>
</div>
<a th:if="${role == 'ROLE_ADMIN'}" href="addUsers" target="_blank">
<button type="button" class="btn btn-sm btn-outline-primary" th:text="#{settings.adminSettings}">Admin Settings</button>
</a>
</div>
<style>
.container {
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.buttons-container {
margin-top: 20px;
text-align: center;
}
</style>
<script th:inline="javascript">
document.addEventListener("DOMContentLoaded", function() {
const settingsTableBody = document.querySelector("#settingsTable tbody");
/*<![CDATA[*/
var accountSettingsString = /*[[${settings}]]*/ {};
/*]]>*/
var accountSettings = JSON.parse(accountSettingsString);
let allKeys = new Set([...Object.keys(accountSettings), ...Object.keys(localStorage)]);
allKeys.forEach(key => {
if(key === 'debug' || key === '0' || key === '1') return; // Ignoring specific keys
const accountValue = accountSettings[key] || '-';
const browserValue = localStorage.getItem(key) || '-';
const row = settingsTableBody.insertRow();
const propertyCell = row.insertCell(0);
const accountCell = row.insertCell(1);
const browserCell = row.insertCell(2);
propertyCell.textContent = key;
accountCell.textContent = accountValue;
browserCell.textContent = browserValue;
});
document.getElementById('syncToBrowser').addEventListener('click', function() {
// First, clear the local storage
localStorage.clear();
// Then, set the account settings to local storage
for (let key in accountSettings) {
if(key !== 'debug' && key !== '0' && key !== '1') { // Only sync non-ignored keys
localStorage.setItem(key, accountSettings[key]);
}
}
location.reload(); // Refresh the page after sync
});
document.getElementById('syncToAccount').addEventListener('click', function() {
let form = document.createElement("form");
form.method = "POST";
form.action = "/updateUserSettings"; // Your endpoint URL
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if(key !== 'debug' && key !== '0' && key !== '1') { // Only send non-ignored keys
let hiddenField = document.createElement("input");
hiddenField.type = "hidden";
hiddenField.name = key;
hiddenField.value = localStorage.getItem(key);
form.appendChild(hiddenField);
}
}
document.body.appendChild(form);
form.submit();
});
});
</script>
<!-- Sign Out Button -->
<div class="form-group mt-4">
<a href="/logout">
<button type="button" class="btn btn-danger" th:text="#{settings.signOut}">Sign Out</button>
</a>
</div>
</div>
</div>
</div>
</div>
<div th:insert="~{fragments/footer.html :: footer}"></div>
</div>
</body>
</html>

View file

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html th:lang="${#locale.toString()}" th:lang-direction="#{language.direction}" xmlns:th="http://www.thymeleaf.org">
<th:block th:insert="~{fragments/common :: head(title=#{settings.userSettings})}"></th:block>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<div th:insert="~{fragments/navbar.html :: navbar}"></div>
<br> <br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<!-- User Settings Title -->
<h2 class="text-center" th:text="#{settings.accountSettings}">User Settings</h2>
<hr>
<!-- At the top of the user settings -->
<h3 class="text-center">Welcome <span th:text="${username}">User</span>!</h3>
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Roles</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="user : ${users}">
<td th:text="${user.username}"></td>
<td th:text="${user.getRolesAsString()}"></td>
<td>
<a th:href="@{'/admin/deleteUser/' + ${user.username}}">Delete</a>
</td>
</tr>
</tbody>
</table>
<h2>Add New User</h2>
<form action="/admin/saveUser" method="post">
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" class="form-control" name="password" required>
</div>
<div class="form-group">
<label for="role">Role</label>
<select name="role" class="form-control" required>
<option value="ROLE_ADMIN">Admin</option>
<option value="ROLE_USER">User</option>
<option value="ROLE_LIMITED_API_USER">Limited API User</option>
<option value="ROLE_WEB_ONLY_USER">Web Only User</option>
</select>
</div>
<!-- Add other fields as required -->
<button type="submit" class="btn btn-primary">Save User</button>
</form>
</div>
</div>
</div>
</div>
<div th:insert="~{fragments/footer.html :: footer}"></div>
</div>
</body>
</html>

View file

@ -339,48 +339,10 @@
</div> </div>
</div> </div>
<hr> <a href="account" target="_blank">
<h5 th:text="#{settings.userSettings}">User Settings</h5> <button type="button" class="btn btn-sm btn-outline-primary" th:text="#{settings.accountSettings}">Account Settings</button>
<form action="/change-username" method="post">
<div class="form-group">
<label for="newUsername" th:text="#{settings.changeUsername}">Change Username</label>
<input type="text" class="form-control" name="newUsername" id="newUsername" placeholder="New Username">
</div>
<div class="form-group">
<label for="password" th:text="#{settings.password}">Password</label>
<input type="password" class="form-control" name="password" id="password" placeholder="Password">
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary" th:text="#{settings.changeUsernameButton}">Change Username</button>
</div>
</form>
<!-- Change Password Form -->
<form action="/change-password" method="post">
<div class="form-group">
<label for="oldPassword" th:text="#{settings.oldPassword}">Old Password</label>
<input type="password" class="form-control" name="oldPassword" id="oldPassword" placeholder="Old Password">
</div>
<div class="form-group">
<label for="newPassword" th:text="#{settings.newPassword}">New Password</label>
<input type="password" class="form-control" name="newPassword" id="newPassword" placeholder="New Password">
</div>
<div class="form-group">
<label for="confirmNewPassword" th:text="#{settings.confirmNewPassword}">Confirm New Password</label>
<input type="password" class="form-control" name="confirmNewPassword" id="confirmNewPassword" placeholder="Confirm New Password">
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary" th:text="#{settings.changePasswordButton}">Change Password</button>
</div>
</form>
<!-- Sign Out Button -->
<div class="form-group">
<a href="/logout">
<button type="button" class="btn btn-danger" th:text="#{settings.signOut}">Sign Out</button>
</a> </a>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View file

@ -23,9 +23,6 @@
<script src="js/homecard.js"></script> <script src="js/homecard.js"></script>
<div class=" container"> <div class=" container">
<form th:if="${@loginEnabled == true}" action="#" th:action="@{/logout}" method="post">
<input type="submit" value="Logout" />
</form>
<input type="text" id="searchBar" onkeyup="filterCards()" placeholder="Search for features..."> <input type="text" id="searchBar" onkeyup="filterCards()" placeholder="Search for features...">
<div class="features-container "> <div class="features-container ">