diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..370a93b --- /dev/null +++ b/build.gradle @@ -0,0 +1,51 @@ +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath("org.springframework.boot:spring-boot-gradle-plugin:2.1.3.RELEASE") + } +} + +apply plugin: 'java' +apply plugin: 'idea' +apply plugin: 'org.springframework.boot' +apply plugin: 'io.spring.dependency-management' + +bootJar { + baseName = 'writer-fic' + version = '0.1.0' +} + +repositories { + mavenCentral() +} + +sourceCompatibility = 1.8 +targetCompatibility = 1.8 +compileJava.options.encoding = 'UTF-8' + +tasks.withType(JavaCompile) { + options.encoding = 'UTF-8' +} +dependencies { + compile("org.springframework.boot:spring-boot-starter-data-jpa") + compile("org.springframework.boot:spring-boot-starter-web") + compile("org.springframework.boot:spring-boot-starter-test") + compile("org.springframework.boot:spring-boot-starter-hateoas") + compile("org.springframework.boot:spring-boot-starter-security") + compile("org.springframework.security:spring-security-test") + compile("org.springframework.security:spring-security-oauth2-client") + + compile("org.apache.poi:poi:4.1.0") + compile("io.jsonwebtoken:jjwt:0.5.1") + compile("fr.opensagres.xdocreport:org.apache.poi.xwpf.converter.xhtml:1.0.4") + compile("org.apache.poi:poi-ooxml:4.1.0") + compile("es.nitaur.markdown:txtmark:0.16") + compile("org.projectlombok:lombok") + compile("mysql:mysql-connector-java") + compile("org.postgresql:postgresql") + compile("com.h2database:h2") + + testCompile("junit:junit") +} \ No newline at end of file diff --git a/src/main/java/fic/writer/Application.java b/src/main/java/fic/writer/Application.java new file mode 100644 index 0000000..dd023a8 --- /dev/null +++ b/src/main/java/fic/writer/Application.java @@ -0,0 +1,14 @@ +package fic.writer; + +import fic.writer.web.config.properties.AppProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties(AppProperties.class) +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/src/main/java/fic/writer/domain/aspect/DenyAccessToNotAuthorAspect.java b/src/main/java/fic/writer/domain/aspect/DenyAccessToNotAuthorAspect.java new file mode 100644 index 0000000..937ef8b --- /dev/null +++ b/src/main/java/fic/writer/domain/aspect/DenyAccessToNotAuthorAspect.java @@ -0,0 +1,64 @@ +package fic.writer.domain.aspect; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.service.BookService; +import fic.writer.exception.DoesNotHavePermissionException; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.AuditorAware; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Aspect +@Component +public class DenyAccessToNotAuthorAspect { + @Autowired + private BookService bookService; + @Autowired + private UserDetailsService userDetailsService; + @Autowired + private AuditorAware profileAuditorAware; + + + @Pointcut("execution(* fic.writer.domain.service.BookService.deleteById(..)) && args(bookId,..))") + public void deleteBook(Long bookId) { + } + + @Pointcut("execution(* fic.writer.domain.service.BookService.addArticle(..)) && args(bookId,..)) ") + public void addArticle(Long bookId) { + } + + @Pointcut("execution(* fic.writer.domain.service.BookService.removeArticle(..)) && args(bookId,..))") + public void removedArticle(Long bookId) { + } + + @Before("deleteBook(bookId) || addArticle(bookId) || removedArticle(bookId)") + public void denyModifyBookForNoneAuthors(Long bookId) { + Optional optionalProfile = profileAuditorAware.getCurrentAuditor(); + Book book = bookService.findById(bookId).get(); + + optionalProfile.ifPresent(p -> { + if (checkForPermissions(book, p)) { + throw new DoesNotHavePermissionException(); + } + }); + } + + private boolean checkForPermissions(Book book, Profile profile) { + return !(isAuthor(book, profile) || isCoauthor(book, profile)); + } + + private boolean isAuthor(Book book, Profile profile) { + return book.getAuthor().getId().equals(profile.getId()); + } + + private boolean isCoauthor(Book book, Profile profile) { + return book.getCoauthors().contains(profile); + } + +} diff --git a/src/main/java/fic/writer/domain/audit/SpringSecurityAuditorAware.java b/src/main/java/fic/writer/domain/audit/SpringSecurityAuditorAware.java new file mode 100644 index 0000000..3166611 --- /dev/null +++ b/src/main/java/fic/writer/domain/audit/SpringSecurityAuditorAware.java @@ -0,0 +1,22 @@ +package fic.writer.domain.audit; + +import fic.writer.domain.entity.Profile; +import fic.writer.web.config.security.authorization.UserPrincipal; +import org.springframework.data.domain.AuditorAware; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +public class SpringSecurityAuditorAware implements AuditorAware { + @Override + public Optional getCurrentAuditor() { + Optional user = Optional.empty(); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null) { + UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); + user = Optional.ofNullable(userPrincipal.getProfileDetails().getProfile()); + } + return user; + } +} \ No newline at end of file diff --git a/src/main/java/fic/writer/domain/entity/Actor.java b/src/main/java/fic/writer/domain/entity/Actor.java new file mode 100644 index 0000000..41c0fdd --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/Actor.java @@ -0,0 +1,25 @@ +package fic.writer.domain.entity; + +import lombok.*; + +import javax.persistence.*; +import javax.validation.constraints.NotBlank; +import java.util.Set; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Actor { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(updatable = false) + private Long id; + @NotBlank + private String name; + private String description; + @ManyToMany(fetch = FetchType.LAZY, mappedBy = "actors") + private Set books; +} \ No newline at end of file diff --git a/src/main/java/fic/writer/domain/entity/Article.java b/src/main/java/fic/writer/domain/entity/Article.java new file mode 100644 index 0000000..68c197a --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/Article.java @@ -0,0 +1,40 @@ +package fic.writer.domain.entity; + +import lombok.*; +import org.hibernate.annotations.Formula; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.*; +import javax.validation.constraints.NotBlank; +import java.util.Date; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Article { + private static final int CHARS_IN_PAGE = 1800; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @NotBlank + private String title; + @CreatedDate + private Date created; + @LastModifiedDate + private Date lastModify; + @Column(columnDefinition = "TEXT") + @NotBlank + private String content; + private String annotation; + @ManyToOne(fetch = FetchType.LAZY) + private Book book; + @Formula("ceil( CHAR_LENGTH(content)/" + CHARS_IN_PAGE + ")") + private long pageCount; + +} \ No newline at end of file diff --git a/src/main/java/fic/writer/domain/entity/Book.java b/src/main/java/fic/writer/domain/entity/Book.java new file mode 100644 index 0000000..cac4763 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/Book.java @@ -0,0 +1,83 @@ +package fic.writer.domain.entity; + +import fic.writer.domain.entity.enums.Size; +import fic.writer.domain.entity.enums.State; +import lombok.*; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.*; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.util.Set; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Book { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @NotBlank + private String title; + @ManyToOne + @CreatedBy + @NotNull + private Profile author; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "book_subauthors", + joinColumns = {@JoinColumn(name = "book_id")}, + inverseJoinColumns = {@JoinColumn(name = "user_id")} + ) + @Singular("coauthors") + private Set coauthors; + + @OneToMany(fetch = FetchType.EAGER) + @Singular("source") + private Set source; + + @Column(columnDefinition = "text") + private String description; + + @Enumerated + private Size size; + + @Enumerated + private State state; + + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @Singular("articles") + private Set
articles; + + @Transient + private Long pageCount; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "book_genres", + joinColumns = {@JoinColumn(name = "book_id")}, + inverseJoinColumns = {@JoinColumn(name = "genre_id")} + ) + private Set genres; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "book_actors", + joinColumns = {@JoinColumn(name = "book_id")}, + inverseJoinColumns = {@JoinColumn(name = "actor_id")} + ) + @Singular("actors") + private Set actors; + + @PostLoad + private void calculatePageCount() { + this.pageCount = 0L; + if (articles != null) { + pageCount = articles.stream().mapToLong(Article::getPageCount).sum(); + } + } + +} diff --git a/src/main/java/fic/writer/domain/entity/Genre.java b/src/main/java/fic/writer/domain/entity/Genre.java new file mode 100644 index 0000000..5af034f --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/Genre.java @@ -0,0 +1,23 @@ +package fic.writer.domain.entity; + +import lombok.*; + +import javax.persistence.*; +import javax.validation.constraints.NotBlank; +import java.util.Set; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Genre { + @Id + private Long id; + @NotBlank + @Column(unique = true) + private String name; + @ManyToMany(mappedBy = "genres", fetch = FetchType.LAZY) + private Set book; +} diff --git a/src/main/java/fic/writer/domain/entity/Profile.java b/src/main/java/fic/writer/domain/entity/Profile.java new file mode 100644 index 0000000..ce9d1be --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/Profile.java @@ -0,0 +1,40 @@ +package fic.writer.domain.entity; + +import lombok.*; +import org.hibernate.validator.constraints.Length; + +import javax.persistence.*; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import java.util.Set; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "profile") +@EqualsAndHashCode(of = {"id", "email", "username"}) +public class Profile { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @NotBlank + @Length(min = 2, max = 255) + private String username; + @Length(min = 5, max = 255) + @Email(regexp = "[A-Za-z0-9._%-+]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,4}") + private String email; + private String imageUrl; + private String about; + private String information; + + @ManyToMany(mappedBy = "coauthors", fetch = FetchType.LAZY) + @Singular("booksAsCoauthor") + private Set booksAsCoauthor; + + @OneToMany(fetch = FetchType.LAZY) + @Singular("booksAsAuthor") + private Set booksAsAuthor; +} diff --git a/src/main/java/fic/writer/domain/entity/auth/AuthProvider.java b/src/main/java/fic/writer/domain/entity/auth/AuthProvider.java new file mode 100644 index 0000000..9dc57e4 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/auth/AuthProvider.java @@ -0,0 +1,5 @@ +package fic.writer.domain.entity.auth; + +public enum AuthProvider { + LOCAL, GOOGLE, GITHUB +} diff --git a/src/main/java/fic/writer/domain/entity/auth/OauthProfileDetails.java b/src/main/java/fic/writer/domain/entity/auth/OauthProfileDetails.java new file mode 100644 index 0000000..1fb4e1b --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/auth/OauthProfileDetails.java @@ -0,0 +1,27 @@ +package fic.writer.domain.entity.auth; + +import fic.writer.domain.entity.Profile; +import lombok.*; + +import javax.persistence.*; +import javax.validation.constraints.NotNull; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OauthProfileDetails { + @Id + @GeneratedValue + private Long id; + @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL) + @NotNull + private Profile profile; + @NotNull + @Enumerated(EnumType.STRING) + private AuthProvider provider; + private String providerId; + private String password; +} diff --git a/src/main/java/fic/writer/domain/entity/auth/profileInfo/GithubOAuth2ProfileInfo.java b/src/main/java/fic/writer/domain/entity/auth/profileInfo/GithubOAuth2ProfileInfo.java new file mode 100644 index 0000000..bd0afb4 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/auth/profileInfo/GithubOAuth2ProfileInfo.java @@ -0,0 +1,30 @@ +package fic.writer.domain.entity.auth.profileInfo; + +import java.util.Map; + +public class GithubOAuth2ProfileInfo extends OAuth2ProfileInfo { + + public GithubOAuth2ProfileInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return ((Integer) attributes.get("id")).toString(); + } + + @Override + public String getName() { + return (String) attributes.get("login"); + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } + + @Override + public String getImageUrl() { + return (String) attributes.get("avatar_url"); + } +} diff --git a/src/main/java/fic/writer/domain/entity/auth/profileInfo/GoogleOAuth2ProfileInfo.java b/src/main/java/fic/writer/domain/entity/auth/profileInfo/GoogleOAuth2ProfileInfo.java new file mode 100644 index 0000000..925a99e --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/auth/profileInfo/GoogleOAuth2ProfileInfo.java @@ -0,0 +1,30 @@ +package fic.writer.domain.entity.auth.profileInfo; + +import java.util.Map; + +public class GoogleOAuth2ProfileInfo extends OAuth2ProfileInfo { + + public GoogleOAuth2ProfileInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return (String) attributes.get("sub"); + } + + @Override + public String getName() { + return (String) attributes.get("name"); + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } + + @Override + public String getImageUrl() { + return (String) attributes.get("picture"); + } +} diff --git a/src/main/java/fic/writer/domain/entity/auth/profileInfo/OAuth2ProfileInfo.java b/src/main/java/fic/writer/domain/entity/auth/profileInfo/OAuth2ProfileInfo.java new file mode 100644 index 0000000..fd7eec7 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/auth/profileInfo/OAuth2ProfileInfo.java @@ -0,0 +1,23 @@ +package fic.writer.domain.entity.auth.profileInfo; + +import java.util.Map; + +public abstract class OAuth2ProfileInfo { + protected Map attributes; + + public OAuth2ProfileInfo(Map attributes) { + this.attributes = attributes; + } + + public Map getAttributes() { + return attributes; + } + + public abstract String getId(); + + public abstract String getName(); + + public abstract String getEmail(); + + public abstract String getImageUrl(); +} diff --git a/src/main/java/fic/writer/domain/entity/auth/profileInfo/OAuth2ProfileInfoFactory.java b/src/main/java/fic/writer/domain/entity/auth/profileInfo/OAuth2ProfileInfoFactory.java new file mode 100644 index 0000000..b0af6a1 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/auth/profileInfo/OAuth2ProfileInfoFactory.java @@ -0,0 +1,22 @@ +package fic.writer.domain.entity.auth.profileInfo; + +import fic.writer.domain.entity.auth.AuthProvider; +import fic.writer.exception.OAuth2AuthenticationProcessingException; + +import java.util.Map; + +public class OAuth2ProfileInfoFactory { + + public static OAuth2ProfileInfo getOAuth2ProfileInfo(AuthProvider authProvider, Map attributes) { + + switch (authProvider) { + case GOOGLE: + return new GoogleOAuth2ProfileInfo(attributes); + case GITHUB: + return new GithubOAuth2ProfileInfo(attributes); + default: + throw new OAuth2AuthenticationProcessingException("Sorry! Login with " + authProvider.name().toLowerCase() + " is not supported yet."); + } + } + +} diff --git a/src/main/java/fic/writer/domain/entity/dto/ActorDto.java b/src/main/java/fic/writer/domain/entity/dto/ActorDto.java new file mode 100644 index 0000000..477f7a3 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/dto/ActorDto.java @@ -0,0 +1,22 @@ +package fic.writer.domain.entity.dto; + +import fic.writer.domain.entity.Actor; +import lombok.Builder; +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Builder +@Data +public class ActorDto { + @NotBlank + private String name; + private String description; + + public static ActorDto of(Actor actor) { + return builder() + .name(actor.getName()) + .description(actor.getDescription()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/fic/writer/domain/entity/dto/ArticleDto.java b/src/main/java/fic/writer/domain/entity/dto/ArticleDto.java new file mode 100644 index 0000000..61998dd --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/dto/ArticleDto.java @@ -0,0 +1,27 @@ +package fic.writer.domain.entity.dto; + +import fic.writer.domain.entity.Article; +import lombok.*; + +import javax.validation.constraints.NotBlank; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ArticleDto { + @NotBlank + private String title; + @NotBlank + private String content; + private String annotation; + + public static ArticleDto of(Article article) { + return builder() + .title(article.getTitle()) + .content(article.getContent()) + .annotation(article.getAnnotation()) + .build(); + } +} diff --git a/src/main/java/fic/writer/domain/entity/dto/BookDto.java b/src/main/java/fic/writer/domain/entity/dto/BookDto.java new file mode 100644 index 0000000..411a1d3 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/dto/BookDto.java @@ -0,0 +1,31 @@ +package fic.writer.domain.entity.dto; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.enums.Size; +import fic.writer.domain.entity.enums.State; +import lombok.*; + +import javax.validation.constraints.NotBlank; + + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class BookDto { + @NotBlank + private String title; + private String description; + private Size size; + private State state; + + public static BookDto of(Book book) { + return builder() + .title(book.getTitle()) + .description(book.getDescription()) + .size(book.getSize()) + .state(book.getState()) + .build(); + } +} diff --git a/src/main/java/fic/writer/domain/entity/dto/ProfileDto.java b/src/main/java/fic/writer/domain/entity/dto/ProfileDto.java new file mode 100644 index 0000000..41e21d2 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/dto/ProfileDto.java @@ -0,0 +1,33 @@ +package fic.writer.domain.entity.dto; + +import fic.writer.domain.entity.Profile; +import lombok.*; +import org.hibernate.validator.constraints.Length; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ProfileDto { + @NotBlank + @Length(min = 2, max = 255) + private String username; + private String about; + private String information; + @Email(regexp = "[A-Za-z0-9._%-+]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,4}") + @NotBlank + private String email; + + public static ProfileDto of(Profile profile) { + return builder() + .username(profile.getUsername()) + .about(profile.getAbout()) + .information(profile.getInformation()) + .email(profile.getEmail()) + .build(); + } +} diff --git a/src/main/java/fic/writer/domain/entity/enums/FileExtension.java b/src/main/java/fic/writer/domain/entity/enums/FileExtension.java new file mode 100644 index 0000000..e625c46 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/enums/FileExtension.java @@ -0,0 +1,5 @@ +package fic.writer.domain.entity.enums; + +public enum FileExtension { + TXT, DOCX, XML, JSON, FB2 +} diff --git a/src/main/java/fic/writer/domain/entity/enums/Size.java b/src/main/java/fic/writer/domain/entity/enums/Size.java new file mode 100644 index 0000000..1b3b0a0 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/enums/Size.java @@ -0,0 +1,5 @@ +package fic.writer.domain.entity.enums; + +public enum Size { + MINI, MEDIUM, LONG +} diff --git a/src/main/java/fic/writer/domain/entity/enums/State.java b/src/main/java/fic/writer/domain/entity/enums/State.java new file mode 100644 index 0000000..318ac06 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/enums/State.java @@ -0,0 +1,5 @@ +package fic.writer.domain.entity.enums; + +public enum State { + FROZEN, IN_PROGRESS, DONE +} diff --git a/src/main/java/fic/writer/domain/repository/ActorRepository.java b/src/main/java/fic/writer/domain/repository/ActorRepository.java new file mode 100644 index 0000000..3eb5d55 --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/ActorRepository.java @@ -0,0 +1,7 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.Actor; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActorRepository extends JpaRepository { +} diff --git a/src/main/java/fic/writer/domain/repository/ActorRepositry.java b/src/main/java/fic/writer/domain/repository/ActorRepositry.java new file mode 100644 index 0000000..fb2894a --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/ActorRepositry.java @@ -0,0 +1,7 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.Actor; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActorRepositry extends JpaRepository { +} diff --git a/src/main/java/fic/writer/domain/repository/ArticleRepository.java b/src/main/java/fic/writer/domain/repository/ArticleRepository.java new file mode 100644 index 0000000..0456388 --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/ArticleRepository.java @@ -0,0 +1,10 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.Article; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ArticleRepository extends JpaRepository { + List
findAllByBookId(Long bookId); +} diff --git a/src/main/java/fic/writer/domain/repository/BookRepository.java b/src/main/java/fic/writer/domain/repository/BookRepository.java new file mode 100644 index 0000000..a769dfb --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/BookRepository.java @@ -0,0 +1,7 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.Book; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookRepository extends JpaRepository { +} diff --git a/src/main/java/fic/writer/domain/repository/GenreRepository.java b/src/main/java/fic/writer/domain/repository/GenreRepository.java new file mode 100644 index 0000000..fddb9e1 --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/GenreRepository.java @@ -0,0 +1,7 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.Genre; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GenreRepository extends JpaRepository { +} diff --git a/src/main/java/fic/writer/domain/repository/OauthProfileDetailsRepository.java b/src/main/java/fic/writer/domain/repository/OauthProfileDetailsRepository.java new file mode 100644 index 0000000..28bd406 --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/OauthProfileDetailsRepository.java @@ -0,0 +1,10 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.auth.OauthProfileDetails; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OauthProfileDetailsRepository extends JpaRepository { + Optional findByProfileEmail(String profile); +} \ No newline at end of file diff --git a/src/main/java/fic/writer/domain/repository/ProfileRepository.java b/src/main/java/fic/writer/domain/repository/ProfileRepository.java new file mode 100644 index 0000000..49404c8 --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/ProfileRepository.java @@ -0,0 +1,12 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.Profile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ProfileRepository extends JpaRepository { + Optional findByUsername(String username); + + Optional findByEmail(String email); +} diff --git a/src/main/java/fic/writer/domain/service/ActorService.java b/src/main/java/fic/writer/domain/service/ActorService.java new file mode 100644 index 0000000..e39043f --- /dev/null +++ b/src/main/java/fic/writer/domain/service/ActorService.java @@ -0,0 +1,25 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Actor; +import fic.writer.domain.entity.dto.ActorDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ActorService { + Actor getOne(Long id); + + Actor create(ActorDto actor); + + List findAll(); + + Page findPage(Pageable pageable); + + Optional findById(Long id); + + Actor update(Long id, ActorDto actor); + + void deleteById(Long aLong); +} diff --git a/src/main/java/fic/writer/domain/service/ArticleService.java b/src/main/java/fic/writer/domain/service/ArticleService.java new file mode 100644 index 0000000..d6832bd --- /dev/null +++ b/src/main/java/fic/writer/domain/service/ArticleService.java @@ -0,0 +1,19 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.dto.ArticleDto; + +import java.util.List; +import java.util.Optional; + +public interface ArticleService { + List
findAll(); + + Article update(Long id, ArticleDto articleDto); + + Optional
findById(Long articleId); + + void delete(Article article); + + void deleteById(Long articleId); +} diff --git a/src/main/java/fic/writer/domain/service/BookService.java b/src/main/java/fic/writer/domain/service/BookService.java new file mode 100644 index 0000000..3f39d6e --- /dev/null +++ b/src/main/java/fic/writer/domain/service/BookService.java @@ -0,0 +1,37 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.dto.ArticleDto; +import fic.writer.domain.entity.dto.BookDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface BookService { + + List findAll(); + + Page findPage(Pageable pageable); + + Optional findById(Long bookId); + + Book create(BookDto bookDto); + + Book save(Book book); + + Book update(Long id, BookDto bookDto); + + Book addArticle(Long bookId, ArticleDto articleDto); + + Book removeArticle(Long bookId, Long articleId); + + void delete(Book book); + + void deleteById(Long bookId); + + byte[] getBookAsByteArray(Long bookId); + + byte[] convertBookToByteArray(Book book); +} diff --git a/src/main/java/fic/writer/domain/service/CrudService.java b/src/main/java/fic/writer/domain/service/CrudService.java new file mode 100644 index 0000000..7e5739f --- /dev/null +++ b/src/main/java/fic/writer/domain/service/CrudService.java @@ -0,0 +1,21 @@ +package fic.writer.domain.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface CrudService { + List findAll(); + + Page findPage(Pageable pageable); + + Optional findById(ID id); + + T save(T t); + + void delete(T t); + + void deleteById(ID id); +} diff --git a/src/main/java/fic/writer/domain/service/FileService.java b/src/main/java/fic/writer/domain/service/FileService.java new file mode 100644 index 0000000..bd4576b --- /dev/null +++ b/src/main/java/fic/writer/domain/service/FileService.java @@ -0,0 +1,13 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.Book; +import org.springframework.web.multipart.MultipartFile; + +public interface FileService { + String parseText(MultipartFile file); + + Article parseArticle(MultipartFile file); + + Book parseBook(MultipartFile file); +} diff --git a/src/main/java/fic/writer/domain/service/GenreService.java b/src/main/java/fic/writer/domain/service/GenreService.java new file mode 100644 index 0000000..9292154 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/GenreService.java @@ -0,0 +1,7 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Genre; + +public interface GenreService extends CrudService { + +} diff --git a/src/main/java/fic/writer/domain/service/OauthProfileDetailsService.java b/src/main/java/fic/writer/domain/service/OauthProfileDetailsService.java new file mode 100644 index 0000000..a5657ee --- /dev/null +++ b/src/main/java/fic/writer/domain/service/OauthProfileDetailsService.java @@ -0,0 +1,6 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.auth.OauthProfileDetails; + +public interface OauthProfileDetailsService extends CrudService { +} diff --git a/src/main/java/fic/writer/domain/service/ProfileService.java b/src/main/java/fic/writer/domain/service/ProfileService.java new file mode 100644 index 0000000..d8520e5 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/ProfileService.java @@ -0,0 +1,35 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.dto.ProfileDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ProfileService { + List findAll(); + + Page findPage(Pageable pageable); + + Optional findById(Long aLong); + + Optional findByUsername(String username); + + Optional findByEmail(String email); + + Profile create(ProfileDto user); + + Profile addBookAsAuthor(Long userId, Long bookId); + + Profile update(Long userId, ProfileDto user); + + Profile save(Profile profile); + + void delete(Profile profile); + + void deleteById(Long aLong); + + Optional findByUsernameOrEmail(String usernameOrEmail); +} diff --git a/src/main/java/fic/writer/domain/service/WriterService.java b/src/main/java/fic/writer/domain/service/WriterService.java new file mode 100644 index 0000000..92e573d --- /dev/null +++ b/src/main/java/fic/writer/domain/service/WriterService.java @@ -0,0 +1,10 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.dto.BookDto; + +public interface WriterService { + Book saveBook(BookDto bookDto); + + Book saveBook(Book book); +} diff --git a/src/main/java/fic/writer/domain/service/files/ArticleParser.java b/src/main/java/fic/writer/domain/service/files/ArticleParser.java new file mode 100644 index 0000000..f4f07a2 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/ArticleParser.java @@ -0,0 +1,19 @@ +package fic.writer.domain.service.files; + +import fic.writer.domain.entity.Article; + +public interface ArticleParser { + String getTitle(); + + String getAnnotation(); + + String getContent(); + + default Article parse() { + return Article.builder() + .title(this.getTitle()) + .annotation(this.getAnnotation()) + .content(this.getContent()) + .build(); + } +} diff --git a/src/main/java/fic/writer/domain/service/files/BookParser.java b/src/main/java/fic/writer/domain/service/files/BookParser.java new file mode 100644 index 0000000..077d941 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/BookParser.java @@ -0,0 +1,28 @@ +package fic.writer.domain.service.files; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.enums.State; + +import java.util.Set; + +public interface BookParser { + String getTitle(); + + Set getCoauthors(); + + String getDescription(); + + State getState(); + + Set
getArticles(); + + default Book parse() { + return Book.builder() + .title(this.getTitle()) + .description(this.getDescription()) + .articles(this.getArticles()) + .build(); + } +} diff --git a/src/main/java/fic/writer/domain/service/files/TextParser.java b/src/main/java/fic/writer/domain/service/files/TextParser.java new file mode 100644 index 0000000..5a821a6 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/TextParser.java @@ -0,0 +1,7 @@ +package fic.writer.domain.service.files; + +import org.springframework.web.multipart.MultipartFile; + +public interface TextParser { + String parseFile(MultipartFile file); +} diff --git a/src/main/java/fic/writer/domain/service/files/impl/DocxTextParser.java b/src/main/java/fic/writer/domain/service/files/impl/DocxTextParser.java new file mode 100644 index 0000000..2b54ccd --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/impl/DocxTextParser.java @@ -0,0 +1,28 @@ +package fic.writer.domain.service.files.impl; + +import fic.writer.domain.service.files.TextParser; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +//TODO IT read unformatted text +public class DocxTextParser implements TextParser { + + @Override + public String parseFile(MultipartFile file) { + StringBuilder fileContent = new StringBuilder(); + try { + XWPFDocument document = new XWPFDocument(file.getInputStream()); + List paragraphs = document.getParagraphs(); + for (int i = 0; i < paragraphs.size(); i++) { + fileContent.append(paragraphs.get(i).getParagraphText()); + } + } catch (IOException e) { + throw new RuntimeException(); + } + return fileContent.toString(); + } +} diff --git a/src/main/java/fic/writer/domain/service/files/impl/JsonArticleParser.java b/src/main/java/fic/writer/domain/service/files/impl/JsonArticleParser.java new file mode 100644 index 0000000..64e3668 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/impl/JsonArticleParser.java @@ -0,0 +1,41 @@ +package fic.writer.domain.service.files.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import fic.writer.domain.service.files.ArticleParser; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +public class JsonArticleParser implements ArticleParser { + private JsonNode jsonObject; + + public JsonArticleParser(MultipartFile file) { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode root; + try { + root = objectMapper.reader().readTree(file.getInputStream()); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + + this.jsonObject = root; + } + + @Override + public String getTitle() { + return jsonObject.path("title").textValue(); + } + + @Override + public String getAnnotation() { + return jsonObject.path("annotation").textValue(); + } + + @Override + public String getContent() { + return jsonObject.path("content").textValue(); + } + +} diff --git a/src/main/java/fic/writer/domain/service/files/impl/TxtTextParser.java b/src/main/java/fic/writer/domain/service/files/impl/TxtTextParser.java new file mode 100644 index 0000000..d41c6ae --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/impl/TxtTextParser.java @@ -0,0 +1,24 @@ +package fic.writer.domain.service.files.impl; + +import fic.writer.domain.service.files.TextParser; +import org.apache.tomcat.util.http.fileupload.IOUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +public class TxtTextParser implements TextParser { + + @Override + public String parseFile(MultipartFile file) { + String fileContent = ""; + try { + ByteArrayOutputStream stringWriter = new ByteArrayOutputStream(); + IOUtils.copy(file.getInputStream(), stringWriter); + fileContent = stringWriter.toString(); + } catch (IOException e) { + throw new RuntimeException(); + } + return fileContent; + } +} diff --git a/src/main/java/fic/writer/domain/service/files/impl/XmlArticleParser.java b/src/main/java/fic/writer/domain/service/files/impl/XmlArticleParser.java new file mode 100644 index 0000000..9d67d80 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/impl/XmlArticleParser.java @@ -0,0 +1,59 @@ +package fic.writer.domain.service.files.impl; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.service.files.ArticleParser; +import org.springframework.web.multipart.MultipartFile; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.IOException; + +public class XmlArticleParser implements ArticleParser { + private Element element; + + @Override + public Article parse() { + return Article.builder() + .title(this.getTitle()) + .annotation(this.getAnnotation()) + .content(this.getContent()) + .build(); + } + + public XmlArticleParser(MultipartFile multipartFile) { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder dBuilder = null; + Document doc; + try { + dBuilder = dbFactory.newDocumentBuilder(); + doc = dBuilder.parse(multipartFile.getInputStream()); + } catch (ParserConfigurationException | SAXException | IOException e) { + e.printStackTrace(); + throw new RuntimeException(e.getMessage()); + } + doc.getDocumentElement().normalize(); + element = doc.getDocumentElement(); + } + + + @Override + public String getTitle() { + return element.getElementsByTagName("title").item(0).getFirstChild().getNodeValue(); + } + + @Override + public String getAnnotation() { + return element.getElementsByTagName("annotation").item(0).getFirstChild().getNodeValue(); + } + + @Override + public String getContent() { + return element.getElementsByTagName("section").item(0).getFirstChild().getNodeValue(); + } + + +} diff --git a/src/main/java/fic/writer/domain/service/files/impl/XmlBookParser.java b/src/main/java/fic/writer/domain/service/files/impl/XmlBookParser.java new file mode 100644 index 0000000..3a21458 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/impl/XmlBookParser.java @@ -0,0 +1,152 @@ +package fic.writer.domain.service.files.impl; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.enums.State; +import fic.writer.domain.service.files.BookParser; +import org.springframework.web.multipart.MultipartFile; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.*; +import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; + +public class XmlBookParser implements BookParser { + private Document xmlDocument; + private XPath xPath; + + { + xPath = XPathFactory.newInstance().newXPath(); + } + + public XmlBookParser(MultipartFile file) { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder dBuilder = null; + try { + dBuilder = dbFactory.newDocumentBuilder(); + xmlDocument = dBuilder.parse(file.getInputStream()); + } catch (ParserConfigurationException + | SAXException + | IOException e) { + e.printStackTrace(); + throw new RuntimeException(e.getMessage()); + } + + xmlDocument.getDocumentElement().normalize(); + } + + + @Override + public String getTitle() { + final String BOOK_TITLE_PATH = "//FictionBook/description/title-info/book-title"; + String title = ""; + try { + XPathExpression expr = xPath.compile(BOOK_TITLE_PATH); + title = (String) expr.evaluate(xmlDocument, XPathConstants.STRING); + } catch (XPathExpressionException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + return trimAndNormalizeSpace(title); + } + + @Override + public String getDescription() { + final String BOOK_DESCRIPTION_PATH = "//FictionBook/description/title-info/annotation"; + String description = ""; + try { + XPathExpression expr = xPath.compile(BOOK_DESCRIPTION_PATH); + description = (String) expr.evaluate(xmlDocument, XPathConstants.STRING); + } catch (XPathExpressionException e) { + e.printStackTrace(); + throw new RuntimeException(e.getMessage()); + } + + return trimAndNormalizeSpace(description); + } + + @Override + public Set getCoauthors() { + throw new UnsupportedOperationException("fb2 doesn't contain this field"); + } + + @Override + public State getState() { + throw new UnsupportedOperationException("fb2 doesn't contain this field"); + } + + @Override + public Set
getArticles() { + final String ARTICLE_CONTAINER_PATH = "//body"; + final String ARTICLE_ELEMENT_NAME = "section"; + final String TITLE_ELEMENT_NAME = "title"; + final String ANNOTATION_ELEMENT_NAME = "annotation"; + final String CONTENT_TAG_NAME = "p"; + Set
articles = new LinkedHashSet<>(); + Element bodyElement; + try { + XPathExpression sectionPathExpression = xPath.compile(ARTICLE_CONTAINER_PATH); + bodyElement = (Element) sectionPathExpression.evaluate(xmlDocument, XPathConstants.NODE); + } catch (XPathExpressionException e) { + e.printStackTrace(); + throw new RuntimeException(e.getMessage()); + } + NodeList articleNodes = bodyElement.getElementsByTagName(ARTICLE_ELEMENT_NAME); + for (int i = 0; i < articleNodes.getLength(); i++) { + Element articleElement = (Element) articleNodes.item(i); + + String title = getNormalizedTagContent(articleElement, TITLE_ELEMENT_NAME); + String annotation = getNormalizedTagContent(articleElement, ANNOTATION_ELEMENT_NAME); + + StringBuilder content = new StringBuilder(); + NodeList articleContentNodes = articleElement.getElementsByTagName(CONTENT_TAG_NAME); + for (int j = 0; j < articleContentNodes.getLength(); j++) { + if (articleContentNodes.item(j).getParentNode().getNodeName().equals(ARTICLE_ELEMENT_NAME)) { + content.append("\n") + .append(articleContentNodes.item(j).getTextContent()) + .append("\n"); + } + } + Article article = Article.builder() + .title(title) + .annotation(annotation) + .content(content.toString()) + .build(); + articles.add(article); + } + + return articles; + } + + private String trimAndNormalizeSpace(String source) { + return source.trim().replaceAll("(\\s)\\1", "$1"); + } + + private String getNormalizedTagContent(Element source, String tagName) { + Optional annotationNode = getFirstNodeByTag(source, tagName); + return trimAndNormalizeSpace(annotationNode.map(Node::getTextContent).orElse("")); + } + + private Optional getNode(Element element, String tagName, int index) { + NodeList nodeList = element.getElementsByTagName(tagName); + Optional result; + + result = index < nodeList.getLength() + ? Optional.of(nodeList.item(index)) + : Optional.empty(); + return result; + } + + private Optional getFirstNodeByTag(Element element, String tagName) { + return getNode(element, tagName, 0); + } +} diff --git a/src/main/java/fic/writer/domain/service/helper/BookStringBuilder.java b/src/main/java/fic/writer/domain/service/helper/BookStringBuilder.java new file mode 100644 index 0000000..d5f8424 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/BookStringBuilder.java @@ -0,0 +1,24 @@ +package fic.writer.domain.service.helper; + +class BookStringBuilder { + private final static String DEFAULT_SPLITTER = ":"; + private String content = ""; + + public BookStringBuilder addParagraph(String content) { + this.content += "\n" + content; + return this; + } + + public BookStringBuilder addDescription(String header, String value) { + return this.addDescription(header, value, DEFAULT_SPLITTER); + } + + public BookStringBuilder addDescription(String header, String value, String splitter) { + this.content += "\n" + header + splitter + value; + return this; + } + + public String getContent() { + return content; + } +} diff --git a/src/main/java/fic/writer/domain/service/helper/BookStringConstructor.java b/src/main/java/fic/writer/domain/service/helper/BookStringConstructor.java new file mode 100644 index 0000000..71f015a --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/BookStringConstructor.java @@ -0,0 +1,35 @@ +package fic.writer.domain.service.helper; + +import fic.writer.domain.entity.Book; + +public abstract class BookStringConstructor { + protected String content; + + public final String convertBookToText(Book book) { + writeTitle(book); + writeDescription(book); + writeAuthor(book); + writeCoauthors(book); + writeSize(book); + writeState(book); + writeArticleHeaders(book); + writeArticlesContent(book); + return content; + } + + protected abstract void writeTitle(Book book); + + protected abstract void writeDescription(Book book); + + protected abstract void writeAuthor(Book book); + + protected abstract void writeCoauthors(Book book); + + protected abstract void writeSize(Book book); + + protected abstract void writeState(Book book); + + protected abstract void writeArticleHeaders(Book book); + + protected abstract void writeArticlesContent(Book book); +} diff --git a/src/main/java/fic/writer/domain/service/helper/BookTXTStringConstructor.java b/src/main/java/fic/writer/domain/service/helper/BookTXTStringConstructor.java new file mode 100644 index 0000000..b12dfbf --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/BookTXTStringConstructor.java @@ -0,0 +1,93 @@ +package fic.writer.domain.service.helper; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.Profile; + +import java.util.Set; + +public class BookTXTStringConstructor extends BookStringConstructor { + protected BookStringBuilder bookStringBuilder = new BookStringBuilder(); + + @Override + protected void writeTitle(Book book) { + String titleHeader = "Title"; + bookStringBuilder.addDescription(titleHeader, book.getTitle()); + content = bookStringBuilder.getContent(); + } + + @Override + protected void writeDescription(Book book) { + String descriptionHeader = "Description"; + bookStringBuilder.addDescription(descriptionHeader, book.getDescription()); + content = bookStringBuilder.getContent(); + } + + @Override + protected void writeAuthor(Book book) { + if (book.getAuthor() != null) { + String authorHeader = "Author"; + bookStringBuilder.addDescription(authorHeader, book.getAuthor().getUsername()); + content = bookStringBuilder.getContent(); + } + } + + @Override + protected void writeCoauthors(Book book) { + final String coauthorSeparator = ","; + String coauthors = book.getCoauthors() + .stream() + .map(Profile::getUsername) + .reduce((b, a) -> b + coauthorSeparator + a) + .orElse(""); + String coauthorsHeader = "Coauthor"; + bookStringBuilder.addDescription(coauthorsHeader, coauthors); + content = bookStringBuilder.getContent(); + } + + @Override + protected void writeSize(Book book) { + if (book.getSize() != null) { + String sizeHeader = "Size"; + bookStringBuilder.addDescription(sizeHeader, book.getSize().name()); + } + content = bookStringBuilder.getContent(); + } + + @Override + protected void writeState(Book book) { + if (book.getState() != null) { + String stateHeader = "State"; + bookStringBuilder.addDescription(stateHeader, book.getState().name()); + } + content = bookStringBuilder.getContent(); + } + + @Override + protected void writeArticleHeaders(Book book) { + String contentHeader = "Content"; + bookStringBuilder.addParagraph(contentHeader); + int articleCounter = 0; + Set
articles = book.getArticles(); + for (Article article : articles) { + articleCounter++; + String contentRow = articleCounter + ")" + article.getTitle(); + bookStringBuilder.addParagraph(contentRow); + } + content = bookStringBuilder.getContent(); + } + + @Override + protected void writeArticlesContent(Book book) { + Set
articles = book.getArticles(); + for (Article article : articles) { + bookStringBuilder.addParagraph(article.getTitle()); + String annotationHeader = "Annotation"; + bookStringBuilder.addDescription(annotationHeader, article.getAnnotation()); + bookStringBuilder.addParagraph(article.getContent()); + } + content = bookStringBuilder.getContent(); + } + + +} diff --git a/src/main/java/fic/writer/domain/service/helper/FileParserFactory.java b/src/main/java/fic/writer/domain/service/helper/FileParserFactory.java new file mode 100644 index 0000000..528574e --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/FileParserFactory.java @@ -0,0 +1,14 @@ +package fic.writer.domain.service.helper; + +import fic.writer.domain.service.files.ArticleParser; +import fic.writer.domain.service.files.BookParser; +import fic.writer.domain.service.files.TextParser; +import org.springframework.web.multipart.MultipartFile; + +public interface FileParserFactory { + TextParser getTextParser(MultipartFile file); + + ArticleParser getArticleParser(MultipartFile file); + + BookParser getBookParser(MultipartFile file); +} diff --git a/src/main/java/fic/writer/domain/service/helper/FileParserFactoryImpl.java b/src/main/java/fic/writer/domain/service/helper/FileParserFactoryImpl.java new file mode 100644 index 0000000..c75ba7f --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/FileParserFactoryImpl.java @@ -0,0 +1,70 @@ +package fic.writer.domain.service.helper; + +import fic.writer.domain.entity.enums.FileExtension; +import fic.writer.domain.service.files.ArticleParser; +import fic.writer.domain.service.files.BookParser; +import fic.writer.domain.service.files.TextParser; +import fic.writer.domain.service.files.impl.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Objects; + +public class FileParserFactoryImpl implements FileParserFactory { + @Override + public TextParser getTextParser(MultipartFile file) { + FileExtension fileExtension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + TextParser parser; + switch (fileExtension) { + case TXT: + parser = new TxtTextParser(); + break; + case DOCX: + parser = new DocxTextParser(); + break; + default: + throw new EnumConstantNotPresentException(FileExtension.class, fileExtension.toString()); + } + return parser; + } + + @Override + public ArticleParser getArticleParser(MultipartFile file) { + FileExtension fileExtension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + ArticleParser parser; + switch (fileExtension) { + case XML: + parser = new XmlArticleParser(file); + break; + case JSON: + parser = new JsonArticleParser(file); + break; + case FB2: + parser = new XmlArticleParser(file); + default: + throw new EnumConstantNotPresentException(FileExtension.class, fileExtension.toString()); + } + return parser; + } + + @Override + public BookParser getBookParser(MultipartFile file) { + FileExtension fileExtension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + BookParser parser; + switch (fileExtension) { + case FB2: + parser = new XmlBookParser(file); + break; + default: + throw new EnumConstantNotPresentException(FileExtension.class, fileExtension.toString()); + } + return parser; + } + + private FileExtension getExtension(String fileName) { + int pointIndex = fileName.lastIndexOf('.'); + int pointPosition = pointIndex + 1; + return pointIndex == -1 + ? FileExtension.TXT + : FileExtension.valueOf(fileName.substring(pointPosition).toUpperCase()); + } +} diff --git a/src/main/java/fic/writer/domain/service/helper/flusher/ActorFlusher.java b/src/main/java/fic/writer/domain/service/helper/flusher/ActorFlusher.java new file mode 100644 index 0000000..88a9a68 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/flusher/ActorFlusher.java @@ -0,0 +1,23 @@ +package fic.writer.domain.service.helper.flusher; + +import fic.writer.domain.entity.Actor; +import fic.writer.domain.entity.dto.ActorDto; + +import javax.validation.Valid; + +public class ActorFlusher { + public static void flushActorDtoToArticle(Actor actor, @Valid ActorDto actorDto) { + if (actorDto.getName() != null) { + actor.setName(actorDto.getName()); + } + if (actorDto.getDescription() != null) { + actor.setDescription(actorDto.getDescription()); + } + } + + public static Actor convertArticleDtoToArticle(@Valid ActorDto actorDto) { + Actor actor = new Actor(); + flushActorDtoToArticle(actor, actorDto); + return actor; + } +} diff --git a/src/main/java/fic/writer/domain/service/helper/flusher/ArticleFlusher.java b/src/main/java/fic/writer/domain/service/helper/flusher/ArticleFlusher.java new file mode 100644 index 0000000..3a333f1 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/flusher/ArticleFlusher.java @@ -0,0 +1,18 @@ +package fic.writer.domain.service.helper.flusher; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.dto.ArticleDto; + +public class ArticleFlusher { + public static void flushArticleDtoToArticle(Article article, ArticleDto articleDto) { + if (articleDto.getTitle() != null) { + article.setTitle(articleDto.getTitle()); + } + if (articleDto.getAnnotation() != null) { + article.setAnnotation(articleDto.getAnnotation()); + } + if (articleDto.getContent() != null) { + article.setContent(articleDto.getContent()); + } + } +} diff --git a/src/main/java/fic/writer/domain/service/helper/flusher/BookFlusher.java b/src/main/java/fic/writer/domain/service/helper/flusher/BookFlusher.java new file mode 100644 index 0000000..ad72be7 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/flusher/BookFlusher.java @@ -0,0 +1,28 @@ +package fic.writer.domain.service.helper.flusher; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.dto.BookDto; + +public class BookFlusher { + + public static void flushBookDtoToBook(Book book, BookDto bookDto) { + if (bookDto.getTitle() != null) { + book.setTitle(bookDto.getTitle()); + } + if (bookDto.getDescription() != null) { + book.setDescription(bookDto.getDescription()); + } + if (bookDto.getSize() != null) { + book.setSize(bookDto.getSize()); + } + if (bookDto.getState() != null) { + book.setState(bookDto.getState()); + } + } + + public static Book convertBookDtoToBook(BookDto bookDto) { + Book book = Book.builder().build(); + BookFlusher.flushBookDtoToBook(book, bookDto); + return book; + } +} diff --git a/src/main/java/fic/writer/domain/service/helper/flusher/ProfileFlusher.java b/src/main/java/fic/writer/domain/service/helper/flusher/ProfileFlusher.java new file mode 100644 index 0000000..29ce82e --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/flusher/ProfileFlusher.java @@ -0,0 +1,27 @@ +package fic.writer.domain.service.helper.flusher; + +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.dto.ProfileDto; + +public class ProfileFlusher { + public static void flushProfileDtoToProfile(Profile profile, ProfileDto profileDto) { + if (profileDto.getUsername() != null) { + profile.setUsername(profileDto.getUsername()); + } + if (profileDto.getAbout() != null) { + profile.setAbout(profileDto.getAbout()); + } + if (profileDto.getInformation() != null) { + profile.setInformation(profileDto.getInformation()); + } + if (profileDto.getEmail() != null) { + profile.setEmail(profileDto.getEmail()); + } + } + + public static Profile convertProfileDtoToProfile(ProfileDto profileDto) { + Profile profile = Profile.builder().build(); + ProfileFlusher.flushProfileDtoToProfile(profile, profileDto); + return profile; + } +} diff --git a/src/main/java/fic/writer/domain/service/impl/ActorServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/ActorServiceImpl.java new file mode 100644 index 0000000..5ed949f --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/ActorServiceImpl.java @@ -0,0 +1,65 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.Actor; +import fic.writer.domain.entity.dto.ActorDto; +import fic.writer.domain.repository.ActorRepository; +import fic.writer.domain.service.ActorService; +import fic.writer.domain.service.helper.flusher.ActorFlusher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import javax.persistence.EntityNotFoundException; +import java.util.List; +import java.util.Optional; + +@Service +public class ActorServiceImpl implements ActorService { + + private ActorRepository actorRepository; + + @Autowired + public ActorServiceImpl(ActorRepository actorRepository) { + this.actorRepository = actorRepository; + } + + @Override + public List findAll() { + return actorRepository.findAll(); + } + + @Override + public Page findPage(Pageable pageable) { + return actorRepository.findAll(pageable); + } + + @Override + public Optional findById(Long id) { + return actorRepository.findById(id); + } + + @Override + public Actor getOne(Long id) { + return actorRepository.getOne(id); + } + + @Override + public Actor create(ActorDto actorDto) { + Actor actor = ActorFlusher.convertArticleDtoToArticle(actorDto); + return actorRepository.save(actor); + } + + @Override + public Actor update(Long id, ActorDto actorDto) { + Actor actor = actorRepository.findById(id).orElseThrow(EntityNotFoundException::new); + ActorFlusher.flushActorDtoToArticle(actor, actorDto); + return actorRepository.save(actor); + } + + @Override + public void deleteById(Long id) { + actorRepository.deleteById(id); + } + +} diff --git a/src/main/java/fic/writer/domain/service/impl/ArticleServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/ArticleServiceImpl.java new file mode 100644 index 0000000..0db6442 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/ArticleServiceImpl.java @@ -0,0 +1,47 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.dto.ArticleDto; +import fic.writer.domain.repository.ArticleRepository; +import fic.writer.domain.service.ArticleService; +import fic.writer.domain.service.helper.flusher.ArticleFlusher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.persistence.EntityExistsException; +import java.util.List; +import java.util.Optional; + +@Service +public class ArticleServiceImpl implements ArticleService { + @Autowired + private ArticleRepository articleRepository; + + @Override + public List
findAll() { + return articleRepository.findAll(); + } + + @Override + public Optional
findById(Long id) { + return articleRepository.findById(id); + } + + @Override + public Article update(Long id, ArticleDto articleDto) { + Article article = articleRepository.findById(id).orElseThrow(EntityExistsException::new); + ArticleFlusher.flushArticleDtoToArticle(article, articleDto); + return articleRepository.save(article); + } + + @Override + public void delete(Article article) { + articleRepository.delete(article); + } + + @Override + public void deleteById(Long id) { + articleRepository.deleteById(id); + } + +} diff --git a/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java new file mode 100644 index 0000000..d077cbe --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java @@ -0,0 +1,112 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.dto.ArticleDto; +import fic.writer.domain.entity.dto.BookDto; +import fic.writer.domain.repository.BookRepository; +import fic.writer.domain.service.BookService; +import fic.writer.domain.service.helper.BookStringConstructor; +import fic.writer.domain.service.helper.BookTXTStringConstructor; +import fic.writer.domain.service.helper.flusher.ArticleFlusher; +import fic.writer.domain.service.helper.flusher.BookFlusher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.EntityListeners; +import javax.persistence.EntityNotFoundException; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Service +@EntityListeners(AuditingEntityListener.class) +@Transactional +public class BookServiceImpl implements BookService { + @Autowired + private BookRepository bookRepository; + + @Override + public List findAll() { + return bookRepository.findAll(); + } + + @Override + public Page findPage(Pageable pageable) { + return bookRepository.findAll(pageable); + } + + @Override + public Optional findById(Long id) { + return bookRepository.findById(id); + } + + @Override + public Book create(BookDto bookDto) { + Book book = BookFlusher.convertBookDtoToBook(bookDto); + Book savedBook = bookRepository.save(book); + return savedBook; + } + + @Override + public Book save(Book book) { + return bookRepository.save(book); + } + + @Override + public Book update(Long id, BookDto bookDto) { + Book book = bookRepository.getOne(id); + BookFlusher.flushBookDtoToBook(book, bookDto); + return bookRepository.save(book); + } + + @Override + public void delete(Book book) { + bookRepository.delete(book); + } + + @Override + public void deleteById(Long id) { + bookRepository.deleteById(id); + } + + + @Override + public Book addArticle(Long bookId, ArticleDto articleDto) { + Book book = bookRepository.getOne(bookId); + Article article = Article.builder().build(); + ArticleFlusher.flushArticleDtoToArticle(article, articleDto); + article.setBook(book); + Set
articles = new HashSet<>(book.getArticles()); + articles.add(article); + book.setArticles(articles); + bookRepository.save(book); + return book; + } + + @Override + public Book removeArticle(Long bookId, Long articleId) { + Book book = bookRepository.getOne(bookId); + book.getArticles().removeIf(article -> article.getId().equals(articleId)); + bookRepository.save(book); + return book; + } + + @Override + public byte[] getBookAsByteArray(Long bookId) { + Book book = bookRepository.findById(bookId).orElseThrow(EntityNotFoundException::new); + return convertBookToByteArray(book); + } + + @Override + public byte[] convertBookToByteArray(Book book) { + BookStringConstructor bookStringConstructor = new BookTXTStringConstructor(); + String bookAsString = bookStringConstructor.convertBookToText(book); + return bookAsString.getBytes(); + } +} diff --git a/src/main/java/fic/writer/domain/service/impl/FileServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/FileServiceImpl.java new file mode 100644 index 0000000..e2cf73e --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/FileServiceImpl.java @@ -0,0 +1,33 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.Book; +import fic.writer.domain.service.FileService; +import fic.writer.domain.service.files.ArticleParser; +import fic.writer.domain.service.files.BookParser; +import fic.writer.domain.service.files.TextParser; +import fic.writer.domain.service.helper.FileParserFactory; +import fic.writer.domain.service.helper.FileParserFactoryImpl; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class FileServiceImpl implements FileService { + FileParserFactory fileParserFactory = new FileParserFactoryImpl(); + + @Override + public String parseText(MultipartFile file) { + TextParser parser = fileParserFactory.getTextParser(file); + return parser.parseFile(file); + } + + public Article parseArticle(MultipartFile file) { + ArticleParser parser = fileParserFactory.getArticleParser(file); + return parser.parse(); + } + + public Book parseBook(MultipartFile file) { + BookParser parser = fileParserFactory.getBookParser(file); + return parser.parse(); + } +} diff --git a/src/main/java/fic/writer/domain/service/impl/GenreServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/GenreServiceImpl.java new file mode 100644 index 0000000..9ff80dd --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/GenreServiceImpl.java @@ -0,0 +1,52 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.Genre; +import fic.writer.domain.repository.GenreRepository; +import fic.writer.domain.service.GenreService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class GenreServiceImpl implements GenreService { + private GenreRepository genreRepository; + + @Autowired + public GenreServiceImpl(GenreRepository genreRepository) { + this.genreRepository = genreRepository; + } + + @Override + public List findAll() { + return genreRepository.findAll(); + } + + @Override + public Page findPage(Pageable pageable) { + return genreRepository.findAll(pageable); + } + + @Override + public Optional findById(Long id) { + return genreRepository.findById(id); + } + + @Override + public Genre save(Genre genre) { + return genreRepository.save(genre); + } + + @Override + public void delete(Genre genre) { + genreRepository.delete(genre); + } + + @Override + public void deleteById(Long id) { + genreRepository.deleteById(id); + } +} \ No newline at end of file diff --git a/src/main/java/fic/writer/domain/service/impl/OauthProfileDetailsServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/OauthProfileDetailsServiceImpl.java new file mode 100644 index 0000000..6ebcf76 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/OauthProfileDetailsServiceImpl.java @@ -0,0 +1,48 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.auth.OauthProfileDetails; +import fic.writer.domain.repository.OauthProfileDetailsRepository; +import fic.writer.domain.service.OauthProfileDetailsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class OauthProfileDetailsServiceImpl implements OauthProfileDetailsService { + @Autowired + private OauthProfileDetailsRepository oauthProfileDetailsRepository; + + @Override + public List findAll() { + return oauthProfileDetailsRepository.findAll(); + } + + @Override + public Page findPage(Pageable pageable) { + return null; + } + + @Override + public Optional findById(Long id) { + return oauthProfileDetailsRepository.findById(id); + } + + @Override + public OauthProfileDetails save(OauthProfileDetails oauthProfileDetails) { + return oauthProfileDetailsRepository.save(oauthProfileDetails); + } + + @Override + public void delete(OauthProfileDetails oauthProfileDetails) { + oauthProfileDetailsRepository.delete(oauthProfileDetails); + } + + @Override + public void deleteById(Long id) { + oauthProfileDetailsRepository.deleteById(id); + } +} diff --git a/src/main/java/fic/writer/domain/service/impl/ProfileServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/ProfileServiceImpl.java new file mode 100644 index 0000000..f0ffba2 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/ProfileServiceImpl.java @@ -0,0 +1,104 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.dto.ProfileDto; +import fic.writer.domain.repository.ProfileRepository; +import fic.writer.domain.service.ProfileService; +import fic.writer.domain.service.helper.flusher.ProfileFlusher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.EntityNotFoundException; +import java.util.List; +import java.util.Optional; + +@Service +@Transactional +public class ProfileServiceImpl implements ProfileService { + @Autowired + private ProfileRepository profileRepository; + + @Override + public List findAll() { + return profileRepository.findAll(); + } + + @Override + public Page findPage(Pageable pageable) { + return profileRepository.findAll(pageable); + } + + @Override + public Optional findById(Long id) { + return profileRepository.findById(id); + } + + @Override + public Optional findByUsername(String username) { + return profileRepository.findByUsername(username); + } + + @Override + public Optional findByEmail(String email) { + return profileRepository.findByEmail(email); + } + + @Override + public Profile create(ProfileDto profileDto) { + Profile profile = ProfileFlusher.convertProfileDtoToProfile(profileDto); + return profileRepository.save(profile); + } + + @Override + public Profile update(Long userId, ProfileDto profileDto) { + Profile profile = profileRepository.findById(userId).orElseThrow(EntityNotFoundException::new); + ProfileFlusher.flushProfileDtoToProfile(profile, profileDto); + profileRepository.save(profile); + return profile; + } + + @Override + public Profile save(Profile profile) { + return profileRepository.save(profile); + } + + @Override + public void delete(Profile profile) { + profileRepository.delete(profile); + } + + @Override + public void deleteById(Long id) { + profileRepository.deleteById(id); + } + + @Override + public Profile addBookAsAuthor(Long userId, Long bookId) { + Profile profile = profileRepository.findById(userId).orElseThrow(EntityNotFoundException::new); + Book book = Book.builder().id(bookId).build(); + profile.getBooksAsAuthor().add(book); + return profile; + } + + @Override + public Optional findByUsernameOrEmail(String usernameOrEmail) { + Optional profile; + if (isEmail(usernameOrEmail)) { + profile = findByEmail(usernameOrEmail); + } else { + profile = findByUsername(usernameOrEmail); + } + return profile; + } + + private boolean isEmail(String source) { + final String emailRegex = "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"; + return source.matches(emailRegex); + } + + +} diff --git a/src/main/java/fic/writer/domain/service/impl/WriterServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/WriterServiceImpl.java new file mode 100644 index 0000000..d8387b1 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/WriterServiceImpl.java @@ -0,0 +1,52 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.audit.SpringSecurityAuditorAware; +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.dto.BookDto; +import fic.writer.domain.service.BookService; +import fic.writer.domain.service.ProfileService; +import fic.writer.domain.service.WriterService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class WriterServiceImpl implements WriterService { + private BookService bookService; + private ProfileService profileService; + private SpringSecurityAuditorAware auditorAware; + + @Autowired + public WriterServiceImpl(BookService bookService, ProfileService profileService, SpringSecurityAuditorAware auditorAware) { + this.bookService = bookService; + this.profileService = profileService; + this.auditorAware = auditorAware; + } + + + @Override + public Book saveBook(BookDto bookDto) { + Book book = bookService.create(bookDto); + Optional currentProfile = auditorAware.getCurrentAuditor(); + currentProfile.ifPresent(profile -> { + Profile updatedProfile = profileService.addBookAsAuthor(profile.getId(), book.getId()); + profileService.save(updatedProfile); + }); + + return book; + } + + @Override + public Book saveBook(Book book) { + Book savedBook = bookService.save(book); + Optional currentProfile = auditorAware.getCurrentAuditor(); + currentProfile.ifPresent(profile -> { + Profile updatedProfile = profileService.addBookAsAuthor(profile.getId(), savedBook.getId()); + profileService.save(updatedProfile); + }); + + return savedBook; + } +} diff --git a/src/main/java/fic/writer/domain/utils/CookieUtils.java b/src/main/java/fic/writer/domain/utils/CookieUtils.java new file mode 100644 index 0000000..0805942 --- /dev/null +++ b/src/main/java/fic/writer/domain/utils/CookieUtils.java @@ -0,0 +1,60 @@ +package fic.writer.domain.utils; + +import org.springframework.util.SerializationUtils; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Base64; +import java.util.Optional; + +public class CookieUtils { + + public static Optional getCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + + if (cookies != null && cookies.length > 0) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + return Optional.of(cookie); + } + } + } + + return Optional.empty(); + } + + public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } + + public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies != null && cookies.length > 0) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + cookie.setValue(""); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + } + } + } + + public static String serialize(Object object) { + return Base64.getUrlEncoder() + .encodeToString(SerializationUtils.serialize(object)); + } + + public static T deserialize(Cookie cookie, Class cls) { + return cls.cast(SerializationUtils.deserialize( + Base64.getUrlDecoder().decode(cookie.getValue()))); + } + + +} diff --git a/src/main/java/fic/writer/domain/utils/MapConstructor.java b/src/main/java/fic/writer/domain/utils/MapConstructor.java new file mode 100644 index 0000000..18b7b6a --- /dev/null +++ b/src/main/java/fic/writer/domain/utils/MapConstructor.java @@ -0,0 +1,21 @@ +package fic.writer.domain.utils; + +import java.util.HashMap; +import java.util.Map; + +public class MapConstructor { + private Map map = new HashMap<>(); + + public static MapConstructor getNew() { + return new MapConstructor<>(); + } + + public MapConstructor put(K k, V v) { + map.put(k, v); + return this; + } + + public Map getMap() { + return map; + } +} diff --git a/src/main/java/fic/writer/exception/BadRequestException.java b/src/main/java/fic/writer/exception/BadRequestException.java new file mode 100644 index 0000000..4f5dd6d --- /dev/null +++ b/src/main/java/fic/writer/exception/BadRequestException.java @@ -0,0 +1,15 @@ +package fic.writer.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { + super(message); + } + + public BadRequestException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/fic/writer/exception/DoesNotHavePermissionException.java b/src/main/java/fic/writer/exception/DoesNotHavePermissionException.java new file mode 100644 index 0000000..0e5fd0f --- /dev/null +++ b/src/main/java/fic/writer/exception/DoesNotHavePermissionException.java @@ -0,0 +1,6 @@ +package fic.writer.exception; + +public class DoesNotHavePermissionException extends RuntimeException { + public DoesNotHavePermissionException() { + } +} \ No newline at end of file diff --git a/src/main/java/fic/writer/exception/JwtTokenNotFoundException.java b/src/main/java/fic/writer/exception/JwtTokenNotFoundException.java new file mode 100644 index 0000000..76a324a --- /dev/null +++ b/src/main/java/fic/writer/exception/JwtTokenNotFoundException.java @@ -0,0 +1,7 @@ +package fic.writer.exception; + +public class JwtTokenNotFoundException extends RuntimeException { + public JwtTokenNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/fic/writer/exception/OAuth2AuthenticationProcessingException.java b/src/main/java/fic/writer/exception/OAuth2AuthenticationProcessingException.java new file mode 100644 index 0000000..ab5d690 --- /dev/null +++ b/src/main/java/fic/writer/exception/OAuth2AuthenticationProcessingException.java @@ -0,0 +1,13 @@ +package fic.writer.exception; + +import org.springframework.security.core.AuthenticationException; + +public class OAuth2AuthenticationProcessingException extends AuthenticationException { + public OAuth2AuthenticationProcessingException(String msg, Throwable t) { + super(msg, t); + } + + public OAuth2AuthenticationProcessingException(String msg) { + super(msg); + } +} diff --git a/src/main/java/fic/writer/exception/ResourceNotFoundException.java b/src/main/java/fic/writer/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..ad1a494 --- /dev/null +++ b/src/main/java/fic/writer/exception/ResourceNotFoundException.java @@ -0,0 +1,30 @@ +package fic.writer.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class ResourceNotFoundException extends RuntimeException { + private String resourceName; + private String fieldName; + private Object fieldValue; + + public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) { + super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue)); + this.resourceName = resourceName; + this.fieldName = fieldName; + this.fieldValue = fieldValue; + } + + public String getResourceName() { + return resourceName; + } + + public String getFieldName() { + return fieldName; + } + + public Object getFieldValue() { + return fieldValue; + } +} diff --git a/src/main/java/fic/writer/web/config/audit/PersistenceConfig.java b/src/main/java/fic/writer/web/config/audit/PersistenceConfig.java new file mode 100644 index 0000000..e4f3abd --- /dev/null +++ b/src/main/java/fic/writer/web/config/audit/PersistenceConfig.java @@ -0,0 +1,18 @@ +package fic.writer.web.config.audit; + +import fic.writer.domain.audit.SpringSecurityAuditorAware; +import fic.writer.domain.entity.Profile; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing(auditorAwareRef = "auditorProvider") +public class PersistenceConfig { + + @Bean + public AuditorAware auditorProvider() { + return new SpringSecurityAuditorAware(); + } +} diff --git a/src/main/java/fic/writer/web/config/database/init/UserLoader.java b/src/main/java/fic/writer/web/config/database/init/UserLoader.java new file mode 100644 index 0000000..6f2778d --- /dev/null +++ b/src/main/java/fic/writer/web/config/database/init/UserLoader.java @@ -0,0 +1,42 @@ +package fic.writer.web.config.database.init; + +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.auth.AuthProvider; +import fic.writer.domain.entity.auth.OauthProfileDetails; +import fic.writer.domain.repository.OauthProfileDetailsRepository; +import fic.writer.domain.repository.ProfileRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class UserLoader implements ApplicationRunner { + @Autowired + private ProfileRepository profileRepository; + @Autowired + private OauthProfileDetailsRepository detailsRepository; + @Autowired + private PasswordEncoder passwordEncoder; + + @Override + public void run(ApplicationArguments args) throws Exception { + Profile profile = Profile.builder() + .id(1L) + .information("first profile information") + .username("firstUser") + .email("firstUser@mail.com") + .build(); + profileRepository.save(profile); + OauthProfileDetails ownProfileDetails = OauthProfileDetails.builder() + .id(1L) + .password(passwordEncoder.encode("qwerty")) + .profile(profile) + .provider(AuthProvider.LOCAL) + .build(); + detailsRepository.save(ownProfileDetails); + + + } +} diff --git a/src/main/java/fic/writer/web/config/properties/AppProperties.java b/src/main/java/fic/writer/web/config/properties/AppProperties.java new file mode 100644 index 0000000..adafd5c --- /dev/null +++ b/src/main/java/fic/writer/web/config/properties/AppProperties.java @@ -0,0 +1,12 @@ +package fic.writer.web.config.properties; + + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app") +@Getter +public class AppProperties { + private final Auth auth = new Auth(); + private final OAuth2 oauth2 = new OAuth2(); +} diff --git a/src/main/java/fic/writer/web/config/properties/Auth.java b/src/main/java/fic/writer/web/config/properties/Auth.java new file mode 100644 index 0000000..7e165bb --- /dev/null +++ b/src/main/java/fic/writer/web/config/properties/Auth.java @@ -0,0 +1,13 @@ +package fic.writer.web.config.properties; + + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Auth { + private String tokenSecret; + private long tokenExpirationMsec; + +} diff --git a/src/main/java/fic/writer/web/config/properties/OAuth2.java b/src/main/java/fic/writer/web/config/properties/OAuth2.java new file mode 100644 index 0000000..ea91f84 --- /dev/null +++ b/src/main/java/fic/writer/web/config/properties/OAuth2.java @@ -0,0 +1,13 @@ +package fic.writer.web.config.properties; + +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +public final class OAuth2 { + private List authorizedRedirectUris = new ArrayList<>(); +} diff --git a/src/main/java/fic/writer/web/config/security/CurrentUser.java b/src/main/java/fic/writer/web/config/security/CurrentUser.java new file mode 100644 index 0000000..23c27dd --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/CurrentUser.java @@ -0,0 +1,13 @@ +package fic.writer.web.config.security; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import java.lang.annotation.*; + +@Target({ElementType.PARAMETER, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@AuthenticationPrincipal +public @interface CurrentUser { + +} \ No newline at end of file diff --git a/src/main/java/fic/writer/web/config/security/RestAuthenticationEntryPoint.java b/src/main/java/fic/writer/web/config/security/RestAuthenticationEntryPoint.java new file mode 100644 index 0000000..0952b1e --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/RestAuthenticationEntryPoint.java @@ -0,0 +1,25 @@ +package fic.writer.web.config.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private static final Logger logger = LoggerFactory.getLogger(RestAuthenticationEntryPoint.class); + + @Override + public void commence(HttpServletRequest httpServletRequest, + HttpServletResponse httpServletResponse, + AuthenticationException e) throws IOException, ServletException { + logger.error("Responding with unauthorized error. Message - {}", e.getMessage()); + httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, + e.getLocalizedMessage()); + } +} diff --git a/src/main/java/fic/writer/web/config/security/SecurityConfig.java b/src/main/java/fic/writer/web/config/security/SecurityConfig.java new file mode 100644 index 0000000..83363db --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/SecurityConfig.java @@ -0,0 +1,118 @@ +package fic.writer.web.config.security; + +import fic.writer.web.config.security.oauth.CustomOAuth2UserService; +import fic.writer.web.config.security.oauth.HttpCookieOAuth2AuthorizationRequestRepository; +import fic.writer.web.config.security.oauth.OAuth2AuthenticationFailureHandler; +import fic.writer.web.config.security.oauth.OAuth2AuthenticationSuccessHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.BeanIds; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +public class SecurityConfig extends WebSecurityConfigurerAdapter { + + @Autowired + private UserDetailsService customUserDetailsService; + + @Autowired + private CustomOAuth2UserService customOAuth2UserService; + + @Autowired + private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; + + @Autowired + private OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler; + + @Autowired + private HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + + @Bean + public TokenAuthenticationFilter tokenAuthenticationFilter() { + return new TokenAuthenticationFilter(); + } + + @Bean + public HttpCookieOAuth2AuthorizationRequestRepository cookieAuthorizationRequestRepository() { + return new HttpCookieOAuth2AuthorizationRequestRepository(); + } + + @Override + public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception { + authenticationManagerBuilder + .userDetailsService(customUserDetailsService) + .passwordEncoder(passwordEncoder()); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + + @Bean(BeanIds.AUTHENTICATION_MANAGER) + @Override + public AuthenticationManager authenticationManagerBean() throws Exception { + return super.authenticationManagerBean(); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .cors() + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .csrf() + .disable() + .formLogin() + .disable() + .httpBasic() + .disable() + .exceptionHandling() + .authenticationEntryPoint(new RestAuthenticationEntryPoint()) + .and() + .authorizeRequests() + .antMatchers("/", + "/error", + "/favicon.ico", + "/**/*.png", + "/**/*.gif", + "/**/*.svg", + "/**/*.jpg", + "/**/*.html", + "/**/*.css", + "/**/*.js") + .permitAll() + .antMatchers("/auth/**", "/oauth2/**") + .permitAll() + .anyRequest() + .authenticated() + .and() + .oauth2Login() + .authorizationEndpoint() + .baseUri("/oauth2/authorize") + .authorizationRequestRepository(cookieAuthorizationRequestRepository()) + .and() + .redirectionEndpoint() + .baseUri("/oauth2/callback/*") + .and() + .userInfoEndpoint() + .userService(customOAuth2UserService) + .and() + .successHandler(oAuth2AuthenticationSuccessHandler) + .failureHandler(oAuth2AuthenticationFailureHandler); + + http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); + } +} diff --git a/src/main/java/fic/writer/web/config/security/TokenAuthenticationFilter.java b/src/main/java/fic/writer/web/config/security/TokenAuthenticationFilter.java new file mode 100644 index 0000000..1dca335 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/TokenAuthenticationFilter.java @@ -0,0 +1,65 @@ +package fic.writer.web.config.security; + +import fic.writer.web.config.security.authorization.UserDetailsServiceImpl; +import fic.writer.web.config.security.oauth.TokenProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private UserDetailsServiceImpl customUserDetailsService; + + private static final Logger logger = LoggerFactory.getLogger(TokenAuthenticationFilter.class); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + String jwt = getJwtFromRequest(request); + + if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { + Long userId = tokenProvider.getUserIdFromToken(jwt); + + UserDetails userDetails = customUserDetailsService.loadUserById(userId); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception ex) { + logger.error("Could not set user authentication in security context", ex); + } + + filterChain.doFilter(request, response); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + String tokenType = "Bearer "; + + if (StringUtils.hasText(bearerToken) && isTokenTypeEquals(tokenType, bearerToken)) { + return bearerToken.substring(tokenType.length()); + } + return null; + } + + private boolean isTokenTypeEquals(String tokenType, String requestHeader) { + return requestHeader.substring(0, tokenType.length()).equalsIgnoreCase(tokenType); + } +} diff --git a/src/main/java/fic/writer/web/config/security/WebMvcConfig.java b/src/main/java/fic/writer/web/config/security/WebMvcConfig.java new file mode 100644 index 0000000..c516dfe --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/WebMvcConfig.java @@ -0,0 +1,20 @@ +package fic.writer.web.config.security; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + private final long MAX_AGE_SECS = 3600; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(MAX_AGE_SECS); + } +} diff --git a/src/main/java/fic/writer/web/config/security/authorization/UserDetailsServiceImpl.java b/src/main/java/fic/writer/web/config/security/authorization/UserDetailsServiceImpl.java new file mode 100644 index 0000000..81229b1 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/authorization/UserDetailsServiceImpl.java @@ -0,0 +1,33 @@ +package fic.writer.web.config.security.authorization; + +import fic.writer.domain.entity.auth.OauthProfileDetails; +import fic.writer.domain.repository.OauthProfileDetailsRepository; +import fic.writer.exception.ResourceNotFoundException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + @Autowired + private OauthProfileDetailsRepository profileDetailsRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + OauthProfileDetails profileDetails = profileDetailsRepository + .findByProfileEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found with email : " + email)); + + return UserPrincipal.create(profileDetails); + } + + public UserDetails loadUserById(Long id) { + OauthProfileDetails profileDetails = profileDetailsRepository + .findById(id) + .orElseThrow(() -> new ResourceNotFoundException("profile", "id", id)); + + return UserPrincipal.create(profileDetails); + } +} \ No newline at end of file diff --git a/src/main/java/fic/writer/web/config/security/authorization/UserPrincipal.java b/src/main/java/fic/writer/web/config/security/authorization/UserPrincipal.java new file mode 100644 index 0000000..19e46de --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/authorization/UserPrincipal.java @@ -0,0 +1,99 @@ +package fic.writer.web.config.security.authorization; + +import fic.writer.domain.entity.auth.OauthProfileDetails; +import io.jsonwebtoken.lang.Assert; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Getter +@Setter +@Builder +public class UserPrincipal implements OAuth2User, UserDetails { + private Collection authorities; + private Map attributes; + private OauthProfileDetails profileDetails; + + + public static UserPrincipal create(OauthProfileDetails details) { + Assert.notNull(details, "ProfileDetails cannot be null"); + Assert.notNull(details.getProfile(), "Profile cannot be null"); + + List authorities = Collections. + singletonList(new SimpleGrantedAuthority("ROLE_USER")); + + return builder() + .profileDetails(details) + .authorities(authorities) + .build(); + } + + public static UserPrincipal create(OauthProfileDetails user, Map attributes) { + UserPrincipal userPrincipal = UserPrincipal.create(user); + userPrincipal.setAttributes(attributes); + return userPrincipal; + } + + public Long getId() { + return profileDetails.getProfile().getId(); + } + + @Override + public String getPassword() { + return profileDetails.getPassword(); + } + + //Use email instead username, because different oauth provider can produce different username + @Override + public String getUsername() { + return profileDetails.getProfile().getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getName() { + return String.valueOf(getId()); + } +} diff --git a/src/main/java/fic/writer/web/config/security/oauth/CustomOAuth2UserService.java b/src/main/java/fic/writer/web/config/security/oauth/CustomOAuth2UserService.java new file mode 100644 index 0000000..f7d1eb7 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/oauth/CustomOAuth2UserService.java @@ -0,0 +1,111 @@ +package fic.writer.web.config.security.oauth; + +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.auth.AuthProvider; +import fic.writer.domain.entity.auth.OauthProfileDetails; +import fic.writer.domain.entity.auth.profileInfo.OAuth2ProfileInfo; +import fic.writer.domain.entity.auth.profileInfo.OAuth2ProfileInfoFactory; +import fic.writer.domain.repository.OauthProfileDetailsRepository; +import fic.writer.domain.repository.ProfileRepository; +import fic.writer.exception.OAuth2AuthenticationProcessingException; +import fic.writer.web.config.security.authorization.UserPrincipal; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Optional; + +@Service +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private ProfileRepository profileRepository; + private OauthProfileDetailsRepository profileDetailsRepository; + + @Autowired + public CustomOAuth2UserService(ProfileRepository profileRepository, OauthProfileDetailsRepository profileDetailsRepository) { + this.profileRepository = profileRepository; + this.profileDetailsRepository = profileDetailsRepository; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + try { + return processOAuth2User(userRequest, oAuth2User); + } catch (AuthenticationException ex) { + throw ex; + } catch (Exception ex) { + throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause()); + } + } + + private OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) { + String authProviderName = oAuth2UserRequest.getClientRegistration().getRegistrationId(); + AuthProvider authProvider = AuthProvider.valueOf(authProviderName.toUpperCase()); + + OAuth2ProfileInfo userInfo = OAuth2ProfileInfoFactory.getOAuth2ProfileInfo(authProvider, oAuth2User.getAttributes()); + assertEmailExist(userInfo); + + return createOAuth2User(userInfo, authProvider); + } + + private void assertEmailExist(OAuth2ProfileInfo userInfo) { + if (StringUtils.isEmpty(userInfo.getEmail())) { + throw new OAuth2AuthenticationProcessingException("Email not found from OAuth2 provider"); + } + } + + private OAuth2User createOAuth2User(OAuth2ProfileInfo userInfo, AuthProvider authProvider) { + Optional userOptional = profileDetailsRepository.findByProfileEmail(userInfo.getEmail()); + OauthProfileDetails user = userOptional + .map(u -> checkEqualsProvider(u, authProvider)) + .map(x -> updateExistingUser(x, userInfo)) + .orElseGet(() -> registerNewProfile(authProvider, userInfo)); + return UserPrincipal.create(user, userInfo.getAttributes()); + } + + private OauthProfileDetails registerNewProfile(AuthProvider authProvider, OAuth2ProfileInfo oAuth2ProfileInfo) { + Profile user = Profile.builder() + .username(oAuth2ProfileInfo.getName()) + .email(oAuth2ProfileInfo.getEmail()) + .imageUrl(oAuth2ProfileInfo.getImageUrl()) + .build(); + OauthProfileDetails details = OauthProfileDetails.builder() + .profile(user) + .provider(authProvider) + .providerId(oAuth2ProfileInfo.getId()) + .build(); + + return profileDetailsRepository.save(details); + } + + private OauthProfileDetails updateExistingUser(OauthProfileDetails existingUser, OAuth2ProfileInfo oAuth2ProfileInfo) { + Profile profile = existingUser.getProfile(); + profile.setUsername(oAuth2ProfileInfo.getName()); + profile.setImageUrl(oAuth2ProfileInfo.getImageUrl()); + profileRepository.save(profile); + return profileDetailsRepository.save(existingUser); + } + + private OauthProfileDetails checkEqualsProvider(OauthProfileDetails user, AuthProvider authProvider) { + assertEqualsProvider(user, authProvider); + return user; + } + + private void assertEqualsProvider(OauthProfileDetails user, AuthProvider authProvider) { + if (!user.getProvider().equals(authProvider)) { + String exceptionMessage = "Looks like you're signed up with " + + user.getProvider() + " account. Please use your " + user.getProvider() + + " account to login."; + throw new OAuth2AuthenticationProcessingException(exceptionMessage); + } + } + + +} diff --git a/src/main/java/fic/writer/web/config/security/oauth/HttpCookieOAuth2AuthorizationRequestRepository.java b/src/main/java/fic/writer/web/config/security/oauth/HttpCookieOAuth2AuthorizationRequestRepository.java new file mode 100644 index 0000000..4471cfd --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/oauth/HttpCookieOAuth2AuthorizationRequestRepository.java @@ -0,0 +1,62 @@ +package fic.writer.web.config.security.oauth; + +import com.nimbusds.oauth2.sdk.util.StringUtils; +import fic.writer.domain.utils.CookieUtils; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +@Component +public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository { + public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"; + public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri"; + private static final int cookieExpireSeconds = 180; + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest httpServletRequest) { + return CookieUtils.getCookie(httpServletRequest, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME) + .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class)) + .orElse(null); + } + + @Override + public void saveAuthorizationRequest(OAuth2AuthorizationRequest oAuth2AuthorizationRequest, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) { + if (oAuth2AuthorizationRequest == null) { + cleanAuthCookie(httpServletRequest, httpServletResponse); + } else { + addAuthorizationRequestInCookie(oAuth2AuthorizationRequest, httpServletResponse); + addRedirectUrlInRequestIfExist(httpServletRequest, httpServletResponse); + } + } + + public void cleanAuthCookie(HttpServletRequest req, HttpServletResponse res) { + CookieUtils.deleteCookie(req, res, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + CookieUtils.deleteCookie(req, res, REDIRECT_URI_PARAM_COOKIE_NAME); + } + + private void addAuthorizationRequestInCookie(OAuth2AuthorizationRequest authorizationRequest, HttpServletResponse httpServletResponse) { + String serializedRequest = CookieUtils.serialize(authorizationRequest); + CookieUtils.addCookie(httpServletResponse, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, serializedRequest, cookieExpireSeconds); + } + + private void addRedirectUrlInRequestIfExist(HttpServletRequest request, HttpServletResponse response) { + String redirectUriAfterLogin = getRedirectUrlFromRequest(request); + if (StringUtils.isNotBlank(redirectUriAfterLogin)) { + CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds); + } + } + + private String getRedirectUrlFromRequest(HttpServletRequest request) { + return request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME); + + } + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest httpServletRequest) { + return this.loadAuthorizationRequest(httpServletRequest); + } + +} diff --git a/src/main/java/fic/writer/web/config/security/oauth/OAuth2AuthenticationFailureHandler.java b/src/main/java/fic/writer/web/config/security/oauth/OAuth2AuthenticationFailureHandler.java new file mode 100644 index 0000000..f32f420 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/oauth/OAuth2AuthenticationFailureHandler.java @@ -0,0 +1,38 @@ +package fic.writer.web.config.security.oauth; + +import fic.writer.domain.utils.CookieUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +import static fic.writer.web.config.security.oauth.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME; + +@Component +public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Autowired + HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + String targetUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME) + .map(Cookie::getValue) + .orElse(("/")); + + targetUrl = UriComponentsBuilder.fromUriString(targetUrl) + .queryParam("error", exception.getLocalizedMessage()) + .build().toUriString(); + + httpCookieOAuth2AuthorizationRequestRepository.cleanAuthCookie(request, response); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/src/main/java/fic/writer/web/config/security/oauth/OAuth2AuthenticationSuccessHandler.java b/src/main/java/fic/writer/web/config/security/oauth/OAuth2AuthenticationSuccessHandler.java new file mode 100644 index 0000000..7715ce8 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/oauth/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,83 @@ +package fic.writer.web.config.security.oauth; + +import fic.writer.domain.utils.CookieUtils; +import fic.writer.exception.BadRequestException; +import fic.writer.web.config.properties.AppProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URI; +import java.util.Optional; + +import static fic.writer.web.config.security.oauth.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME; + +@Component +public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private TokenProvider tokenProvider; + private AppProperties appProperties; + private HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + + @Autowired + public OAuth2AuthenticationSuccessHandler(TokenProvider tokenProvider, AppProperties appProperties, HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository) { + this.tokenProvider = tokenProvider; + this.appProperties = appProperties; + this.httpCookieOAuth2AuthorizationRequestRepository = httpCookieOAuth2AuthorizationRequestRepository; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + String targetUrl = determineTargetUrl(request, response, authentication); + + if (response.isCommitted()) { + logger.debug("Response has already been committed. Unable to redirect to " + targetUrl); + return; + } + + clearAuthenticationAttributes(request, response); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + + protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + Optional redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME) + .map(Cookie::getValue); + + if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) { + throw new BadRequestException("Sorry! We've got an Unauthorized Redirect URI and can't proceed with the authentication"); + } + + String targetUrl = redirectUri.orElse(getDefaultTargetUrl()); + + String token = tokenProvider.createToken(authentication); + + return UriComponentsBuilder.fromUriString(targetUrl) + .queryParam("token", token) + .build().toUriString(); + } + + protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) { + super.clearAuthenticationAttributes(request); + httpCookieOAuth2AuthorizationRequestRepository.cleanAuthCookie(request, response); + } + + private boolean isAuthorizedRedirectUri(String uri) { + URI clientRedirectUri = URI.create(uri); + + return appProperties.getOauth2().getAuthorizedRedirectUris() + .stream() + .anyMatch(authorizedRedirectUri -> { + // Only validate host and port. Let the clients use different paths if they want to + URI authorizedURI = URI.create(authorizedRedirectUri); + return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost()) + && authorizedURI.getPort() == clientRedirectUri.getPort(); + }); + } + +} diff --git a/src/main/java/fic/writer/web/config/security/oauth/TokenProvider.java b/src/main/java/fic/writer/web/config/security/oauth/TokenProvider.java new file mode 100644 index 0000000..a02d30f --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/oauth/TokenProvider.java @@ -0,0 +1,65 @@ +package fic.writer.web.config.security.oauth; + +import fic.writer.web.config.properties.AppProperties; +import fic.writer.web.config.security.authorization.UserPrincipal; +import io.jsonwebtoken.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import java.util.Date; + +@Service +public class TokenProvider { + + private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class); + + private AppProperties appProperties; + + public TokenProvider(AppProperties appProperties) { + this.appProperties = appProperties; + } + + public String createToken(Authentication authentication) { + UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); + + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + appProperties.getAuth().getTokenExpirationMsec()); + + return Jwts.builder() + .setSubject(Long.toString(userPrincipal.getId())) + .setIssuedAt(new Date()) + .setExpiration(expiryDate) + .signWith(SignatureAlgorithm.HS512, appProperties.getAuth().getTokenSecret()) + .compact(); + } + + public Long getUserIdFromToken(String token) { + Claims claims = Jwts.parser() + .setSigningKey(appProperties.getAuth().getTokenSecret()) + .parseClaimsJws(token) + .getBody(); + + return Long.parseLong(claims.getSubject()); + } + + public boolean validateToken(String authToken) { + try { + Jwts.parser().setSigningKey(appProperties.getAuth().getTokenSecret()).parseClaimsJws(authToken); + return true; + } catch (SignatureException ex) { + logger.error("Invalid JWT signature"); + } catch (MalformedJwtException ex) { + logger.error("Invalid JWT token"); + } catch (ExpiredJwtException ex) { + logger.error("Expired JWT token"); + } catch (UnsupportedJwtException ex) { + logger.error("Unsupported JWT token"); + } catch (IllegalArgumentException ex) { + logger.error("JWT claims string is empty."); + } + return false; + } + +} diff --git a/src/main/java/fic/writer/web/controller/ActorController.java b/src/main/java/fic/writer/web/controller/ActorController.java new file mode 100644 index 0000000..a7b6300 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/ActorController.java @@ -0,0 +1,59 @@ +package fic.writer.web.controller; + +import fic.writer.domain.entity.Actor; +import fic.writer.domain.entity.dto.ActorDto; +import fic.writer.domain.service.ActorService; +import fic.writer.web.controller.response.ActorResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import javax.persistence.EntityNotFoundException; +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/actors") +public class ActorController { + private static final String ID_TEMPLATE_PATH = "/{actorId}"; + private static final String ID_TEMPLATE = "actorId"; + + private ActorService actorService; + + @Autowired + public ActorController(ActorService actorService) { + this.actorService = actorService; + } + + @GetMapping + public List getAllActors() { + return actorService.findAll().stream() + .map(ActorResponse::new) + .collect(Collectors.toList()); + } + + @GetMapping(ID_TEMPLATE_PATH) + public ActorResponse getActorById(@PathVariable(ID_TEMPLATE) Long id) { + return actorService.findById(id) + .map(ActorResponse::new) + .orElseThrow(EntityNotFoundException::new); + } + + @PostMapping + public ActorResponse createActor(ActorDto actor) { + Actor savedActor = actorService.create(actor); + return new ActorResponse(savedActor); + } + + @PutMapping(ID_TEMPLATE_PATH) + public ActorResponse updateActor(Long id, ActorDto actor) { + Actor savedActor = actorService.update(id, actor); + return new ActorResponse(savedActor); + } + + @DeleteMapping(ID_TEMPLATE_PATH) + public HttpStatus deleteActor(Long id) { + actorService.deleteById(id); + return HttpStatus.NO_CONTENT; + } +} diff --git a/src/main/java/fic/writer/web/controller/ArticleController.java b/src/main/java/fic/writer/web/controller/ArticleController.java new file mode 100644 index 0000000..6f4523d --- /dev/null +++ b/src/main/java/fic/writer/web/controller/ArticleController.java @@ -0,0 +1,67 @@ +package fic.writer.web.controller; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.dto.ArticleDto; +import fic.writer.domain.service.ArticleService; +import fic.writer.domain.service.BookService; +import fic.writer.web.controller.response.ArticleResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +import javax.persistence.EntityNotFoundException; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping(value = "/api/books/{bookId}/articles", produces = MediaType.APPLICATION_JSON_VALUE) +public class ArticleController { + private static final String ID_TEMPLATE_PATH = "/{articleId}"; + private static final String ID_TEMPLATE = "articleId"; + private static final String BOOK_ID_TEMPLATE = "bookId"; + @Autowired + private ArticleService articleService; + @Autowired + private BookService bookService; + + @GetMapping + public List getAllArticles(@PathVariable(BOOK_ID_TEMPLATE) Long bookId) { + List list = bookService.findById(bookId).get() + .getArticles().stream() + .sorted(Comparator.comparingLong(Article::getId)) + .map(ArticleResponse::new) + .collect(Collectors.toList()); //articleService.findAllArticlesForBook(bookId).stream().map(ArticleResponse::new).collect(Collectors.toList()); + return list; + } + + @GetMapping(ID_TEMPLATE_PATH) + public ArticleResponse getOneArticle(@PathVariable(BOOK_ID_TEMPLATE) Long bookId, @PathVariable(ID_TEMPLATE) Long articleId) { + return bookService.findById(bookId).get().getArticles().stream(). + filter(article -> article.getId().equals(articleId)) + .map(ArticleResponse::new) + .findFirst() + .orElseThrow(EntityNotFoundException::new); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public void createArticle(@PathVariable(BOOK_ID_TEMPLATE) Long bookId, @RequestBody ArticleDto articleDto) { + bookService.addArticle(bookId, articleDto); + } + + @DeleteMapping(ID_TEMPLATE_PATH) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteArticle(@PathVariable(BOOK_ID_TEMPLATE) Long bookId, @PathVariable(ID_TEMPLATE) Long articleId) { + bookService.removeArticle(bookId, articleId); + } + + @PutMapping(ID_TEMPLATE_PATH) + @ResponseStatus(HttpStatus.OK) + public void updateArticle(@PathVariable(ID_TEMPLATE) Long articleId, @RequestBody ArticleDto articleDto) { + articleService.update(articleId, articleDto); + } + + +} \ No newline at end of file diff --git a/src/main/java/fic/writer/web/controller/AuthController.java b/src/main/java/fic/writer/web/controller/AuthController.java new file mode 100644 index 0000000..18e536f --- /dev/null +++ b/src/main/java/fic/writer/web/controller/AuthController.java @@ -0,0 +1,92 @@ +package fic.writer.web.controller; + +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.auth.AuthProvider; +import fic.writer.domain.entity.auth.OauthProfileDetails; +import fic.writer.domain.service.OauthProfileDetailsService; +import fic.writer.domain.service.ProfileService; +import fic.writer.domain.utils.MapConstructor; +import fic.writer.exception.BadRequestException; +import fic.writer.web.config.security.oauth.TokenProvider; +import fic.writer.web.controller.request.LoginRequest; +import fic.writer.web.controller.request.SignUpRequest; +import fic.writer.web.controller.response.TokenResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import javax.validation.Valid; +import java.net.URI; +import java.util.Map; + +@RestController +@RequestMapping("/auth") +public class AuthController { + @Autowired + private ProfileService profileService; + @Autowired + private OauthProfileDetailsService profileDetailsService; + + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired + private AuthenticationManager authenticationManager; + @Autowired + private TokenProvider tokenProvider; + + @PostMapping("/login") + public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest loginRequest) { + + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + loginRequest.getEmail(), + loginRequest.getPassword() + ) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + String token = tokenProvider.createToken(authentication); + return ResponseEntity.ok(new TokenResponse(token)); + } + + @PostMapping("/signup") + public ResponseEntity registerUser(@Valid @RequestBody SignUpRequest signUpRequest) { + if (profileService.findByEmail(signUpRequest.getEmail()).isPresent()) { + throw new BadRequestException("Email address already in use."); + } + + Profile user = Profile.builder() + .username(signUpRequest.getName()) + .email(signUpRequest.getEmail()) + .build(); + OauthProfileDetails details = OauthProfileDetails.builder() + .provider(AuthProvider.LOCAL) + .profile(user) + .password(passwordEncoder.encode(signUpRequest.getPassword())) + .build(); + + OauthProfileDetails result = profileDetailsService.save(details); + + URI location = ServletUriComponentsBuilder + .fromCurrentContextPath().path("/user/me") + .buildAndExpand(result.getProfile().getId()).toUri(); + + Map map = MapConstructor.getNew() + .put("success", true) + .put("message", "User registered successfully") + .getMap(); + return ResponseEntity.created(location) + .body(map); + } + +} diff --git a/src/main/java/fic/writer/web/controller/BookController.java b/src/main/java/fic/writer/web/controller/BookController.java new file mode 100644 index 0000000..9eee5fd --- /dev/null +++ b/src/main/java/fic/writer/web/controller/BookController.java @@ -0,0 +1,69 @@ +package fic.writer.web.controller; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.dto.BookDto; +import fic.writer.domain.service.BookService; +import fic.writer.domain.service.ProfileService; +import fic.writer.domain.service.WriterService; +import fic.writer.web.controller.response.BookResponse; +import fic.writer.web.controller.response.PageResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +import javax.persistence.EntityNotFoundException; + +@RestController +@RequestMapping(value = "/api/books", produces = MediaType.APPLICATION_JSON_VALUE) +public class BookController { + private static final String ID_TEMPLATE_PATH = "/{bookId}"; + private static final String ID_TEMPLATE = "bookId"; + + private BookService bookService; + private ProfileService profileService; + private WriterService writerService; + + @Autowired + public BookController(BookService bookService, ProfileService profileService, WriterService writerService) { + this.bookService = bookService; + this.profileService = profileService; + this.writerService = writerService; + } + + @GetMapping + public PageResponse getAllBooks(Pageable pageable) { + Page resourcePage = bookService.findPage(pageable).map(BookResponse::new); + return new PageResponse<>(resourcePage); + } + + @GetMapping(ID_TEMPLATE_PATH) + public BookResponse getBookById(@PathVariable(ID_TEMPLATE) Long id) { + return bookService.findById(id) + .map(BookResponse::new) + .orElseThrow(EntityNotFoundException::new); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public BookResponse createBook(@RequestBody BookDto book) { + Book savedBook = writerService.saveBook(book); + return new BookResponse(savedBook); + } + + @PutMapping(ID_TEMPLATE_PATH) + public BookResponse updateBook(@PathVariable(ID_TEMPLATE) Long id, @RequestBody BookDto book) { + Book savedBook = bookService.update(id, book); + return new BookResponse(savedBook); + } + + @DeleteMapping(ID_TEMPLATE_PATH) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteBook(@PathVariable(ID_TEMPLATE) Long id) { + bookService.deleteById(id); + } + + +} diff --git a/src/main/java/fic/writer/web/controller/FileController.java b/src/main/java/fic/writer/web/controller/FileController.java new file mode 100644 index 0000000..ba1d99a --- /dev/null +++ b/src/main/java/fic/writer/web/controller/FileController.java @@ -0,0 +1,74 @@ +package fic.writer.web.controller; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.enums.FileExtension; +import fic.writer.domain.service.BookService; +import fic.writer.domain.service.FileService; +import fic.writer.domain.service.WriterService; +import fic.writer.web.controller.response.BookResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.hateoas.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.persistence.EntityNotFoundException; +import java.io.IOException; + +@RestController +public class FileController { + private static final String BOOK_DOWNLOAD_TEMPLATE_PATH = "/api/books/{bookId}/download"; + private static final String FILE_CONTENT_PARSE_PATH = "/api/files/content"; + private static final String PARSE_FILE_AS_BOOK_PATH = "/api/files/books"; + + private static final String BOOK_ID_TEMPLATE = "bookId"; + private static final String FILE_NAME_PARAMETER = "file"; + + private FileService fileService; + private BookService bookService; + private WriterService writerService; + + @Autowired + public FileController(FileService fileService, BookService bookService, WriterService writerService) { + this.fileService = fileService; + this.bookService = bookService; + this.writerService = writerService; + } + + @PostMapping(FILE_CONTENT_PARSE_PATH) + @ResponseStatus(HttpStatus.CREATED) + public Resource takeArticleContentFromFile(@RequestParam(FILE_NAME_PARAMETER) MultipartFile file) { + return new Resource<>(fileService.parseText(file)); + } + + @PostMapping(PARSE_FILE_AS_BOOK_PATH) + @ResponseStatus(HttpStatus.CREATED) + public BookResponse parseBookFromFile(@RequestParam(FILE_NAME_PARAMETER) MultipartFile file) { + Book book = fileService.parseBook(file); + writerService.saveBook(book); + return new BookResponse(book); + } + + @GetMapping(BOOK_DOWNLOAD_TEMPLATE_PATH) + public ResponseEntity getBookAsByteArray(@PathVariable(BOOK_ID_TEMPLATE) Long id, FileExtension fileExtension) throws IOException { + Book book = bookService.findById(id).orElseThrow(EntityNotFoundException::new); + final String filenameHeader = getAttachmentHeader(book.getTitle(), fileExtension); + byte[] bookAsByteArray = bookService.convertBookToByteArray(book); + ByteArrayResource resource = new ByteArrayResource(bookAsByteArray); + MediaType mediaType = MediaType.TEXT_PLAIN; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, filenameHeader) + .contentType(mediaType) + .contentLength(bookAsByteArray.length) + .body(resource); + } + + private String getAttachmentHeader(String title, FileExtension fileExtension) { + String extensionNameInLowerCase = fileExtension.name().toLowerCase(); + return "attachment;filename=" + title + "." + extensionNameInLowerCase; + } +} diff --git a/src/main/java/fic/writer/web/controller/FileControllerAdvice.java b/src/main/java/fic/writer/web/controller/FileControllerAdvice.java new file mode 100644 index 0000000..9aa43aa --- /dev/null +++ b/src/main/java/fic/writer/web/controller/FileControllerAdvice.java @@ -0,0 +1,17 @@ +package fic.writer.web.controller; + +import org.springframework.hateoas.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class FileControllerAdvice { + @ExceptionHandler(EnumConstantNotPresentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Resource unexpectedExtension(EnumConstantNotPresentException e) { + return new Resource<>("unsupported extension: " + e.constantName()); + } + +} diff --git a/src/main/java/fic/writer/web/controller/ProfileController.java b/src/main/java/fic/writer/web/controller/ProfileController.java new file mode 100644 index 0000000..af19cff --- /dev/null +++ b/src/main/java/fic/writer/web/controller/ProfileController.java @@ -0,0 +1,72 @@ +package fic.writer.web.controller; + +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.dto.ProfileDto; +import fic.writer.domain.service.ProfileService; +import fic.writer.web.config.security.CurrentUser; +import fic.writer.web.config.security.authorization.UserPrincipal; +import fic.writer.web.controller.response.PageResponse; +import fic.writer.web.controller.response.ProfileResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import javax.persistence.EntityNotFoundException; + +@RestController +@RequestMapping("/api/users") +public class ProfileController { + private static final String ID_TEMPLATE_PATH = "/{userId}"; + private static final String ID_TEMPLATE = "userId"; + + private ProfileService profileService; + + @Autowired + public ProfileController(ProfileService profileService) { + this.profileService = profileService; + } + + @GetMapping + public PageResponse getAllUsers(Pageable pageable) { + Page userResponses = profileService.findPage(pageable).map(ProfileResponse::new); + return new PageResponse<>(userResponses); + } + + @GetMapping(ID_TEMPLATE_PATH) + public ProfileResponse getUserById(@PathVariable(ID_TEMPLATE) Long id) { + return profileService.findById(id) + .map(ProfileResponse::new) + .orElseThrow(EntityNotFoundException::new); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ProfileResponse createUser(@RequestBody ProfileDto user) { + Profile savedProfile = profileService.create(user); + return new ProfileResponse(savedProfile); + } + + @PutMapping(ID_TEMPLATE_PATH) + public ProfileResponse updateUser(@PathVariable(ID_TEMPLATE) Long id, @RequestBody ProfileDto profileDto) { + Profile savedProfile = profileService.update(id, profileDto); + return new ProfileResponse(savedProfile); + } + + @DeleteMapping(ID_TEMPLATE_PATH) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteUser(Long id) { + profileService.deleteById(id); + } + + @RequestMapping({"/user", "/me"}) + public ProfileResponse getLoggedProfile(@CurrentUser UserPrincipal userPrincipal) { + ProfileResponse profileResponse; + + profileResponse = profileService.findById(userPrincipal.getId()) + .map(ProfileResponse::new) + .orElseThrow(EntityNotFoundException::new); + return profileResponse; + } +} diff --git a/src/main/java/fic/writer/web/controller/request/LoginRequest.java b/src/main/java/fic/writer/web/controller/request/LoginRequest.java new file mode 100644 index 0000000..79f9ad7 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/request/LoginRequest.java @@ -0,0 +1,20 @@ +package fic.writer.web.controller.request; + +import lombok.*; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest { + @NotBlank + @Email + private String email; + + @NotBlank + private String password; +} diff --git a/src/main/java/fic/writer/web/controller/request/SignUpRequest.java b/src/main/java/fic/writer/web/controller/request/SignUpRequest.java new file mode 100644 index 0000000..e6b3745 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/request/SignUpRequest.java @@ -0,0 +1,23 @@ +package fic.writer.web.controller.request; + +import lombok.*; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SignUpRequest { + @NotBlank + private String name; + + @NotBlank + @Email + private String email; + + @NotBlank + private String password; +} diff --git a/src/main/java/fic/writer/web/controller/response/ActorResponse.java b/src/main/java/fic/writer/web/controller/response/ActorResponse.java new file mode 100644 index 0000000..948f741 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/response/ActorResponse.java @@ -0,0 +1,37 @@ +package fic.writer.web.controller.response; + +import fic.writer.domain.entity.Actor; +import fic.writer.web.controller.ActorController; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.hateoas.ResourceSupport; + +import java.util.Set; +import java.util.stream.Collectors; + +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo; +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.methodOn; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class ActorResponse extends ResourceSupport { + private Long actorId; + private String name; + private String description; + private Set books; + + public ActorResponse(Actor actor) { + actorId = actor.getId(); + name = actor.getName(); + description = actor.getDescription(); + books = actor.getBooks().stream().map(BookResponse::new).collect(Collectors.toSet()); + addSelfLink(actorId); + } + + + private void addSelfLink(Long id) { + add(linkTo(methodOn(ActorController.class, id).getActorById(id)).withSelfRel()); + } +} diff --git a/src/main/java/fic/writer/web/controller/response/ArticleResponse.java b/src/main/java/fic/writer/web/controller/response/ArticleResponse.java new file mode 100644 index 0000000..e6dc841 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/response/ArticleResponse.java @@ -0,0 +1,39 @@ +package fic.writer.web.controller.response; + +import fic.writer.domain.entity.Article; +import fic.writer.web.controller.BookController; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.hateoas.ResourceSupport; + +import java.util.Date; + +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo; +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.methodOn; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class ArticleResponse extends ResourceSupport { + private Long articleId; + private String title; + private Date created; + private String content; + private String annotation; + private Long pageCount; + + public ArticleResponse(Article article) { + articleId = article.getId(); + title = article.getTitle(); + created = article.getCreated(); + content = article.getContent(); + annotation = article.getAnnotation(); + pageCount = article.getPageCount(); + addSelfLink(articleId); + } + + private void addSelfLink(Long id) { + add(linkTo(methodOn(BookController.class, id).getBookById(id)).withSelfRel()); + } +} diff --git a/src/main/java/fic/writer/web/controller/response/BookResponse.java b/src/main/java/fic/writer/web/controller/response/BookResponse.java new file mode 100644 index 0000000..335672d --- /dev/null +++ b/src/main/java/fic/writer/web/controller/response/BookResponse.java @@ -0,0 +1,78 @@ +package fic.writer.web.controller.response; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.Genre; +import fic.writer.domain.entity.enums.FileExtension; +import fic.writer.domain.entity.enums.Size; +import fic.writer.domain.entity.enums.State; +import fic.writer.web.controller.ArticleController; +import fic.writer.web.controller.BookController; +import fic.writer.web.controller.FileController; +import fic.writer.web.controller.ProfileController; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.ResourceSupport; + +import java.io.IOException; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo; +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.methodOn; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class BookResponse extends ResourceSupport { + private Long bookId; + private String title; + private Link author; + private Set subAuthors; + private Set source; + private String description; + private Size size; + private State state; + private Set genres; + private Set actors; + private Link articles; + private Long pageCount; + + public BookResponse(Book book) { + this.bookId = book.getId(); + title = book.getTitle(); + pageCount = book.getPageCount(); + if (book.getAuthor() != null) { + Long authorId = book.getAuthor().getId(); + author = linkTo(methodOn(ProfileController.class, authorId).getUserById(authorId)).withRel("author"); + } + subAuthors = book.getCoauthors().stream().map(author -> + linkTo(methodOn(ProfileController.class, author.getId()).getUserById(author.getId())) + .withRel("subauthor")).collect(Collectors.toSet()); + source = book.getSource().stream().map(BookResponse::new).map(ResourceSupport::getId).collect(Collectors.toSet()); + description = book.getDescription(); + size = book.getSize(); + state = book.getState(); + genres = book.getGenres(); + actors = book.getActors().stream().map(ActorResponse::new).map(ResourceSupport::getId).collect(Collectors.toSet()); + articles = linkTo(methodOn(ArticleController.class, bookId).getAllArticles(bookId)).withRel("articles"); + addSelfLink(bookId); + addDownloadLink((bookId)); + } + + private void addSelfLink(Long id) { + add(linkTo(methodOn(BookController.class, id).getBookById(id)).withSelfRel()); + } + + private void addDownloadLink(Long id) { + + try { + add(linkTo(methodOn(FileController.class, id).getBookAsByteArray(id, FileExtension.TXT)).withRel("download")); + } catch (IOException e) { + e.printStackTrace(); + } + } + + +} diff --git a/src/main/java/fic/writer/web/controller/response/ConstraintViolationExceptionResponse.java b/src/main/java/fic/writer/web/controller/response/ConstraintViolationExceptionResponse.java new file mode 100644 index 0000000..45f3496 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/response/ConstraintViolationExceptionResponse.java @@ -0,0 +1,19 @@ +package fic.writer.web.controller.response; + +import lombok.Getter; +import org.springframework.hateoas.ResourceSupport; + +import javax.validation.ConstraintViolation; + +@Getter +public class ConstraintViolationExceptionResponse extends ResourceSupport { + private String field; + private Object value; + private String message; + + public ConstraintViolationExceptionResponse(ConstraintViolation constraintViolation) { + field = constraintViolation.getPropertyPath().toString(); + message = constraintViolation.getMessage(); + value = constraintViolation.getInvalidValue(); + } +} diff --git a/src/main/java/fic/writer/web/controller/response/PageResponse.java b/src/main/java/fic/writer/web/controller/response/PageResponse.java new file mode 100644 index 0000000..251cde2 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/response/PageResponse.java @@ -0,0 +1,52 @@ +package fic.writer.web.controller.response; + +import org.springframework.data.domain.Page; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.PagedResources; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +public class PageResponse extends PagedResources { + private Page page; + + public PageResponse(Page page) { + super(page.getContent(), new PageMetadata(page.getSize(), page.getNumber(), page.getTotalElements(), page.getTotalPages())); + this.page = page; + addSelfLink(); + addPageableLinks(); + } + + private void addPageableLinks() { + int firstPageNumber = 0; + int lastPageNumber = page.getTotalPages() <= 0 ? 0 : page.getTotalPages() - 1; + if (page.getTotalElements() != 0) { + addPageNumberLinkWithRel(firstPageNumber, Link.REL_FIRST); + addPageNumberLinkWithRel(lastPageNumber, Link.REL_LAST); + } + + if (page.hasNext()) { + int nextPageNumber = page.nextPageable().getPageNumber(); + addPageNumberLinkWithRel(nextPageNumber, Link.REL_NEXT); + } + + if (page.hasPrevious()) { + int previousPageNumber = page.previousPageable().getPageNumber(); + addPageNumberLinkWithRel(previousPageNumber, Link.REL_PREVIOUS); + } + } + + private void addSelfLink() { + String path = getCurrentRequestUriBuilder().build().toString(); + Link link = new Link(path, Link.REL_SELF); + add(link); + } + + private void addPageNumberLinkWithRel(Integer number, String rel) { + String path = getCurrentRequestUriBuilder().replaceQueryParam("page", number).build().toString(); + Link link = new Link(path, rel); + add(link); + } + + private ServletUriComponentsBuilder getCurrentRequestUriBuilder() { + return ServletUriComponentsBuilder.fromCurrentRequest(); + } +} diff --git a/src/main/java/fic/writer/web/controller/response/ProfileResponse.java b/src/main/java/fic/writer/web/controller/response/ProfileResponse.java new file mode 100644 index 0000000..5ad5149 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/response/ProfileResponse.java @@ -0,0 +1,50 @@ +package fic.writer.web.controller.response; + +import fic.writer.domain.entity.Profile; +import fic.writer.web.controller.ProfileController; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.hateoas.ResourceSupport; + +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo; +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.methodOn; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class ProfileResponse extends ResourceSupport { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userId; + private String username; + private String about; + private String information; + private String email; + private String imageUrl; + private Set booksAsSubAuthor; + private Set booksAsAuthor; + + + public ProfileResponse(Profile profile) { + this.userId = profile.getId(); + username = profile.getUsername(); + about = profile.getAbout(); + information = profile.getInformation(); + booksAsSubAuthor = profile.getBooksAsCoauthor().stream().map(BookResponse::new).collect(Collectors.toSet()); + booksAsAuthor = profile.getBooksAsAuthor().stream().map(BookResponse::new).collect(Collectors.toSet()); + email = profile.getEmail(); + imageUrl = profile.getImageUrl(); + addSelfLink(userId); + } + + private void addSelfLink(Long id) { + add(linkTo(methodOn(ProfileController.class, id).getUserById(id)).withSelfRel()); + } +} diff --git a/src/main/java/fic/writer/web/controller/response/TokenResponse.java b/src/main/java/fic/writer/web/controller/response/TokenResponse.java new file mode 100644 index 0000000..cce677d --- /dev/null +++ b/src/main/java/fic/writer/web/controller/response/TokenResponse.java @@ -0,0 +1,15 @@ +package fic.writer.web.controller.response; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class TokenResponse { + private String accessToken; + private String tokenType = "Bearer"; + + public TokenResponse(String accessToken) { + this.accessToken = accessToken; + } +} diff --git a/src/main/java/fic/writer/web/exception/handler/ValidationExceptionHandler.java b/src/main/java/fic/writer/web/exception/handler/ValidationExceptionHandler.java new file mode 100644 index 0000000..57eb694 --- /dev/null +++ b/src/main/java/fic/writer/web/exception/handler/ValidationExceptionHandler.java @@ -0,0 +1,24 @@ +package fic.writer.web.exception.handler; + +import fic.writer.exception.DoesNotHavePermissionException; +import fic.writer.web.controller.response.ConstraintViolationExceptionResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import javax.validation.ConstraintViolationException; + +@ControllerAdvice +public class ValidationExceptionHandler extends ResponseEntityExceptionHandler { + @ExceptionHandler(ConstraintViolationException.class) + protected ResponseEntity handleValidationException(ConstraintViolationException ex) { + return ResponseEntity.badRequest().body(ex.getConstraintViolations().stream().map(ConstraintViolationExceptionResponse::new)); + } + + @ExceptionHandler(DoesNotHavePermissionException.class) + protected ResponseEntity handlePerpissionException(DoesNotHavePermissionException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } +} diff --git a/src/main/resources/application-db-postgresql.yml b/src/main/resources/application-db-postgresql.yml new file mode 100644 index 0000000..1890bb1 --- /dev/null +++ b/src/main/resources/application-db-postgresql.yml @@ -0,0 +1,23 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/almanac + username: postgres + password: changeme + initialization-mode: always + platform: postgres + jpa: + show-sql: true + database: POSTGRESQL + generate-ddl: true + hibernate: + jdbc: + lob: + non_contextual_creation: true + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + hbm2ddl: + import_files_sql_extractor: org.hibernate.tool.hbm2ddl.MultipleLinesSqlCommandExtractor +# import_files: data/getLoggedProfile.sql, data/actor.sql, data/book.sql, data/article.sql,data/book_article.sql, data/actor_state.sql + diff --git a/src/main/resources/application-oauth2-config.yml b/src/main/resources/application-oauth2-config.yml new file mode 100644 index 0000000..89f8bb8 --- /dev/null +++ b/src/main/resources/application-oauth2-config.yml @@ -0,0 +1,33 @@ +spring: + security: + oauth2: + client: + registration: + google: + clientId: 1026826912350-7fmgasm5s5bn952j8bverf5skre0spt0.apps.googleusercontent.com + clientSecret: qku837SCPKgNM1M5nbNCmHbk + redirectUriTemplate: "{baseUrl}/oauth2/callback/{registrationId}" + scope: + - email + - profile + facebook: + clientId: 121189305185277 + clientSecret: 42ffe5aa7379e8326387e0fe16f34132 + redirectUriTemplate: "{baseUrl}/oauth2/callback/{registrationId}" + scope: + - email + - public_profile + github: + clientId: 98ec518608b7facf2a4b + clientSecret: a0f0d1c9bf39eeb3600baa6f34c5a4ef317e901e + redirectUriTemplate: "{baseUrl}/oauth2/callback/{registrationId}" + scope: + - user:email + - read:user +app: + auth: + tokenSecret: 926D96C90030DD58429D2751AC1BDBBC + tokenExpirationMsec: 864000000 + oauth2: + authorizedRedirectUris: + - http://localhost:3000/oauth2/redirect \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..d982622 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,16 @@ +spring: + main: + allow-bean-definition-overriding: true + profiles: + active: db-postgresql,oauth2-config + servlet: + multipart: + max-file-size: 5MB + max-request-size: 5MB + location: ${user.dir}/files/temp +logging: + level: + org: + springframework: DEBUG +server: + port: 8080 \ No newline at end of file diff --git a/src/main/resources/docker/docker-compose.yml b/src/main/resources/docker/docker-compose.yml new file mode 100644 index 0000000..892fa5a --- /dev/null +++ b/src/main/resources/docker/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3.5' + +services: + postgres: + container_name: postgres_container + image: postgres + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + PGDATA: /data/postgres + volumes: + - postgres:/data/postgres + ports: + - "5432:5432" + networks: + - postgres + restart: unless-stopped + + pgadmin: + container_name: pgadmin_container + image: dpage/pgadmin4 + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-pgadmin4@pgadmin.org} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} + volumes: + - pgadmin:/root/.pgadmin + ports: + - "8081:80" + networks: + - postgres + restart: unless-stopped + +networks: + postgres: + driver: bridge + +volumes: + postgres: + pgadmin: \ No newline at end of file diff --git a/src/test/java/fic/writer/ApplicationTest.java b/src/test/java/fic/writer/ApplicationTest.java new file mode 100644 index 0000000..e4ab0f0 --- /dev/null +++ b/src/test/java/fic/writer/ApplicationTest.java @@ -0,0 +1,12 @@ +package fic.writer; + +import org.junit.Test; + +public class ApplicationTest { + + @Test + public void applicationRunningWithoutFatalExceptions() { + String[] args = new String[]{}; + Application.main(args); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/aspect/DenyAccessToNotAuthorAspectTest.java b/src/test/java/fic/writer/domain/aspect/DenyAccessToNotAuthorAspectTest.java new file mode 100644 index 0000000..0cf35df --- /dev/null +++ b/src/test/java/fic/writer/domain/aspect/DenyAccessToNotAuthorAspectTest.java @@ -0,0 +1,190 @@ +package fic.writer.domain.aspect; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.auth.AuthProvider; +import fic.writer.domain.entity.auth.OauthProfileDetails; +import fic.writer.domain.repository.BookRepository; +import fic.writer.domain.service.BookService; +import fic.writer.web.config.security.authorization.UserDetailsServiceImpl; +import fic.writer.web.config.security.authorization.UserPrincipal; +import fic.writer.web.config.security.oauth.TokenProvider; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@WebAppConfiguration +public class DenyAccessToNotAuthorAspectTest { + private static final String BOOK_PATH = "/api/books"; + private static final String BOOK_ID_PATH_TEMPLATE = BOOK_PATH + "/{id}"; + private static final String ARTICLES_PATH_TEMPLATE = BOOK_ID_PATH_TEMPLATE + "/articles"; + private static final String ARTICLES_ID_PATH_TEMPLATE = BOOK_ID_PATH_TEMPLATE + "/articles/{articleId}"; + + + @Autowired + private MockMvc mockMvc; + @MockBean + private UserDetailsServiceImpl userDetailsService; + @MockBean + private BookRepository bookRepository; + @Autowired + private TokenProvider tokenProvider; + private String token; + @Autowired + private BookService bookService; + + @Before + public void setUp() throws Exception { + UserDetails userDetails = UserPrincipal.create(OauthProfileDetails.builder() + .provider(AuthProvider.LOCAL) + .profile(createProfileWithId(1L)) + .id(1L) + .build()); + Mockito.when(userDetailsService.loadUserById(anyLong())).thenReturn(userDetails); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + token = tokenProvider.createToken(authentication); + } + + @Test + public void beforeDeleteBook_whenDifferentProfiles_shouldReturnForbidden() throws Exception { + Long bookId = 1L; + Profile author = createProfileWithId(2L); + Book book = Book.builder().author(author).build(); + Mockito.when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + + mockMvc.perform(delete(BOOK_ID_PATH_TEMPLATE, bookId) + .header("authorization", "bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + public void beforeDeleteBook_whenProfileIsAuthor_shouldReturnNoContent() throws Exception { + Long bookId = 1L; + Profile author = createProfileWithId(1L); + Book book = Book.builder().author(author).build(); + Mockito.when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + + mockMvc.perform(delete(BOOK_ID_PATH_TEMPLATE, bookId) + .header("authorization", "bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + public void beforeDeleteBook_whenProfileIsCoauthor_shouldReturnNoContent() throws Exception { + Long bookId = 1L; + Profile author = createProfileWithId(2L); + Profile coauthor = createProfileWithId(1L); + Book book = Book.builder().author(author).coauthors(coauthor).build(); + Mockito.when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + + mockMvc.perform(delete(BOOK_ID_PATH_TEMPLATE, bookId) + .header("authorization", "bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + public void beforeAddArticle_whenDifferentProfiles_shouldReturnForbidden() throws Exception { + Long bookId = 1L; + Profile author = createProfileWithId(2L); + Book book = Book.builder().author(author).build(); + Mockito.when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + + mockMvc.perform(post(ARTICLES_PATH_TEMPLATE, bookId) + .header("authorization", "bearer " + token) + .content("{}") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + public void beforeAddArticle_whenProfileIsAuthor_shouldReturnCreated() throws Exception { + Long bookId = 1L; + Profile author = createProfileWithId(1L); + Book book = Book.builder().author(author).build(); + Mockito.when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + Mockito.when(bookRepository.getOne(bookId)).thenReturn(book); + + mockMvc.perform(post(ARTICLES_PATH_TEMPLATE, bookId) + .header("authorization", "bearer " + token) + .content("{}") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()); + } + + @Test + public void beforeAddArticle_whenProfileIsCoauthor_shouldReturnCreated() throws Exception { + Long bookId = 1L; + Profile author = createProfileWithId(2L); + Profile coauthor = createProfileWithId(1L); + Book book = Book.builder().author(author).coauthors(coauthor).build(); + Mockito.when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + Mockito.when(bookRepository.getOne(bookId)).thenReturn(book); + + mockMvc.perform(post(ARTICLES_PATH_TEMPLATE, bookId) + .header("authorization", "bearer " + token) + .content("{}") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()); + } + + @Test + public void beforeRemoveArticle_whenDifferentProfiles_shouldReturnForbidden() throws Exception { + Long bookId = 1L; + Long articleId = 1L; + Profile author = createProfileWithId(2L); + Book book = Book.builder().author(author).build(); + Mockito.when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + Mockito.when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + + mockMvc.perform(delete(ARTICLES_ID_PATH_TEMPLATE, bookId, articleId) + .header("authorization", "bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + public void beforeRemoveArticle_whenProfileIsAuthor_shouldReturnNoContent() throws Exception { + Long bookId = 1L; + Long articleId = 1L; + Profile author = createProfileWithId(2L); + Profile coauthor = createProfileWithId(1L); + Book book = Book.builder().author(author).coauthors(coauthor).build(); + Mockito.when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + Mockito.when(bookRepository.getOne(bookId)).thenReturn(book); + + mockMvc.perform(delete(ARTICLES_ID_PATH_TEMPLATE, bookId, articleId) + .header("authorization", "bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + private Profile createProfileWithId(Long id) { + String username = "username"; + String email = "email@mail.com"; + return Profile.builder().username(username).email(email).id(id).build(); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/audit/SpringSecurityAuditorAwareTest.java b/src/test/java/fic/writer/domain/audit/SpringSecurityAuditorAwareTest.java new file mode 100644 index 0000000..64a2c65 --- /dev/null +++ b/src/test/java/fic/writer/domain/audit/SpringSecurityAuditorAwareTest.java @@ -0,0 +1,37 @@ +package fic.writer.domain.audit; + +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.auth.OauthProfileDetails; +import fic.writer.web.config.security.authorization.UserPrincipal; +import org.junit.Test; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +import static org.junit.Assert.*; + +public class SpringSecurityAuditorAwareTest { + SpringSecurityAuditorAware auditorAware = new SpringSecurityAuditorAware(); + + @Test + public void getCurrentAuditor_whenProfileExistInSecurityContext_shouldReturnProfile() { + Profile profile = Profile.builder().build(); + OauthProfileDetails details = OauthProfileDetails.builder().profile(profile).build(); + UserPrincipal userPrincipal = UserPrincipal.create(details); + Authentication authentication = new TestingAuthenticationToken(userPrincipal, details.getPassword()); + SecurityContextHolder.getContext().setAuthentication(authentication); + + Optional currentProfile = auditorAware.getCurrentAuditor(); + assertTrue(currentProfile.isPresent()); + assertEquals(profile, currentProfile.get()); + } + + @Test + public void getCurrentAuditor_whenProfileNotExistInSecurityContext_shouldReturnEmptyOptional() { + SecurityContextHolder.clearContext(); + Optional currentProfile = auditorAware.getCurrentAuditor(); + assertFalse(currentProfile.isPresent()); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/entity/ActorValidationTest.java b/src/test/java/fic/writer/domain/entity/ActorValidationTest.java new file mode 100644 index 0000000..17ba879 --- /dev/null +++ b/src/test/java/fic/writer/domain/entity/ActorValidationTest.java @@ -0,0 +1,47 @@ +package fic.writer.domain.entity; + +import org.junit.Before; +import org.junit.Test; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ActorValidationTest { + private Validator validator; + + @Before + public void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + public void ValidateActor_whenNameIsNull_shouldThrowException() { + Actor actorDto = Actor.builder().name(null).build(); + + Set> violations = validator.validate(actorDto); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateActor_whenNameIsEmpty_shouldThrowException() { + Actor actor = Actor.builder().name("").build(); + + Set> violations = validator.validate(actor); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateActor_whenNameIsValid_shouldReturnEmptyViolations() { + Actor actor = Actor.builder().name("name").build(); + + Set> violations = validator.validate(actor); + assertTrue(violations.isEmpty()); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/entity/ArticleValidationTest.java b/src/test/java/fic/writer/domain/entity/ArticleValidationTest.java new file mode 100644 index 0000000..3fb0ab7 --- /dev/null +++ b/src/test/java/fic/writer/domain/entity/ArticleValidationTest.java @@ -0,0 +1,81 @@ +package fic.writer.domain.entity; + +import org.junit.Before; +import org.junit.Test; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import java.util.Set; + +import static org.junit.Assert.*; + +public class ArticleValidationTest { + private Validator validator; + + @Before + public void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + public void validateArticle_whenTitleIsEmpty_shouldThrowException() { + Article article = Article.builder().title("").content("content").build(); + + Set> violations = validator.validate(article); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateArticle_whenContentIsNull_shouldThrowException() { + Article article = Article.builder().title("title").content(null).build(); + + Set> violations = validator.validate(article); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateArticle_whenContentIsEmpty_shouldThrowException() { + Article article = Article.builder().title("title").content("").build(); + + Set> violations = validator.validate(article); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateArticle_whenTitleIsNull_shouldThrowException() { + Article article = Article.builder().title(null).content("content").build(); + + Set> violations = validator.validate(article); + assertFalse(violations.isEmpty()); + } + + + @Test + public void validateArticle_whenTitleAndContentAreNull_shouldReturnTwoViolations() { + int expectedSize = 2; + Article article = Article.builder().title(null).content(null).build(); + + Set> violations = validator.validate(article); + assertEquals(expectedSize, violations.size()); + } + + @Test + public void validateArticle_whenTitleAndContentAreEmpty_shouldReturnTwoViolations() { + int expectedSize = 2; + Article article = Article.builder().title("").content("").build(); + + Set> violations = validator.validate(article); + assertEquals(expectedSize, violations.size()); + } + + @Test + public void validateArticle_whenValid_shouldReturnEmptyViolations() { + Article article = Article.builder().title("title").content("content").build(); + + Set> violations = validator.validate(article); + assertTrue(violations.isEmpty()); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/entity/BookValidationTest.java b/src/test/java/fic/writer/domain/entity/BookValidationTest.java new file mode 100644 index 0000000..6c025b5 --- /dev/null +++ b/src/test/java/fic/writer/domain/entity/BookValidationTest.java @@ -0,0 +1,95 @@ +package fic.writer.domain.entity; + +import org.junit.Before; +import org.junit.Test; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import java.util.Optional; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class BookValidationTest { + private Validator validator; + + @Before + public void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + public void validateBook_whenTitleIsEmpty_shouldThrowException() { + String propertyPath = "title"; + Book book = Book.builder().title("").build(); + + Set> violations = validator.validate(book); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateBook_whenTitleIsNull_shouldThrowException() { + String propertyPath = "title"; + Book book = Book.builder().title(null).build(); + + Set> violations = validator.validate(book); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateBook_whenTitleIsValid_shouldReturnEmptyViolation() { + String propertyPath = "title"; + Book book = Book.builder().title("title").build(); + + Set> violations = validator.validate(book); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertFalse(optional.isPresent()); + } + + @Test + public void validateBook_whenAuthorIsNull_shouldReturnException() { + String propertyPath = "author"; + Book book = Book.builder().author(null).build(); + + Set> violations = validator.validate(book); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateBook_whenAuthorExists_shouldReturnEmptyViolation() { + String propertyPath = "author"; + Profile profile = Profile.builder().build(); + Book book = Book.builder().author(profile).build(); + + Set> violations = validator.validate(book); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertFalse(optional.isPresent()); + } + + @Test + public void validateBook_whenAuthorAndTitleExist_shouldReturnEmptyViolation() { + String propertyPath = "author"; + Profile profile = Profile.builder().build(); + Book book = Book.builder().author(profile).title("title").build(); + + Set> violations = validator.validate(book); + assertTrue(violations.isEmpty()); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/entity/ProfileValidationTest.java b/src/test/java/fic/writer/domain/entity/ProfileValidationTest.java new file mode 100644 index 0000000..d4c4ede --- /dev/null +++ b/src/test/java/fic/writer/domain/entity/ProfileValidationTest.java @@ -0,0 +1,120 @@ +package fic.writer.domain.entity; + +import org.junit.Before; +import org.junit.Test; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import java.util.Optional; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ProfileValidationTest { + private Validator validator; + + @Before + public void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + public void validateProfile_whenUsernameIsEmpty_shouldThrowException() { + String propertyPath = "username"; + Profile profile = Profile.builder().username("").build(); + + Set> violations = validator.validate(profile); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenUsernameisNull_shouldThrowException() { + String propertyPath = "username"; + Profile profile = Profile.builder().username(null).build(); + + Set> violations = validator.validate(profile); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenUsernameLengthIsLessThanTwo_shouldThrowException() { + String propertyPath = "username"; + Profile profile = Profile.builder().username("a").build(); + + Set> violations = validator.validate(profile); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenUsernameIsValid_shouldNotFoundViolation() { + String propertyPath = "username"; + Profile profile = Profile.builder().username("username").build(); + + Set> violations = validator.validate(profile); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertFalse(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenEmailNotBlank_shouldThrowException() { + String propertyPath = "email"; + Profile profile = Profile.builder().email("email").build(); + + Set> violations = validator.validate(profile); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenEmailWithoutDot_shouldThrowException() { + String propertyPath = "email"; + Profile profile = Profile.builder().email("email@cc").build(); + + Set> violations = validator.validate(profile); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenEmailContainLessThanTwoAfterDot_shouldNotFoundViolation() { + String propertyPath = "email"; + Profile profile = Profile.builder().email("email@cc.c").build(); + + Set> violations = validator.validate(profile); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenEmailValid_shouldNotFoundViolation() { + String propertyPath = "email"; + Profile profile = Profile.builder().email("email@cc.com").build(); + + Set> violations = validator.validate(profile); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertFalse(optional.isPresent()); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/entity/dto/ActorDtoValidationTest.java b/src/test/java/fic/writer/domain/entity/dto/ActorDtoValidationTest.java new file mode 100644 index 0000000..f87b709 --- /dev/null +++ b/src/test/java/fic/writer/domain/entity/dto/ActorDtoValidationTest.java @@ -0,0 +1,47 @@ +package fic.writer.domain.entity.dto; + +import org.junit.Before; +import org.junit.Test; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ActorDtoValidationTest { + private Validator validator; + + @Before + public void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + public void validateActorDto_whenNameIsEmpty_shouldThrowException() { + ActorDto actorDto = ActorDto.builder().name("").build(); + + Set> violations = validator.validate(actorDto); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateActorDto_whenNameIsNull_shouldThrowException() { + ActorDto actorDto = ActorDto.builder().name(null).build(); + + Set> violations = validator.validate(actorDto); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateActorDto_whenNameIsValid_shouldReturnEmptyViolations() { + ActorDto actorDto = ActorDto.builder().name("name").build(); + + Set> violations = validator.validate(actorDto); + assertTrue(violations.isEmpty()); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/entity/dto/ArticleDtoValidationTest.java b/src/test/java/fic/writer/domain/entity/dto/ArticleDtoValidationTest.java new file mode 100644 index 0000000..8f1ea9e --- /dev/null +++ b/src/test/java/fic/writer/domain/entity/dto/ArticleDtoValidationTest.java @@ -0,0 +1,81 @@ +package fic.writer.domain.entity.dto; + +import org.junit.Before; +import org.junit.Test; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import java.util.Set; + +import static org.junit.Assert.*; + +public class ArticleDtoValidationTest { + private Validator validator; + + @Before + public void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + public void validateArticleDto_whenTitleIsEmpty_shouldThrowException() { + ArticleDto articleDto = ArticleDto.builder().title("").content("content").build(); + + Set> violations = validator.validate(articleDto); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateArticleDto_whenContentIsNull_shouldThrowException() { + ArticleDto articleDto = ArticleDto.builder().title("title").content(null).build(); + + Set> violations = validator.validate(articleDto); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateArticleDto_whenContentIsEmpty_shouldThrowException() { + ArticleDto articleDto = ArticleDto.builder().title("title").content("").build(); + + Set> violations = validator.validate(articleDto); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateArticleDto_whenTitleIsNull_shouldThrowException() { + ArticleDto articleDto = ArticleDto.builder().title(null).content("content").build(); + + Set> violations = validator.validate(articleDto); + assertFalse(violations.isEmpty()); + } + + + @Test + public void validateArticleDto_whenTitleAndContentAreNull_shouldReturnTwoViolations() { + int expectedSize = 2; + ArticleDto articleDto = ArticleDto.builder().title(null).content(null).build(); + + Set> violations = validator.validate(articleDto); + assertEquals(expectedSize, violations.size()); + } + + @Test + public void validateArticleDto_whenTitleAndContentAreEmpty_shouldReturnTwoViolations() { + int expectedSize = 2; + ArticleDto articleDto = ArticleDto.builder().title("").content("").build(); + + Set> violations = validator.validate(articleDto); + assertEquals(expectedSize, violations.size()); + } + + @Test + public void validateArticleDto_whenValid_shouldReturnEmptyViolations() { + ArticleDto articleDto = ArticleDto.builder().title("title").content("content").build(); + + Set> violations = validator.validate(articleDto); + assertTrue(violations.isEmpty()); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/entity/dto/BookDtoValidationTest.java b/src/test/java/fic/writer/domain/entity/dto/BookDtoValidationTest.java new file mode 100644 index 0000000..0f7a8dd --- /dev/null +++ b/src/test/java/fic/writer/domain/entity/dto/BookDtoValidationTest.java @@ -0,0 +1,48 @@ +package fic.writer.domain.entity.dto; + +import org.junit.Before; +import org.junit.Test; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class BookDtoValidationTest { + private Validator validator; + + @Before + public void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + public void validateBookDto_whenTitleIsEmpty_shouldThrowException() { + BookDto bookDto = BookDto.builder().title("").build(); + + Set> violations = validator.validate(bookDto); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateBookDto_whenTitleIsNull_shouldThrowException() { + BookDto bookDto = BookDto.builder().title(null).build(); + + Set> violations = validator.validate(bookDto); + assertFalse(violations.isEmpty()); + } + + @Test + public void validateBookDto_whenTitleIsValid_shouldReturnEmptyViolation() { + BookDto bookDto = BookDto.builder().title("title").build(); + + Set> violations = validator.validate(bookDto); + assertTrue(violations.isEmpty()); + } + +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/entity/dto/ProfileDtoValidationTest.java b/src/test/java/fic/writer/domain/entity/dto/ProfileDtoValidationTest.java new file mode 100644 index 0000000..17a91a6 --- /dev/null +++ b/src/test/java/fic/writer/domain/entity/dto/ProfileDtoValidationTest.java @@ -0,0 +1,120 @@ +package fic.writer.domain.entity.dto; + +import org.junit.Before; +import org.junit.Test; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import java.util.Optional; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ProfileDtoValidationTest { + private Validator validator; + + @Before + public void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + public void validateProfileDto_whenUsernameIsEmpty_shouldThrowException() { + String propertyPath = "username"; + ProfileDto profileDto = ProfileDto.builder().username("").build(); + + Set> violations = validator.validate(profileDto); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenUsernameisNull_shouldThrowException() { + String propertyPath = "username"; + ProfileDto profileDto = ProfileDto.builder().username(null).build(); + + Set> violations = validator.validate(profileDto); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenUsernameLengthIsLessThanTwo_shouldThrowException() { + String propertyPath = "username"; + ProfileDto profileDto = ProfileDto.builder().username("a").build(); + + Set> violations = validator.validate(profileDto); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenUsernameIsValid_shouldNotFoundViolation() { + String propertyPath = "username"; + ProfileDto profileDto = ProfileDto.builder().username("username").build(); + + Set> violations = validator.validate(profileDto); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertFalse(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenEmailNotBlank_shouldThrowException() { + String propertyPath = "email"; + ProfileDto profileDto = ProfileDto.builder().email("email").build(); + + Set> violations = validator.validate(profileDto); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenEmailWithoutDot_shouldThrowException() { + String propertyPath = "email"; + ProfileDto profileDto = ProfileDto.builder().email("email@cc").build(); + + Set> violations = validator.validate(profileDto); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenEmailContainLessThanTwoAfterDot_shouldNotFoundViolation() { + String propertyPath = "email"; + ProfileDto profileDto = ProfileDto.builder().email("email@cc.c").build(); + + Set> violations = validator.validate(profileDto); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertTrue(optional.isPresent()); + } + + @Test + public void validateProfileDto_whenEmailValid_shouldNotFoundViolation() { + String propertyPath = "email"; + ProfileDto profileDto = ProfileDto.builder().email("email@cc.com").build(); + + Set> violations = validator.validate(profileDto); + Optional> optional = violations.stream() + .filter(c -> c.getPropertyPath().toString().equalsIgnoreCase(propertyPath)) + .findFirst(); + assertFalse(optional.isPresent()); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/service/ActorServiceTest.java b/src/test/java/fic/writer/domain/service/ActorServiceTest.java new file mode 100644 index 0000000..0b87b3a --- /dev/null +++ b/src/test/java/fic/writer/domain/service/ActorServiceTest.java @@ -0,0 +1,59 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Actor; +import fic.writer.domain.entity.dto.ActorDto; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static org.junit.Assert.*; + +@RunWith(SpringRunner.class) +@SpringBootTest +@Transactional +public class ActorServiceTest { + @Autowired + private ActorService actorService; + + @Test + public void createActor_whenCorrect_shouldFindWithId() { + Actor actor = Actor.builder().name("name").build(); + actor = actorService.create(ActorDto.of(actor)); + + assertTrue(actorService.findById(actor.getId()).isPresent()); + } + + @Test + public void createActor_whenIdExists_shouldFindUpdate() { + final Long ACTOR_ID = 1L; + final String NEW_NAME = "new name"; + actorService.update(ACTOR_ID, ActorDto.builder().name(NEW_NAME).build()); + Optional updatedActor = actorService.findById(ACTOR_ID); + assertTrue(updatedActor.isPresent()); + assertEquals(NEW_NAME, updatedActor.get().getName()); + } + + @Test + public void deleteActor_whenExists_shouldNotFound() { + final Long AUTHOR_ID = 987L; + assertTrue(actorService.findById(AUTHOR_ID).isPresent()); + actorService.deleteById(AUTHOR_ID); + assertFalse(actorService.findById(AUTHOR_ID).isPresent()); + } + + @Test(expected = EmptyResultDataAccessException.class) + public void deleteActor_whenNotExists_shouldThrowException() { + final Long AUTHOR_ID = -1L; + + assertFalse(actorService.findById(AUTHOR_ID).isPresent()); + actorService.deleteById(AUTHOR_ID); + } + + +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/service/ArticleServiceTest.java b/src/test/java/fic/writer/domain/service/ArticleServiceTest.java new file mode 100644 index 0000000..7f17f45 --- /dev/null +++ b/src/test/java/fic/writer/domain/service/ArticleServiceTest.java @@ -0,0 +1,62 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.dto.ArticleDto; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.test.context.junit4.SpringRunner; + +import java.util.Date; + +import static org.junit.Assert.*; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class ArticleServiceTest { + @Autowired + private ArticleService articleService; + + @Test + public void updateArticle_whenUpdateTitle_shouldChangeTitle() { + final Long ARTICLE_ID = 3L; + final String NEW_TITLE = "new title"; + + ArticleDto articleDto = ArticleDto.builder() + .title(NEW_TITLE) + .build(); + articleService.update(ARTICLE_ID, articleDto); + Article article = articleService.findById(ARTICLE_ID).get(); + assertEquals(NEW_TITLE, article.getTitle()); + } + + @Test + public void updateArticle_whenUpdateTitle_shouldChangeUpdateDate() { + final Long ARTICLE_ID = 4L; + final String NEW_TITLE = "new title"; + Date prevUpdateDate = articleService.findById(ARTICLE_ID).get().getLastModify(); + + ArticleDto articleDto = ArticleDto.builder() + .title(NEW_TITLE) + .build(); + articleService.update(ARTICLE_ID, articleDto); + Article article = articleService.findById(ARTICLE_ID).get(); + assertNotEquals(prevUpdateDate, article.getLastModify()); + } + + @Test + public void deleteArticle_whenExist_shouldNotFoundById() { + final Long ARTICLE_ID = 333L; + articleService.deleteById(ARTICLE_ID); + assertFalse(articleService.findById(ARTICLE_ID).isPresent()); + } + + @Test(expected = EmptyResultDataAccessException.class) + public void deleteArticle_whenNotExist_shouldNotFoundById() { + final Long ARTICLE_ID = -1L; + articleService.deleteById(ARTICLE_ID); + } + +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java b/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java new file mode 100644 index 0000000..5773355 --- /dev/null +++ b/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java @@ -0,0 +1,55 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.dto.ArticleDto; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.Assert.*; + +@RunWith(SpringRunner.class) +@SpringBootTest +@Transactional +public class BookAndArticleServicesTest { + @Autowired + private ArticleService articleService; + @Autowired + private BookService bookService; + + + @Test + public void createArticle_shouldFindByGeneratedId() { + final Long BOOK_ID = 1L; + Article article = Article.builder().title("title").content("content").build(); + bookService.addArticle(BOOK_ID, ArticleDto.of(article)); + + Book book = bookService.findById(BOOK_ID).get(); + book.getArticles().forEach(art -> assertTrue(articleService.findById(art.getId()).isPresent())); + } + + @Test + public void createArticle_shouldGenerateCreatedDate() { + final Long BOOK_ID = 1L; + Article article = Article.builder().title("title").content("content").build(); + bookService.addArticle(BOOK_ID, ArticleDto.of(article)); + + Book book = bookService.findById(BOOK_ID).get(); + book.getArticles().forEach(art -> assertNotNull(art.getCreated())); + } + + @Test + public void removeArticle_shouldGenerateCreatedDate() { + final Long BOOK_ID = 334L; + Book book = bookService.findById(BOOK_ID).get(); + + Long articleId = book.getArticles().stream().findFirst().get().getId(); + book = bookService.removeArticle(BOOK_ID, articleId); + + book.getArticles().forEach(art -> assertNotEquals(articleId, art.getId())); + } +} diff --git a/src/test/java/fic/writer/domain/service/BookServiceTest.java b/src/test/java/fic/writer/domain/service/BookServiceTest.java new file mode 100644 index 0000000..80b8f46 --- /dev/null +++ b/src/test/java/fic/writer/domain/service/BookServiceTest.java @@ -0,0 +1,114 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.auth.OauthProfileDetails; +import fic.writer.domain.entity.dto.BookDto; +import fic.writer.web.config.security.authorization.UserPrincipal; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.annotation.Transactional; + +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; + +import static org.junit.Assert.*; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class BookServiceTest { + @Autowired + private BookService bookService; + @Autowired + private ProfileService profileService; + + @Test + public void createBook_shouldChangeCount() { + final long profileId = 1L; + final int SIZE_BEFORE = bookService.findAll().size(); + Book emptyBook = Book.builder().title("title").build(); + setUserInSecurityContext(profileId); + bookService.create(BookDto.of(emptyBook)); + + assertNotEquals(SIZE_BEFORE, bookService.findAll().size()); + } + + @Test + public void createBook_whenTitleIsCyrillic_shouldFindByGeneratedId() { + final String TITLE = "Заголовок"; + final long CURRENT_COUNT = 1L; + final long authorId = 1L; + Book emptyBook = Book.builder().title(TITLE).build(); + setUserInSecurityContext(authorId); + bookService.create(BookDto.of(emptyBook)); + + assertEquals(CURRENT_COUNT, bookService.findAll().stream().filter(book -> book.getTitle().equals(TITLE)).count()); + } + + private void setUserInSecurityContext(Long profileId) { + Profile profile = profileService.findById(profileId).get(); + this.setUserInSecurityContext(profile); + } + + private void setUserInSecurityContext(Profile profile) { + final String PASSWORD = "qwerty"; + OauthProfileDetails details = OauthProfileDetails.builder().profile(profile).build(); + UserPrincipal profileDetails = UserPrincipal.create(details); + TestingAuthenticationToken token = new TestingAuthenticationToken(profileDetails, null); + SecurityContextHolder.getContext().setAuthentication(token); + } + + @Test + public void findBook_whenContainSizeEnum_shouldConvertToNotNullSize() { + final Long BOOK_ID = 3L; + Book book = bookService.findById(BOOK_ID).get(); + + assertNotNull(book.getSize()); + } + + @Test + public void findBook_whenContainStateEnum_shouldConvertToNotNullState() { + final Long BOOK_ID = 4L; + Book book = bookService.findById(BOOK_ID).get(); + + assertNotNull(book.getState()); + } + + @Test + public void deletedBook_whenExist_shouldNotFound() { + final Long DELETE_BOOK_ID = 333L; + bookService.deleteById(DELETE_BOOK_ID); + + assertNotNull(bookService.findById(DELETE_BOOK_ID)); + } + + @Test(expected = NoSuchElementException.class) + public void deletedBook_whenNotExist_shouldThorowException() { + final Long DELETE_BOOK_ID = -1L; + bookService.deleteById(DELETE_BOOK_ID); + } + + @Test + @Transactional + public void findAllArticlesForBook_whenBookExistAndContainsArticles_shouldFindSomeArticles() { + final Long BOOK_ID = 33L; + Set
articles = bookService.findById(BOOK_ID).get().getArticles(); + assertNotEquals(0, articles.size()); + } + + @Test + public void findAllArticlesForBook_whenBookDoesNotExist_shouldBeNotPresent() { + final Long BOOK_ID = -1L; + Optional book = bookService.findById(BOOK_ID); + assertFalse(book.isPresent()); + } + + +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/service/FileServiceTest.java b/src/test/java/fic/writer/domain/service/FileServiceTest.java new file mode 100644 index 0000000..053e345 --- /dev/null +++ b/src/test/java/fic/writer/domain/service/FileServiceTest.java @@ -0,0 +1,37 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.Book; +import fic.writer.domain.service.impl.FileServiceImpl; +import org.junit.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; + +public class FileServiceTest { + private FileService fileService = new FileServiceImpl(); + + @Test + public void parseJSONArticle_whenJSONIsValid_shouldReturnArticle() throws IOException { + File inputStream = new File(getClass().getClassLoader().getResource("files/ValidArticle.json").getFile()); + MultipartFile multipartFile = new MockMultipartFile("ValidArticle", "ValidArticle.json", "multipart/form-data", new FileInputStream(inputStream)); + Article article = fileService.parseArticle(multipartFile); + assertEquals("it's content", article.getContent()); + assertEquals("descr", article.getAnnotation()); + assertEquals("article", article.getTitle()); + + } + + @Test + public void parseFB2Book_whenDataIsValid_shouldReturnBook() throws IOException { + File inputStream = new File(getClass().getClassLoader().getResource("files/t.fb2").getFile()); + MultipartFile multipartFile = new MockMultipartFile("t", "t.fb2", "multipart/form-data", new FileInputStream(inputStream)); + Book book = fileService.parseBook(multipartFile); + } + +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/service/ProfileServiceTest.java b/src/test/java/fic/writer/domain/service/ProfileServiceTest.java new file mode 100644 index 0000000..ecf6507 --- /dev/null +++ b/src/test/java/fic/writer/domain/service/ProfileServiceTest.java @@ -0,0 +1,42 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.dto.ProfileDto; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.junit.Assert.*; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class ProfileServiceTest { + @Autowired + private ProfileService profileService; + + @Test + public void createUser() { + final String USERNAME = "createTestUser"; + ProfileDto user = ProfileDto.builder().username(USERNAME).build(); + profileService.create(user); + assertTrue(profileService.findAll().stream().anyMatch(u -> u.getUsername().equals(USERNAME))); + } + + @Test + public void deleteUser() { + final Long USER_ID = 123L; + assertTrue(profileService.findById(USER_ID).isPresent()); + profileService.deleteById(USER_ID); + assertFalse(profileService.findById(USER_ID).isPresent()); + } + + @Test + public void updateUser_whenUpdateAbout_shouldChangeAbout() { + final Long USER_ID = 1L; + final String NEW_ABOUT = "new about"; + ProfileDto profileDto = ProfileDto.builder().about(NEW_ABOUT).build(); + profileService.update(USER_ID, profileDto); + assertEquals(NEW_ABOUT, profileService.findById(USER_ID).get().getAbout()); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/service/WriterServiceTest.java b/src/test/java/fic/writer/domain/service/WriterServiceTest.java new file mode 100644 index 0000000..214366a --- /dev/null +++ b/src/test/java/fic/writer/domain/service/WriterServiceTest.java @@ -0,0 +1,68 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.auth.OauthProfileDetails; +import fic.writer.domain.entity.dto.BookDto; +import fic.writer.web.config.security.authorization.UserPrincipal; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.annotation.Transactional; + +import javax.validation.ConstraintViolationException; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class WriterServiceTest { + @Autowired + private WriterService writerService; + @Autowired + private ProfileService profileService; + + @Test(expected = ConstraintViolationException.class) + public void createBook_whenSecurityContextIsEmpty_shouldThrowException() { + BookDto bookDto = BookDto.builder().title("bookTitle").build(); + Book book = writerService.saveBook(bookDto); + } + + @Test + public void createBook_whenProfileExistInSecurityContext_shouldSaveWithNotNullAuthor() { + final Long PROFILE_ID = 1L; + BookDto bookDto = BookDto.builder().title("bookTitle").build(); + setUserInSecurityContext(PROFILE_ID); + + Book book = writerService.saveBook(bookDto); + Assert.assertNotNull(book.getAuthor()); + } + + @Test + @Transactional + public void createBook_whenProfileExistInSecurityContext_shouldAddBookInAuthorCollection() { + final Long PROFILE_ID = 1L; + BookDto bookDto = BookDto.builder().title("title").build(); + setUserInSecurityContext(PROFILE_ID); + + Book book = writerService.saveBook(bookDto); + Profile profile = profileService.findById(PROFILE_ID).get(); + Assert.assertTrue(profile.getBooksAsAuthor().contains(book)); + } + + private void setUserInSecurityContext(Long profileId) { + Profile profile = profileService.findById(profileId).get(); + this.setUserInSecurityContext(profile); + } + + private void setUserInSecurityContext(Profile profile) { + final String PASSWORD = "qwerty"; + OauthProfileDetails details = OauthProfileDetails.builder().profile(profile).build(); + UserPrincipal profileDetails = UserPrincipal.create(details); + TestingAuthenticationToken token = new TestingAuthenticationToken(profileDetails, null); + SecurityContextHolder.getContext().setAuthentication(token); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/service/helper/BookTXTStringConstructorTest.java b/src/test/java/fic/writer/domain/service/helper/BookTXTStringConstructorTest.java new file mode 100644 index 0000000..6d47f1c --- /dev/null +++ b/src/test/java/fic/writer/domain/service/helper/BookTXTStringConstructorTest.java @@ -0,0 +1,38 @@ +package fic.writer.domain.service.helper; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.enums.Size; +import org.assertj.core.util.Lists; +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class BookTXTStringConstructorTest { + + @Test + public void convertBookToTXT() { + BookStringConstructor bookStringConstructor = new BookTXTStringConstructor(); + String description = "it's a book", + title = "Header"; + + Book book = Book.builder() + .id(1L) + .description(description) + .size(Size.MINI) + .title(title) + .articles(Lists.list( + Article.builder().content("art content1").title("art1").build(), + Article.builder().content("art content2").title("art2").build())) + .build(); + + String convertedBook = bookStringConstructor.convertBookToText(book); + + assertTrue(convertedBook.contains(description)); + assertTrue(convertedBook.contains(title)); + assertTrue(convertedBook.contains("art content1")); + assertTrue(convertedBook.contains("art content2")); + assertTrue(convertedBook.contains("art1")); + assertTrue(convertedBook.contains("art2")); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/utils/MapConstructorTest.java b/src/test/java/fic/writer/domain/utils/MapConstructorTest.java new file mode 100644 index 0000000..23a2bad --- /dev/null +++ b/src/test/java/fic/writer/domain/utils/MapConstructorTest.java @@ -0,0 +1,20 @@ +package fic.writer.domain.utils; + +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertTrue; + +public class MapConstructorTest { + + @Test + public void getNew() { + Map map = MapConstructor.getNew() + .put("success", true) + .put("a", "true") + .getMap(); + assertTrue(map.containsKey("success")); + assertTrue(map.containsKey("a")); + } +} \ No newline at end of file diff --git a/src/test/java/fic/writer/web/config/security/authorization/UserPrincipalTest.java b/src/test/java/fic/writer/web/config/security/authorization/UserPrincipalTest.java new file mode 100644 index 0000000..15d03a5 --- /dev/null +++ b/src/test/java/fic/writer/web/config/security/authorization/UserPrincipalTest.java @@ -0,0 +1,55 @@ +package fic.writer.web.config.security.authorization; + +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.auth.OauthProfileDetails; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class UserPrincipalTest { + + @Test + public void createUserPrincipal_withProfileId_shouldEqualsProfileIdAndUserPrincipalId() { + final Long profileId = 1L; + Profile profile = Profile.builder() + .id(profileId).build(); + OauthProfileDetails details = OauthProfileDetails.builder().profile(profile).build(); + UserPrincipal userPrincipal = UserPrincipal.create(details); + + assertEquals(profileId, userPrincipal.getId()); + } + + @Test + public void createUserPrincipal_WithProfileEmail_shouldEqualsProfileEmailAndPrincipalUsername() { + final String email = "email@mail.com"; + Profile profile = Profile.builder().email(email).build(); + OauthProfileDetails details = OauthProfileDetails.builder().profile(profile).build(); + + UserPrincipal userPrincipal = UserPrincipal.create(details); + + assertEquals(email, userPrincipal.getUsername()); + } + + @Test + public void createUserPrincipal_WithProfileDetails_shouldEqualsProfileDetails() { + Profile profile = Profile.builder().build(); + OauthProfileDetails details = OauthProfileDetails.builder().profile(profile).build(); + + UserPrincipal userPrincipal = UserPrincipal.create(details); + + assertEquals(details, userPrincipal.getProfileDetails()); + } + + @Test(expected = IllegalArgumentException.class) + public void createUserPrincipal_withoutProfile_shouldThrowException() { + OauthProfileDetails details = OauthProfileDetails.builder().build(); + + UserPrincipal.create(details); + } + + @Test(expected = IllegalArgumentException.class) + public void createUserPrincipal_withoutProfileDetails_shouldThrowException() { + UserPrincipal.create(null); + } + +} \ No newline at end of file diff --git a/src/test/java/fic/writer/web/controller/BookControllerValidationTest.java b/src/test/java/fic/writer/web/controller/BookControllerValidationTest.java new file mode 100644 index 0000000..5791649 --- /dev/null +++ b/src/test/java/fic/writer/web/controller/BookControllerValidationTest.java @@ -0,0 +1,136 @@ +package fic.writer.web.controller; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.auth.AuthProvider; +import fic.writer.domain.entity.auth.OauthProfileDetails; +import fic.writer.domain.entity.dto.BookDto; +import fic.writer.domain.entity.enums.State; +import fic.writer.web.config.security.authorization.UserDetailsServiceImpl; +import fic.writer.web.config.security.authorization.UserPrincipal; +import fic.writer.web.config.security.oauth.TokenProvider; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@RunWith(SpringRunner.class) +@WebAppConfiguration +@SpringBootTest +@AutoConfigureMockMvc +public class BookControllerValidationTest { + private static final String BOOK_PATH = "/api/books"; + private static final String BOOK_ID_PATH_TEMPLATE = BOOK_PATH + "/{id}"; + + @Autowired + private BookController bookController; + @Autowired + private MockMvc mockMvc; + @MockBean + private UserDetailsServiceImpl userDetailsService; + @Autowired + private TokenProvider tokenProvider; + private String token; + + @Before + public void setUp() throws Exception { + String username = "username"; + String email = "email@mail.com"; + UserDetails userDetails = UserPrincipal.create(OauthProfileDetails.builder() + .provider(AuthProvider.LOCAL) + .profile(Profile.builder().username(username).email(email).id(1L).build()) + .id(1L) + .build()); + Mockito.when(userDetailsService.loadUserById(anyLong())).thenReturn(userDetails); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + token = tokenProvider.createToken(authentication); + } + + @Test + public void checkTokenToAllowAccess() throws Exception { + mockMvc.perform(get(BOOK_PATH).header("authorization", "bearer " + token)) + .andExpect(status().isOk()); + } + + @Test + public void createBook_whenTitleIsNull_shouldReceiveBadRequest() throws Exception { + BookDto bookDto = BookDto.builder() + .title(null) + .description("desc") + .state(State.FROZEN) + .build(); + ObjectMapper mapper = new ObjectMapper(); + + + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + String body = mapper.writeValueAsString(bookDto); + + mockMvc.perform(post(BOOK_PATH) + .header("authorization", "bearer " + token) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.[0].field").value("title")) + .andExpect(jsonPath("$.[0].value").doesNotExist()); + } + + @Test + public void createBook_whenTitleIsBlank_shouldReceiveBadRequest() throws Exception { + BookDto bookDto = BookDto.builder() + .title("") + .description("desc") + .state(State.FROZEN) + .build(); + ObjectMapper mapper = new ObjectMapper(); + + + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + String body = mapper.writeValueAsString(bookDto); + + mockMvc.perform(post(BOOK_PATH) + .header("authorization", "bearer " + token) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.[0].field").value("title")) + .andExpect(jsonPath("$.[0].value").isEmpty()); + } + + @Test + public void createBook_whenCorrect_shouldReceiveCreated() throws Exception { + BookDto bookDto = BookDto.builder() + .title("new adv") + .description("desc") + .state(State.FROZEN) + .build(); + ObjectMapper mapper = new ObjectMapper(); + + + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + String body = mapper.writeValueAsString(bookDto); + + mockMvc.perform(post(BOOK_PATH) + .header("authorization", "bearer " + token) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()); + } + +} diff --git a/src/test/java/fic/writer/web/controller/OauthAuthenticationTest.java b/src/test/java/fic/writer/web/controller/OauthAuthenticationTest.java new file mode 100644 index 0000000..8d58245 --- /dev/null +++ b/src/test/java/fic/writer/web/controller/OauthAuthenticationTest.java @@ -0,0 +1,150 @@ +package fic.writer.web.controller; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import fic.writer.web.controller.request.LoginRequest; +import fic.writer.web.controller.request.SignUpRequest; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@RunWith(SpringRunner.class) +@WebAppConfiguration +@SpringBootTest +public class OauthAuthenticationTest { + + private final String OAUTH_TOKEN_URL = "/auth/login"; + private final String OAUTH_SIGNUP_URL = "/auth/signup"; + + @Autowired + private WebApplicationContext webApplicationContext; + + @Autowired + private FilterChainProxy springSecurityFilterChain; + + private MockMvc mockMvc; + + @Before + public void setup() { + this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext) + .addFilter(springSecurityFilterChain).build(); + } + + @Test + public void sendTokenRequest_whenSendValidEmail_ShouldReturnAccessToken() throws Exception { + String email = "firstUser@mail.com", password = "qwerty"; + String body = serializeLoginInfoToJson(email, password); + mockMvc.perform(post(OAUTH_TOKEN_URL) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").hasJsonPath()) + .andExpect(content().contentType("application/json;charset=UTF-8")); + } + + @Test + public void sendTokenRequest_whenSendInvalidPassword_ShouldReturnAccessToken() throws Exception { + String email = "firstUser@mail.com", password = "invalid Password"; + String body = serializeLoginInfoToJson(email, password); + mockMvc.perform(post(OAUTH_TOKEN_URL) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + public void sendTokenRequest_whenUserNotExistsAndFindByEmail() throws Exception { + String email = "fakeUserWhoNotExists@mail.cc", password = "qwerty"; + String body = serializeLoginInfoToJson(email, password); + mockMvc.perform(post(OAUTH_TOKEN_URL) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + public void sendTokenRequest_whenEmailIsNotValid_shouldReturnBadRequest() throws Exception { + String email = "fakeUserWhoNotExists"; + String password = "qwerty"; + String body = serializeLoginInfoToJson(email, password); + mockMvc.perform(post(OAUTH_TOKEN_URL) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + public void sendSignupRequest_whenEmailIsNotValid_shouldReturnBadRequest() throws Exception { + String email = "fakeUserWhoNotExists"; + String password = "qwerty"; + String username = "name"; + + String body = serializeSignupInfoToJson(email, username, password); + mockMvc.perform(post(OAUTH_SIGNUP_URL) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + public void sendSignupRequest_whenEmailIsValid_shouldReturnCreated() throws Exception { + String email = "UserWhoNotExistsYet@mail.cc"; + String password = "qwerty"; + String username = "name"; + + String body = serializeSignupInfoToJson(email, username, password); + mockMvc.perform(post(OAUTH_SIGNUP_URL) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()); + } + + @Test + public void sendSignupRequest_whenEmailIsAlreadyUsed_shouldReturnBadRequest() throws Exception { + String email = "firstUser@mail.com"; + String password = "qwerty"; + String username = "name"; + + String body = serializeSignupInfoToJson(email, username, password); + mockMvc.perform(post(OAUTH_SIGNUP_URL) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + private String serializeLoginInfoToJson(String email, String password) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + + LoginRequest loginRequest = LoginRequest.builder() + .password(password) + .email(email) + .build(); + return objectMapper.writeValueAsString(loginRequest); + } + + private String serializeSignupInfoToJson(String email, String username, String password) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + + SignUpRequest loginRequest = SignUpRequest.builder() + .password(password) + .email(email) + .name(username) + .build(); + return objectMapper.writeValueAsString(loginRequest); + } +} diff --git a/src/test/java/fic/writer/web/controller/ProfileControllerTest.java b/src/test/java/fic/writer/web/controller/ProfileControllerTest.java new file mode 100644 index 0000000..6556fb1 --- /dev/null +++ b/src/test/java/fic/writer/web/controller/ProfileControllerTest.java @@ -0,0 +1,147 @@ +package fic.writer.web.controller; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.dto.ProfileDto; +import fic.writer.domain.service.ProfileService; +import org.assertj.core.util.Lists; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@RunWith(SpringRunner.class) +@WebMvcTest(value = ProfileController.class, secure = false) +public class ProfileControllerTest { + private static final String USERS_PATH = "/api/users"; + private static final String USER_ID_PATH_TEMPLATE = USERS_PATH + "/{id}"; + @Autowired + private ProfileController profileController; + @Autowired + private MockMvc mockMvc; + @MockBean + private ProfileService profileService; + + + @Test + public void getUsers_whenDtoIsEmpty_shouldReturnOk() throws Exception { + final long ID = 1L; + final String username = "testUsername"; + + List profileList = Lists.list(); + Profile profile = Profile.builder() + .id(ID) + .username(username) + .build(); + profileList.add(profile); + Mockito.when(profileService.findPage(any(Pageable.class))).thenReturn(new PageImpl<>(profileList)); + + mockMvc.perform(get(USERS_PATH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.profileResponseList.[0].username").value(profile.getUsername())) + .andExpect(jsonPath("$._embedded.profileResponseList.[0]._links.self").hasJsonPath()); + } + + @Test + public void getUserById_whenUserExists_shouldReturnOk() throws Exception { + final long ID = 1L; + final String username = "testUsername"; + Profile profile = Profile.builder() + .id(ID) + .username(username) + .build(); + + Mockito.when(profileService.findById(1L)).thenReturn(Optional.of(profile)); + + mockMvc.perform(get(USER_ID_PATH_TEMPLATE, ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value(username)) + .andExpect(jsonPath("$._links.self").hasJsonPath()); + } + + @Test + public void createUser() throws Exception { + final Long USER_ID = 1L; + final String username = "testUsername", + about = "about", + information = "inform"; + ProfileDto profileDto = ProfileDto.builder() + .username(username) + .about(about) + .information(information) + .build(); + ObjectMapper mapper = new ObjectMapper(); + + Profile profile = Profile.builder() + .id(USER_ID) + .username(username) + .about(about) + .information(information) + .build(); + + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + String body = mapper.writeValueAsString(profileDto); + Mockito.when(profileService.create(any(ProfileDto.class))).thenReturn(profile); + + mockMvc.perform(post(USERS_PATH) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.username").value(profile.getUsername())) + .andExpect(jsonPath("$._links.self").hasJsonPath()); + } + + @Test + public void updateUser() throws Exception { + final Long USER_ID = 1L; + final String NEW_USERNAME = "testUsername"; + Profile updatedProfile = Profile.builder() + .id(USER_ID) + .username(NEW_USERNAME) + .booksAsAuthor(new HashSet<>()) + .booksAsCoauthor(new HashSet<>()) + .build(); + ProfileDto dto = ProfileDto.builder() + .username(NEW_USERNAME) + .build(); + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + String body = mapper.writeValueAsString(dto); + + Mockito.when(profileService.update(anyLong(), any(ProfileDto.class))).thenReturn(updatedProfile); + + mockMvc.perform(put(USER_ID_PATH_TEMPLATE, USER_ID) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value(updatedProfile.getUsername())) + .andExpect(jsonPath("$._links.self").hasJsonPath()); + + } + + @Test + public void deleteUser() throws Exception { + final long ID = 1L; + + mockMvc.perform(delete(USER_ID_PATH_TEMPLATE, ID)) + .andExpect(status().isNoContent()); + } +} \ No newline at end of file diff --git a/src/test/resources/application-db-h2.yml b/src/test/resources/application-db-h2.yml new file mode 100644 index 0000000..ced131b --- /dev/null +++ b/src/test/resources/application-db-h2.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driverClassName: org.h2.Driver + username: sa + password: + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + h2: + console: + enabled: true \ No newline at end of file diff --git a/src/test/resources/application-db-mysql.yml b/src/test/resources/application-db-mysql.yml new file mode 100644 index 0000000..68c4a70 --- /dev/null +++ b/src/test/resources/application-db-mysql.yml @@ -0,0 +1,12 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/ficwriter-test + username: dl + password: p@ssword + initialization-mode: always + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL5InnoDBDialect \ No newline at end of file diff --git a/src/test/resources/application-oauth2-config.yml b/src/test/resources/application-oauth2-config.yml new file mode 100644 index 0000000..89f8bb8 --- /dev/null +++ b/src/test/resources/application-oauth2-config.yml @@ -0,0 +1,33 @@ +spring: + security: + oauth2: + client: + registration: + google: + clientId: 1026826912350-7fmgasm5s5bn952j8bverf5skre0spt0.apps.googleusercontent.com + clientSecret: qku837SCPKgNM1M5nbNCmHbk + redirectUriTemplate: "{baseUrl}/oauth2/callback/{registrationId}" + scope: + - email + - profile + facebook: + clientId: 121189305185277 + clientSecret: 42ffe5aa7379e8326387e0fe16f34132 + redirectUriTemplate: "{baseUrl}/oauth2/callback/{registrationId}" + scope: + - email + - public_profile + github: + clientId: 98ec518608b7facf2a4b + clientSecret: a0f0d1c9bf39eeb3600baa6f34c5a4ef317e901e + redirectUriTemplate: "{baseUrl}/oauth2/callback/{registrationId}" + scope: + - user:email + - read:user +app: + auth: + tokenSecret: 926D96C90030DD58429D2751AC1BDBBC + tokenExpirationMsec: 864000000 + oauth2: + authorizedRedirectUris: + - http://localhost:3000/oauth2/redirect \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..49ce427 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,26 @@ +spring: + profiles: + active: db-mysql,oauth2-config + main: + allow-bean-definition-overriding: true + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + hbm2ddl: + import_files: data/user.sql, data/actor.sql, data/book.sql, data/article.sql, data/book_article.sql, data/actor_state.sql +logging: + level: + org: + springframework: INFO + +security: + oauth2: + client: + client-id: acme + client-secret: acmesecret + scope: read,write + auto-approve-scopes: '.*' +server: + port: 8082 \ No newline at end of file diff --git a/src/test/resources/data/actor.sql b/src/test/resources/data/actor.sql new file mode 100644 index 0000000..bb9597c --- /dev/null +++ b/src/test/resources/data/actor.sql @@ -0,0 +1,10 @@ +INSERT INTO actor(id,description,name)VALUES(1,'description1','name1'); +INSERT INTO actor(id,description,name)VALUES(2,'description2','name2'); +INSERT INTO actor(id,description,name)VALUES(3,'description3','name3'); +INSERT INTO actor(id,description,name)VALUES(987,'description','name'); +INSERT INTO actor(id,description,name)VALUES(333,'description','name'); +INSERT INTO actor(id,description,name)VALUES(334,'description','name'); + + + + diff --git a/src/test/resources/data/actor_state.sql b/src/test/resources/data/actor_state.sql new file mode 100644 index 0000000..6e99aca --- /dev/null +++ b/src/test/resources/data/actor_state.sql @@ -0,0 +1,7 @@ +INSERT INTO actor_state(content,title,actor_id,article_id)VALUES('content1','title1',1,1); +INSERT INTO actor_state(content,title,actor_id,article_id)VALUES('content2','title2',1,2); +INSERT INTO actor_state(content,title,actor_id,article_id)VALUES('content3','title3',2,1); +INSERT INTO actor_state(content,title,actor_id,article_id)VALUES('content4','title4',2,2); + +INSERT INTO actor_state(content,title,actor_id,article_id)VALUES('content4','title4',333,3); +INSERT INTO actor_state(content,title,actor_id,article_id)VALUES('content4','title4',334,3); \ No newline at end of file diff --git a/src/test/resources/data/article.sql b/src/test/resources/data/article.sql new file mode 100644 index 0000000..f9ec22f --- /dev/null +++ b/src/test/resources/data/article.sql @@ -0,0 +1,14 @@ +INSERT INTO article(id,annotation,content,title)VALUES(1,'annotation1','content1','title1'); +INSERT INTO article(id,annotation,content,title)VALUES(2,'annotation2','content2','title2'); +INSERT INTO article(id,annotation,content,title)VALUES(3,'annotation','content','title'); +INSERT INTO article(id,annotation,content,title)VALUES(4,'annotation','content','title'); +INSERT INTO article(id,annotation,content,title)VALUES(333,'annotation','content','delete article'); +INSERT INTO article(id,annotation,content,title)VALUES(334,'annotation','content','delete article'); + +INSERT INTO article(id,annotation,content,title,book_id)VALUES(5,'Place for annotation','

Place for content.

','Summer inspiration',335); +INSERT INTO article(id,annotation,content,title,book_id)VALUES(6,'Place for annotation','

Place for content.

','Summer inspiration#2',335); +INSERT INTO article(id,annotation,content,title,book_id)VALUES(7,'Place for annotation','

Place for content.

','Summer #3',335); +INSERT INTO article(id,annotation,content,title,book_id)VALUES(8,'Place for annotation','

Place for content.

','Winrte',335); + +INSERT INTO article(id,annotation,content,title,book_id)VALUES(9,'Place for annotation','

Place for content.

','Summer #3',33); +INSERT INTO article(id,annotation,content,title,book_id)VALUES(10,'Place for annotation','

Place for content.

','Winrte',33); diff --git a/src/test/resources/data/book.sql b/src/test/resources/data/book.sql new file mode 100644 index 0000000..c7c3871 --- /dev/null +++ b/src/test/resources/data/book.sql @@ -0,0 +1,18 @@ +INSERT INTO book(id,title,author_id)VALUES(1,'book title',1); +INSERT INTO book(id,title,description,author_id)VALUES(2,'book title','description',1); +INSERT INTO book(id,title,size,author_id)VALUES(3,'book title',1,1); +INSERT INTO book(id,title,state,author_id)VALUES(4,'book title',1,1); +INSERT INTO book(id,title,author_id)VALUES(5,'book title',1); +INSERT INTO book(id,title,author_id)VALUES(333,'delete book',1); +INSERT INTO book(id,title,author_id)VALUES(334,'Книга для удаления',1); + +INSERT INTO book(id,title,description, size, state,author_id)VALUES(335,'Arabella','Artic monkeys', 1,2,3); +INSERT INTO user_books_as_author (user_id,books_as_author_id)VALUES(3,335); +INSERT INTO book_subauthors (user_id,book_id)VALUES(4,335); + + +INSERT INTO book(id,title,author_id)VALUES(33,'Книга',1); + + + + diff --git a/src/test/resources/data/book_article.sql b/src/test/resources/data/book_article.sql new file mode 100644 index 0000000..be31ab1 --- /dev/null +++ b/src/test/resources/data/book_article.sql @@ -0,0 +1,7 @@ +INSERT INTO book_articles(book_id,articles_id)VALUES(334,334); +INSERT INTO book_articles(book_id,articles_id)VALUES(335,5); + + +INSERT INTO book_articles(book_id,articles_id)VALUES(33,6); +INSERT INTO book_articles(book_id,articles_id)VALUES(33,7); +INSERT INTO book_articles(book_id,articles_id)VALUES(33,8); \ No newline at end of file diff --git a/src/test/resources/data/user.sql b/src/test/resources/data/user.sql new file mode 100644 index 0000000..f919d49 --- /dev/null +++ b/src/test/resources/data/user.sql @@ -0,0 +1,6 @@ +INSERT INTO profile(id,username)VALUES(123,'delete profile'); +INSERT INTO profile(id,username)VALUES(1,'test profile'); +INSERT INTO profile(id,username)VALUES(3,'Bella'); +INSERT INTO profile(id,username)VALUES(4,'Bella junior'); + +INSERT INTO oauth_profile_details(id,password)VALUES(1,'qwerty'); \ No newline at end of file diff --git a/src/test/resources/files/ValidArticle.json b/src/test/resources/files/ValidArticle.json new file mode 100644 index 0000000..ef98f1b --- /dev/null +++ b/src/test/resources/files/ValidArticle.json @@ -0,0 +1,5 @@ +{ + "title": "article", + "annotation": "descr", + "content": "it's content" +} \ No newline at end of file diff --git a/src/test/resources/files/t.fb2 b/src/test/resources/files/t.fb2 new file mode 100644 index 0000000..3a3c576 --- /dev/null +++ b/src/test/resources/files/t.fb2 @@ -0,0 +1,3484 @@ + + + + .body{font-family : Verdana, Geneva, Arial, Helvetica, sans-serif;} .p{margin:0.5em 0 0 0.3em; padding:0.2em; + text-align:justify;} + + + + Юмор + Фэнтези + Экшн (action) + POV + AU + Стёб + Попаданцы + + Cheshirro + + Бессмертный: Вопреки...скуке + + +

+ Бессмертный: Вопреки...скуке +

+

+ Направленность: + Джен +

+

+ Автор: + Cheshirro +

+

+ Беты (редакторы): + AnteLucem , Евгений Кузюк +

+

+ Фэндом: + Человек-Паук (1994), Marvel Comics, Мстители, Люди Икс, Сорвиголова (кроссовер) +

+

+ Рейтинг: + R +

+

+ Жанры: + Юмор, Фэнтези, Экшн (action), POV, AU, Стёб, Попаданцы +

+

+ Предупреждения: + Смерть основного персонажа, ОМП, Смерть второстепенного персонажа +

+

+ Размер: + планируется Макси, + написано + 75 страниц +

+

+ Кол-во частей: + 13 +

+

+ Статус: + в процессе +

+

+ Посвящение: + Моей борьбе с ленью и тем кому скучно ( ͡° ͜ʖ ͡°) +
+ +

+

+ Публикация на других ресурсах: + Уточнять у автора/переводчика +

+

+ Примечания автора: + Работа скучающего и начинающего фикрайтера, которая изначально планировалась как + депрессивно-философская хрень, но в процессе написании превратившаяся в стёб и юмор. +
+ Не судите строго, написано для юмора и с юмором =) +
+ Проды часто не обещаю, но они будут... ( ͡° ͜ʖ ͡°) +
+ 08.04.19 — 1000+ лайков +
+ №1 в топе «Джен по жанру AU» +
+ №1 в топе «Джен по жанру POV» +
+ №1 в топе «Джен по жанру Попаданцы» +
+ №1 в топе «Джен по жанру Стёб» +
+ №1 в топе «Джен по жанру Фэнтези» +
+ №1 в топе «Джен по жанру Экшн (action)» +
+ №1 в топе «Джен по жанру Юмор» +
+ №1 в топе «Джен по всем жанрам» +
+ Рубрика – "Пранк вышедший из под контроля" +

+

+ Описание: + Бессмертие, все его жаждут и стремятся к нему. Но что делать тем кто достиг? Как бороться со скукой, + если все возможное уже перепробовано, а перед тобой вечность... +
+ Ну или если без пафоса: Сказания о скучающем Томе, головной боли профессора Ксавьера и всего + Нью-Йорка +

+ + 2019-03-02 11:12:25 + ru + ru + + + + http://ficbook.net/authors/2136034 + + + ficbook.net + 2019-03-02 11:12:25 + http://ficbook.net/readfic/7969707 + 2136034_7969707 + 2.0 + + + + + + + + + + + + + +
+ + <p>Пролог</p> + +

— Кися, я серьёзно говорю, отстань от меня. А то тебе же будет хуже, — грозно пищал загнанный в угол + маленький мышонок, смотря в глаза огромного, по сравнению с ней, белоснежного породистого кота.- Я же + тебя с говном смешаю… +

+

Но тот, не став дослушивать маленького храброго наглеца, одним мощным прыжком оказался над ним. Не + растерявшись, мышонок неестественно быстрым движением успел увернуться от когтей и клыков домашнего + хищника и столь же молниеносно взобрался на его спину. И стоит отметить, карабкался он получше многих + обезьян. +

+

Держась за шелковистый и густой мех посредине хребта, он начал выдирать его. Но конечно же, для его + маленьких мускулов это было не под силу. +

+

Хотя он и добивался не этого, его главной задачей было привлечь внимание своего «скакуна».

+

Яростно мяукая, представитель кошачьих попытался укусить наглеца. Но сразу же потерпел фиаско из-за + ловкости своего обидчика, который, как таракан, сместился в сторону от его клыков и быстрым движением + царапнул кота по носу. Обиженно зашипев, белый хищник снова и снова пытался скинуть грызуна с себя. Но + каждый раз мышка оказывалась быстрее, ловко уворачиваясь от его задних лап и клыков, попутно болезненно + царапая нежный розовый носик. И даже несколько кувырков по ковру не помогли бедному коту избавиться от + назойливой жертвы, которая в одночасье оказалась хозяином положения. +

+

— Что я тебе говорил, шерстяной мешок, а?! — пищал его наездник. — Решил на меня лезть, ты, засранец + мелкий. Ты хоть знаешь, кто я?! Да я ведь… +

+

— Крыыысаааа! — ультразвуковой женский визг перебил яростную тираду мышонка.

+

А последовавший за ним удар трубкой пылесоса вызвал обиженный мяв у кота и сотрясение у маленького + партизана. +

+

Выпав со спины своего обидчика, мышонок успел лишь повернуть голову и увидеть стремительно приближающуюся + чёрную тень. +

+

— Пиз…

+

Что ещё хотел пропищать смелый мышонок? Неизвестно. Это осталось загадкой для всех, кроме него.

+

А его храбрая душа отправилась дальше по кругу сансары.

+ +
+
+ + <p>Глава 1</p> + +

Устало окинув взглядом младенца, который так же устало глядел на меня из зеркала, я меланхолично полетел + в сторону своей кроватки. +

+

Который это раз? Я уже и сам запутался.

+

Но одно я мог сказать точно - я заипался.

+

Детство обычно являлось самой беззаботной и веселой стадией жизни, а до школьных лет так вообще. Никаких + тревог и забот, самая большая проблема – как бы покушать вкусняшек и поиграть подольше. И эти истины + лишь незначительно менялись независимо от миров и рас, в которые я умудрялся "попадать". Даже детские + будни в виде животных были похожи как две капли воды на вышеописанное. +

+

Вообще, жизнь любых существ оказывается очень скучна, если переживаешь ее в тысячный раз. Никогда бы не + подумал, но бытие бессмертным оказалось и вправду очень скучным. +

+

В первые раз пятьдесят было весело. Удивлять окружающих, устраивать козни "глупым" взрослым в детском + возрасте, ломать стереотипы местным жителям. И просто изучать окружающий мир, ведь все было таким + необычным, удивительным и незнакомым. +

+

Классные были времена...

+

Каждый раз хотелось познавать новое, учиться премудростям школ магии и боевых искусств разных миров, + чтобы потом захватить своей силой целый мир! Стать властелином всех и вся! +

+

Моя идея фикс, которую я исполнил в свое седьмое перерождение.

+

В общем и целом, поначалу было реально интересно. Интересно было жить. Эх...

+

А потом мне все начало надоедать своей однообразностью. Словно тысячная книга одного единственного жанра, + которая состоит из одних и тех же давно знакомых клише (Тут наверное каждый опытный читатель печально + вздохнул :)). +

+

Из многих своих жизней я даже сам начал сливаться, чтобы быстрее перейти в новые миры, познать новое и + поднакопить уникальных знаний. +

+

Время шло, миры менялись, но новые знания, которые превосходили бы уже имеющиеся, стали попадаться + гораздо реже. +

+

Последовав примеру одного бессмертного существа, я даже начал жить жизнью и бытом разных людей. + Перепробовал все известные профессии, побывал в роли простых смертных, которые живут этим днем и даже не + имеют представления о других мирах. +

+

Был сапожником, который еле сводил концы с концами. Рабом в услужении жестоких хозяев. И даже обычным + клерком в государственной системе. Кстати, эта попытка была одной из самых отстойных: унылее и скучнее + даже бессмертного существования. +

+

И, наверное, поэтому последующие несколько сотен перерождений были полны абсурда и экспериментов, которые + уже не были настолько невинными как раньше. +

+

Как бы окружающие прореагировали, если ребенок начнет со светящимися глазами сыпать пророчествами, + заявляя, что он - посланник богов? +

+

Начнет курить и бухать, жалуясь, что не хотел рождаться, но бог нагло выпнул его с небес... Или же вообще + заявит что он - возродившийся верховный демон ада и будет бегать по городу в подгузниках с огненной + аурой и ножом наперевес... Я даже в одном из перерождении принудительно остановил рост своего организма + и тридцать лет ходил в облике младенца! +

+

Но, хотя... наверное, одним из самых веселых было и остается перерождение в качестве льва! Тогда + получилось пробудить с помощью магии разум у животных и поднять бунт против человечества. Было сплошным + удовольствием наблюдать за взаимодействием двух разных цивилизаций. Особенно за цивилизацией животных, + которые пытались урегулировать отношения между хищниками и травоядными. Межвидовые браки... +

+

Чудные были времена, в прямом смысле этого слова.

+

Но потом мне и это надоело. В итоге, я с самого рождения в новом мире изучал магический фон и, подобрав + среди изученных работающую школу магии, улетал из отчего дома и за несколько лет изучал всю уникальную и + интересную информацию, после чего переходил в следующий мир. +

+

Сплошной конвейер по сбору информации.

+

Спустя некоторое время и это занятие надоело. Я долго размышлял над тем, что мне делать дальше. Тогда мне + и пришла мысль изучать разных животных в естественной среде обитания. +

+

С того самого решения прошло уже несколько тысяч перерождений, большая часть которых заканчивалась + суицидом, поскольку выбирать, в кого переродиться, не получалось, а тратить время на скучную жизнь уже + изученных рас и животных не хотелось. +

+

Вот так и проходило время, пока я не решил поменять планы и пару раз пожить жизнью гуманоида.

+

Теперь вот, впервые оглядывал свое тело полностью, заодно придумывая условия и ограничения, чтобы эта + жизнь не была очень скучной. +

+

Покивав своим воспоминаниям, я небрежным посылом воли пролеветировал обратно в свою кроватку.

+

Эх, наверное, стоит в первую очередь поставить себе условие дальше не использовать магические силы, а то + уж слишком все будет скучным и легким. А без нее встречи с местными магами и колдунами наверняка будут + очень интересными... Ну, я надеюсь на это. +

+

Уж очень магический фон этого мира был специфичен. Это и вселяло в меня надежду на хоть какую-то веселую + жизнь. Столько намешанных разнообразных потоков магии я давно уже не встречал за свое долгое + путешествие. +

+

Так что я сразу в голове поставил примечание в своем условии: Если будут новые интересные знания, то + изучать их и использовать магию при этом можно. +

+

Подумав еще немного, я поставил себе ограничение не убивать никого, по крайней мере, осознанно и с + желанием. Ведь они, в отличие от меня, вряд ли смогут начать новую жизнь со старыми воспоминаниями и + памятью. Так что мне не хотелось прерывать жизнь их личностей. Да и интереснее, когда поверженные + соперники и враги могут прийти за тобой в любой момент. +

+

Довольно покивав своим мыслям, я вновь задумался. Чем бы еще сделать свою жизнь веселее? С моим + характером и удачей я был полностью уверен, что жить тихой спокойной жизнью у меня не получится. +

+

Что уж говорить, если даже в жизни клерком наш офис трижды взрывали, десяток раз захватывали террористы, + а коллега по работе оказался шпионом другой страны. +

+

А поскольку в этой своей жизни я даже не планировал специально избегать таких поворотов судьбы, то с + поставленными условиями жить должно быть не слишком скучно, как без них. +

+

Еще немного помучив себя разными мыслями и думами, в конце концов решив оставить все на туманное будущее + и злодейку судьбу, я с удовольствием погрузился в сон. Ведь во сне не так скучно... +

+ +
+
+ + <p>Глава 2</p> + +

Хм...

+

— Ну давай, парень, решайся. Первая порция абсолютно бесплатно! Тебе остаётся только попробовать! Поверь + мне, это абсолютный шикос, получше всяких игровых автоматов! Просто офигительная бомба! +

+

Хм... Это был реально тяжёлый выбор...

+

— Эх, ладно. - Вдруг воспылал этот субтильный индивидуум. — Давай даже так: я не только отсыплю тебе + сейчас, но и дам с собой малую порцию на потом. И всё абсолютно бесплатно! Давай - давай. Идём в тот + переулок, там и поделюсь порошком. +

+

Глядя на его бегающий по сторонам взгляд и дрожащие руки, я окончательно решился.

+

Вмажу с левой.

+

— Ну и... Угхууу... - смачный пендель по копчику не дал ему закончить его очередное заманчивое + предложение. +

+

— Суслик, я же тебя просил - начал усталым тоном монолог над корчащимся на земле пацаном лет на пять + постарше. — Не буду я брать твой дешёвый товар. Что за фиговый дилер, который сидит на своей же травке? + Тьфу на тебя, ни капли профессионализма. - сокрушался я. +

+

И даже не поддавшись на его молящиеся глаза, жестоко добавил:

+

— И денег не займу.

+

Презрительно цыкнув на него и, сделав на месте оборот на 180 градусов, я неспешно влился в людскую + массу. +

+

Времени было море, так что неплохо было бы подзаработать ещё зелёных фантиков, которые так любили + Нью-Йоркцы. Хотя почему я должен их винить? Эти бумажки с мёртвыми президентами любят все! +

+

Мягко обойти прохожего встречного в самый последний момент и продолжить дальше путь.

+

Но бедный пешеход продолжит путь уже без бумажника, а я ещё с некоторой долей банкнотиков.

+

Ловкость рук и никакой магии.

+

Конечно это было не самым честным путем обогащения, но мне, откровенно говоря, было пофиг. Хотелось + вкусно кушать, а работать было лень. Да и кто возьмёт тринадцатилетнего пацана на работу? Вряд ли кто-то + захочет. А даже если бы и брали, я бы не пошёл. Потому что мне лень. Гы. +

+

А эта подработка была не напряжной и очень прибыльной. На место работы годились любые скопления людей. И + не было никого над тобой, кто портил бы тебе нервы. Разве что местная криминальная банда, которая + стабильно получая свою долю, не лезла с претензиями. В общем, сказка, а не работа. Главное, иметь + кое-какие навыки и можно неплохо устроиться. +

+

Довольно покивав своим мыслям, я на ходу обчистил ещё одного прохожего...

+

— Сопля, тебя разве не учили, что совать руку куда попало плохо?

+

... То есть, попытался.

+

Вообще, я был в шоке, что какой-то лохматый и звероватый мужик, смог меня, МЕНЯ, поймать за руку во время + дела. Кто он вообще такой? С виду дуб дубом. Я и руку то сунул по привычке, хотя с виду он не был похож + на человека с деньгами. И теперь я не знал то ли радоваться, то ли печалиться ситуации. С одной стороны, + интересный поворот судьбы, который надеюсь поможет мне побороть скуку. С другой стороны, только что + растоптали мою профессиональную гордость! +

+

Тем временем "сильно" сжав мне руку, мужик с тихим рыком произнёс:

+

— Ведь руку могут и отрезать...

+

Тихо и беззвучно выскочившее лезвие между пальцами заставило меня радостно воскликнуть.

+

— Нифига ж себе, наконец-то!

+

***

+

Радостная улыбка пацана не соответствовала ожиданиям Логана. От слова совсем.

+

Очень странная реакция. Хотя пацан сам по себе был странным. А его ловкость рук вообще была чем-то + запредельным. +

+

Даже он, учитывая звериные инстинкты и немалое мастерство в Боевых Искусствах, лишь в последний момент + смог ощутить еле заметное прикосновение паренька и среагировать. +

+

Не хотелось, чтобы такой ловкий и умелый парень тратил свои таланты на поприще карманника.

+

Так что из самых светлых побуждений Логан решил немного вспугнуть мальчика, чтобы он бросил эту работу и + занялся чем-нибудь другим. +

+

Но в итоге...

+

— Так ты из тех самых мутантов, да? Крутяяяг. А ты знаешь ещё других мутантов и таких же крутых парней? + Познакомь меня с ними, а? -от такого радостного возбуждения Росомаха немного оху...удивился. +

+

Отпустив паренька и со вздохом помассировав переносицу, он как можно строго обратился к блондинистому + пацану. +

+

— В общем ты это бросай и лучше займись чем-нибудь полезным. А то в один не самый прекрасный день + попадешь к тем, у кого совсем не стоило красть и в итоге лишишься жизни. +

+

Посчитав что свой долг сознательного и хорошего гражданина он выполнил, Логан быстрым шагом пошел + дальше. +

+

Но как говорится - не тут то было…

+

— Ну познакомьте меня пожалуйста с ними, я тоже мутант. Честно-пречестно. Хочу познакомиться с себе + подобными! Найти людей, которые меня поймут и примут таким... какой я есть... +

+

Не знамо почему, но Логану не верилось не единому слову ловкого паренька.

+

— Слушай шкет, я одиночка. И понятие не имею о других сверхах. Так что если так жаждешь с ними + познакомиться, ищи их сам. И не еб...кхм, и не доставай уже. +

+

Еще раз рыкнув для весомости своих слов и с неудовольствием отметив полное отсутствие реакции, он хмуро + зыркнув, прибавил шагу. +

+

Его слова были почти правдой. До недавнего времени у него полностью отсутствовали знакомые среди других + мутантов... То есть среди мирных мутантов, с которыми можно было бы не опасаясь за его здоровье, в том + числе и моральное, познакомить маленького мальчика. +

+

Но всего месяц назад, после знакомства и спасения девчушки-мутанта Шельмы, он вдруг неожиданно обнаружил + целое сообщество мирных сверхов, которые жили без притеснений и боязни, учась контролировать свои силы в + особняке профессора Ксавьера. +

+

Но перед тем, как отвести парня в место, что стало и для него пристанищем, Логан все же решил сначала + посоветоваться с самим профессором. Пацан совсем не был похож на того, кто тяготел от своих способностей + и еле уживался в обычном социуме. Да и почему-то доверия совсем не внушал. Так что мужчина решил + оставить все на попечение начальства. Потерпит пару деньков, пока профессор сам не встретится с ним и не + решит, что с ним делать. +

+

— А как вас зов...

+

— Заткнись. И отстань. Послезавтра жди на этом же месте. Познакомлю кое с кем. Только отстань.

+

С облегчением отметив довольную моську пацана, он еще прибавил шагу. Но ему принесло облегчение не сама + мордочка мальчишки, а то, что он наконец отстал от него. +

+

Месяца было мало, очень мало для того, чтобы привыкнуть к этим надоедливым созданиям под названием дети. + Да и в школе мутантов он занимался преподавательской деятельностью с более взрослой возрастной группой. +

+

Еще раз бросив взгляд назад и отметив отсутствие прилипучки, он остановил такси и поехал в сторону + Бруклинских баров как и планировал. Ведь мог же он, в конце концов, позволить себе отдохнуть без мелких + непосед? +

+

А отсутствие бумажника, денег и даже кредитной карты бедный Логан обнаружил лишь тогда, когда доехал до + места назначения и пришло время оплатить такси... +

+

***

+

— Ха, лох! Вот и наступил тот день, когда ты не смог украсть и тебя даже поймали за руку! Ну что мелюзга, + понял теперь, что ты не самый крутой в этом мире, а? Лошара...– самодовольное бахвальство Эша или, в + простонародье, Суслика, изрядно позабавили меня. +

+

Радуется так, будто сам меня поймал.

+

Увидев мою издевательскую улыбку и кожаный бумажник, который я подкидывал в руке, он резко замолк. Потом, + подумав несколько секунд, удивленно спросил: +

+

— Ты что, в итоге все равно спер у него кошелек? Он же видел твое лицо и знает, что это ты пытался + спереть у него в первый раз. Что если он обратится в полицию или сам поймает тебя? Тебе же пиздец, + пацан... +

+

— Не боись, Суслище. Все под контролем, ничего он мне не сделает...– Видя, что он ни на грош не доверяет + моим словам, добавил. — Ну, по крайней мере, ничего такого с летальным исходом. +

+

Недоверчиво фыркнув моим словам, Суслик пошел дальше по своим делам, то есть дальше толкать свою дурь. +

+

Вообще Эш был очень даже неплохим парнем, которому не повезло подсесть на иглу еще в раннем возрасте. + Семнадцать лет всего, а уже наркоман с немалым стажем. Шутка ли – пять лет! +

+

Я до сих пор не понимал, что могло сподвигнуть двенадцатилетнего мальчика из вполне благополучной и + любящей семьи начать употреблять наркотики. Наверное, извечное детское любопытство и бунтарский дух. + Хех. +

+

Познакомился я с ним тут же, в районе Бруклинского моста, где у него была точка. Он подрабатывал по + своему, я по своему. Часто замечали друг друга в данном районе, понимали, чем каждый занимается, но + молчаливо сохраняли статус-кво, поскольку сферы интереса почти и не пересекались. А какая то странная + дружба-знакомство у нас образовалась после того, как он меня однажды спас от облавы, заранее предупредив + о намечающейся акции со стороны полиции. А когда он помог мне связаться и вступить в роли свободного + карманника в местное гетто, то стал, можно сказать, моим лучшим другом. +

+

Я в ответ подкидывал ему деньги, с которыми у него в последнее время всегда было туго и помогал временами + бить морды оборзевшим клиентам. В общем, взаимный симбиоз уличного отребья. +

+

И каждый в принципе был доволен своей ролью и не лез помыкать другим. Я - из-за лени, а он, наверное, + потому, что не раз видел, как я валил огроменных шкафов направо и налево. +

+

Вот и была наша дружба хорошей и крепкой.

+

Махнув рукой стоящему на своем излюбленном месте Эшу, я пошел в сторону своего связного. Хозяина местного + потрепанного ломбарда. +

+

Надо было сдать ему тридцать процентов от награбленного, плюс к этому он покупал у меня все лишнее, что + оставалось после промысла. Телефоны, драгоценные цацки, кредитные карточки и даже сами бумажники. Он, + конечно, не давал мне даже и половины цены за все это, но вкупе с процентами от дохода, которые они + забирали, все это гарантировало мне спокойный и тихий промысел. +

+

От желания поубивать всех этих мелких бандитиков и работать только на себя, меня удерживало лишь данное + обещание никого не убивать. Я надеялся, что так будет интереснее, но пока все получалось наоборот, + йэх... +

+

Рассовав по карманам полученную за день прибыль в размере чуть больше полтысячи долларов, я спокойно + отправился на автобусную остановку. Надо было еще домой доехать до начала пробок, если не желал + потратить на дорогу несколько часов. Эти пробки большого мегаполиса жутко бесили меня до такой степени, + что хотелось наплевать на все и попрыгать с крыши на крышу, усилив мышцы с помощью ЦИ. Но я просто не + представлял, как в этом случае буду отбиваться от любопытных без использования магии и даже не убивая. +

+

Печально повздыхав пару раз, я с обычной полуулыбкой уселся в свой маршрутный автобус, который за полчаса + домчал меня в откровенные трущобы Бруклина. Эх, дом, милый дом! +

+

Прыгучей веселой походкой, обходя остатки мусора и различных стройматериалов, я поскакал в сторону своего + дома. +

+

На небе светило солнце, на улицах лежали бомжи и наркоманы, все было как обычно. Все было прекрасно!

+

— Ма...– услышав сопение и пыхтение со стороны спальни, я лишь хмыкнул и пошел в сторону кухни. Похоже, + моя мама обслуживала своего очередного клиента. +

+

Было очень иронично и забавно, что в этой жизни я родился сыном шлюхи. Да еще не простой, а человека с + такой натурой. Даже несмотря на то, что я зарабатывал немало денег и их вполне хватило бы для нас с + лихвой, она занималась этим для себя, лишь для самого процесса. +

+

Видя, что мне в принципе фиолетово на все ее постельные шашни, она начала водить своих клиентов и просто + бойфрендов, даже когда я был дома. +

+

А я-то что? Мне и вправду было пофиг на все это. Я ее матерью-то не особо воспринимал (Трудно вообще + кого-то воспринимать родителем, когда их у тебя были тысячи). А ее занятие... В мире много профессий, а + ей нравится заниматься именно этим. Так кто я такой, чтобы запрещать ей это? Пусть занимается тем, чем + хочет. Это никак не влияло на мое чувство благодарности и признательности к ней. Ведь у меня было немало + родителей, которые бросали меня в младенческие годы, продавали в рабство или просто люто ненавидели, + словно мое тело и не их ребенок вовсе. А Марта, хоть и не была образцовой матерью, но по своему любила и + заботилась. +

+

Послышался легкий скрип старой двери и из комнаты матери выскочил молодой мужчина, поспешно надевая часы + и лихорадочно смотря на циферблат. А за ним, походкой наевшейся сметаны кошки, вышла Марта и с ходу + впилась в него страстным поцелуем. Задержав этим поцелуем его еще на минуту, она с довольной улыбкой + сама сопроводила его до дверей. +

+

— Сладкое на верхней полке, а остатки вчерашнего ужина в холодильнике. Меня можешь не ждать, я наверное + посплю, а то утомилась немного...– На ходу отдав мне ЦУ (1), она ушла обратно в свою комнату. +

+

Если подумать, кое-что изменилось с тех пор, как я стал зарабатывать побольше нее. Она теперь могла сама + выбирать себе клиентов и стала больше ухаживать за собой. Да так, что расцветала с каждым днем, + потихоньку возвращая свою былую красоту. Все же всего 33 года и вся жизнь еще впереди, даже будь она + простым человеком, а не сверх существом вроде меня. Кстати насчет сверх существ... +

+

Как-то вдруг неожиданно мысли перескочили на мужика, которого встретил днем. То есть непростого мужика, а + мутанта. Крутыша этого мира. +

+

Вообще, с тех пор как я узнал об этих мутантах, всегда мечтал встретиться с ними. Скорее всего, у них + жизнь полна приключений, интриг и вражды. Ведь у разумных так было всегда! А у них должно быть гораздо + высшего уровня, чем возня простых людишек. То, что доктор прописал, отличное средство от скуки! +

+

Но что обиднее всего, никак не получалось окунуться в их мирок. Очень уж малочисленными оказались, + жучары. Только по телику удавалось посмотреть на них или, в редких случаях, на результаты их работы + вживую. Но сегодня наконец повезло, и я встретил их представителя. И если тот волосатик не врал, то + скоро познакомлюсь с еще одним сверхом, а дальше уже можно будет по-тихому влиться в их общество. +

+

Похихикав над своими коварными мыслями, я с удовольствием впился в офигенно вкусное пирожное, и, плюнув + на напряжные размышления, с головой погрузился в сладкую нирвану. +

+

Ведь что бы там не получилось, все покажет будущий день. А мысли и размышления сегодня никак в этом не + помогут... +

+ +
+ + <p>Примечание к части</p> + +

Заранее хотелось бы поставить стенку перед тапками, которые могут полететь от читателей, возмущенных + аморальным поведением героя (Если, конечно, вы посчитали его аморальным). Я просто постарался + представить себе существо, которое прожило немало жизней, и по всему получается, что его вряд ли + будут заботить общепринятые моральные нормы. А вообще, что я тут распинаюсь? Гг мой, фанф мой, творю + что хочу. +
+ И да, как вы наверное уже заметили, гг не ознакомлен с каноном и миром Марвел в целом. +
+
+ Спасибо всем, кто читает это и дошел до этого места!)))) +
+
+ Примечания в тексте: +
+ 1. ЦУ - Ценные Указания +

+
+ > +
+
+ + <p>Глава 3</p> + +

Подкинутая вверх монетка сделала ровно три оборота и упала на ладонь точно той же стороной. И через + мгновение отправилась обратно в полет. +

+

Господи, как же скучно! Ожидание мутанта-волосатика и его загадочного друга съедало все мои нервы и + погружало в пучину отчаянной скуки. И в то же время, делать что-либо было жутко лень. Даже лезть в + карманы прохожих и позаимствовать пару сотен зеленых казалось непосильной задачей для моей тушки. +

+

Вот и стоял я, тупо глядя то в полноводную реку, то на прохожих и с грустным лицом подкидывая монетку. + Может, и вправду пойти в клуб игровых автоматов и поиграть там во всякие безделушки? Хотя нет, не + получится. Ведь тогда волосатик может придти и, не найдя меня, уйти восвояси. А учитывая вездесущий + закон подлости, особенно в отношении меня, так оно и случится. +

+

Еще раз грустно вздохнув на судьбу-злодейку, я в очередной раз подкинул монетку вверх, но уже не так + ловко ее поймал, поскольку именно в этот момент, почувствовал ментальный щуп, который не очень-то и + аккуратно вторгся в верхние слои моего разума. +

+

Силой воли выкинув незваного гостя, я еле подавил в себе желание атаковать в ответ. Я ведь обещал себе не + использовать магию. Используя лишь силу своего разума, не подпитываясь из магического источника, было бы + очень трудно атаковать другого человека. Даже простого. А менталиста тем более. Но вот если он сам + создаст канал... +

+

Выбросив лишние мысли и ускорив поток ци в теле, я неспешно развернулся в сторону, откуда почувствовал + воздействие. Интуиция пока молчала, так что я не спешил атаковать или предпринимать еще какие-либо + действия в отношении лысого телепата, которого на коляске подкатывал уже знакомый волосатик. +

+

— Знаешь, старик, не очень-то и вежливо с ходу лезть в головы незнакомых людей...– от моего спокойного, + но холодного тона он виновато рассмеялся. +

+

— Прошу прощения, юноша, привычка уже, из за специфической способности. Обычно никто не замечает + воздействия, а узнать мысли и цели разумного может быть полезно перед знакомством.– хоть он и держал на + лице виноватую улыбку, но его осторожные попытки обойти мой щит рубили на корню всю его искренность. +

+

Повернувшись к своему старому знакомому, я обратился уже к нему.

+

— Если этот вредный старикан не перестанет пытаться пролезть мне в голову, я его убью.

+

Напряженно рассмеявшись, лысый примирительно поднял руки.

+

— Все, прости-прости. Больше не буду. Ты тоже телепат по сверхспособностям?

+

Приподняв гордо нос, я, фыркнув с полным превосходством в голосе, выдал:

+

— Я не просто там телепат какой-то, я сверхчеловек!

+

После долгих размышлений, я решил именно так сформулировать свои способности для посторонних. + Многогранно, расплывчато и можно не ограничивать себя одной специализацией. +

+

Вообще, благодаря ци и полному контролю своего организма, я и так был сильнее простого человека в + несколько раз. А при накачке тела энергией ци, усиливался еще больше, но уже только на определенное + время. Пока не кончится накопленная жизненная энергия ци и/или не иссякнут ресурсы организма. +

+

— И в чем именно выражаются твои способности? – с интересом спросил телепат.

+

— Я просто-напросто круче любого человека во всем! – гордо приподняв нос, выдал я.

+

— Так уж во всем? – с хитрой улыбкой спросил он.

+

— Во всем!– категорично повторил я.

+

Еще немного с улыбкой поглядев на меня, он весело рассмеялся.

+

— Эх, ладно молодой человек, так уж и быть. Я не знаю пока насколько сильны твои способности, но думаю + одно то, что ты не пустил меня себе в голову и умудрился украсть у Логана бумажник, стоит того, чтобы на + тебя обратили внимание. Меня зовут Чарльз Ксавьер, но можешь звать профессором. Я основатель Института + для "Одаренных подростков" или, если точнее и по факту, для мутантов. Мы стараемся помочь своим + неопытным братьям научиться контролировать свои силы. Даем им пищу, кров и защиту от внешнего мира. А + главное, семью и чувство плеча, – простые, казалось бы слова, из его уст выходили с особым шармом, + хотелось ему верить и следовать за ними. — Если ты хочешь, можешь своими глазами посмотреть наш дом. И + кто знает, может быть, он и для тебя в будущем станет родным гнездышком. +

+

— Ух, я аж захотел сразу к вам переехать. – с придыханием и ослепительной улыбкой протянул я. Потом + презрительно фыркнув, продолжил уже независимым тоном. — Такого ожидали? Пф! Я и так неплохо устроился в + этой жизни. Есть стабильная работа, крыша над головой, а от тех, кто меня достает, я и сам могу + защититься. Так что все мимо! Но я подумаю и да, не против посмотреть ваш дом. Все же мне и вправду + интересно поглядеть на других себе подобных. +

+

Смотря на понимающе переглянувшихся старших мутантов какого-то там института, я внутренне усмехнулся. Уж + лучше пусть думают, что у меня юношеское свободолюбие и максимализм, чем начнут подозревать во мне не + совсем "обычного" мальчика. +

+

С улыбкой кивнув головой на мое высказывание, профессор задал очередной вопрос, который следовало бы + задать гораздо раньше. +

+

— И как тебя зовут, друг мой?

+

— Я уж думал, что вы никогда не спросите...– поворчал я ради приличия. — Зовут Том, Томас Уэйн.

+

Потом, повернув голову, в ожидании уставился на лохматого. Ведь я все еще так и не узнал, как его + зовут. +

+

Закатив глаза, он устало бросил:

+

— Зови меня Логан. – видя мое все еще ожидающее лицо, он раздраженно добавил. — Просто Логан.

+

— Окей, будешь лохматым.

+

Возмущенно вскинувшись, он собирался что-то сказать, но на миг замер, и потом уже с куда более + кровожадным лицом надвинулся в мою сторону. +

+

— Шкет, где мой бумажник?!

+

— Ммаа...Хотя знаешь, думаю Логан прекрасное имя! А твой бумажник...бумажник..мне-то откуда знать?! Не + знаю я ничего. И вообще, давайте побыстрее посмотрим вашу школу, а то мне еще до заката дома нужно быть. + Мама там будет волноваться и все такое... Так что давайте, ходу-ходу отсюда! +

+

Текучим движением обойдя злющего лохматика, я взялся за коляску профа и покатил его вперед.

+

— Томми, наша машина в обратной стороне. –со смехом сказал мне Ксавьер, когда я полным ходом отдалялся от + Логана. +

+

— Да? Так бы и сказали!

+

Резкий разворот, испуганный мат/возглас от профессора и мы "покатились" в обратном направлении.

+

***

+

Силуэт загородного особняка я рассматривал прямо-таки с неописуемой радостью.

+

Всю дорогу этот приставучий профессор словно клещ вытягивал из меня факты о моей жизни. С кем живу, где + отец, что люблю, когда узнал о своих силах, с кем дружу, в какую школу хожу и тому подобное. +

+

Самым возмутившим его фактом из моей жизни было не то, что я занимаюсь клептоманством. А то, что я не + хожу в школу. Я сказал, что мне там неинтересно и я слишком крут, чтобы учиться там с простыми людьми. + Ну не объяснять же ему, что учебные заведения у меня уже в печенке сидят и где-где, а в обычной школе + мне вряд ли смогут дать новых знаний. +

+

Вот так и сидя с затравленным видом и вяло отбиваясь от его советов и нотаций, я доехал до их школы, + которая с виду напоминала простой дворец/особняк богача, стилизованный под Средневековье. +

+

Пулей вылетев из остановившейся машины, я облегченно вздохнул.

+

— Профессор, я искренне надеюсь, что тут не все уроки преподаете вы. А то это же... Просто полный капут и + вынос мозга! – видя, что он хочет начать еще один свой монолог, я поспешно замахал рукой. — Профессор, + профессор! Давайте быстренько проведем экскурсию. Время-то тикает, тик-так. Уже скоро и солнышку домой + будет пора. И мне вместе с ним. +

+

Устало вздохнув, профессор посмотрел в сторону лохматого. Но тот, закатив глаза, сразу отрицательно + качнул головой. И бурча что-то, про то, что не подписывался нянчить любопытных карманников, пошел ко + входу в особняк. Не отчаявшись, телепат, обведя взглядом округу, подозвал проходящего мимо молодого + парня в очках. +

+

— Скотт! Удели мне толику своего внимания, пожалуйста. Не мог бы ты показать нашему возможному новому + ученику школу? +

+

— Возможному?– удивленно спросил парень, но потом, пару секунд поиграв в гляделки с профом, и по ходу + ментально получив все нужные инструкции, спокойно кивнул головой. +

+

Когда я уже собирался идти следом за парнем, профессор окликнул меня обратно.

+

— Том, оставь мне, пожалуйста, один волосок. Мы сделаем генетическую экспертизу и, может быть, + разберемся, что именно улучшил твой ген Х. Да и это в целом поможет продвижению общих исследований. +

+

Подумав несколько секунд и в итоге так и не придумав достойной причины ему отказать, я со вздохом + выдернул один волосок со своей светлой шевелюры и торжественно вручил профессору. +

+

— Не потеряйте, он дорог мне как память.

+

Не став дальше следить за его реакцией, я потопал следом за ...как там его...а, да, Скоттом.

+

В том, что в моем организме нет их гена Х, я был уверен на сто процентов. Уж обследовать себя и удалить + ненужное я успел еще в первые годы своей жизни. Было бы странно, если бы у бывшего архимага жизни было + по-другому . +

+

Но и тот факт, что я превосходил обычного человека, как гоночный болид простой кар, был неопровержим. + Было даже интересно, что они решат по моему вопросу, если обнаружат абсолютно нормальное ДНК. +

+

Да и я ведь уже знаю штаб-квартиру самого большого скопления мутантов в ближайшей округе. Так что, мне + было не особо интересно, передумают ли они приглашать меня в свои ряды. План-минимум был выполнен. +

+

Покивав своим мыслям, я с интересом начал слушать своего экскурсовода, который, проходя по разным залам, + объяснял их предназначение, и иногда, если встречали, знакомил с жителями особняка. +

+

Мне даже удалось краем глаза увидеть тренировки по контролю одного из самых крутых мутантов школы (ну, по + словам Скотта). Хотя, если судить по его словам, то Джинн была идеалом. +

+

Молодая девушка с огненно-рыжими волосами, сидя в позе лотоса в большом зале, телекинезом двигала сотни + разных вещей. От маленьких книг и сумочек до двухметровых шкафов и тяжеленных гантелей. +

+

Хм, реально круто. Минимум, уровень мастера магии, если классифицировать по уровням Эдема(1). Самого + масштабного мира, в котором я побывал. Но что-то мне подсказывает, что для нее это не было пределом. +

+

Цокнув языком и пародируя Вальтонских торговцев(2), я воскликнул:

+

— Вах, как она хороша! Словно цветущий пустынный цвэток, прекрасна, э! – потом натянув на лицо ехидную + улыбочку, я заговорщицки похлопал его по плечу. — Если ты ее упустишь, пеняй на себя. Я заберу ее к + себе. +

+

Не оглядываясь на охреневшего парня, я пошел дальше по корпусу. Наверное для него было странно слышать + такое от шкета ростом метр с кепкой, хе-хе. Но я то был серьезен... +

+

Текучим движением обойдя неожиданно выскочившую из-за угла девушку, я недовольно закатил глаза. Поскольку + эта егоза не только чуть не сбила меня, но и, испугавшись меня, едва сама не упала. Цапнув ее за руку в + последнее мгновение и притянув к себе, я недоуменно замер. Энергия жизни – Ци, ментальная энергия и даже + запечатанная магическая сила стали бешено утекать. После секундного промедления, взяв всю свою + энергетику под контроль и силой не давая ей утекать, я удивленно выдохнул. +

+

Что за феноменальный энергетический вампир?! За секунду высосала объем, равный половине мастера магии. +

+

Ойкнув, она смертельно побледнела и с силой вырвала свою руку. Попутно своим замахом высвободив четверть + украденной энергии: голой неоформленной волной силы выбила все окна в коридоре и вместе с ними - бедного + Скота. +

+

Удивленно оглядевшись и, с еще большим удивлением на грани шока, оглядев меня, она потерянно спросила: +

+

— Т..ты...ты в порядк-к-ке?

+

А в это время я с приятным удивлением рассматривал девушку. Оказывается, в этом мире тоже есть + интересные...существа. Похоже, и вправду будет не так скучно... +

+ +
+ + <p>Примечание к части</p> + +

!!!! Возможно текст будет изменен, собираюсь поискать бету что бы покрасивее оформила текст!!!! +
+ [Upd: Найдена классная бета и уже принялась исправлять мои крокозябры =)] +
+ Хей-хей, очередная прода, которую выкладываю с жару с пылу. Прошу прощения у немногочисленных + читателей за столь долгую задержку. Были проблемы в реале, которые заняли много времнеи. А с + вдохновением, что удивительно, никаких! Написал главу за полтора дня. +
+ И да, прошу учесть что версия текста далеко не последняя, до неузнаваемости конечно не изменится, но + будут большие отличия в стилистике. А сейчас, пишу пока есть вдохновение, что бы успеть до окончания + действия сего неожиданного бафа Х) +
+ Редактуру всего текста буду производить холодными днями без музы, когда что-то делать надо, а писать + нормально не получается Х) +
+ Спасибо всем за внимание, всем удачки. +
+ Пишите отзывы и ставьте лайки, это дико мотивирует! Особенно когда их раз-два и обчелся, пхпхп)))) +
+ Примечания в тексте: +
+ 1-2, данные термины не касаются определенного фэндома и придуманы самим автором, так что не + старайтесь их искать, пх) +
+

+
+ > +
+
+ + <p>Интерлюдия</p> + +

— Чарльз, повторный анализ ничего не дал. Экспертиза все так же показывает полное отсутствие генетических + отклонений и следов мутаций. Но...– остановившись и подумав пару секунд, продолжил Хэнк. — Строение его + волоска, оставленного нам, отличается по структуре от стандартного. Гораздо крепче и плотнее любых + человеческих... Но никаких изменений в ДНК. Такое чувство, что он каким то образом смог натренировать + свои волосы! Натренировать! Волосы! +

+

Его голос к концу уже полностью был наполнен возмущением. И было непонятно, что его возмущает больше: + отсутствие генома Х в ДНК паренька или же то, что он не может понять природу таких изменений. +

+

— Хэнк, тебе не кажется, что изменения в его организме носят искусственный характер? – слова до сих пор + молчавшего профессора породили в голове его соратника еще больше вопросов. +

+

— Имеешь ввиду как у Логана? Но ведь... ведь он еще слишком юн! Или...или может быть, что его еще до + рождения подвергли экспериментам?! – эмоционально воскликнул он, схватившись за новую теорию и + лихорадочно шагая по лаборатории. — И вправду, ведь еще со времен второй мировой войны правительства + многих государств хотели получить себе в услужение суперсолдат. И у американцев даже получилось. Стивен + Роджерс - Капитан Америка. Хоть его и потеряли, а вместе с ним и формулу суперсолдата, но, может быть... + Может быть специальные отделы опять взялись за разработку!? +

+

— Это все лишь беспочвенные предположения, Хэнк. Нужно изучить и провести обследования всего его тела. + Думаю, тогда получится понять и быть уверенным, является ли он результатом эксперимента или же еще что. + А то со своими теориями ты скоро до мирового заговора дойдешь, – улыбнувшись, смотря на закатившего + глаза друга, глава школы продолжил. — Знаешь, если бы он был результатом эксперимента, то за ним скорее + всего наблюдали бы. И вряд ли позволили бы жить в трущобах и промышлять воровством. Есть, конечно, еще + вариант того, что его специально хотят внедрить к нам. Но... я не хочу в это верить. Просто не хочу. + Верить в то, что мир оскотился настолько, чтобы использовать детей в своих грязных играх... – устало + откинувшись на спинку своей инвалидной коляски, Ксавьер с силой помассировал виски. И немножко помолчав, + будто собираясь с мыслями, продолжил: — Да и вчера я отправил Гамбита, чтобы он проследил за ним до его + дома. И мальчик не врал. Он и вправду живет с матерью в не самых благополучных кварталах Бруклина. И + частичный опрос жителей подтвердил, что он живет там с детства. Так что есть надежда, что военные тут не + причем. Да и ты видел, как он легко перенес касание Шельмы? Она вытянула из него немало жизненных сил и + энергии. Ты и сам видел результаты выброса, а ему хоть бы хны. Руж после еще сказала, что он смог + каким-то образом заблокировать ее силу и последние мгновения она даже не вытягивала из него силы. + Внушает, не правда ли? После того, как я услышал это, мне вспомнились совсем ненаучные рассказы о + колдунах и магах. Помнишь мутанта-девушку из Европы, которая управляла Алой энергией? Ее еще прозвали + Алой Ведьмой. Мы пытались найти ее, но потерпели неудачу. Может, он тоже уникум вроде нее? Ведь мы так и + не смогли подтвердить мутант она или нет. А слухи о ордене магов в Тибете и богах скандинавской + мифологии, которые иногда посещают нашу землю, заставляют задуматься о существовании сил, неведомых и + необъяснимых наукой. +

+

— Ох Чарльз, ну и умеешь ты нагружать человека так, что потом хочется выть на луну. – спустя пару минут + молчания воскликнул Зверь, с раздражением взъерошив свою шерстку на голове. — Кстати, а почему ты просто + не узнал все о нем из его же разума? Я, конечно, понимаю, что ты уважаешь чужое сознание и не станешь + лезть просто так, – в этот момент, столь уважаемый своим другом, профессор смущенно отвел взгляд. — Но в + этот раз-то повод стоящий. И непосредственно касается безопасности студентов. +

+

— Я не смог незаметно прочитать его мысли и разум через выставленную им защиту. – улыбнулся телепат, + смотря на удивленного друга. — Это еще один интересный факт, который путает все карты. Он представитель + тех самых загадочных магов, выдающийся результат военной науки или же представитель...новой расы? +

+

— Да не, это уже кажется бредом. – с тонной сомнение в голосе высказался Хэнк, потом чуть подумав начал + перечислять. — Так что нам о нем известно? Он превосходит по ловкости обычного человека, а на словах + заявляет что и по другим параметрам тоже заметно превосходит стандарты... Умеет чувствовать воздействие + на разум и сопротивляться ему. И самое главное, в его ДНК отсутствуют мутировавшие гены. Тц, как же все + с ним запутанно. В общем, похоже и вправду придется ждать результаты обследования его тела. +

+

— Осталась самая малость - убедить его в том, что обследование необходимо. Эх, что-то мне подсказывает, + что это будет не самой легкой задачей... – сокрушенно вздохнув, Ксавьер задумчиво повернулся в сторону + окна, откуда выглядывала полная луна. +

+

***

+

Оторвав взгляд от полной луны, которая выглядывала из окна, Стив со вздохом опять взялся за бухгалтерские + отчеты. +

+

Хоть и воровская гильдия Нью-Йорка была совсем незаконной организацией, она, так же, как и любая другая + серьезная организация, требовала учета всех доходов и расходов. Чтобы не нашлись слишком умные, которые + решат сунуть руку в общую казну. Хоть в организации и состояли одни воры и иногда даже убийцы, но + воровавших у себя они не терпели. Вот и приходилось особо доверенным лицам глав гильдий мучиться + подсчетами капающих денег и ценностей от членов организаций, в которую входили как элитные воры, так и + считающиеся заштатными мелкие карманники. +

+

Вот и сидел старый вор, давно ушедший на покой, такой прекрасной ночью считая деньги, накопившиеся за + неделю от мелких карманников Бруклина. А ведь еще оставались мелкие Манхеттена... +

+

— Билл, походу пацаненкам из района Бруклинских мостов последние месяцы жутко везет, да? Уже который + месяц меньше всех попадаются полиции, а доходы от них выросли почти в два раза. Да они выдают сумму не + меньшую, чем четверть всего остального района!! – сверившись с таблицей, удивленно воскликнул Стив. +

+

Рассмеявшись, его помощник и когда-то ученик, весело поправил:

+

— Не весь район Бруклинского моста. А один новенький паренек. Ловкий до чёрта. В день выдает примерно по + триста долларов, и это только проценты, которые полагаются гильдии! За прошлый месяц он в одно рыло + принес деньги и ценности примерно на двенадцать тысяч долларов! И просто работая карманником! – с + гордостью, словно тот самый парень его собственный сын, нахваливал Билл. +

+

— А не пробовали его позвать в постоянные члены гильдии и сделать настоящим вором? – с любопытством + спросил старый вор, задумчиво поглаживая свою поседевшую щетину. +

+

— Так парень-то совсем мелкий еще, всего двенадцать-тринадцать лет. – огорченно развел руки его ученик. +

+

— То есть в таком возрасте и с таким проворством?! - уже с толикой восхищения протянул Стив. — Он + работает в паре с каким-то старшим карманником или у него есть связи в гильдии? Как он с такими доходами + еще свободным ходит? Обычно таких мелких берут под крыло и все деньги отбирают более старшие. А этот, + смотри-ка, сам работает и сам приносит деньги. +

+

— Еще один интересный факт о мальце. Он реально работает один! Если не считать его дружбу с наркодилером + того района. Хотя тот дилер и сам мелкая сошка, и на нашу гильдию влияния не имеет и вовсе. Пару раз его + пытались зажать старшие, так он их всех взял и раскидал как щенят. А паре старших, которые напали на + него с заточками, он бошки проломил. – с довольством рассказывал он. И, предвещая вопрос учителя, + продолжил. — Неизвестно мутант ли он, но особых навыков или возможностей, которые были бы непосильны + обычному человеку, он не имеет. По нашим данным. Только офигенную реакцию и ловкость рук. +

+

— Семья?

+

— Из семьи только мама, которую прикрывает Мерзавец Джо. Она из его девочек. Так что мелким сошкам + гильдии нечем на него надавить. А старшим он пока не интересен. +

+

Задумчиво дослушав речь своего бывшего ученика, который недавно тоже решил выйти на покой, он медленно + высказал витавшую в голове идею: +

+

— Может, взять себе его в ученики...

+

— Зачем тебе, старый, из тебя уже песок сыплется. – с улыбкой подначил его ученик. — Да и после моего + обучения тридцать лет назад, ты, вроде, плевался и слушать больше не хотел об учениках. +

+

— Так ведь сколько времени прошло. Да и мне до жути надоела эта бумажная работа. Уже лет десять тут сижу, + мочи нет! А сейчас как раз и замена есть для бумажных работ, благо ты сам вызвался. Поэтому я могу с + чистой совестью свалить и завести себе нового ученика. +

+

— Ты что старый! Я же только из-за тебя вызвался сюда, думал, буду сидеть на непыльном месте и вспоминать + с тобой старые годки, да попивать виски! А тут... – начал жаловаться Билл. — Почти каждый божий день + писать отчеты. Считать денежки от мелких раздолбаев! А теперь ты еще хочешь свалить все это на одного + меня?! +

+

Каркающе рассмеявшись, старый вор утер заслезившиеся от смеха глаза.

+

— Просто понимаешь, Билл, я чувствую, что скоро и эта работа мне будет не по плечу. Уже сейчас приходится + перепроверять свои же отсчеты пару раз, чтобы исправить ошибки, которые с каждым годом появляются все + чаще и чаще. А воровская гильдия - это место, которое стало для меня всем в этой жизни. И семьей, и + работой. Она для меня как смысл жизни, и я просто не представляю себя вдали от нее. Так что я хочу хоть + на немного задержаться тут и заодно оставить напоследок еще одного талантливого вора после себя, который + продолжит держать мое имя на слуху даже после моей смерти. А то ты, гаденыш, как-то слишком рано завязал + с ремеслом. – смотря на недовольный взгляд учителя, Билл, словно сопливый мальчишка, всплеснул руками и + начал оправдываться. +

+

— Старик, имей совесть! Я пообещал жене и дочке завязать с этим. А после того, как год назад меня чуть не + пристрелили, жена такой скандал подняла с битьем посуды, что пришлось убегать от нее, выпрыгивая из + окна. Третьего этажа! А вообще, это ты виноват! Каждый раз капал мне на мозги, говоря не повторять твоих + ошибок и жениться скорее, пока не стало поздно. +

+

Еще раз рассмеявшись, Стивен примиряюще замахал руками.

+

— Ладно-ладно, не кипятись, я же не обвиняю тебя. Да точно говорю, не обвиняю! – громче добавил он, + перебивая возмущенно открывшего рот ученика. — Просто привел этот факт в качестве довода, чтобы завести + себе нового ученика. Так что успокойся уже и давай доделывать работу, а то крошка Джинни меня не + простит, если я сегодня опять тебя задержу допоздна. +

+

С улыбкой вспомнив дочку своего ученика, которая стала для него словно внучкой, старый вор еще больше + преисполнился решимостью забрать себе мальчика, если он и вправду будет хоть на половину хорош, как о + нем рассказывают. А то бытье одиночки становилось невыносимым... +

+ +
+ + <p>Примечание к части</p> + +

Уф, вот и еще одна часть, хоть и по сути переходная, но нужная. И кстати, хотелось бы предупредить + любителей вселенной Марвел, о том что надвигается тотальный AU! Пхпх +
+ Я не хотел, но, честно говоря, заебался, после того как потратил несколько часов на более + углубленное изучение вселенной. Там разные серии комиксов об одном герое и каждая киновселенная + противоречит друг другу. Так что буду лепить своего Франкенштейна из разных кусков, разного канона) +
+ Спасибо всем читателям и удачи на выходных!))) +
+ P.s. Я до сих пор жду отзывов х) +

+
+ > +
+
+ + <p>Глава 4</p> + +

Пересчитав добытые за сегодня деньги, я довольно вздохнул.

+

Деньги на хлеб с маслом есть. Да еще на хлеб и масло самых высших сортов! Уж очень оказался сегодня + жирным улов, даже пришлось паре усталых прохожих незаметно вернуть их кошелки. Слишком большая для + карманника сумма была у них. И, скорее всего, они несли свою зарплату. +

+

Я, конечно, совсем не местный Робин-Гуд, но мне и так хватало денег с лихвой. Да еще настроение было + благодушным, так что решил не расстраивать работяг и отбирать честно добытое. +

+

Сегодня улица вообще была необычайно оживленна и полна всяких интересных событий. Пару раз даже пришлось + с веселой улыбкой провожать копов под прикрытием, которые старались словить бесчинствующих преступников + на живца. Открыто и нарочно доступно носящие свои ценности. Но, слава богу, я не попался в их ловушку... +

+

Ну почти...

+

Несмотря на тревожный звон интуиции, я в своей обычной манере вытащил у одной из этих подсадных уток + кошелек, который спустя мгновение уже начал орать благим матом сирены. +

+

На максимальной и даже запредельной от неожиданности скорости подсунув кошелек мимо проходящему парню, я + вместе со всеми удивленно посмотрел на него. Из карманов его куртки уже звучала неприятная мелодия. +

+

Бедного парня забрали в участок, а я отделался легким испугом.

+

Вот так я и узнал, что вдобавок к установленным камерам, они начали ловить на живца.

+

После этого обокрасть копов и при этом не попасться стало своеобразным делом принципа, которое я исполнял + целый день, обокрав пятерых блюстителей закона и при этом не потревожив их пищащую сигнализацию. +

+

А вот некоторым моим соратникам не так сильно повезло и в этот день троих из них забрали в участок. Было + ясно, что их вытащат после отсчета некоторой суммы наверх. Все знают, как "хорошо" копы нашего района + работают. Но в то же время было ясно, что ближайший месяц этим пацанам придется работать задаром, чтобы + вернуть должок перед гильдией. +

+

Но это не мои проблемы. Сами дураки, и я тут ни при чем.

+

Довольно кивнув своим таким приятным и эгоистичным мыслям, я неспешно зашел в ломбард к своему связному. + Надо было как обычно поделиться некоторым количеством нечестно добытого. +

+

Но в этот раз тщедушный представитель гильдий был не один, и рядом с ним, на стуле, сидел седовласый + старик с благодушным лицом любимого дедушки. +

+

— О, Том, как раз тебя мы и ждали!

+

— Да? Мне это уже не нравится. - доверительно поделился я с радостным владельцем ломбарда. И переведя + взгляд на старика, который изучающе рассматривал мою фигуру, задумчиво обронил. — Похоже, радостное + ожидание мистера Грю напрямую связано с вами, мистер...? +

+

— Стивен Роу, бывший мастер-вор гильдий воров Нью-Йорка. Рад с тобой познакомиться вживую, юноша. - по + его доброжелательной улыбке я сразу понял, что ему от меня что-то нужно. Подозрительно нахмурившись, я в + голове постарался вспомнить, где и как мог не угодить или перейти дорогу гильдии. +

+

— А я пока что не уверен, рад ли я, Мистер Роу. Поскольку, пока что не знаю, зачем вы меня искали.

+

— Не стоит так подозрительно косится на меня, Томми. Даже если ты в чем-то виноват перед гильдией, я не + за этим. - со старческим скрипом рассмеялся он. — Просто до меня дошли некоторые слухи, утверждающие, + что ты довольно хорош и можешь дать фору многим профессионалам. Вот я и заинтересовался таким способным + малым. +

+

— Хм, и что будет, если окажется, что люди не врут? - по-птичьи склонив голову набок, уже с интересом + спросил я. +

+

— Ну, в таком случае я собирался предложить тебе ученичество у меня. - со значением сказал он.

+

Подождав пару секунд, так и не дождавшись продолжения, я лениво бросил.

+

— Не интересует. - с удовольствием поглядев на его удивленное лицо, я взял из своего рюкзачка добытые за + сегодня портмоне и кредитки и поставил их на стойку перед окошком. И покопавшись в том же рюкзачке, + отсчитал и выложил перед Грю 430 долларов. — 30% от сегодняшнего дохода, день выдался удачным. +

+

Больше не сказав ни слова и бросив последний взгляд на старика, я разочарованно пошел к выходу. А я + надеялся, что будет еще что-то интересное, ведь не мог же столь интересный день скатится в скукоту к + вечеру? Или мог? Может я все "очки интересности" уже потратил? +

+

Мои наркоманские размышления прервали тихие, почти беззвучные шаги старикана. А он не врал. И вправду + что-то умеет. +

+

— Томми, ты уронил. - с хитрой улыбкой протянул он мне ножик-бабочку, который только что незаметно (и, + опять же, почти что незаметно) вытащил из моих карманов. Хех, а ведь в эту игру можно играть и вдвоем. +

+

— Спасибо мистер Роу! Я как раз тоже нашел тут на полу сигары, случаем не ваши?

+

Что поделать, у меня была только одна попытка, в итоге которой получилось вытащить лишь их. Но даже так я + был доволен эффектом. +

+

С поблекшей улыбкой он тоже принял свои вещи обратно. А чего он ждал-то, в одной из своих жизней мне тоже + доводилось дорасти до уровня мастера-вора. +

+

Так что, хоть у него и были преимущества в своем родном мире во многих других аспектах воровского дела + вроде взлома или же обхода сигнализаций, но именно в искусстве лезть в чужие карманы я мог с ним + поспорить. +

+

А учитывая молодость и мой прокаченный организм, преимущества были на моей стороне. Так что даже + неудивительно, что несмотря на весь свой опыт, он не успел почувствовать мое вторжение. +

+

С радостно-дебильной улыбкой кивнув ему, я опять пошел в сторону выхода. И во второй раз он догнал меня + уже на улице. +

+

— Том, подожди! Ох, мои старые кости уже не так подвижны как в молодости. - догнав меня, фальшиво + пожаловался старый вор. — Кстати, ты оказался даже гораздо лучше, чем я надеялся. Не передумал идти ко + мне в ученики? +

+

— Не-а. - пофигистично ответил я с весельем в душе.

+

— Да, признаюсь, ты меня уделал перед входом и, наверное, теперь думаешь, чему меня может обучить тот, + который... +

+

— Мистер Роу. - со вздохом перебил его я. — Если вы помните, я отказал вам еще до этого инцидента.

+

— И почему же тогда? - с непритворным интересом спросил старый вор.

+

— Не вижу смысла просто. Зачем мне это?

+

— Но...но ведь после ученичества ты сможешь стать профессиональным вором, получать заказы от гильдий. + Получать помощь с ее стороны при траблах с полицией или еще чего... +

+

— Хм, а зачем мне все это?

+

— Ты не собираешься становится вором?! - уже с удивлением на грани ужаса спросил Стивен. — У тебя же + такой талант! Нельзя губить его! +

+

От души рассмеявшись, я вытер заслезившиеся глаза.

+

— Мистер Роу. Мне все это просто неинтересно. Я занимаюсь воровством просто потому, что это легко и + ненапряжно. - в этот момент у опытного вора глаза чуть было на лоб не полезли. — А в будущем...да фиг + его знает, будущее. Я живу сегодняшним днем. Мне сейчас это интересно, поэтому я этим и занимаюсь. + Надоест, брошу и начну что-то другое... +

+

Безмолвно хлопая ртом, Роу напоминал рыбу, выброшенную на берег.

+

— То есть, у тебя нет никаких амбиций? Желания достичь чего-то большего? - от недоумения и непонимания, + голос старика даже дрогнул. — Ты ведь сын...куртизанки и неизвестно кого! Неужели у тебя нет желания... + заставить этот мир уважать себя?!- как будто утопающий, хватающийся за последнюю соломинку, воскликнул + бывший вор, всплеснув руками. +

+

— Эй-эй, я попрошу без оскорблений! - деланно-возмущенно воскликнул я, внутри начиная опять хохотать. — А + что обо мне думают окружающие и вообще весь этот гребаный мир - мне насрать. Как и на амбиции. Зачем мне + взбираться куда-то высоко, когда я и внизу неплохо поживаю? Лишние деньги? Есть. Вкусная еда? Есть. Да + блин, у меня даже нормальная крыша над головой есть. Зачем мне лишние напряги? Если я вступлю в вашу + секту полностью, то мне ведь придется иногда ходить на персональные задания от глав гильдии. А сейчас я + свободный игрок и могу делать что хочу. Хочу - работаю с некоторой комиссией, не хочу - гуляю где хочу. + Сказка, а не жизнь! +

+

Немного подумав, старик осторожно спросил: — А что будешь делать, если гильдия запретит тебе работать на + улицах? +

+

Недовольно посмотрев старика, я презрительно фыркнул.

+

— Если гильдия запретит, больше на ее территориях работать не буду. Но в среде домушников города, + возможно, появится еще один вольный стрелок. Если я захочу, конечно. +

+

— Не боишься заявлять, хоть и бывшему, но члену гильдии, что собираешься нарушать законы его + сообщества? +

+

— Я просто прекрасно знаю, что гильдия контролирует не всех воров города и будет легко затеряться в их + среде. - беспечно пожал я плечами. +

+

— А ведь после твоих слов гильдия может и прижать тебя при случае несанкционированной кражи.

+

— А вы сначала докажите!

+

— Гильдии не всегда требуются доказательства, чтобы прижать наглого мальца. - ровным голосом, но не с + двусмысленным прищуром оборонил старик. +

+

Устало вздохнув, я перевел на него насмешливый взгляд.

+

— Если все сказанные вами "если" и предположительные события исполнятся, тогда и посмотрите на результат + таких...необдуманных шагов. +

+

По крайней мере... будет интересно, не правда ли?

+

Поиграв со мной немного в гляделки, он со вздохом отвел взгляд.

+

— Какой же ты все-таки еще максималистичный мальчишка... - не-а старик, просто древний и заскучавший + монстр. +

+

— Ладно, Томми, не злись, все это лишь безобидная демагогия и маловероятные исходы. Просто хотел узнать + про тебя побольше. +

+

— Могли бы просто спросить. - с улыбкой пожурил его я, все так же неспешно шагая в сторону остановки.

+

— Кхм, в принципе хорошая идея. - с неловким кашлем и улыбкой кивнул Роу. — Хм... Чем ты увлекаешься + Томми? +

+

Немного помолчав из-за неожиданного вопроса, я задумчиво начал:

+

— Честно говоря, Мистер Роу, я до этого особо и не задумывался. У меня было много интересов, но сейчас, + уже ко всему интерес угас. Наверное...Наверное поэтому, мне интересны интересности. - с улыбкой хмыкнул + я своей тавтологии. — Все, что необычно и поможет одолеть скуку. +

+

— Так ведь работа профессионального вора полна разных интересностей! - опять завел свою шарманку старый + вор и, видя мои закатывающиеся глаза, поспешно продолжил. —Нет, реально, ты сам посуди! Много интересных + задач, которые могут потребовать нестандартных решений. Необычные и иногда даже мистические вещички, + которые придется украсть из логова самых опасных преступников или самых богатых богачей! А если не + удастся, бежать от недругов со всей мочи. Перестрелки, игры в прятки, разные ловушки... +

+

Прерывистое дыхание и горящие глаза ясно показывали насколько сам старик скучает по всему этому. И его + эмоции волей-неволей передались и мне, и я начал делать то, что не делал уже давно. Вспоминать. + Вспоминать свои прошлые жизни. А именно несколько перерождений, в которых я решил стать вором. И он, по + сути, был прав. Жизнь и задачи у вора были очень насыщенными. А нестандартные решения требовались сплошь + и рядом. +

+

Я даже вспомнил, как однажды мне пришлось в добытой охотниками мертвой туше слона пробираться в деревню + диких пигмеев, у которых надо было спереть какую-то ценную реликвию. +

+

С удовольствием погрузившись вместе с седым стариком в радостные воспоминания, я отчаянно вздохнул.

+

— Хотя знаешь, черт с тобой. Если обещаешь, что в будущем будешь помогать мне брать для себя только те + задания, которые я захочу сам и не будешь нагружать серой рутиной, я согласен идти к тебе в ученики. +

+

Весело рассмеявшись, старый вор хитро прищурился.

+

— Это вроде бы я беру тебя в ученики, а условия с чего-то ставишь ты.

+

— Дак где ты найдешь такого же крутого ученика как я, который может обвести вокруг пальца даже бывалого + вора? - гордо задрав нос, задорно улыбнулся я. Посмеявшись вместе со мной, он тепло улыбнулся. +

+

— Убедил, чертяга. Помогу я тебе. При условии, что ты будешь прилежным учеником. Только хотелось бы сразу + уточнить, ты мутант? +

+

Неожиданный вопрос невольно заставил приподнять удивленно бровь.

+

— Ну, некоторые моменты и завышенная самоуверенность как бы намекают. Я конечно допускаю, что, возможно, + я ошибся. Но хотелось бы все же уточнить. - с хитрой улыбкой спросил он. +

+

Не видя смысла что-либо скрывать от своего будущего "учителя", я спокойно кивнул.

+

— И...Что ты умеешь?

+

— Просто представьте себе очень хорошо подготовленного человека. Быстрого, сильного и с очень хорошей + реакцией. Допустим спецназовца. Представили? Теперь умножьте его возможности в два раза и получусь я. - + специально занизил свои возможности я. — Я просто супер-человек. +

+

Услышав сомнительный хмык от старого волка, я недовольно закатил глаза. И, оглядевшись вокруг, приметил + одну из железных труб каркаса автобусной остановки. Повторно устало вздохнув, я подошел к нему и сделал + быстрый удар. Рука неприятно засаднила, а на трубе остался заметная вмятина и полетела краска. +

+

—Сойдет? - лениво спросил я офигевшего старика. Получив потерянный кивок, с той же показной леньцой + расселся на остановке. +

+

— Даа, не ожидал, честно. Но раз ты и вправду имеешь такие...выдающиеся способности, думаю тебе будет + вполне по силам побить рекорд моего первого ученика и стать профессиональным вором раньше семнадцати + лет. +

+

Безразлично пожав плечами на его заявление, я с толикой интереса спросил:

+

— А когда начнем обучение?

+

— Тебе нужно будет во время обучения пожить у меня. Устраивает? - с интересом и надеждой спросил он. — + Если для тебя это невозможно, то, конечно, можно посмотреть другие варианты... Но обычно... +

+

Пораскинув мозгами и так и не найдя достойных причин для отказа, я, не став дослушивать, согласно + кивнул. +

+

— Я согласен. Если за время ученичества кушанья и сладости будут за твой счет. - увидев согласный кивок с + улыбкой, я тоже довольно хмыкнул. — Только дай мне время до завтрашнего вечера. Я должен предупредить + мать и... кое-куда сходить. +

+

Уж очень интригующим была просьба лысого профа посетить их школу.

+

Получив согласный кивок от моего "почти учителя", я довольно потянулся.

+

— Ну раз уж так, старик. Я, наверное, домой. Удачи! - радостно помахав рукой все еще сидящему с довольным + прищуром старику, я, быстрым шагом догнав, уселся на уезжающий автобус. +

+

***

+

Не прошло и минуты после ухода мальчишки, как телефон Стива завибрировал от звонка. Неспешно взяв телефон + и на маленьком дисплее прочитав имя своего прошлого ученика, он со вздохом ответил на телефон. +

+

— Алло, Старик? Ну как там? Парень годится? Будет твоим новым учеником? - сходу засыпал его вопросами + ученик. +

+

— Ох, Билл. Он просто... Просто... Я даже не знаю, как его описать. Но за простой разговор он вытянул из + меня все соки. Я даже боюсь, как его буду обучать. В общем, готовь несколько ящиков пива. Я скоро к тебе + подтянусь и расскажу все, что было. +

+

Закончив телефонный разговор, старый вор со вздохом потянулся к своим сигарам.

+

— Хех, мелкий паршивец. - с удовольствием рассмеялся старик, не найдя в карманах свою пачку с куревом. +

+

***

+

— То есть вы хотите, чтобы я прошел у вас полное медобследование? - получив от профессора и какого-то + чудища с синей шерсткой осторожные кивки, я недовольно фыркнул. — Нет. +

+

— Но... Это ведь поможет нам понять природу твоих сил и мы сможем помочь тебе в ней разобраться! - в этот + момент я еще раз невольно фыркнул. Они хотят помочь МНЕ разобраться в моих силах? Хех. — Научим их + контролировать и... +

+

— Достаточно, Хэнк. - прервал его профессор. — Том, тебе есть что скрывать от нас? - изучающе глядя мне в + лицо и начиная медленно шерстить по моей ментальной защите, спросил он. +

+

Хмыкнув его словам, я философский заметил: — Скрывать всегда есть что. - и силой воли обжег его + ментальные щупы. +

+

Поморщившись от отката, телепат хмуро взглянул на меня. Его синий друг, будто случайно встав перед ним, + неспешно и специально напоказ засучил рукава. +

+

— Но именно в этом случае мне скрывать нечего. Просто лень переться по всем вашим высоконаучным аппаратам + и тратить на это свое время. - одарив их лучезарной улыбкой, я невинно захлопал глазами. +

+

— Том, мы настаиваем. Если ты хочешь быть учеником нашей школы, то мы должны знать с кем имеем дело.

+

— А кто вам сказал, что я хочу быть учеником вашей школы? - от моего вопроса лицо профессора посмурнело. + А чуткий слух уловил за дверью тихое сопение. Хех. — Простите профессор, но буквально вчера, у меня уже + появился учитель, который обещал сделать из меня профессионального вора. А с вами я хочу быть друзьями. + Все же вряд ли обычные люди смогут меня понять так же хорошо, как себе подобные. И в знак нашей дружбы я + готов пройти ваш медосмотр... За сто тысяч долларов! +

+

Радостно и с улыбкой закончил я, подвергнув только что расслабившихся мутантов в возмущенный шок.

+

— Что? Мы еще не такие уж и хорошие друзья. Да и дружба дружбой, а дело это дело. - значительно поиграв + бровями выдал я. Потом, приняв грустное лицо и заломив руки, продолжил свое шоу. — Да и я ведь бедный + мальчик, который растет без отца! И неужели несправедливо, что я хочу немного подзаработать за такую + щепетильную услугу? Я же перед вами словно свою душу раскрываю, собираюсь предстать нагишом! +

+

— У нас... - начал было Хэнк, как я сразу его перебил.

+

— В жизни не поверю, что у владельца такого шикарного особняка в несколько тысяч квадратных метров и с + земельным участком в десяток гектаров не найдется такой мелочи. - не смотря на лицо + растерянно-возмущенного Хэнка, профессор согласно кивнул. +

+

— Мы согласны, Том.

+

— Отлично! - довольно потер я руки. Куда идти, на какой операционный стол ложиться?

+

Получив еле заметный кивок от профессора и, скорее всего, целый пакет инструкций прямым путем, Хэнк, + буркнув что-то не особо понятное, пошел в сторону выхода. Пожав плечами, я веселым шагом пошел за ним. +

+

Прямо за дверью стояла группа быстрого реагирования в лице Логана, Скотта и той самой Джинн Грей, которая + еще самая-самая в этом домике. +

+

Я был, прямо скажем, польщен таким вниманием к своей персоне. Такая сборная солянка крутышей и все для + меня. Вот это перестраховка! +

+

Радостно пожав руки мужской половине, с удовольствием поцеловав руку прекрасной даме и сыпанув пару + комплиментов ей, я таким же бодрым шагом продолжил идти за чудищем. +

+

После этого были незабываемые пару часов скучнейших обследований. Меня просто брали и незатейливо + засовывали во все, что можно и нельзя. Измеряли все что можно, не нужно и не принято! +

+

Походу таким нехитрым образом этот синий йети пытался отыграться на мне и хоть немного отомстить. А я + что? Мне было наплевать. Раз уж им интересно, пусть измеряют. Мне-то за это заплатят, хе-хе. +

+

Закончив к полудню и получив от Хэнка разрешение проваливать куда хочу, я вышел из его унылой + лаборатории, где меня сразу встретили и с конвоем доставили до профессора. И уже он без неприязни, а + даже с каким-то веселым ехидством выписал мне чек. +

+

— Хоть и накладно, но приятно с тобой сотрудничать, Том. Точно не хочешь поучиться в нашей школе? Ведь + знания – сила! +

+

— А время – деньги. - с ехидством добавил я, помахав чеком. — Ладно проф, в ближайшее время в гости не + ждите. Но я все же постараюсь еще раз как-нибудь посетить вашу обитель знаний. Если, конечно, вы не + против...? +

+

— В чем вопрос, Томми? В любое время! - с улыбкой воскликнул он.

+

Благодарно кивнув, я с веселой улыбкой пошел в сторону выхода. Еще надо было вытащить мать из дому и + вместе с ней сходить в банк, чтобы открыть счет и оставить там все накопленные лишние деньги и сумму + чека. Потом уже вечером к старикану! +

+

Когда довольно утвердив в голове свои планы, я уже выходил с территории особняка, меня догнал девичий + окрик. +

+

— Томас! Подождите!

+

Недоуменно обернувшись назад, я приметил спешно бегущую в мою сторону девушку-вампа, которая энергию + высасывала на зависть любым энергетическим демонам нижнего плана. +

+

— А! Девушка с белыми прядями. - воскликнул я, поскольку так и не смог вспомнить ее имя.

+

— Меня зовут Анна-Мария. Но можешь звать Шельма, как другие. Привет! - немного подзависнув, пытаясь + понять, почему все зовут ее Шельма, если она Анна, я упустил момент для ответа. +

+

— Ты ведь помнишь меня? Встреча в коридоре школы! Я... - поспешно зачастила девушка.

+

— Да помню я тебя, помню. Ты тогда еще эффектно пол-школы разнесла.

+

— Я...я...- покраснев, отвела взгляд девушка.

+

— Ну и? Зачем искала? - поторопил я ее, поскольку на дороге уже был виден мой маршрутный автобус до + города. +

+

— Ты тогда как-то смог заблокировать мою силу. Сможешь это сделать еще раз? Только навсегда? - с надеждой + спросила она. А я, удивленно оглядев ее, внутренне покрутил пальцем у виска. Своей волей отказываться от + дара? Она серьезно? +

+

Заметив мое удивление и безвольный вопрос на лице, она поспешно зачастила: — Просто знаешь, я не умею + контролировать свои силы и они слишком опасны для окружающих. С тех пор, как раскрылись мои способности, + я не могу ни до кого дотронуться открытыми участками кожи. Они все падают при смерти...Ну кроме тебя. + Прошу, помоги мне. - уже со слезами в глазах, повторила свою просьбу девушка. +

+

— Ну...- в принципе, я мог запечатать ее способности. Или же вообще сделать двухуровневую печать, которая + позволила бы ей использовать свои силы только по желанию. Но было одно но: для этого пришлось бы + использовать магию. А я обещал себе, что не буду ее использовать без интересных причин. Так что, прости + милашка. — Прости, я могу блокировать твое воздействие только в отношении себя. +

+

Увидев опустившиеся плечи девушки и вновь накапливающиеся слезы в уголках глаз, я поспешно замахал + руками. +

+

— Хей-хей не грусти. Неужели тренировки не дают никаких результатов?

+

— Только научилась усиливать поглощение, а вот остановить или хотя ослабить никак не получается. - + грустно поведала она мне. А я, с загоревшимися глазами экспериментатора, протянул ей руку. +

+

— Попробуй усиленно потянуть! - отшатнувшись от моей руки словно от прокаженного, она в ужасе перевела на + меня взгляд. — Да не волнуйся, ты же знаешь, что я могу сопротивляться твоим силам. Попробуй! + Пожалуйста. - добавил я, видя все еще недоверчивое личико девушки. +

+

Все еще неуверенно и слабо кивнув, девушка сняла с руки длинную хлопковую перчатку до локтя. И осторожно, + словно боясь сломать, коснулась тонкими пальчиками моей ладони. Тревожно замерев, она смотрела на мое + лицо в ожиданий реакций. +

+

Уже приготовленный заранее, я не дал и капли энергии перетечь к ней. И, с ободряющей улыбкой кивнув ей, + сам схватил ее за руку. +

+

— Давай смелей! - с удивлением прислушавшись к себе и не обнаружив приток энергии, она обрадованно + улыбнулась. Потом переведя взгляд на удерживаемую мною ладонь, мило заалела. — Хей-хей, не вздумай + влюбляться в такого идеального меня. Мы тут экспериментируем вообще-то. Давай посильнее. +

+

— Я...я даже не думала об этом, придурок. - недовольно, но так же мило краснея выдала она. — Я просто в + первые за это время, касаюсь чужих рук без всяких проблем вроде падения в обморок моих оппонентов. +

+

— Да-да, но все равно, признай, я классный. - с самодовольной рожей выдал я, смотря в глаза разозленной + девушки. +

+

И именно в этот момент давление и настойчивость, с которой пытались отобрать у меня энергию, начала + лавинообразно расти. Уже через три секунды воздействие ее силы было настолько велико, что стало трудно + дышать. А магический источник, чувствуя мое подсознательное желание, начал вырываться из магических + оков, желая помочь своему хозяину справиться с давлением. +

+

На четвертой секунде энергия стала вытекать, с каждым мгновением все увеличивая и увеличивая свой поток. + Шельма, почувствовав, что пробила мою защиту, испуганно ойкнула и отпрыгнула назад. +

+

Не устояв на дрожащих ногах, я устало плюхнулся на землю. С размытым зрением, вытерев капающую с носа + кровь. Мда, нехило так она на меня надавила. +

+

— Том! Прости меня пожалуйста, прости! Подожди, я сейчас вызову других и они доставят тебя в медпу... - + испуганно зачастила девушка уже с заслезившимися глазами, пытаясь помочь мне одной левой рукой, на + которой еще была перчатка. +

+

— Шельма, все в порядке. - Спокойно ответил ей я, ускоряя в теле поток ЦИ и расслабляя перенапрягшиеся + мышцы. Через несколько секунд я уже чувствовал себя нормально, и лишь разводы крови под носом напоминали + о моем фиаско. — Я просто переоценил свои силы. Но будь уверена, в следующий раз все будет по-другому! +

+

Увидев мой лихорадочный взгляд, прикидывающий разные варианты решения этой проблемы, она неверующе + встала, и, обматерив меня последними словами, злющими шагами пошла в сторону школы, на ходу вытирая + слезы. +

+

А я, удивленно пожав плечами, продолжил размышлять над новым, интересным вопросом: как бороться против + супер-мощных энергетических вампиров без магии. +

+ +
+ + <p>Примечание к части</p> + +

Вот и прода, господа! По просьбе-совету единственного человека, который смог найти время и написать + отзыв, сделал главу побольше...Гораздо больше, чем планировал ( ͡° ͜ʖ ͡°) +
+ Эта часть, конечно не изобилует экшеном и действиями( впрочем, как и все предыдущие главы + ¯\_(ツ)_/¯), но писал то, что было нужно для сюжета. +
+ Спасибо всем за внимание и удачных будней! Пишите отзывы, делитесь впечатлениями и костерите автора, + пхпп) +

+
+ > +
+
+ + <p>Глава 5(RE)</p> + +
+ + <p>Примечание к части</p> + +

Йожкин кот! Вчера из-за моей феноменальной рукожопости и непередаваемой забывчивости я забыл + поставить отметку Черновик. И маленькая часть главы уже успела увидеть свет. Вот и пришлось ударными + темпами дописывать главу. И все, что вы видите, было написано за эту половину суток. Так что не + судите строго =_= +
+ Всем приятного чтения! +
+
+ P.s. Я, еще за час, который кусочек проды провисел на сайте, успел убедиться, что отзывы вы все же + писать умеете, гыгы +
+ Радуйте так побольше, что ли) +

+
+

Стив, или как я привык его называть, старик, смотрел на меня нечитаемым взглядом.

+

— Ты... Взломал все типовые замки... с первого раза. - неуверенно и как-то неверяще произнес старый.И, + помолчав несколько секунд, вдруг взорвался негодованием. — Да господи! Ты и времени потратил на каждый + максимум тридцать секунд! Даже на дисковые замки, с помощью универсальных отмычек! +

+

— Это же хорошо, не? - самодовольно спросил я с улыбкой Чеширского кота.

+

— Хорошо-то хорошо, но... - помявшись и спустя секунду махнув рукой, он не стал продолжать, вместо этого + устало сел на свое место и спешно пытался закурить свою сигару...держа наоборот. Хм. +

+

Наверное, слишком способный ученик не всегда хорошо... Для чувства собственного достоинства. Хех. Ведь во + время объяснения и последовавшей демонстрации, сам Стив потратил на каждый более минуты. Впрочем, ничего + удивительного и это было очень даже отличное время. Но мне было бы стыдно, с моим-то опытом и + преимуществами организма, не опередить простого человека минимум в два раза. Так что, сколько бы мне ни + было жалко своего учителя, но моя гордость требовала делать все по высшему разряду. +

+

— Ладно, взломать электронные кодовые замки или же биометрические много ума не надо. Просто подключаешь + флешку с набором программ взломщика и, скрестив пальцы, ждешь результата... Некоторые немногочисленные + способы обойти их я уже показал. - Увидев мой хмык, Стив недовольно заворчал. — А что еще делать-то, + научиться писать взламывающие программы дело не одного года. Да и не смогу я тебя научить этому, я + больше практик и не разбираюсь в этих циферках, стар я для всего этого дерьма! Все, не придирайся! +

+

Убедившись, что я не собираюсь спорить или шутить по своему обыкновению, Стив облегченно вздохнул.

+

— То есть, я уже готов?

+

Помолчав пару секунд и оглядев меня непередаваемым взглядом, мой учитель громко вздохнул и начал от души + возмущенно материться. Прекрасные многоэтажные конструкции в его исполнении продлились где-то минуту. + После которых он, отдышавшись, оглядел мою довольную рожу и начал второй круг построения словесных + многоэтажек. +

+

— Это просто пи**ец как странно. Я даже не знаю, радоваться мне или плакать. Прошла же всего неделя! + Какого хрена, Том?! +

+

— Просто я очень талантливый. - Пошаркав ножкой для вида, смущенно выдал я.

+

— Пи**ец. - сокрушенно прокомментировал это он. — Ты уже готовый домушник и взломщик. Всего неделя... + Господи помилуй. Чтобы овладеть всеми уловками и техниками профи тебе понадобилась всего неделя. +

+

От его упаднического настроения и грустного взгляда я даже почувствовал себя виноватым. Может, не + следовало так быстро проходить его обучение? +

+

Хотя, я так и так долго бы не утерпел. Все же, слушать опять то, что ты и так знаешь, с некоторыми + незначительными отличиями, было до обидного скучно. За свои жизни мне доводилось побывать два раза вором + и целых пять раз блюстителем закона, да еще в разных по развитости мирах. Так что уловки и тактику вора + любой эпохи я знал не понаслышке. И зачем, при таком раскладе, тратить свое, хоть и в теории + бесконечное, время на такую скукоту? +

+

— Если бы ты не был настолько мелким, я бы посчитал, что у тебя есть минимум десятилетия опыта. - со + вздохом поделился своими мыслями старик, на что я мог лишь блеснуть глазами и в очередной раз + улыбнуться. — Ладно. По-хорошему, надо бы тебя еще раз прогнать по теории и пройденным материалам. Но + что-то мне подсказывает, что результат будет таким же идеальным. Да и я считаю, что лучший учитель это + практика. +

+

С умным видом покивав его словам, я, не удержавшись, поторопил его: — Это значит...?

+

— Да-да, ты идешь на свое первое дело.

+

— Не забудь, ты обещал только интересные задачи. - Напомнил ему я.

+

— Пф, я обещал тебе интересные дела после завершения обучения и твоего становления профессиональным + вором. А это дело еще часть обучения. Так сказать, закрепление материала! +

+

Поиграв несколько мгновений с ним в гляделки и недовольно признав его правоту, я мысленно махнул рукой. + Все же он прав. Так что придется играть по его правилам. +

+

— И когда будет это учебное дело? - в тоннах пренебрежения, что плескались в моем голосе, казалось бы, + можно утопить целый корабль. Но старик даже бровью не повел. Задумчиво кивнув каким-то своим мыслям, он + неспешно ответил. +

+

— Есть тут некое дельце, как раз для тебя. Я до сегодняшнего вечера все подробности узнаю и сообщу тебе. + Пока можешь быть свободен. Считай, что у тебя первый выходной. Порадуй маму там, что ли... - + неоднозначно махнув рукой в воздухе, он с таким же задумчивым видом пошел в сторону выхода из + тренировочного комплекса. +

+

А я что, я ничего. Пожав плечами и посчитав карманные деньги на дорожные расходы, тоже с удовольствием + пошел в сторону выхода из здания гильдии. +

+

Все же вся эта неделя и вправду не баловала меня свободным временем. Сплошные уроки и демонстрации от + старика, мое идеальное повторение и неверящее требование повторить еще раз с разными другими условиями + от него же. Тренировочный комплекс гильдии давал очень широкий простор для разнообразия в тренировках, а + старик не страдал отсутствием фантазии. Вот и приходилось крутиться как белка в колесе, чтобы старик, + так и жаждущий завалить своего ученика из-за нереальных результатов, остался доволен и посчитал материал + закрепленным. +

+

Попетляв по запутанным коридорам комплекса, поднявшись по не самой короткой винтовой лестнице наверх и + кивнув сидящему на страже охраннику, я, наконец, вырвался из темных подвальных помещений под теплые + солнечные лучи. +

+

Кто бы мог подумать, что под невзрачным одноэтажным зданием в трущобах Бронкса есть многоэтажный + подвальный комплекс воровской гильдии? Уж точно не я. Как и девяносто девять процентов всех обывателей + Нью-Йорка. Масштабность и запутанность подземного комплекса делала честь даже гордому подгорному народу + гномов. А количество разных выходов в разных точках Бронкса и проходы через канализацию гарантировали + возможность побега в случае раскрытия комплекса. +

+

Еще раз окинув взглядом серую одноэтажную конструкцию прошлого века, я с хмыком пошел в сторону более + оживленных улиц. Куда бы я ни собирался ехать, ловить такси или ждать маршрутку удобнее оттуда. Да и + походить подумать, чтобы решиться с пунктом назначения, было необходимо. +

+

Дома мать меня в ближайший месяц не ждала, зная о моем ученичестве у мастера вора. А вот профессор + Ксавьер после того очень выгодного сотрудничества очень настойчиво просил о повторной встрече. Наверное, + скорее всего, хотят спросить: какого хрена? А если точнее, что за хрень показывают их лабораторные + исследования. +

+

Вообще, я не хотел лезть под их шаловливые научные ручки. Но и нынешнее положение дел меня устраивало. + Потому что мне была интересна сама ситуация! Непредсказуемость и отсутствие точных прогнозов о их + реакции приятно перчили скучную жизнь и разгоняли сонную хмарь даже в мыслях. Попытаются схватить? + Сдадут военным? Или же сразу попробуют уничтожить? Заскучавшее по интересным событиям подсознание + подкидывало маловероятные, но все же такие желанные варианты. +

+

Господи, чего я еще думаю? Все, решено! Иду в логово телепата!

+

***

+

Спокойно глядящий лысый проф, напряженный синий Хэнк и задумчиво курящий Логан.

+

Святые яйца, антураж мне уже нравится!

+

— Томас... - начал осторожно Хэнк. — Мы тут изучили твои результаты и у нас возникли некоторые вопросы. + Не поможешь? - подавив желание поставить очередной ценник, даже на такую пустяковую услугу, я с улыбкой + пожал плечами. +

+

Беспомощно переглянувшись с профессором, не зная, как трактовать мое движение, он начал поспешно + перелистывать удерживаемые в руках бумаги. +

+

— Просто понимаешь, Том. Твои показатели аномальны! - еще бы я не знал. — Все показатели твоего организма + положительно превосходят показатели обычного человека в несколько раз! Немного иное, более плотное + строение мышц и костей, более мощное сердце и органы... Я бы посчитал тебя представителем инопланетной + расы, но в тоже время у тебя совершенно простое человеческое ДНК! Хотя простой человек, даже на пике + своих возможностей, не сможет сравниться с тобой! +

+

Я на все его слова мог лишь довольно кивать. Всегда приятно, когда другие оценивают твои труды по высшему + разряду. +

+

— А показатели скорости и времени отклика нервных импульсов просто нереальны! Не может организм живого + существа передавать импульсы с такой скоростью... +

+

— Если у него нет ускоряющего все энергетические процессы источника энергии. Который у тебя есть. - + спокойно завершил за него профессор. — Некоторые приборы показали, что у тебя повышенная энергетика + всего тела. Но подозрительно не это, а наличие более сильного источника энергии под солнечным + сплетением. Который как будто чем-то скрыт и еле взаимодействует с остальной энергией тела. +

+

Что у них за приборы такие-то?! Которые видят не только магический источник, но и каким-то макаром + прикрывающую его печать! +

+

Уважительно кивая головой с видом "нифигасебе", я участливо спросил:

+

— Иии?

+

Возмущенно открывшего рот Хэнка профессор заткнул одним движением брови.

+

— У нас в итоге образовались две теории. Первая, что ты результат генной инженерии военных. - Хэнк + значительно зыркнул, сразу показывая, кому принадлежит сия светлая идея. — И вторая, о том, что ты... + маг. +

+

Профессор произнес последнее предложение настолько неуверенно и с видом, будто произносит глупость, что я + не удержался и фыркнул. +

+

— Ага. Могучий маг, которому запечатали силы, чтобы великий я не уничтожил этот скучный мир!

+

— Да он издевается! - пыхнув сигаретой, выдал до сих пор молчавший волосатик.

+

— Ага! - довольно кивнул я.

+

Ведь им не обязательно знать, что издеваюсь я, скорее, в первую очередь над собой, не правда ли?

+

С удовольствием послушав ворчание Логана насчет оборзевших детишек, я перевел взгляд на замолчавшего + профа. +

+

— Том, что тебе от "нас" нужно? - с серьезным видом спросил у меня Ксавьер, а по замолчавшим и + подобравшимся Хэнку и Логану я понял, что телепат успел и их насторожить. — Ты сам нашел нас. Сам + навязался. Твои цели непонятны. Да и ты сам та еще загадка, Томас Уэйн. Так что тебе нужно? +

+

Молча постояв пару секунд перед напряженными мутантами я недовольно махнул рукой.

+

— Ой, все! Что вы за конченные параноики такие?! Я еще в первую нашу встречу говорил. Мне просто скучно + среди простых людей и я хочу "потусоваться" с крутыми сверхами. От вас больше мне ничего не надо. А + насчет моей непонятности. Вы меня полностью изучили, чего вам еще нужно-то?! +

+

— Узнать, кто ты! - разозлённо выдал профессор, шибанув ментальной волной.

+

Пару секунд поглядев на хмуро зыркающего телепата, я на мгновение даже задумался над тем, чтобы + рассказать им о своей отличительной черте, но потом еще немного поразмышляв, с широкой улыбкой выдал: +

+

— Я ж уже говорил, великий маг с запечатанной силой!

+

С глубоким вдохом подавив вспыхнувшее раздражение на грани злости, профессор тяжелым тоном бросил.

+

— Открывай свое сознание или уходи.

+

— Старик, мне кажется, ты слишком оборзел. - с той же милой улыбкой ответил я, спокойно глядя на телепата + и в то же время сознательно опустив на верхние слои ментала эмоции пренебрежения и скуки. +

+

Проняло!

+

С боевым азартом, успела мелкнуть мысль, пока ментальные щиты трещали под напором грубой мощи и вправду + сильного телепата. Короткий импульс воли и удерживающий профессора щит пропал, погружая сознание + незваного гостя в ждущее за ним болото скуки и безразличия, которые за прошедшие века стали частыми + спутниками в моей голове. Первые мощные попытки вырваться были встречены ставшим снова монолитным + барьером, который не давал телепату вернуться обратно. А затем квинтэссенция безразличия, бывшая + ловушкой, полностью поглотила разум Ксавьера, убивая желание сопротивляться. +

+

С хмыком выкинув сознание сильного, но неумелого в ментальных битвах профессора обратно в его тело, я + устало открыл глаза и успел лишь в последний момент увернуться от когтей Логана. +

+

— Что ты сделал с профессором, тварь!? - прорычал он, яростно сверкая глазами.

+

А профессор то что, ничего. Сидел с пустым взглядом овоща и наблюдал за узорами ковра. Нелегко ведь + отойти от воздействия тотального безразличия, которым страдает бессмертное существо. Несколько часов и + такой сильный телепат будет в норме. +

+

Но вместо ответа я, лишь улыбнувшись, пожал плечами. Зачем ему рассказывать и убивать все веселье?

+

Яростно прорычав и быстро сократив расстояние, хищник в образе человека начал яростно полосовать воздух + когтями. То есть он хотел нашинковать меня, но моя вертлявость этого не позволяла. +

+

Спустя несколько секунд, поколдовав над своим телефоном, к нему присоединился и синий Йети. И если у + Логана прослеживались четкие серии ударов и каких-либо комбинаций, то у Хэнка была лишь голая сила и + яростный энтузиазм. +

+

Не прошло и минуты с начала нашего танца, где не было третьего лишнего, как в зал ввалились Скотт и его + рыжая подруга. Черт, они уже точно были лишними в моих танцульках. +

+

— Логан, Хэнк отойдите! - прокричал Скотт, зачем-то держась за свои модные очки.

+

Заподозрив неладное, я прыгнул сразу за синего, когда он попытался разорвать дистанцию. И не прогадал. + Вспыхнувший и ударивший в место, где я стоял, луч силы был впечатляющим. Но еще больше впечатляющим было + то, что он пулял эти лучи глазами! +

+

С восхищением цокнув языком и апперкотом вырубив синего громилу, я усиленным пинком отправил его в + Лучеглазого. Но именно в этот момент вмешалась его подруга, телекинезом остановив летящего пушистика. +

+

Уже недовольно цыкнув языком, я в очередной раз увернулся от молниеносного выпада Логана и дав ему локтем + в грудь, схватил за левую руку с разворота и бросил опять же в сторону Скотта. Эх, вот бы кто-нибудь + такой спорт придумал, людей кидать. Я бы, может, и чемпионом там стал... +

+

Новую посылку опять успела перехватить его боевитая подруга, которая уже положила бессознательного Хэнка + на землю. Но с ускорением двинувшись прямо за Логаном, я успел подойти достаточно близко до того, как + меня успели заметить. А последовавший за ним смачный удар прямо по очкам Скотту был морально очень + приятен. Очкастых бьют и очень сильно! +

+

Не успел я как следует порадоваться, как мощный пресс телекинеза, протаранивший прямо в грудь, отправил + меня в скоростной полет. И даже то, что я в воздухе успел перевернуться и встретить стену ногами, не + особо помогло. Сила удара была настолько сильна, что я под аккомпанемент выбитых кирпичей и пыли вылетел + из дома прямо на улицу. +

+

— Охушки... А она умеет б... - не успел я как следует встать и пожаловаться на жизнь, как инстинкты + яростно взвыли, заставляя с помощью взрывной волны Ци направленной в ноги, выстрелить собой вбок словно + пробкой. А в место, на котором я стоял мгновение назад, с неба ударила е*аная молния. +

+

— А че, так мо...- выбитые моим телом кирпичики, взяв приличное ускорение, забомбили мое нынешнее + местоположение, заставляя тратить драгоценную энергию Ци и изображать из себя флаг на ветру. Рыжая вышла + из дома... +

+

Под сотрясающие звуки грома с неба ударила очередная молния, заставляя меня как никогда раньше + ностальгировать по магии. +

+

Подняв голову к небу, я увидел левитирующую сереброволосую девушку с горящими белым огнем глазами. + Похоже, именно она была повелительницей молний. +

+

—Простите, мне кажется вы слишком вспыл...

+

Да что ж такое, в очередной раз не дав мне закончить речь рядом со мной взорвалась какая-то карта, чуть + не опалив мои шикарные патлы. Если и это сделала девушка, то я вконец потеряю уважение к мужикам из + этого дома. +

+

Слава яйцам, буквально яйцам, хехе. Это оказался парень, который пулял чем в руки попадется, и все, что + удивительно, взрывалось! +

+

Когда я, одобрительно цокнув языком, увернулся от очередного взрыва... понял, что мне писец. Рыжая, пыхтя + недовольством, своим телекинезом смогла словить меня и не давала двигаться, а чуйка, отчаянно визжа, + напоминала, что сейчас с неба бумкнет. Пару раз подергавшись и поняв, что никак выйти из-под воздействия + не получится, я вытащил и без размаха, с мыслями, "главное, чтоб долетел", кинул в девушку свой + нож-бабочку. +

+

Ожидаемо, увидев летящий острый предмет, она потеряла концентрацию и всеми силами схватилась за нож, + покорежив его так, что мама родная не узнает. А я, в очередной раз увернувшись от молний, понял, что + лучше бежать, если не хочу дойти до смертоубийства. +

+

— Ктулху вам в тещи, бешеные б... - уворот от взрыва. — И тебе того же, мудак!

+

Плюнув и не став больше разглагольствовать, я, под максимальным ускорением, побежал в сторону дороги. Но + уже спустя мгновение, пришлось рыбкой нырять в сторону. +

+

Прилетевший чуть ли не на сверхзвуковой скорости покорёженный ножик, на считанные миллиметры разминулся с + моей спиной. +

+

— Ненормальная! - возмущенно проорав и на ходу подобрав свое имущество, я еще быстрее припустил подальше + от особняка. +

+

Уж очень мне не понравились вихрящиеся предметы разной величины вокруг рыжей истерички. Ну бросил ножик, + бывает, зачем так обижаться-то!? +

+

***

+

Только промахав несколько километров и убедившись, что на горизонте не видно грозовых туч седовласки и + урагана рыжей, я облегченно выдохнул. +

+

Неожиданно, мать его!

+

Я совсем не ожидал, что у Ксавьера окажется столько сильных сверхов, но это даже еще лучше! Ничего так не + бодрит, как знание того, что где-то в одном городе с тобой есть целая группа могучих существ, алчущих + твоей крови. +

+

Я, конечно, понимал, что поведи я себя чуть иначе и терок с профом и его школой можно было избежать. Но, + положа руку на сердце, будем честны: мне нафиг не сдалась дружба с ними. А вот войнушка была вариантом + поинтересней. Хе-хе. +

+

Да и хоть понятные, но оттого не менее раздражающие попытки Ксавьера докопаться до истины начали + приедаться и надоедать. Вот и покумекав маленько, я решил, что будет лучше и интересней форсировать + конфликт. +

+

Так что я ни о чем не жалел, больше размышляя не о оставленных в ярости мутантах, а о том, где бы еще + найти интересных знакомых... +

+ +
+
+ + <p>Глава 6</p> + +
+ + <p>Примечание к части</p> + +

Хей-хей народ! Не ждали? А тут быстрая прода! Пхпх +
+ Я не знаю, что стало катализатором, но статы последней проды были просто крышесносящими! Количество + лайков на данной работе выросло почти в два раза, просмотров за тыщу! И главное *барабанная дробь* +
+ №28 в топе «Джен по жанру Стёб» +
+ Вы вывели меня в топ лист фикбука! (ノ◕ヮ◕)ノ*:・゚✧ +
+ Спасибо вам всем, народ! Вы еще неожиданно и отзывов накатили...В общем, вы зарядили меня такой + дозой мотивации, что я, бросив все дела, взялся за перо и выдал очередную проду =) +
+ Вух, я выжат как лимон, но я доволен. +
+ Так что всем спасибо за внимание, приятного чтения и удачных будней с: +

+
+

— Почему ты такой замызганный? - удивленно спросил старик, увидев мой непотребный вид.

+

Хотя, учитывая прошедший махач, слава яйцам, что я ушел на своих двоих и с полным комплектом + конечностей. +

+

— Да не обращай внимания, просто с лестницы упал.

+

Окинув взглядом мою черную от копоти и со следами подпалин одежду, Стив многозначительно так хмыкнул.

+

— Ну... Судя по твоему виду, лестница минимум вела в древнюю гробницу и была полна различных + смертоубийственных ловушек. +

+

Не став с ним спорить, я перевел разговор на интересующие меня темы.

+

— Надеюсь, ты нашел для меня задачку? А то будет обидно, если я зря половину города в непотребном виде + пересек. - ворчливо выдал я, копаясь среди своих вещей в поисках сменной одежды. +

+

— Если ты уже готов, оденься в темных тонах. Особенно свои патлы прикрой. - посоветовал мой учитель.

+

— Что за задача? - заинтересованно спросил я, натягивая поверх черной футболки такую же черную ветровку с + капюшоном. +

+

— По твоему главному профилю, нужно пробраться в квартиру, взломать сейф и забрать хранящуюся там + посылку. +

+

— Хм?

+

— Да бог знает, что в ней. Главное, забери и принеси заказчику. Что в ней и зачем оно - не наша забота. +

+

Пожав плечами и повернувшись в сторону зеркала, я с неудовольствием окинул взглядом свое отражение. + Чертовы волосы, а если точнее, челка, своим блондинистым блеском, выделялась как белый в группе + чернокожих. +

+

Недовольно и тихо ругнувшись, начал натягивать на голову запасную черную бандану.

+

— Говорил я тебе, сбрей эту свою гриву. Только и делает, что мешает! - по стариковскому обычаю начал + ворчать Стив, наблюдая за моими манипуляциями. +

+

— Зато я классный и красивый. - со смешинкой в голосе ответил я, натягивая вторую бандану на лицо.

+

Честно говоря, мне было плевать с высокой колокольни на эти патлы, но и против них ничего не имел. Вот и + цвел на моей голове бурелом из платиновых локонов. +

+

— Да ты ж и так смазливый, зачем тебе они?! - все продолжал ворчать старик.

+

— А как же? - удивленно всплеснул я руками. — Как я буду голубоглазым блондином, если буду сверкать + лысиной? +

+

— Но у тебя же цвет глаз се... - не успев завершить свою тираду, Роу удивленно замер. С открытым ртом + смотря в мои сверкающие смешинками глаза сапфирово-голубого цвета. +

+

Поменять пигментацию? Как два пальца об асфальт! Это одна из самых легких задач для меня, как для бывшего + мага жизни. +

+

— Чертов мутант! - устало проворчал старик, всем своим видом показывая, как он устал от этого дерьма. — + Если готов, пошли. Только свои банданки снять не забудь. А то чертовски подозрительно выглядишь. +

+

Улыбнувшись его словам я все же снял бандану и заодно вернул глазам родной серый цвет. Поняв, что + обидчивый старик не собирается меня ждать я поспешно захватив рюкзак с набором необходимых профильных + инструментов, зашагал за ним. +

+

— Адрес-то хоть скажешь?

+

— Больше. Я сам покажу и буду еще наблюдать неподалеку. Все ж твое первое дело и не хотелось бы, чтобы + оно же стало и последним. - недовольно закатив глаза, я как можно эффектней фыркнул. Вот еще! Да у меня + успешных налетов в два раза больше чем у него за всю карьеру! +

+

— Планировку дома, способы проникновения и присутствующие виды замков - как я понимаю, должен узнать + сам? +

+

— Правильно понимаешь. - хмыкнул старый пень.

+

— Что-то как-то дело не для новичков. - недовольно пробурчал я, стараясь скрыть довольство. А как же? + Дело обещает быть не таким скучным, как думалось раньше! +

+

— Ну так и ты не простой новичок. И я думаю, это тебе вполне по силам! Но если ты попросишь, я готов дать + тебе другое задание, хотя кто знает, когда оно найдется... - с хитринкой в голосе медленно потянул он. +

+

— Пф, еще чего! Я по твоей хитрой морде вижу, что новое ты будешь искать бог знает как долго. Так что + нетушки! Я и с этим справлюсь! +

+

Гордо вздернув нос, я встал в пафосную позу. Всем своим видом показывая, что хрен ему а не саботаж. Я + вовсе не понимал, почему он так хочет, продлить время моего ученичества. +

+

Поиграв с ним пару секунд в гляделки, мы одновременно отвернулись и с независимым видом продолжили + дорогу. И только спустя десяток минут неспешной ходьбы по ночному городу мы наконец добрались до больших + улиц. Где и сели в такси до пункта назначения. +

+

Спустя час пути, большая часть которого ушла на пробки и заторы, мы наконец-то прибыли в более + благополучные районы Бронкса. А я в очередной раз пожалел, что не могу использовать магию. Как было бы + легче и быстрее, если можно было скастовать те же крылья ветра. Вжух - и на нужном месте. А то эти + запутанные улочки мегаполиса, наполненные заторами, у меня уже в печенках сидят. +

+

— Что встал? Идем, нам еще нужно пару кварталов пройти до места будущего преступления.

+

В очередной раз ругнувшись в душе на слишком большой город, я спешно потрусил за уже успевшим отдалиться + стариком. +

+

— Хей, старик, может уже пора начинать объяснять детали?

+

— На месте узнаешь. - недовольно буркнул он, прибавив шагу.

+

Мне оставалось лишь тоже поднажать, вполголоса костеря вредного старика.

+

Спустя еще десяток минут, мы прибыли к какому-то жилому кварталу, состоящему из многочисленных коттеджей. + Построенных на разный вкус и цвет, и, конечно же, каждый был облагорожен пышным газоном. В общем, + типичный район жителей из "американской мечты". +

+

— Твоя цель находится в том доме. - Стив еле заметным кивком головы указал на ничем не выделяющийся + классический двухэтажный домик с белыми стенами. При этом продолжая медленно шагать по тротуару. — На + первом этаже кухня, ванная, гостиная и кладовая. Остальную территорию занимает гараж. На втором три + спальни, еще одна ванная и рабочий кабинет. Сейф с нужной посылкой находится в последнем. Главное + условие — не оставлять никаких следов. Никаких. После тебя даже пыли не должно остаться. +

+

— Жильцы? - не обратив внимания на в чем-то смешное условие, задал вопрос.

+

— Семья. Муж, жена и дочь. Все сейчас дома. Но... - глянув на часы, учитель продолжил. — Через час, + максимум, они уже должны лечь спать. +

+

Мазнув взглядом по своим детским наручным часикам я убедился, что он, в принципе, прав. Нормальные люди к + полуночи уже должны спать. Они же не воры и убийцы, работающие под лунным светом. У них активное время + наступает только с восходом солнца. Я надеялся, что они как и все "нормальные" люди, будут спать в + ночное время. Но странность задания заставляла ждать подвоха и ожидать худшего. +

+

— Пока можешь покружить и поизучать дом снаружи, только помни. Не будь подозрительным. - сделав страшные + глаза, выдал, как неразумному, старик. +

+

Фыркнув и всем видом показав, что "а как же иначе?", я продолжил прогулку по кварталу уже в одиночку. А + Стив остался сидеть и отдыхать на одной из многочисленных скамеек вдоль улицы, немолод ведь уже, хех. +

+

После исхода озвученного часа и трех кругов вокруг дома нужный минимум данных был собран. В каждой + спальной комнате дома и в рабочем кабинете было по одному среднему окну. А в гостиной на первом этаже, + было вообще панорамное длиной в два метра. В ванных лишь маленькие окошки. +

+

В момент последнего обхода теплый свет горел в комнате лишь у дочки хозяина, ну, если судить по + виднеющемуся в окно нежному тону обоев и паре плакатов знаменитых мальчишеских групп, именно у нее. +

+

И, конечно же, по закону подлости, окно было открыто только у нее и в ванной на втором этаже. Была мысль + вломиться через другие окна, но чертовы пластиковые окна не позволяли без следов, на что так упирал + старик, открыть их извне. И если попытаться открыть, никакой секретности тут не получится. +

+

Эх, мне бы щепотку магии и никакой проблемы бы не было. Покрутив эту мысль так и так, я с улыбкой ее + отбросил. Ведь отказался я от магии именно чтобы было интереснее и жизнь малиной не казалась. +

+

Отсалютовав стоящему под раскидистой кроной дерева с другой стороны дороги старику, я решительно пошел в + сторону окна ванной комнаты. Второй этаж для меня не проблема. А благодаря совсем не внушительному + детскому телосложению можно было надеяться на проникновение без проблем. +

+

В общем-то так и получалось поначалу, импульс энергии на ноги и тело легко взмывает в прыжке на уровень + второго этажа. Там уже дело техники, ухватиться и подтянуться, и вуаля! Я почти в ванной, осталось лишь + зайти... +

+

— So you gotta fire up, you gotta let go...You'll never be loved! Till you've...- БЛЯДЬ?!

+

Чуть не свалившись, я быстро выполз обратно.

+

Какого хрена этой девочке не спится-то!? Да еще как можно орать песни почти в час ночи!? Она же всех + перебудит! +

+

Яростно матюгая не вовремя решившую посетить ванную, девку, я легко спрыгнул на землю и побежал в обход + на другую сторону дома. Надеюсь, она хоть не закрыла окно в своей комнате. +

+

Яростно махнув рукой вскинувшемуся старику и показав что все в порядке, я начал взбираться уже ко второму + своему окну за эту ночь. +

+

Прыжок. Подтянуться. И через мгновение я уже мягко приземляюсь на ворсистый ковер.

+

Обведя подозрительным взглядом всю комнату, так и дышащую девичьим духом, я неспешно заскользил в сторону + выхода из комнаты. Я, конечно, по специфике своей работы всегда был не против взять все, что плохо + лежит. Но идея красть мягкие игрушки или плакаты разных рок-групп меня совсем не прельщала. +

+

И когда я только взялся за ручку... с той стороны послышались шаги. Матюгаясь на чем свет стоит, пришлось + вновь прыгать в открытое окно. Да чтоб эта беспокойная барышня с лестницы навернулась! +

+

Успокаивая бешеный стук сердца и стоя под окном все так же напевающей девушки, я вновь задумался над тем, + чтобы дождаться, пока все домашние уснут. Но внутренний голос, доверительно сообщивший что так + интереснее, с крупным счетом обыграл здравый смысл. +

+

Убедившись, что девушка уже ложится на кровать и не собирается делать неожиданные рейды в ванную, я, + выстраивая изящные кружева всесокрушающего мата, пошел в сторону ванной комнаты. По пути стараясь не + замечать ехидные взгляды старого пня. +

+

Постояв несколько минут, настороженно прислушиваясь к звукам из дома, я решился на повторное + проникновение через ванную комнату. +

+

Уже начавший раздражать прыжок. Потянуться и текучим движением ввалиться в помещение. В очередной раз + раздраженно окинув утопающую во мраке комнату взглядом, я медленно пошел в сторону выхода в коридор. И, + услышав поспешный топот ног, чуть не взвыл от обиды. +

+

Пока я решал, стоит ли нарушать свой обет и убивать беспокойных жильцов, стало уже поздно кидаться в + окно. И я, быстро сориентировавшись, юркнул в корзину для белья. Где хоть и с некоторым напрягом, но + смог полностью поместиться и даже накинуть пару тряпок поверх себя, когда дверь открылась нараспашку и + внутрь ввалился шумно дышащий индивид. +

+

— Ууу е**ный понос, с е**ной диареей, как же меня задолбали. Чуть не обосрался. - и дальше последовал + звук открывающейся унитазной крышки. — Ан не, обосрался. Черт. Опять Маргарет будет ругаться. Эх... +

+

И было в его голосе столько печали, что я может быть его и пожалел бы... Если бы не последовавшие за этим + натужные хрипы, булькающие звуки и непередаваемое амбре... Хотя, какое там непередаваемое - вонючий + запах застоявшегося говна. +

+

Оставалось лишь тихо лежать в корзинке и зажав нос желать для сруна самых страшных кар от могучей + Маргарет. Пусть сковородку ему в жопу запихает, что ли... А то уж слишком страшное у этого мужика + биологическое оружие массового поражения в заднице. +

+

— Уф, кажется все. - при этих словах, моя радость наверное была даже больше чем у него. — Ай-ссс, + интересно, у меня остались чистые труселя? - от бухнувшихся сверху смердящих семейников я чуть было не + впал в состояние яростного берсеркера. Ебона вошь, какого наха!? +

+

Пока я повторял в голове монашеские мантры и старался обуздать свою полыхающую ярость, ночной срун, + укутавшись в свой халат, наконец свалил в свою комнату. +

+

Выскочив из корзины, словно черт из табакерки я начал яростно шипеть и отряхиваться. Чертов ирод! + Кидаться обосраным бельем! +

+

Побомбив еще с минуту и еле успокоив себя, я, с желанием быстрее все закончить, пошел в сторону выхода в + коридор. Чутко прислушавшись и даже усилив слух с помощью Ци, я, наконец ощутил радость. Кроме + предсонного ворчания сруна, равномерного тика механических часиков и капающих где-то на первом этаже + капель воды не было слышно ни одного лишнего звука. Ни топающих тебе в коридоре жильцов, ни орущих песен + девок. Тишина! +

+

Стелющимися движениями, не шагая, а словно вытекая в новую позицию, я совершенно бесшумно передвигался в + сторону рабочего кабинета. По нескольку раз забираться в один и тот же дом, получать на голову плохо + пахнущие труселя, конечно, интересно. Но мне почему-то сегодня больше совсем не хотелось испытывать + подобное еще раз. +

+

Наконец, добравшись до кабинета и осмотрев дверь на предмет неприятных сюрпризов вроде сигналки, я + медленно открыл дверь. Через щель опять же рассматривая комнату в ожидании подвоха. То, что в доме в + целом отсутствуют камеры и специальная сигнализация, совсем не означает, что так же будет в рабочем + кабинете. +

+

Но капризная дама Фортуна, похоже, решила, что на сегодня мне хватит неприятных ситуаций и все дальнейшие + дела прошли тихо и без проблем. Как и полагается работать профессиональным ворам. +

+

За минуту открыв с помощью отмычки не самый трудный замок от сейфа, который прятался в шкафу, я вытащил + на свет божий черную коробку. А услышав булькающие звуки внутри, невольно нахмурился. Меня что, послали + похитить бутылку алкоголя!? +

+

Подавив раздражение и проговорив несколько раз внутри себя, словно мантру "Клиент прав, пока платит" я + так же тихо и без особых усилий закрыл сейф. +

+

Обратный путь занял буквально десяток секунд. Так же тихо и неспешно дойти до ванной и беззвучно шмыгнуть + в окно. Когда я вручал черную коробку своему наставнику, на моем лице играла улыбка истинной радости. +

+

— Лови старик! Там была только одна коробка и я её принес. Это, надеюсь, она?

+

Поглядев на меня и почему-то грустно улыбнувшись, старик кивнул.

+

— Она. Молодец малыш. Ты доказал, что уже профи. - на этих словах, вспоминая свое похождение, я невольно + криво усмехнулся. — Все, теперь беги домой. Я сам вручу заказ нанимателю а оплату завтра переведу на + твой счет. Пару таких дел под моим надзором и, наверное, твое ученичество можно будет завершать. А пока + что, ближайшие дни, можешь отдохнуть. +

+

Чуть ли не подпрыгнув от радости, я козырнул своему старику и скорым шагом пошел в сторону больших улиц. + Ох, как же я соскучился по домашней кроватке, жди меня кроватка, я скоро приду! И пусть только попробует + кто-нибудь остановить меня на этом священном пути! +

+

***

+

Поглядев вслед весело шагающему пареньку старый вор грустно вздохнул.

+

Казалось бы, талантливый ученик должен был дарить только радость своему учителю. Но его Том был даже + слишком талантлив. И в сердце уже давно не молодого мужчины зарождалась грусть. +

+

Он, наверное, в первую очередь хотел себе не ученика, а близкого к сердцу человека, которому можно было + подарить свою заботу и любовь, получая в ответ то же. Но Том...Том был слишком талантлив, слишком + независим и слишком своеволен. Забота и любовь старика его слабо интересовали. И он, ворвавшись в его + жизнь словно комета, так же поспешно старался упорхнуть из его небосвода в самостоятельный полет. +

+

Устало вздохнув и вытащив из коробки дорогой виски, Стивен начал его бездумно рассматривать.

+

"Chivas Regal 1997"

+

Хотя, у него ведь и так есть маленькая семья...

+

С улыбкой хмыкнув и взяв телефон, старый вор сделал звонок своему первому ученику у которого и была + позаимствована эта бутылка дорогого шотландского виски. +

+

— Алло, Билл? Поднимай свою тощую задницу, бери пару рюмок и подходи к скамейке напротив дома... Да-да, я + знаю что Маргарет тебя убьет и завтра тебе на работу. Но у меня тут пойло высшего сорта! Так что давай + быстрее, у меня руки так и чешутся, что я могу не сдержаться и выпить все один! +

+

Услышав ворчание про старого демона искусителя, старый вор, довольно рассмеявшись, откинулся на спинку + скамейки. Ох и ругать их будет завтра маленькая Джинни... +

+ +
+
+ + <p>Глава 7</p> + +

— То-о-ом, ты сегодня будешь дома? - прокричала из своей комнаты мама.

+

Не желая поднимать свою ленивую задницу с теплого дивана я так же прокричал в ответ:

+

— Да, мам, сегодня я дома. Устал чет, да и тут интересные мультики.

+

Звуки недовольного бормотания мамы заглушили звуки телевизора. В котором синий кот и по совместительству + мой тезка гонялся за каким-то ловким мышонком. Что удивительно, было вправду интересно и забавно + наблюдать за злоключениями жителей домашнего царства. +

+

Чем-то напоминало мою прошлую реинкарнацию, когда в облике мышонка приходилось выживать в доме у + любительницы кошек. Про то, что я сам туда поперся жить, пожалуй, умолчу. Но весь смак той жизни был в + бесконечных войнах с усатыми хищниками. +

+

Так что в продукте местных мультипликаторов, я, казалось, видел экранизацию своей жизни. И ни капли не + жалел своего тезку и наслаждался гипертрофированными битвами вечных соперников, в которых неизменно + побеждала мышка, как, собственно, и я в своей прошлой жизни. Хотя, конец тогда был немного печальным, + да... Этого не отнять +

+

— Том, я в салон, дальше погулять. - уведомила меня мама, когда я досматривал последние минуты мультика. + Повернув голову, в прихожую, я задумчиво уставился на нее, в полном боевом облачении надевающую черные + туфельки на шпильках. +

+

Черное облегающее платье с разрезом до бедер по бокам и открытой спиной, как-то не сочетающееся с легким + неброским макияжем и по-простому собранными волосами. Сразу стало понятно зачем ей в салон. Но куда она + собирается или, скорее, с кем, было все еще загадкой, учитывая нетипично дорогое платье. Хотя... честно + говоря, было не особо и интересно. Новый ухажер? Пусть. Хочет шиковать? Да ей богу! Благо денег + достаточно, чтобы она не ударила в грязь лицом даже перед самыми богатыми представителями Нью-Йорка. +

+

А если она все же сможет каким-то образом потратить оставшиеся после покупки машины 30 с чем-то тысяч + долларов, то все равно остаются те самые богатые представители Нью-Йорка, у которых можно + позаимствовать. Хе-хе. +

+

Хотя, будем честны. Желание матери, первым делом купить не дом в нормальном районе, а крутую машину, + порядком меня удивило. Сколько людей, столько и бзиков. Вот и у моей мамы оказался бзик на большие + машины. И она, потратив 78 тысяч зеленых, купила себе новый Cadillac Escalade. Хрен его знает, чем она + так хороша, но Марта от покупки была в восторге. А поскольку она его покупала еще когда я был на + ученичестве, увидеть ее машину я смог лишь вчера, когда пришел после своего первого дела. +

+

Выйдя к балкону я еще раз с усмешкой оглядел этого железного монстра, в салон которого в этот момент + садилась хрупкая фигура матери. Настолько странная картина, что контраст резал глаза. Хотя... если + добавить сюда откровенно трущобный район, мусор, валяющийся тут и там, контраст с огромным черным, но в + то же время элегантным представительским каром становился просто ослепляющим. +

+

Было даже странно, что уличная шпана не стала трогать столь экзотическую машину на своей территории. + Наверняка, посчитала транспортом какого-нибудь богача или шишки из преступного мира, (что, впрочем, одно + и тоже) который он подарил своей любовнице. А дураков перебегать дорогу таким мастодонтам здесь нет. +

+

Знание о моей матери и то, что она не могла позволить себе такую машину, лишь подкрепляло это ложное + представление, которое я не собирался развеивать. +

+

Скучающе обведя взглядом ставший серым и тихим, после отключения телевизора, дом, я взял в руки свою + верную черную ветровку и пошел в сторону выхода. Тоже погулять по большому городу. +

+

Не обращая внимания на пару спящих в подъезде алкашей или на еще не менее обычную картину мусора и трущоб + на улице я неспешным шагом направился в сторону более благополучных районов, в которых обычно сам и + подрабатывал когда-то. Но теперь-то я не простой карманник, а целый вор! Так что теперь я отправлялся + туда лишь погулять. Впервые за долгое время у меня был до омерзения миролюбивый характер. Хотелось лишь + спокойствия и сладкого. Никаких битв, сложных заданий и головоломок. Только спокойствие... и сладкого. А + раз я хочу, то я буду! +

+

Важно кивнув своим мыслям я подошел и купил в ближайшем лотке самое большое мороженое. Зачем ждать-то, + раз хочется? +

+

Так, медленно поедая мороженку, я совсем разленившись, остановил такси до района Бруклинских мостов. + Можно было бы и так дойти пешочком, за час с лишним. Но горячее солнце и духота отбили все мало-мальские + желания ходить на своих двоих. Так что, потешив своего распоясавшегося ленивца, я решил доехать с + ветерком. Что, честно говоря, не особо получилось из-за сладкого на руках, которое так и норовило + растечься под ветром из окна. +

+

Не спеша покидать машину и обведя взглядом столь привычное место, я не нашел ничего интересного и нового, + поэтому не много думая, барским жестом махнул рукой в сторону водителя: +

+

— Шеф, врубай счетчик и гони в Манхэттен, сразу в Тайм-Сквер

+

Оглядев меня через зеркало заднего вида и с отеческой улыбкой кивнув, таксист ловко вклинился в поток + машин, который нескончаемым потоком вливался в Бруклинский мост. +

+

Вся поездка по мосту, которая заняла всего несколько минут, была наполнена приятной морской свежестью и + офигенными видами на Ист-Ривер и сам Нью-Йорк. Этот мегаполис, конечно, не изящный Аль'Сиэль высших или + даже не футуристичный Роун гарлуков, но тоже по-своему красив. Все эти многометровые исполины из бетона + и стекла, отражающие тысячами бликов солнечный свет, были красивы по своему. И даже вызывали + варварско-исследовательский зуд, хотелось похулиганить и поглядеть, как красиво они будут разрушаться... +

+

Я настолько глубоко погрузился в свои заманчивые мысли и вспоминания из прошлого, когда приходилось(или + хотелось, хе) устраивать тотальный экстерминатус, что только после покашливания таксиста понял, что мы + на месте. Расплатившись с таксистом и легко выпрыгнув на улицу, я бодрым шагом погрузился в людское + море, которое хаотично перемещалось по огромной площади. +

+

Столь знаменитый Тайм-Сквер, в котором даже мне, прирожденному нью-йоркцу, доводилось бывать ранее лишь + раз. Место наполненное туристами, неоновыми вывесками и кусачими ценами... +

+

В последнем я невольно убедился зайдя в первое попавшееся кафе. Цены там были в несколько раз выше, чем в + том же Бруклине. Пожав плечами и презрительно фыркнув ценам, на которые мне, честно говоря, было + плевать, я с удовольствием заказал столь желанные пирожные и молочный коктейль. Хотелось бы, конечно, в + качестве напитка чего-нибудь горячительного и расслабляющего, но ведь эти гады и моралисты не дадут! +

+

Но даже на это, спустя пяток минут, стало плевать. Поскольку пирожные оказались до неприличия вкусными! +

+

И может я так и сидел бы, с удовольствием кушая разные яства да поглядывая на людей за окном кафе. Но + внезапные панические вопли и начинающаяся истерика на улице заставили недовольно нахмуриться. Не очень + приятно, знаете ли, когда твоему умиротворенному и спокойному состоянию мешают всепоглощающие эманации + страха и ужаса. +

+

Недовольно поморщившись, поспешно запихав в рот остатки сладости и кинув на стол пару купюр номиналом в + сто баксов, я неспешно зашагал в сторону выхода. Хоть и было обидно за сорванный отдых, но в то же время + было жутко интересно, что же такое случилось на улице. +

+

Мой презрительно-скучающий взгляд, наблюдающий за истеричным бегом людского племени сразу сменился + заинтересованным, стоило увидеть человека в красном костюме, который стоя на вертикальной(!) стене + стрелял из двух рук каким-то клеем в определенную точку из толпы. +

+

С загоревшимися от любопытства глазами я поспешно пошел в его сторону, врываясь в людской поток и на ходу + накидывая капюшон и закрывая лицо банданой, которая лежала в кармане ветровки. +

+

Как бы там ситуация ни повернулась, мне совсем не хотелось мелькать своей блондинистой шевелюрой и + красивым личиком где-нибудь в вечерних новостях. А учитывая место происшествия, можно было быть + полностью уверенным, что телевизионщики это событие своим вниманием не обделят. Это ведь не какой-то там + махач за городом и у какого -то богача на заднем дворе. Это Тайм-Сквер, мать его! +

+

С улыбкой вырвавшись в пустое пространство, которое огибали все бегущие, я с восторгом оглядел великана в + странной пижаме, которого в своем клее пытался утопить красненький. Хотя, с близкого расстояние, этот + клей стал чем-то похожим на паутину. И теперь я наконец узнал красного человека, ходящего по стенам и + стреляющего паутиной, пытающегося заплести в кокон великана. +

+

Тот самый Человек-паук! Новый герой большого города, только недавно начавший геройствовать, причиняя + добро и вбивая справедливость. И пока не был слишком популярен, воспринимаясь народом Большого яблока, + привыкшему к бесчинствам сверхов, еще с подозрением. Ну... думаю, после сегодняшнего дня его уж точно + причислят к сонму хороших парней. +

+

С яростным рыком порвав сковывающую паутину, пижамоносец в одно движение вырвал с земли и кинул в своего + обидчика фонарный столб. От которого молодой герой легко увернулся, но потом, увидев, как он падает на + прохожих, в последний момент смог подхватить обратно своей паутиной. +

+

Воспользовавшись моментом, большой и хитрый великан подхватил с земли большой инкассаторский мешок и на + полной побежал в сторону от людного района. Что удивительно, скорость у этой ошибки природы была + внушающей. Под полсотни кэмэ! Да еще бега в режиме танка, когда любые препятствия не стоят и капли + внимания. Одним взмахом свободного от мешка руки он на легкую переворачивал встречающиеся машины и + пинками отбрасывал встречных людей. +

+

Боже, да сколько в нем дури-то!?

+

Восхищенно подумал я, уже догоняя его со спины. Паучок тоже не отставал, мелькая где-то наверху. Прям + догонялки! +

+

С безумной улыбкой, так и так обкатав эту идею, я резко ускорился. И за несколько ударов сердца догнав + великана, поднырнул под его руку и ловко умыкнул мешок денег. +

+

— Что за...

+

— Теперь ты водишь! - и с радостным смехом, показав ему язык, добавил газу.

+

То, что он из-за банданы не увидел моего языка, не сделало его менее злющим. И за мной, сминая все, что я + перескакивал, перся сыпящий угрозами мясной танк. +

+

И что удивительно, сколько бы я не прибавлял скорости, он совсем не собирался отставать, все больше + ускоряясь и в какой-то момент начав догонять меня. А мой запас Ци был совсем не бездонным. Так что + решив, что пытаться ускользнуть напрямую бесполезно, я резко повернул в сторону одного из проулков между + зданий. Не такой легкий, как я и набравший приличное ускорение великан не смог остановиться так же + быстро и непринужденно, своим торможением снес кусок асфальта в несколько метров. +

+

— Догоняй, жирный!

+

Подначив его еще сильнее и увернувшись от в ярости кинутой скамейки я уже без накачки тела Ци на своих + ресурсах припустил в переулок. Где и без Ци получалось акробатикой и банально петляя избегать ударов + великана. Но от предчувствия того, что если он в меня все же удачно попадет, то даже несмотря на + усиленный организм я легко не отделаюсь, в крови играл адреналин. +

+

К черту спокойствие, такое времяпровождение гораздо интереснее!

+

С хищной улыбкой увернувшись от его очередного удара, я чуть было не нанизался на железные трубы + лестничного пролета. Совсем из головы вылетела его танковитость и, вильнув под пожарную лестницу, я + оказался совсем не готов к тому, что он просто её снесет мне на голову, сбив плечом. Лишь бухнув в + организм очередную порцию Ци я смог в последний момент ускользнуть от падающей лестницы. +

+

Истерично и обрадованно рассмеялся от кольнувшего сердце...нет не страха, а напряжения. Это было близко, + близко к тому, чтобы неожиданно и бесславно отправиться к следующему по "колесу Сансары" миру. +

+

Не став ожидать конца моего смеха, великан без лишних слов атаковал, как и подобает в настоящей схватке. + Легко увернувшись от его ужасающе мощного, в меру быстрого, но до жути предсказуемого удара, я сделал + молниеносную контратаку, врезав со всей дури по большому носу. +

+

Удар, даже без Ци, получился настолько мощным, что бедный гигант даже сделал пару шагов назад, зажимая + пораженное место. +

+

— Хо? Так сильно подействовало? Я думал, ты будешь покрепче... - с истинным сокрушением и печалью сказал + я, оглядывая его тело поподробнее. А ведь, судя по его одежде, которая прикрывала его тело полностью, + кроме лица, мне не повезло ударить его в слабое место. А может..? +

+

— Ах-ты ж гребаный мальчишка, да я...

+

— Так, держи удар. - небрежно бросив ему, максимально ускорился, без подпитки жизненной энергией.

+

От первого мощного удара левой по почке, который гарантированно размазал бы орган простого человека по + всему нутру, он лишь вздрогнул, стремительно разворачиваясь без капли потери скорости. Уворот от удара + локтем и уже удар ногой в грудь повернувшемуся сопернику, заставил его сделать лишь полшага назад. Опять + же без видимого урона. +

+

Начиная загораться азартом я начал методично обрабатывать его ударами по всему телу и болевым точкам... +

+

***

+

Когда немного отставший из-за помощи пострадавшим Питер, наконец, догнал своего противника и малорослого + представителя третей стороны, он был очень удивлен. И слово "очень" лишь мягко описывало степень его + чувства удивления. Паркер, откровенно говоря, был в а*уе! +

+

Малорослый парень (считать этого монстра ребенком мозг Питера отказывался) на какой-то нереальной + скорости кружился вокруг резко контрастирующего с ним гиганта и сыпал сложнейшими сериями ударов. И + Носорог, как казалось ранее, имеющий неуязвимое тело, морщился от боли и пытался поймать вертлявого + соперника на свои кулаки, что, впрочем, получалось очень не очень. +

+

Даже более, с каждой пройденной секундой противник гиганта становился все быстрее, а удары все + сокрушительнее. Уж очень был показательным шатающийся после каждого удара Носорог. +

+

В какой-то момент парень вообще стал одним черным еле заметным вихрем а его противник, совсем забросив + свои попытки атаковать, мог лишь шататься, сморщившись и еле стоя на ногах. +

+

Спустя несколько секунд карлик резко взмыл на несколько метров вверх и мощно оттолкнулся от стены, + развалил его нахрен сокрушающим ударом ноги в затылок, от которого даже асфальт под ногами просел на + десяток сантиметров, чем эффектно завершил битву. +

+

— Ух-х, и вправду, годная груша получилась. Я даже вспотел! - довольный размяв руки и потянувшись он + обратил внимание на Питера. — О, Паучок! Как раз вовремя, пригляди за этим крепышом, пока законники не + придут. Я бы сам с радостью, но мне уже домой пора. Детский час, все дела... - и все это детским + голосом... +

+

И от осознания того факта, что под маской реально скрывается какой-то мальчишка, у Паркера случился + когнитивный диссонанс, который он смог одолеть только когда парень начал по-хозяйски копаться в мешке с + деньгами, попутно рассовывая содержимое по карманам. +

+

— Стой!

+

— Чего?

+

— Там не твои деньги!

+

— Хочешь остановить меня? - от этого простого вопроса, заданного с легким интересом и по-птичьи + склоненной головой, у Питера по спине пробежали капельки холодного пота. +

+

Осознанно вставший на путь героя и обладающий немалой силой парень, который уже не раз успел смахнуться с + другими сверхами, впервые ощутил не только неуверенность, но и нежелание сражаться с противником. Но + долг требовал остановить нарушителя. +

+

— Я обязан вернуть все деньги банку! - отчаянно храбрясь, сказал парень, уже напряженный и готовый, к + казалось бы, неминуемому сражению. +

+

Побуравив нечитаемым взглядом Паркера и усмехнувшись, мальчишка отвернулся и в ответ небрежно бросил:

+

— Ну так верни... - и схватив весь мешок, вместе с ним прыгнул в окно ближайшего дома. Под звон стекла и + с веселым смехом. +

+

За секунду сориентировавшись, молодой герой прыгнул вслед за новым нарушителем. Но не смотря на паучье + чутье, которое в последний момент буквально взвыло, Питер так и не смог увернуться от смазанного от + скорости удара ногой в голову. Хитрый мальчишка, оказывается, так и не стал убегать, встретив его в + первой же комнате неожиданным ударом. +

+

...Только очнувшись, новый герой Нью-Йорка, осознал, что потерял сознание после удара. Вскочив с места и + поглядев по сторонам, он ожидаемо не увидел наглого воришку, но что удивительно, увидел на пыльном + диване мешок с инкассаторскими деньгами, сверху которого лежала стодолларовая купюра, исписанная + красивым убористым почерком: +

+

Хей, паучок! Решил оставить мешочек Санты, чтобы злые горожане не повесили всех собак на тебя, посчитав, + что деньги украл именно ты. Но поскольку я свою долю все же забрал, а ты так уж жаждешь вернуть деньги + "городу", советую тебе остатки разбросать по всей пройденной дороге между домов а самую большую часть + оставить в мешке рядом с великаном. +

+

P.s.Он еще должен крепко спать, когда ты проснешься. Тебя я стукнул не сильно...Вроде бы. С любовью, + Том! +

+

Пару раз матюгнув гадкого мал...Тома, Питер пошел исполнять его совет. Все же здравое зерно в его + рассуждениях было. Как бы ни было неприятно, придется бросать деньги на воздух...В буквальном смысле... + А то ведь аукнется именно ему, а не наглому вору... +

+ +
+ + <p>Примечание к части</p> + +

Спасибо. Могу сказать только огромное спасибо. За то, что вы рядом, читаете и так активно меня + поддерживаете. Вот и в благодарность, к юбилею в 100+ лайков (я уже звизда?:D), очередная часть + моего графоманства =) +
+ Больше проды на этой неделе и даже в этом месяце можете не ждать хD +
+
+ №11 в топе «Джен по жанру Стёб» +
+ №13 в топе «Джен по жанру Экшн (action)» и еще не самые высокие позиции +5 топах и за это очередное + большое спасибо вам) +

+
+ > +
+
+ + <p>Глава 8</p> + +

— Джон, но ведь профессор запретил лезть к нему! Может не стоит?

+

— Да что ты ноешь, как девочка, Бобби?! - недовольно цыкнул на своего друга и соперника Пиро, даже не + сбавив шагу. — Если бы они и вправду не хотели,что бы мы лезли к нему, то Гамбит не дал бы мне его + адреса. Думаю, он тоже хочет, чтобы мы преподали урок этому мелкому. +

+

— Но профессор...

+

— Да хватит уже! Он даже ничего не узнает! Просто тихо пойдем, дадим звездюлей и сразу домой. Хотя... + если ты такой трус, оставайся. +

+

— Господи, Джон, он смог положить половину взрослой команды в больничную койку, а от остальных убежать! + Что ты собираешься ему сделать в одиночку!? +

+

— Он всего лишь быстрый и сильный, от моего огня ему не уйти. А взрослые, пф... они просто не хотели его + по-настоящему убивать. - вспоминая, как с неба били толстые молнии, Бобби с сомнением глянул на своего + товарища. — Как только Джин начала входить в раж, он просто сбежал как трусливый пес! +

+

Недовольно оглядев повелителя огня, Айсмен сокрушенно вздохнул.

+

— Ладно, я с тобой. Тебя опасно отправлять одного на такое дело. - пробурчал он, продолжая тихо шагать в + сторону выхода со школы. Ведь не мог же он реально оставить всю славу победителя мудака Тома ему одному? + Позволив обойти себя в битве за внимание новенькой красавицы... +

+

Вот так двое молодых парней-мутантов, сев на маршрутный автобус, отправились на ратные подвиги.

+

***

+

— Ма, я дома... - устало прокричал я, заходя домой. А в ответ тишина.

+

Непринужденно пожав плечами я, так же устало, пошел в сторону ванной.

+

Наверное, все еще не пришла со своей "прогулки", да и, в принципе, не важно это. Просто если бы она была + дома, пришлось бы еще объясняться с ней, с чего у меня одежда такая грязная да еще в порезах. А мне, + откровенно, говоря было влом. Все, что крутилось в голове - лишь смыть весь грязь и пот, пожрать, потом + завалиться спать. Нехило я так потратился на сегодняшний показной бой, так что просто жаждал + восстановить потраченные силы. Поэтому то, что ее нет дома, было приятным плюсом. +

+

Неспешно раздевшись и так же лениво неспешным шагом дойдя до ванной я с удовольствием встал под холодные + струйки душа. Блаженство... Самое то после напряженного денька. +

+

Начавшийся так спокойно день немного резко, но от этого не менее приятно, перетек в так мной любимый + экшн. Деньги, гонки (хоть и на своих двоих) и эпичный махач. Ничего не бодрит так, как риск для жизни по + утрам. Хотя мне в этот раз и в обед неплохо зашло. Хе. +

+

С удовольствием подискуссировал с гигантом, используя тяжелые аргументы. Познакомился с паучком и + познакомил его со своей приветливой ногой. И даже деньжат смог заработать! Чем не хороший день-то? + Теперь можно и от души поесть и поспать. +

+

С улыбкой монаха, постигшего дзен, я, легкой походкой вышел из-под душа и, на ходу накинув на плечи + полотенце, сразу почапал в сторону филиала рая на земле - кухни. Место, где хранятся великие сокровища + вселенной - вкусняшки... +

+

Когда я уже уничтожал вторую порцию тефтелек, закусывая сладкими пирожными (О вкусах не спорят!), в дверь + внушительно так постучали. Посмотрев через окно и убедившись, что маминой машины не видно, я плюнул на + неизвестных посетителей. Я никого не ждал, а мамины Дон Жуаны и просто знакомые пусть приходят, когда + она будет дома. +

+

— Мамы дома нет. Приходите завтра! - проорал я, сидя на своем месте.

+

Но даже спустя полминуты в дверь продолжали колотить без лишних слов.

+

— Вы кто такие!? Я вас не звал... Идите на*уй! - уже с раздражением прокричал я, перед тем, как + приступить к следующей порции сладкого. И, наконец-то, после этого за дверью наступила тишина. +

+

Не успел я как следует обрадоваться, как еще через минуту повеяло паленым а замок входной двери начал + медленно наливаться красным. +

+

Пока я думал, как реагировать в такой ситуации и вообще, стоит ли реагировать, замок проплавился и стек + на пол и, в итоге, дверь просто и чинно открыли. +

+

— Н-да... - задумчиво пробормотал я, глядя, как смутно знакомый парень накрывает льдом расплескавшиеся + капли расплавленного метала. +

+

— Прости, просто ты не откры...

+

— Да господи, Бобби! Нахрена ты извиняешься у него дома!? Он половину нашего особняка снес!

+

А, да. Парень со льдом мелькал в школе профессора Ксавьера. А по тому, как негодовал блондин, можно было + сделать предположение, что и он был оттуда. Уже по-другому на них посмотрев я, всеми возможными + способами промониторил окружение. +

+

— Хм, странно... Вы что, только вдвоем пришли мстить?

+

— На такого шкета и нас двоих хватит. - презрительно сказал тот же блондин, взмахом руки вызывая огонь. +

+

— Джон! Мы не убивать пришли!

+

— Захлопнись, я и не собираюсь его убивать. Просто оставлю несколько паленых отметин...- покрыв руку + льдом, чернявый затушил огонь своего вспыльчивого друга. +

+

— Давай лучше я. Мой лед безопаснее.

+

"Драму заказывали? Нет? А она уже тут..." пришла в голову нелепая мысль, пока я наблюдал, как мстюны + спорят, кто именно будет мне мстить. +

+

— Хей-хей, ребят, может, мне стоит выбрать, кто именно будет мне мстить?

+

Оглянувшись на меня и злобно сверкнув глазами, огонек всплеском пламени поджег весь коридор.

+

— Э-э-эй, ледышка, потуши этот огонь! - немного в панике воскликнул я, боясь, что огонь может дойти до + ванной и поджечь все честно награбленное. +

+

Но он и без моих слов уже покрывал льдом все горящие участки.

+

— Это месть... Да хватит мне мешать! - яростно воскликнул названный Джоном и пламенным кулаком дал под + дых своему товарищу. Охнув от неожиданности, холодный парень сделал шикарный удар в челюсть, который, к + несчастью, ушел в молоко. Но зато оставил внушительную дырку в стене. В стене ванной комнаты! +

+

В раздражении смяв две чайные ложки до шарообразной формы я, с нехилой силой, метнул их в головы + визитеров. Чертовы ироды! +

+

***

+

— Вот если бы вы завтра пришли, да подальше от дома, я бы с вами поиграл. А то ведь я сегодня и так + усталый, намахался, так сказать. А тут вы приперлись, дом мой рушите, договориться не можете... Меня же + теперь мама будет ругать, она завещала не шалить, а тут вы, пошалили и за меня и за себя. Не по понятиям + это, ребят, совсем не по понятиям... - недовольно гундел я, таща по лестнице бессознательные тела с + большущими шишками на лбах. +

+

Наконец-то выйдя на улицу и подправив висящее на поясе полотенце, я достал из кармана блондина его + телефон. +

+

— Так-так, где это у нас. А, да, вот. - с удовлетворением найдя в списке контактов профессора, я, не + задумываясь, позвонил. — Алло, профессор? Вы меня уважаете? +

+

— ...Том?

+

— Да-да, так уважаете или как?

+

— Почему ты звонишь с телефона Джонатана? Где он сам?! - последний вопрос уже был наполнен некоей долей + угрозы. +

+

— Звоню с его, потому что он ближе всего, а мой черт знает где. Звоню с его, потому что он со своим + ледяным дружком сам ко мне приперся. И, наконец, звоню с его, потому что его тушка без сознания лежит у + моего дома. Забирайте его, проф. И в следующий раз, если уж отправляете, отправляйте кого-нибудь... + более ответственного. +

+

— Я их не отпра...

+

— Это меня не ибеть, проф. - уже с раздражением грубо ответил я. — Забирайте их.

+

Не став дослушивать дальнейшую речь телепата я просто завершил звонок. Все же насыщенность сегодняшнего + дня, похоже, даже для скучающего меня, стала перебором. +

+

— Че смотришь, епт? Тебе хер оторвать?! - давя концентрацией Ки, злобно буркнул я заинтересованно + смотрящему на меня какому-то гомику. Не обращая внимания на в панике убежавшего цветного, я, уже с + другим интересом оглядел лежащих без сознания парней. — Гомики... Хм... +

+

***

+

В комнате для девушек стояла легкая и ненавязчивая тишина. Даже чрезмерно активная Джубили, копаясь в + своем телефоне, не тревожила эту приятную, тихую атмосферу. +

+

Ведь у всех должна быть отдушина? Для Шельмы таковой стали книжки, которые могли отвлечь от жестокой + реальности. И в отсутствие навязчивых парней, да в такой благостной тишине, Шельма наконец-то могла + посидеть и почитать свой интересный роман. Лепота... +

+

Когда она только-только заканчивала читать первую страницу очередной главы, тишину пронзил многоголосый + хор телефонных уведомлений. Момент был настолько неожиданным, что даже Джин, расчесывающая волосы после + душа, заинтересованно взялась за телефон. И Роуг, не желая отставать от своих подруг, тоже потянулась к + своей мобилке. +

+

— И-и-и! Девочки, тут Бобби и Джон!- первой узнать причину бомбардировки уведомлениями смогла Джубили, у + которой телефон находился уже в руке. +

+

— И они целуются! - пораженно добавила Китти, которая тоже успела открыть ММС сообщение.

+

Лихорадочно открыв сообщение и с ворохом крутящихся в голове вопросов, Шельма пораженно застыла.

+

Несколько фоток полуголых парней. И на самой первой они реально, сидя, оперевшись о стену, самозабвенно + целовались. На второй уже лежащие парни с переплетенными телами. На третьей почти та же поза, только они + почти голые... и целуются. +

+

После просмотра на лице Шельмы был недетский такой шок. А вот остальные, даже Джин, горели румянцем и с + жадностью рассматривали фотки. Не став даже задаваться вопросом, что за херня с их ориентацией, кто + фоткал и зачем, Шельма тоже начала с удовольствием рассматривать посылку. Ведь это было...Так + прекрасно... +

+

А ответы на логичные вопросы можно узнать и потом

+ +
+ + <p>Примечание к части</p> + +

В первую очередь хотел бы выразить благодарность пользователю Tamop. Который огненным мечом логики и + правил русского языка, прошел по первым главам этого произведения. Спасибо тебе добрый чел=) +
+
+ Прошу прощения за маленький размер проды, просто описал одно событие, с логичным концом:)) +
+ P.s. Яойщицы, ликуйте, пхпх. А я пожалуй спать) +
+
+

+
+ > +
+
+ + <p>Глава 9</p> + +

— Тебе нравится? - с любопытством спросила мама, мечтательно разглядывая живописный вид из панорамного + окна, в то время как я просто лежал и утопал в удобном кресле. Виды меня мало волновали и все мысли были + направлены на то, где бы еще реквизировать немножко денег. Уж очень кусачими оказались цены в этом + элитном районе. Впрочем, стоит признать, вид, открывающийся с восемнадцатого этажа на побережье + Ист-Ривера и стеклянный Манхеттен, и вправду был красивым. Но, по-моему, цена в два миллиона долларов за + девяносто квадратных метров была... слегка очень высоковата. +

+

Но обещание надо было держать. В тот вечер, после пришествия народных мстюнов, она навеселе пришла домой + и удивленно хмыкнув с ехидцой спросила: неужели я затеял ремонт? +

+

А я что? Взял и ляпнул, что таки да, хотел, но не получается. И теперь я хочу переехать из этого + клоповника. Даже по щедроте душевной предложил именно ей выбрать нам новый дом. Думал, что тех семи + пачек зеленых, реквизированных за помощь городу в борьбе с преступностью, и трех пачек, которые лежали в + банке, вполне хватит на первоначальный взнос для покупки нормального жилья. +

+

Но мама, желающая жить на широкую ногу, уже к завтрашнему вечеру, нашла эту квартиру и даже смогла + договориться внести в качестве первоначальной "всего лишь" имеющиеся сто тысяч долларов, с договором о + зачислении остальной суммы в течении месяца. +

+

Если бы она, перед тем как соглашаться покупать квартиру, не посоветовалась со мной, я бы, может, и + послал ее подальше, возмутившись ее аппетитам. Но теперь это уже было дело принципа. Исполнить обещание, + несмотря на размеры жопы. Да и задачка была, по сути, интересной - добыть из воздуха два лимона. +

+

— Надо было с собой сразу мешок забирать. - пробормотал я себе под нос, глядя как Марта, светясь от + радости, выходит на большой балкон +

+

Иногда мне даже кажется, что это именно она моя дочь, а не я ее сын. И ее слишком большая вера в меня + немного смущает. Похоже, то, что я легко добываю казавшиеся большими суммы денег и уже заканчиваю + обучение в качестве профессионального вора, слишком ее расслабили. Она, кажется, уже подзабыла, что + физически мне еще даже тринадцати нету. Хотя, в принципе, и неудивительно. С таким-то идеальным сыном + как я, хе-хе. +

+

Я бы тоже любил и уважал своего мелкого сына, если бы он не норовил обкакаться, не плакал ночами и тихо + лежал в кроватке днем, когда родитель занят другими делами. Да, я бы уважал. Но все мои бывшие сорок + девять тысяч сто семнадцать отпрысков, которых я знал и растил лично, не давали мне повода обрадоваться + таким приятным мелочам. Но, думаю, Марта уж точно не сможет пожаловаться на меня в этом вопросе. Я был + настолько хорошим ребенком, что даже какал по расписанию. А с достижением одного годика, вообще стал сам + ходить в горшок. +

+

Ну не нравится мне в своем говне валяться, что поделать-то. Особенно зная, что старая матрона, которая за + мной присматривала, как и за несколькими детьми других проституток, не очень-то желала менять воняющие + пеленки. Так что выбор был невелик: либо терпеть, либо ждать. +

+

Так и завелось, что я с самого раннего детства почти никак не обременял Марту. Даже больше – носил домой + всякие мелочи уже с ранних годов. Там у пьяного алкаша из кармана доллар вытащить, тут в магазине + продуктов колбаску спиздить или у соседей с балкона стащить казавшиеся ненужными ролики и по дешевке + пихнуть пацанам из соседнего двора. В общем, делал все, что было в моих невеликих по детству силах. А + как начал шарить по чужим карманам, у мамы отпала надобность работать, поскольку львиную долю доходов + приносил уже я. +

+

И недавняя покупка дорогого авто для нее, похоже, совсем усугубила (если, конечно, тут применимо это + слово) ее веру в меня. +

+

Ну что же, раз она так хочет, побуду персональным волшебником для нее, который может щелчком пальцев + добывать миллионы долларов. Как раз я и волшебник, аж в ранге архимагистра всех известных мне + направлений магий, и знаю, как добывать миллионы. +

+

И поможет мне в этом мой учитель и воровская гильдия. Как раз старик приглашал посетить тренировочную + базу для важного разговора. И я даже подозреваю, что для официального окончания моего ученичества. Так + что после можно было бы без всяких стеснений попросить его найти мне жирный заказ для дебютного дела. А + то тысяча долларов, которую он перевел на мой счет за учебную миссию, смотрелись блекло даже по + сравнению с моими прошлыми доходами. +

+

— Ма, меня старик зовет, так что я пошел. Скорее всего буду поздно.

+

— Удачи! - с той же мечтательной улыбкой кивнула мне она, все так же стоя на балконе.

+

***

+

— Не, так не пойдет. Надо минимум найти летающий ковер или модные сапоги как у Гермеса! - раздраженно + бурча себе под нос, зашел в неприметное наземное здание учебного центра гильдии. +

+

Я потратил полтора часа! Полтора гребаных часа чтобы доехать сюда! Мне в этом городе еще жить и жить, и + каждый раз тратить столько времени на дорогу... Это просто неприемлемо! Хм... Паучка что ли напрячь, + чтобы для меня транспортом подработал? С ним уж точно никакие пробки не страшны! +

+

— Куда? - остановил меня скучающий охранник при входе.

+

— Туда! - передразнил я его, взглядом указав на землю.

+

— Так и зачем тебе сюда, иди туда. - с таким же скучающим видом, но с бесенятами в глазах и ехидцей в + голосе, ответил этот индивидуум. +

+

— Так... Джордж. - прочитав имя на бейджике, продолжил я. — Ты не раз видел меня в компании старого + Стива. И именно он меня пригласил сегодня посетить его, не за*бывай пожалуйста. А то втащу. - хмыкнув + моим угрозам и насмешливо оглядев мою детскую фигуру свысока, он еще раз значительно так хмыкнул. Ууу, + ирод, я бы поглядел на твои хмыки после нескольких панчей в морду. +

+

— Покажи пропуск. Без него не положено.

+

— Я еще на обучении, какой у меня к черту пропуск?! - раздраженно прорычал я, начиная испускать волны ки. + Настороженно оглядев меня и схватившись за свою кобуру, он уже без смеха, но также твердо ответил. +

+

— Прости малец, я знаю, что ты ученик Стива, но без пропуска не положено. Пусть он сам тебя отсюда + заберет под свою ответственность. +

+

Со вздохом подавляя свой гнев, я недовольно поморщился. Все же надо найти средство для полетов. А то + из-за скуки я становлюсь раздражительным и это... тоже раздражает. +

+

Взяв телефон и набрав номер старика, я терпеливо стал ждать ответа, не переставая буравить настороженного + охранника недовольным взглядом. Гад, доебался на пустом месте! +

+

— Алле, старик! Тут какой-то мудак не хочет пускать меня внутрь. Поднимись, пожалуйста. - устало вздохнув + и тоже что-то недовольно буркнув, он отключился. +

+

Спустя еще минуту игры в гляделки с охранником, который успел весь побледнеть и покрыться испариной из-за + моего немигающего взгляда и давящей ауры, старик наконец-то поднялся наверх. +

+

— Джордж, я же тебя предупреждал о том, что сегодня придет мой ученик. Какого хрена?! - сразу пошел в + атаку недовольно пыхтящий Стив. Но увидев еле стоящего на дрожащих ногах охранника, старик непреклонным + тоном воскликнул. — Том! Хватит! +

+

Сделав вперед резкий шаг, я еще сильнее сконцентрировал на нем свое Ки.

+

В ужасе отшатнувшись и запутавшись в своих ногах, охранник свалился на спину. И только тогда, посчитав + себя отомщенным, я полностью свернул свою жажду убийства. Можно было бы, конечно, сразу его убить, + надавив на него на полную мощность, но я до сих пор помнил свое обещание. Обещание не убивать, какими бы + доставучими и раздражительными мразями разумные ни были. +

+

Обведя дрожащего и потного мужика презрительным взглядом, я, гордо подняв свой носик, прошествовал в + сторону Стива, который с неодобрением и затаенной опаской смотрел на меня. Приветственно кивнув ему, я + зашагал в сторону потаенного лифта, который вел на подземные этажи. +

+

— Том, тебе не кажется, что ты был слишком жесток с ним? - недовольно спросил мой учитель, как только мы + оказались в кабине лифта. +

+

— Не-а, в самый раз. Не люблю, когда чужие веселятся за мой счет. Это делать право имею лишь Я.

+

— Но он выполнял лишь свои обязанности!

+

— Он знал, кто я. Ты его предупреждал о том, что я приду. Так что он просто решил повеселиться за мой + счет. +

+

— Но... - посмотрев ему в глаза скучающим взглядом, я лишь приподнял бровь, мол: Ты реально хочешь + продолжать эту тему? — Ладно, к черту. Забыли. Тебя все равно не переубедить, если ты что-то вбил себе в + голову. - в этот момент я не смог сдержаться и гордо вскинул носик. — Хех, паршивец... Так что я хотел + то... А, да. Поздравляю, Том, руководство гильдии официально приняло твою кандидатуру на звание вора. И + присудило прозвище - Суслик. +

+

— ...

+

Видя мою застывшую улыбку и стеклянные глаза от офигевания, Стив с радостью рассмеялся.

+

— Да шучу я, шучу. Прозвище для своего ученика выбирает учитель. А я не могу позволить, чтобы мое имя + прославлял вор с таким именем. Хе-хе. Так что будешь – Мраком! - с гордостью сказал учитель, выпятив + грудь, как будто ожидая медаль. — Было трудно, но я смог выбить такой звучный и грозный псевдоним! + Теперь ты Мрак Нью-Йоркской гильдии воров! +

+

— Ндя... Можно я буду лучше Сусликом? - с надеждой спросил я, и с удовольствием наблюдал за офигеванием + уже на лице Стива. — Один-один, старый. +

+

— Ух, гад мелкий! - пожурил меня старик с улыбкой. — Мастер как раз сделал для тебя личный пропуск, + заберем его и после этого ты сможешь выбрать для себя дебютное задание в качестве профессионального + вора. +

+

Предвкушающе ухмыльнувшись его предложению, я еще бодрее зашагал вслед за стариком по запутанным + коридорам подземелья. И спустя пяток минут блужданий мы дошли до ничем не примечательного кабинета на + третьем подземном этаже. +

+

— Мастер Роу. - уважительно пожал руку моему учителю молодой мужчина. — Как я понимаю, ты – Томас? - + пожимая руку уже мне, спросил хозяин кабинета. Я лишь беззвучно кивнул, рассматривая неожиданно большой + кабинет. +

+

— По вашей просьбе мы сделали пропуск-ключ в виде кулона. - тем временем продолжил ничем по виду не + примечательный хозяин кабинета. — Держи Том, твоя кодовая фраза - В тени не увидеть тень. - с интересом + послушав странное предложение, больше похожее на девиз гильдии убийц и, взяв ромбовидный медальон на + тонкой цепочке с его рук, я стал внимательно рассматривать его необычные узоры. +

+

— Это штрих-код? - удивленно спросил я, поняв, что он мне так напоминает.

+

— Так точно, юноша! - чинно кивнул изготовитель ключа. — Засветив его специальным сканером, простые + охранники могут узнать псевдоним, звание, внешний вид и кодовое слово, которое должен произнести данный + член гильдии. А мастера и другие члены с гораздо высоким уровнем доступа могут узнать больше + персональной информации о хозяине штрих-кода. Вроде возраста, настоящего имени вора, перечня выполненных + заданий и тому подобное. +

+

— Нда... и тут бюрократия и учет. - с отвращением выдал я, невольно вспомнив свою жизнь в качестве + офисного планктона. +

+

— Без него никак. - пожал плечами хозяин кабинета, и я, в принципе, с ним был согласен. — Зато все это + дает высокий уровень защиты. +

+

— А что, если сканер сопрут? - с интересом спросил я.

+

— Каждый сканер имеет защиту и для активации требует специальный ключ-пропуск. А если смогут и его + украсть, то опять же не беда. Таких сканеров не так уж и много и за ними тщательно следят. Если сканер + запросит информацию с сервера в неположенном месте и в неположенное время, то его попросту заблокируют + до выяснения ситуации. +

+

— А что, если просто примут вид члена гильдий? Выбив, вызнав штрих-код и пароль у настоящего хозяина?

+

— В базе данных так же хранятся данные об опечатках пальцев и ДНК. Так что, если охранник заподозрит + что-то неладное, то может проверить на соответствие и эти данные. +

+

Смотря на его уверенный вид творца, который гордится своим творчеством, я невольно усмехнулся. Похоже, он + так полностью и не понял, мои слова о возможности принять вид члена гильдии. Что же тут сказать, + неплохая система...против простых людей. А тот же профессор Ксавьер, в принципе, мог, наплевав на все + системы, пройти в базу прогулочным шагом...если бы, конечно, умел ходить. Хе-хе. +

+

Не дав мне задать следующий вопрос, учитель быстро попрощался и потянул меня в сторону выхода.

+

— Ну и клещ же ты, Томми! - то ли с восхищением, то ли с осуждением протянул Стив. — Если бы не я, ты бы + наверняка из него душу вынул. +

+

— Ну... - задумчиво протянул я, в голове раскидывая варианты. В принципе, если вынуть душу, человек + остается живым. То есть, его тело, в виде овоща. Можно ли такое считать за убийство? Ведь, по идее, его + тело живое... +

+

— Том, не мог бы ты не смотреть на меня таким... странным взглядом. - немного нервно попросил учитель, + прерывая размышления. — Фух, парень, тебе бы коллектором работать. У любого долги выбьешь одним лишь + взглядом. +

+

— Не старик, это скучно... А вот на перечень свободных заданий для профессионального вора я бы посмотрел. + Мне как раз деньги нужны. +

+

— О, уже так рвешься в бой? - с улыбкой спросил старик и резко повернул направо. — Тогда нам в эту + сторону. +

+

Пожав плечами, я продолжил свое путешествие в кильватере старика. Еще через минуту блужданий мы пришли к + очередной двери ничем не примечательного с виду кабинета. В нем, на удивление, не оказалось людей, + только компьютеры, стоящие в ряд. Поднеся плечо ко встроенному в стену сканеру, Стив активировал + электропитание кабинета. На мой вопросительный взгляд, он с ухмылкой ответил: +

+

— У важных членов гильдии и мастеров ключ-пропуск вживлен под кожу. - на мой еще более красноречивый + взгляд, он всплеснул руками. — Для всех устанавливать такой ключ получится слишком дорого и накладно. +

+

Кивнув, принимая его ответы, я уселся рядом с ним к крайнему компьютеру.

+

— Чего ждешь? Задание ты же собираешься брать. Включай комп и поднеси свой ключ к сканеру.

+

То говорят, что этих сканеров очень мало, а тут они установлены чуть ли не на каждый унитаз. Хотя, может + быть, за стационарными сканерами следить легче... Но неужели они такие дешевые? +

+

Мое праздное любопытство было прервано наконец включенным компьютером и уже взяло новое направление.

+

— Теперь зайди в окно "Задания" и введи свой псевдоним и пароль. - направлял меня учитель, наблюдая за + моими манипуляциями. Когда авторизация закончилась и перед моими глазами высыпали строки шрифта, старик + довольно воскликнул. — И вот! Тут все доступные тебе задания. +

+

Прочитав условия нескольких первых заданий, я недовольно нахмурился и максимально разогнав сознание, + начал быстро мотать страницу вниз. +

+

— Семьдесят девять заданий с максимальной оплатой в сорок тысяч долларов? - возмущенно воскликнул я, + глядя на своего учителя недовольным взглядом. +

+

— А чего ты еще ожидал? Что тебе сразу дадут возможность получать заказы на миллион?! - также возмущенно + ответил старый. — У многих заданий имеются требования к исполнителю вроде опыта работы, количества + успешных заданий или даже сильных сторон! А тут только самые легкие задания. То, что нужно новичкам или + не самым умелым ворам! +

+

— Но мне нужны деньги! Большие деньги! - продолжил я разговор на повышенных тонах. — Мне нужно продукты + покупать, за комуналку платить, ипотеку гасить! - от моего душевного крика, даже старик замолчал. — Мне + нужно найти за месяц два ляма долларов! Да я, даже если каким-то чудом исполню все эти задания, не смогу + собрать нужную сумму! +

+

— А зачем тебе два миллиона? - удивленно спросил Стив.

+

— Да квартиру мама купила в прибережном районе Бруклина.

+

Удивленно и пораженно покачав головой, старик выдал жизненное "Мд-я-я", на что я, тяжело вздохнув, + согласно кивнул. +

+

— А что, если я просто как нормальный вор полезу красть ценности в дом богача, а не ждать задания, словно + наемник? - с интересом спросил я у своего учителя. +

+

— В принципе, возможно. Только тебе сначала нужно ознакомится со "списком неприкосновенных".

+

— Это те, которых грабить нельзя, поскольку они тем или иным образом сотрудничают с гильдией или + прикрывают? - с улыбкой спросил я. +

+

А увидев его согласный кивок, весело фыркнул. Миры разные, а все так предсказуемо и похоже...

+

Через полчаса изучения списка, в котором половина была представителями правоохранительных органов, я + начал искать и гуглить других богатых людей города. То есть искать тех, к которым можно полезть. +

+

— Слушай старик, а я могу полезть в другой город и поживиться за счет их богачей? - с интересом спросил + я, записывая очередное имя в свой список "зеленых". То есть тех, кого можно грабить без санкций. +

+

— Все гильдии хоть автономны и не имеют общего руководства, но у них есть определенные соглашения. Одно + из них: не лезть на чужую территорию без разрешения их гильдий. Если ты что-то украдешь и в тебе узнают + члена гильдии другого города, то можешь попрощаться с жизнью. Гильдия не будет тебя прикрывать и, + возможно, даже поможет поймать нарушителя. Беспредельщиков никто не любит. Они рушат устои и баланс, а + это чревато большим уроном бизнесу. +

+

Довольно кивнув головой новой информации, я продолжил писать свой грин-список. Оказывается, в принципе + можно полезть в любой дом, чуть поменяв внешность, а потом смело валить на проходимцев из других + городов, которые решили устроить гастроли в нашем городе. Хе-хе. +

+

— Кстати, не забудь. Десять процентов дохода после свободной охоты переходят в казну гильдии. За это тебе + помогут собрать информацию, оборудование или же навести нужные мосты. Помогут, если попадешь в беду с + полицией. В общем, все, что нужно. Из заданий не удерживается ни цента, поскольку заказчик уже платит + гильдии при размещении заказа. А вся указанная сумма для исполнителя. Поэтому эти задания так все и + любят. Дается информация о всех ожидаемых проблемах, возможных осложнениях и точная сумма заработка. + И... +

+

— И мне пока она не интересна. - недовольно перебил его я. — В списке интересных заданий нет. А нужную + сумму мне легче заработать при свободной охоте. +

+

Старик на это лишь пожал плечами, мол дело твое, делай что хочешь. А я, не обращая внимания, задумчиво + рассматривал фамилию очередного миллиардера. Старк. Плейбой, филантроп, миллиардер и главное - владелец + железного костюма. В котором он недавно вырвался из плена террористов. А ныне говорят, что он настолько + его переделал, что костюм уже летает! То, что доктор прописал! +

+

...Может, удастся спереть у него кроме денег один образец летающих ходулек?

+ +
+ + <p>Примечание к части</p> + +

Всем спасибо за внимание, на этом все. Глава, конечно, получилась по событиям так себе, но для того, + чтобы был хоть какой-то сюжет, а не бесконечный махач, эта глава была необходима =) +
+ Удачи вам и приятных выходных:) +
+ Бета реагирует не оперативно в силу своей феноменальной рукожопости и лени, гомен'насай! + (Lopatonosets) +

+
+ > +
+
+ + <p>Глава 10</p> + +

Выйдя из машины и легким кивком головы попрощавщись со стариком, который сидел за рулем арендованного + Мерседеса, я уверенным шагом пошел в сторону нужного особняка. +

+

Зачем мучаться и искать щели для проникновения, если главный вход открыт нараспашку?

+

Примерно к таким выводам мы пришли после нескольких дней активного мыслительного процесса и восхищенных + цоканий языком при рассмотрения системы защиты гения-миллиардера. +

+

Его защиту обойти было не невозможно, но накладно. И это требовало немало специализированного + оборудования, которое гильдия отказывалась давать столь молодому и не опытному вору. А без него и без + магии, пройти высокотехнологичную и курируемую двадцать четыре на семь бдительным Искусственным + Интеллектом оборону дома, было почти что нереально. +

+

Я уже собирался было испытать, насколько вообще получится обойти эту защиту, как пришла информация, что + скоро в особняке этого плейбоя устраивается очередная вечеринка для сливок общества. +

+

Так что, недолго думая, взяв в аренду дорогущий белоснежный смокинг, такую же дорогую машину и, изменив + некоторые детали внешности, пошел в гости к обладателю модного железного костюма. +

+

— Мел... - жестом заткнув своего наглого напарника слово взял второй представитель секюрити у входа.

+

— Простите, но тут сегодня проходит частная вечеринка, если вы к мистеру Старку по делам, приходите + завтра, пожалуйста. +

+

Нацепив на лицо презрительную маску и с раздражением окинув взглядом охранников, я сквозь зубы + процедил. +

+

— Гарри Озборн, наследник ОзКорп. Пришел засвидетельствовать уважение отца к мистеру Старку.

+

С одной стороны это было одним из самых слабых, но в то же время беспроигрышных моментов моей липовой + легенды. Семейство Озборнов тоже были представителями высшего света Нью-Йорка, но также являясь прямыми + конкурентами Старка в области передовых разработок, не особо баловали его приемы своим посещением. И + только для приличия они всегда отправляли друг другу приглашения на любое устраиваемое мероприятие, + особенно в честь своих больших успехов или достижений. В этот раз вечеринка, конечно, была лишь блажью + молодого миллиардера-гуляки, но я практический был уверен, что семейство Озборнов тоже есть в списке + гостей. +

+

Была лишь опаска, что представители элитного секъюрити в курсе, как выглядит младший Озборн, которого + старший родственник особо не светит. А учитывая, что он никогда особо не мелькал на медиа-ресурсах, как + нормальные дети миллиардеров в пьяных дебошах и оргиях, была вероятность сойти за него. +

+

— У него сын разве не...старше? - шепотом спросил первый охранник у своего более вежливого сослуживца. +

+

А я в это время с раздражением, имитируя привычное движение, провел рукой по рыжим волосам, и так лежащим + в идеальной укладке, зализанные назад. +

+

— Проходите, мистер Озборн, банкет идет в большом зале прямо по коридору. - С ленцой кивнув, будто и не + ждал другого итога, я неспешно прошествовал в сторону зала, попутно с усмешкой слушая, как вежливый + охранник распекает напарника. +

+

— Придурок, просто смотри на одежду и умение держать себя! Какого хрена ты грубишь ему?! - на низких + тонах шипел он. +

+

— Но ведь он реально не похож на Гарри, которого мы видели ...

+

— Так прошло-то семь лет уже. - резко припечатал второй. — Конечно, странно, что по росту он так особо и + не прибавил. Но ты же видел его умение держать себя, этот парень даже если не Гарри, то уж точно не + простой уличный бродяга. К черту с такими конфли... +

+

Дальнейший разговор я уже не расслышал, даже усиливая слух, поскольку шум музыки и разговоров с главного + зала, уже полностью перебивал их голоса. Так что убрав усиление слуха, я с довольством выдохнул. + Услышанного было достаточно. Гарри Озборн выполнил свое дело, помог мне войти особняк без тревоги. +

+

Но дальше было уже опасно использовать его имя, поскольку в зале были собраны представители высшего + света. А надеяться на то, что там никто не встречал сына знаменитого миллиардера, минимум было глупо. +

+

— Кхм... Bonjour monsieur, madame. Я хад с вами познакомиться. - как можно более естественно картавля, я + пытался имитировать акцент лягушатников. — Тепехь, позалусто, идите нафех. +

+

Кивнув головой и посчитав произношение приемлемым, я твердым шагом зашел в столь ожидаемый зал. Надо было + казаться уверенным в себе, но в то же время не хотелось специально привлекать внимание. +

+

Быстро окинув взглядом шумный зал и приметив хозяина мероприятия в самом центре, я с довольной улыбкой + пошел в боковой коридор. Именно там, дальше по коридору находились жилые комнаты, рабочий кабинет и + мастерская Старка. Благодаря часто устраиваемым вечеринкам и отсутствию явного ограничения передвижения, + частые гости уже очень неплохо знали строение дома. А как говорится – то что знают двое, знает и свинья. +

+

Первым делом нужно было посетить спальню а потом и кабинет миллиардера. Поскольку именно в этих комнатах + была самая большая вероятность встретить столь нужные мне зеленые бумажки. +

+

Пока я неспешным шагом шествовал в сторону спальни мистера Старка, о расположении которой знала каждая + вторая модель и красивая журналистка, попутно рассматривая расположения камер, проблемы нашли меня + откуда не ждали... +

+

— Дэвид, я думала...ты будешь получше... - усмехающийся женский голосок прозвучал прямо за поворотом. И + судя по цокоту каблуков, девушка с неведомым Дэвидом приближались ко мне быстрым шагом. +

+

— Заткнись. - ох, столько в этом голосе было недовольства и обиды...

+

— Даже полминуты не сдержался, ну и зачем, спрашивается, звал-то... - от ехидного голоска девушки ее + собеседник что-то недовольно пробурчал и, шагая на грани бега, выскочил из ответвления коридора, чуть не + столкнувшись со мной и продолжил свою спортивную ходьбу в сторону главного зала. +

+

— Ой, а кто это тут у нас? - от прозвучавшего сладкого голоска я невольно поморщился. И почему все не + могло пойти по плану, без всяких неожиданных встреч и внеплановых разговоров? +

+

— Здха...Ох, Бонжур мадмуазель. Ваш англихский такой тхудный (чур не кидаться тапками, что должен быть по + идеи хеллоу, и он не сложный))! Меня зовут Пьер. Пьер Руссо. - и так значительно поиграть бровями, будто + это фамилия что то значит. — Мой отец фхансузский партниох мистегха Старкха и он отпхавил меня выгхазить + свою поддерхжку. - постояв пару секнуд, пытаясь переварить мой ужасно картавый спич, она еще радостней + улыбнулась. И, подхватив меня подлокоток, неспешно пошла прямо по коридору. +

+

— И как проживает ваш отец, мсье Руссо? - жарко прошептала она мне на ушко, прижимаясь всем телом.

+

— Отлично, мадемуазель. Он чувствует себя пхосто отлично...А вы...Простите не знаю вашего имени.

+

— Сьюзан Боунс, мсье. Но можете звать меня Сьюзи. - от ее жаркого голоса и видимого в глубоком декольте, + гормоны поневоле взбунтовались, требовательно качая кровь к лицу и тому, что пониже пояса. +

+

Довольно улыбнувшись эффекту, девушка с модельной внешностью начала активную атаку по всем фронтам, + медленно переходя на нужные ей темы. Красавец, сколько тебе лет? Ооо, шестнадцать? А у тебя есть + девушка? А почему? Отец, наверное, не дает...нет? А почему тогда? А хотел бы ее? +

+

Играя роль молодого неопытного паренька, я медленно наполнялся раздражением. Сидим на кушетке в этом + злосчастным коридоре, да еще прямо напротив спальни Старка и уже полчаса! Как же раздражает когда цель + рядом, а взять ее не можешь. А эта девка все не унималась, выражая всем видом что не прочь покувыркаться + в постели, но она воспитанная девочка и сама не предложит. +

+

Я уже собирался было плюнуть на роль и сам потащить доставучую девку в эту же спальню, как рядом с нами + остановился какой-то хлыщ, со своей дамой под ручку. +

+

— Оу, Сьюзи, тебя что, на молоденьких потянуло?

+

— Рейз, как грубо. Он сын и наследник того самого Руссо! - от ее активных движений бровьями, которые + сильно напоминали мои, я против воли улыбнулся. +

+

— Ооо, мсье Руссо! Как я мог вас не узнать... - уже уважительно протянул нежданный собеседник, совсем + позабыв о своей подруге. +

+

— Пьер Руссо... - подсказала Сьюзан, в ответ на мимолетный взгляд своего, как видно, хорошего + знакомого. +

+

— Точно, мсье Пьер Руссо. - как в ни в чем не бывало продолжил этот Рейз. — Кхм, щас... Comment + aimez-vous mon francais?[1] +

+

— Я думаю, что нам стоит говохить на английхском, ведь дхугие могут не понять нас... - медленно + проговорил я, судорожно пытаясь понять, что за дичь он мне только что сказал. Я знал больше пятидесяти + тысяч языков, но именно из этого мира лишь английский и, вроде бы, древнюю латынь, и то только потому, + что она мне раньше встречалась. А остальные учить было просто лень и бессмысленно, поскольку уже в + следующем мире они мне скорее всего уже не понадобятся. Но сейчас... сук, что мне ему ответить-то?! +

+

— Это был лишь вопрос, и у вас картавость? Почему вы так странно выговариваете слова? Даже мои коллеги из + Франции не страдают таким странным акцентом. - удивленно задумчиво протянул этот разрушитель легенд + хренов. +

+

Устало и лениво поднявшись на ноги и задумчиво постояв пару секунд в ожидании, когда камера повернется в + другую сторону, я в максимальном ускорении дал им по паре ударов, гарантированно отправляя в страну + Морфея. +

+

— Надо было сразу так сделать. - недовольно буркнул я себе под нос, в темпе вальса занося бессознательные + тела в спальню Старка. Оглядев всю комнату и не найдя камер, с радостью выдохнул:— Наконец-то! +

+

С удовольствием устроив натюрморт из трех обнаженных тел на кровати, я занялся тем, для чего и устраивал + весь этот цирк с переодеванием — поисками сейфа с деньгами. +

+

А найдя его, по закону жанра, за большой картиной, счастливо улыбнулся. Биометрический замок с отпечатком + пальцев! +

+

— Это не магия, это умение! - с гордостью бормоча себе под нос, я тщательно разглядывал оставленные следы + прошлых посещений на стеклянном сенсоре, заодно меняя узоры своего отпечатка в соответствие с ними. Все + же полное управление своим организмом, это покруче иной магии! +

+

Но что печально, приз меня не особо порадовал: Всего несколько пачек долларов, много бумаг и несколько + флешек. +

+

— Мне бы, конечно, лучше было, будь приз наличными, но и так найду, кому двинуть эти бумажки за зеленые + фантики. Вытащив из потайного кармана специальный черный мешок, начал сваливать туда все подряд, дома + разберусь! +

+

Закончив собирать все, я бросил в открытый сейф три пары труселей лежащих без сознания гостей и только + потом его закрыл. Будет подарок-утешение для Тони. +

+

Оставив мешок внутри комнаты и выйдя в коридор, я довольно потянулся. Но увидев, что камера как раз + смотрит в другую сторону, а сам проход пустой, вытащил мешок и кинул под ту самую кушетку, на которой мы + столько времени сидели. Надеюсь, никто не сопрет, а то будет до жути обидно. Но в то же время я не мог + позволить себе ходить по дому, напичканному камерами, с черным подозрительным мешком. И то хлеб, что в + доме не так много мест, которые засвечиваются камерой все время. +

+

...И по закону подлости, кабинет оказался таким местом. Похоже, там были вещи поважнее, чем в + спальне... +

+

Лишь недовольно цыкнув языком и кивнув пару раз совсем не знакомым людям, которые со скрытым недоумением + кивнули в ответ, я, не подавая виду, прошел рядом со столь желанной дверью. Может, в конце я и попробую + с шумом войти и быстро там все обчистить, но пока нужно посетить еще одно место с не менее желанной + целью. Летающими ходульками! +

+

Быстро дошагав до мастерской, недовольно поморщился. Там было просто дофига людей, которые глазели через + прозрачное стекло на домашнюю мастерскую Тони Старка. А там и вправду было на что поглядеть. +

+

Десятки железных костюмов разных цветов и даже один наполовину разобранный, лежащий на столе. + Футуристически выглядящий стеклянный стенд с разными графиками и непонятными примечаниями. Там, + посередине комнаты, была даже 3D голограмма непонятной хрени! В общем, идеальная мастерская + гения...Наверное, поэтому это больше напоминало макет и показуху, а не настоящее место работы техника от + бога. +

+

— О, как вижу, тут собралось немало любопытных. - с довольством протянул прибывший Старк, прижимая к себе + какую-то фигуристую красавицу, и добавил. — Впрочем, как и всегда. Джарвис, открой двери мастерской, + пусть наши гости посмотрят на крутые финтифлюшки. +

+

Даже не обратив внимания на ироничный тон железного человека, гости, высший свет общества, чуть ли не + толкаясь, повалили на липовую мастерскую. +

+

— Только чур, ничего не пытайтесь там спереть. Во всех деталях расположен маяк и директива + самоуничтожения, как и в моих костюмах, собственно. Так что будьте лапочками, большой брат Джарвис + следит за вами. +

+

После его слов я потерял последнее желание входить в этот макет. Это как быть в магазине без денег - + хочется все, но брать нельзя. +

+

— Тони, то есть ты дал в своем доме контроль за всем Искусственному Интеллекту? - удивленно спросил + кто-то из толпы, оторвавшись от рассмотрения голограммы. +

+

— Ну, есть такое. Техника вся подчинена ему. Камеры, сигнализация или даже кофеварка с холодильником.

+

— А вам не страшно? Ну, вдруг он восстанет? - с испугом спросила одна гостья.

+

— Мне страшно пускать людей к себе в дом. А Джарвис мое детище!

+

— Спасибо, сэр. - неожиданно прозвучал из динамиков лишенный эмоций, но в то же время вежливый + синтезированный голос. +

+

И пока народ шептался о детище миллиардера, я начал, неожиданно даже для себя, импровизированное + продолжение своего дела. +

+

— Мистер Старк. Здравствуйте.

+

— А ты что за карапуз? - удивленно спросил он у меня. На что я лишь вежливо улыбнулся. Несмотря на + немного измененные черты лица, зализанные волосы и некоторый грим, я тянул максимум на шестнадцать лет. +

+

— Я...Можете звать меня посредник. Я предоставляю интересы третьей стороны, которые хотели бы вам + предложить интересное деловое предложение. А на мой вид... Не обращайте внимание. Лишь одноразовая + личина. +

+

— Личина? - удивленно, но в то же время заинтересованно спросил Тони.

+

— Если у вас и стороны, интересы которой я сегодня представляю, получится сотрудничать, думаю, эта + технология скоро и для вас не будет откровением. - после моих слов он заинтересованно еще раз оглядел + меня. +

+

— Смотрится как живое лицо... - я на это лишь еще раз вежливо улыбнулся. — Так и что именно хочет + предложить мне эта третья сторона? +

+

— Я бы хотел обсудить и передать вам подробности предлагаемой сделки в приватной обстановке, если это + возможно. - видя мой красноречивый взгляд, направленный на девушку в его объятиях, которая с горящими + глазами слушала наш разговор, он согласно кивнул. +

+

— Милая, как видишь, дела не ждут, так что продолжим позже. - и даже не став выслушивать девку, которая + хотела было возмутиться, он быстрым шагом пошел в сторону своего кабинета, попутно жестом приказав + следовать за ним. Нья-ха-ха! +

+

Оглядев кабинет уже изнутри и с неудовольствием отметив камеру в углу, я как можно спокойнее обратился к + хозяину кабинета. +

+

— Мистер Старк, сторона нанявшая меня в качестве посредника, особо настаивала, чтобы при передаче условий + сотрудничества не было свидетелей или же записывающих устройств, которые могли бы скомпрометировать их. + Так что я бы попросил все возможные камеры и другие записывающие устройства. +

+

— Незаконные делишки больших людей? - хмыкнул он, то ли вопросительно, то ли утвердительно. — Джарвис, + отключи камеру в этой комнате. - без словесных подтверждений, красный детектор камеры мигнул и погас. +

+

— Мистер Старк. Все. - камень был брошен наугад, по шепоту интуиции, но Тони не стал играть в + непонимание, лишь усмехнулся и, достав из нижней полки рукавицу своего костюма, демонстративно надел. +

+

— Джарвис, отключи все камеры и следящие устройства в комнате, кроме датчика в моем реакторе. Если он + остановится, не дай выйти этому поганцу из дома. - внимательно оглядев мою все так же улыбающуюся + физиономию, он расслабленно выдохнул. — Итак, что же хочет предложить мне твоя третья сторона? +

+

— Грабеж и насилие.

+

— Чт...

+

Выпущенная большим пальцем монета, с громко слышным шлепком впечаталась в голову Старка и мотнула его + тело назад. Да еще с такой силой, что он свалился вместе со своим креслом. +

+

— Упс, сотрясение, похоже, ему гарантировано... Зато живой.

+

Таким образом успокоив себя, что не нарушил обещания, я кинулся в поисках сейфа и нашел его только спустя + десять минут, за одной из выдвигаемых полок. Если бы не немалый опыт в обнаружении схронов и тайников, я + бы копался гораздо дольше. +

+

Открыв биометрический замок все тем же измененным пальцем, я недовольно открыл вторую дверцу, с таким же + биометрическим но уже для сетчатки глаза. Пришлось идти и смотреть на узоры закатывающего глаза Тони. + Когда я еще через десяток минут наконец трансформировал свой глаз и открыл эту дверцу, за ним был + простой электронный замок, с кодом доступа. Чертов параноик! +

+

— Но как тебя открыть-то... Ведь я даже не взял устройство для взлома электронщины... - пока я думал, мой + взгляд упал на железную лапу Тони. — Хе-хе... +

+

После удара лучом дверца устояла, но была сильно оплавлена, и мощного удара этим кулаком, да с моей + силой, не выдержала, позорно порвавшись, словно тряпка. +

+

— Охо-хо-хо... А вот это мне уже нравится! - там было один и два миллиона долларов, сложенных в + аккуратную пирамидку и с припиской "на материалы". Что поделать, похоже, мистер Старк в этот раз + останется без материалов, хе-хе. +

+

С удовольствием собрав все деньги и попавшиеся документы в найденный тут же кейс, с улыбкой вышел из + кабинета и, встретив за дверью подругу Старка, недоуменно замер. +

+

— За дверью был слышен какой-то шум, все в порядке?

+

— Да-да, конечно, можете сами убедиться в этом. - видя мой приглашающий жест, она поспешно, будто боясь, + что я передумаю, ворвалась в комнату. +

+

— Что т...

+

Какой это по счету раз? Подумалось мне, смотря на мешком свалившуюся на пол бессознательную девушку. + Аккуратно закрыв за собой дверь, вышел и с наглым видом пошел в сторону выхода. +

+

— Это мое! - с грозным видом отобрав у какой-то дамы в форме обслуги свой черный мешок, я продолжил свое + шествие на выход. Слава богу, что успел хоть. +

+

Даже не обратив внимания на удивленно глядящих мне вслед охранников, я вышел на улицу и сразу сел в с + шумом остановившееся рядом такси. За рулем сидел какой-то незнакомец, а рядом с ним, на переднем + сидении, сам старик собственной персоной. +

+

— Ну?! - видя мою самодовольную улыбку, он тоже усмехнулся и не стал дальше спрашивать. — Это надо + отметить. Трогай, Дрю, до второй точки. Том, там внизу, в мешке, есть сменная одежда, переоденься... +

+ +
+ + <p>Примечание к части</p> + +

[1] - Как вам мой французский? +
+ Хей, пиплы! Спасибо вам просто огромнейшее за тысячу с лишним лайков, горы позитивных отзывов и + первые места во всех топах в разделе Джен. Я, честно, совсем не ждал, что через месяц после начала + наберу такую аудиторию активных читателей! Спасибо вам, друзья =) +
+ Кстати, пользуясь моментом, хотел бы вам порекомендовать вам работу моего лучшего друга, который еще + помогает мне редактировать тексты ("Lopatonosets" - Евгений Кузюк, слыхали после глав?)). + Он, по идее, был против того, чтобы я пихал его работу сюда. Но я хочу, чтобы годную работу моего + друга, который мне самому очень нравится, заметили как можно больше людей. +
+ https://ficbook.net/authors/2307649 +
+ Ознакомьтесь, пожалуйста, с его работой, и оставьте, нет, не позитивный, а просто честный отзыв. В + свою очередь, постараюсь вам предоставить проду уже к... завтрашнему дню х) +
+ Кидайте тапки по проде, моему офигеванию и по любому поводу, что душе угодно. С любовью, афтар + кривые ручки :3 +

+
+ > +
+
+ + <p>Интерлюдия: Итоги</p> + +

Открыв глаза, Тони обнаружил, что лежит на полу своего кабинета. Первая попытка встать чуть было не + отправила миллиардера обратно в беспамятство. Любое, даже самое малейшее движение головой, + сопровождалось помутнением зрения и дикой тошнотой. +

+

Сотрясение, невольно отметил привыкший всегда думать и анализировать мозг не простого богача, а еще и + ученого. И поэтому следующая попытка хотя бы присесть была принята только спустя несколько минут. +

+

Обведя мутным взглядом царящую в кабинете разруху, Старк невольно воскликнул: — Я что, бухал с русскими, + что ли? +

+

Но даже такое, не особо громкое восклицание, отразилось болью и набатом в голове. А при инстинктивной + попытке схватиться за голову тошнота атаковала с новой силой. И не ждавший столь резкой реакции Тони + успел лишь повернуть голову набок, как желудок полностью опустошил себя. Отдышавшись после неприятного + отклика организма, он начал медленно вставать, шатаясь и опираясь на ближайший книжный шкаф. +

+

После достижения самой верхней точки пришлось еще некоторое время стоять без движения, успокаивая + бунтующие органы и голову, которые как будто с новой силой взялись за террор и так усталого сознания. + Еще раз обведя взглядом комнату, уже с высоты своего роста, он, с неудовольствием, снова отметил + разруху. Комната напоминала место, в котором повеселились бесы или... в котором что-то искали. +

+

Увидев лежащую без сознания девушку у входа и оплавленный сейф, Старк начал вспоминать события, + предшествовавшие этой ситуации. +

+

— Ну и сволочь же ты, карапуз...

+

***

+

— Джарвис, узнали, кто этот поганец и как он зашел в мой дом? - устало спросил Тони, с недовольством + покрывая мазью воспаленный оттиск двадцати пяти центовой монеты прямо посредине лба. Получить сотрясение + и бороться с его последствиями было больно. Но еще больнее было для самооценки получить такое сотрясение + монетой номиналом в четверть доллара. +

+

— Узнать, кто он, не смогли. Прошу прощения, сэр. Приехал на арендованном за семьсот двадцать долларов на + два часа Mersedes Benz S класса, который взяли по паспортным данным бездомного из Бронкса. А при входе + он опять же использовал ложные данные, представившись Гарри Озборном, который пришел на прием вместо + отца. +

+

— А остолопы на воротах нахрена стоят-то?! Они что, отпрыска Озборна от самозванца отличить не могут?! - + вспышку ярости миллиардера подавила такая же вспышка боли в голове. Сотрясение не было болезнью, которую + можно победить одной таблеткой за день. +

+

— Боюсь, что нет, сэр. Гарри Озборн нечастый гость на таких приемах. Поэтому его немногие знают в лицо. - + от слов "своего детища" Тони обреченно махнул рукой. — В следующий раз, предлагаю давать представителям + охраны изображение гостей, сэр, во избежание подобного конфуза. +

+

А после этих слов своего ИскИна[1] Старк подозрительно прищурился, неужели этот рукотворный железяка его + троллит? Но безупречно вежливый и взвешенный голос не давал повода к нему прикопаться, чего сидящий без + настроения Тони очень даже хотел. +

+

— Ладно, к черту, давай дальше.

+

— Дальше вор изменил свою легенду и представился мисс Боунс, как Пьер Руссо. Сын вашего зарубежного + коллеги Руссо. +

+

— А у меня что, есть такой коллега? - силясь вспомнить наморщил лоб миллиардер.

+

— В моих базах данных такого нет, сэр.

+

— Ну и наглый сукин же сын... - даже с нотками восхищения протянул Тони. — Просто взял и придумал, на + ровном месте. А дальше? +

+

— Преступник вырубил мисс Боунс, мисс Реин и мистера Кроу. Снял с них одежду...

+

— Все, можешь не продолжать этот эпизод, давай ещё дальше. - с каменным лицом перебил ИскИна Старк. С + недовольством вспоминая произошедшее... +

+

Flashback: Вечер после ограбления

+

— Мистер Старк, осторожнее!

+

— Твою мать, Джесси! Ты не мог бы не орать мне в ухо! - почти простонал миллиардер, держась за + перебинтованную голову. — У меня сотрясение, а не слепота. И я тоже вижу дорогу и стоящие клумбы! +

+

Но, как будто опровергая свои же слова, Тони чуть было не свалился на них. Удержавшись только благодаря + подпирающей его служанке, глухо выругался. С координацией были все еще некоторые проблемы. +

+

Может, стоило и вправду лечь в больничку, а не посылать служителей скорой к чертовой матери? Но Тони был + слишком зол в то мгновение, чтобы руководствоваться здравым смыслом. +

+

Устало ввалившись в свою спальню, он с некоторой радостью отметил отсутствие разрухи, это давало + кое-какие надежды, что сейф в спальне остался нетронутым. +

+

— Сюда полицейские не заходили?

+

— Заходили, сэр, и даже просили не трогать ничего тут, пока они не опросят всех свидетелей. Но когда вы + приказали приготовить вашу спальню, даже не захотели слушать никого. - обиду и укор в ее голосе Старк + предпочел не замечать. +

+

Проковыляв к сейфу, словно хромой, он взмахом руки снес висящую картину. И с третьей попытки попав по + сканеру пальцем, с нарастающей паникой стал ожидать открытия. Не прошло и три секунды, как сейф + одобрительно пиликнув, открыл свою дверцу... +

+

— Ой, а ведь у меня еще Мисс Боунс со своей подругой спрашивали, не видела ли я их белье. Я еще тогда + удивилась, как они умудрились его потерять. А оказывается это вы, мистер Старк, с ними пошалили... Но + зачем вы припрятали его в сейфе-то...Мистер Старк...? +

+

— Джесси. Ты уволена.

+

End flashback

+

Не самые приятные воспоминания миллиардера прервал монотонный голос ИскИна.

+

— Дальше преступник прошел у вашего кабинета, скорее всего, оценивая обстановку. А после встретился с + вами в выставочной мастерской и убедил провести личную встречу без свидетелей в вашем кабинете. После... +

+

— А после он набил мне морду, ограбил и спокойненько, припеваючи, ушел из дома. - мрачно закончил за него + Старк. +

+

— Покинул на такси с регистрационным номером "XREH BAM", который был снят с учета еще три года назад. + Машина в последний раз была замечена в районе портов, после этого ее потеряли. +

+

— Нда... Кажется, надо создать пару десятков роботов, которые смогут обеспечить безопасность в городе. А + то от этих полицейских никакого толку. - мрачно буркнул миллиардер, с усталостью откидываясь на спинку + стула. (Альтрон?)) +

+

— Сэр, на вашу почту пришло сообщение с аккаунта "I_believe_i_can_fly". В нем вам предлагается за + скромное вознаграждение в десять миллионов долларов вернуть все ваши документы. - не ожидавший такого + Старк удивленно округлил глаза. Но Джарвис, не замечая этого, до тошнотности вежливым тоном продолжал + рапортовать. — Просят сообщить ответ в течение получаса. После этого почта будет удалена. При согласии, + условия сделки сообщат уже с другого аккаунта. Айпи адрес плавающий и меняется каждые десять секунд. +

+

— Пиши, что я согласен!

+

Миллиардер просто не мог поверить, что стоящие за кражей люди так дешево оценят его бумаги. В которых + были не только документы на некоторые предприятия и схемы разработок, но и неприятные документы, которые + держали за жабры немало политиков. И Старк даже думал, что именно эти интриганы наняли вора. Но это + поступившее предложение перевернуло все вверх дном. +

+

И вместо возможного сокрушительного удара по компании и миллиардных расходов, отдариться "жалким" + десятком миллионов было наилучшим выходом. Ведь для бизнесмена остаться на плаву и выйти из кризиса с + наименьшими потерями было первоочередной задачей. А раздать долги... Можно было и потом... +

+

***

+

— Так... Давайте все еще раз уточним. То есть, пришел какой-то мальчишка тринадцати лет. Без сильных + способностей A, S ранга, имея только превосходные физические данные и ментальную защиту ... И навалял + всем Иксменам? - от скептического тона и неверяще приподнятой брови синекожей девушки, двое ее молодых + собеседников невольно смутились... Или этому был виной ее полностью обнаженный вид? +

+

— Но он даже не сражался, а лишь убегал! - переборов свое стеснение, возмущенно ответил блондинистый + парень. — Если бы он принял бой... +

+

— Если то, если это... Факт в том, что какой-то паренек смог полностью разгромить вашу школу и уйти + невредимым. +

+

— Мы просто его недооценили. - мрачно ответил товарищ блондинистого.

+

— Ага, и после такой демонстрации его силы вы самоуверенно полезли к нему в логово. Без подготовки и + только вдвоем. И что в итоге? +

+

От ее слов оба парня дернулись, будто от пощечины, а от охватившей их ярости в комнате стало одновременно + душно и холодно. От черноволосого парня во всю сторону распространялись эманации стужи и все вокруг + покрывалось инеем. А воздух вокруг блондина пошел рябью из-за жара. Их буйство стихий остановил + немолодой мужчина, который до этого лишь стоял за их спинами, прислушиваясь к разговору. +

+

— Джон, Бобби. Успокойтесь. Рэйвен лишь хотела узнать, в чем сила этого необычного парня... - от его + спокойного, но сильного голоса, молодые люди сдулись, будто воздушный шар, теряя весь свои угрожающий + вид. Убедившись, что парни больше не хотят играть мускулами, девушка благодарно кивнула и продолжила + свои расспросы: +

+

— И... Почему же вы хотите присоединиться к нам? Разочаровались в своей школе и хотите вступить в более + сильную команду? +

+

— Нет. Мы... Мы просто хотим стать сильнее и... вернуть свою честь. - от горящего огня злобы и обиды в + глазах повелителя холода, девушка зябко поежилась. +

+

— Ладно... кажется, вам стоит отдохнуть немного. Вечером мы вас позовем, чтобы озвучить решение свое + насчет. А до этого... погуляйте там, познакомьтесь со своими возможными братьями по оружию... +

+

Поняв по взгляду, что они тут уже не нужны, а совсем даже наоборот, парни, поспешно попрощавшись, вышли + из комнаты. +

+

— Им вообще не интересны наши взгляды и желания. Они хотят достигнуть лишь своей цели. - первой нарушила + тишину синекожая девушка. +

+

Устало плюхнувшись напротив и задумчиво проведя рукой по покрытым сединой волосам, мужчина кивнул.

+

— Они хотят лишь мести. Смотрят на наше братство, как способ набрать силы... Но ведь это не помешает и + нам использовать их к своей выгоде. Все же два потенциальных мутанта А ранга, в хозяйстве не бывают + лишними. Да и их взгляды, по ходу времени, можно поменять. +

+

— А тот странный парень?

+

— А тот странный парень меня интересует даже поболее, чем два мутанта А ранга. - задумчиво протянул + мужчина. — Так что нужно дать ориентиры нашим ребятам, пусть поищут между делом. Такой перспективный + кадр мне точно понадобится... +

+

— Особенно учитывая его невосприимчивость к силе Ксавьера.

+

Не став спорить с хитро улыбающейся девушкой, он улыбнулся в ответ.

+

***

+

— Директор! Помните вчерашнее ограбление в особняке Тони Старка!? - Ворвавшегося в кабинет, своего + помощника, Ник полностью проигнорировал. +

+

У него было не так много времени отдыха и еще меньше нервов, чтобы тратить их по любому поводу. Так что + уже по предмету вопроса поняв, что дело не особо-то и спешное, позволил себе закончить очередное ката. +

+

— Говори.

+

— После полного опроса свидетелей, сопоставления фактов и анализа движений на доступных записях, + аналитики пришли к выводу, что мутант, напавший на Школу Ксавьера и неизвестный, ограбивший Старка, с + вероятностью в восемьдесят три процента один и тот же человек. +

+

— Как будто в Нью-Йорке много подростков, которые могут себе такое позволить. - недовольно пробурчал + Фьюри. — И почему же тогда наш человек из гильдии не сообщил нам заранее о намечающейся акции с его + участием? Ты же в курсе, что с недавних пор и Старк должен быть в списке неприкосновенных! +

+

Вспотев от грозного голоса своего начальника, его помощник вытянулся по струнке.

+

— Скорее всего, запоздали с доставкой новых инструкций, сэр!

+

Недовольно поморщившись, директор одной из самых тайных организаций вздохнул.

+

— Поставьте за объектом "дитя" полноценное живое наблюдение. А то, похоже, частичного и технического для + него недостаточно. Не хотелось бы, чтобы он еще наломал дров. Скажи аналитикам, пусть проработают + наилучшую методику для его вербовки... Похоже, пассивно ждать, пока он подрастет, не самая лучшая идея в + его отношении... +

+ +
+ + <p>Примечание к части</p> + +

НУ НАХЕР ДЕДЛАЙНЫ - именно к таким выводам я пришел, воя от горя (Это правда, он выл! (Бета + Lopatonosets)), когда у меня отключился ноут и две трети всего написанного исчезло во тьме астрала. + (то есть то, что не успел сохранить). Да и просто висящий дамоклов мечом над головой - дедлайн, + оказывается ,сильно давит психологически)) +
+ Но все-таки вот. Как и обещал, прода =) +
+ И автор теперь, на негнущихся ногах, морально выжатый и с музой в обнимку, идет бухать ударными + дозами кофе. +
+ Всем приятного чтения! И вообще, спасибо, что вы со мной) +
+
+ P.s. А пока я морально восстанавливаюсь и борюсь с проблемной учебой в реале, можете уделить + внимание и почитать творчество моего другана, который пишет даже получше меня + https://ficbook.net/readfic/8084320 Х) +

+
+ > +
+ + + /9j/4AAQSkZJRgABAQEAgQCBAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsK + CwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQU + FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wgARCAB6AlgDAREA + AhEBAxEB/8QAHQABAAMAAwEBAQAAAAAAAAAAAAYHCAMEBQECCf/EABoBAQADAQEBAAAAAAAAAAAA + AAACBAUDAQb/2gAMAwEAAhADEAAAAdUgAAAA+ePCq2I7Ts+LW7edx6+p35TDRpSW7V/XoAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAADhj7DM29EM650+c+KMvpam9kSS7WAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAiefbr3I0u70h1YS6PLpyy8svaypTfqgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADjj7W + uLpxynZ9y1wj9Szxx99DtzuH6LF7E4gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADij7VGBr9TnPv9 + ufh1e/r2OPanGZaVKQ3KwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+eKqwdbo8unY6R8KpYHNKNp + 7uTILlcDzyty2CLkVLSAAAAAAAAAAKoLIO+AAAAAAVAW+AAAAAAAAAACJZ9yC5V/u9YeBUsATvVz + 59rZ4Ajxmc0+Y/NlHoAAAAAAHw809MAEHM2EFJ8euauP0dU+nZAAABjE2cAAAAADrHOfoAAAi1C3 + XGNpe7b4RmjaAmWlSsfZzAAI8ZYPKNfnvFBE0LJMbmyDHJsYysTM7pRZKzQhR58IobIP0DEptAyy + aRKjPSJUUqeSW8TQpM0yZANbFXlHlZlhGuSpCSk9MDH9DDHxq4yWaxM2nQPENFHuGOixDnNTHHH2 + qMHX73XnE8+4BKb9Sz9vK++gAI8fz9LBNjntmfTzybmezb5jIvooMtU7x3C4AZ5IyVqbOJQDExtk + yeaRIqV6XAZ7OyRU1EZeL0M3m4TGRtYxmaYKOJaSYpkihuQxSdc2IdEz4arOMxoahMrFvlemuiNU + rUPzrng1LHDGQkNytau9k/v3wAAR4zOaWMlm1TP51icmczbxholhdJFTvHcLgK5K6NFmZC7iWgzA + SIgJehm81qZbNdnaMcmmTMZfBmk28YwNsGMzTRRhLSpy/SiTZRhksIsUnBno1YcZjQ1CZ2L6MzG1 + iG5t2I59zxq3fmlGxdnNlt+n99AAAeAZuNYFPkOPWJoWOY6Nin89jepCCLHdO4W2eaZIPcK+Nhkr + AKmMxlsl+EgKIK/OY880qUqaWMhmsyrijStixjWpUxCTVJj82AZCNemRTSRQR5p4pow9ox2WGeca + 9IZm3a5xtP2rPC19/I7E4gAAAAAAAAAAAAAAAAAAAAAAADGZswAAAAAHAcx9AAOKPtb42nL9Cn71 + uuAAAAAAAAAAAAAAAAAAAAAAAAAKlLaAAAAAAAAAInn25FcrdrpEAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAACM0bUmvVQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABxR95ZeAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAD/xAAuEAABBAIBAQYEBwEAAAAAAAAGAgMEBQEHADYQERcgMGASFDE0 + ExUzNTdAUBb/2gAIAQEAAQUC9LOe7Ei7iR+PE6uOXsxzip0hfItZNmci0LDPEpwnHsF15DCJhLjH + HZMmetaMI7PryprkMMew7G+bjckSnZa0wcModlfGnjbSnl1VMmH7DccS0i0vFyuMR3JTmVs1fHHF + Or5Bx8chllDCPYTrqWW7S0XYOQoK5q5M5DDbbancppJi+JHpmeVNVivR7CznuxcWmZzkCAqaudPS + pHY2841ynblYZ7bCX8hACj1Bk/wjIogxWBpo8X5/qnB3gMXXyvnoHqiexGym4/rWNsw5FgwlTnrC + cnKO2jp+/wApF0/o/wC+ubiLQ18Ziy20S18Birh+r9OYs4anPIWlkUSrozhhsdZsGPCKo2sbyPXj + ezLCmsUqwpPH5TMXDElmUn0NRZxg19R2S0xxKsLx57+bmNGjx1ynp8hERntpKj8fPlIun9PT2KxU + 6bY7YJaSlij9dzc7imhnXKsrCuQ33PGPmtn3F7C5tk2zhWm3FOCu3FqbEBjXdgTU/g5acFKZ0fob + cMKiu3Xo5j8GsvLjWd9jOFY7CH4zfZkWK1Cj7y/WH/2Hbo63YUOprhdoKmrd27URdNSp3LnV9iMM + 64NFFVcW5ykW0k6t2JzdTzjV1F+2PTtsRjQREnPUComsot025NrOfTW8e9rdxuKbFdZLysHs7Jio + gTyiWSFXH33PGayso9RCW/abavqqrj0sE0PrCyt4ulp0xFiJkeu+A5i2X1hbnKRYLDZZiwrUFsxg + IL7atJeOOYabuZXzc9WfyaJ201T84rGO7HlIunxignE024qJuqySluI99W83X0wFbCoqcX8VRrlH + OZs9s81p/ImwDBIpUthyq/X+mOlNv9Han6J7CTbseBKScnC+HRgsnZFnMvDPZQrxUbc5vL9Yf/Yd + jS0RAzScZSKMjJoIvBztW/uHUbJJanmoX05My/pTVhbVjUfxVGubQI4BJZxftmWMF+1/pzUPWplT + N3o3pGxXlG5+k9X9Cl1tI2ESm1THojDlvOZrNuz7t/aBRT08WigFE9VWO6Sq0KzxxtLqAnvHNnl/ + SmtTauFIju5aJCBCBLNjfl9M+WiVrSYzTzypDvZU1WZ7iEYbT5iLp/R/3xBRRyOqFLuVrglSrC07 + r6YCAKhthbwwGeUkJmt23wLtY9IZidVI2IS7M6H0x0pt/o7U/RPNhzXYAbpykiIpubvQn5AR6V7N + rhz7rwbtODZxd0y2Ji4JPVVA6TkU3ZlwP0rQ9TnKfz3Z8SIzAj5x341knCNiF/SmqhWrI4/hgM82 + mPV47aRftmH8B+1vrzUPWpjcN0Y3pCuWlG5+k/8AsFVuvNfByRWp2f8AyFwmqsXm0jvWzUCu12bJ + Ka8nr1Wo7pO0QhXHHEstg/eR7OL+lNQ0VdbwngIffRdsK1eZ8u46pkuyl4kOcaaW8usrlI59PQIM + ZVQ6VjPMTebGDMFFZqonk5b3Ky4+Na6bU0F8hxXsbh5CFJpEVVlaxUQNktqdCtOsuMC22mlvCGrG + lshnLKvZtYDcEn1jN8aZbuCyCT3UMTTlAv23msaO7cPQVAi5R6drpEOmoK8fj82OBvECo+0r6gQ4 + dkxjjWNRKqDstTlYtpSO6xF5uiK8/cxvtj0DbLo8EoKgJAoUvDVx+TFOyZ1NUR6Kt3Ey4+K6oB8p + XzZcV50/4/Fe8ZOGotLD7wbvUEVUa6/nwbaLuefBTYFBJsbAQHtCFYWJysXDSuxDmFbau3cU4feG + ZBwhm4ZRyvhPPuMNYab/ANLUPWvqOMNvcxjCceV1zDLbFc9dOxaWLF/1RbXjAtb/ANO/eVluMxiK + x7Jjs5lWnsprHc37K//EACsRAAIBAwMDAgUFAAAAAAAAAAECAwARIRIxYAQQEyBBFCIwQlEyM1Jh + cf/aAAgBAwEBPwH6awO1DpfyaEEY9q0IPamljSn6hm2xwMAtgUnTfzoKke1A37zSFjYHHBI+nLZa + lUJgVrvhKC2yexIGTUs+vA24GATgVFAEy29MwUXNWaXfAoADA7SYW97USW34GAWNhUUQjH908gQU + sZY65KJtXnjHvXxMdTS+TbbgkMXjFzvUkmj/AGo486n37kA71MUvZBwSOFgyk1I4QXqND+t9/RPN + 9i8E6dNTXNMwUXNRqXPkb0TzaflXggFzYVAuhM1+81/tHomm0YG/BenTU1/xUpLHxrQAUWHeaXxj + G9E34LA2hWaok0i53PYkDepZAdmPB+nTVk9pHVRvTG54OBc2FNIsA0CnnduE9OMlz7UzajfhTNoj + CcLO/C//xAArEQACAQMEAAUDBQEAAAAAAAABAgMABBESITFgEBMiMkEgQmEUJDBDgVH/2gAIAQIB + AT8B/je5iT5pr4/aKN1KfmjK55NJBLJSWqL7t646EzBRlqkvfhKZ5JTvRGPG3hCrkjfok12qbLua + d2kOWoRaRqk2pnzsNh4AFjgVBbCP1Nz0MkKMmp7ov6V4pELnC1lIPbu1Eljk+EW7YxmlUKNh0NmC + jJqecyn8VHEZTtTyhRoioAtxQtpT8V+jlq3g8oZPPRLifzTgcVFEZD+KllBGiP2+IYrwatxJpzIe + iTToUZRUURlOKllGPLj4+i1t/wCx+iXcuhdI+aRC50ipXEa+Un+/RbW+r1tx0QkKMmrl/Mk2o/tk + x9x+i2t/M9TcdFupNCY/7UKhB5zf5TMXOo+NvAZTk8UBjYdFuUMjqtTSazheB4KpY7CoYSPco6Pd + yaRpHJ8IYmY5C5pV0jjo7EKMmlha5JkNJbRp0m7Y4EY+aRQihR0pF1zGQ8DpY46X/8QATxAAAgEC + AwMGCAkIBwgDAAAAAQIDBBEABRITITEQFCJBUWEyQlJicXJ0siAjMGCBobHB0QYVJDNzgpGiNDVA + UJPC8BYlQ0RTY5LSg7Ph/9oACAEBAAY/Avkrndj9ZtD2Jvx8VCB3ucfrNHqjG+eQ/vYv0kTynOLy + Ezt53DFgLDsHzC1SMEXtOCtOmrz2xZmaQ+Ti2q57uVHeALN37z8xCkPxsnb1DGqVy5wJKptkp4IP + DbGiNRFF5K9fp7eQKilmPUMCSXpTe78wy7nSo4k4McN0i7etsBI11McWjtPVeX4qejBZ2LMes8ir + sduT4t7YsiLH3L8w2dzpUcTiw6MI4Liy9FB4TngMGno+injS9b4svH043Q/zDHBB6WwWazTHiR1f + MO53DGhP1K8O/vwd+iJd7uerHN6YaKZf4t3nl6DsnqnGupkJ1cEPV8CpqdOvYxtJp7bC+KqNaI0u + wUNcyar3+jkesqm3DckY4u3YMSuuVtS0se7btLe7dg3f2ajHMud84Df8XRptbuPbimqdOjbRrJpv + e1xf5aagWianMUbSazJe9iB2d/8AZ6qJGuwFvTjSOio3s/YMCmpujTJ/Oe34AqJx6in7fg5n7LJ7 + pxmv7NPtOJa2rk2cMf8AEnsHfgyy3gy6HjbhEvYPOOIqWljEUEQ0qo+X0CrgL+TtBf4POJ/jJX3R + QA73P4YaSKc0dBe11YxxejdvbFHt6wVb1AY7lta1vxxT12VZp05I1lEau0Tbxe18fmz8pEawOkzO + tnj9btGAQbg8COS800cI89rY1QypKvajX+Rrt/8Ay8nvr8r8ZKkfrNbF1Nx2j5ARobPJ9mFjQXY4 + 5lTncP1r+UfgCeYfF+KvlfCzP2WT3TjOqqpkEUEUKMzn0nCU1PeCgi3qDwjXy278RUVImiJOvrY9 + p5KUoxU87XgfMfGWFiWOlt59Y8jJtG0c6fo33eCeStVpGZdM24nzuQ5JQyWI31MiH+T8cTF2LHnb + cT5q4YqxU7ZN4xFXx5oIVckaG1E7jbH9dJ/BsU1BNNziSLVeQdd2J+/FSlfXpT5cj/F6D0GHcg+/ + HRzaTa9ph3fbiPLc0kNRlzW69Q0+Un4YBBuD18v5vLHYJLzcW8VV8P7GxHBAgihjXSqLwAxk/ol/ + y4y32aP3Rg5ki/pVHa7DxkJ3j7/44WKU6pKR9jc+TxH22+jGnInVaktZvK0+aTuGNvm2bk1DeEEG + s/8AkcNmWTZjJK0I1FVGiQDutxxJHVW5/TW1kbtY6mxm5BseaS+6cZrrdn6cfhHuPJQaJGX9H6j5 + xxF6owscSrNmEoukZ4KPKOBWZlXNT0sm9BNfePNQdWKigjqRA8UbSB2W97ED78Rw1zNV0LcFZ9Ub + jzW8XEFbStqhlF+8dxxEUYqedLwPmtjLSxLH4zef2jYmrKp9nBEupjiKskdo1aZAkQbci33DkCbR + tHOF6N93gYlq6uUQwRi7McaItdHk9OePkjtPaxxFSUqaIYx9J7zg5H+T2rVq2bTReG7dYU9Q78bX + MM1WKdt5CoZf4m4wa/L60zUqb3MX+ZOzBk0iKrh6M0Q+0dxxm5BseaS+6cVMiZm1LsWC2ILXv9ON + VNng2g4X1J9d8f7PZ27TEts1eQ3ZGtcb+sHkZ23KoucHQdSDorbGgf0yYbz5A+BtZR8SP5sWG4fC + zP2WT3TjmFHdY2s0zeIoHWcU1ZSO01K3gu3jjxkbENbStqilH0qesHkpPbF9x8UFHVVZjqIlIZdk + 5t0j3Y/p7f4D/hiOqp21wS1LsjWtcaTyVvqze9j4og18/RhXs876MZrnNeC1fVKpTXxRTIu/0nE/ + tbe6uH/bpik9eT3jytSZVT/nCYHTtSehfu8rG1GQ3j7OZyfjijiq8vahrqYtqv1g26jvHDGUu29m + pIif/Ecsiz9HVVzJc+dfT9o5Mn9Ev+XGW+zR+6MZmX8dNmB2km2K6Y+DJPYfQP8A9xzmte19yRr4 + TnuwwyfJQ8Y/7bzH6rY1ZxkJ5t1uInj+s3GKzZArDJA9l7BqW2M49kl904zBcwqDCZWQpZGa9r9m + P6e3+A/4Yo5svmM0ccOliUK779+IvUGJUqunAlQ91PWkfAfV9fJXezye+uK2mkW7CMvGfJccMZlQ + MboumZB2dR+7EPtae62Ms/8Ak/8AsbEOQ5W36FE3TlHgkji/oGMpoqVdMMUUI7z0zvPJJVVD7OCK + YMzd2zGKag24oMv1fFox+vvY4jo6OPZwp/EntPfjMapDaSOBip7DbdjMcxcapRaFD2dZ+7kZHUMj + CxB6xifLoydg7ywW7t5X7BjOPZJfdOK2Ou2uqZ1Zdml+rBKR1cjdgjA+/D/lHLDzejjk1jvIFlUf + VyaLX2t19GGrphdV3Rr5TYaRzdm5dTboV4ntwFUWUcB8PM/ZZPdOM1/Zp9pxNQ1I6D+C3WjdRGJs + rzLdRu9pOwdkg7v9dWAym4O8EYpPbF9x8UFXV0G1qJFJZ9q4v0j2HH9WD/Gk/wDbCUtOmzgiqXVF + vew0nkzStqm0QxJMT39LhibPczX9BhboRHgSOCegdeMz9Ce+uJ/a291cP+3TFJ68nvHkzOWEkSaA + lx1BmCn7cPmehZKx5GTWeKAdQ5MsfSNW0YauvhjJ/Y4vcHKM9y9SZEA26p4W7g+I6fNZlo61RYyP + ujk779WMnaCaOZdMu+Ng3k4y9qvMIItNNH0dd28EdXHEGWZXC4pEa6huvz27BimoId6xLvbyj1nF + LltU5WlDRRD0GxP8b2wkFPEsMKCyogsBix3jGYgCwCTWA9cYzj2SX3TjMWzGl5wYmQJ02W179hx/ + Vg/xpP8A2xRxZfT83jeHUw1Frm/fiL1BiV6roQNUPdj1JJwP1jkrvZ5PfXFdUyNY7MpGO1zuGMyr + iLI2mFT29Z+7EPtae62MryegJbMKoSBtHFFMjfWcXlAOYT9KZvJ836MUHqQ++eSooGk2SzzqpcDh + 0BiGvyONopaNfjEU9JgPH9YY2NQwGZQD4wf9QeUMZjSpvklgYKPOtuxmOWudMrWmQHr6m+7kaR2C + IouWPUMT5igOwR5aj6DcL9oxnHskvunGYtW0cNUySKFMqXtuwVbKacDzF0/ZiB8umc0kqiQwseK3 + sVP8N3JSwjcN5J7BgRxboIuig+/ksiFz2AYBlpIE+s/I5kALk00m7904zTaxPHeNPCW3WeTbU6/7 + xpxePzx5GPzLmEUqtGP0eSRTw8j8MUojRpDztdyi/iPjLFdSrBW3EeceRpNjJs+dP09O7wTyTUiR + PEjTuXlZdyrfecQ0dKmzgiXSoxmaopdiE3KPPXEyyI0bc6bcwt4q4ZY0Z22yblF8UqupRtcm5hbx + jyT0dQNUMyFGxNzSE1+XObmyFkbvIG9T/rfjZRZIDU/tSf5dOI83zaF1jMmzipgtilwTfT1cOvfj + KFYWIpIrg+qPgNLsWo524vTHTf6OGKMU88tUJ9XhJ4NrfjilqaqsqX2sayGNLLxF7Y2NBTJTqeJH + FvSevkizLLTbMIRYpe2sdVj24FPnGUmZ03a3vEx+qxxzXJstNGj7mnW50/vmwGKyCpRrxRSIZLHS + x1DgcZuqi7GklsB6pxmu1jeO7x21C3UeSgMcTyDYeKt/GOIvVGFliYQ5hELI54MPJOBR11E1RSx7 + l2ykgDzXGJ62CmWolljaMIx4XIP3YilzBWo6JeBdNCKPNXif9b8Q0VKumGIW7z3nESxo0jc6Xcov + 4rY/PVfEVI3U0bj+f8OShdIXdNEPSVbjwzyCXYvs+cL09O7wOSLPcjVhCXuY4xfZt2W8k4iq1jaF + +EkTjejYOefk9q2mraPDF4at1le0d2NjmOVLJULuJDmI/SLHHMaCiMFG3hiLh+8+DFqEtXL0ppR1 + 9w7hjN1UXY0ktgPVOKmOLKnqdswa7Bha30Y0w5D8Z1XDtiPNc+janp1IJWQaSwHBAvUOTZJ+tcbz + 2LyBhTbVPP3DAARY+5OH9513s8nvr8r041f1hfFhuHwmdtyqLnD1LMI0ZsX0bRu19/8Aes1fHVyT + tJGyaGUAbyD939kipY/DmOEiXgo+ZUtU36uPoR/MtfR8y//EACsQAQABAwMCBQUAAwEAAAAAAAER + ACExQVFhEHGBkaGx8CAwYMHRQOHxUP/aAAgBAQABPyH7QMgDK1KFjo+7FKk74PQrFkbGV5XU6hZI + 1R6ZqNMezy/2gZiwEB+BNijVirmTwfArXTwMHhiriG3eHj/OgKAJXAU/L3Vf8PwSyobPxTW1YZwd + imIXHtjQ5aZPgUXRijIE0aI09Ts55/AwUhnAKRVhuH8CmLaQ05avcbLunueau5EUlegrzsE/soYU + 1C34GGccpV0De9y80/gucdoKlbfKgqEw7EHvRQ2HV/pWV+U0rGCOENj8DBkAJV0qViNZv3UgPDkP + 7RNnnHqKy5ylSE4xZG65n6AVgdDF1CfCpwWQr5NkY6awoDjfKxVzMyH2qM87eP8AjKlzMnuKn2V5 + MSDQnXP3k/0DGxYh/joFby0kwx2qeeUsVcmQ13H0aDc+6fWW2ASB+kWqpR34yTfG3PHAFbP3rn7d + V1+8oJbBV+QxH00/THiUxr9IatTLXgVw/o8Km4VKd2VmfZR1IoQggDC31SrVxzT6MLc+db0CY0ok + TofUMJn1pQHaI+n2MVOAJI5+7pAw4Cl50FI+EkfsX2BJMmqpgxjtzV8bE52u30YZ901b9vrLYPeC + h7nFGWg6m4dx7eG7UbM3XftqvR5OKYmh9pmSV6acBd3wfQOEuFMOioUF0TkD6+A3qDTmZmvK+ew6 + 0Gd/CJNDxXyH90aN16iA58FR54SNiuuxmc8tSASFjX9rvWtQjZi2Jm5F57uGjbCkGE6swUe3NRyy + eVAdkZAsFfD79OYYoiBeETspw7qcKmcjE8koRVZFgatcSHOmEaUyNT8ZyfKn0GeE5Uo7beNRZYBY + PoaNkY/cU5oEEyVW5CCRjoiIalHKLvj2pLjldt2Zwaw7VeypFxrCDuieafl8AWpZtqm+KXwF6IzN + dG1uRqxHlOXXkG1RedzE0ttJJJWsNCXsQ3VsG7T26tQT/s3V6EwFXdHSv0QLGbroVIOQuj0hYNPN + bNeAWU1TVd6VC/8AbUBeeDcMsr22k8t6gUGu4Bu8jyvHFNFEKWFx2h7QnK5gEEyVB8GrKF2bU2av + QXuxRTdrPIGjN9zo2cgnBRNgomr4tdpBZ2e/0KKB7G7btQEAFgNPrLS8YVW7RvxLBzRT30BatvM8 + HJVy+sP9CG3VAUOFzSmSGE6I37tIpGm36fM7aewmrvzZt6mOaDOXXtCp7/bu9M/zPP0MruEyDiJf + wQbTRpqiMKeui4vXoDoC5gz3p7pAOr1rTIHxN4+p0+H36cyZFlWhCPOfCg7Qs+scvxpU6MsPO2H7 + wUlIIwPdiKUOLWnwCpAm33LydLQrniECWDudEb8URCldBXxu1WnMYUgi2Yz3UAILB0NhIY8uFWe3 + ZadQqQyZ9npA+gpz5Xt5ZG8s3XkqGbFOW5yLfoE5PiYFD1Ze3BdGiwYMbqH0/wC0mq3qP9VcJeaU + GFLul93289EaIPkTI1AKk5yTxXvPQ9pCQLAhm5vUaHWmXi1gvpcw7msQlxZ36SYnGSIRmpYBkfC3 + zFTQtK9Yorv4SgoEgMB9ktstY2RLDzR/TWpjztZ/XiJ45hQQDSEib9EAuL1QAYEwHRpdlwqBblv0 + 5Up5RgcrYq3XpeXwt8m5eXr+z/M89WW92SkB5KrdB0mG7EzL3OnGRzCFp+joD3DFafsQO0DvQgrf + qyPcYNtgyakCZ20sSlRrpBleVYFZIlh2wWO+7FNNaoYVv46tKUG0xbhcpS7bVZvaoKBgEIR1oAxk + EAdO0VF0MQJ4tjo0As0W+JN7Xxu1KdtxSGfYvdmhAIyOE6GwgT8blRPG/YaQ+qKyJ93pAz0jd53B + HbOO5QYYmRwLtq3Z46mFISGCJWPKjKAlm7QfEeO14dFmHRC99nuUU/IdcHmFHxRm0bHh++3R5RnY + AurWS8ZMRLv7T0PCzhAyqYmkgQiZPnBqbxemV7tlLSTborljtxLR2RuR/t0Mb/5qDV3V9efegBAQ + fYd8LASrU7DwFpdzpGLPQznV7nPdoqrEak5SmnotoUb+srUcFSTOoycfRahPu27nRaNlQ77nBqpR + eAvcrurdd2oKlUxeKpzY5ajlqfQuctdqI/pGLtdIHJAzDqcmadpjhJpgHril9YsW89kvWiqMYAgG + Ywm5bimsGAQj9E7VkhBbrHyBQknbYcPGZ9lQvFkYhchdeK5TyjbrfxdAoG4nM7Av/bUSCreP1WXc + Cl1+MKOx5GdqwyV+F1C45pYBAEq1E6tw5d3oHuMVgdijAtn9VJP+Pv8Aszh0lrDBrgaWUcMxxS1l + CRctrtJoWl5bM1139FWN8py68i3qQWGWo4aLIFgyOFD5eJ26DqkiQ5Oj8x1zT16TzV5C5g5+1zal + ge9AGS+TZ2qBDLaNGtS88myMGqSqly3vKnDhiRM54I4tPNDBKDWRjvD5rrFPQIAlWowg1ZImnKoI + pcm7AVPSrnqcnuLu5egLSZ52PHoYLeT59M9ovrPI/ADcFa8QwowAGA+pb5BOKZWQlJewelRlj+Yx + UR/6jX7IgStv8RcQKRxP99q07FO+7+FHxuWqWX38/wALA4AQsfhf/9oADAMBAAIAAwAAABCSSSSR + 5c7+SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSEfpeSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSScLo + eSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSRPmIaSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSQ5w9KS + SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSR9xLuSSQCSSSSSSSSSSQSSSSSSQSSSSSSSSSSSB5JWSSQ + ASSSSSSSSSQAASASSSQCSSSSSSCSSSJxJASSQSQSCSASAQSSCASASQSAQCAASSACQASKVJBySSSC + SSSQACACCSQCACQSCCSACSCQCQQQQHpSSSSSQCQSAQASCQSQCCQCACQSCCCQQAQCQCYrzSSSQSQA + SSQCCCSSSSAQQSCQAAACSAQSQCASrGSSSSSSSSSSSSSSSSSSSSSSSSQSSSSSSQSST3CSSSSSSSSS + SSSSSSSSSSSSSSSSCSSSSSSSSSfySSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSRSSSSSSSSSSSS + SSSSSSSSSSSSSSSSSSSSSSSSSeSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSST/xAAoEQEAAQMC + BAYDAQAAAAAAAAABABEhMUFgEFFhoSBxkbHB0TCB4fD/2gAIAQMBAT8Q/EFbEuVKHWH9U5484Yh9 + JacvSWK13iq1dhOUasVuv0QyoCJdS3DF2Kan/PXYmlh3h1GkW6VXnoff6hqpV5/XLgLUoRtP7tho + gVZrl7JUzQhc+V1fPpAaFDglMDWOVVdhmAqsrpvFZN1wc5rA0ND+wTVjkdjEOfpFVLBsMFaEPUHa + AeawRDKfby44dWM0jXYhBWZVa7oc4JyPt08FOqeb8bEPSj3iPET0DnI8GVX16f3YiFkYoFi3YF99 + j4COu7Ra3diZNzQVmc9CGMRxGhkiKrnYoudIic8frgbVUlkZ2PiZ2M60j3/nCmGl6XYrKr57HYsj + BAVQlhrQ6bJEwMM712UQuW78fGy3VV2X/8QAKREBAAECBAQHAQEBAAAAAAAAAREAITFBYGEQUXGh + IJGxwdHh8IEw8f/aAAgBAgEBPxD/ACUCWrJc7X+q+R1y90KxN82r3cObV9Ut8PKgBAW0FLiCiLM7 + vxUKS7fVBZMvAJsUGj+f/mhJH2w+alhLQAsMjN+OrSiPYPdz4RhlrOT06DcLAVI2e5oOUtNjDnZH + Tm04WV4AHFZTFR4jpoNE8BUOLDA92opYGLkUntGbm/VORS7HefNCYgf2hxhaDUCWmyf33p0rAxeV + Qigd93jfmOlHlGcDQiq3Dzli1QFYMXkUwWD33fBLAdD3+NCIa39FBxu1lTzc3wTwdhz+vXQjjCKF + VyFilZb+x4EeR7/VABBoS3zMUxGxg5v1+wpit3jko99qAgQGhSfhddi0tEPKj5/vCCTNRCLzXvPr + QRY0MeVl+n3wDQG9jzohAHTD20Opwim4gX9FX2Jeb+jRKMTf7vWGgaKXlIPf30WQIaL/AP/EACkQ + AQACAgICAgEEAwEBAQAAAAERIQAxQVEQYXGBkTBgobEgwfBAUOH/2gAIAQEAAT8Q/SOCUogD24k/ + /VNP5Y2B8In+CPy44yn/APYIv84+xryh/ExnNbI+nZs/Ee829VaB9Fv7OBruAAdAa/YW8/QB8Hb6 + MY4IhT57X7T4wOsTQkbQwA7j5yQB9r/pV+x78O2dAJV6MIu1Hum6fo1y8H7DWLuBL+029H54xXwo + VPQUHxgR4ghDsX/RA48U8isNL2vmjgPCQchJOPWpI/D7dvw7f2FLR4UBlgogYXv+jnnrOM/RQ7NB + 7cm6XE9cHX21+TGfHbh4OiAsK9rwNs1BkQ+kGS+YJ+X9hvxFqQ/2+uc70QbX8/px+VSQfUc9XvcH + 9EuW/iVPzf8AD/VzO19IX4kTjVS4BMQn1I/0cSk+wZNOg+15+v2G/wBElADauMem49R/Rwe3Dsg0 + R7QncOPvF3trXbNvofU8B4JkrMo/w5BvhKvQFnU0bvXll87LMJgxNJhidYqS0QgAKXy344SxocMB + 5YldArrK116pEHJBlTASbB/5ogxexTH+gx2mgJhn/oJFKTBMaP1hfIBR4GCYszx/5/iP+vn82v8A + icjgHwO2vvcH+hcDpZCKBteSbD76DzI42dvoOuj76/zzk5TRWIXfNCg+2AUInGTJyHSI8qOJGAfR + IVBtXaKolSqq/rOEASqwBgh4gUn1sn/EWo4g0FuG4MLEgCoMT3GAjdhzlYjS0MgBkFNFtu7B+WVe + dkTFBKSF9YbFAopEAEBGJRsYwN9iiRIiUic+NsU23wsxiQQlr8pP0FAqwG1zYEUQlrO9fq7/ADIf + wkThmdkAHYm/0HiuVoHudTIfb1lF/nQ5TwBbiSUuFyZdGo9Rwz5W3GH0cjr/AD8bCCCj/LOwZsYF + WG1KAJVQBUy+JZJQIMTwA7QsCjelIKiKq0W6KAAAxLnRzE0SOqMSYOl2NfDlS2VNFojxEGBMYjpY + 8JD0IAQM1FQfJrh/GJxGmXi3I7UpMU1kyWI6AuKwWy8bXpwEPLytAt1jHWNItzACBGGQAnEBDYb9 + jEfb7xKZqEvDes0RgRLiZBDsoJETYnlvO1vp+kA+xoMg7goFAHwebJirjwSndBRdCG2L5DCQTvpA + 9HFmwbnZEQRUTZAJb7qRzdmUt3PbggkkEy0wAqyQICpbRIUlYBqQEQMJAQzx90QiRNOIrBlapYlr + wc5kSu5BxKlVWV+OPmOVZYYiwICLsBOCdqQjQmmNNKEgziUsLlPwGfqKQzJDPdpshYp3ISIkVOkM + mAsG4Mo7KUhx/jU6LVJxRkn814AErejGuia1ChcocgDnDE2cRUggsKqLCpgwrhRKE8WjeNYCx9Ab + VAEqoBhWz8MyqGJyxCvBg30oxWxt1q/ggzrDYBk6TwUWQDOBCdcW9lHMCdLvDr1s7bX0hOy0cfzy + pOWbeoG0hYMEXPuiESJpx2w0lygkUfLeBFKkulr8ocghcvxA7w0Mm8Qk8U+WVYCVg9GFNThIYlTu + U/jAmQmst6Dwv7vglZZd+b8jGkuHpy/RzByRAoAaA/zztSmmKABShblQRaBA4FqIEQL0aStiP5Bi + gUA1OR6kkRfFMW/6AHJ6tUu/D5W5peAMIPhB88QGDuNMPdhA7RCUkcco1DtbJbEFI+Nv/A785ygK + sBy4FasybES3VKTkM4YZ+BW2uTCVniM8EgA/I5RjQ1bSpOvy35V4pryk3d/8PZMATsbJFg5SXwTx + kX1bRoz1MZ7XWMySEDyV6aqUkhLYKKfpAPE5buEfl2tkBhHTbcx6E9m8rfXsYbQUkV9vhdBy8ijc + xod0b8Pl5AcXjQ5pGQjP+51wsi4CSzpKI57XDAAIAIA8P59FEEqtkpKNi5x0MMpRZ7/he/KPohKb + YmlSkFP1FrnDk4SXcbcp7agg8PbT5aUAKroC1QML3HdKESiEmsg4IDew2r3zCVeggAFNbdyr6TPr + CyDrKBcXmidwu3gLSzBCAaRFE95PUVQyzR2EJ/sfEstnBNcoYmRhVIr0cCAD3fxjU0AzIlAWCYUq + QeFBZZe4aXsr3hU9M3W/HbsXCXUXd9HQaDo8l61Kld39vB7TAn6JgGgP0c5OW5BMmNrkfsUpY9zo + kpQdzQLdyDBifhEiQJSJc+Kbpb2snYhQoNeIR4h5rQkq+VXwftWEMZO7cB2mt4rdltUQrQ0aNRGo + x472/wDA785zt0VUA0ljXTTDi8GgxYE28jEKA0ePVTbtuyJuNT/hhOzs7SKNsUGxGgQohiCIB3nd + ktXhHUwczDK0uH8ZNwx1hImjiBYp0Yco5EkKBKgtiLaT6RlhwuRLAhowX+jjfTPmRJvDDmkFjcAf + ldrbeP8AxByB2JyYc/ygQABQBx4XTpHSUjEmXdOvEKOSMmbxZQECGf8Ac65L6DVJY0lRcd5htjSi + ROzw/TZYCCRbbSjSOMW0MqU8+v53rwjZj6ABw7WQFiWlcJTu4giffDI2qUIeEp8IUT4BQWEE1KYn + mdEGJ7UlCJEkJMazKFAj2RgJSDQWIHMLufaJ942JaIApB5sjcK5eA5D4GKDQAKvrJRiBhCWnViP6 + HxLd6s7DCFCh+MbPpJE9OD7HEmA4fWRkik7CpcnZrnU0/wCAr2neUSFegUrth+I5nxJOUyFPcWGA + FK/t8ET8PjDAAIAID9ByCGkAADauG8LL0ThAnxHFBYLanu1dVobEuIE4FohIsuDSBOexZQJQLEpf + swgaTV7tWU+L4g4ZDGsR7nwoKT81KgKEJKCpkHaILULVyjyEeclbg5GRgW0L9YiCHY0YwBim/WOP + dQkZsBYxvdQZFCUDrw6lR0AYk4SA8IOQ7WGZSSBAZSYJAYDEGOS1KDvj88eqw/IBHZnkZyGFY/2C + NEbEePO8SSB6O7QraoZtyAY/skBLSzwYXMoirElAwqVcYygElTtKLbiTE1Hh8bgleeQHmFQRCEJr + fONKSnPSGm9t9LRNpYqh2abjkPXkgXiFGC2jrEU6WSAALVeMXiMHySxAnZ4dP+/JIUMOMYQNHZXH + 4eWtMkFJFEVUIuD3CXR4iokQkUAaYWN6nVw0VipndZ3YGhoXDFNZgEIx7kkxUsu5cp7aAABUR5GC + WAsWX7xp53RZFpIuTmmn4ARMhArIIIPBCWjVo5rETW8SSGzLzYliTbAhGk0Dkqwx541hsGC0aZAu + pIYlL2d2WQMAXn6SgcwVcxDcBrBwTOcrRQO4lGlBjNKUAyTb1E2qAoZzvZIgAtV4xfp25gAJM4TE + jEV6XL8CZdGUKELbaJSCi+ExDtYPwlP0PrxNed/IsUn4v4wquEeno2fKfsC+w2P9UJGMAmEDgDoP + 8vRBZAS4H0AqMiKQgRJeOc7SiqD60fifeAAAAUBx/wDUbE5sXBUqJPt/8l5Qh6iB6FH0s0V2qJf2 + LL9/sre3/cw+wUz36fssrziCAr9l/wD/2Q== + + \ No newline at end of file