adding validated services? patching forcad_local.py

This commit is contained in:
Your Name
2026-08-13 08:32:35 +07:00
parent d485f61169
commit e795614e45
1459 changed files with 420036 additions and 436 deletions

View File

@@ -0,0 +1,18 @@
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -0,0 +1,37 @@
services:
oracle-db:
image: gvenzl/oracle-xe:21-slim
container_name: jform-oracle
environment:
ORACLE_PASSWORD: oracle123
APP_USER: jform
APP_USER_PASSWORD: jform123
ports:
- "1521:1521"
volumes:
- oracle-data:/opt/oracle/oradata
networks:
- jform-network
app:
build: .
container_name: jform
ports:
- "5003:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:oracle:thin:@oracle-db:1521/XEPDB1
SPRING_DATASOURCE_USERNAME: jform
SPRING_DATASOURCE_PASSWORD: jform123
dns:
- 8.8.8.8
- 8.8.4.4
networks:
- jform-network
restart: unless-stopped
volumes:
oracle-data:
networks:
jform-network:
driver: bridge

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.5</version>
<relativePath/>
</parent>
<groupId>com.jform</groupId>
<artifactId>jform</artifactId>
<version>1.0.0</version>
<name>JForm</name>
<description>JForm</description>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc11</artifactId>
<version>23.3.0.23.09</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,13 @@
package com.jform;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

View File

@@ -0,0 +1,43 @@
package com.jform.config;
import jakarta.annotation.PostConstruct;
import java.util.Random;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
@Component
@Configuration
public class ApplicationConfig {
private static Random seedGenerator;
private static long jwtSeed;
private static long formIdSeed;
private static boolean initialized = false;
@PostConstruct
public void init() {
if (!initialized) {
long initialSeed = System.currentTimeMillis();
seedGenerator = new Random(initialSeed);
jwtSeed = seedGenerator.nextLong();
formIdSeed = seedGenerator.nextLong();
initialized = true;
}
}
public static long getJwtSeed() {
if (!initialized) {
throw new IllegalStateException("ApplicationConfig not initialized");
}
return jwtSeed;
}
public static long getFormIdSeed() {
if (!initialized) {
throw new IllegalStateException("ApplicationConfig not initialized");
}
return formIdSeed;
}
}

View File

