diff --git a/MusicBand_LoadTest.jmx b/MusicBand_LoadTest.jmx new file mode 100644 index 0000000..bfa90e0 --- /dev/null +++ b/MusicBand_LoadTest.jmx @@ -0,0 +1,381 @@ + + + + + + false + false + + + + BASE_URL + http://localhost:8080/is1/api + = + + + + + + + + continue + + false + 1 + + 10 + 2 + 1 + 1 + false + + + + + + true + + + + false + { + "username": "testuser${__threadNum}", + "password": "password123" +} + = + + + + localhost + 8080 + http + + /is1/api/auth/register + POST + true + false + true + false + + + + + + + + + Content-Type + application/json + + + + + + token + $.token + 1 + NOTFOUND + + + + + + + continue + + false + 1 + + 10 + 2 + 5 + true + + + + + true + + + + false + { + "username": "testuser${__threadNum}", + "password": "password123" +} + = + + + + localhost + 8080 + http + /is1/api/auth/login + POST + true + true + + + + + + Content-Type + application/json + + + + + + token + $.token + 1 + + + + + + true + + + + false + { + "name": "Test Band ${__threadNum}_${__time(,)}", + "genre": "ROCK", + "coordinates": { + "x": ${__Random(1,1000)}, + "y": ${__Random(1,1000)} + }, + "numberOfParticipants": ${__Random(1,10)}, + "singlesCount": ${__Random(1,50)}, + "albumsCount": ${__Random(1,20)}, + "establishmentDate": "20${__Random(10,23)}-01-${__Random(10,28)}T10:00:00+03:00", + "description": "Test band ${__threadNum}" +} + = + + + + localhost + 8080 + /is1/api/music-bands + POST + true + + + + + + Content-Type + application/json + + + Authorization + Bearer ${token} + + + + + + bandId + $.id + 1 + + + + + + true + + + + false + { + "name": "Updated Band ${__threadNum}_${__time(,)}", + "genre": "JAZZ", + "coordinates": { + "x": ${__Random(1,1000)}, + "y": ${__Random(1,1000)} + }, + "numberOfParticipants": ${__Random(1,10)}, + "singlesCount": ${__Random(1,50)}, + "albumsCount": ${__Random(1,20)}, + "establishmentDate": "20${__Random(10,23)}-02-${__Random(10,28)}T10:00:00+03:00" +} + = + + + + localhost + 8080 + /is1/api/music-bands/${bandId} + PUT + true + + + + + + Content-Type + application/json + + + Authorization + Bearer ${token} + + + + + + + + localhost + 8080 + /is1/api/music-bands/${bandId} + DELETE + true + + + + + + Authorization + Bearer ${token} + + + + + + + + + continue + + false + 1 + + 5 + 0 + 10 + true + + + + true + + + + false + { + "username": "admin", + "password": "admin" +} + = + + + + localhost + 8080 + /is1/api/auth/login + POST + true + + + + + + Content-Type + application/json + + + + + + token + $.token + 1 + + + + + + true + + + + false + { + "name": "Unique Test Band", + "genre": "ROCK", + "coordinates": {"x": 100, "y": 100}, + "numberOfParticipants": 5, + "singlesCount": 10, + "establishmentDate": "2023-01-01T10:00:00+03:00" +} + = + + + + localhost + 8080 + /is1/api/music-bands + POST + true + + + + + + Content-Type + application/json + + + Authorization + Bearer ${token} + + + + + + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + + + + + \ No newline at end of file diff --git a/bands.json b/bands.json new file mode 100644 index 0000000..ab0950b --- /dev/null +++ b/bands.json @@ -0,0 +1,57 @@ +{ + "bands": [ + { + "name": "The Rolling Stones", + "genre": "ROCK", + "coordinates": { + "x": 100, + "y": 200 + }, + "numberOfParticipants": 4, + "singlesCount": 50, + "albumsCount": 30, + "establishmentDate": "1962-07-12T10:00:00+03:00", + "description": "British rock band formed in London", + "bestAlbum": { + "name": "Sticky Fingers", + "tracks": 10 + } + }, + { + "name": "Pink Floyd", + "genre": "PSYCHEDELIC_CLOUD_RAP", + "coordinates": { + "x": 150, + "y": 250 + }, + "numberOfParticipants": 5, + "singlesCount": 30, + "albumsCount": 15, + "establishmentDate": "1965-01-01T12:00:00+03:00", + "description": "Progressive rock band", + "bestAlbum": { + "name": "The Dark Side of the Moon", + "tracks": 10 + }, + "frontMan": { + "name": "David Gilmour", + "eyeColor": "BLUE", + "hairColor": "BROWN", + "height": 183.0, + "nationality": "SOUTH_KOREA", + "location": { + "x": 10, + "y": 20.5, + "z": 30 + } + } + }, + { + "name": "Miles Davis Quintet", + "genre": "JAZZ", + "coordinates": { + "x": 300, + "y": 400 + }, + "numberOfParticipants": 5, + "singlesCount": 20, \ No newline at end of file diff --git a/bands.xml b/bands.xml new file mode 100644 index 0000000..52148ff --- /dev/null +++ b/bands.xml @@ -0,0 +1,46 @@ + + + + + Queen + ROCK + + 500 + 600 + + 4 + 40 + 15 + 1970-06-27T10:00:00+03:00 + British rock band + + A Night at the Opera + 12 + + + Freddie Mercury + BROWN + BROWN + 177.0 + THAILAND + + + + Led Zeppelin + ROCK + + 700 + 800 + + 4 + 25 + 9 + 1968-09-07T11:00:00+03:00 + English rock band + + Led Zeppelin IV + 8 + + + + \ No newline at end of file diff --git a/bands.yaml b/bands.yaml new file mode 100644 index 0000000..68f6d03 --- /dev/null +++ b/bands.yaml @@ -0,0 +1,57 @@ +bands: + - name: "Nirvana" + genre: ROCK + coordinates: + x: 900 + y: 1000 + numberOfParticipants: 3 + singlesCount: 15 + albumsCount: 3 + establishmentDate: "1987-01-01T09:00:00+03:00" + description: "American grunge band" + bestAlbum: + name: "Nevermind" + tracks: 12 + frontMan: + name: "Kurt Cobain" + eyeColor: BLUE + height: 175.0 + nationality: NORTH_KOREA + + - name: "The Beatles" + genre: ROCK + coordinates: + x: 1100 + y: 1200 + numberOfParticipants: 4 + singlesCount: 63 + albumsCount: 13 + establishmentDate: "1960-08-18T10:30:00+03:00" + description: "English rock band" + bestAlbum: + name: "Abbey Road" + tracks: 17 + frontMan: + name: "John Lennon" + eyeColor: BROWN + hairColor: BROWN + height: 179.0 + nationality: INDIA + location: + x: 5 + y: 10.0 + z: 15 + + - name: "Coltrane Quartet" + genre: JAZZ + coordinates: + x: 1300 + y: 1400 + numberOfParticipants: 4 + singlesCount: 12 + albumsCount: 20 + establishmentDate: "1960-05-01T13:00:00+03:00" + description: "Jazz quartet" + bestAlbum: + name: "A Love Supreme" + tracks: 4 \ No newline at end of file diff --git a/build.sh b/build.sh index 007be9d..0b2d2b5 100755 --- a/build.sh +++ b/build.sh @@ -1,35 +1,53 @@ #!/bin/bash -# Build script for Music Band Information System - -echo "Building Music Band Information System..." - -# Clean previous builds -echo "Cleaning previous builds..." -mvn clean - -# Compile and package -echo "Compiling and packaging..." -mvn package -DskipTests - -# Check if build was successful -if [ $? -eq 0 ]; then - echo "Build successful! WAR file created at target/is1.war" - echo "File size: $(ls -lh target/is1.war | awk '{print $5}')" - - # Copy to WildFly deployments directory - echo "" - echo "Deploying to WildFly..." - cp target/is1.war wildfly/standalone/deployments/ - - if [ $? -eq 0 ]; then - echo "WAR file successfully copied to wildfly/standalone/deployments/" - echo "Application will be auto-deployed when WildFly starts." - else - echo "Failed to copy WAR file to WildFly deployments directory" - echo "Make sure wildfly/standalone/deployments/ directory exists" - fi -else - echo "Build failed!" +set -e +if [ ! -f "pom.xml" ]; then + echo "Error: pom.xml not found. Please run this script from the project root." exit 1 -fi \ No newline at end of file +fi + +echo "[1/5] Cleaning previous builds..." +mvn clean +echo "Clean completed" +echo "" + +echo "[2/5] Compiling sources..." +mvn compile +echo "Compilation completed" +echo "" + +echo "[3/5] Running tests..." +mvn test -DskipTests +echo "Tests completed (skipped)" +echo "" + +echo "[4/5] Packaging WAR file..." +mvn package -DskipTests +echo "Packaging completed" +echo "" + +if [ ! -f "target/is1.war" ]; then + echo "Error: WAR file not created. Build failed." + exit 1 +fi + +WAR_SIZE=$(ls -lh target/is1.war | awk '{print $5}') +echo "WAR file created: target/is1.war (${WAR_SIZE})" +echo "" + +echo "[5/5] Deploying to WildFly..." + +WILDFLY_DEPLOYMENTS="wildfly/standalone/deployments" + +if [ -d "$WILDFLY_DEPLOYMENTS" ]; then + cp target/is1.war "$WILDFLY_DEPLOYMENTS/" + echo "WAR file copied to: $WILDFLY_DEPLOYMENTS/is1.war" +else + echo "WildFly deployments directory not found at: $WILDFLY_DEPLOYMENTS" +fi + +echo "" +echo "✓ Build successful" +echo "✓ WAR file: target/is1.war (${WAR_SIZE})" +echo "✓ Ready for deployment" +echo "" \ No newline at end of file diff --git a/pom.xml b/pom.xml index 2cd682a..7c50233 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ ru.akarpov is1 - 1.0.0 + 2.0.0 war @@ -49,6 +49,44 @@ jackson-datatype-jsr310 2.15.2 + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + 2.15.2 + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + 2.15.2 + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + + org.mindrot + jbcrypt + 0.4 + @@ -73,4 +111,4 @@ - + \ No newline at end of file diff --git a/schema.sql b/schema.sql index 1af4426..e8a6ea3 100644 --- a/schema.sql +++ b/schema.sql @@ -1,13 +1,11 @@ --- Database schema for Music Band Information System - --- Create sequences for auto-generated IDs CREATE SEQUENCE IF NOT EXISTS coordinates_seq START 1; CREATE SEQUENCE IF NOT EXISTS location_seq START 1; CREATE SEQUENCE IF NOT EXISTS album_seq START 1; CREATE SEQUENCE IF NOT EXISTS person_seq START 1; CREATE SEQUENCE IF NOT EXISTS music_band_seq START 1; +CREATE SEQUENCE IF NOT EXISTS users_seq START 1; +CREATE SEQUENCE IF NOT EXISTS import_operation_seq START 1; --- Create tables CREATE TABLE IF NOT EXISTS coordinates ( id BIGINT PRIMARY KEY DEFAULT nextval('coordinates_seq'), x BIGINT NOT NULL, @@ -37,6 +35,23 @@ CREATE TABLE IF NOT EXISTS person ( nationality VARCHAR(20) NOT NULL CHECK (nationality IN ('INDIA', 'THAILAND', 'SOUTH_KOREA', 'NORTH_KOREA')) ); +CREATE TABLE IF NOT EXISTS users ( + id BIGINT PRIMARY KEY DEFAULT nextval('users_seq'), + username VARCHAR(50) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL CHECK (role IN ('USER', 'ADMIN')), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS import_operation ( + id BIGINT PRIMARY KEY DEFAULT nextval('import_operation_seq'), + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL CHECK (status IN ('SUCCESS', 'FAILED')), + objects_count INTEGER, + error_message TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS music_band ( id BIGINT PRIMARY KEY DEFAULT nextval('music_band_seq'), name VARCHAR(255) NOT NULL CHECK (name <> ''), @@ -49,16 +64,33 @@ CREATE TABLE IF NOT EXISTS music_band ( best_album_id BIGINT REFERENCES album(id) ON DELETE SET NULL, albums_count INTEGER CHECK (albums_count IS NULL OR albums_count > 0), establishment_date TIMESTAMP WITH TIME ZONE NOT NULL, - front_man_id BIGINT REFERENCES person(id) ON DELETE SET NULL + front_man_id BIGINT REFERENCES person(id) ON DELETE SET NULL, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ); --- Create indexes for better performance +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'music_band' + ) AND NOT EXISTS ( + SELECT 1 FROM music_band WHERE created_by IS NOT NULL LIMIT 1 + ) THEN + UPDATE music_band + SET created_by = (SELECT id FROM users WHERE username = 'admin' LIMIT 1) + WHERE created_by IS NULL; + END IF; +END $$; + CREATE INDEX IF NOT EXISTS idx_music_band_name ON music_band(name); CREATE INDEX IF NOT EXISTS idx_music_band_genre ON music_band(genre); CREATE INDEX IF NOT EXISTS idx_music_band_participants ON music_band(number_of_participants); CREATE INDEX IF NOT EXISTS idx_music_band_establishment_date ON music_band(establishment_date); +CREATE INDEX IF NOT EXISTS idx_music_band_created_by ON music_band(created_by); +CREATE INDEX IF NOT EXISTS idx_import_operation_user ON import_operation(user_id); +CREATE INDEX IF NOT EXISTS idx_import_operation_status ON import_operation(status); +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); --- Database functions for special operations CREATE OR REPLACE FUNCTION calculate_average_albums_count() RETURNS DOUBLE PRECISION AS $$ BEGIN @@ -112,4 +144,8 @@ BEGIN RAISE EXCEPTION 'Band with ID % not found or cannot remove participant (minimum 1 required)', band_id; END IF; END; -$$ LANGUAGE plpgsql; \ No newline at end of file +$$ LANGUAGE plpgsql; + +INSERT INTO users (username, password_hash, role) +VALUES ('admin', '$2a$10$SIVWLONFduZBQmozzHmVbO21zvCfXeg649BvXLwbYxL/8EOBGCqSG', 'ADMIN') +ON CONFLICT (username) DO NOTHING; \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/dto/AuthResponse.java b/src/main/java/ru/akarpov/is1/dto/AuthResponse.java new file mode 100644 index 0000000..cbac590 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/dto/AuthResponse.java @@ -0,0 +1,49 @@ +package ru.akarpov.is1.dto; + +public class AuthResponse { + private String token; + private Long userId; + private String username; + private String role; + + public AuthResponse() {} + + public AuthResponse(String token, Long userId, String username, String role) { + this.token = token; + this.userId = userId; + this.username = username; + this.role = role; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/dto/ImportHistoryResponse.java b/src/main/java/ru/akarpov/is1/dto/ImportHistoryResponse.java new file mode 100644 index 0000000..524b1c8 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/dto/ImportHistoryResponse.java @@ -0,0 +1,42 @@ +package ru.akarpov.is1.dto; + +import java.util.Date; + +public class ImportHistoryResponse { + private Long id; + private String username; + private String status; + private Integer objectsCount; + private String errorMessage; + private Date createdAt; + + public ImportHistoryResponse() {} + + public ImportHistoryResponse(Long id, String username, String status, Integer objectsCount, + String errorMessage, Date createdAt) { + this.id = id; + this.username = username; + this.status = status; + this.objectsCount = objectsCount; + this.errorMessage = errorMessage; + this.createdAt = createdAt; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public Integer getObjectsCount() { return objectsCount; } + public void setObjectsCount(Integer objectsCount) { this.objectsCount = objectsCount; } + + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + + public Date getCreatedAt() { return createdAt; } + public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/dto/LoginRequest.java b/src/main/java/ru/akarpov/is1/dto/LoginRequest.java new file mode 100644 index 0000000..ce15cab --- /dev/null +++ b/src/main/java/ru/akarpov/is1/dto/LoginRequest.java @@ -0,0 +1,27 @@ +package ru.akarpov.is1.dto; + +import jakarta.validation.constraints.NotBlank; + +public class LoginRequest { + @NotBlank + private String username; + + @NotBlank + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/dto/RegisterRequest.java b/src/main/java/ru/akarpov/is1/dto/RegisterRequest.java new file mode 100644 index 0000000..a64996e --- /dev/null +++ b/src/main/java/ru/akarpov/is1/dto/RegisterRequest.java @@ -0,0 +1,30 @@ +package ru.akarpov.is1.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class RegisterRequest { + @NotBlank + @Size(min = 3, max = 50) + private String username; + + @NotBlank + @Size(min = 6) + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/entity/ImportOperation.java b/src/main/java/ru/akarpov/is1/entity/ImportOperation.java new file mode 100644 index 0000000..827af50 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/entity/ImportOperation.java @@ -0,0 +1,69 @@ +package ru.akarpov.is1.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import java.util.Date; + +@Entity +@Table(name = "import_operation") +@NamedQueries({ + @NamedQuery(name = "ImportOperation.findByUser", + query = "SELECT io FROM ImportOperation io WHERE io.userId = :userId ORDER BY io.createdAt DESC"), + @NamedQuery(name = "ImportOperation.findAll", + query = "SELECT io FROM ImportOperation io ORDER BY io.createdAt DESC") +}) +public class ImportOperation { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "import_operation_seq_gen") + @SequenceGenerator(name = "import_operation_seq_gen", sequenceName = "import_operation_seq", allocationSize = 1) + private Long id; + + @NotNull + @Column(name = "user_id", nullable = false) + private Long userId; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private ImportStatus status; + + @Column(name = "objects_count") + private Integer objectsCount; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @NotNull + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "created_at", nullable = false, updatable = false) + private Date createdAt; + + public ImportOperation() { + this.createdAt = new Date(); + } + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = new Date(); + } + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public ImportStatus getStatus() { return status; } + public void setStatus(ImportStatus status) { this.status = status; } + + public Integer getObjectsCount() { return objectsCount; } + public void setObjectsCount(Integer objectsCount) { this.objectsCount = objectsCount; } + + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + + public Date getCreatedAt() { return createdAt; } + public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/entity/ImportStatus.java b/src/main/java/ru/akarpov/is1/entity/ImportStatus.java new file mode 100644 index 0000000..d7c13dd --- /dev/null +++ b/src/main/java/ru/akarpov/is1/entity/ImportStatus.java @@ -0,0 +1,6 @@ +package ru.akarpov.is1.entity; + +public enum ImportStatus { + SUCCESS, + FAILED +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/entity/MusicBand.java b/src/main/java/ru/akarpov/is1/entity/MusicBand.java index ea471e0..d5e31e3 100644 --- a/src/main/java/ru/akarpov/is1/entity/MusicBand.java +++ b/src/main/java/ru/akarpov/is1/entity/MusicBand.java @@ -7,7 +7,6 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import java.time.OffsetDateTime; -import java.time.ZonedDateTime; import java.util.Date; @Entity @@ -20,7 +19,11 @@ import java.util.Date; @NamedQuery(name = "MusicBand.findWithMaxName", query = "SELECT mb FROM MusicBand mb WHERE mb.name = (SELECT MAX(mb2.name) FROM MusicBand mb2)"), @NamedQuery(name = "MusicBand.groupByParticipants", - query = "SELECT mb.numberOfParticipants, COUNT(mb) FROM MusicBand mb GROUP BY mb.numberOfParticipants") + query = "SELECT mb.numberOfParticipants, COUNT(mb) FROM MusicBand mb GROUP BY mb.numberOfParticipants"), + @NamedQuery(name = "MusicBand.checkNameGenreUnique", + query = "SELECT COUNT(mb) FROM MusicBand mb WHERE mb.name = :name AND mb.genre = :genre AND mb.id <> :excludeId"), + @NamedQuery(name = "MusicBand.checkEstablishmentDateUnique", + query = "SELECT COUNT(mb) FROM MusicBand mb WHERE mb.establishmentDate = :establishmentDate AND mb.id <> :excludeId") }) public class MusicBand { @@ -70,17 +73,18 @@ public class MusicBand { @Column(name = "albums_count") private Integer albumsCount; - @NotNull @JsonFormat(shape = JsonFormat.Shape.STRING) @Column(name = "establishment_date", nullable = false) private OffsetDateTime establishmentDate; - @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) @JoinColumn(name = "front_man_id") private Person frontMan; + @Column(name = "created_by") + private Long createdBy; + public MusicBand() { this.creationDate = new Date(); } @@ -125,7 +129,9 @@ public class MusicBand { public OffsetDateTime getEstablishmentDate() { return establishmentDate; } public void setEstablishmentDate(OffsetDateTime establishmentDate) { this.establishmentDate = establishmentDate; } - public Person getFrontMan() { return frontMan; } public void setFrontMan(Person frontMan) { this.frontMan = frontMan; } -} + + public Long getCreatedBy() { return createdBy; } + public void setCreatedBy(Long createdBy) { this.createdBy = createdBy; } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/entity/Role.java b/src/main/java/ru/akarpov/is1/entity/Role.java new file mode 100644 index 0000000..3b8bc0f --- /dev/null +++ b/src/main/java/ru/akarpov/is1/entity/Role.java @@ -0,0 +1,6 @@ +package ru.akarpov.is1.entity; + +public enum Role { + USER, + ADMIN +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/entity/User.java b/src/main/java/ru/akarpov/is1/entity/User.java new file mode 100644 index 0000000..ddfce83 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/entity/User.java @@ -0,0 +1,63 @@ +package ru.akarpov.is1.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Date; + +@Entity +@Table(name = "users") +public class User { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "users_seq_gen") + @SequenceGenerator(name = "users_seq_gen", sequenceName = "users_seq", allocationSize = 1) + private Long id; + + @NotNull + @NotBlank + @Size(min = 3, max = 50) + @Column(name = "username", unique = true, nullable = false, length = 50) + private String username; + + @NotNull + @NotBlank + @Column(name = "password_hash", nullable = false) + private String passwordHash; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + private Role role; + + @NotNull + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "created_at", nullable = false, updatable = false) + private Date createdAt; + + public User() { + this.createdAt = new Date(); + } + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = new Date(); + } + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getPasswordHash() { return passwordHash; } + public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } + + public Role getRole() { return role; } + public void setRole(Role role) { this.role = role; } + + public Date getCreatedAt() { return createdAt; } + public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/repository/ImportOperationRepository.java b/src/main/java/ru/akarpov/is1/repository/ImportOperationRepository.java new file mode 100644 index 0000000..382f03f --- /dev/null +++ b/src/main/java/ru/akarpov/is1/repository/ImportOperationRepository.java @@ -0,0 +1,38 @@ +package ru.akarpov.is1.repository; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import ru.akarpov.is1.entity.ImportOperation; + +import java.util.List; + +@ApplicationScoped +public class ImportOperationRepository { + + @PersistenceContext(unitName = "musicBandPU") + private EntityManager em; + + public ImportOperation save(ImportOperation operation) { + if (operation.getId() == null) { + em.persist(operation); + em.flush(); + return operation; + } else { + ImportOperation merged = em.merge(operation); + em.flush(); + return merged; + } + } + + public List findByUser(Long userId) { + return em.createNamedQuery("ImportOperation.findByUser", ImportOperation.class) + .setParameter("userId", userId) + .getResultList(); + } + + public List findAll() { + return em.createNamedQuery("ImportOperation.findAll", ImportOperation.class) + .getResultList(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/repository/MusicBandRepository.java b/src/main/java/ru/akarpov/is1/repository/MusicBandRepository.java index 69a3a81..f4769ee 100644 --- a/src/main/java/ru/akarpov/is1/repository/MusicBandRepository.java +++ b/src/main/java/ru/akarpov/is1/repository/MusicBandRepository.java @@ -1,6 +1,9 @@ package ru.akarpov.is1.repository; import ru.akarpov.is1.entity.MusicBand; +import ru.akarpov.is1.entity.MusicGenre; + +import java.time.OffsetDateTime; import java.util.List; import java.util.Map; @@ -13,4 +16,6 @@ public interface MusicBandRepository extends GenericRepository Map groupByNumberOfParticipants(); MusicBand addSingle(Long bandId); MusicBand removeParticipant(Long bandId); + Long checkNameGenreUnique(String name, MusicGenre genre, Long excludeId); + Long checkEstablishmentDateUnique(OffsetDateTime establishmentDate, Long excludeId); } \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/repository/MusicBandRepositoryImpl.java b/src/main/java/ru/akarpov/is1/repository/MusicBandRepositoryImpl.java index 4220176..40a05e0 100644 --- a/src/main/java/ru/akarpov/is1/repository/MusicBandRepositoryImpl.java +++ b/src/main/java/ru/akarpov/is1/repository/MusicBandRepositoryImpl.java @@ -2,9 +2,12 @@ package ru.akarpov.is1.repository; import jakarta.enterprise.context.ApplicationScoped; import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; import jakarta.persistence.PersistenceContext; import ru.akarpov.is1.entity.MusicBand; +import ru.akarpov.is1.entity.MusicGenre; +import java.time.OffsetDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -16,16 +19,19 @@ public class MusicBandRepositoryImpl implements MusicBandRepository { private static final Logger logger = Logger.getLogger(MusicBandRepositoryImpl.class.getName()); - @PersistenceContext + @PersistenceContext(unitName = "musicBandPU") private EntityManager em; @Override public MusicBand save(MusicBand entity) { if (entity.getId() == 0) { em.persist(entity); + em.flush(); return entity; } else { - return em.merge(entity); + MusicBand merged = em.merge(entity); + em.flush(); + return merged; } } @@ -60,7 +66,8 @@ public class MusicBandRepositoryImpl implements MusicBandRepository { } else { em.remove(em.merge(entity)); } - logger.info("MusicBand marked for deletion"); + em.flush(); + logger.info("MusicBand deleted"); } @Override @@ -69,7 +76,8 @@ public class MusicBandRepositoryImpl implements MusicBandRepository { MusicBand band = em.find(MusicBand.class, id); if (band != null) { em.remove(band); - logger.info("MusicBand with ID " + id + " marked for deletion"); + em.flush(); + logger.info("MusicBand with ID " + id + " deleted"); } else { logger.warning("MusicBand with ID " + id + " not found for deletion"); } @@ -156,7 +164,7 @@ public class MusicBandRepositoryImpl implements MusicBandRepository { @Override public MusicBand addSingle(Long bandId) { logger.info("Adding single to band ID: " + bandId); - MusicBand band = em.find(MusicBand.class, bandId); + MusicBand band = em.find(MusicBand.class, bandId, LockModeType.PESSIMISTIC_WRITE); if (band == null) { logger.warning("Band not found with ID: " + bandId); return null; @@ -165,6 +173,7 @@ public class MusicBandRepositoryImpl implements MusicBandRepository { int oldCount = band.getSinglesCount(); band.setSinglesCount(oldCount + 1); MusicBand merged = em.merge(band); + em.flush(); logger.info("Single added to band ID " + bandId + ": " + oldCount + " -> " + merged.getSinglesCount()); return merged; @@ -173,7 +182,7 @@ public class MusicBandRepositoryImpl implements MusicBandRepository { @Override public MusicBand removeParticipant(Long bandId) { logger.info("Removing participant from band ID: " + bandId); - MusicBand band = em.find(MusicBand.class, bandId); + MusicBand band = em.find(MusicBand.class, bandId, LockModeType.PESSIMISTIC_WRITE); if (band == null || band.getNumberOfParticipants() <= 1) { if (band == null) { logger.warning("Band not found with ID: " + bandId); @@ -186,8 +195,26 @@ public class MusicBandRepositoryImpl implements MusicBandRepository { long oldCount = band.getNumberOfParticipants(); band.setNumberOfParticipants(oldCount - 1); MusicBand merged = em.merge(band); + em.flush(); logger.info("Participant removed from band ID " + bandId + ": " + oldCount + " -> " + merged.getNumberOfParticipants()); return merged; } + + @Override + public Long checkNameGenreUnique(String name, MusicGenre genre, Long excludeId) { + return em.createNamedQuery("MusicBand.checkNameGenreUnique", Long.class) + .setParameter("name", name) + .setParameter("genre", genre) + .setParameter("excludeId", excludeId) + .getSingleResult(); + } + + @Override + public Long checkEstablishmentDateUnique(OffsetDateTime establishmentDate, Long excludeId) { + return em.createNamedQuery("MusicBand.checkEstablishmentDateUnique", Long.class) + .setParameter("establishmentDate", establishmentDate) + .setParameter("excludeId", excludeId) + .getSingleResult(); + } } \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/repository/UserRepository.java b/src/main/java/ru/akarpov/is1/repository/UserRepository.java new file mode 100644 index 0000000..2192420 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/repository/UserRepository.java @@ -0,0 +1,51 @@ +package ru.akarpov.is1.repository; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import jakarta.persistence.PersistenceContext; +import ru.akarpov.is1.entity.User; + +import java.util.Optional; + +@ApplicationScoped +public class UserRepository { + + @PersistenceContext(unitName = "musicBandPU") + private EntityManager em; + + public User save(User user) { + if (user.getId() == null) { + em.persist(user); + em.flush(); + return user; + } else { + User merged = em.merge(user); + em.flush(); + return merged; + } + } + + public Optional findById(Long id) { + User user = em.find(User.class, id); + return Optional.ofNullable(user); + } + + public Optional findByUsername(String username) { + try { + User user = em.createQuery("SELECT u FROM User u WHERE u.username = :username", User.class) + .setParameter("username", username) + .getSingleResult(); + return Optional.of(user); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + public boolean existsByUsername(String username) { + Long count = em.createQuery("SELECT COUNT(u) FROM User u WHERE u.username = :username", Long.class) + .setParameter("username", username) + .getSingleResult(); + return count > 0; + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/rest/AuthResource.java b/src/main/java/ru/akarpov/is1/rest/AuthResource.java new file mode 100644 index 0000000..f398be6 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/rest/AuthResource.java @@ -0,0 +1,55 @@ +package ru.akarpov.is1.rest; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import ru.akarpov.is1.dto.AuthResponse; +import ru.akarpov.is1.dto.LoginRequest; +import ru.akarpov.is1.dto.RegisterRequest; +import ru.akarpov.is1.service.AuthService; + +import java.util.Map; + +@Path("/auth") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class AuthResource { + + @Inject + private AuthService authService; + + @POST + @Path("/register") + public Response register(RegisterRequest request) { + try { + AuthResponse response = authService.register(request); + return Response.status(Response.Status.CREATED).entity(response).build(); + } catch (IllegalArgumentException e) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", e.getMessage())) + .build(); + } catch (Exception e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Registration failed: " + e.getMessage())) + .build(); + } + } + + @POST + @Path("/login") + public Response login(LoginRequest request) { + try { + AuthResponse response = authService.login(request); + return Response.ok(response).build(); + } catch (IllegalArgumentException e) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", e.getMessage())) + .build(); + } catch (Exception e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Login failed: " + e.getMessage())) + .build(); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/rest/ImportResource.java b/src/main/java/ru/akarpov/is1/rest/ImportResource.java new file mode 100644 index 0000000..bbb58c6 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/rest/ImportResource.java @@ -0,0 +1,96 @@ +package ru.akarpov.is1.rest; + +import jakarta.inject.Inject; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import ru.akarpov.is1.dto.ImportHistoryResponse; +import ru.akarpov.is1.entity.ImportOperation; +import ru.akarpov.is1.security.UserPrincipal; +import ru.akarpov.is1.service.ImportService; + +import java.util.List; +import java.util.Map; + +@Path("/import") +@Produces(MediaType.APPLICATION_JSON) +public class ImportResource { + + @Inject + private ImportService importService; + + @POST + @Consumes(MediaType.TEXT_PLAIN) + public Response importBands(@Context HttpServletRequest request, + @QueryParam("format") String format, + String fileContent) { + try { + UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal"); + if (principal == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Authentication required")) + .build(); + } + + if (format == null || format.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Format parameter is required")) + .build(); + } + + if (fileContent == null || fileContent.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "File content is required")) + .build(); + } + + ImportOperation operation = importService.importBands(fileContent, format, principal.getUserId()); + + if (operation.getStatus().name().equals("SUCCESS")) { + return Response.ok(Map.of( + "message", "Import completed successfully", + "operationId", operation.getId(), + "objectsCount", operation.getObjectsCount() + )).build(); + } else { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of( + "error", operation.getErrorMessage(), + "operationId", operation.getId() + )) + .build(); + } + + } catch (Exception e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Import processing failed: " + e.getMessage())) + .build(); + } + } + + @GET + @Path("/history") + public Response getImportHistory(@Context HttpServletRequest request) { + try { + UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal"); + if (principal == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Authentication required")) + .build(); + } + + List history = importService.getImportHistory( + principal.getUserId(), + principal.isAdmin() + ); + + return Response.ok(history).build(); + } catch (Exception e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to retrieve history: " + e.getMessage())) + .build(); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/rest/MusicBandResource.java b/src/main/java/ru/akarpov/is1/rest/MusicBandResource.java index 96164b9..f890c1f 100644 --- a/src/main/java/ru/akarpov/is1/rest/MusicBandResource.java +++ b/src/main/java/ru/akarpov/is1/rest/MusicBandResource.java @@ -1,10 +1,13 @@ package ru.akarpov.is1.rest; import jakarta.inject.Inject; +import jakarta.servlet.http.HttpServletRequest; import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import ru.akarpov.is1.entity.MusicBand; +import ru.akarpov.is1.security.UserPrincipal; import ru.akarpov.is1.service.MusicBandService; import java.util.List; @@ -70,11 +73,19 @@ public class MusicBandResource { } @POST - public Response createBand(MusicBand musicBand) { + public Response createBand(@Context HttpServletRequest request, MusicBand musicBand) { try { + UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal"); + if (principal == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Authentication required")) + .build(); + } + + musicBand.setCreatedBy(principal.getUserId()); MusicBand createdBand = musicBandService.create(musicBand); return Response.status(Response.Status.CREATED).entity(createdBand).build(); - } catch (MusicBandService.ValidationException e) { + } catch (MusicBandService.ValidationException | MusicBandService.BusinessConstraintException e) { return Response.status(Response.Status.BAD_REQUEST) .entity(Map.of("error", e.getMessage())) .build(); @@ -87,15 +98,37 @@ public class MusicBandResource { @PUT @Path("/{id}") - public Response updateBand(@PathParam("id") Long id, MusicBand musicBand) { + public Response updateBand(@Context HttpServletRequest request, + @PathParam("id") Long id, + MusicBand musicBand) { try { + UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal"); + if (principal == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Authentication required")) + .build(); + } + + Optional existingBand = musicBandService.findById(id); + if (existingBand.isEmpty()) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Music band not found")) + .build(); + } + + if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("error", "You can only edit your own bands")) + .build(); + } + MusicBand updatedBand = musicBandService.update(id, musicBand); return Response.ok(updatedBand).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.NOT_FOUND) .entity(Map.of("error", e.getMessage())) .build(); - } catch (MusicBandService.ValidationException e) { + } catch (MusicBandService.ValidationException | MusicBandService.BusinessConstraintException e) { return Response.status(Response.Status.BAD_REQUEST) .entity(Map.of("error", e.getMessage())) .build(); @@ -108,8 +141,28 @@ public class MusicBandResource { @DELETE @Path("/{id}") - public Response deleteBand(@PathParam("id") Long id) { + public Response deleteBand(@Context HttpServletRequest request, @PathParam("id") Long id) { try { + UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal"); + if (principal == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Authentication required")) + .build(); + } + + Optional existingBand = musicBandService.findById(id); + if (existingBand.isEmpty()) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Music band not found")) + .build(); + } + + if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("error", "You can only delete your own bands")) + .build(); + } + musicBandService.delete(id); return Response.noContent().build(); } catch (Exception e) { @@ -166,8 +219,28 @@ public class MusicBandResource { @POST @Path("/{id}/add-single") - public Response addSingle(@PathParam("id") Long id) { + public Response addSingle(@Context HttpServletRequest request, @PathParam("id") Long id) { try { + UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal"); + if (principal == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Authentication required")) + .build(); + } + + Optional existingBand = musicBandService.findById(id); + if (existingBand.isEmpty()) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Music band not found")) + .build(); + } + + if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("error", "You can only modify your own bands")) + .build(); + } + musicBandService.addSingle(id); return Response.ok(Map.of("message", "Single added successfully", "bandId", id)).build(); } catch (IllegalArgumentException e) { @@ -183,8 +256,28 @@ public class MusicBandResource { @POST @Path("/{id}/remove-participant") - public Response removeParticipant(@PathParam("id") Long id) { + public Response removeParticipant(@Context HttpServletRequest request, @PathParam("id") Long id) { try { + UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal"); + if (principal == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Authentication required")) + .build(); + } + + Optional existingBand = musicBandService.findById(id); + if (existingBand.isEmpty()) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Music band not found")) + .build(); + } + + if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("error", "You can only modify your own bands")) + .build(); + } + musicBandService.removeParticipant(id); return Response.ok(Map.of("message", "Participant removed successfully", "bandId", id)).build(); } catch (IllegalArgumentException e) { diff --git a/src/main/java/ru/akarpov/is1/security/AuthenticationFilter.java b/src/main/java/ru/akarpov/is1/security/AuthenticationFilter.java new file mode 100644 index 0000000..a087602 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/security/AuthenticationFilter.java @@ -0,0 +1,77 @@ +package ru.akarpov.is1.security; + +import jakarta.inject.Inject; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import ru.akarpov.is1.entity.Role; + +import java.io.IOException; + +public class AuthenticationFilter implements Filter { + + @Inject + private JwtUtil jwtUtil; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + String path = httpRequest.getRequestURI(); + + if (path.startsWith("/is1/api/auth/") || + path.startsWith("/is1/api/test-password/") || + path.equals("/is1/") || + path.startsWith("/is1/index.html") || + path.startsWith("/is1/css/") || + path.startsWith("/is1/js/") || + path.startsWith("/is1/websocket/") || + path.equals("/is1/api/test-db/connection")) { + chain.doFilter(request, response); + return; + } + + if ("OPTIONS".equalsIgnoreCase(httpRequest.getMethod())) { + chain.doFilter(request, response); + return; + } + + String authHeader = httpRequest.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + httpResponse.getWriter().write("{\"error\":\"Missing or invalid Authorization header\"}"); + return; + } + + String token = authHeader.substring(7); + + if (!jwtUtil.validateToken(token)) { + httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + httpResponse.getWriter().write("{\"error\":\"Invalid or expired token\"}"); + return; + } + + try { + Long userId = jwtUtil.getUserId(token); + String username = jwtUtil.getUsername(token); + Role role = jwtUtil.getRole(token); + + UserPrincipal principal = new UserPrincipal(userId, username, role); + httpRequest.setAttribute("userPrincipal", principal); + + chain.doFilter(request, response); + } catch (Exception e) { + httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + httpResponse.getWriter().write("{\"error\":\"Authentication failed\"}"); + } + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException {} + + @Override + public void destroy() {} +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/security/JwtUtil.java b/src/main/java/ru/akarpov/is1/security/JwtUtil.java new file mode 100644 index 0000000..508e2f9 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/security/JwtUtil.java @@ -0,0 +1,61 @@ +package ru.akarpov.is1.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import jakarta.enterprise.context.ApplicationScoped; +import ru.akarpov.is1.entity.Role; + +import java.security.Key; +import java.util.Date; + +@ApplicationScoped +public class JwtUtil { + private static final String SECRET_KEY = "your-very-secure-secret-key-that-is-at-least-256-bits-long-for-hs256"; + private static final long EXPIRATION_TIME = 86400000; + private final Key key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes()); + + public String generateToken(Long userId, String username, Role role) { + return Jwts.builder() + .setSubject(username) + .claim("userId", userId) + .claim("role", role.name()) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public Claims parseToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public boolean validateToken(String token) { + try { + Claims claims = parseToken(token); + return claims.getExpiration().after(new Date()); + } catch (Exception e) { + return false; + } + } + + public Long getUserId(String token) { + Claims claims = parseToken(token); + return claims.get("userId", Long.class); + } + + public String getUsername(String token) { + return parseToken(token).getSubject(); + } + + public Role getRole(String token) { + Claims claims = parseToken(token); + String roleName = claims.get("role", String.class); + return Role.valueOf(roleName); + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/security/PasswordUtil.java b/src/main/java/ru/akarpov/is1/security/PasswordUtil.java new file mode 100644 index 0000000..8f12b31 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/security/PasswordUtil.java @@ -0,0 +1,16 @@ +package ru.akarpov.is1.security; + +import jakarta.enterprise.context.ApplicationScoped; +import org.mindrot.jbcrypt.BCrypt; + +@ApplicationScoped +public class PasswordUtil { + + public String hashPassword(String plainPassword) { + return BCrypt.hashpw(plainPassword, BCrypt.gensalt(10)); + } + + public boolean checkPassword(String plainPassword, String hashedPassword) { + return BCrypt.checkpw(plainPassword, hashedPassword); + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/security/UserPrincipal.java b/src/main/java/ru/akarpov/is1/security/UserPrincipal.java new file mode 100644 index 0000000..0accebe --- /dev/null +++ b/src/main/java/ru/akarpov/is1/security/UserPrincipal.java @@ -0,0 +1,31 @@ +package ru.akarpov.is1.security; + +import ru.akarpov.is1.entity.Role; + +public class UserPrincipal { + private final Long userId; + private final String username; + private final Role role; + + public UserPrincipal(Long userId, String username, Role role) { + this.userId = userId; + this.username = username; + this.role = role; + } + + public Long getUserId() { + return userId; + } + + public String getUsername() { + return username; + } + + public Role getRole() { + return role; + } + + public boolean isAdmin() { + return role == Role.ADMIN; + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/service/AuthService.java b/src/main/java/ru/akarpov/is1/service/AuthService.java new file mode 100644 index 0000000..8bee795 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/service/AuthService.java @@ -0,0 +1,63 @@ +package ru.akarpov.is1.service; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import ru.akarpov.is1.dto.AuthResponse; +import ru.akarpov.is1.dto.LoginRequest; +import ru.akarpov.is1.dto.RegisterRequest; +import ru.akarpov.is1.entity.Role; +import ru.akarpov.is1.entity.User; +import ru.akarpov.is1.repository.UserRepository; +import ru.akarpov.is1.security.JwtUtil; +import ru.akarpov.is1.security.PasswordUtil; + +import java.util.Optional; + +@ApplicationScoped +public class AuthService { + + @Inject + private UserRepository userRepository; + + @Inject + private PasswordUtil passwordUtil; + + @Inject + private JwtUtil jwtUtil; + + @Transactional + public AuthResponse register(RegisterRequest request) { + if (userRepository.existsByUsername(request.getUsername())) { + throw new IllegalArgumentException("Username already exists"); + } + + User user = new User(); + user.setUsername(request.getUsername()); + user.setPasswordHash(passwordUtil.hashPassword(request.getPassword())); + user.setRole(Role.USER); + + user = userRepository.save(user); + + String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole()); + return new AuthResponse(token, user.getId(), user.getUsername(), user.getRole().name()); + } + + @Transactional + public AuthResponse login(LoginRequest request) { + Optional userOpt = userRepository.findByUsername(request.getUsername()); + + if (userOpt.isEmpty()) { + throw new IllegalArgumentException("Invalid username or password"); + } + + User user = userOpt.get(); + + if (!passwordUtil.checkPassword(request.getPassword(), user.getPasswordHash())) { + throw new IllegalArgumentException("Invalid username or password"); + } + + String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole()); + return new AuthResponse(token, user.getId(), user.getUsername(), user.getRole().name()); + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/service/ImportService.java b/src/main/java/ru/akarpov/is1/service/ImportService.java new file mode 100644 index 0000000..e79def7 --- /dev/null +++ b/src/main/java/ru/akarpov/is1/service/ImportService.java @@ -0,0 +1,127 @@ +package ru.akarpov.is1.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import ru.akarpov.is1.dto.ImportHistoryResponse; +import ru.akarpov.is1.entity.*; +import ru.akarpov.is1.repository.ImportOperationRepository; +import ru.akarpov.is1.repository.UserRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +@ApplicationScoped +public class ImportService { + + private static final Logger logger = Logger.getLogger(ImportService.class.getName()); + + @Inject + private MusicBandService musicBandService; + + @Inject + private ImportOperationRepository importOperationRepository; + + @Inject + private UserRepository userRepository; + + @Transactional + public ImportOperation importBands(String fileContent, String fileFormat, Long userId) { + List bands; + ImportOperation operation = new ImportOperation(); + operation.setUserId(userId); + + try { + bands = parseFile(fileContent, fileFormat); + + if (bands.isEmpty()) { + throw new IllegalArgumentException("No valid bands found in file"); + } + + for (MusicBand band : bands) { + band.setCreatedBy(userId); + musicBandService.create(band); + } + + operation.setStatus(ImportStatus.SUCCESS); + operation.setObjectsCount(bands.size()); + + logger.info("Successfully imported " + bands.size() + " bands for user " + userId); + + } catch (Exception e) { + operation.setStatus(ImportStatus.FAILED); + operation.setErrorMessage(e.getMessage()); + logger.log(Level.SEVERE, "Import failed for user " + userId, e); + } + + return importOperationRepository.save(operation); + } + + private List parseFile(String content, String format) throws IOException { + ObjectMapper mapper; + + switch (format.toLowerCase()) { + case "json": + mapper = new ObjectMapper(); + mapper.findAndRegisterModules(); + break; + case "xml": + mapper = new XmlMapper(); + mapper.findAndRegisterModules(); + break; + case "yaml": + case "yml": + mapper = new YAMLMapper(); + mapper.findAndRegisterModules(); + break; + default: + throw new IllegalArgumentException("Unsupported format: " + format); + } + + MusicBandsWrapper wrapper = mapper.readValue(content, MusicBandsWrapper.class); + return wrapper.getBands() != null ? wrapper.getBands() : new ArrayList<>(); + } + + @Transactional + public List getImportHistory(Long userId, boolean isAdmin) { + List operations = isAdmin ? + importOperationRepository.findAll() : + importOperationRepository.findByUser(userId); + + List responses = new ArrayList<>(); + for (ImportOperation op : operations) { + String username = userRepository.findById(op.getUserId()) + .map(User::getUsername) + .orElse("Unknown"); + + responses.add(new ImportHistoryResponse( + op.getId(), + username, + op.getStatus().name(), + op.getObjectsCount(), + op.getErrorMessage(), + op.getCreatedAt() + )); + } + + return responses; + } + + public static class MusicBandsWrapper { + private List bands; + + public List getBands() { + return bands; + } + + public void setBands(List bands) { + this.bands = bands; + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/akarpov/is1/service/MusicBandService.java b/src/main/java/ru/akarpov/is1/service/MusicBandService.java index db075a0..08451bc 100644 --- a/src/main/java/ru/akarpov/is1/service/MusicBandService.java +++ b/src/main/java/ru/akarpov/is1/service/MusicBandService.java @@ -33,6 +33,7 @@ public class MusicBandService { try { validateMusicBand(musicBand); + checkBusinessConstraints(musicBand, 0L); logger.fine("Validation passed for band: " + musicBand.getName()); MusicBand created = musicBandRepository.save(musicBand); @@ -42,7 +43,7 @@ public class MusicBandService { logger.fine("WebSocket broadcast sent for created band ID: " + created.getId()); return created; - } catch (ValidationException e) { + } catch (ValidationException | BusinessConstraintException e) { logger.warning("Validation failed for band: " + e.getMessage()); throw e; } catch (Exception e) { @@ -51,27 +52,32 @@ public class MusicBandService { } } + @Transactional public Optional findById(Long id) { logger.fine("Finding band by ID: " + id); return musicBandRepository.findById(id); } + @Transactional public List findAll(int page, int size) { logger.fine("Finding all bands - page: " + page + ", size: " + size); return musicBandRepository.findAll(page, size); } + @Transactional public List findAllSorted(String sortBy, boolean ascending, int page, int size) { logger.fine("Finding all bands sorted by: " + sortBy + " (" + (ascending ? "ASC" : "DESC") + ")"); validateSortField(sortBy); return musicBandRepository.findAllSorted(sortBy, ascending, page, size); } + @Transactional public List findByNameContaining(String name, int page, int size) { logger.fine("Finding bands by name containing: " + name); return musicBandRepository.findByNameContainingSorted(name, "id", true, page, size); } + @Transactional public List findByNameContainingSorted(String name, String sortBy, boolean ascending, int page, int size) { logger.fine("Finding bands by name containing: " + name + ", sorted by: " + sortBy); validateSortField(sortBy); @@ -91,8 +97,10 @@ public class MusicBandService { updatedBand.setId(id); updatedBand.setCreationDate(existingBand.get().getCreationDate()); + updatedBand.setCreatedBy(existingBand.get().getCreatedBy()); validateMusicBand(updatedBand); + checkBusinessConstraints(updatedBand, id); logger.fine("Validation passed for band update: " + id); MusicBand updated = musicBandRepository.save(updatedBand); @@ -102,7 +110,7 @@ public class MusicBandService { logger.fine("WebSocket broadcast sent for updated band ID: " + updated.getId()); return updated; - } catch (ValidationException | IllegalArgumentException e) { + } catch (ValidationException | BusinessConstraintException | IllegalArgumentException e) { logger.warning("Update failed for band ID " + id + ": " + e.getMessage()); throw e; } catch (Exception e) { @@ -135,12 +143,14 @@ public class MusicBandService { } } + @Transactional public long count() { long count = musicBandRepository.count(); logger.fine("Total band count: " + count); return count; } + @Transactional public Double getAverageAlbumsCount() { logger.fine("Calculating average albums count"); Double average = musicBandRepository.getAverageAlbumsCount(); @@ -148,6 +158,7 @@ public class MusicBandService { return average != null ? average : 0.0; } + @Transactional public MusicBand findWithMaxName() { logger.fine("Finding band with max name"); MusicBand band = musicBandRepository.findWithMaxName(); @@ -155,6 +166,7 @@ public class MusicBandService { return band; } + @Transactional public Map groupByNumberOfParticipants() { logger.fine("Grouping bands by number of participants"); Map groups = musicBandRepository.groupByNumberOfParticipants(); @@ -225,6 +237,33 @@ public class MusicBandService { } } + private void checkBusinessConstraints(MusicBand musicBand, Long excludeId) throws BusinessConstraintException { + Long nameGenreCount = musicBandRepository.checkNameGenreUnique( + musicBand.getName(), + musicBand.getGenre(), + excludeId + ); + + if (nameGenreCount > 0) { + throw new BusinessConstraintException( + "Band with name '" + musicBand.getName() + + "' and genre '" + musicBand.getGenre() + "' already exists" + ); + } + + Long establishmentDateCount = musicBandRepository.checkEstablishmentDateUnique( + musicBand.getEstablishmentDate(), + excludeId + ); + + if (establishmentDateCount > 0) { + throw new BusinessConstraintException( + "Band with establishment date '" + musicBand.getEstablishmentDate() + + "' already exists" + ); + } + } + private void validateSortField(String sortBy) { Set allowedFields = Set.of("id", "name", "genre", "numberOfParticipants", "singlesCount", "albumsCount", "establishmentDate", "creationDate"); @@ -239,4 +278,10 @@ public class MusicBandService { super(message); } } + + public static class BusinessConstraintException extends RuntimeException { + public BusinessConstraintException(String message) { + super(message); + } + } } \ No newline at end of file diff --git a/src/main/resources/META-INF/persistence.xml b/src/main/resources/META-INF/persistence.xml index bd4ac97..b2c5e0b 100644 --- a/src/main/resources/META-INF/persistence.xml +++ b/src/main/resources/META-INF/persistence.xml @@ -13,6 +13,8 @@ ru.akarpov.is1.entity.Album ru.akarpov.is1.entity.Person ru.akarpov.is1.entity.Location + ru.akarpov.is1.entity.User + ru.akarpov.is1.entity.ImportOperation @@ -24,9 +26,9 @@ - - + + diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 94ffea6..29e59d7 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -20,4 +20,14 @@ /* + + AuthenticationFilter + ru.akarpov.is1.security.AuthenticationFilter + + + + AuthenticationFilter + /api/* + + \ No newline at end of file diff --git a/src/main/webapp/css/style.css b/src/main/webapp/css/style.css index 21d2dff..343b26e 100644 --- a/src/main/webapp/css/style.css +++ b/src/main/webapp/css/style.css @@ -38,6 +38,95 @@ body { line-height: 1.55; } +.auth-screen { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.auth-container { + width: min(450px, 100%); + padding: 40px 36px; +} + +.auth-container .brand.centered { + justify-content: center; + margin-bottom: 40px; +} + +.auth-form h2 { + margin: 0 0 32px 0; + text-align: center; + font-size: 22px; + font-weight: 700; +} + +.auth-form .form-group { + margin-bottom: 20px; +} + +.auth-form .form-group label { + display: block; + margin-bottom: 8px; + color: var(--text); + font-size: 14px; + font-weight: 500; +} + +.auth-form .form-group input { + width: 100%; + padding: 14px 16px; + background: #0e1017; + color: var(--text); + border: 1px solid var(--line); + border-radius: 10px; + font-size: 15px; + transition: all 0.2s; +} + +.auth-form .form-group input:focus { + outline: none; + border-color: var(--focus); + box-shadow: 0 0 0 3px rgba(45,212,191,0.15); +} + +.auth-form button[type="submit"] { + margin-top: 24px; +} + +.auth-switch { + text-align: center; + margin-top: 24px; + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} + +.link-btn { + appearance: none; + background: none; + border: none; + color: var(--brand); + cursor: pointer; + font: inherit; + padding: 0; + text-decoration: underline; + font-weight: 600; + transition: color 0.2s; +} + +.link-btn:hover { + color: var(--brand-2); +} + +.full-width { + width: 100%; + padding: 14px 20px; + font-size: 15px; + font-weight: 600; +} + .app-shell { max-width: 1200px; padding: 24px; margin: 0 auto; } .card { @@ -62,7 +151,7 @@ body { .brand-text h1 { font-size: 18px; margin: 0; letter-spacing: 0.3px; } .muted { color: var(--muted); font-size: 12px; } -.top-nav { display: flex; gap: 8px; flex-wrap: wrap; } +.top-nav { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } .nav-btn { appearance: none; @@ -101,13 +190,20 @@ body { .form-group select, .form-group textarea { width: 100%; - padding: 10px 12px; + padding: 12px 14px; background: #0e1017; color: var(--text); border: 1px solid var(--line); border-radius: 10px; transition: all 0.2s; + font-size: 14px; } + +.field input[type="file"], +.form-group input[type="file"] { + padding: 10px 14px; +} + .field input:focus, .field select:focus, .form-group input:focus, @@ -115,7 +211,7 @@ body { .form-group textarea:focus { outline: none; border-color: var(--focus); - box-shadow: 0 0 0 2px rgba(45,212,191,0.2); + box-shadow: 0 0 0 3px rgba(45,212,191,0.15); } .field-row { display: flex; gap: 8px; } @@ -203,6 +299,23 @@ body { .pagination { display: flex; align-items: center; justify-content: center; gap: 10px; padding-top: 10px; } .pagination .muted { font-size: 12px; } +.import-container { + max-width: 600px; + margin: 20px auto; +} + +.import-info { + background: var(--panel-soft); + padding: 16px; + border-radius: 10px; + margin-bottom: 20px; + border: 1px solid var(--line); +} + +.import-info p { + margin: 8px 0; +} + .modal { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(0,0,0,.7); backdrop-filter: blur(4px); @@ -230,12 +343,33 @@ body { .modal-title { margin: 0 0 8px 0; } .close { position: absolute; right: 12px; top: 12px; z-index: 1; } -.form { margin-top: 10px; } -.form-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); } -.form-group label { display: block; margin: 6px 0 6px; color: var(--muted); font-size: 12px; } -.subhead { margin: 16px 0 6px; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; } +.form { margin-top: 16px; } +.form-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); } +.form-group { margin-bottom: 4px; } +.form-group label { + display: block; + margin: 0 0 8px 0; + color: var(--muted); + font-size: 13px; + font-weight: 500; +} +.subhead { + margin: 24px 0 12px; + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: .08em; + font-weight: 600; +} -.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid var(--line); +} .toast { position: fixed; @@ -443,4 +577,5 @@ body { .section-head { flex-direction: column; align-items: flex-start; } .controls { width: 100%; } .filters { width: 100%; flex-wrap: wrap; } + .auth-container { padding: 24px; } } \ No newline at end of file diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html index c11fd8f..434ff84 100644 --- a/src/main/webapp/index.html +++ b/src/main/webapp/index.html @@ -11,13 +11,67 @@ -
+
+
+
+ +
+

Music Band IS

+ Information System +
+
+ +
+

Login

+
+
+ + +
+
+ + +
+ +
+

+ Don't have an account? + +

+
+ + +
+
+ + + +
+
+

Import Bands

+
+ +
+
+

Upload a file containing band data in JSON, XML, or YAML format.

+

The format will be automatically detected based on file extension.

+
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+
+

Import History

+ +
+ +
+ + + + + + + + + + + + +
IDUserStatusObjectsDateError
+
+
-