@@ -0,0 +1,157 @@
package com.jform.controller;
import com.jform.service.UserService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.NonNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Validated
@RestController
@RequestMapping("/api/account")
@RequiredArgsConstructor
public class AccountController {
@NonNull
private final UserService userService;
@PostMapping("/signup")
public ResponseEntity<?> signup(@RequestBody Map<String, String> request,
HttpServletResponse response) {
try {
String username = request.get("username");
String password = request.get("password");
if (username == null || username.isEmpty() ||
password == null || password.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Username and password are required"));
}
var user = userService.signup(username, password);
String jwtToken = userService.generateToken(user);
setCookies(response, user.getUserId(), username, jwtToken);
return ResponseEntity.ok(Map.of(
"success", true,
"username", username,
"userId", user.getUserId()
));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, String> request,
HttpServletResponse response) {
try {
String username = request.get("username");
String password = request.get("password");
if (username == null || username.isEmpty() ||
password == null || password.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Username and password are required"));
}
var user = userService.login(username, password);
String jwtToken = userService.generateToken(user);
setCookies(response, user.getUserId(), username, jwtToken);
return ResponseEntity.ok(Map.of(
"success", true,
"username", username,
"userId", user.getUserId()
));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser(@CookieValue(value = "uid", required = false) String userIdStr,
@CookieValue(value = "u", required = false) String username,
@CookieValue(value = "auth", required = false) String authToken) {
if (username == null || authToken == null || userIdStr == null) {
return ResponseEntity.status(401)
.body(Map.of("authenticated", false));
}
if (userService.validateAuth(username, authToken)) {
try {
Long userId = Long.parseLong(userIdStr);
return ResponseEntity.ok(Map.of(
"authenticated", true,
"userId", userId,
"username", username
));
} catch (NumberFormatException e) {
return ResponseEntity.status(401)
.body(Map.of("authenticated", false));
}
}
return ResponseEntity.status(401)
.body(Map.of("authenticated", false));
}
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
clearCookies(response);
return ResponseEntity.ok(Map.of("success", true));
}
private void setCookies(HttpServletResponse response, Long userId, String username, String jwtToken) {
Cookie userIdCookie = new Cookie("uid", String.valueOf(userId));
userIdCookie.setHttpOnly(true);
userIdCookie.setPath("/");
userIdCookie.setMaxAge(7 * 24 * 60 * 60);
Cookie userCookie = new Cookie("u", username);
userCookie.setHttpOnly(true);
userCookie.setPath("/");
userCookie.setMaxAge(7 * 24 * 60 * 60);
Cookie authCookie = new Cookie("auth", jwtToken);
authCookie.setHttpOnly(true);
authCookie.setPath("/");
authCookie.setMaxAge(7 * 24 * 60 * 60);
response.addCookie(userIdCookie);
response.addCookie(userCookie);
response.addCookie(authCookie);
}
private void clearCookies(HttpServletResponse response) {
Cookie userIdCookie = new Cookie("uid", "");
userIdCookie.setMaxAge(0);
userIdCookie.setPath("/");
Cookie userCookie = new Cookie("u", "");
userCookie.setMaxAge(0);
userCookie.setPath("/");
Cookie authCookie = new Cookie("auth", "");
authCookie.setMaxAge(0);
authCookie.setPath("/");
response.addCookie(userIdCookie);
response.addCookie(userCookie);
response.addCookie(authCookie);
}
}

View File

@@ -0,0 +1,246 @@
package com.jform.controller;
import com.jform.model.Form;
import com.jform.model.Response;
import com.jform.service.FormService;
import com.jform.service.UserService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.NonNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Validated
@RestController
@RequestMapping("/api/form")
@RequiredArgsConstructor
public class FormController {
@NonNull
private final FormService formService;
@NonNull
private final UserService userService;
@PostMapping("/new")
public ResponseEntity<?> createForm(@RequestBody Map<String, Object> request,
HttpServletRequest httpRequest) {
try {
String username = getAuthenticatedUsername(httpRequest);
if (username == null) {
return ResponseEntity.status(401)
.body(Map.of("error", "Authentication required"));
}
Object schemaObj = request.get("schema");
if (schemaObj == null) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Schema is required"));
}
String schemaJson = convertToJson(schemaObj);
String formId = formService.createForm(username, schemaJson);
Optional<Form> createdForm = formService.getForm(formId);
Long formNumber = createdForm.map(Form::getFormNumber).orElse(null);
return ResponseEntity.ok(Map.of(
"formId", formId,
"formNumber", formNumber != null ? formNumber : 0L
));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/{formId}")
public ResponseEntity<?> getForm(@PathVariable String formId) {
Optional<Form> formOpt = formService.getForm(formId);
if (formOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
Form form = formOpt.get();
return ResponseEntity.ok(Map.of(
"formId", form.getFormId(),
"formNumber", form.getFormNumber(),
"schema", form.getSchemaJson(),
"owner", form.getOwnerUsername(),
"createdAt", form.getCreatedAt().toString()
));
}
@PutMapping("/{formId}")
public ResponseEntity<?> updateForm(@PathVariable String formId,
@RequestBody Map<String, Object> request,
HttpServletRequest httpRequest) {
try {
String username = getAuthenticatedUsername(httpRequest);
if (username == null) {
return ResponseEntity.status(401)
.body(Map.of("error", "Authentication required"));
}
Object schemaObj = request.get("schema");
if (schemaObj == null) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Schema is required"));
}
String schemaJson = convertToJson(schemaObj);
formService.updateFormSchema(formId, username, schemaJson);
return ResponseEntity.ok(Map.of("success", true));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/{formId}/submit")
public ResponseEntity<?> submitResponse(@PathVariable String formId,
@RequestBody Map<String, Object> request) {
try {
List<Response> existingResponses = formService.getFormResponses(formId);
if (existingResponses.size() >= 100) {
return ResponseEntity.status(429)
.body(Map.of(
"error", "Достигнут лимит ответов на эту форму (максимум 100)",
"limit", 100,
"current", existingResponses.size()
));
}
String payloadJson = convertToJson(request);
formService.submitResponse(formId, payloadJson);
return ResponseEntity.ok(Map.of(
"success", true,
"responseCount", existingResponses.size() + 1
));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/my-forms")
public ResponseEntity<?> getMyForms(HttpServletRequest httpRequest) {
try {
String username = getAuthenticatedUsername(httpRequest);
if (username == null) {
return ResponseEntity.status(401)
.body(Map.of("error", "Authentication required"));
}
List<Form> forms = formService.getUserForms(username);
List<Map<String, Object>> result = forms.stream()
.map(form -> {
Map<String, Object> formMap = new java.util.HashMap<>();
formMap.put("formId", form.getFormId());
formMap.put("formNumber", form.getFormNumber());
formMap.put("schema", form.getSchemaJson());
formMap.put("owner", form.getOwnerUsername());
formMap.put("createdAt", form.getCreatedAt().toString());
return formMap;
})
.collect(Collectors.toList());
return ResponseEntity.ok(result);
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()));
}
}
@GetMapping({"/{formId}/results", "/{formId}/results.json"})
public ResponseEntity<?> getResults(@PathVariable String formId,
HttpServletRequest httpRequest) {
try {
String username = getAuthenticatedUsername(httpRequest);
if (username == null) {
return ResponseEntity.status(401)
.body(Map.of("error", "Authentication required"));
}
Optional<Form> formOpt = formService.getForm(formId);
if (formOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
Form form = formOpt.get();
if (!form.getOwnerUsername().equals(username)) {
return ResponseEntity.status(403)
.body(Map.of("error", "Access denied"));
}
List<Response> responses = formService.getFormResponses(formId);
List<String> jsonResponses = responses.stream()
.map(Response::getPayloadJson)
.collect(Collectors.toList());
return ResponseEntity.ok(jsonResponses);
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()));
}
}
private String getAuthenticatedUsername(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
String username = null;
String authToken = null;
for (Cookie cookie : cookies) {
if ("u".equals(cookie.getName())) {
username = cookie.getValue();
} else if ("auth".equals(cookie.getName())) {
authToken = cookie.getValue();
}
}
if (username == null || authToken == null) {
return null;
}
if (userService.validateAuth(username, authToken)) {
return username;
}
return null;
}
private String convertToJson(Object obj) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
return mapper.writeValueAsString(obj);
} catch (Exception e) {
return obj.toString();
}
}
}

View File

@@ -0,0 +1,72 @@
package com.jform.controller;
import com.jform.service.UserService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Controller;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
@Validated
@Controller
@RequiredArgsConstructor
public class PageController {
@NonNull
private final UserService userService;
@GetMapping("/f/{formId}")
public String formPage(@PathVariable String formId) {
return "forward:/form.html";
}
@GetMapping("/profile")
public String profile(HttpServletRequest request) {
String username = getAuthenticatedUsername(request);
if (username == null) {
return "redirect:/";
}
return "forward:/profile.html";
}
@GetMapping("/healthz")
@ResponseBody
public String healthz() {
return "OK";
}
private String getAuthenticatedUsername(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
String username = null;
String authToken = null;
for (Cookie cookie : cookies) {
if ("u".equals(cookie.getName())) {
username = cookie.getValue();
} else if ("auth".equals(cookie.getName())) {
authToken = cookie.getValue();
}
}
if (username == null || authToken == null) {
return null;
}
if (userService.validateAuth(username, authToken)) {
return username;
}
return null;
}
}

View File

@@ -0,0 +1,64 @@
package com.jform.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Generated;
import org.hibernate.annotations.GenerationTime;
import java.io.Serializable;
import java.time.LocalDateTime;
@Entity
@Table(name = "FORMS", indexes = {
@Index(name = "idx_forms_owner", columnList = "owner_username"),
@Index(name = "idx_forms_created", columnList = "created_at"),
@Index(name = "idx_forms_number", columnList = "form_number")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@EqualsAndHashCode(of = {"formId"})
public class Form implements Serializable {
private static final long serialVersionUID = 1L;
@Generated(GenerationTime.INSERT)
@Column(name = "form_number", nullable = false, unique = true, insertable = false, updatable = false, columnDefinition = "NUMBER(19,0) GENERATED ALWAYS AS IDENTITY")
private Long formNumber;
@Id
@NotBlank
@Size(min = 16, max = 16)
@Column(name = "form_id", nullable = false, length = 16)
private String formId;
@NotBlank
@Size(min = 1, max = 255)
@Column(name = "owner_username", nullable = false, length = 255)
private String ownerUsername;
@Lob
@NotBlank
@Column(name = "schema_json", nullable = false)
private String schemaJson;
@NotNull
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
public Form(String formId, String ownerUsername, String schemaJson) {
this.formId = formId;
this.ownerUsername = ownerUsername;
this.schemaJson = schemaJson;
this.createdAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,55 @@
package com.jform.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.io.Serializable;
import java.time.LocalDateTime;
@Entity
@Table(name = "RESPONSES", indexes = {
@Index(name = "idx_responses_form_created", columnList = "form_id,created_at"),
@Index(name = "idx_responses_form", columnList = "form_id")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@EqualsAndHashCode(of = {"id"})
public class Response implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@NotBlank
@Size(min = 16, max = 16)
@Column(name = "form_id", nullable = false, length = 16)
private String formId;
@Lob
@NotBlank
@Column(name = "payload_json", nullable = false)
private String payloadJson;
@NotNull
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
public Response(String formId, String payloadJson) {
this.formId = formId;
this.payloadJson = payloadJson;
this.createdAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,61 @@
package com.jform.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.io.Serializable;
import java.time.LocalDateTime;
@Entity
@Table(name = "USERS", indexes = {
@Index(name = "idx_users_username", columnList = "username", unique = true)
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = {"passHash", "authKey"})
@EqualsAndHashCode(of = {"userId", "username"})
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id", nullable = false)
private Long userId;
@NotBlank
@Size(min = 1, max = 255)
@Column(name = "username", nullable = false, unique = true, length = 255)
private String username;
@NotBlank
@Size(min = 1, max = 255)
@Column(name = "pass_hash", nullable = false, length = 255)
private String passHash;
@NotNull
@Size(min = 8, max = 8)
@Column(name = "auth_key", nullable = false, length = 8)
private byte[] authKey;
@NotNull
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
public User(String username, String passHash, byte[] authKey) {
this.username = username;
this.passHash = passHash;
this.authKey = authKey;
this.createdAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,20 @@
package com.jform.repository;
import com.jform.model.Form;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface FormRepository extends JpaRepository<Form, String> {
Optional<Form> findByFormId(String formId);
List<Form> findByOwnerUsername(String ownerUsername);
}

View File

@@ -0,0 +1,17 @@
package com.jform.repository;
import com.jform.model.Response;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ResponseRepository extends JpaRepository<Response, Long> {
List<Response> findByFormIdOrderByCreatedAtAsc(String formId);
}

View File

@@ -0,0 +1,19 @@
package com.jform.repository;
import com.jform.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, String> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
}

View File

@@ -0,0 +1,38 @@
package com.jform.security;
import com.jform.util.IdProvider;
import lombok.RequiredArgsConstructor;
import java.util.Random;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class FormIdGenerator {
@NonNull
private final IdProvider idProvider;
public String generateFormId() {
int seed = idProvider.nextSeed();
Random idRandom = new Random(seed);
long rawId = idRandom.nextLong();
String formId = String.format("%016x", rawId);
return formId;
}
public boolean isValidFormId(String formId) {
return idProvider.validateIdFormat(formId, 16);
}
public String generateShortId() {
return idProvider.generateStringId(8);
}
public boolean checkUniqueness(String formId) {
return formId != null && formId.length() == 16;
}
}

View File

@@ -0,0 +1,165 @@
package com.jform.security;
import com.jform.util.KeyProvider;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import javax.crypto.SecretKey;
import java.nio.ByteBuffer;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Service
@Validated
@RequiredArgsConstructor
public class JwtService {
private static final long TOKEN_VALIDITY_MS = 24 * 60 * 60 * 1000;
@NonNull
private final KeyProvider keyProvider;
public long generateUserKey() {
long key = keyProvider.generateValue();
return key;
}
public byte[] generateKeyData() {
byte[] keyData = longToBytes(generateUserKey());
return keyData;
}
public byte[] longToBytes(long value) {
return ByteBuffer.allocate(8).putLong(value).array();
}
public long bytesToLong(@NotNull @Size(min = 8, max = 8) byte[] bytes) {
if (bytes.length != 8) {
throw new IllegalArgumentException("Key data must be exactly 8 bytes");
}
return ByteBuffer.wrap(bytes).getLong();
}
private SecretKey createSigningKey(byte[] keyData) {
byte[] expandedKey = new byte[32];
for (int i = 0; i < 32; i++) {
expandedKey[i] = keyData[i % 8];
}
return Keys.hmacShaKeyFor(expandedKey);
}
@NotNull
public String generateToken(@NotBlank String username,
@NotNull @Size(min = 8, max = 8) byte[] keyData) {
Map<String, Object> claims = new HashMap<>();
claims.put("u", username);
Date now = new Date();
Date expiration = new Date(now.getTime() + TOKEN_VALIDITY_MS);
SecretKey key = createSigningKey(keyData);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
@NotNull
public String validateToken(@NotBlank String token,
@NotNull @Size(min = 8, max = 8) byte[] keyData) throws Exception {
try {
SecretKey key = createSigningKey(keyData);
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
String username = claims.get("u", String.class);
if (username == null || username.isEmpty()) {
throw new Exception("Username not found in token");
}
return username;
} catch (Exception e) {
throw new Exception("Invalid JWT token");
}
}
public boolean isTokenExpired(String token) {
try {
String[] parts = token.split("\\.");
if (parts.length < 2) {
return true;
}
String payload = new String(java.util.Base64.getUrlDecoder().decode(parts[1]));
return !payload.contains("\"exp\"");
} catch (Exception e) {
return true;
}
}
public Long getIssuedAt(String token) {
try {
String[] parts = token.split("\\.");
if (parts.length < 2) {
return null;
}
String payload = new String(java.util.Base64.getUrlDecoder().decode(parts[1]));
if (payload.contains("\"iat\"")) {
int start = payload.indexOf("\"iat\"") + 6;
int end = payload.indexOf(",", start);
if (end == -1) end = payload.indexOf("}", start);
if (end > start) {
String iatStr = payload.substring(start, end).trim();
return Long.parseLong(iatStr);
}
}
return null;
} catch (Exception e) {
return null;
}
}
public String extractUsernameUnsafe(String token) {
try {
String[] parts = token.split("\\.");
if (parts.length < 2) {
return null;
}
String payload = new String(java.util.Base64.getUrlDecoder().decode(parts[1]));
if (payload.contains("\"u\"")) {
int start = payload.indexOf("\"u\"") + 5;
int end = payload.indexOf("\"", start);
if (end > start) {
return payload.substring(start, end);
}
}
return null;
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,91 @@
package com.jform.service;
import com.jform.model.Form;
import com.jform.model.Response;
import com.jform.repository.FormRepository;
import com.jform.repository.ResponseRepository;
import com.jform.security.FormIdGenerator;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Optional;
@Service
@Validated
@Transactional
@RequiredArgsConstructor
public class FormService {
@NonNull
private final FormRepository formRepository;
@NonNull
private final ResponseRepository responseRepository;
@NonNull
private final FormIdGenerator formIdGenerator;
@NotNull
public String createForm(@NotBlank String ownerUsername, @NotBlank String schemaJson) {
String formId = formIdGenerator.generateFormId();
Form form = new Form(formId, ownerUsername, schemaJson);
formRepository.save(form);
return formId;
}
@NotNull
public Optional<Form> getForm(@NotBlank String formId) {
return formRepository.findByFormId(formId);
}
@NotNull
public List<Form> getUserForms(@NotBlank String username) {
return formRepository.findByOwnerUsername(username);
}
public void updateFormSchema(@NotBlank String formId,
@NotBlank String ownerUsername,
@NotBlank String newSchemaJson) throws Exception {
Optional<Form> formOpt = formRepository.findByFormId(formId);
if (formOpt.isEmpty()) {
throw new Exception("Form not found");
}
Form form = formOpt.get();
if (!form.getOwnerUsername().equals(ownerUsername)) {
throw new Exception("Access denied");
}
form.setSchemaJson(newSchemaJson);
formRepository.save(form);
}
public void submitResponse(@NotBlank String formId, @NotBlank String payloadJson) throws Exception {
Optional<Form> formOpt = formRepository.findByFormId(formId);
if (formOpt.isEmpty()) {
throw new Exception("Form not found");
}
Response response = new Response(formId, payloadJson);
responseRepository.save(response);
}
@NotNull
public List<Response> getFormResponses(@NotBlank String formId) {
return responseRepository.findByFormIdOrderByCreatedAtAsc(formId);
}
}

View File

@@ -0,0 +1,94 @@
package com.jform.service;
import com.jform.model.User;
import com.jform.repository.UserRepository;
import com.jform.security.JwtService;
import org.springframework.lang.NonNull;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Optional;
@Service
@Validated
@Transactional
public class UserService {
private final UserRepository userRepository;
private final JwtService jwtService;
private final BCryptPasswordEncoder passwordEncoder;
public UserService(@NonNull UserRepository userRepository,
@NonNull JwtService jwtService) {
this.userRepository = userRepository;
this.jwtService = jwtService;
this.passwordEncoder = new BCryptPasswordEncoder();
}
@NotNull
public User signup(@NotBlank String username, @NotBlank String password) throws Exception {
if (userRepository.existsByUsername(username)) {
throw new Exception("Username already exists");
}
String passHash = passwordEncoder.encode(password);
byte[] keyData = jwtService.generateKeyData();
User user = new User(username, passHash, keyData);
user = userRepository.save(user);
return user;
}
@NotNull
public User login(@NotBlank String username, @NotBlank String password) throws Exception {
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isEmpty()) {
throw new Exception("Invalid username or password");
}
User user = userOpt.get();
if (!passwordEncoder.matches(password, user.getPassHash())) {
throw new Exception("Invalid username or password");
}
return user;
}
@NotNull
public String generateToken(@NotNull User user) {
return jwtService.generateToken(user.getUsername(), user.getAuthKey());
}
public boolean validateAuth(@NotBlank String username, @NotBlank String token) {
try {
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isEmpty()) {
return false;
}
User user = userOpt.get();
String tokenUsername = jwtService.validateToken(token, user.getAuthKey());
return username.equals(tokenUsername);
} catch (Exception e) {
return false;
}
}
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username);
}
}

View File

@@ -0,0 +1,42 @@
package com.jform.util;
import com.jform.config.ApplicationConfig;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Component;
import java.util.Random;
import jakarta.annotation.PostConstruct;
@Component
@DependsOn("applicationConfig")
public class IdProvider {
private Random idGenerator;
@PostConstruct
public void init() {
long seed = ApplicationConfig.getFormIdSeed();
this.idGenerator = new Random(seed);
}
public synchronized int nextSeed() {
int seed = idGenerator.nextInt();
return seed;
}
public synchronized String generateStringId(int length) {
StringBuilder sb = new StringBuilder();
String chars = "0123456789abcdefghijklmnopqrstuvwxyz";
for (int i = 0; i < length; i++) {
sb.append(chars.charAt(idGenerator.nextInt(chars.length())));
}
return sb.toString();
}
public synchronized long generateNumericId(long bound) {
return Math.abs(idGenerator.nextLong()) % bound;
}
public boolean validateIdFormat(String id, int expectedLength) {
return id != null && id.length() == expectedLength && id.matches("[0-9a-f]+");
}
}

View File

@@ -0,0 +1,43 @@
package com.jform.util;
import com.jform.config.ApplicationConfig;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Component;
import java.util.Random;
import jakarta.annotation.PostConstruct;
@Component
@DependsOn("applicationConfig")
public class KeyProvider {
private Random dataGenerator;
@PostConstruct
public void init() {
long seed = ApplicationConfig.getJwtSeed();
this.dataGenerator = new Random(seed);
}
public synchronized long generateValue() {
long value = dataGenerator.nextLong();
return value;
}
public synchronized int generateInt(int bound) {
return dataGenerator.nextInt(bound);
}
public synchronized byte[] generateBytes(int length) {
byte[] bytes = new byte[length];
dataGenerator.nextBytes(bytes);
return bytes;
}
public synchronized boolean generateBoolean() {
return dataGenerator.nextBoolean();
}
public synchronized double generateDouble() {
return dataGenerator.nextDouble();
}
}

View File

@@ -0,0 +1,37 @@
spring:
application:
name: jform
datasource:
url: jdbc:oracle:thin:@oracle-db:1521/XEPDB1
username: jform
password: jform123
driver-class-name: oracle.jdbc.OracleDriver
hikari:
maximum-pool-size: 10
minimum-idle: 5
jpa:
database-platform: org.hibernate.dialect.OracleDialect
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
jdbc:
batch_size: 20
web:
resources:
static-locations: classpath:/static/
server:
port: 8080
servlet:
context-path: /

View File

@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ошибка</title>
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', sans-serif;
background: #f0ebf8;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.container {
max-width: 500px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15);
padding: 60px 40px;
text-align: center;
}
.error-icon {
font-size: 80px;
color: #ea4335;
margin-bottom: 24px;
}
h1 {
font-family: 'Google Sans', sans-serif;
font-size: 28px;
font-weight: 400;
color: #202124;
margin-bottom: 12px;
}
p {
color: #5f6368;
font-size: 16px;
line-height: 24px;
margin-bottom: 32px;
}
a {
display: inline-flex;
align-items: center;
gap: 8px;
background: #673AB7;
color: white;
padding: 10px 24px;
font-family: 'Google Sans', sans-serif;
font-size: 14px;
font-weight: 500;
border-radius: 4px;
text-decoration: none;
transition: all 0.2s;
letter-spacing: 0.25px;
}
a:hover {
background: #5E35B1;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15);
}
.material-icons {
font-size: 18px;
}
</style>
</head>
<body>
<div class="container">
<div class="error-icon">
<span class="material-icons" style="font-size: inherit;">error_outline</span>
</div>
<h1>Ошибка</h1>
<p>Страница не найдена или произошла ошибка при обработке запроса.</p>
<a href="/">
<span class="material-icons">arrow_back</span>
Вернуться на главную
</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,496 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Форма</title>
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', sans-serif;
background: #f0ebf8;
min-height: 100vh;
padding: 24px;
}
.header {
max-width: 760px;
margin: 0 auto 24px;
display: flex;
align-items: center;
gap: 16px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #673AB7 0%, #9C27B0 100%);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
}
.logo-text {
font-family: 'Google Sans', sans-serif;
font-size: 18px;
font-weight: 400;
color: #202124;
}
.back-link {
color: #1a73e8;
text-decoration: none;
font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
}
.back-link:hover {
text-decoration: underline;
}
.container {
max-width: 760px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15);
margin-bottom: 12px;
overflow: hidden;
}
.card-header {
border-top: 10px solid #673AB7;
padding: 24px;
background: white;
}
.form-title {
font-family: 'Google Sans', sans-serif;
font-size: 32px;
font-weight: 400;
color: #202124;
margin-bottom: 8px;
line-height: 40px;
}
.form-description {
font-size: 14px;
color: #5f6368;
line-height: 20px;
}
.card-content {
padding: 24px;
}
.form-group {
margin-bottom: 24px;
}
.question-label {
font-size: 16px;
color: #202124;
margin-bottom: 12px;
font-weight: 400;
display: flex;
align-items: center;
gap: 4px;
}
.required {
color: #d93025;
margin-left: 4px;
}
input[type="text"],
input[type="email"],
input[type="number"],
input[type="tel"],
input[type="url"],
textarea,
select {
width: 100%;
padding: 13px 16px;
font-size: 16px;
font-family: 'Roboto', sans-serif;
border: none;
border-bottom: 1px solid #dadce0;
background: transparent;
transition: all 0.2s;
}
input[type="text"]:hover,
input[type="email"]:hover,
input[type="number"]:hover,
input[type="tel"]:hover,
input[type="url"]:hover,
textarea:hover,
select:hover {
border-bottom: 1px solid #202124;
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="number"]:focus,
input[type="tel"]:focus,
input[type="url"]:focus,
textarea:focus,
select:focus {
outline: none;
border-bottom: 2px solid #673AB7;
}
textarea {
min-height: 100px;
resize: vertical;
}
select {
padding: 12px 16px;
background: white;
border: 1px solid #dadce0;
border-radius: 4px;
cursor: pointer;
}
select:hover {
border: 1px solid #202124;
}
select:focus {
border: 2px solid #673AB7;
}
button[type="submit"] {
background: #673AB7;
color: white;
padding: 10px 24px;
font-family: 'Google Sans', sans-serif;
font-size: 14px;
font-weight: 500;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 0.25px;
display: flex;
align-items: center;
gap: 8px;
}
button[type="submit"]:hover {
background: #5E35B1;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15);
}
.loading {
text-align: center;
padding: 60px 20px;
color: #5f6368;
font-size: 14px;
}
.error {
background: #fce8e6;
color: #c5221f;
padding: 16px;
border-radius: 8px;
font-size: 14px;
margin-top: 20px;
display: flex;
align-items: center;
gap: 12px;
}
.success {
background: #e6f4ea;
color: #137333;
padding: 16px;
border-radius: 8px;
font-size: 14px;
margin-top: 20px;
display: flex;
align-items: center;
gap: 12px;
}
.material-icons {
font-size: 20px;
vertical-align: middle;
}
@media (max-width: 768px) {
body {
padding: 12px;
}
.card-header,
.card-content {
padding: 20px 16px;
}
.form-title {
font-size: 24px;
line-height: 32px;
}
}
</style>
</head>
<body>
<div class="header">
<div class="logo">
<div class="logo-icon">
<span class="material-icons">description</span>
</div>
<span class="logo-text">Forms</span>
</div>
</div>
<div class="container">
<div style="margin-bottom: 16px;">
<a href="/" class="back-link">
<span class="material-icons" style="font-size: 18px;">arrow_back</span>
На главную
</a>
</div>
<div id="loading" class="loading">
<span class="material-icons" style="font-size: 48px; color: #673AB7;">hourglass_empty</span>
<p style="margin-top: 16px;">Загрузка формы...</p>
</div>
<form id="response-form" style="display: none;">
<div class="card">
<div class="card-header">
<h1 class="form-title" id="form-title">Форма</h1>
<p class="form-description" id="form-description"></p>
</div>
</div>
<div id="fields-container"></div>
<div class="card">
<div class="card-content">
<button type="submit">
<span class="material-icons" style="font-size: 18px;">send</span>
Отправить
</button>
</div>
</div>
</form>
<div id="message"></div>
</div>
<script>
const pathParts = window.location.pathname.split('/');
const formId = pathParts[pathParts.length - 1];
if (!formId || formId === 'form.html') {
document.getElementById('loading').innerHTML =
'<span class="material-icons" style="font-size: 48px; color: #c5221f;">error</span>' +
'<p style="margin-top: 16px; color: #5f6368;">ID формы не указан</p>';
}
window.onload = async function() {
if (formId && formId !== 'form.html') {
await loadForm();
}
};
async function loadForm() {
try {
const response = await fetch('/api/form/' + formId);
if (!response.ok) {
document.getElementById('loading').innerHTML =
'<span class="material-icons" style="font-size: 48px; color: #c5221f;">error</span>' +
'<p style="margin-top: 16px; color: #5f6368;">Форма не найдена</p>';
return;
}
const data = await response.json();
const schema = JSON.parse(data.schema);
if (schema.title) {
document.getElementById('form-title').textContent = schema.title;
}
if (schema.description) {
document.getElementById('form-description').textContent = schema.description;
}
const container = document.getElementById('fields-container');
if (schema.fields && Array.isArray(schema.fields)) {
schema.fields.forEach(field => {
const card = document.createElement('div');
card.className = 'card';
const cardContent = document.createElement('div');
cardContent.className = 'card-content';
const formGroup = document.createElement('div');
formGroup.className = 'form-group';
const label = document.createElement('div');
label.className = 'question-label';
label.innerHTML = (field.label || field.name) + (field.required ? '<span class="required">*</span>' : '');
formGroup.appendChild(label);
let input;
switch (field.type) {
case 'textarea':
input = document.createElement('textarea');
break;
case 'select':
input = document.createElement('select');
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = 'Выберите вариант';
input.appendChild(emptyOption);
if (field.options && Array.isArray(field.options)) {
field.options.forEach(opt => {
const option = document.createElement('option');
option.value = opt;
option.textContent = opt;
input.appendChild(option);
});
}
break;
default:
input = document.createElement('input');
input.type = field.type || 'text';
}
input.name = field.name;
input.id = 'field-' + field.name;
if (field.required) {
input.required = true;
}
if (field.placeholder) {
input.placeholder = field.placeholder;
}
formGroup.appendChild(input);
cardContent.appendChild(formGroup);
card.appendChild(cardContent);
container.appendChild(card);
});
} else {
const card = document.createElement('div');
card.className = 'card';
const cardContent = document.createElement('div');
cardContent.className = 'card-content';
const formGroup = document.createElement('div');
formGroup.className = 'form-group';
const label = document.createElement('div');
label.className = 'question-label';
label.textContent = 'Ваш ответ (JSON):';
formGroup.appendChild(label);
const textarea = document.createElement('textarea');
textarea.name = 'response';
textarea.placeholder = 'Введите ваш ответ в формате JSON';
textarea.style.minHeight = '200px';
formGroup.appendChild(textarea);
cardContent.appendChild(formGroup);
card.appendChild(cardContent);
container.appendChild(card);
}
document.getElementById('loading').style.display = 'none';
document.getElementById('response-form').style.display = 'block';
} catch (error) {
document.getElementById('loading').innerHTML =
'<span class="material-icons" style="font-size: 48px; color: #c5221f;">error</span>' +
'<p style="margin-top: 16px; color: #5f6368;">Ошибка загрузки формы: ' + error.message + '</p>';
}
}
document.getElementById('response-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const response = {};
for (let [key, value] of formData.entries()) {
response[key] = value;
}
try {
const res = await fetch('/api/form/' + formId + '/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(response)
});
const data = await res.json();
if (res.ok) {
document.getElementById('message').innerHTML =
'<div class="success">' +
'<span class="material-icons">check_circle</span>' +
'Ваш ответ сохранён. Спасибо за участие!' +
(data.responseCount ? ' (Ответ #' + data.responseCount + ')' : '') +
'</div>';
this.reset();
window.scrollTo({ top: 0, behavior: 'smooth' });
} else if (res.status === 429) {
document.getElementById('message').innerHTML =
'<div class="error">' +
'<span class="material-icons">block</span>' +
'<strong>Форма закрыта</strong><br>' +
(data.error || 'Достигнут лимит ответов на эту форму') +
'</div>';
this.querySelectorAll('input, textarea, select, button').forEach(el => el.disabled = true);
} else {
document.getElementById('message').innerHTML =
'<div class="error">' +
'<span class="material-icons">error</span>' +
'Ошибка: ' + (data.error || 'не удалось отправить ответ') +
'</div>';
}
} catch (error) {
document.getElementById('message').innerHTML =
'<div class="error">' +
'<span class="material-icons">error</span>' +
'Ошибка соединения с сервером' +
'</div>';
}
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff