From 62cdbef0eb7527040ff77cc1683dabbc084f65d7 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:24:22 +0300 Subject: [PATCH 01/24] some config --- build.gradle | 42 +++++++++++++++++++ src/main/java/fic/writer/Application.java | 11 +++++ .../java/fic/writer/config/AuditConfig.java | 9 ++++ 3 files changed, 62 insertions(+) create mode 100644 build.gradle create mode 100644 src/main/java/fic/writer/Application.java create mode 100644 src/main/java/fic/writer/config/AuditConfig.java diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..3d950a6 --- /dev/null +++ b/build.gradle @@ -0,0 +1,42 @@ +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.projectlombok:lombok") + compile("mysql:mysql-connector-java") + 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..7da4aa1 --- /dev/null +++ b/src/main/java/fic/writer/Application.java @@ -0,0 +1,11 @@ +package fic.writer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/src/main/java/fic/writer/config/AuditConfig.java b/src/main/java/fic/writer/config/AuditConfig.java new file mode 100644 index 0000000..d052953 --- /dev/null +++ b/src/main/java/fic/writer/config/AuditConfig.java @@ -0,0 +1,9 @@ +package fic.writer.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class AuditConfig { +} From d442655ee06772390825bed963d57985e249dc0d Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:27:47 +0300 Subject: [PATCH 02/24] Main entities --- .../java/fic/writer/domain/entity/Actor.java | 28 ++++++++++ .../fic/writer/domain/entity/ActorState.java | 25 +++++++++ .../writer/domain/entity/ActorStateId.java | 22 ++++++++ .../fic/writer/domain/entity/Article.java | 35 ++++++++++++ .../java/fic/writer/domain/entity/Book.java | 54 +++++++++++++++++++ .../java/fic/writer/domain/entity/Genre.java | 23 ++++++++ .../java/fic/writer/domain/entity/User.java | 27 ++++++++++ .../writer/domain/entity/auth/CustomUser.java | 22 ++++++++ .../writer/domain/entity/auth/OauthUser.java | 23 ++++++++ .../fic/writer/domain/entity/enums/Size.java | 5 ++ .../fic/writer/domain/entity/enums/State.java | 5 ++ 11 files changed, 269 insertions(+) create mode 100644 src/main/java/fic/writer/domain/entity/Actor.java create mode 100644 src/main/java/fic/writer/domain/entity/ActorState.java create mode 100644 src/main/java/fic/writer/domain/entity/ActorStateId.java create mode 100644 src/main/java/fic/writer/domain/entity/Article.java create mode 100644 src/main/java/fic/writer/domain/entity/Book.java create mode 100644 src/main/java/fic/writer/domain/entity/Genre.java create mode 100644 src/main/java/fic/writer/domain/entity/User.java create mode 100644 src/main/java/fic/writer/domain/entity/auth/CustomUser.java create mode 100644 src/main/java/fic/writer/domain/entity/auth/OauthUser.java create mode 100644 src/main/java/fic/writer/domain/entity/enums/Size.java create mode 100644 src/main/java/fic/writer/domain/entity/enums/State.java 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..8c88ef2 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/Actor.java @@ -0,0 +1,28 @@ +package fic.writer.domain.entity; + +import lombok.*; + +import javax.persistence.*; +import java.util.Set; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Actor { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(updatable = false) + private Long id; + private String name; + private String description; + @ManyToMany(fetch = FetchType.LAZY, mappedBy = "actors") + private Set books; + @OneToMany(cascade = {CascadeType.ALL}, + fetch = FetchType.LAZY, + orphanRemoval = true, + mappedBy = "actor") + private Set actorStates; +} \ No newline at end of file diff --git a/src/main/java/fic/writer/domain/entity/ActorState.java b/src/main/java/fic/writer/domain/entity/ActorState.java new file mode 100644 index 0000000..b3ef976 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/ActorState.java @@ -0,0 +1,25 @@ +package fic.writer.domain.entity; + +import lombok.*; + +import javax.persistence.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(of = "id") +public class ActorState { + @EmbeddedId + private ActorStateId id; + @MapsId("articleId") + @ManyToOne(fetch = FetchType.LAZY) + private Article article; + @MapsId("actorId") + @ManyToOne(fetch = FetchType.LAZY) + private Actor actor; + private String title; + private String content; +} \ No newline at end of file diff --git a/src/main/java/fic/writer/domain/entity/ActorStateId.java b/src/main/java/fic/writer/domain/entity/ActorStateId.java new file mode 100644 index 0000000..43425e0 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/ActorStateId.java @@ -0,0 +1,22 @@ +package fic.writer.domain.entity; + +import lombok.*; + +import javax.persistence.Column; +import javax.persistence.Embeddable; +import java.io.Serializable; + +@Embeddable +@NoArgsConstructor +@Getter +@Setter +@Builder +@AllArgsConstructor +@EqualsAndHashCode +public class ActorStateId implements Serializable { + @Column(name = "article_id") + private Long articleId; + @Column(name = "actor_id") + private Long actorId; + +} \ 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..5e1f6f1 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/Article.java @@ -0,0 +1,35 @@ +package fic.writer.domain.entity; + +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.*; +import java.util.Date; +import java.util.Set; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Article { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String title; + @CreatedDate + private Date created; + @LastModifiedDate + private Date lastModify; + @Column(columnDefinition = "text") + private String content; + private String annotation; + @ManyToOne(fetch = FetchType.LAZY) + private Book book; + @OneToMany(cascade = CascadeType.REMOVE) + private Set actorStates; +} \ 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..d3ad3e8 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/Book.java @@ -0,0 +1,54 @@ +package fic.writer.domain.entity; + +import fic.writer.domain.entity.enums.Size; +import fic.writer.domain.entity.enums.State; +import lombok.*; + +import javax.persistence.*; +import java.util.Set; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Book { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String title; + @ManyToOne + private User author; + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "book_subauthors", + joinColumns = {@JoinColumn(name = "book_id")}, + inverseJoinColumns = {@JoinColumn(name = "user_id")} + ) + @Singular("subAuthors") + private Set subAuthors; + @OneToMany(fetch = FetchType.EAGER) + @Singular("source") + private Set source; + private String description; + @Enumerated + private Size size; + @Enumerated + private State state; + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true) + @Singular("articles") + private Set
articles; + @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; +} 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..9e14053 --- /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.Entity; +import javax.persistence.FetchType; +import javax.persistence.Id; +import javax.persistence.ManyToMany; +import java.util.Set; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Genre { + @Id + private Long id; + private String name; + @ManyToMany(mappedBy = "genres", fetch = FetchType.LAZY) + private Set book; +} diff --git a/src/main/java/fic/writer/domain/entity/User.java b/src/main/java/fic/writer/domain/entity/User.java new file mode 100644 index 0000000..d84375a --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/User.java @@ -0,0 +1,27 @@ +package fic.writer.domain.entity; + +import lombok.*; + +import javax.persistence.*; +import java.util.Set; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String username; + private String about; + private String information; + @ManyToMany(mappedBy = "subAuthors", fetch = FetchType.LAZY) + @Singular("booksAsSubAuthor") + private Set booksAsSubAuthor; + @OneToMany(fetch = FetchType.LAZY) + @Singular("booksAsAuthor") + private Set booksAsAuthor; +} diff --git a/src/main/java/fic/writer/domain/entity/auth/CustomUser.java b/src/main/java/fic/writer/domain/entity/auth/CustomUser.java new file mode 100644 index 0000000..22f2961 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/auth/CustomUser.java @@ -0,0 +1,22 @@ +package fic.writer.domain.entity.auth; + +import fic.writer.domain.entity.User; +import lombok.*; + +import javax.persistence.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CustomUser { + @Id + @GeneratedValue + private Long id; + @OneToOne(fetch = FetchType.EAGER) + private User profile; + private String email; + private String password; +} diff --git a/src/main/java/fic/writer/domain/entity/auth/OauthUser.java b/src/main/java/fic/writer/domain/entity/auth/OauthUser.java new file mode 100644 index 0000000..d9c38b4 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/auth/OauthUser.java @@ -0,0 +1,23 @@ +package fic.writer.domain.entity.auth; + +import fic.writer.domain.entity.User; +import lombok.*; + +import javax.persistence.*; +import java.util.Date; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OauthUser { + @Id + @GeneratedValue + private Long id; + @OneToOne(fetch = FetchType.EAGER) + private User profile; + private String token; + private Date expireDate; +} 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 +} From ead01962014a2f795bd3f08c6d3be92b57a8276d Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:29:02 +0300 Subject: [PATCH 03/24] Create dto for entities --- .../writer/domain/entity/dto/ActorDto.java | 24 ++++++++++++++++ .../domain/entity/dto/ActorStateDto.java | 23 +++++++++++++++ .../writer/domain/entity/dto/ArticleDto.java | 23 +++++++++++++++ .../fic/writer/domain/entity/dto/BookDto.java | 28 +++++++++++++++++++ .../fic/writer/domain/entity/dto/UserDto.java | 23 +++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 src/main/java/fic/writer/domain/entity/dto/ActorDto.java create mode 100644 src/main/java/fic/writer/domain/entity/dto/ActorStateDto.java create mode 100644 src/main/java/fic/writer/domain/entity/dto/ArticleDto.java create mode 100644 src/main/java/fic/writer/domain/entity/dto/BookDto.java create mode 100644 src/main/java/fic/writer/domain/entity/dto/UserDto.java 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..d8272e7 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/dto/ActorDto.java @@ -0,0 +1,24 @@ +package fic.writer.domain.entity.dto; + +import fic.writer.domain.entity.Actor; +import fic.writer.domain.entity.ActorState; +import lombok.Builder; +import lombok.Data; + +import java.util.Set; + +@Builder +@Data +public class ActorDto { + private String name; + private String description; + private Set actorStates; + + public static ActorDto of(Actor actor) { + return builder() + .name(actor.getName()) + .description(actor.getDescription()) + .actorStates(actor.getActorStates()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/fic/writer/domain/entity/dto/ActorStateDto.java b/src/main/java/fic/writer/domain/entity/dto/ActorStateDto.java new file mode 100644 index 0000000..9c38538 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/dto/ActorStateDto.java @@ -0,0 +1,23 @@ +package fic.writer.domain.entity.dto; + +import fic.writer.domain.entity.ActorState; +import fic.writer.domain.entity.ActorStateId; +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +public class ActorStateDto { + private ActorStateId id; + private String title; + private String content; + + + public static ActorStateDto of(ActorState actorState) { + return builder() + .id(actorState.getId()) + .title(actorState.getTitle()) + .content(actorState.getContent()) + .build(); + } +} 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..4bb8458 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/dto/ArticleDto.java @@ -0,0 +1,23 @@ +package fic.writer.domain.entity.dto; + +import fic.writer.domain.entity.Article; +import lombok.*; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ArticleDto { + private String title; + 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..ba254c0 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/dto/BookDto.java @@ -0,0 +1,28 @@ +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.*; + + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class BookDto { + 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/UserDto.java b/src/main/java/fic/writer/domain/entity/dto/UserDto.java new file mode 100644 index 0000000..716a430 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/dto/UserDto.java @@ -0,0 +1,23 @@ +package fic.writer.domain.entity.dto; + +import fic.writer.domain.entity.User; +import lombok.*; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class UserDto { + private String username; + private String about; + private String information; + + public static UserDto of(User user) { + return builder() + .username(user.getUsername()) + .about(user.getAbout()) + .information(user.getInformation()) + .build(); + } +} From e799f71b172b863f76ae62e48f77f5688e4bab4b Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:32:19 +0300 Subject: [PATCH 04/24] Add config to db in properties --- src/main/resources/application-db-mysql.yml | 17 +++ src/main/resources/application.yml | 9 ++ src/main/resources/data/actor.sql | 10 ++ src/main/resources/data/actor_state.sql | 7 + src/main/resources/data/article.sql | 146 ++++++++++++++++++++ src/main/resources/data/book.sql | 11 ++ src/main/resources/data/book_article.sql | 2 + src/main/resources/data/user.sql | 2 + 8 files changed, 204 insertions(+) create mode 100644 src/main/resources/application-db-mysql.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/data/actor.sql create mode 100644 src/main/resources/data/actor_state.sql create mode 100644 src/main/resources/data/article.sql create mode 100644 src/main/resources/data/book.sql create mode 100644 src/main/resources/data/book_article.sql create mode 100644 src/main/resources/data/user.sql diff --git a/src/main/resources/application-db-mysql.yml b/src/main/resources/application-db-mysql.yml new file mode 100644 index 0000000..c27a72f --- /dev/null +++ b/src/main/resources/application-db-mysql.yml @@ -0,0 +1,17 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/ficwriter + username: dl + password: p@ssword + initialization-mode: always + + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL5InnoDBDialect + hbm2ddl: + import_files_sql_extractor: org.hibernate.tool.hbm2ddl.MultipleLinesSqlCommandExtractor + import_files: data/user.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.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..596c249 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,9 @@ +spring: + profiles: + active: db-mysql +logging: + level: + org: + springframework: INFO +server: + port: 8080 \ No newline at end of file diff --git a/src/main/resources/data/actor.sql b/src/main/resources/data/actor.sql new file mode 100644 index 0000000..bb9597c --- /dev/null +++ b/src/main/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/main/resources/data/actor_state.sql b/src/main/resources/data/actor_state.sql new file mode 100644 index 0000000..6e99aca --- /dev/null +++ b/src/main/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/main/resources/data/article.sql b/src/main/resources/data/article.sql new file mode 100644 index 0000000..1572fba --- /dev/null +++ b/src/main/resources/data/article.sql @@ -0,0 +1,146 @@ +INSERT INTO article(id,annotation,content,title,book_id)VALUES(1,'Place for annotation','

Place for content.

','Summer inspiration',1); +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,book_id,annotation,title,content)VALUES(335,1,'','Зимняя поэма', +'

Измеряются грустью и чашками чая
+Безымянные, кроткие, зимние дни.
+Проживаешь мгновения, не замечая,
+Как бесследно теряются в прошлом они.
+
+Покрывает надежды обманчивый иней,
+Запотевшая память подобна окну.
+Растворяется контур реальности синей:
+Над экраном холодным и ясным усну.
+
+Утомлен безучастностью, сентиментален,
+Погружаюсь, запутавшись, в тихую тьму.
+А приснятся весенние язвы проталин
+И снега, равнодушны к концу своему.
+
+И покажется, будто неловко ступаю
+По сулящему гибель лукавому льду,
+Приближаюсь безвольно к незримому краю,
+Безучастно забвения смертного жду.
+
+Обреченность души молодой беспричинна,
+И мечты, и стремления сердца смешны.
+Неизбежно и близко маячит кончина,
+Обещая обители вечной весны.
+
+А былого сомнения ныне убоги,
+Поглотила пучина загробной реки;
+Мысли канули в тени последней дороги.
+Берега колдовской глубины далеки.
+
+Не внимая молитвам, судьба непреклонна,
+И слова, и молчание тщетны, пусты,
+Но зовут перезвоны миров Авалона
+Сквозь слепую завесу могильной черты.
+
+Промелькнуло знамение в зыби туманной,
+Воплощение обетованной земли.
+Кровоточит сознание призрачной раной.
+Рассеченную веру, Харон, исцели!
+
+На морозе растрескалась черная лодка,
+Перевозчик не тронет стремнину веслом.
+Изменилась от боли и страха походка:
+Не устанешь терзаться, скорбеть о былом.
+
+Разрывается грудь от печали железной,
+Утянуло уныние душу на дно.
+Беспредельная пропасть зияющей бездной
+Констатирует факт: умереть суждено.
+
+Принимаю жестокую истину эту.
+Под ногами ни твердь, но крошащийся наст.
+Пересечь пожелавший замерзшую Лету
+Неизбежно бесценное стражу отдаст.
+
+Проседает поверхность, ломаясь и тая,
+Увлекаясь течением мертвой воды,
+И тасует агония льды, как живая,
+И надежды безлики и сердцу чужды.
+
+На сминаемой глади нельзя схорониться,
+Настигает невидимый ужас. Фантом
+Беспощаден. Аида владений граница
+Искаверкала личность в тумане густом.
+
+Просыпаюсь, но кровь под висками грохочет,
+Что увидел в зловещем, пророческом сне.
+И дрожу, возвратившийся первопроходчик,
+Почему темнота ухмыляется мне?
+
+Поднимусь, в запыленное зеркало гляну,
+Не поверю, что нет в волосах седины.
+Удивленный открытому в духе изъяну,
+Осознаю, подобные обречены.
+
+Не бывает беспечна дорога поэта,
+И печаль неотступная бдит за спиной.
+Угнетает суровая истина эта.
+Почему безучастность любезна со мной?
+
+Очарован иллюзией, горечью болен,
+За словами под землю бреду в глубину.
+В лабиринтах блуждаю заброшенных штолен
+И ищу драгоценную правду одну.
+
+И в прорубленных поиском чьим-то пещерах
+Затеряюсь: обманка, сомнения, ложь.
+А в рассветных, неискренних сумерках серых
+На умершего в зеркале старом похож.
+
+Бесполезен желанием я. Одноразов
+И порыв, и поэзии выкрик немой.
+Принимаю стекляшки за груды алмазов.
+Одиссей никогда не вернется домой.
+
+Помню Данте и вечный огонь Одиссею,
+И правдивый, бесплотный, печальный язык.
+Опереться на слабую веру не смею,
+К безысходности мрачной за годы привык.
+
+Не боюсь очевидной угрозы обвала,
+Не боюсь нависающей тяжести гор.
+Равнодушная память, чадя, истлевала.
+Неужели конец однозначен и скор?
+
+Умываюсь, покинув недвижность постели,
+Отвечает насмешкой безмолвие глаз.
+А секунды, минуты, недели летели.
+Поклянусь измениться единственный раз.
+
+И слезами обет приношу перед Богом,
+Не терять ни мгновения, миг не терять.
+На пути разрушения скользком, пологом
+Не могу, покатившись, подняться опять.
+
+Поклонюсь утешающим, строгим иконам,
+И молитва развеет гнетущую тьму.
+И прощенный, служивший делам беззаконным,
+Не надеюсь тщеславно спастись самому.
+
+На восходе сугроб переливчат и розов,
+И затейлив узор приукрасил окно.
+Неужели с уходом кристальных морозов
+Полотно белотканное обречено?
+
+Опалив чистоту, молодое светило
+Прожигает сияющий кров белизны,
+А снежинки в бесплодную грязь превратило.
+Обтекают деревья угрюмы, темны.
+
+Почему разрушения ярость воспета?
+Сожаления вязки, сугубы, остры.
+Разлилась половодьем забвения Лета,
+Разделяя потоком зеркальным миры.
+
+А тлетворная оттепель жизни хлопочет,
+Умирает в смирении зимний покой.
+Вырываются листья из лопнувших почек,
+И весна торжествует в капели слепой.
'); \ No newline at end of file diff --git a/src/main/resources/data/book.sql b/src/main/resources/data/book.sql new file mode 100644 index 0000000..49fd526 --- /dev/null +++ b/src/main/resources/data/book.sql @@ -0,0 +1,11 @@ +INSERT INTO book(id,title,description, size, state,author_id)VALUES(1,'Arabella','Artic monkeys', 1,2,1); +INSERT INTO user_books_as_author (user_id,books_as_author_id)VALUES(1,1); +INSERT INTO book_subauthors (user_id,book_id)VALUES(2,1); +INSERT INTO book(title,description, size, state)VALUES('Old yellow bricks','Artic monkeys', 1,2); +INSERT INTO book(title,description, size, state)VALUES('End of me','Apocalyptica', 0,1); +INSERT INTO book(title,description, size, state)VALUES('Отблеск разочарований','Безнадежности печать...', 0,1); + + + + + diff --git a/src/main/resources/data/book_article.sql b/src/main/resources/data/book_article.sql new file mode 100644 index 0000000..1c61683 --- /dev/null +++ b/src/main/resources/data/book_article.sql @@ -0,0 +1,2 @@ +INSERT INTO book_articles(book_id,articles_id)VALUES(1,335); +INSERT INTO book_articles(book_id,articles_id)VALUES(1,1); \ No newline at end of file diff --git a/src/main/resources/data/user.sql b/src/main/resources/data/user.sql new file mode 100644 index 0000000..deb354d --- /dev/null +++ b/src/main/resources/data/user.sql @@ -0,0 +1,2 @@ +INSERT INTO user(id,about, information, username)VALUES(1,'I am author',' zaraza-takaja@mail.ru','Zaraza takaja'); +INSERT INTO user(id,about, information, username)VALUES(2,'I am author, too',' zaraza-takaja@mail.ru','@uthor'); \ No newline at end of file From dd61e47710ff31fb40d85b33c92e1234cf7d99c7 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:33:00 +0300 Subject: [PATCH 05/24] Add repositories --- .../writer/domain/repository/ActorRepository.java | 7 +++++++ .../domain/repository/ActorStateRepository.java | 15 +++++++++++++++ .../domain/repository/ArticleRepository.java | 10 ++++++++++ .../writer/domain/repository/BookRepository.java | 7 +++++++ .../writer/domain/repository/GenreRepository.java | 7 +++++++ .../writer/domain/repository/UserRepository.java | 10 ++++++++++ 6 files changed, 56 insertions(+) create mode 100644 src/main/java/fic/writer/domain/repository/ActorRepository.java create mode 100644 src/main/java/fic/writer/domain/repository/ActorStateRepository.java create mode 100644 src/main/java/fic/writer/domain/repository/ArticleRepository.java create mode 100644 src/main/java/fic/writer/domain/repository/BookRepository.java create mode 100644 src/main/java/fic/writer/domain/repository/GenreRepository.java create mode 100644 src/main/java/fic/writer/domain/repository/UserRepository.java 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/ActorStateRepository.java b/src/main/java/fic/writer/domain/repository/ActorStateRepository.java new file mode 100644 index 0000000..10cb175 --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/ActorStateRepository.java @@ -0,0 +1,15 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.ActorState; +import fic.writer.domain.entity.ActorStateId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ActorStateRepository extends JpaRepository { + Page findAllByIdActorId(Long actorId, Pageable pageable); + + Optional findAByIdActorIdAndIdArticleId(Long actorId, Long articleId); +} 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/UserRepository.java b/src/main/java/fic/writer/domain/repository/UserRepository.java new file mode 100644 index 0000000..361fabf --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/UserRepository.java @@ -0,0 +1,10 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); +} From 04727574963505a1c7ab3347c0b9c1a04c8b7b84 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:35:26 +0300 Subject: [PATCH 06/24] Add config for tests --- src/test/resources/application-db-h2.yml | 13 +++++++++++++ src/test/resources/application-db-mysql.yml | 12 ++++++++++++ src/test/resources/application.yml | 15 +++++++++++++++ src/test/resources/data/actor.sql | 10 ++++++++++ src/test/resources/data/actor_state.sql | 7 +++++++ src/test/resources/data/article.sql | 12 ++++++++++++ src/test/resources/data/book.sql | 15 +++++++++++++++ src/test/resources/data/book_article.sql | 5 +++++ src/test/resources/data/user.sql | 4 ++++ 9 files changed, 93 insertions(+) create mode 100644 src/test/resources/application-db-h2.yml create mode 100644 src/test/resources/application-db-mysql.yml create mode 100644 src/test/resources/application.yml create mode 100644 src/test/resources/data/actor.sql create mode 100644 src/test/resources/data/actor_state.sql create mode 100644 src/test/resources/data/article.sql create mode 100644 src/test/resources/data/book.sql create mode 100644 src/test/resources/data/book_article.sql create mode 100644 src/test/resources/data/user.sql 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.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..5828953 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,15 @@ +spring: + profiles: + active: db-mysql + 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 + 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..3ca4a6e --- /dev/null +++ b/src/test/resources/data/article.sql @@ -0,0 +1,12 @@ +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); + diff --git a/src/test/resources/data/book.sql b/src/test/resources/data/book.sql new file mode 100644 index 0000000..b3adcdc --- /dev/null +++ b/src/test/resources/data/book.sql @@ -0,0 +1,15 @@ +INSERT INTO book(id,title)VALUES(1,'book title'); +INSERT INTO book(id,title,description)VALUES(2,'book title','description'); +INSERT INTO book(id,title,size)VALUES(3,'book title',1); +INSERT INTO book(id,title,state)VALUES(4,'book title',1); +INSERT INTO book(id,title)VALUES(5,'book title'); +INSERT INTO book(id,title)VALUES(333,'delete book'); +INSERT INTO book(id,title)VALUES(334,'Книга для удаления'); + +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); + + + + diff --git a/src/test/resources/data/book_article.sql b/src/test/resources/data/book_article.sql new file mode 100644 index 0000000..7cf2f23 --- /dev/null +++ b/src/test/resources/data/book_article.sql @@ -0,0 +1,5 @@ +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(335,6); +INSERT INTO book_articles(book_id,articles_id)VALUES(335,7); +INSERT INTO book_articles(book_id,articles_id)VALUES(335,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..dd5d16b --- /dev/null +++ b/src/test/resources/data/user.sql @@ -0,0 +1,4 @@ +INSERT INTO user(id,username)VALUES(123,'delete user'); +INSERT INTO user(id,username)VALUES(1,'test user'); +INSERT INTO user(id,username)VALUES(3,'Bella'); +INSERT INTO user(id,username)VALUES(4,'Bella junior'); \ No newline at end of file From 2e4c135b23c3331d3c33f3b4d928bec368965f45 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:37:06 +0300 Subject: [PATCH 07/24] Create abstract services --- .../writer/domain/service/ActorService.java | 25 ++++++++++++++ .../domain/service/ActorStateService.java | 25 ++++++++++++++ .../writer/domain/service/ArticleService.java | 25 ++++++++++++++ .../writer/domain/service/BookService.java | 34 +++++++++++++++++++ .../writer/domain/service/CrudService.java | 21 ++++++++++++ .../writer/domain/service/GenreService.java | 7 ++++ .../writer/domain/service/UserService.java | 27 +++++++++++++++ 7 files changed, 164 insertions(+) create mode 100644 src/main/java/fic/writer/domain/service/ActorService.java create mode 100644 src/main/java/fic/writer/domain/service/ActorStateService.java create mode 100644 src/main/java/fic/writer/domain/service/ArticleService.java create mode 100644 src/main/java/fic/writer/domain/service/BookService.java create mode 100644 src/main/java/fic/writer/domain/service/CrudService.java create mode 100644 src/main/java/fic/writer/domain/service/GenreService.java create mode 100644 src/main/java/fic/writer/domain/service/UserService.java 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/ActorStateService.java b/src/main/java/fic/writer/domain/service/ActorStateService.java new file mode 100644 index 0000000..f3988bf --- /dev/null +++ b/src/main/java/fic/writer/domain/service/ActorStateService.java @@ -0,0 +1,25 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.ActorState; +import fic.writer.domain.entity.ActorStateId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ActorStateService { + Page findAllByActor(Long id, Pageable pageable); + + Optional findForActorByArticle(Long actorId, Long articleId); + + List findAll(); + + Page findPage(Pageable pageable); + + Optional findById(ActorStateId actorStateId); + + void delete(ActorState actorState); + + void deleteById(ActorStateId actorStateId); +} 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..8129e9a --- /dev/null +++ b/src/main/java/fic/writer/domain/service/ArticleService.java @@ -0,0 +1,25 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.dto.ArticleDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ArticleService { + List

findAll(); + + List
findAllForBook(Long bookId); + + Page
findPage(Pageable pageable); + + Article update(Long id, ArticleDto articleDto); + + Optional
findById(Long aLong); + + void delete(Article article); + + void deleteById(Long aLong); +} 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..49a027d --- /dev/null +++ b/src/main/java/fic/writer/domain/service/BookService.java @@ -0,0 +1,34 @@ +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.io.IOException; +import java.util.List; +import java.util.Optional; + +public interface BookService { + + List findAll(); + + Page findPage(Pageable pageable); + + Optional findById(Long aLong); + + Book create(BookDto bookDto); + + 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 aLong); + + byte[] getBookAsByteArray(Long bookId) throws IOException; +} 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/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/UserService.java b/src/main/java/fic/writer/domain/service/UserService.java new file mode 100644 index 0000000..5df0850 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/UserService.java @@ -0,0 +1,27 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.User; +import fic.writer.domain.entity.dto.UserDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface UserService { + List findAll(); + + Page findPage(Pageable pageable); + + Optional findById(Long aLong); + + Optional findByUsername(String username); + + User create(UserDto user); + + User update(Long userId, UserDto user); + + void delete(User user); + + void deleteById(Long aLong); +} From 39d68b0061decc9fce13fd201f702bd9ff6283d5 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:38:41 +0300 Subject: [PATCH 08/24] Create tests for services --- .../ActorAndActorStateServicesTest.java | 105 +++++++++++++++ .../domain/service/ActorServiceTest.java | 59 +++++++++ .../domain/service/ActorStateServiceTest.java | 125 ++++++++++++++++++ .../domain/service/ArticleServiceTest.java | 54 ++++++++ .../service/BookAndArticleServicesTest.java | 56 ++++++++ .../domain/service/BookServiceTest.java | 69 ++++++++++ .../domain/service/UserServiceTest.java | 42 ++++++ 7 files changed, 510 insertions(+) create mode 100644 src/test/java/fic/writer/domain/service/ActorAndActorStateServicesTest.java create mode 100644 src/test/java/fic/writer/domain/service/ActorServiceTest.java create mode 100644 src/test/java/fic/writer/domain/service/ActorStateServiceTest.java create mode 100644 src/test/java/fic/writer/domain/service/ArticleServiceTest.java create mode 100644 src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java create mode 100644 src/test/java/fic/writer/domain/service/BookServiceTest.java create mode 100644 src/test/java/fic/writer/domain/service/UserServiceTest.java diff --git a/src/test/java/fic/writer/domain/service/ActorAndActorStateServicesTest.java b/src/test/java/fic/writer/domain/service/ActorAndActorStateServicesTest.java new file mode 100644 index 0000000..4c36603 --- /dev/null +++ b/src/test/java/fic/writer/domain/service/ActorAndActorStateServicesTest.java @@ -0,0 +1,105 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Actor; +import fic.writer.domain.entity.ActorState; +import fic.writer.domain.entity.ActorStateId; +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.dto.ActorDto; +import org.hibernate.Session; +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 javax.persistence.EntityManager; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +@RunWith(SpringRunner.class) +@SpringBootTest +@Transactional +public class ActorAndActorStateServicesTest { + @Autowired + private ActorService actorService; + @Autowired + private ActorStateService actorStateService; + @Autowired + private EntityManager entityManager; + + @Test + public void updateActorWithActorStates_whenCorrect_shouldUpdateInActorState() { + final Long ACTOR_ID = 333L; + final Long ARTICLE_ID = 3L; + final String NEW_ACTOR_NAME = "new name"; + Actor actor = actorService.findById(ACTOR_ID).get(); + ActorStateId actorStateId = ActorStateId.builder() + .actorId(ACTOR_ID) + .articleId(ARTICLE_ID) + .build(); + actor.setName(NEW_ACTOR_NAME); + actorService.update(ACTOR_ID, ActorDto.of(actor)); + + ActorState actorState = actorStateService.findById(actorStateId).get(); + + assertEquals(NEW_ACTOR_NAME, actorState.getActor().getName()); + } + + private Session getSession() { + return entityManager.unwrap(Session.class); + } + + @Test + public void deleteActorState_whenCorrect_shouldCleanInActor() { + final Long ACTOR_ID = 333L; + final Long ARTICLE_ID = 3L; + Session session = getSession(); + ActorStateId actorStateId = ActorStateId.builder() + .actorId(ACTOR_ID) + .articleId(ARTICLE_ID) + .build(); + + actorStateService.deleteById(actorStateId); + session.flush(); + Actor actor = actorService.findById(ACTOR_ID).get(); + + Boolean isStateInActor = actor.getActorStates().stream().anyMatch(as -> as.getId().equals(actorStateId)); + assertFalse(isStateInActor); + } + + + @Test + public void deleteActor_whenCorrect_shouldCleanActorStates() { + final Long ACTOR_ID = 334L; + + Actor actor = actorService.findById(ACTOR_ID).get(); + + actorService.deleteById(ACTOR_ID); + + assertFalse(actorStateService.findAll().stream().anyMatch(as -> as.getActor().equals(actor))); + } + + @Test + public void createActorState_whenCorrect_shouldCascadeAddInActor() { + final Long ACTOR_ID = 333L; + final Long ARTICLE_ID = 1L; + final int EXPECTED_SIZE = 2; + Article article = Article.builder().id(ARTICLE_ID).build(); + Actor actor = actorService.findById(ACTOR_ID).get(); + ActorState actorState = ActorState.builder() + .id(ActorStateId.builder().actorId(ACTOR_ID).articleId(ARTICLE_ID).build()) + .actor(actor) + .article(article) + .title("title") + .build(); + Set actorStates = actor.getActorStates(); + actorStates.add(actorState); + actor.setActorStates(actorStates); + actorService.update(ACTOR_ID, ActorDto.of(actor)); + int changedSize = actorService.findById(ACTOR_ID).get().getActorStates().size(); + assertEquals(EXPECTED_SIZE, changedSize); + } +} 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..85f6754 --- /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().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/ActorStateServiceTest.java b/src/test/java/fic/writer/domain/service/ActorStateServiceTest.java new file mode 100644 index 0000000..419ba1a --- /dev/null +++ b/src/test/java/fic/writer/domain/service/ActorStateServiceTest.java @@ -0,0 +1,125 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Actor; +import fic.writer.domain.entity.ActorState; +import fic.writer.domain.entity.ActorStateId; +import fic.writer.domain.entity.Article; +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.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +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 ActorStateServiceTest { + @Autowired + private ActorStateService actorStateService; + @Autowired + private ActorService actorService; + + @Test + public void createActorState_whenCorrect_shouldFindWithId() { + final Long ARTICLE_ID = 3L; + final Long ACTOR_ID = 3L; + Actor actor = actorService.findById(ACTOR_ID).get(); + ActorState actorState = buildActorState(actor, ARTICLE_ID); + actor.getActorStates().add(actorState); + + actor = actorService.update(ACTOR_ID, ActorDto.of(actor)); + assertTrue(actorStateService.findById(actorState.getId()).isPresent()); + } + + private ActorState buildActorState(Actor actor, Long articleId) { + Article article = Article.builder().id(articleId).build(); + ActorStateId actorStateId = ActorStateId.builder().articleId(articleId).actorId(actor.getId()).build(); + return ActorState.builder() + .id(actorStateId) + .actor(actor) + .article(article) + .build(); + } + + private ActorState buildActorState(Long actorId, Long articleId) { + Article article = Article.builder().id(articleId).build(); + Actor actor = actorService.findById(actorId).get(); + ActorStateId actorStateId = ActorStateId.builder().articleId(articleId).actorId(actor.getId()).build(); + return ActorState.builder() + .id(actorStateId) + .actor(actor) + .article(article) + .build(); + } + + @Test + public void findActorStateByArticle_whenCorrect_shouldExist() { + assertTrue(actorStateService.findForActorByArticle(1L, 1L).isPresent()); + } + + @Test + public void findActorStateByActor_whenCorrect_shouldExist() { + Pageable pageable = new PageRequest(0, 10); + assertNotEquals(0, actorStateService.findAllByActor(1L, pageable).getTotalElements()); + } + + @Test + public void findActorStateByActor_whenActorNotExist_shouldReturnEmptyPage() { + Pageable pageable = new PageRequest(0, 10); + assertEquals(0, actorStateService.findAllByActor(-1L, pageable).getTotalElements()); + } + + @Test + public void findActorStateByArticle_whenArticleNotExists_shouldExist() { + assertFalse(actorStateService.findForActorByArticle(1L, -1L).isPresent()); + } + + @Test + public void createActorState_whenAlreadyExist_shouldCreateId() { + final Long ARTICLE_ID = 1L; + final Long ACTOR_ID = 1L; + + Actor actor = actorService.findById(ACTOR_ID).get(); + ActorState actorState = buildActorState(ACTOR_ID, ARTICLE_ID); + actor.getActorStates().add(actorState); + actorService.update(ACTOR_ID, ActorDto.of(actor)); + + assertNotNull(actorStateService.findForActorByArticle(ACTOR_ID, ARTICLE_ID)); + } + + @Test + public void deleteActorState_whenCorrect_shouldNotFoundAfterDelete() { + final Long ARTICLE_ID = 2L; + final Long ACTOR_ID = 2L; + ActorStateId actorStateId = ActorStateId.builder().actorId(ACTOR_ID).articleId(ARTICLE_ID).build(); + + Optional actorState = actorStateService.findById(actorStateId); + assertTrue(actorState.isPresent()); + + actorStateService.delete(actorState.get()); + + assertFalse(actorStateService.findById(actorStateId).isPresent()); + } + + @Test(expected = EmptyResultDataAccessException.class) + public void deleteActorState_whenIdNotExist_should() { + final Long ARTICLE_ID = 2L; + final Long ACTOR_ID = -2L; + + ActorStateId actorStateId = ActorStateId.builder() + .actorId(ACTOR_ID) + .articleId(ARTICLE_ID) + .build(); + + actorStateService.deleteById(actorStateId); + } +} \ 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..9c52f6f --- /dev/null +++ b/src/test/java/fic/writer/domain/service/ArticleServiceTest.java @@ -0,0 +1,54 @@ +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.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.junit4.SpringRunner; + +import java.util.Date; + +import static org.junit.Assert.*; + +@RunWith(SpringRunner.class) +@SpringBootTest +@EnableJpaAuditing +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()); + } + +} \ 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..3b2b564 --- /dev/null +++ b/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java @@ -0,0 +1,56 @@ +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.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.Assert.*; + +@RunWith(SpringRunner.class) +@SpringBootTest +@EnableJpaAuditing +@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().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().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..279574c --- /dev/null +++ b/src/test/java/fic/writer/domain/service/BookServiceTest.java @@ -0,0 +1,69 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.dto.BookDto; +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 static org.junit.Assert.*; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class BookServiceTest { + @Autowired + private BookService bookService; + + @Test + public void createBook_shouldChangeCount() { + final int SIZE_BEFORE = bookService.findAll().size(); + Book emptyBook = Book.builder().build(); + 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; + Book emptyBook = Book.builder().title(TITLE).build(); + bookService.create(BookDto.of(emptyBook)); + + assertEquals(CURRENT_COUNT, bookService.findAll().stream().filter(book -> book.getTitle().equals(TITLE)).count()); + } + + @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 = EmptyResultDataAccessException.class) + public void deletedBook_whenNotExist_shouldThorwException() { + final Long DELETE_BOOK_ID = -1L; + bookService.deleteById(DELETE_BOOK_ID); + } + +} \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/service/UserServiceTest.java b/src/test/java/fic/writer/domain/service/UserServiceTest.java new file mode 100644 index 0000000..2dc5473 --- /dev/null +++ b/src/test/java/fic/writer/domain/service/UserServiceTest.java @@ -0,0 +1,42 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.dto.UserDto; +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 UserServiceTest { + @Autowired + private UserService userService; + + @Test + public void createUser() { + final String USERNAME = "createTestUser"; + UserDto user = UserDto.builder().username(USERNAME).build(); + userService.create(user); + assertTrue(userService.findAll().stream().anyMatch(u -> u.getUsername().equals(USERNAME))); + } + + @Test + public void deleteUser() { + final Long USER_ID = 123L; + assertTrue(userService.findById(USER_ID).isPresent()); + userService.deleteById(USER_ID); + assertFalse(userService.findById(USER_ID).isPresent()); + } + + @Test + public void updateUser_whenUpdateAbout_shouldChangeAbout() { + final Long USER_ID = 1L; + final String NEW_ABOUT = "new about"; + UserDto userDto = UserDto.builder().about(NEW_ABOUT).build(); + userService.update(USER_ID, userDto); + assertEquals(NEW_ABOUT, userService.findById(USER_ID).get().getAbout()); + } +} \ No newline at end of file From 4ead6e56a7d159b8c10181e1164f6ec43e47fd82 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:40:12 +0300 Subject: [PATCH 09/24] Implements services --- .../domain/service/impl/ActorServiceImpl.java | 75 +++++++++ .../service/impl/ActorStateServiceImpl.java | 70 ++++++++ .../service/impl/ArticleServiceImpl.java | 74 +++++++++ .../domain/service/impl/BookServiceImpl.java | 154 ++++++++++++++++++ .../domain/service/impl/GenreServiceImpl.java | 52 ++++++ .../domain/service/impl/UserServiceImpl.java | 82 ++++++++++ 6 files changed, 507 insertions(+) create mode 100644 src/main/java/fic/writer/domain/service/impl/ActorServiceImpl.java create mode 100644 src/main/java/fic/writer/domain/service/impl/ActorStateServiceImpl.java create mode 100644 src/main/java/fic/writer/domain/service/impl/ArticleServiceImpl.java create mode 100644 src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java create mode 100644 src/main/java/fic/writer/domain/service/impl/GenreServiceImpl.java create mode 100644 src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java 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..c67d57d --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/ActorServiceImpl.java @@ -0,0 +1,75 @@ +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 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 update(Long id, ActorDto actorDto) { + Actor actor = actorRepository.findById(id).orElseThrow(EntityNotFoundException::new); + flushArticleDtoToArticle(actor, actorDto); + return actorRepository.save(actor); + } + + private void flushArticleDtoToArticle(Actor actor, ActorDto actorDto) { + if (actorDto.getName() != null) { + actor.setName(actorDto.getName()); + } + if (actorDto.getActorStates() != null) { + actor.setActorStates(actorDto.getActorStates()); + } + if (actorDto.getDescription() != null) { + actor.setDescription(actorDto.getDescription()); + } + } + + @Override + public Actor getOne(Long id) { + return actorRepository.getOne(id); + } + + @Override + public void deleteById(Long id) { + actorRepository.deleteById(id); + } + + @Override + public Actor create(ActorDto actorDto) { + Actor actor = Actor.builder().build(); + flushArticleDtoToArticle(actor, actorDto); + return actorRepository.save(actor); + } +} diff --git a/src/main/java/fic/writer/domain/service/impl/ActorStateServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/ActorStateServiceImpl.java new file mode 100644 index 0000000..3749d67 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/ActorStateServiceImpl.java @@ -0,0 +1,70 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.ActorState; +import fic.writer.domain.entity.ActorStateId; +import fic.writer.domain.entity.dto.ActorStateDto; +import fic.writer.domain.repository.ActorRepository; +import fic.writer.domain.repository.ActorStateRepository; +import fic.writer.domain.service.ActorStateService; +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 ActorStateServiceImpl implements ActorStateService { + private ActorStateRepository actorStateRepository; + private ActorRepository actorRepository; + + @Autowired + public ActorStateServiceImpl(ActorStateRepository actorStateRepository) { + this.actorStateRepository = actorStateRepository; + } + + @Override + public List findAll() { + return actorStateRepository.findAll(); + } + + + @Override + public Page findPage(Pageable pageable) { + return actorStateRepository.findAll(pageable); + } + + @Override + public Optional findById(ActorStateId id) { + return actorStateRepository.findById(id); + } + + @Override + public void delete(ActorState actorState) { + actorStateRepository.delete(actorState); + } + + @Override + public void deleteById(ActorStateId id) { + actorStateRepository.deleteById(id); + } + + @Override + public Page findAllByActor(Long id, Pageable pageable) { + return actorStateRepository.findAllByIdActorId(id, pageable); + } + + @Override + public Optional findForActorByArticle(Long actorId, Long articleId) { + return actorStateRepository.findAByIdActorIdAndIdArticleId(actorId, articleId); + } + + private ActorState actorStateDtoForActorState(ActorStateDto actorState) { + return ActorState.builder() + .id(actorState.getId()) + .title(actorState.getTitle()) + .content(actorState.getContent()) + .build(); + } +} 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..3ad1f61 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/ArticleServiceImpl.java @@ -0,0 +1,74 @@ +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 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.EntityExistsException; +import java.util.List; +import java.util.Optional; + +@Service +public class ArticleServiceImpl implements ArticleService { + private ArticleRepository articleRepository; + + @Autowired + public ArticleServiceImpl(ArticleRepository articleRepository) { + this.articleRepository = articleRepository; + } + + @Override + public List
findAll() { + return articleRepository.findAll(); + } + + @Override + public List
findAllForBook(Long bookId) { + return articleRepository.findAllByBookId(bookId); + } + + @Override + public Page
findPage(Pageable pageable) { + return articleRepository.findAll(pageable); + } + + @Override + public Article update(Long id, ArticleDto articleDto) { + Article article = articleRepository.findById(id).orElseThrow(EntityExistsException::new); + flushArticleDtoToArticle(article, articleDto); + return articleRepository.save(article); + } + + @Override + public Optional
findById(Long id) { + return articleRepository.findById(id); + } + + @Override + public void delete(Article article) { + articleRepository.delete(article); + } + + @Override + public void deleteById(Long id) { + articleRepository.deleteById(id); + } + + + private 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/impl/BookServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java new file mode 100644 index 0000000..8d850e8 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java @@ -0,0 +1,154 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.User; +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 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.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Service +@EntityListeners(AuditingEntityListener.class) +@Transactional +public class BookServiceImpl implements BookService { + private BookRepository bookRepository; + + @Autowired + + public BookServiceImpl(BookRepository bookRepository) { + this.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 = Book.builder().build(); + flushBookDtoToBook(book, bookDto); + return bookRepository.save(book); + } + + @Override + public Book update(Long id, BookDto bookDto) { + Book book = bookRepository.getOne(id); + flushBookDtoToBook(book, bookDto); + return bookRepository.save(book); + } + + @Override + public Book addArticle(Long bookId, ArticleDto articleDto) { + Book book = bookRepository.getOne(bookId); + Article article = Article.builder().build(); + flushArticleDtoToArticle(article, articleDto); + article.setBook(Book.builder().id(bookId).build()); + book.getArticles().add(article); + 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; + } + + private 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()); + } + } + + @Override + public void delete(Book book) { + bookRepository.delete(book); + } + + @Override + public void deleteById(Long id) { + bookRepository.deleteById(id); + } + + @Override + public byte[] getBookAsByteArray(Long bookId) throws IOException { + Book book = bookRepository.findById(bookId).orElseThrow(EntityNotFoundException::new); + String output = ""; + output += "Title:" + book.getTitle(); + output += " \nDescription:" + book.getDescription(); + if (book.getAuthor() != null) { + output += " \nAuthor:" + book.getAuthor().getUsername(); + } + String coauthors = book.getSubAuthors().stream().map(User::getUsername).reduce((b, a) -> b + "," + a).orElse(""); + output += " \nCoauthor:" + coauthors; + if (book.getSize() != null) { + output += " \nSize:" + book.getSize().name(); + } + if (book.getSize() != null) { + output += " \nState:" + book.getState().name(); + } + int counter = 0; + String content = " \nContent: \n"; + Set
articles = book.getArticles(); + for (Article article : articles) { + content += " \n" + ++counter + ". " + article.getTitle(); + } + output += content; + + for (Article article : articles) { + output += " \n" + article.getTitle(); + output += " \nAnnotation:" + article.getAnnotation(); + output += " \n" + article.getContent(); + } + return output.getBytes(); + } + + private 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()); + } + } +} 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/UserServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..30dba9e --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java @@ -0,0 +1,82 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.User; +import fic.writer.domain.entity.dto.UserDto; +import fic.writer.domain.repository.UserRepository; +import fic.writer.domain.service.UserService; +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 UserServiceImpl implements UserService { + private UserRepository userRepository; + + @Autowired + public UserServiceImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public List findAll() { + return userRepository.findAll(); + } + + @Override + public Page findPage(Pageable pageable) { + return userRepository.findAll(pageable); + } + + @Override + public Optional findById(Long id) { + return userRepository.findById(id); + } + + @Override + public Optional findByUsername(String username) { + return userRepository.findByUsername(username); + } + + @Override + public User create(UserDto userDto) { + User user = User.builder().build(); + flushUserDtoToUser(user, userDto); + return userRepository.save(user); + + } + + @Override + public User update(Long userId, UserDto userDto) { + User user = userRepository.findById(userId).orElseThrow(EntityNotFoundException::new); + flushUserDtoToUser(user, userDto); + userRepository.save(user); + return user; + } + + private void flushUserDtoToUser(User user, UserDto userDto) { + if (userDto.getUsername() != null) { + user.setUsername(userDto.getUsername()); + } + if (userDto.getAbout() != null) { + user.setAbout(userDto.getAbout()); + } + if (userDto.getInformation() != null) { + user.setInformation(userDto.getInformation()); + } + } + + @Override + public void delete(User user) { + userRepository.delete(user); + } + + @Override + public void deleteById(Long id) { + userRepository.deleteById(id); + } +} From c6543353f9c9cbe202cffa8767dd7a47572f525a Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:41:40 +0300 Subject: [PATCH 10/24] Create responses for entities --- .../writer/web/response/ActorResponse.java | 39 ++++++++++ .../web/response/ActorStateResponse.java | 12 +++ .../writer/web/response/ArticleResponse.java | 37 ++++++++++ .../fic/writer/web/response/BookResponse.java | 74 +++++++++++++++++++ .../fic/writer/web/response/PageResponse.java | 52 +++++++++++++ .../fic/writer/web/response/UserResponse.java | 45 +++++++++++ 6 files changed, 259 insertions(+) create mode 100644 src/main/java/fic/writer/web/response/ActorResponse.java create mode 100644 src/main/java/fic/writer/web/response/ActorStateResponse.java create mode 100644 src/main/java/fic/writer/web/response/ArticleResponse.java create mode 100644 src/main/java/fic/writer/web/response/BookResponse.java create mode 100644 src/main/java/fic/writer/web/response/PageResponse.java create mode 100644 src/main/java/fic/writer/web/response/UserResponse.java diff --git a/src/main/java/fic/writer/web/response/ActorResponse.java b/src/main/java/fic/writer/web/response/ActorResponse.java new file mode 100644 index 0000000..b86ea74 --- /dev/null +++ b/src/main/java/fic/writer/web/response/ActorResponse.java @@ -0,0 +1,39 @@ +package fic.writer.web.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; + private Set actorStates; + + public ActorResponse(Actor actor) { + actorId = actor.getId(); + name = actor.getName(); + description = actor.getDescription(); + books = actor.getBooks().stream().map(BookResponse::new).collect(Collectors.toSet()); + actorStates = actor.getActorStates().stream().map(ActorStateResponse::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/response/ActorStateResponse.java b/src/main/java/fic/writer/web/response/ActorStateResponse.java new file mode 100644 index 0000000..67a6718 --- /dev/null +++ b/src/main/java/fic/writer/web/response/ActorStateResponse.java @@ -0,0 +1,12 @@ +package fic.writer.web.response; + +import fic.writer.domain.entity.ActorState; +import org.springframework.hateoas.ResourceSupport; + +public class ActorStateResponse extends ResourceSupport { + private ActorState actorState; + + public ActorStateResponse(ActorState actorState) { + this.actorState = actorState; + } +} diff --git a/src/main/java/fic/writer/web/response/ArticleResponse.java b/src/main/java/fic/writer/web/response/ArticleResponse.java new file mode 100644 index 0000000..ca96f1e --- /dev/null +++ b/src/main/java/fic/writer/web/response/ArticleResponse.java @@ -0,0 +1,37 @@ +package fic.writer.web.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; + + public ArticleResponse(Article article) { + articleId = article.getId(); + title = article.getTitle(); + created = article.getCreated(); + content = article.getContent(); + annotation = article.getAnnotation(); + 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/response/BookResponse.java b/src/main/java/fic/writer/web/response/BookResponse.java new file mode 100644 index 0000000..5baaa2c --- /dev/null +++ b/src/main/java/fic/writer/web/response/BookResponse.java @@ -0,0 +1,74 @@ +package fic.writer.web.response; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.Genre; +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.UserController; +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; + + public BookResponse(Book book) { + this.bookId = book.getId(); + title = book.getTitle(); + if (book.getAuthor() != null) { + Long authorId = book.getAuthor().getId(); + author = linkTo(methodOn(UserController.class, authorId).getUserById(authorId)).withRel("author"); + } + subAuthors = book.getSubAuthors().stream().map(author -> + linkTo(methodOn(UserController.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(BookController.class, id).downloadBook(id)).withRel("download")); + } catch (IOException e) { + e.printStackTrace(); + } + } + + +} diff --git a/src/main/java/fic/writer/web/response/PageResponse.java b/src/main/java/fic/writer/web/response/PageResponse.java new file mode 100644 index 0000000..3b6e4ce --- /dev/null +++ b/src/main/java/fic/writer/web/response/PageResponse.java @@ -0,0 +1,52 @@ +package fic.writer.web.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/response/UserResponse.java b/src/main/java/fic/writer/web/response/UserResponse.java new file mode 100644 index 0000000..bc6d942 --- /dev/null +++ b/src/main/java/fic/writer/web/response/UserResponse.java @@ -0,0 +1,45 @@ +package fic.writer.web.response; + +import fic.writer.domain.entity.User; +import fic.writer.web.controller.UserController; +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 UserResponse extends ResourceSupport { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userId; + private String username; + private String about; + private String information; + private Set booksAsSubAuthor; + private Set booksAsAuthor; + + public UserResponse(User user) { + this.userId = user.getId(); + username = user.getUsername(); + about = user.getAbout(); + information = user.getInformation(); + booksAsSubAuthor = user.getBooksAsSubAuthor().stream().map(BookResponse::new).collect(Collectors.toSet()); + booksAsAuthor = user.getBooksAsAuthor().stream().map(BookResponse::new).collect(Collectors.toSet()); + addSelfLink(userId); + } + + private void addSelfLink(Long id) { + add(linkTo(methodOn(UserController.class, id).getUserById(id)).withSelfRel()); + } +} From 8cccb073c5ea6a9e81d1febbc2109ddb19377916 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:42:45 +0300 Subject: [PATCH 11/24] Create rest controllers --- .../web/controller/ActorController.java | 59 ++++++++ .../web/controller/ArticleController.java | 61 ++++++++ .../writer/web/controller/BookController.java | 82 +++++++++++ .../writer/web/controller/UserController.java | 61 ++++++++ .../web/controller/UserControllerTest.java | 133 ++++++++++++++++++ 5 files changed, 396 insertions(+) create mode 100644 src/main/java/fic/writer/web/controller/ActorController.java create mode 100644 src/main/java/fic/writer/web/controller/ArticleController.java create mode 100644 src/main/java/fic/writer/web/controller/BookController.java create mode 100644 src/main/java/fic/writer/web/controller/UserController.java create mode 100644 src/test/java/fic/writer/web/controller/UserControllerTest.java 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..0c254b7 --- /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.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..4016fbb --- /dev/null +++ b/src/main/java/fic/writer/web/controller/ArticleController.java @@ -0,0 +1,61 @@ +package fic.writer.web.controller; + +import fic.writer.domain.entity.dto.ArticleDto; +import fic.writer.domain.service.ArticleService; +import fic.writer.domain.service.BookService; +import fic.writer.web.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.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping(value = "/books/{bookId}/articles", produces = MediaType.APPLICATION_JSON_VALUE) +@CrossOrigin(origins = "http://localhost:3000") +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_PATH = "/{bookId}"; + 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 = articleService.findAllForBook(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 articleService.findAllForBook(bookId).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/BookController.java b/src/main/java/fic/writer/web/controller/BookController.java new file mode 100644 index 0000000..1969ea6 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/BookController.java @@ -0,0 +1,82 @@ +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.web.response.BookResponse; +import fic.writer.web.response.PageResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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 javax.persistence.EntityNotFoundException; +import java.io.IOException; + +@RestController +@RequestMapping(value = "/books", produces = MediaType.APPLICATION_JSON_VALUE) +@CrossOrigin(origins = "http://localhost:3000") +public class BookController { + private static final String ID_TEMPLATE_PATH = "/{bookId}"; + private static final String ID_TEMPLATE = "bookId"; + + private BookService bookService; + + @Autowired + public BookController(BookService bookService) { + this.bookService = bookService; + } + + @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 = bookService.create(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) + public HttpStatus deleteBook(Long id) { + bookService.deleteById(id); + return HttpStatus.NO_CONTENT; + } + + @GetMapping(ID_TEMPLATE_PATH + "/download") + public ResponseEntity downloadBook(@PathVariable(ID_TEMPLATE) Long id) throws IOException { + byte[] bytes = bookService.getBookAsByteArray(id); + Book book = bookService.findById(id).get(); + + ByteArrayResource resource = new ByteArrayResource(bytes); + MediaType mediaType = MediaType.TEXT_PLAIN; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + book.getTitle() + ".txt") + .contentType(mediaType) + .contentLength(bytes.length) + .body(resource); + } + + +} diff --git a/src/main/java/fic/writer/web/controller/UserController.java b/src/main/java/fic/writer/web/controller/UserController.java new file mode 100644 index 0000000..dd1f123 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/UserController.java @@ -0,0 +1,61 @@ +package fic.writer.web.controller; + +import fic.writer.domain.entity.User; +import fic.writer.domain.entity.dto.UserDto; +import fic.writer.domain.service.UserService; +import fic.writer.web.response.UserResponse; +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("/users") +@CrossOrigin(origins = "http://localhost:3000") +public class UserController { + private static final String ID_TEMPLATE_PATH = "/{userId}"; + private static final String ID_TEMPLATE = "userId"; + + private UserService userService; + + @Autowired + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping + public List getAllUsers() { + return userService.findAll().stream() + .map(UserResponse::new) + .collect(Collectors.toList()); + } + + @GetMapping(ID_TEMPLATE_PATH) + public UserResponse getUserById(@PathVariable(ID_TEMPLATE) Long id) { + return userService.findById(id) + .map(UserResponse::new) + .orElseThrow(EntityNotFoundException::new); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public UserResponse createUser(@RequestBody UserDto user) { + User savedUser = userService.create(user); + return new UserResponse(savedUser); + } + + @PutMapping(ID_TEMPLATE_PATH) + public UserResponse updateUser(@PathVariable(ID_TEMPLATE) Long id, @RequestBody UserDto userDto) { + User savedUser = userService.update(id, userDto); + return new UserResponse(savedUser); + } + + @DeleteMapping(ID_TEMPLATE_PATH) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteUser(Long id) { + userService.deleteById(id); + } +} diff --git a/src/test/java/fic/writer/web/controller/UserControllerTest.java b/src/test/java/fic/writer/web/controller/UserControllerTest.java new file mode 100644 index 0000000..3c2eb61 --- /dev/null +++ b/src/test/java/fic/writer/web/controller/UserControllerTest.java @@ -0,0 +1,133 @@ +package fic.writer.web.controller; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import fic.writer.domain.entity.User; +import fic.writer.domain.entity.dto.UserDto; +import fic.writer.domain.service.UserService; +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.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.ArrayList; +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 = UserController.class, secure = false) +public class UserControllerTest { + private static final String USERS_PATH = "/users"; + private static final String USER_ID_PATH_TEMPLATE = USERS_PATH + "/{id}"; + @Autowired + private UserController userController; + @Autowired + private MockMvc mockMvc; + @MockBean + private UserService userService; + + + @Test + public void getUsers_whenDtoIsEmpty_shouldReturnOk() throws Exception { + List userList = new ArrayList<>(); + User user = new User(); + user.setId(1L); + user.setUsername("testUsername"); + userList.add(user); + Mockito.when(userService.findAll()).thenReturn(userList); + + mockMvc.perform(get(USERS_PATH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.[0].username").value(user.getUsername())) + .andExpect(jsonPath("$.[0].links.[0].rel").value("self")); + } + + @Test + public void getUserById_whenUserExists_shouldReturnOk() throws Exception { + final long ID = 1L; + User user = new User(); + user.setId(ID); + user.setUsername("testUsername"); + + Mockito.when(userService.findById(1L)).thenReturn(Optional.of(user)); + + mockMvc.perform(get(USER_ID_PATH_TEMPLATE, ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value(user.getUsername())) + .andExpect(jsonPath("$._links.self").hasJsonPath()); + } + + @Test + public void createUser() throws Exception { + final Long USER_ID = 1L; + final String username = "testUsername", about = "about", information = "inform"; + UserDto dto = new UserDto(username, about, information); + ObjectMapper mapper = new ObjectMapper(); + + User user = User.builder() + .id(USER_ID) + .username(username) + .about(about) + .information(information) + .booksAsAuthor(new HashSet<>()) + .booksAsSubAuthor(new HashSet<>()) + .build(); + + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + String body = mapper.writeValueAsString(dto); + Mockito.when(userService.create(any(UserDto.class))).thenReturn(user); + + mockMvc.perform(post(USERS_PATH) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.username").value(user.getUsername())) + .andExpect(jsonPath("$._links.self").hasJsonPath()); + } + + @Test + public void updateUser() throws Exception { + final Long USER_ID = 1L; + final String NEW_USERNAME = "testUsername"; + User updatedUser = User.builder() + .id(USER_ID) + .username(NEW_USERNAME) + .booksAsAuthor(new HashSet<>()) + .booksAsSubAuthor(new HashSet<>()) + .build(); + UserDto dto = new UserDto(NEW_USERNAME, null, null); + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + String body = mapper.writeValueAsString(dto); + + Mockito.when(userService.update(anyLong(), any(UserDto.class))).thenReturn(updatedUser); + + mockMvc.perform(put(USER_ID_PATH_TEMPLATE, USER_ID) + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value(updatedUser.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 From 8ed06e4a2da1611370d7af604c4fb7e14e985e89 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Thu, 11 Apr 2019 18:43:55 +0300 Subject: [PATCH 12/24] Create react client --- almanac-web/package.json | 47 ++++++ almanac-web/src/App.js | 93 +++++++++++ almanac-web/src/App.test.js | 9 ++ almanac-web/src/Header.js | 22 +++ almanac-web/src/Navbar.js | 26 +++ almanac-web/src/agent.js | 96 ++++++++++++ almanac-web/src/bootstrap.min.css | 7 + almanac-web/src/components/HeaderList.js | 57 +++++++ almanac-web/src/components/ListPagination.js | 55 +++++++ almanac-web/src/constants/actionTypes.js | 45 ++++++ almanac-web/src/constants/commonConstants.js | 1 + .../src/containers/Article/ArticleCreate.js | 66 ++++++++ .../src/containers/Article/ArticleEdit.js | 114 ++++++++++++++ .../src/containers/Article/ArticleList.js | 56 +++++++ .../src/containers/Article/ArticlePreview.js | 23 +++ almanac-web/src/containers/Article/index.js | 76 +++++++++ .../containers/Authentication/ListErrors.js | 26 +++ .../src/containers/Authentication/Login.js | 97 ++++++++++++ .../src/containers/Authentication/Register.js | 113 +++++++++++++ almanac-web/src/containers/Book/BookCreate.js | 90 +++++++++++ almanac-web/src/containers/Book/BookEdit.js | 148 ++++++++++++++++++ almanac-web/src/containers/Book/BookList.js | 38 +++++ .../src/containers/Book/BookPreview.js | 47 ++++++ almanac-web/src/containers/Book/index.js | 110 +++++++++++++ almanac-web/src/containers/Home/Banner.js | 20 +++ almanac-web/src/containers/Home/MainView.js | 30 ++++ almanac-web/src/containers/Home/Tags.js | 36 +++++ almanac-web/src/containers/Home/index.js | 58 +++++++ .../src/containers/Profile/ProfilePreview.js | 43 +++++ almanac-web/src/containers/Profile/index.js | 100 ++++++++++++ almanac-web/src/index.js | 21 +++ almanac-web/src/logo.svg | 7 + almanac-web/src/middleware.js | 68 ++++++++ almanac-web/src/reducer.js | 20 +++ almanac-web/src/reducers/article.js | 37 +++++ almanac-web/src/reducers/auth.js | 34 ++++ almanac-web/src/reducers/book.js | 40 +++++ almanac-web/src/reducers/bookList.js | 33 ++++ almanac-web/src/reducers/common.js | 70 +++++++++ almanac-web/src/reducers/home.js | 14 ++ almanac-web/src/reducers/profile.js | 19 +++ almanac-web/src/store.js | 29 ++++ 42 files changed, 2141 insertions(+) create mode 100644 almanac-web/package.json create mode 100644 almanac-web/src/App.js create mode 100644 almanac-web/src/App.test.js create mode 100644 almanac-web/src/Header.js create mode 100644 almanac-web/src/Navbar.js create mode 100644 almanac-web/src/agent.js create mode 100644 almanac-web/src/bootstrap.min.css create mode 100644 almanac-web/src/components/HeaderList.js create mode 100644 almanac-web/src/components/ListPagination.js create mode 100644 almanac-web/src/constants/actionTypes.js create mode 100644 almanac-web/src/constants/commonConstants.js create mode 100644 almanac-web/src/containers/Article/ArticleCreate.js create mode 100644 almanac-web/src/containers/Article/ArticleEdit.js create mode 100644 almanac-web/src/containers/Article/ArticleList.js create mode 100644 almanac-web/src/containers/Article/ArticlePreview.js create mode 100644 almanac-web/src/containers/Article/index.js create mode 100644 almanac-web/src/containers/Authentication/ListErrors.js create mode 100644 almanac-web/src/containers/Authentication/Login.js create mode 100644 almanac-web/src/containers/Authentication/Register.js create mode 100644 almanac-web/src/containers/Book/BookCreate.js create mode 100644 almanac-web/src/containers/Book/BookEdit.js create mode 100644 almanac-web/src/containers/Book/BookList.js create mode 100644 almanac-web/src/containers/Book/BookPreview.js create mode 100644 almanac-web/src/containers/Book/index.js create mode 100644 almanac-web/src/containers/Home/Banner.js create mode 100644 almanac-web/src/containers/Home/MainView.js create mode 100644 almanac-web/src/containers/Home/Tags.js create mode 100644 almanac-web/src/containers/Home/index.js create mode 100644 almanac-web/src/containers/Profile/ProfilePreview.js create mode 100644 almanac-web/src/containers/Profile/index.js create mode 100644 almanac-web/src/index.js create mode 100644 almanac-web/src/logo.svg create mode 100644 almanac-web/src/middleware.js create mode 100644 almanac-web/src/reducer.js create mode 100644 almanac-web/src/reducers/article.js create mode 100644 almanac-web/src/reducers/auth.js create mode 100644 almanac-web/src/reducers/book.js create mode 100644 almanac-web/src/reducers/bookList.js create mode 100644 almanac-web/src/reducers/common.js create mode 100644 almanac-web/src/reducers/home.js create mode 100644 almanac-web/src/reducers/profile.js create mode 100644 almanac-web/src/store.js diff --git a/almanac-web/package.json b/almanac-web/package.json new file mode 100644 index 0000000..42f6cbd --- /dev/null +++ b/almanac-web/package.json @@ -0,0 +1,47 @@ +{ + "name": "almanac-web", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tinymce/tinymce-react": "^3.0.1", + "axios": "^0.18.0", + "history": "^4.6.3", + "moment": "^2.24.0", + "moment-timezone": "^0.5.23", + "prismic-reactjs": "^0.3.2", + "react": "^16.4.2", + "react-datepicker": "^2.0.0", + "react-dom": "^16.4.2", + "react-dropdown": "^1.6.4", + "react-intl": "^2.8.0", + "react-moment": "^0.8.4", + "react-paginate": "^6.2.1", + "react-redux": "^5.0.7", + "react-router-dom": "^4.3.1", + "react-router-redux": "^5.0.0-alpha.6", + "react-rte": "^0.16.1", + "react-scripts": "1.1.4", + "redux": "^4.0.0", + "redux-devtools-extension": "^2.13.5", + "redux-logger": "^3.0.1", + "redux-sequence-action": "^0.2.1", + "redux-thunk": "^2.3.0", + "superagent": "^3.8.2", + "superagent-promise": "^1.1.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all" + ] +} diff --git a/almanac-web/src/App.js b/almanac-web/src/App.js new file mode 100644 index 0000000..fadf377 --- /dev/null +++ b/almanac-web/src/App.js @@ -0,0 +1,93 @@ +import agent from './agent'; +import Header from './Header'; +import React from 'react'; +import { connect } from 'react-redux'; +import { APP_LOAD, REDIRECT } from './constants/actionTypes'; +import { Route, Switch } from 'react-router-dom'; +import Home from './containers/Home'; +import { store } from './store'; +import { push } from 'react-router-redux'; +import Book from './containers/Book'; +import Article from './containers/Article'; +import './bootstrap.min.css' +import Profile from './containers/Profile'; +import BookCreate from './containers/Book/BookCreate'; +import BookList from './containers/Book/BookList'; +import ArticleCreate from './containers/Article/ArticleCreate'; +import BookEdit from './containers/Book/BookEdit'; +import { IntlProvider } from 'react-intl'; +import ArticleEdit from './containers/Article/ArticleEdit'; +import Login from './containers/Authentication/Login'; +import Register from './containers/Authentication/Register'; + +const mapStateToProps = state => { + return { + appLoaded: state.common.appLoaded, + appName: state.common.appName, + currentUser: state.common.currentUser, + redirectTo: state.common.redirectTo + } +}; + +const mapDispatchToProps = dispatch => ({ + onLoad: (payload, token) => + dispatch({ type: APP_LOAD, payload, token, skipTracking: true }), + onRedirect: () => + dispatch({ type: REDIRECT }) +}); + +class App extends React.Component { + componentWillReceiveProps(nextProps) { + if (nextProps.redirectTo) { + // this.context.router.replace(nextProps.redirectTo); + store.dispatch(push(nextProps.redirectTo)); + this.props.onRedirect(); + } + } + + componentWillMount() { + const token = window.localStorage.getItem('jwt'); + if (token) { + agent.setToken(token); + } + + this.props.onLoad(token ? agent.Auth.current() : null, token); + } + render() { + if (this.props.appLoaded) { + return ( +
+
+ + + + + + + + + + + + + +
+ ); + } + return ( +
+
+
+ ); + } +} + +// App.contextTypes = { +// router: PropTypes.object.isRequired +// }; + +export default connect(mapStateToProps, mapDispatchToProps)(App); diff --git a/almanac-web/src/App.test.js b/almanac-web/src/App.test.js new file mode 100644 index 0000000..a754b20 --- /dev/null +++ b/almanac-web/src/App.test.js @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +it('renders without crashing', () => { + const div = document.createElement('div'); + ReactDOM.render(, div); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/almanac-web/src/Header.js b/almanac-web/src/Header.js new file mode 100644 index 0000000..68a6fba --- /dev/null +++ b/almanac-web/src/Header.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import HeaderList from './components/HeaderList' + +class Header extends React.Component { + render() { + return ( + + ); + } +} + +export default Header; diff --git a/almanac-web/src/Navbar.js b/almanac-web/src/Navbar.js new file mode 100644 index 0000000..9cdeb9b --- /dev/null +++ b/almanac-web/src/Navbar.js @@ -0,0 +1,26 @@ +import React from 'react'; +import Banner from './containers/Home/Banner'; + +const Navbar = (props) => { + return ( + + + ); +}; +export default Navbar \ No newline at end of file diff --git a/almanac-web/src/agent.js b/almanac-web/src/agent.js new file mode 100644 index 0000000..d1ad137 --- /dev/null +++ b/almanac-web/src/agent.js @@ -0,0 +1,96 @@ +import superagentPromise from 'superagent-promise'; +import _superagent from 'superagent'; +import { PAGE_SIZE } from './constants/commonConstants' + +const superagent = superagentPromise(_superagent, global.Promise); + +const API_ROOT = 'http://localhost:8080'; + +const encode = encodeURIComponent; +const responseBody = res => res.body; + +let token = null; +const tokenPlugin = req => { + if (token) { + req.set('authorization', `Token ${token}`); + } +} + +const requests = { + del: url => + superagent.del(`${API_ROOT}${url}`).use(tokenPlugin).then(responseBody), + get: url => + superagent.get(`${API_ROOT}${url}`).use(tokenPlugin).then(responseBody), + put: (url, body) => + superagent.put(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody), + post: (url, body) => + superagent.post(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody) +}; +const directRequest = { + del: url => + superagent.del(`${url}`).use(tokenPlugin).then(responseBody), + get: url => + superagent.get(`${url}`).use(tokenPlugin).then(responseBody), + put: (url, body) => + superagent.put(`${url}`, body).use(tokenPlugin).then(responseBody), + post: (url, body) => + superagent.post(`${url}`, body).use(tokenPlugin).then(responseBody) +}; + +const Auth = { + current: () => + requests.get('/user'), + login: (email, password) => + requests.post('/users/login', { user: { email, password } }), + register: (username, email, password) => + requests.post('/users', { username, email, password }), + save: user => + requests.put('/user', { user }) +}; + +const Tags = { + getAll: () => requests.get('/tags') +}; + +const limit = (size, page) => `size=${size}&page=${page ? page : 0}`; +const omitSlug = article => Object.assign({}, article, { slug: undefined }) +const Books = { + all: page => + requests.get(`/books?${limit(PAGE_SIZE, page)}`), + del: slug => + requests.del(`/books/${slug}`), + get: slug => + requests.get(`/books/${slug}`), + update: (id, book) => + requests.put(`/books/${id}`, { ...book }), + create: book => + requests.post('/books', { ...book }) +}; +const Articles = { + del: (bookId, articleId) => + requests.del(`/books/${bookId}/articles/${articleId}`), + get: (bookId, articleId) => + requests.get(`/books/${bookId}/articles/${articleId}`), + update: (bookId, articleId, article) => + requests.put(`/books/${bookId}/articles/${articleId}`, { ...article }), + create: (bookId, article) => + requests.post(`/books/${bookId}/articles`, { ...article }) +}; + +const Profile = { + follow: username => + requests.post(`/profiles/${username}/follow`), + get: id => + requests.get(`/users/${id}`), + unfollow: username => + requests.del(`/profiles/${username}/follow`) +}; + +export default { + Books, + Auth, + Profile, + directRequest, + Articles, + setToken: _token => { token = _token; } +}; diff --git a/almanac-web/src/bootstrap.min.css b/almanac-web/src/bootstrap.min.css new file mode 100644 index 0000000..7aebd0f --- /dev/null +++ b/almanac-web/src/bootstrap.min.css @@ -0,0 +1,7 @@ +/*! + * Bootstrap v4.1.1 (https://getbootstrap.com/) + * Copyright 2011-2018 The Bootstrap Authors + * Copyright 2011-2018 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#6c757d;--gray-dark:#343a40;--primary:#007bff;--secondary:#6c757d;--success:#28a745;--info:#17a2b8;--warning:#ffc107;--danger:#dc3545;--light:#f8f9fa;--dark:#343a40;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg:not(:root){overflow:hidden}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-family:inherit;font-weight:500;line-height:1.2;color:inherit}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}.small,small{font-size:80%;font-weight:400}.mark,mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote-footer{display:block;font-size:80%;color:#6c757d}.blockquote-footer::before{content:"\2014 \00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#6c757d}code{font-size:87.5%;color:#e83e8c;word-break:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:#212529}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1140px}}.container-fluid{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;min-height:1px;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.333333%}.offset-2{margin-left:16.666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.333333%}.offset-5{margin-left:41.666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.333333%}.offset-8{margin-left:66.666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.333333%}.offset-11{margin-left:91.666667%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-sm-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-sm-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-sm-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-sm-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-sm-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.333333%}.offset-sm-2{margin-left:16.666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.333333%}.offset-sm-5{margin-left:41.666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.333333%}.offset-sm-8{margin-left:66.666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.333333%}.offset-sm-11{margin-left:91.666667%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-md-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-md-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-md-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-md-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.333333%}.offset-md-2{margin-left:16.666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.333333%}.offset-md-5{margin-left:41.666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.333333%}.offset-md-8{margin-left:66.666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.333333%}.offset-md-11{margin-left:91.666667%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-lg-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-lg-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-lg-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-lg-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-lg-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.333333%}.offset-lg-2{margin-left:16.666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.333333%}.offset-lg-5{margin-left:41.666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.333333%}.offset-lg-8{margin-left:66.666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.333333%}.offset-lg-11{margin-left:91.666667%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-xl-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-xl-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-xl-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-xl-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-xl-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.333333%}.offset-xl-2{margin-left:16.666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.333333%}.offset-xl-5{margin-left:41.666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.333333%}.offset-xl-8{margin-left:66.666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.333333%}.offset-xl-11{margin-left:91.666667%}}.table{width:100%;max-width:100%;margin-bottom:1rem;background-color:transparent}.table td,.table th{padding:.75rem;vertical-align:top;border-top:1px solid #dee2e6}.table thead th{vertical-align:bottom;border-bottom:2px solid #dee2e6}.table tbody+tbody{border-top:2px solid #dee2e6}.table .table{background-color:#fff}.table-sm td,.table-sm th{padding:.3rem}.table-bordered{border:1px solid #dee2e6}.table-bordered td,.table-bordered th{border:1px solid #dee2e6}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{background-color:rgba(0,0,0,.075)}.table-primary,.table-primary>td,.table-primary>th{background-color:#b8daff}.table-hover .table-primary:hover{background-color:#9fcdff}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#9fcdff}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#d6d8db}.table-hover .table-secondary:hover{background-color:#c8cbcf}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#c8cbcf}.table-success,.table-success>td,.table-success>th{background-color:#c3e6cb}.table-hover .table-success:hover{background-color:#b1dfbb}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#b1dfbb}.table-info,.table-info>td,.table-info>th{background-color:#bee5eb}.table-hover .table-info:hover{background-color:#abdde5}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abdde5}.table-warning,.table-warning>td,.table-warning>th{background-color:#ffeeba}.table-hover .table-warning:hover{background-color:#ffe8a1}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#ffe8a1}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c6cb}.table-hover .table-danger:hover{background-color:#f1b0b7}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1b0b7}.table-light,.table-light>td,.table-light>th{background-color:#fdfdfe}.table-hover .table-light:hover{background-color:#ececf6}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#ececf6}.table-dark,.table-dark>td,.table-dark>th{background-color:#c6c8ca}.table-hover .table-dark:hover{background-color:#b9bbbe}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b9bbbe}.table-active,.table-active>td,.table-active>th{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(0,0,0,.075)}.table .thead-dark th{color:#fff;background-color:#212529;border-color:#32383e}.table .thead-light th{color:#495057;background-color:#e9ecef;border-color:#dee2e6}.table-dark{color:#fff;background-color:#212529}.table-dark td,.table-dark th,.table-dark thead th{border-color:#32383e}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,.05)}.table-dark.table-hover tbody tr:hover{background-color:rgba(255,255,255,.075)}@media (max-width:575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.table-responsive-md>.table-bordered{border:0}}@media (max-width:991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;line-height:1.5;color:#495057;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media screen and (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:focus{color:#495057;background-color:#fff;border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.form-control::-webkit-input-placeholder{color:#6c757d;opacity:1}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control:-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}select.form-control:not([size]):not([multiple]){height:calc(2.25rem + 2px)}select.form-control:focus::-ms-value{color:#495057;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding-top:.375rem;padding-bottom:.375rem;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm,.input-group-lg>.form-control-plaintext.form-control,.input-group-lg>.input-group-append>.form-control-plaintext.btn,.input-group-lg>.input-group-append>.form-control-plaintext.input-group-text,.input-group-lg>.input-group-prepend>.form-control-plaintext.btn,.input-group-lg>.input-group-prepend>.form-control-plaintext.input-group-text,.input-group-sm>.form-control-plaintext.form-control,.input-group-sm>.input-group-append>.form-control-plaintext.btn,.input-group-sm>.input-group-append>.form-control-plaintext.input-group-text,.input-group-sm>.input-group-prepend>.form-control-plaintext.btn,.input-group-sm>.input-group-prepend>.form-control-plaintext.input-group-text{padding-right:0;padding-left:0}.form-control-sm,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.input-group-sm>.input-group-append>select.btn:not([size]):not([multiple]),.input-group-sm>.input-group-append>select.input-group-text:not([size]):not([multiple]),.input-group-sm>.input-group-prepend>select.btn:not([size]):not([multiple]),.input-group-sm>.input-group-prepend>select.input-group-text:not([size]):not([multiple]),.input-group-sm>select.form-control:not([size]):not([multiple]),select.form-control-sm:not([size]):not([multiple]){height:calc(1.8125rem + 2px)}.form-control-lg,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.input-group-lg>.input-group-append>select.btn:not([size]):not([multiple]),.input-group-lg>.input-group-append>select.input-group-text:not([size]):not([multiple]),.input-group-lg>.input-group-prepend>select.btn:not([size]):not([multiple]),.input-group-lg>.input-group-prepend>select.input-group-text:not([size]):not([multiple]),.input-group-lg>select.form-control:not([size]):not([multiple]),select.form-control-lg:not([size]):not([multiple]){height:calc(2.875rem + 2px)}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*=col-]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.form-check-input:disabled~.form-check-label{color:#6c757d}.form-check-label{margin-bottom:0}.form-check-inline{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#28a745}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.5rem;margin-top:.1rem;font-size:.875rem;line-height:1;color:#fff;background-color:rgba(40,167,69,.8);border-radius:.2rem}.custom-select.is-valid,.form-control.is-valid,.was-validated .custom-select:valid,.was-validated .form-control:valid{border-color:#28a745}.custom-select.is-valid:focus,.form-control.is-valid:focus,.was-validated .custom-select:valid:focus,.was-validated .form-control:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.custom-select.is-valid~.valid-feedback,.custom-select.is-valid~.valid-tooltip,.form-control.is-valid~.valid-feedback,.form-control.is-valid~.valid-tooltip,.was-validated .custom-select:valid~.valid-feedback,.was-validated .custom-select:valid~.valid-tooltip,.was-validated .form-control:valid~.valid-feedback,.was-validated .form-control:valid~.valid-tooltip{display:block}.form-control-file.is-valid~.valid-feedback,.form-control-file.is-valid~.valid-tooltip,.was-validated .form-control-file:valid~.valid-feedback,.was-validated .form-control-file:valid~.valid-tooltip{display:block}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#28a745}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#28a745}.custom-control-input.is-valid~.custom-control-label::before,.was-validated .custom-control-input:valid~.custom-control-label::before{background-color:#71dd8a}.custom-control-input.is-valid~.valid-feedback,.custom-control-input.is-valid~.valid-tooltip,.was-validated .custom-control-input:valid~.valid-feedback,.was-validated .custom-control-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid:checked~.custom-control-label::before,.was-validated .custom-control-input:valid:checked~.custom-control-label::before{background-color:#34ce57}.custom-control-input.is-valid:focus~.custom-control-label::before,.was-validated .custom-control-input:valid:focus~.custom-control-label::before{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(40,167,69,.25)}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#28a745}.custom-file-input.is-valid~.custom-file-label::before,.was-validated .custom-file-input:valid~.custom-file-label::before{border-color:inherit}.custom-file-input.is-valid~.valid-feedback,.custom-file-input.is-valid~.valid-tooltip,.was-validated .custom-file-input:valid~.valid-feedback,.was-validated .custom-file-input:valid~.valid-tooltip{display:block}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.5rem;margin-top:.1rem;font-size:.875rem;line-height:1;color:#fff;background-color:rgba(220,53,69,.8);border-radius:.2rem}.custom-select.is-invalid,.form-control.is-invalid,.was-validated .custom-select:invalid,.was-validated .form-control:invalid{border-color:#dc3545}.custom-select.is-invalid:focus,.form-control.is-invalid:focus,.was-validated .custom-select:invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.custom-select.is-invalid~.invalid-feedback,.custom-select.is-invalid~.invalid-tooltip,.form-control.is-invalid~.invalid-feedback,.form-control.is-invalid~.invalid-tooltip,.was-validated .custom-select:invalid~.invalid-feedback,.was-validated .custom-select:invalid~.invalid-tooltip,.was-validated .form-control:invalid~.invalid-feedback,.was-validated .form-control:invalid~.invalid-tooltip{display:block}.form-control-file.is-invalid~.invalid-feedback,.form-control-file.is-invalid~.invalid-tooltip,.was-validated .form-control-file:invalid~.invalid-feedback,.was-validated .form-control-file:invalid~.invalid-tooltip{display:block}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc3545}.custom-control-input.is-invalid~.custom-control-label::before,.was-validated .custom-control-input:invalid~.custom-control-label::before{background-color:#efa2a9}.custom-control-input.is-invalid~.invalid-feedback,.custom-control-input.is-invalid~.invalid-tooltip,.was-validated .custom-control-input:invalid~.invalid-feedback,.was-validated .custom-control-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid:checked~.custom-control-label::before,.was-validated .custom-control-input:invalid:checked~.custom-control-label::before{background-color:#e4606d}.custom-control-input.is-invalid:focus~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus~.custom-control-label::before{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(220,53,69,.25)}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc3545}.custom-file-input.is-invalid~.custom-file-label::before,.was-validated .custom-file-input:invalid~.custom-file-label::before{border-color:inherit}.custom-file-input.is-invalid~.invalid-feedback,.custom-file-input.is-invalid~.invalid-tooltip,.was-validated .custom-file-input:invalid~.invalid-feedback,.was-validated .custom-file-input:invalid~.invalid-tooltip{display:block}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-inline{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width:576px){.form-inline label{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;margin-top:0;margin-right:.25rem;margin-left:0}.form-inline .custom-control{-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;text-align:center;white-space:nowrap;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media screen and (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:focus,.btn:hover{text-decoration:none}.btn.focus,.btn:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}.btn:not(:disabled):not(.disabled).active,.btn:not(:disabled):not(.disabled):active{background-image:none}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:hover{color:#fff;background-color:#0069d9;border-color:#0062cc}.btn-primary.focus,.btn-primary:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0062cc;border-color:#005cbf}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5a6268;border-color:#545b62}.btn-secondary.focus,.btn-secondary:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#545b62;border-color:#4e555b}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-success{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:hover{color:#fff;background-color:#218838;border-color:#1e7e34}.btn-success.focus,.btn-success:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#1e7e34;border-color:#1c7430}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-info{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:hover{color:#fff;background-color:#138496;border-color:#117a8b}.btn-info.focus,.btn-info:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#117a8b;border-color:#10707f}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-warning{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#212529;background-color:#e0a800;border-color:#d39e00}.btn-warning.focus,.btn-warning:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{color:#212529;background-color:#d39e00;border-color:#c69500}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#c82333;border-color:#bd2130}.btn-danger.focus,.btn-danger:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#bd2130;border-color:#b21f2d}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-light{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#212529;background-color:#e2e6ea;border-color:#dae0e5}.btn-light.focus,.btn-light:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-light.disabled,.btn-light:disabled{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{color:#212529;background-color:#dae0e5;border-color:#d3d9df}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-dark{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:hover{color:#fff;background-color:#23272b;border-color:#1d2124}.btn-dark.focus,.btn-dark:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1d2124;border-color:#171a1d}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-outline-primary{color:#007bff;background-color:transparent;background-image:none;border-color:#007bff}.btn-outline-primary:hover{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#007bff;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-secondary{color:#6c757d;background-color:transparent;background-image:none;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-success{color:#28a745;background-color:transparent;background-image:none;border-color:#28a745}.btn-outline-success:hover{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#28a745;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-info{color:#17a2b8;background-color:transparent;background-image:none;border-color:#17a2b8}.btn-outline-info:hover{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#17a2b8;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-warning{color:#ffc107;background-color:transparent;background-image:none;border-color:#ffc107}.btn-outline-warning:hover{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-danger{color:#dc3545;background-color:transparent;background-image:none;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-light{color:#f8f9fa;background-color:transparent;background-image:none;border-color:#f8f9fa}.btn-outline-light:hover{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-dark{color:#343a40;background-color:transparent;background-image:none;border-color:#343a40}.btn-outline-dark:hover{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#343a40;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-link{font-weight:400;color:#007bff;background-color:transparent}.btn-link:hover{color:#0056b3;text-decoration:underline;background-color:transparent;border-color:transparent}.btn-link.focus,.btn-link:focus{text-decoration:underline;border-color:transparent;box-shadow:none}.btn-link.disabled,.btn-link:disabled{color:#6c757d;pointer-events:none}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media screen and (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;transition:height .35s ease}@media screen and (prefers-reduced-motion:reduce){.collapsing{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle::after{display:inline-block;width:0;height:0;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu-right{right:0;left:auto}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;width:0;height:0;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropright .dropdown-toggle::after{display:inline-block;width:0;height:0;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropright .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropleft .dropdown-toggle::after{display:inline-block;width:0;height:0;margin-left:.255em;vertical-align:.255em;content:""}.dropleft .dropdown-toggle::after{display:none}.dropleft .dropdown-toggle::before{display:inline-block;width:0;height:0;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropleft .dropdown-toggle:empty::after{margin-left:0}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #e9ecef}.dropdown-item{display:block;width:100%;padding:.25rem 1.5rem;clear:both;font-weight:400;color:#212529;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#16181b;text-decoration:none;background-color:#f8f9fa}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#007bff}.dropdown-item.disabled,.dropdown-item:disabled{color:#6c757d;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1.5rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1.5rem;color:#212529}.btn-group,.btn-group-vertical{position:relative;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;-ms-flex:0 1 auto;flex:0 1 auto}.btn-group-vertical>.btn:hover,.btn-group>.btn:hover{z-index:1}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus{z-index:1}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group,.btn-group-vertical .btn+.btn,.btn-group-vertical .btn+.btn-group,.btn-group-vertical .btn-group+.btn,.btn-group-vertical .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{-ms-flex-direction:column;flex-direction:column;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:center;justify-content:center}.btn-group-vertical .btn,.btn-group-vertical .btn-group{width:100%}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;margin-bottom:0}.input-group>.custom-file:focus,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control{margin-left:-1px}.input-group>.custom-select:not(:last-child),.input-group>.form-control:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-append,.input-group-prepend{display:-ms-flexbox;display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.375rem .75rem;margin-bottom:0;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;display:block;min-height:1.5rem;padding-left:1.5rem}.custom-control-inline{display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;z-index:-1;opacity:0}.custom-control-input:checked~.custom-control-label::before{color:#fff;background-color:#007bff}.custom-control-input:focus~.custom-control-label::before{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-control-input:active~.custom-control-label::before{color:#fff;background-color:#b3d7ff}.custom-control-input:disabled~.custom-control-label{color:#6c757d}.custom-control-input:disabled~.custom-control-label::before{background-color:#e9ecef}.custom-control-label{position:relative;margin-bottom:0}.custom-control-label::before{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:#dee2e6}.custom-control-label::after{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background-repeat:no-repeat;background-position:center center;background-size:50% 50%}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label::before{background-color:#007bff}.custom-checkbox .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::before{background-color:#007bff}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3E%3Cpath stroke='%23fff' d='M0 2h4'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label::before{background-color:#007bff}.custom-radio .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='%23fff'/%3E%3C/svg%3E")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-select{display:inline-block;width:100%;height:calc(2.25rem + 2px);padding:.375rem 1.75rem .375rem .75rem;line-height:1.5;color:#495057;vertical-align:middle;background:#fff url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") no-repeat right .75rem center;background-size:8px 10px;border:1px solid #ced4da;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#80bdff;outline:0;box-shadow:inset 0 1px 2px rgba(0,0,0,.075),0 0 5px rgba(128,189,255,.5)}.custom-select:focus::-ms-value{color:#495057;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:.75rem;background-image:none}.custom-select:disabled{color:#6c757d;background-color:#e9ecef}.custom-select::-ms-expand{opacity:0}.custom-select-sm{height:calc(1.8125rem + 2px);padding-top:.375rem;padding-bottom:.375rem;font-size:75%}.custom-select-lg{height:calc(2.875rem + 2px);padding-top:.375rem;padding-bottom:.375rem;font-size:125%}.custom-file{position:relative;display:inline-block;width:100%;height:calc(2.25rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(2.25rem + 2px);margin:0;opacity:0}.custom-file-input:focus~.custom-file-label{border-color:#80bdff;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-file-input:focus~.custom-file-label::after{border-color:#80bdff}.custom-file-input:lang(en)~.custom-file-label::after{content:"Browse"}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(2.25rem + 2px);padding:.375rem .75rem;line-height:1.5;color:#495057;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:2.25rem;padding:.375rem .75rem;line-height:1.5;color:#495057;content:"Browse";background-color:#e9ecef;border-left:1px solid #ced4da;border-radius:0 .25rem .25rem 0}.custom-range{width:100%;padding-left:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:0}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#007bff;border:0;border-radius:1rem;-webkit-appearance:none;appearance:none}.custom-range::-webkit-slider-thumb:focus{outline:0;box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range::-webkit-slider-thumb:active{background-color:#b3d7ff}.custom-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#007bff;border:0;border-radius:1rem;-moz-appearance:none;appearance:none}.custom-range::-moz-range-thumb:focus{outline:0;box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range::-moz-range-thumb:active{background-color:#b3d7ff}.custom-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;background-color:#007bff;border:0;border-radius:1rem;appearance:none}.custom-range::-ms-thumb:focus{outline:0;box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range::-ms-thumb:active{background-color:#b3d7ff}.custom-range::-ms-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:.5rem}.custom-range::-ms-fill-lower{background-color:#dee2e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dee2e6;border-radius:1rem}.nav{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#6c757d}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#007bff}.nav-fill .nav-item{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar>.container,.navbar>.container-fluid{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;line-height:inherit;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler:not(:disabled):not(.disabled){cursor:pointer}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width:575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:576px){.navbar-expand-sm{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:768px){.navbar-expand-md{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:992px){.navbar-expand-lg{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:1200px){.navbar-expand-xl{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.5);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(0, 0, 0, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a{color:rgba(0,0,0,.9)}.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.5);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-dark .navbar-text{color:rgba(255,255,255,.5)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group:first-child .list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card>.list-group:last-child .list-group-item:last-child{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.card-body{-ms-flex:1 1 auto;flex:1 1 auto;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-header+.list-group .list-group-item:first-child{border-top:0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.625rem;margin-bottom:-.75rem;margin-left:-.625rem;border-bottom:0}.card-header-pills{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem}.card-img{width:100%;border-radius:calc(.25rem - 1px)}.card-img-top{width:100%;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img-bottom{width:100%;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card-deck .card{margin-bottom:15px}@media (min-width:576px){.card-deck{-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{display:-ms-flexbox;display:flex;-ms-flex:1 0 0%;flex:1 0 0%;-ms-flex-direction:column;flex-direction:column;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card-group>.card{margin-bottom:15px}@media (min-width:576px){.card-group{-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:first-child{border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:first-child .card-header,.card-group>.card:first-child .card-img-top{border-top-right-radius:0}.card-group>.card:first-child .card-footer,.card-group>.card:first-child .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:last-child{border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:last-child .card-header,.card-group>.card:last-child .card-img-top{border-top-left-radius:0}.card-group>.card:last-child .card-footer,.card-group>.card:last-child .card-img-bottom{border-bottom-left-radius:0}.card-group>.card:only-child{border-radius:.25rem}.card-group>.card:only-child .card-header,.card-group>.card:only-child .card-img-top{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card-group>.card:only-child .card-footer,.card-group>.card:only-child .card-img-bottom{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.card-group>.card:not(:first-child):not(:last-child):not(:only-child){border-radius:0}.card-group>.card:not(:first-child):not(:last-child):not(:only-child) .card-footer,.card-group>.card:not(:first-child):not(:last-child):not(:only-child) .card-header,.card-group>.card:not(:first-child):not(:last-child):not(:only-child) .card-img-bottom,.card-group>.card:not(:first-child):not(:last-child):not(:only-child) .card-img-top{border-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion .card:not(:first-of-type):not(:last-of-type){border-bottom:0;border-radius:0}.accordion .card:not(:first-of-type) .card-header:first-child{border-radius:0}.accordion .card:first-of-type{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion .card:last-of-type{border-top-left-radius:0;border-top-right-radius:0}.breadcrumb{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#e9ecef;border-radius:.25rem}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;color:#6c757d;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#6c757d}.pagination{display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:.25rem}.page-link{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#007bff;background-color:#fff;border:1px solid #dee2e6}.page-link:hover{z-index:2;color:#0056b3;text-decoration:none;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:2;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.page-link:not(:disabled):not(.disabled){cursor:pointer}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.page-item.active .page-link{z-index:1;color:#fff;background-color:#007bff;border-color:#007bff}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dee2e6}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#007bff}.badge-primary[href]:focus,.badge-primary[href]:hover{color:#fff;text-decoration:none;background-color:#0062cc}.badge-secondary{color:#fff;background-color:#6c757d}.badge-secondary[href]:focus,.badge-secondary[href]:hover{color:#fff;text-decoration:none;background-color:#545b62}.badge-success{color:#fff;background-color:#28a745}.badge-success[href]:focus,.badge-success[href]:hover{color:#fff;text-decoration:none;background-color:#1e7e34}.badge-info{color:#fff;background-color:#17a2b8}.badge-info[href]:focus,.badge-info[href]:hover{color:#fff;text-decoration:none;background-color:#117a8b}.badge-warning{color:#212529;background-color:#ffc107}.badge-warning[href]:focus,.badge-warning[href]:hover{color:#212529;text-decoration:none;background-color:#d39e00}.badge-danger{color:#fff;background-color:#dc3545}.badge-danger[href]:focus,.badge-danger[href]:hover{color:#fff;text-decoration:none;background-color:#bd2130}.badge-light{color:#212529;background-color:#f8f9fa}.badge-light[href]:focus,.badge-light[href]:hover{color:#212529;text-decoration:none;background-color:#dae0e5}.badge-dark{color:#fff;background-color:#343a40}.badge-dark[href]:focus,.badge-dark[href]:hover{color:#fff;text-decoration:none;background-color:#1d2124}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#e9ecef;border-radius:.3rem}@media (min-width:576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{position:absolute;top:0;right:0;padding:.75rem 1.25rem;color:inherit}.alert-primary{color:#004085;background-color:#cce5ff;border-color:#b8daff}.alert-primary hr{border-top-color:#9fcdff}.alert-primary .alert-link{color:#002752}.alert-secondary{color:#383d41;background-color:#e2e3e5;border-color:#d6d8db}.alert-secondary hr{border-top-color:#c8cbcf}.alert-secondary .alert-link{color:#202326}.alert-success{color:#155724;background-color:#d4edda;border-color:#c3e6cb}.alert-success hr{border-top-color:#b1dfbb}.alert-success .alert-link{color:#0b2e13}.alert-info{color:#0c5460;background-color:#d1ecf1;border-color:#bee5eb}.alert-info hr{border-top-color:#abdde5}.alert-info .alert-link{color:#062c33}.alert-warning{color:#856404;background-color:#fff3cd;border-color:#ffeeba}.alert-warning hr{border-top-color:#ffe8a1}.alert-warning .alert-link{color:#533f03}.alert-danger{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.alert-danger hr{border-top-color:#f1b0b7}.alert-danger .alert-link{color:#491217}.alert-light{color:#818182;background-color:#fefefe;border-color:#fdfdfe}.alert-light hr{border-top-color:#ececf6}.alert-light .alert-link{color:#686868}.alert-dark{color:#1b1e21;background-color:#d6d8d9;border-color:#c6c8ca}.alert-dark hr{border-top-color:#b9bbbe}.alert-dark .alert-link{color:#040505}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;color:#fff;text-align:center;white-space:nowrap;background-color:#007bff;transition:width .6s ease}@media screen and (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}.media{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.media-body{-ms-flex:1;flex:1}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;margin-bottom:-1px;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.list-group-item:focus,.list-group-item:hover{z-index:1;text-decoration:none}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#007bff;border-color:#007bff}.list-group-flush .list-group-item{border-right:0;border-left:0;border-radius:0}.list-group-flush:first-child .list-group-item:first-child{border-top:0}.list-group-flush:last-child .list-group-item:last-child{border-bottom:0}.list-group-item-primary{color:#004085;background-color:#b8daff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#004085;background-color:#9fcdff}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#004085;border-color:#004085}.list-group-item-secondary{color:#383d41;background-color:#d6d8db}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#383d41;background-color:#c8cbcf}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#383d41;border-color:#383d41}.list-group-item-success{color:#155724;background-color:#c3e6cb}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#155724;background-color:#b1dfbb}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#155724;border-color:#155724}.list-group-item-info{color:#0c5460;background-color:#bee5eb}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#0c5460;background-color:#abdde5}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#0c5460;border-color:#0c5460}.list-group-item-warning{color:#856404;background-color:#ffeeba}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#856404;background-color:#ffe8a1}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#856404;border-color:#856404}.list-group-item-danger{color:#721c24;background-color:#f5c6cb}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#721c24;background-color:#f1b0b7}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#721c24;border-color:#721c24}.list-group-item-light{color:#818182;background-color:#fdfdfe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#818182;background-color:#ececf6}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#818182;border-color:#818182}.list-group-item-dark{color:#1b1e21;background-color:#c6c8ca}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#1b1e21;background-color:#b9bbbe}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#1b1e21;border-color:#1b1e21}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5}.close:focus,.close:hover{color:#000;text-decoration:none;opacity:.75}.close:not(:disabled):not(.disabled){cursor:pointer}button.close{padding:0;background-color:transparent;border:0;-webkit-appearance:none}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;outline:0}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out;-webkit-transform:translate(0,-25%);transform:translate(0,-25%)}@media screen and (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{-webkit-transform:translate(0,0);transform:translate(0,0)}.modal-dialog-centered{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;min-height:calc(100% - (.5rem * 2))}.modal-content{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:justify;justify-content:space-between;padding:1rem;border-bottom:1px solid #e9ecef;border-top-left-radius:.3rem;border-top-right-radius:.3rem}.modal-header .close{padding:1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:end;justify-content:flex-end;padding:1rem;border-top:1px solid #e9ecef}.modal-footer>:not(:first-child){margin-left:.25rem}.modal-footer>:not(:last-child){margin-right:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-centered{min-height:calc(100% - (1.75rem * 2))}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg{max-width:800px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow::before,.bs-tooltip-top .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=right] .arrow::before,.bs-tooltip-right .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow::before,.bs-tooltip-bottom .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=left] .arrow::before,.bs-tooltip-left .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::after,.popover .arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top] .arrow,.bs-popover-top .arrow{bottom:calc((.5rem + 1px) * -1)}.bs-popover-auto[x-placement^=top] .arrow::after,.bs-popover-auto[x-placement^=top] .arrow::before,.bs-popover-top .arrow::after,.bs-popover-top .arrow::before{border-width:.5rem .5rem 0}.bs-popover-auto[x-placement^=top] .arrow::before,.bs-popover-top .arrow::before{bottom:0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=top] .arrow::after,.bs-popover-top .arrow::after{bottom:1px;border-top-color:#fff}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right] .arrow,.bs-popover-right .arrow{left:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=right] .arrow::after,.bs-popover-auto[x-placement^=right] .arrow::before,.bs-popover-right .arrow::after,.bs-popover-right .arrow::before{border-width:.5rem .5rem .5rem 0}.bs-popover-auto[x-placement^=right] .arrow::before,.bs-popover-right .arrow::before{left:0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=right] .arrow::after,.bs-popover-right .arrow::after{left:1px;border-right-color:#fff}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom] .arrow,.bs-popover-bottom .arrow{top:calc((.5rem + 1px) * -1)}.bs-popover-auto[x-placement^=bottom] .arrow::after,.bs-popover-auto[x-placement^=bottom] .arrow::before,.bs-popover-bottom .arrow::after,.bs-popover-bottom .arrow::before{border-width:0 .5rem .5rem .5rem}.bs-popover-auto[x-placement^=bottom] .arrow::before,.bs-popover-bottom .arrow::before{top:0;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=bottom] .arrow::after,.bs-popover-bottom .arrow::after{top:1px;border-bottom-color:#fff}.bs-popover-auto[x-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f7f7f7}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left] .arrow,.bs-popover-left .arrow{right:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=left] .arrow::after,.bs-popover-auto[x-placement^=left] .arrow::before,.bs-popover-left .arrow::after,.bs-popover-left .arrow::before{border-width:.5rem 0 .5rem .5rem}.bs-popover-auto[x-placement^=left] .arrow::before,.bs-popover-left .arrow::before{right:0;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=left] .arrow::after,.bs-popover-left .arrow::after{right:1px;border-left-color:#fff}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:1rem;color:inherit;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#212529}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-item{position:relative;display:none;-ms-flex-align:center;align-items:center;width:100%;transition:-webkit-transform .6s ease;transition:transform .6s ease;transition:transform .6s ease,-webkit-transform .6s ease;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}@media screen and (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.carousel-item-next,.carousel-item-prev{position:absolute;top:0}.carousel-item-next.carousel-item-left,.carousel-item-prev.carousel-item-right{-webkit-transform:translateX(0);transform:translateX(0)}@supports ((-webkit-transform-style:preserve-3d) or (transform-style:preserve-3d)){.carousel-item-next.carousel-item-left,.carousel-item-prev.carousel-item-right{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.active.carousel-item-right,.carousel-item-next{-webkit-transform:translateX(100%);transform:translateX(100%)}@supports ((-webkit-transform-style:preserve-3d) or (transform-style:preserve-3d)){.active.carousel-item-right,.carousel-item-next{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.active.carousel-item-left,.carousel-item-prev{-webkit-transform:translateX(-100%);transform:translateX(-100%)}@supports ((-webkit-transform-style:preserve-3d) or (transform-style:preserve-3d)){.active.carousel-item-left,.carousel-item-prev{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.carousel-fade .carousel-item{opacity:0;transition-duration:.6s;transition-property:opacity}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{opacity:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{opacity:0}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-prev,.carousel-fade .carousel-item-next,.carousel-fade .carousel-item-prev,.carousel-fade .carousel-item.active{-webkit-transform:translateX(0);transform:translateX(0)}@supports ((-webkit-transform-style:preserve-3d) or (transform-style:preserve-3d)){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-prev,.carousel-fade .carousel-item-next,.carousel-fade .carousel-item-prev,.carousel-fade .carousel-item.active{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:15%;color:#fff;text-align:center;opacity:.5}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:20px;height:20px;background:transparent no-repeat center center;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3E%3Cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3E%3C/svg%3E")}.carousel-control-next-icon{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3E%3Cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3E%3C/svg%3E")}.carousel-indicators{position:absolute;right:0;bottom:10px;left:0;z-index:15;display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;padding-left:0;margin-right:15%;margin-left:15%;list-style:none}.carousel-indicators li{position:relative;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:rgba(255,255,255,.5)}.carousel-indicators li::before{position:absolute;top:-10px;left:0;display:inline-block;width:100%;height:10px;content:""}.carousel-indicators li::after{position:absolute;bottom:-10px;left:0;display:inline-block;width:100%;height:10px;content:""}.carousel-indicators .active{background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#007bff!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#0062cc!important}.bg-secondary{background-color:#6c757d!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#545b62!important}.bg-success{background-color:#28a745!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#1e7e34!important}.bg-info{background-color:#17a2b8!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#117a8b!important}.bg-warning{background-color:#ffc107!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#d39e00!important}.bg-danger{background-color:#dc3545!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#bd2130!important}.bg-light{background-color:#f8f9fa!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#dae0e5!important}.bg-dark{background-color:#343a40!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#1d2124!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #dee2e6!important}.border-top{border-top:1px solid #dee2e6!important}.border-right{border-right:1px solid #dee2e6!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-left{border-left:1px solid #dee2e6!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#007bff!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#28a745!important}.border-info{border-color:#17a2b8!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#343a40!important}.border-white{border-color:#fff!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-right{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-circle{border-radius:50%!important}.rounded-0{border-radius:0!important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:-ms-flexbox!important;display:flex!important}.d-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}@media (min-width:576px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:-ms-flexbox!important;display:flex!important}.d-sm-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:768px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:-ms-flexbox!important;display:flex!important}.d-md-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:992px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:-ms-flexbox!important;display:flex!important}.d-lg-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:1200px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:-ms-flexbox!important;display:flex!important}.d-xl-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:-ms-flexbox!important;display:flex!important}.d-print-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.857143%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-center{-ms-flex-align:center!important;align-items:center!important}.align-items-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}@media (min-width:576px){.flex-sm-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-sm-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-sm-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-sm-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-sm-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-sm-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-sm-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-sm-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-sm-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-sm-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-sm-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-sm-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-sm-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-sm-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-sm-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-sm-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-sm-center{-ms-flex-align:center!important;align-items:center!important}.align-items-sm-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-sm-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-sm-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-sm-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-sm-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-sm-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-sm-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-sm-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-sm-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-sm-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-sm-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-sm-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-sm-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-sm-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:768px){.flex-md-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-md-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-md-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-md-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-md-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-md-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-md-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-md-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-md-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-md-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-md-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-md-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-md-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-md-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-md-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-md-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-md-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-md-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-md-center{-ms-flex-align:center!important;align-items:center!important}.align-items-md-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-md-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-md-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-md-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-md-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-md-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-md-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-md-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-md-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-md-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-md-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-md-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-md-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-md-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:992px){.flex-lg-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-lg-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-lg-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-lg-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-lg-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-lg-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-lg-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-lg-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-lg-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-lg-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-lg-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-lg-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-lg-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-lg-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-lg-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-lg-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-lg-center{-ms-flex-align:center!important;align-items:center!important}.align-items-lg-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-lg-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-lg-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-lg-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-lg-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-lg-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-lg-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-lg-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-lg-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-lg-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-lg-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-lg-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-lg-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-lg-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:1200px){.flex-xl-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-xl-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-xl-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-xl-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-xl-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-xl-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-xl-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-xl-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-xl-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-xl-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-xl-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-xl-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-xl-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-xl-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-xl-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-xl-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-xl-center{-ms-flex-align:center!important;align-items:center!important}.align-items-xl-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-xl-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-xl-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-xl-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-xl-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-xl-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-xl-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-xl-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-xl-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-xl-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-xl-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-xl-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-xl-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-xl-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:576px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:768px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:992px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:1200px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports ((position:-webkit-sticky) or (position:sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:576px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:768px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:992px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:1200px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}.text-justify{text-align:justify!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:576px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:700!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#007bff!important}a.text-primary:focus,a.text-primary:hover{color:#0062cc!important}.text-secondary{color:#6c757d!important}a.text-secondary:focus,a.text-secondary:hover{color:#545b62!important}.text-success{color:#28a745!important}a.text-success:focus,a.text-success:hover{color:#1e7e34!important}.text-info{color:#17a2b8!important}a.text-info:focus,a.text-info:hover{color:#117a8b!important}.text-warning{color:#ffc107!important}a.text-warning:focus,a.text-warning:hover{color:#d39e00!important}.text-danger{color:#dc3545!important}a.text-danger:focus,a.text-danger:hover{color:#bd2130!important}.text-light{color:#f8f9fa!important}a.text-light:focus,a.text-light:hover{color:#dae0e5!important}.text-dark{color:#343a40!important}a.text-dark:focus,a.text-dark:hover{color:#1d2124!important}.text-body{color:#212529!important}.text-muted{color:#6c757d!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:rgba(255,255,255,.5)!important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #adb5bd;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #dee2e6!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#dee2e6}.table .thead-dark th{color:inherit;border-color:#dee2e6}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/almanac-web/src/components/HeaderList.js b/almanac-web/src/components/HeaderList.js new file mode 100644 index 0000000..f6d0023 --- /dev/null +++ b/almanac-web/src/components/HeaderList.js @@ -0,0 +1,57 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + + const HeaderList = props => { + return ( +
    + +
  • + + Home + +
  • + + {!props.currentUser && +
  • + + Sign in + +
  • } + + {!props.currentUser && +
  • + + Sign up + +
  • + } + + {props.currentUser && +
  • + +  New Post + +
  • + } + {props.currentUser && +
  • + +  Settings + +
  • + } + + {props.currentUser && +
  • + + {props.currentUser.username} + {props.currentUser.username} + +
  • + } +
+ ); +} +export default HeaderList; diff --git a/almanac-web/src/components/ListPagination.js b/almanac-web/src/components/ListPagination.js new file mode 100644 index 0000000..4296588 --- /dev/null +++ b/almanac-web/src/components/ListPagination.js @@ -0,0 +1,55 @@ +import React from 'react'; +import agent from '../agent'; +import { connect } from 'react-redux'; +import { SET_PAGE } from '../constants/actionTypes'; +import { PAGE_SIZE } from '../constants/commonConstants'; + +const mapDispatchToProps = dispatch => ({ + onSetPage: (page, payload) => + dispatch({ type: SET_PAGE, page, payload }) +}); + +const ListPagination = props => { + if (props.booksCount <= PAGE_SIZE) { + return null; + } + + const range = []; + for (let i = 0; i < props.pager.totalPages; ++i) { + range.push(i); + } + + const setPage = page => { + props.onSetPage(page, agent.Books.all(page)) + }; + + return ( + + ); +}; + +export default connect(() => ({}), mapDispatchToProps)(ListPagination); diff --git a/almanac-web/src/constants/actionTypes.js b/almanac-web/src/constants/actionTypes.js new file mode 100644 index 0000000..f8cbc0a --- /dev/null +++ b/almanac-web/src/constants/actionTypes.js @@ -0,0 +1,45 @@ +export const APP_LOAD = 'APP_LOAD'; +export const REDIRECT = 'REDIRECT'; +export const ARTICLE_SUBMITTED = 'ARTICLE_SUBMITTED'; +export const SETTINGS_SAVED = 'SETTINGS_SAVED'; +export const SETTINGS_PAGE_UNLOADED = 'SETTINGS_PAGE_UNLOADED'; +export const HOME_PAGE_LOADED = 'HOME_PAGE_LOADED'; +export const HOME_PAGE_UNLOADED = 'HOME_PAGE_UNLOADED'; +export const BOOK_PAGE_LOADED = 'BOOK_PAGE_LOADED'; +export const BOOK_PAGE_UNLOADED = 'BOOK_PAGE_UNLOADED'; +export const BOOK_ARTICLES_LOADED = 'BOOK_PAGE_LOADED'; +export const ADD_COMMENT = 'ADD_COMMENT'; +export const DELETE_COMMENT = 'DELETE_COMMENT'; +export const ARTICLE_FAVORITED = 'ARTICLE_FAVORITED'; +export const ARTICLE_UNFAVORITED = 'ARTICLE_UNFAVORITED'; +export const SET_PAGE = 'SET_PAGE'; +export const APPLY_TAG_FILTER = 'APPLY_TAG_FILTER'; +export const CHANGE_TAB = 'CHANGE_TAB'; +export const PROFILE_PAGE_LOADED = 'PROFILE_PAGE_LOADED'; +export const PROFILE_PAGE_UNLOADED = 'PROFILE_PAGE_UNLOADED'; +export const LOGIN = 'LOGIN'; +export const LOGOUT = 'LOGOUT'; +export const REGISTER = 'REGISTER'; +export const LOGIN_PAGE_UNLOADED = 'LOGIN_PAGE_UNLOADED'; +export const REGISTER_PAGE_UNLOADED = 'REGISTER_PAGE_UNLOADED'; +export const ASYNC_START = 'ASYNC_START'; +export const ASYNC_END = 'ASYNC_END'; +export const EDITOR_PAGE_LOADED = 'EDITOR_PAGE_LOADED'; +export const EDITOR_PAGE_UNLOADED = 'EDITOR_PAGE_UNLOADED'; +export const ADD_TAG = 'ADD_TAG'; +export const REMOVE_TAG = 'REMOVE_TAG'; +export const UPDATE_FIELD_AUTH = 'UPDATE_FIELD_AUTH'; +export const UPDATE_FIELD_EDITOR = 'UPDATE_FIELD_EDITOR'; +export const FOLLOW_USER = 'FOLLOW_USER'; +export const UNFOLLOW_USER = 'UNFOLLOW_USER'; +export const PROFILE_FAVORITES_PAGE_UNLOADED = 'PROFILE_FAVORITES_PAGE_UNLOADED'; +export const PROFILE_FAVORITES_PAGE_LOADED = 'PROFILE_FAVORITES_PAGE_LOADED'; + +export const ARTICLE_PAGE_LOADED = 'ARTICLE_PAGE_LOADED'; +export const ARTICLE_PAGE_UNLOADED = 'ARTICLE_PAGE_UNLOADED'; +export const CREATE_BOOK = 'CREATE_BOOK'; +export const CREATE_ARTICLE = 'CREATE_ARTICLE'; +export const DELETE_ARTICLE = 'DELETE_ARTICLE'; +export const UPDATE_ARTICLE = 'UPDATE_ARTICLE'; +export const BOOK_UPDATED = 'BOOK_UPDATED'; + diff --git a/almanac-web/src/constants/commonConstants.js b/almanac-web/src/constants/commonConstants.js new file mode 100644 index 0000000..2f38546 --- /dev/null +++ b/almanac-web/src/constants/commonConstants.js @@ -0,0 +1 @@ +export const PAGE_SIZE = 2; \ No newline at end of file diff --git a/almanac-web/src/containers/Article/ArticleCreate.js b/almanac-web/src/containers/Article/ArticleCreate.js new file mode 100644 index 0000000..7040ed8 --- /dev/null +++ b/almanac-web/src/containers/Article/ArticleCreate.js @@ -0,0 +1,66 @@ +import React from 'react'; +import agent from '../../agent'; +import { connect } from 'react-redux'; +import { + CREATE_ARTICLE +} from '../../constants/actionTypes'; +import { Editor } from '@tinymce/tinymce-react'; + +class ArticleCreate extends React.Component { + constructor() { + super(); + this.state = { + article: {} + } + this.updateField = this.updateField.bind(this); + this.createArticle = this.createArticle.bind(this); + }; + createArticle() { + var article = this.state.article; + const payload = agent.Articles.create(this.props.match.params.bookId, + { ...article }); + this.setState({ article: {} }); + this.props.history.push(`/books/${this.props.match.params.bookId}/edit`) + }; + updateField(event) { + var article = { ...this.state.article, [event.target.name]: event.target.value }; + this.setState( + { article: article } + ); + } + render() { + var article = this.state.article; + if (this.state.redirectTo) { + } + return ( +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ ); + } +} +export default ArticleCreate; diff --git a/almanac-web/src/containers/Article/ArticleEdit.js b/almanac-web/src/containers/Article/ArticleEdit.js new file mode 100644 index 0000000..ab4eb9e --- /dev/null +++ b/almanac-web/src/containers/Article/ArticleEdit.js @@ -0,0 +1,114 @@ +import React from 'react'; +import agent from '../../agent'; +import { connect } from 'react-redux'; +import { + UPDATE_ARTICLE, ARTICLE_PAGE_LOADED, + ARTICLE_PAGE_UNLOADED +} from '../../constants/actionTypes'; +import RichTextEditor from 'react-rte'; + +const mapStateToProps = state => ({ + ...state.article, + currentUser: state.common.currentUser +}); +const mapDispatchToProps = dispatch => ({ + onLoad: payload => + dispatch({ type: ARTICLE_PAGE_LOADED, payload }), + onUnload: () => + dispatch({ type: ARTICLE_PAGE_UNLOADED }), + onSubmit: payload => { dispatch({ type: UPDATE_ARTICLE, payload }) } +}); +class ArticleCreate extends React.Component { + constructor() { + super(); + this.state = { + article: { content: RichTextEditor.createEmptyValue() } + }; + this.updateArticle = this.updateArticle.bind(this); + }; + + componentWillMount() { + var getArticle = agent.Articles.get(this.props.match.params.bookId, this.props.match.params.articleId); + this.props.onLoad(Promise.all([getArticle])); + } + componentWillReceiveProps(newProps) { + var article = { ...newProps.article }; + article.content= RichTextEditor.createValueFromString(article.content,"html") + this.setState({ article: article }) + } + componentWillUnmount() { + this.props.onUnload(); + } + updateArticle() { + var article = this.state.article; + article.content = this.state.article.content.toString('html'); + const payload = agent.Articles.update(this.props.match.params.bookId, this.props.match.params.articleId, + { ...article }); + + this.setState({ article: {} }); + this.props.history.push(`/books/${this.props.match.params.bookId}`) + }; + onChangeRte = (value) => { + var article = { ...this.state.article,content: value }; + this.setState({ article: article }); + }; + updateField=(event)=> { + var article = { ...this.state.article, [event.target.name]: event.target.value }; + this.setState( + { article: article } + ); + } + render() { + var article = this.state.article; + return ( +
+
+
+
+ + +
+
+
+
+
+
+
+
+

+ Annotation: +

+

+ +

+
+
+
+ + +
+
+
+ +
+
+
+ ); + } +} +export default connect(mapStateToProps, mapDispatchToProps)(ArticleCreate); diff --git a/almanac-web/src/containers/Article/ArticleList.js b/almanac-web/src/containers/Article/ArticleList.js new file mode 100644 index 0000000..a2634e1 --- /dev/null +++ b/almanac-web/src/containers/Article/ArticleList.js @@ -0,0 +1,56 @@ +import ArticlePreview from './ArticlePreview'; +import React from 'react'; +import { connect } from 'react-redux'; +import axios from 'axios'; + + +const mapStateToProps = state => ({ + ...state, + link: state.link, + currentUser: state.common.currentUser +}); +class ArticleList extends React.Component { + constructor() { + super(); + this.state = { + articles: [] + + } + } + componentWillMount() { + if (this.props.book) { + axios.get(this.props.book.book.articles.href) + .then(response => this.setState({ ...this.state, articles: response.data })) + } + } + + + render() { + var articles = this.state.articles; + if (!articles) { + return ( +
Loading...
+ ); + } + + if (articles.length === 0) { + return ( +
+ No articles are here... yet. +
+ ); + } + return ( +
    + { + articles.map(article => { + return ( +
  1. + ); + }) + } +
+ ); + } +} +export default connect(mapStateToProps, null)(ArticleList); diff --git a/almanac-web/src/containers/Article/ArticlePreview.js b/almanac-web/src/containers/Article/ArticlePreview.js new file mode 100644 index 0000000..3294f3a --- /dev/null +++ b/almanac-web/src/containers/Article/ArticlePreview.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import Moment from 'react-moment'; +import 'moment-timezone'; + +const ArticlePreview = props => { + const article = props.article; + return ( +
+
+
+ +
{article.title}
+ + {article.created && +
} +
+
+
+ ); +} + +export default ArticlePreview; diff --git a/almanac-web/src/containers/Article/index.js b/almanac-web/src/containers/Article/index.js new file mode 100644 index 0000000..0473c67 --- /dev/null +++ b/almanac-web/src/containers/Article/index.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import agent from '../../agent'; +import { Link } from 'react-router-dom'; +import { ARTICLE_PAGE_LOADED, ARTICLE_PAGE_UNLOADED, DELETE_ARTICLE } from '../../constants/actionTypes'; + +const mapStateToProps = state => ({ + ...state.article, + currentUser: state.common.currentUser +}); + +const mapDispatchToProps = dispatch => ({ + onLoad: payload => + dispatch({ type: ARTICLE_PAGE_LOADED, payload }), + onDeleteArticle: payload => dispatch({ + type: ARTICLE_PAGE_LOADED, payload + }), + onUnload: () => + dispatch({ type: ARTICLE_PAGE_UNLOADED }) +}); + +class Book extends React.Component { + constructor() { + super(); + this.deleteArticle = this.deleteArticle.bind(this); + } + componentWillMount() { + this.props.onLoad(Promise.all([agent.Articles.get(this.props.match.params.bookId, this.props.match.params.articleId)])); + } + deleteArticle() { + var deleteArticle = agent.Articles.del(this.props.match.params.bookId, this.props.match.params.articleId); + this.props.onDeleteArticle(Promise.all([deleteArticle])); + this.props.history.push(`/books/${this.props.match.params.bookId}`) + } + + componentWillUnmount() { + this.props.onUnload(); + } + render() { + if (!this.props.article) { + return null; + } + var article = this.props.article; + return ( +
+
+
+

{article.title}

+
+
+ edit +
+ +
+
+ to book +
+
+
+

Annotation:

+

+ {article.annotation} +

+
+
+
+
+

+
+
+
+
+ ); + } +} +export default connect(mapStateToProps, mapDispatchToProps)(Book); \ No newline at end of file diff --git a/almanac-web/src/containers/Authentication/ListErrors.js b/almanac-web/src/containers/Authentication/ListErrors.js new file mode 100644 index 0000000..3b40022 --- /dev/null +++ b/almanac-web/src/containers/Authentication/ListErrors.js @@ -0,0 +1,26 @@ +import React from 'react'; + +class ListErrors extends React.Component { + render() { + const errors = this.props.errors; + if (errors) { + return ( +
    + { + Object.keys(errors).map(key => { + return ( +
  • + {key} {errors[key]} +
  • + ); + }) + } +
+ ); + } else { + return null; + } + } +} + +export default ListErrors; diff --git a/almanac-web/src/containers/Authentication/Login.js b/almanac-web/src/containers/Authentication/Login.js new file mode 100644 index 0000000..e0fa092 --- /dev/null +++ b/almanac-web/src/containers/Authentication/Login.js @@ -0,0 +1,97 @@ +import { Link } from 'react-router-dom'; +import ListErrors from './ListErrors'; +import React from 'react'; +import agent from '../../agent'; +import { connect } from 'react-redux'; +import { + UPDATE_FIELD_AUTH, + LOGIN, + LOGIN_PAGE_UNLOADED +} from '../../constants/actionTypes'; + +const mapStateToProps = state => ({ ...state.auth }); + +const mapDispatchToProps = dispatch => ({ + onChangeEmail: value => + dispatch({ type: UPDATE_FIELD_AUTH, key: 'email', value }), + onChangePassword: value => + dispatch({ type: UPDATE_FIELD_AUTH, key: 'password', value }), + onSubmit: (email, password) => + dispatch({ type: LOGIN, payload: agent.Auth.login(email, password) }), + onUnload: () => + dispatch({ type: LOGIN_PAGE_UNLOADED }) +}); + +class Login extends React.Component { + constructor() { + super(); + this.changeEmail = ev => this.props.onChangeEmail(ev.target.value); + this.changePassword = ev => this.props.onChangePassword(ev.target.value); + this.submitForm = (email, password) => ev => { + ev.preventDefault(); + this.props.onSubmit(email, password); + }; + } + + componentWillUnmount() { + this.props.onUnload(); + } + + render() { + const email = this.props.email; + const password = this.props.password; + return ( +
+
+
+ +
+

Sign In

+

+ + Need an account? + +

+ + + +
+
+ +
+ +
+ +
+ +
+ + + +
+
+
+ +
+
+
+ ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Login); diff --git a/almanac-web/src/containers/Authentication/Register.js b/almanac-web/src/containers/Authentication/Register.js new file mode 100644 index 0000000..af063fb --- /dev/null +++ b/almanac-web/src/containers/Authentication/Register.js @@ -0,0 +1,113 @@ +import { Link } from 'react-router-dom'; +import ListErrors from './ListErrors'; +import React from 'react'; +import agent from '../../agent'; +import { connect } from 'react-redux'; +import { + UPDATE_FIELD_AUTH, + REGISTER, + REGISTER_PAGE_UNLOADED +} from '../../constants/actionTypes'; + +const mapStateToProps = state => ({ ...state.auth }); + +const mapDispatchToProps = dispatch => ({ + onChangeEmail: value => + dispatch({ type: UPDATE_FIELD_AUTH, key: 'email', value }), + onChangePassword: value => + dispatch({ type: UPDATE_FIELD_AUTH, key: 'password', value }), + onChangeUsername: value => + dispatch({ type: UPDATE_FIELD_AUTH, key: 'username', value }), + onSubmit: (username, email, password) => { + const payload = agent.Auth.register(username, email, password); + dispatch({ type: REGISTER, payload }) + }, + onUnload: () => + dispatch({ type: REGISTER_PAGE_UNLOADED }) +}); + +class Register extends React.Component { + constructor() { + super(); + this.changeEmail = ev => this.props.onChangeEmail(ev.target.value); + this.changePassword = ev => this.props.onChangePassword(ev.target.value); + this.changeUsername = ev => this.props.onChangeUsername(ev.target.value); + this.submitForm = (username, email, password) => ev => { + ev.preventDefault(); + this.props.onSubmit(username, email, password); + } + } + + componentWillUnmount() { + this.props.onUnload(); + } + + render() { + const email = this.props.email; + const password = this.props.password; + const username = this.props.username; + + return ( +
+
+
+ +
+

Sign Up

+

+ + Have an account? + +

+ + + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + + +
+
+
+ +
+
+
+ ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Register); diff --git a/almanac-web/src/containers/Book/BookCreate.js b/almanac-web/src/containers/Book/BookCreate.js new file mode 100644 index 0000000..47f2039 --- /dev/null +++ b/almanac-web/src/containers/Book/BookCreate.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import axios from 'axios'; +import agent from '../../agent'; +import { connect } from 'react-redux'; +import { + CREATE_BOOK +} from '../../constants/actionTypes'; + +const mapStateToProps = state => ({ + ...state.book, + currentUser: state.common.currentUser +}); +const mapDispatchToProps = dispatch => ({ + onSubmit: payload => + dispatch({ type: CREATE_BOOK, payload }) +}); +class BookCreate extends React.Component { + constructor() { + super(); + this.state = { + book: {} + } + this.updateField = this.updateField.bind(this); + this.createBook = this.createBook.bind(this); + }; + createBook() { + var book = this.state.book; + book.author = this.props.currentUser; + const payload = agent.Books.create( + { ...book }); + this.setState({ book: {}}); + + this.props.history.push(`/`) + }; + updateField(event) { + var book = { ...this.state.book, [event.target.name]: event.target.value }; + this.setState( + { book: book } + ); + } + render() { + var book = this.props.book; + + return ( +
+
+ + +
+
+ +
+
+ +
+ +
+ +
+
+ + ); + } +} +export default connect(mapStateToProps, mapDispatchToProps)(BookCreate); diff --git a/almanac-web/src/containers/Book/BookEdit.js b/almanac-web/src/containers/Book/BookEdit.js new file mode 100644 index 0000000..b34f72e --- /dev/null +++ b/almanac-web/src/containers/Book/BookEdit.js @@ -0,0 +1,148 @@ +import ArticleList from '../Article/ArticleList'; +import React from 'react'; +import { connect } from 'react-redux'; +import agent from '../../agent'; +import { Link } from 'react-router-dom'; +import { + BOOK_PAGE_LOADED, + BOOK_PAGE_UNLOADED, + BOOK_UPDATED +} from '../../constants/actionTypes'; +import ProfilePreview from '../Profile/ProfilePreview'; + +const mapStateToProps = state => ({ + ...state.book, + currentUser: state.common.currentUser +}); + +const mapDispatchToProps = dispatch => ({ + onLoad: payload => + dispatch({ type: BOOK_PAGE_LOADED, payload }), + onSubmit: payload => + dispatch({ type: BOOK_UPDATED, payload }), + onUnload: () => + dispatch({ type: BOOK_PAGE_UNLOADED }) +}); + + +class BookEdit extends React.Component { + constructor() { + super(); + this.state = {} + this.updateField = this.updateField.bind(this); + this.updateBook = this.updateBook.bind(this); + }; + componentWillMount() { + var getBook = agent.Books.get(this.props.match.params.id); + this.props.onLoad(Promise.all([getBook])); + } + componentWillReceiveProps(newProps) { + this.setState({ book: newProps.book }) + } + componentWillUnmount() { + this.props.onUnload(); + } + updateField(event) { + var book = { ...this.state.book, [event.target.name]: event.target.value }; + this.setState( + { book: book } + ); + } + updateBook() { + var book = this.state.book; + book.author = this.props.currentUser; + const payload = agent.Books.update(this.props.match.params.id, + { ...book }); + this.setState({ book: {} }); + this.props.onSubmit(payload); + this.props.history.push(`/books/${this.props.match.params.id}`) + }; + render() { + if (!this.props.book) { + return null; + } + var book = this.state.book ? this.state.book : {}; + return ( +
+
+
+ + +
+
+
+ {book.author && book.author.href + &&
+ Author: +
+ } +
+
+ {book.subAuthors + && book.subAuthors.length !== 0 + &&
SubAuthors: + {book.subAuthors.map(subAuthor => +
+ +
+ )}
+ } + +
+
+
+
+
State:
+ + +
+
+
+

Description

+
+ +
+
+
+
+
+ { + + } +
+ + Create article +
+
+ +
+
+
+
+ ); + } +} +export default connect(mapStateToProps, mapDispatchToProps)(BookEdit); \ No newline at end of file diff --git a/almanac-web/src/containers/Book/BookList.js b/almanac-web/src/containers/Book/BookList.js new file mode 100644 index 0000000..75dbb1e --- /dev/null +++ b/almanac-web/src/containers/Book/BookList.js @@ -0,0 +1,38 @@ +import BookPreview from './BookPreview'; +import ListPagination from '../../components/ListPagination'; +import React from 'react'; + +const BookList = props => { + if (!props.books) { + return ( +
Loading...
+ ); + } + + if (props.books.length === 0) { + return ( +
+ No books are here... yet. +
+ ); + } + + return ( +
+ { + props.books.map(book => { + return ( + + ); + }) + } + + +
+ ); +}; + +export default BookList; diff --git a/almanac-web/src/containers/Book/BookPreview.js b/almanac-web/src/containers/Book/BookPreview.js new file mode 100644 index 0000000..785c4ae --- /dev/null +++ b/almanac-web/src/containers/Book/BookPreview.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import axios from 'axios'; +import { connect } from 'react-redux'; +import Profilepreview from '../Profile/ProfilePreview'; + +const mapStateToProps = state => ({ + ...state.book, + currentUser: state.common.currentUser +}); + +class BookPreview extends React.Component { + constructor() { + super(); + this.state = { + author: null + } + } + componentWillMount() { + if (this.props.book) { + axios.get(this.props.book.author && this.props.book.author.href) + .then(response => { + this.setState({ ...this.state, author: response.data }) + }) + } + } + + render() { + var book = this.props.book; + + return ( +
+
+
+ +

{book.title}

+ +
+

{book.description}

+
+
+
+ + ); + } +} +export default connect(mapStateToProps, null)(BookPreview); diff --git a/almanac-web/src/containers/Book/index.js b/almanac-web/src/containers/Book/index.js new file mode 100644 index 0000000..d8f3dd5 --- /dev/null +++ b/almanac-web/src/containers/Book/index.js @@ -0,0 +1,110 @@ +import ArticleList from '../Article/ArticleList'; +import React from 'react'; +import { connect } from 'react-redux'; +import agent from '../../agent'; +import { Link } from 'react-router-dom'; +import { + BOOK_PAGE_LOADED, + BOOK_PAGE_UNLOADED +} from '../../constants/actionTypes'; +import ProfilePreview from '../Profile/ProfilePreview'; +import axios from 'axios'; + +const mapStateToProps = state => ({ + ...state.book, + currentUser: state.common.currentUser +}); + +const mapDispatchToProps = dispatch => ({ + onLoad: payload => + dispatch({ type: BOOK_PAGE_LOADED, payload }), + onUnload: () => + dispatch({ type: BOOK_PAGE_UNLOADED }) +}); + + +class Book extends React.Component { + constructor() { + super(); + this.downloadBook = this.downloadBook.bind(this); + } + downloadBook() { + var links = this.props.book._links; + axios.get(links && links.download.href) + } + componentWillMount() { + var getBook = agent.Books.get(this.props.match.params.id); + this.props.onLoad(Promise.all([getBook])); + } + componentWillUnmount() { + this.props.onUnload(); + } + render() { + if (!this.props.book) { + return null; + } + var book = this.props.book; + return ( +
+
+
+

{book.title}

+
+
+ + Edit + {this.props.book._links + && this.props.book._links.download + && + download} +
+
+
+
+ {book.author && book.author.href + &&
+ Author: +
+ } +
+
+ {book.subAuthors + && book.subAuthors.length !== 0 + &&
SubAuthors: + {book.subAuthors.map(subAuthor => +
+ +
+ )}
+ } + +
+
+
+
+

Size: {book.size}

+

State: {book.state}

+ +
+
+
+

Description

+

+ {book.description} +

+
+
+
+
+ { + + } +
+
+ ); + } +} +export default connect(mapStateToProps, mapDispatchToProps)(Book); \ No newline at end of file diff --git a/almanac-web/src/containers/Home/Banner.js b/almanac-web/src/containers/Home/Banner.js new file mode 100644 index 0000000..43a9220 --- /dev/null +++ b/almanac-web/src/containers/Home/Banner.js @@ -0,0 +1,20 @@ +import React from 'react'; + +const Banner = ({ appName, token }) => { + if (token) { + return null; + } + return ( +
+
+

+ {appName.toLowerCase()} +

+

A place to share your imagination.

+
+
+ ); +}; + +export default Banner; + diff --git a/almanac-web/src/containers/Home/MainView.js b/almanac-web/src/containers/Home/MainView.js new file mode 100644 index 0000000..90898fe --- /dev/null +++ b/almanac-web/src/containers/Home/MainView.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import BookList from '../Book/BookList'; +import { CHANGE_TAB } from '../../constants/actionTypes'; + +const mapStateToProps = state => ({ + ...state.bookList, + token: state.common.token +}); + +const mapDispatchToProps = dispatch => ({ + onTabClick: (tab, pager, payload) => dispatch({ type: CHANGE_TAB, tab, pager, payload }) +}); + +const MainView = props => { + return ( +
+
+ +
+
+ ); +}; + +export default connect(mapStateToProps, mapDispatchToProps)(MainView); diff --git a/almanac-web/src/containers/Home/Tags.js b/almanac-web/src/containers/Home/Tags.js new file mode 100644 index 0000000..fb498ae --- /dev/null +++ b/almanac-web/src/containers/Home/Tags.js @@ -0,0 +1,36 @@ +import React from 'react'; +import agent from '../../agent'; + +const Tags = props => { + const tags = props.tags; + if (tags) { + return ( +
+ { + tags.map(tag => { + const handleClick = ev => { + ev.preventDefault(); + props.onClickTag(tag, page => agent.Articles.byTag(tag, page), agent.Articles.byTag(tag)); + }; + + return ( + + {tag} + + ); + }) + } +
+ ); + } else { + return ( +
Loading Tags...
+ ); + } +}; + +export default Tags; diff --git a/almanac-web/src/containers/Home/index.js b/almanac-web/src/containers/Home/index.js new file mode 100644 index 0000000..1191e0c --- /dev/null +++ b/almanac-web/src/containers/Home/index.js @@ -0,0 +1,58 @@ +import Banner from './Banner'; +import MainView from './MainView'; +import React from 'react'; +import agent from '../../agent'; +import { connect } from 'react-redux'; +import { + HOME_PAGE_LOADED, + HOME_PAGE_UNLOADED +} from '../../constants/actionTypes'; + +const Promise = global.Promise; + +const mapStateToProps = state => ({ + ...state.home, + appName: state.common.appName, + token: state.common.token, + books: state.bookList.books, + currentPage: state.bookList.currentPage, + booksCount: state.bookList.booksCount +}); + +const mapDispatchToProps = dispatch => ({ + onLoad: ( pager, payload) => + dispatch({ type: HOME_PAGE_LOADED, pager, payload }), + onUnload: () => + dispatch({ type: HOME_PAGE_UNLOADED }) +}); + +class Home extends React.Component { + componentWillMount() { + const booksPromise = agent.Books.all; + this.props.onLoad(booksPromise, Promise.all([booksPromise()])); + } + + componentWillUnmount() { + this.props.onUnload(); + } + + render() { + return ( +
+ + + +
+
+ +
+
+ +
+ ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Home); diff --git a/almanac-web/src/containers/Profile/ProfilePreview.js b/almanac-web/src/containers/Profile/ProfilePreview.js new file mode 100644 index 0000000..2dc0ff9 --- /dev/null +++ b/almanac-web/src/containers/Profile/ProfilePreview.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import axios from 'axios'; +import { connect } from 'react-redux'; +import auth from '../../reducers/auth'; + +const mapStateToProps = state => ({ + ...state.book, + currentUser: state.common.currentUser +}); + +class ProfilePreview extends React.Component { + constructor() { + super(); + this.state = { + author: null + } + } + componentWillMount() { + console.dir(this.props.link) + if (this.props.link) { + axios.get(this.props.link) + .then(response => this.setState({ ...this.state, author: response.data })) + } + } + render() { + var author = this.state.author; + if (!author) { + return ( +
Loading...
+ ); + }; + return ( + + + {author.username} + + ); + }; +} + +export default connect(mapStateToProps, null)(ProfilePreview); diff --git a/almanac-web/src/containers/Profile/index.js b/almanac-web/src/containers/Profile/index.js new file mode 100644 index 0000000..a6a50e7 --- /dev/null +++ b/almanac-web/src/containers/Profile/index.js @@ -0,0 +1,100 @@ + +import React from 'react'; +import { connect } from 'react-redux'; +import agent from '../../agent'; +import { + PROFILE_PAGE_LOADED, + PROFILE_PAGE_UNLOADED +} from '../../constants/actionTypes'; +import axios from 'axios'; +import { Link } from 'react-router-dom'; + +const mapStateToProps = state => ({ + ...state.profile, + currentUser: state.common.currentUser +}); + +const mapDispatchToProps = dispatch => ({ + onLoad: payload => + dispatch({ type: PROFILE_PAGE_LOADED, payload }), + onUnload: () => + dispatch({ type: PROFILE_PAGE_UNLOADED }) +}); + + +class Profile extends React.Component { + constructor() { + super(); + this.state = { + booksAsAuthor: [], + booksAsSubAuthor: [] + } + } + componentWillMount() { + var getProfile = agent.Profile.get(this.props.match.params.id); + this.props.onLoad(Promise.all([getProfile])); + console.dir(this.props) + + } + componentWillUnmount() { + this.props.onUnload(); + } + render() { + if (!this.props.profile) { + return null; + } + var profile = this.props.profile; + var booksAsSubAuthor = profile.booksAsSubAuthor; + var booksAsAuthor = profile.booksAsAuthor; + return ( +
+
+
+

{profile.username}

+
+
+
+
+
About:
+
+
{profile.about}
+
+
+
Information:
+
{profile.information}
+
+
Books as author:
+ {booksAsAuthor.length === 0 && +
There is no any book as author
} +
    { + + booksAsAuthor && booksAsAuthor.map(book => +
  1. + +
    {book.title}
    + +
  2. + )} +
+
+
Books as coauthor:
+ {booksAsSubAuthor.length === 0 && +
There is no any book as coauthor
+ } + +
    { + booksAsSubAuthor && booksAsSubAuthor.map(book => +
  1. + +
    {book.title}
    + +
  2. + )} +
+
+
+ + ); + } +} +export default connect(mapStateToProps, mapDispatchToProps)(Profile); \ No newline at end of file diff --git a/almanac-web/src/index.js b/almanac-web/src/index.js new file mode 100644 index 0000000..ab262ef --- /dev/null +++ b/almanac-web/src/index.js @@ -0,0 +1,21 @@ +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import React from 'react'; +import { store, history } from './store'; + +import { Route, Switch } from 'react-router-dom'; +import { ConnectedRouter } from 'react-router-redux'; +import Book from './containers/Book'; + +import App from './App'; + +ReactDOM.render(( + + + + + + + + +), document.getElementById('root')); diff --git a/almanac-web/src/logo.svg b/almanac-web/src/logo.svg new file mode 100644 index 0000000..6b60c10 --- /dev/null +++ b/almanac-web/src/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/almanac-web/src/middleware.js b/almanac-web/src/middleware.js new file mode 100644 index 0000000..070ad03 --- /dev/null +++ b/almanac-web/src/middleware.js @@ -0,0 +1,68 @@ +import agent from './agent'; +import { + ASYNC_START, + ASYNC_END, + LOGIN, + LOGOUT, + REGISTER +} from './constants/actionTypes'; + +const promiseMiddleware = store => next => action => { + if (isPromise(action.payload)) { + store.dispatch({ type: ASYNC_START, subtype: action.type }); + + const currentView = store.getState().viewChangeCounter; + const skipTracking = action.skipTracking; + + action.payload.then( + res => { + const currentState = store.getState() + if (!skipTracking && currentState.viewChangeCounter !== currentView) { + return + } + console.log('RESULT', res); + action.payload = res; + store.dispatch({ type: ASYNC_END, promise: action.payload }); + store.dispatch(action); + }, + error => { + const currentState = store.getState() + if (!skipTracking && currentState.viewChangeCounter !== currentView) { + return + } + console.log('ERROR', error); + action.error = true; + action.payload = error.response !==undefined ?error.response.body:error.message; + if (!action.skipTracking) { + store.dispatch({ type: ASYNC_END, promise: action.payload }); + } + store.dispatch(action); + } + ); + + return; + } + + next(action); +}; + +const localStorageMiddleware = store => next => action => { + if (action.type === REGISTER || action.type === LOGIN) { + if (!action.error) { + window.localStorage.setItem('jwt', action.payload.user.token); + agent.setToken(action.payload.user.token); + } + } else if (action.type === LOGOUT) { + window.localStorage.setItem('jwt', ''); + agent.setToken(null); + } + + next(action); +}; + +function isPromise(v) { + return v && typeof v.then === 'function'; +} + + +export { promiseMiddleware, localStorageMiddleware } diff --git a/almanac-web/src/reducer.js b/almanac-web/src/reducer.js new file mode 100644 index 0000000..80b0477 --- /dev/null +++ b/almanac-web/src/reducer.js @@ -0,0 +1,20 @@ +import auth from './reducers/auth'; +import bookList from './reducers/bookList'; +import { combineReducers } from 'redux'; +import common from './reducers/common'; +import home from './reducers/home'; +import profile from './reducers/profile'; +import book from './reducers/book'; +import article from './reducers/article'; +import { routerReducer } from 'react-router-redux'; + +export default combineReducers({ + auth, + common, + home, + profile, + book, + article, + bookList, + router: routerReducer +}); diff --git a/almanac-web/src/reducers/article.js b/almanac-web/src/reducers/article.js new file mode 100644 index 0000000..2a922ca --- /dev/null +++ b/almanac-web/src/reducers/article.js @@ -0,0 +1,37 @@ +import { + ARTICLE_PAGE_LOADED, + ARTICLE_PAGE_UNLOADED, + CREATE_ARTICLE +} from '../constants/actionTypes'; + +export default (state = {}, action) => { + switch (action.type) { + case ARTICLE_PAGE_LOADED: { + var article = action.payload[0]; + return { + ...state, + article: article + }; + } + case ARTICLE_PAGE_UNLOADED: + return {}; + // case ADD_COMMENT: + // return { + // ...state, + // commentErrors: action.error ? action.payload.errors : null, + // comments: action.error ? + // null : + // (state.comments || []).concat([action.payload.comment]) + // }; + // case DELETE_COMMENT: + // const commentId = action.commentId + // return { + // ...state, + // comments: state.comments.filter(comment => comment.id !== commentId) + // }; + case CREATE_ARTICLE: + return { ...state, redirectTo: '/' }; + default: + return state; + } +}; diff --git a/almanac-web/src/reducers/auth.js b/almanac-web/src/reducers/auth.js new file mode 100644 index 0000000..6e83838 --- /dev/null +++ b/almanac-web/src/reducers/auth.js @@ -0,0 +1,34 @@ +import { + LOGIN, + REGISTER, + LOGIN_PAGE_UNLOADED, + REGISTER_PAGE_UNLOADED, + ASYNC_START, + UPDATE_FIELD_AUTH +} from '../constants/actionTypes'; + +export default (state = {}, action) => { + switch (action.type) { + case LOGIN: + case REGISTER: + return { + ...state, + inProgress: false, + errors: action.error ? action.payload.errors : null + }; + case LOGIN_PAGE_UNLOADED: + case REGISTER_PAGE_UNLOADED: + return {}; + case ASYNC_START: + if (action.subtype === LOGIN || action.subtype === REGISTER) { + return { ...state, inProgress: true }; + } + break; + case UPDATE_FIELD_AUTH: + return { ...state, [action.key]: action.value }; + default: + return state; + } + + return state; +}; diff --git a/almanac-web/src/reducers/book.js b/almanac-web/src/reducers/book.js new file mode 100644 index 0000000..a57b8e1 --- /dev/null +++ b/almanac-web/src/reducers/book.js @@ -0,0 +1,40 @@ +import { + BOOK_PAGE_LOADED, + BOOK_PAGE_UNLOADED, + BOOK_ARTICLES_LOADED, + CREATE_ARTICLE +} from '../constants/actionTypes'; + +export default (state = {}, action) => { + switch (action.type) { + case BOOK_PAGE_LOADED: { + var book = action.payload[0]; + return { + ...state, + book: book + }; + } + case CREATE_ARTICLE: + return { + ...state, redirectTo: '/' + }; + case BOOK_PAGE_UNLOADED: + return {}; + // case ADD_COMMENT: + // return { + // ...state, + // commentErrors: action.error ? action.payload.errors : null, + // comments: action.error ? + // null : + // (state.comments || []).concat([action.payload.comment]) + // }; + // case DELETE_COMMENT: + // const commentId = action.commentId + // return { + // ...state, + // comments: state.comments.filter(comment => comment.id !== commentId) + // }; + default: + return state; + } +}; diff --git a/almanac-web/src/reducers/bookList.js b/almanac-web/src/reducers/bookList.js new file mode 100644 index 0000000..1c5e26d --- /dev/null +++ b/almanac-web/src/reducers/bookList.js @@ -0,0 +1,33 @@ +import { + SET_PAGE, + HOME_PAGE_LOADED, + HOME_PAGE_UNLOADED +} from '../constants/actionTypes'; + +export default (state = {}, action) => { + switch (action.type) { + case SET_PAGE: + return { + ...state, + books: action.payload._embedded + ? action.payload._embedded.bookResponseList + : [], + booksCount: action.payload.page.totalElements, + currentPage: action.payload.page.number + }; + case HOME_PAGE_LOADED: + return { + ...state, + pager: action.payload[0].page, + books: action.payload[0]._embedded + ? action.payload[0]._embedded.bookResponseList + : [], + booksCount: action.payload[0].page.totalElements, + currentPage: 0 + }; + case HOME_PAGE_UNLOADED: + return {}; + default: + return state; + } +}; diff --git a/almanac-web/src/reducers/common.js b/almanac-web/src/reducers/common.js new file mode 100644 index 0000000..fb9b83d --- /dev/null +++ b/almanac-web/src/reducers/common.js @@ -0,0 +1,70 @@ +import { + APP_LOAD, + REDIRECT, + LOGOUT, + ARTICLE_SUBMITTED, + SETTINGS_SAVED, + LOGIN, + REGISTER, + DELETE_ARTICLE, + BOOK_PAGE_UNLOADED, + EDITOR_PAGE_UNLOADED, + HOME_PAGE_UNLOADED, + PROFILE_PAGE_UNLOADED, + PROFILE_FAVORITES_PAGE_UNLOADED, + SETTINGS_PAGE_UNLOADED, + LOGIN_PAGE_UNLOADED, + REGISTER_PAGE_UNLOADED +} from '../constants/actionTypes'; + +const defaultState = { + appName: 'Almanac', + token: null, + viewChangeCounter: 0 +}; + +export default (state = defaultState, action) => { + switch (action.type) { + case APP_LOAD: + return { + ...state, + token: action.token || null, + appLoaded: true, + currentUser: action.payload ? action.payload.user : null + }; + case REDIRECT: + return { ...state, redirectTo: null }; + case LOGOUT: + return { ...state, redirectTo: '/', token: null, currentUser: null }; + case ARTICLE_SUBMITTED: + const redirectUrl = `/article/${action.payload.article.slug}`; + return { ...state, redirectTo: redirectUrl }; + case SETTINGS_SAVED: + return { + ...state, + redirectTo: action.error ? null : '/', + currentUser: action.error ? null : action.payload.user + }; + case LOGIN: + case REGISTER: + return { + ...state, + redirectTo: action.error ? null : '/', + token: action.error ? null : action.payload.user.token, + currentUser: action.error ? null : action.payload.user + }; + case DELETE_ARTICLE: + return { ...state, redirectTo: '/' }; + case BOOK_PAGE_UNLOADED: + case EDITOR_PAGE_UNLOADED: + case HOME_PAGE_UNLOADED: + case PROFILE_PAGE_UNLOADED: + case PROFILE_FAVORITES_PAGE_UNLOADED: + case SETTINGS_PAGE_UNLOADED: + case LOGIN_PAGE_UNLOADED: + case REGISTER_PAGE_UNLOADED: + return { ...state, viewChangeCounter: state.viewChangeCounter + 1 }; + default: + return state; + } +}; diff --git a/almanac-web/src/reducers/home.js b/almanac-web/src/reducers/home.js new file mode 100644 index 0000000..05e900d --- /dev/null +++ b/almanac-web/src/reducers/home.js @@ -0,0 +1,14 @@ +import { HOME_PAGE_LOADED, HOME_PAGE_UNLOADED } from '../constants/actionTypes'; + +export default (state = {}, action) => { + switch (action.type) { + case HOME_PAGE_LOADED: + return { + ...state + }; + case HOME_PAGE_UNLOADED: + return {}; + default: + return state; + } +}; diff --git a/almanac-web/src/reducers/profile.js b/almanac-web/src/reducers/profile.js new file mode 100644 index 0000000..64c23bd --- /dev/null +++ b/almanac-web/src/reducers/profile.js @@ -0,0 +1,19 @@ +import { + PROFILE_PAGE_LOADED, + PROFILE_PAGE_UNLOADED +} from '../constants/actionTypes'; + +export default (state = {}, action) => { + switch (action.type) { + case PROFILE_PAGE_LOADED: + return { + ...state, + profile: action.payload[0] + }; + case PROFILE_PAGE_UNLOADED: + return {}; + + default: + return state; + } +}; diff --git a/almanac-web/src/store.js b/almanac-web/src/store.js new file mode 100644 index 0000000..f9a087c --- /dev/null +++ b/almanac-web/src/store.js @@ -0,0 +1,29 @@ +import { applyMiddleware, createStore } from 'redux'; +import { createLogger } from 'redux-logger' +import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; +import { promiseMiddleware, localStorageMiddleware } from './middleware'; +import reducer from './reducer'; + +import { routerMiddleware } from 'react-router-redux' +import createHistory from 'history/createBrowserHistory'; + +export const history = createHistory(); + +const myRouterMiddleware = routerMiddleware(history); + +const initialState = {}; +const getMiddleware = () => { + if (process.env.NODE_ENV === 'production') { + return applyMiddleware(myRouterMiddleware, promiseMiddleware, localStorageMiddleware); + } else { + // Enable additional logging in non-production environments. + return applyMiddleware(myRouterMiddleware, promiseMiddleware, localStorageMiddleware, createLogger()) + } +}; +export const store = createStore( + reducer, + initialState, + composeWithDevTools(getMiddleware()) +); + + From fa10578ca2c855f385eb23bf2618ac6a6310fd37 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Fri, 12 Apr 2019 14:41:05 +0300 Subject: [PATCH 13/24] Add text editor in react client --- almanac-web/package.json | 1 + .../src/containers/Article/ArticleCreate.js | 18 +++++++++++++----- .../src/containers/Article/ArticleEdit.js | 10 +++++----- almanac-web/src/containers/Article/index.js | 5 +++-- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/almanac-web/package.json b/almanac-web/package.json index 42f6cbd..c6bb599 100644 --- a/almanac-web/package.json +++ b/almanac-web/package.json @@ -6,6 +6,7 @@ "@tinymce/tinymce-react": "^3.0.1", "axios": "^0.18.0", "history": "^4.6.3", + "marked": "^0.6.2", "moment": "^2.24.0", "moment-timezone": "^0.5.23", "prismic-reactjs": "^0.3.2", diff --git a/almanac-web/src/containers/Article/ArticleCreate.js b/almanac-web/src/containers/Article/ArticleCreate.js index 7040ed8..e606670 100644 --- a/almanac-web/src/containers/Article/ArticleCreate.js +++ b/almanac-web/src/containers/Article/ArticleCreate.js @@ -1,25 +1,26 @@ import React from 'react'; import agent from '../../agent'; -import { connect } from 'react-redux'; import { CREATE_ARTICLE } from '../../constants/actionTypes'; import { Editor } from '@tinymce/tinymce-react'; +import RichTextEditor from 'react-rte'; class ArticleCreate extends React.Component { constructor() { super(); this.state = { - article: {} + article: { content: RichTextEditor.createEmptyValue() } } this.updateField = this.updateField.bind(this); this.createArticle = this.createArticle.bind(this); }; createArticle() { var article = this.state.article; + article.content = this.state.article.content.toString('markdown'); const payload = agent.Articles.create(this.props.match.params.bookId, { ...article }); - this.setState({ article: {} }); + this.setState({ article: { content: RichTextEditor.createEmptyValue() } }); this.props.history.push(`/books/${this.props.match.params.bookId}/edit`) }; updateField(event) { @@ -27,7 +28,11 @@ class ArticleCreate extends React.Component { this.setState( { article: article } ); - } + } + onChangeRte = (value) => { + var article = { ...this.state.article,content: value }; + this.setState({ article: article }); + }; render() { var article = this.state.article; if (this.state.redirectTo) { @@ -51,7 +56,10 @@ class ArticleCreate extends React.Component {
- +
- edit + edit
@@ -65,7 +66,7 @@ class Book extends React.Component {
-

+

From eba2e1eb8995fa44b9689a0c9fdd7caf939841eb Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Mon, 22 Apr 2019 14:02:49 +0300 Subject: [PATCH 14/24] allow users to login --- almanac-web/public/favicon.ico | Bin 0 -> 3870 bytes almanac-web/public/index.html | 18 + almanac-web/public/manifest.json | 15 + almanac-web/src/App.js | 33 +- almanac-web/src/Header.js | 3 +- almanac-web/src/agent.js | 26 +- almanac-web/src/components/HeaderList.js | 87 +- almanac-web/src/components/ListPagination.js | 12 +- almanac-web/src/constants/actionTypes.js | 2 + almanac-web/src/constants/commonConstants.js | 2 +- almanac-web/src/constants/config.js | 9 + .../src/containers/Article/ArticleCreate.js | 99 +- .../src/containers/Article/ArticleEdit.js | 1 + .../src/containers/Article/ArticleList.js | 19 +- .../src/containers/Article/ArticlePreview.js | 2 +- almanac-web/src/containers/Article/index.js | 21 +- .../src/containers/Authentication/Login.js | 21 +- .../src/containers/Authentication/OAuth2.js | 41 + .../Authentication/OAuthProvider.js | 20 + .../src/containers/Authentication/Popup.js | 88 + almanac-web/src/containers/Book/BookCreate.js | 145 +- almanac-web/src/containers/Book/BookList.js | 16 +- .../src/containers/Book/BookPreview.js | 22 +- almanac-web/src/containers/Book/index.js | 19 +- .../src/containers/Profile/ProfilePreview.js | 10 +- almanac-web/src/containers/Profile/index.js | 9 +- almanac-web/src/middleware.js | 10 +- almanac-web/src/reducers/auth.js | 3 +- almanac-web/src/reducers/book.js | 20 +- almanac-web/src/reducers/bookList.js | 20 +- almanac-web/src/reducers/common.js | 13 +- build.gradle | 6 + .../audit/SpringSecurityAuditorAware.java | 24 + .../fic/writer/domain/entity/Article.java | 6 +- .../java/fic/writer/domain/entity/Book.java | 22 +- .../java/fic/writer/domain/entity/User.java | 1 + .../writer/domain/entity/auth/CustomUser.java | 1 - .../writer/domain/entity/auth/OauthUser.java | 2 +- .../domain/entity/dto/BookSearchDto.java | 4 + .../domain/repository/ActorRepositry.java | 7 + .../repository/CustomUserRepository.java | 10 + .../repository/OauthUserRepository.java | 7 + .../domain/repository/UserRepository.java | 2 + .../writer/domain/service/ArticleService.java | 3 + .../writer/domain/service/FileService.java | 13 + .../domain/service/OauthUserService.java | 6 + .../writer/domain/service/UserService.java | 4 + .../domain/service/files/ArticleParser.java | 9 + .../domain/service/files/BookParser.java | 19 + .../domain/service/files/TextParser.java | 7 + .../service/files/impl/DocxTextParser.java | 28 + .../service/files/impl/JsonArticleParser.java | 40 + .../service/files/impl/TxtTextParser.java | 24 + .../service/files/impl/XmlArticleParser.java | 57 + .../service/files/impl/XmlBookParser.java | 138 + .../service/impl/ArticleServiceImpl.java | 20 + .../domain/service/impl/BookServiceImpl.java | 10 +- .../domain/service/impl/FileServiceImpl.java | 97 + .../service/impl/OauthUserServiceImpl.java | 48 + .../domain/service/impl/UserServiceImpl.java | 14 + .../writer/domain/utils/FileExtention.java | 5 + .../web/config/audit/PersistenceConfig.java | 18 + .../web/config/database/init/UserLoader.java | 37 + ...horizationServerSecurityConfiguration.java | 37 + .../web/config/security/OauthConfig.java | 85 + .../web/config/security/SecurityConfig.java | 28 + .../authorization/CustomUserDetails.java | 56 + .../authorization/UserDetailServiceImpl.java | 38 + .../security/oauth/ClientResources.java | 21 + .../oauth/ResourceServerConfiguration.java | 23 + ...nalServerAuthenticationSuccessHandler.java | 51 + .../web/controller/ArticleController.java | 3 +- .../writer/web/controller/BookController.java | 8 +- .../writer/web/controller/FileController.java | 56 + .../web/controller/FileControllerAdvice.java | 17 + .../writer/web/controller/UserController.java | 8 +- .../writer/web/response/ArticleResponse.java | 2 + .../fic/writer/web/response/BookResponse.java | 2 + src/main/resources/application-oauth-acme.yml | 7 + .../resources/application-oauth-github.yml | 9 + src/main/resources/application.yml | 13 +- src/main/resources/client.yml | 12 + src/main/resources/docker/stack.yml | 11 + src/main/resources/static/index.html | 64 + .../service/ActorArticleActorStateTest.java | 35 + .../domain/service/ArticleServiceTest.java | 2 - .../service/BookAndArticleServicesTest.java | 2 - .../domain/service/FileServiceTest.java | 37 + .../web/controller/UserControllerTest.java | 24 +- src/test/resources/application-oauth-github | 9 + .../resources/application-oauth-react-client | 7 + src/test/resources/application.yml | 7 +- src/test/resources/files/ValidArticle.json | 5 + src/test/resources/files/t.fb2 | 3484 +++++++++++++++++ 94 files changed, 5404 insertions(+), 254 deletions(-) create mode 100644 almanac-web/public/favicon.ico create mode 100644 almanac-web/public/index.html create mode 100644 almanac-web/public/manifest.json create mode 100644 almanac-web/src/constants/config.js create mode 100644 almanac-web/src/containers/Authentication/OAuth2.js create mode 100644 almanac-web/src/containers/Authentication/OAuthProvider.js create mode 100644 almanac-web/src/containers/Authentication/Popup.js create mode 100644 src/main/java/fic/writer/domain/audit/SpringSecurityAuditorAware.java create mode 100644 src/main/java/fic/writer/domain/entity/dto/BookSearchDto.java create mode 100644 src/main/java/fic/writer/domain/repository/ActorRepositry.java create mode 100644 src/main/java/fic/writer/domain/repository/CustomUserRepository.java create mode 100644 src/main/java/fic/writer/domain/repository/OauthUserRepository.java create mode 100644 src/main/java/fic/writer/domain/service/FileService.java create mode 100644 src/main/java/fic/writer/domain/service/OauthUserService.java create mode 100644 src/main/java/fic/writer/domain/service/files/ArticleParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/BookParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/TextParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/impl/DocxTextParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/impl/JsonArticleParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/impl/TxtTextParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/impl/XmlArticleParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/impl/XmlBookParser.java create mode 100644 src/main/java/fic/writer/domain/service/impl/FileServiceImpl.java create mode 100644 src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java create mode 100644 src/main/java/fic/writer/domain/utils/FileExtention.java create mode 100644 src/main/java/fic/writer/web/config/audit/PersistenceConfig.java create mode 100644 src/main/java/fic/writer/web/config/database/init/UserLoader.java create mode 100644 src/main/java/fic/writer/web/config/security/CorsEnabledAuthorizationServerSecurityConfiguration.java create mode 100644 src/main/java/fic/writer/web/config/security/OauthConfig.java create mode 100644 src/main/java/fic/writer/web/config/security/SecurityConfig.java create mode 100644 src/main/java/fic/writer/web/config/security/authorization/CustomUserDetails.java create mode 100644 src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java create mode 100644 src/main/java/fic/writer/web/config/security/oauth/ClientResources.java create mode 100644 src/main/java/fic/writer/web/config/security/oauth/ResourceServerConfiguration.java create mode 100644 src/main/java/fic/writer/web/config/security/oauth/externalService/OauthExternalServerAuthenticationSuccessHandler.java create mode 100644 src/main/java/fic/writer/web/controller/FileController.java create mode 100644 src/main/java/fic/writer/web/controller/FileControllerAdvice.java create mode 100644 src/main/resources/application-oauth-acme.yml create mode 100644 src/main/resources/application-oauth-github.yml create mode 100644 src/main/resources/client.yml create mode 100644 src/main/resources/docker/stack.yml create mode 100644 src/main/resources/static/index.html create mode 100644 src/test/java/fic/writer/domain/service/ActorArticleActorStateTest.java create mode 100644 src/test/java/fic/writer/domain/service/FileServiceTest.java create mode 100644 src/test/resources/application-oauth-github create mode 100644 src/test/resources/application-oauth-react-client create mode 100644 src/test/resources/files/ValidArticle.json create mode 100644 src/test/resources/files/t.fb2 diff --git a/almanac-web/public/favicon.ico b/almanac-web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 zcma);c{J4h9>;%nil|2-o+rCuEF-(I%-F}ijC~o(k~HKAkr0)!FCj~d>`RtpD?8b; zXOC1OD!V*IsqUwzbMF1)-gEDD=A573Z-&G7^LoAC9|WO7Xc0Cx1g^Zu0u_SjAPB3vGa^W|sj)80f#V0@M_CAZTIO(t--xg= z!sii`1giyH7EKL_+Wi0ab<)&E_0KD!3Rp2^HNB*K2@PHCs4PWSA32*-^7d{9nH2_E zmC{C*N*)(vEF1_aMamw2A{ZH5aIDqiabnFdJ|y0%aS|64E$`s2ccV~3lR!u<){eS` z#^Mx6o(iP1Ix%4dv`t@!&Za-K@mTm#vadc{0aWDV*_%EiGK7qMC_(`exc>-$Gb9~W!w_^{*pYRm~G zBN{nA;cm^w$VWg1O^^<6vY`1XCD|s_zv*g*5&V#wv&s#h$xlUilPe4U@I&UXZbL z0)%9Uj&@yd03n;!7do+bfixH^FeZ-Ema}s;DQX2gY+7g0s(9;`8GyvPY1*vxiF&|w z>!vA~GA<~JUqH}d;DfBSi^IT*#lrzXl$fNpq0_T1tA+`A$1?(gLb?e#0>UELvljtQ zK+*74m0jn&)5yk8mLBv;=@}c{t0ztT<v;Avck$S6D`Z)^c0(jiwKhQsn|LDRY&w(Fmi91I7H6S;b0XM{e zXp0~(T@k_r-!jkLwd1_Vre^v$G4|kh4}=Gi?$AaJ)3I+^m|Zyj#*?Kp@w(lQdJZf4 z#|IJW5z+S^e9@(6hW6N~{pj8|NO*>1)E=%?nNUAkmv~OY&ZV;m-%?pQ_11)hAr0oAwILrlsGawpxx4D43J&K=n+p3WLnlDsQ$b(9+4 z?mO^hmV^F8MV{4Lx>(Q=aHhQ1){0d*(e&s%G=i5rq3;t{JC zmgbn5Nkl)t@fPH$v;af26lyhH!k+#}_&aBK4baYPbZy$5aFx4}ka&qxl z$=Rh$W;U)>-=S-0=?7FH9dUAd2(q#4TCAHky!$^~;Dz^j|8_wuKc*YzfdAht@Q&ror?91Dm!N03=4=O!a)I*0q~p0g$Fm$pmr$ zb;wD;STDIi$@M%y1>p&_>%?UP($15gou_ue1u0!4(%81;qcIW8NyxFEvXpiJ|H4wz z*mFT(qVx1FKufG11hByuX%lPk4t#WZ{>8ka2efjY`~;AL6vWyQKpJun2nRiZYDij$ zP>4jQXPaP$UC$yIVgGa)jDV;F0l^n(V=HMRB5)20V7&r$jmk{UUIe zVjKroK}JAbD>B`2cwNQ&GDLx8{pg`7hbA~grk|W6LgiZ`8y`{Iq0i>t!3p2}MS6S+ zO_ruKyAElt)rdS>CtF7j{&6rP-#c=7evGMt7B6`7HG|-(WL`bDUAjyn+k$mx$CH;q2Dz4x;cPP$hW=`pFfLO)!jaCL@V2+F)So3}vg|%O*^T1j>C2lx zsURO-zIJC$^$g2byVbRIo^w>UxK}74^TqUiRR#7s_X$e)$6iYG1(PcW7un-va-S&u zHk9-6Zn&>T==A)lM^D~bk{&rFzCi35>UR!ZjQkdSiNX*-;l4z9j*7|q`TBl~Au`5& z+c)*8?#-tgUR$Zd%Q3bs96w6k7q@#tUn`5rj+r@_sAVVLqco|6O{ILX&U-&-cbVa3 zY?ngHR@%l{;`ri%H*0EhBWrGjv!LE4db?HEWb5mu*t@{kv|XwK8?npOshmzf=vZA@ zVSN9sL~!sn?r(AK)Q7Jk2(|M67Uy3I{eRy z_l&Y@A>;vjkWN5I2xvFFTLX0i+`{qz7C_@bo`ZUzDugfq4+>a3?1v%)O+YTd6@Ul7 zAfLfm=nhZ`)P~&v90$&UcF+yXm9sq!qCx3^9gzIcO|Y(js^Fj)Rvq>nQAHI92ap=P z10A4@prk+AGWCb`2)dQYFuR$|H6iDE8p}9a?#nV2}LBCoCf(Xi2@szia7#gY>b|l!-U`c}@ zLdhvQjc!BdLJvYvzzzngnw51yRYCqh4}$oRCy-z|v3Hc*d|?^Wj=l~18*E~*cR_kU z{XsxM1i{V*4GujHQ3DBpl2w4FgFR48Nma@HPgnyKoIEY-MqmMeY=I<%oG~l!f<+FN z1ZY^;10j4M4#HYXP zw5eJpA_y(>uLQ~OucgxDLuf}fVs272FaMxhn4xnDGIyLXnw>Xsd^J8XhcWIwIoQ9} z%FoSJTAGW(SRGwJwb=@pY7r$uQRK3Zd~XbxU)ts!4XsJrCycrWSI?e!IqwqIR8+Jh zlRjZ`UO1I!BtJR_2~7AbkbSm%XQqxEPkz6BTGWx8e}nQ=w7bZ|eVP4?*Tb!$(R)iC z9)&%bS*u(lXqzitAN)Oo=&Ytn>%Hzjc<5liuPi>zC_nw;Z0AE3Y$Jao_Q90R-gl~5 z_xAb2J%eArrC1CN4G$}-zVvCqF1;H;abAu6G*+PDHSYFx@Tdbfox*uEd3}BUyYY-l zTfEsOqsi#f9^FoLO;ChK<554qkri&Av~SIM*{fEYRE?vH7pTAOmu2pz3X?Wn*!ROX ztd54huAk&mFBemMooL33RV-*1f0Q3_(7hl$<#*|WF9P!;r;4_+X~k~uKEqdzZ$5Al zV63XN@)j$FN#cCD;ek1R#l zv%pGrhB~KWgoCj%GT?%{@@o(AJGt*PG#l3i>lhmb_twKH^EYvacVY-6bsCl5*^~L0 zonm@lk2UvvTKr2RS%}T>^~EYqdL1q4nD%0n&Xqr^cK^`J5W;lRRB^R-O8b&HENO||mo0xaD+S=I8RTlIfVgqN@SXDr2&-)we--K7w= zJVU8?Z+7k9dy;s;^gDkQa`0nz6N{T?(A&Iz)2!DEecLyRa&FI!id#5Z7B*O2=PsR0 zEvc|8{NS^)!d)MDX(97Xw}m&kEO@5jqRaDZ!+%`wYOI<23q|&js`&o4xvjP7D_xv@ z5hEwpsp{HezI9!~6O{~)lLR@oF7?J7i>1|5a~UuoN=q&6N}EJPV_GD`&M*v8Y`^2j zKII*d_@Fi$+i*YEW+Hbzn{iQk~yP z>7N{S4)r*!NwQ`(qcN#8SRQsNK6>{)X12nbF`*7#ecO7I)Q$uZsV+xS4E7aUn+U(K baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/almanac-web/public/index.html b/almanac-web/public/index.html new file mode 100644 index 0000000..bd79eb9 --- /dev/null +++ b/almanac-web/public/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + Almanac + + +
+ + + diff --git a/almanac-web/public/manifest.json b/almanac-web/public/manifest.json new file mode 100644 index 0000000..1f2f141 --- /dev/null +++ b/almanac-web/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/almanac-web/src/App.js b/almanac-web/src/App.js index fadf377..487d0b5 100644 --- a/almanac-web/src/App.js +++ b/almanac-web/src/App.js @@ -2,7 +2,7 @@ import agent from './agent'; import Header from './Header'; import React from 'react'; import { connect } from 'react-redux'; -import { APP_LOAD, REDIRECT } from './constants/actionTypes'; +import { APP_LOAD, REDIRECT, SIGN_IN, LOGOUT } from './constants/actionTypes'; import { Route, Switch } from 'react-router-dom'; import Home from './containers/Home'; import { store } from './store'; @@ -25,7 +25,7 @@ const mapStateToProps = state => { appLoaded: state.common.appLoaded, appName: state.common.appName, currentUser: state.common.currentUser, - redirectTo: state.common.redirectTo + redirectTo: state.common.redirectTo, } }; @@ -33,33 +33,47 @@ const mapDispatchToProps = dispatch => ({ onLoad: (payload, token) => dispatch({ type: APP_LOAD, payload, token, skipTracking: true }), onRedirect: () => - dispatch({ type: REDIRECT }) + dispatch({ type: REDIRECT }), + onLoadProfile: (payload) => + dispatch({ type: SIGN_IN, payload }), + onClickLogout: () => dispatch({ type: LOGOUT }) }); + class App extends React.Component { componentWillReceiveProps(nextProps) { if (nextProps.redirectTo) { - // this.context.router.replace(nextProps.redirectTo); store.dispatch(push(nextProps.redirectTo)); this.props.onRedirect(); } } componentWillMount() { - const token = window.localStorage.getItem('jwt'); + const token = window.localStorage.getItem('access_token'); if (token) { agent.setToken(token); + this.props.onLoadProfile(agent.Auth.current()) } this.props.onLoad(token ? agent.Auth.current() : null, token); } + logout = () => { + this.props.onClickLogout(); + } render() { + if (!(window.location.pathname === "/login" || + window.location.pathname === "/register") && + !window.localStorage.getItem('access_token')) { + store.dispatch(push("/login")); + } + if (this.props.appLoaded) { return (
+ currentUser={this.props.currentUser} + onClickLogout={this.logout} /> @@ -80,14 +94,11 @@ class App extends React.Component {
+ currentUser={this.props.currentUser} + onClickLogout={this.logout} />
); } } -// App.contextTypes = { -// router: PropTypes.object.isRequired -// }; - export default connect(mapStateToProps, mapDispatchToProps)(App); diff --git a/almanac-web/src/Header.js b/almanac-web/src/Header.js index 68a6fba..702e7f8 100644 --- a/almanac-web/src/Header.js +++ b/almanac-web/src/Header.js @@ -11,8 +11,7 @@ class Header extends React.Component { {this.props.appName.toLowerCase()} - - +
); diff --git a/almanac-web/src/agent.js b/almanac-web/src/agent.js index d1ad137..bf81019 100644 --- a/almanac-web/src/agent.js +++ b/almanac-web/src/agent.js @@ -1,6 +1,6 @@ import superagentPromise from 'superagent-promise'; import _superagent from 'superagent'; -import { PAGE_SIZE } from './constants/commonConstants' +import { DEFAULT_PAGE_SIZE } from './constants/commonConstants' const superagent = superagentPromise(_superagent, global.Promise); @@ -12,9 +12,13 @@ const responseBody = res => res.body; let token = null; const tokenPlugin = req => { if (token) { - req.set('authorization', `Token ${token}`); + req.set('authorization', `Bearer ${token}`); } } +let basicAuth = "YWNtZTphY21lc2VjcmV0"; +const basicPlugin = req => { + req.set('authorization', `Basic ${basicAuth}`) +} const requests = { del: url => @@ -24,8 +28,11 @@ const requests = { put: (url, body) => superagent.put(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody), post: (url, body) => - superagent.post(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody) + superagent.post(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody), + postWithBasic: (url, body) => + superagent.post(`${API_ROOT}${url}?grant_type=password&username=${body.username}&password=${body.password}`).use(basicPlugin).then(responseBody) }; + const directRequest = { del: url => superagent.del(`${url}`).use(tokenPlugin).then(responseBody), @@ -39,9 +46,9 @@ const directRequest = { const Auth = { current: () => - requests.get('/user'), + requests.get('/users/user'), login: (email, password) => - requests.post('/users/login', { user: { email, password } }), + requests.postWithBasic('/oauth/token', { username: email, password: password }), register: (username, email, password) => requests.post('/users', { username, email, password }), save: user => @@ -53,10 +60,9 @@ const Tags = { }; const limit = (size, page) => `size=${size}&page=${page ? page : 0}`; -const omitSlug = article => Object.assign({}, article, { slug: undefined }) const Books = { all: page => - requests.get(`/books?${limit(PAGE_SIZE, page)}`), + requests.get(`/books?${limit(localStorage.getItem("page_size") ? localStorage.getItem("page_size") : DEFAULT_PAGE_SIZE, page)}`), del: slug => requests.del(`/books/${slug}`), get: slug => @@ -78,12 +84,10 @@ const Articles = { }; const Profile = { - follow: username => - requests.post(`/profiles/${username}/follow`), get: id => requests.get(`/users/${id}`), - unfollow: username => - requests.del(`/profiles/${username}/follow`) + me: () => + requests.get(`/users/me`) }; export default { diff --git a/almanac-web/src/components/HeaderList.js b/almanac-web/src/components/HeaderList.js index f6d0023..9d6ed35 100644 --- a/almanac-web/src/components/HeaderList.js +++ b/almanac-web/src/components/HeaderList.js @@ -1,57 +1,54 @@ import React from 'react'; import { Link } from 'react-router-dom'; - const HeaderList = props => { - return ( -
    -
  • - - Home - -
  • +class HeaderList extends React.Component { + render() { + let props = this.props; + return ( +
      - {!props.currentUser &&
    • - - Sign in - -
    • } - - {!props.currentUser && -
    • - - Sign up - -
    • - } - - {props.currentUser && -
    • - -  New Post + + Home
    • - } - {props.currentUser && -
    • - -  Settings - -
    • - } - {props.currentUser && -
    • - - {props.currentUser.username} - {props.currentUser.username} + {!props.currentUser && +
    • + + Sign in -
    • - } -
    - ); + } + + {!props.currentUser && +
  • + + Sign up + +
  • + } + + {props.currentUser && +
  • + + {/* {props.currentUser.username} */} + {props.currentUser.username} + +
  • + } + {props.currentUser && + + } +
+ ); + } } + export default HeaderList; diff --git a/almanac-web/src/components/ListPagination.js b/almanac-web/src/components/ListPagination.js index 4296588..f0d56b7 100644 --- a/almanac-web/src/components/ListPagination.js +++ b/almanac-web/src/components/ListPagination.js @@ -2,7 +2,7 @@ import React from 'react'; import agent from '../agent'; import { connect } from 'react-redux'; import { SET_PAGE } from '../constants/actionTypes'; -import { PAGE_SIZE } from '../constants/commonConstants'; +import { DEFAULT_PAGE_SIZE } from '../constants/commonConstants'; const mapDispatchToProps = dispatch => ({ onSetPage: (page, payload) => @@ -10,7 +10,10 @@ const mapDispatchToProps = dispatch => ({ }); const ListPagination = props => { - if (props.booksCount <= PAGE_SIZE) { + let pageSize = localStorage.getItem("page_size") + ? localStorage.getItem("page_size") + : DEFAULT_PAGE_SIZE; + if (props.booksCount <= pageSize) { return null; } @@ -20,7 +23,7 @@ const ListPagination = props => { } const setPage = page => { - props.onSetPage(page, agent.Books.all(page)) + props.onSetPage(page, agent.Books.all(page)) }; return ( @@ -36,7 +39,7 @@ const ListPagination = props => { }; return (
  • @@ -46,7 +49,6 @@ const ListPagination = props => { ); }) } - ); diff --git a/almanac-web/src/constants/actionTypes.js b/almanac-web/src/constants/actionTypes.js index f8cbc0a..dd49735 100644 --- a/almanac-web/src/constants/actionTypes.js +++ b/almanac-web/src/constants/actionTypes.js @@ -42,4 +42,6 @@ export const CREATE_ARTICLE = 'CREATE_ARTICLE'; export const DELETE_ARTICLE = 'DELETE_ARTICLE'; export const UPDATE_ARTICLE = 'UPDATE_ARTICLE'; export const BOOK_UPDATED = 'BOOK_UPDATED'; +export const SIGN_IN = 'SIGN_IN'; + diff --git a/almanac-web/src/constants/commonConstants.js b/almanac-web/src/constants/commonConstants.js index 2f38546..54c4a4a 100644 --- a/almanac-web/src/constants/commonConstants.js +++ b/almanac-web/src/constants/commonConstants.js @@ -1 +1 @@ -export const PAGE_SIZE = 2; \ No newline at end of file +export const DEFAULT_PAGE_SIZE = 2; \ No newline at end of file diff --git a/almanac-web/src/constants/config.js b/almanac-web/src/constants/config.js new file mode 100644 index 0000000..a9649da --- /dev/null +++ b/almanac-web/src/constants/config.js @@ -0,0 +1,9 @@ + +export const providerConfig = { + clientId : 'react-client', + redirectUri : window.location.origin + '/login', + authorizationUrl: 'http://localhost:8080/login/github', + scope :'', + width : 1080, + height : 640 + }; \ No newline at end of file diff --git a/almanac-web/src/containers/Article/ArticleCreate.js b/almanac-web/src/containers/Article/ArticleCreate.js index e606670..e8ebe6f 100644 --- a/almanac-web/src/containers/Article/ArticleCreate.js +++ b/almanac-web/src/containers/Article/ArticleCreate.js @@ -1,10 +1,7 @@ import React from 'react'; import agent from '../../agent'; -import { - CREATE_ARTICLE -} from '../../constants/actionTypes'; -import { Editor } from '@tinymce/tinymce-react'; import RichTextEditor from 'react-rte'; +import axios from 'axios'; class ArticleCreate extends React.Component { constructor() { @@ -14,6 +11,8 @@ class ArticleCreate extends React.Component { } this.updateField = this.updateField.bind(this); this.createArticle = this.createArticle.bind(this); + this.parseFile = this.parseFile.bind(this); + this.updateFile = this.updateFile.bind(this); }; createArticle() { var article = this.state.article; @@ -23,14 +22,37 @@ class ArticleCreate extends React.Component { this.setState({ article: { content: RichTextEditor.createEmptyValue() } }); this.props.history.push(`/books/${this.props.match.params.bookId}/edit`) }; + parseFile() { + if (this.state.file) { + const url = `http://localhost:8080/files`; + const formData = new FormData(); + formData.append('file', this.state.file) + const config = { + headers: { + 'content-type': 'multipart/form-data' + } + } + axios.post(url, formData, config) + .then(response => { + let newArticle = this.state.article; + newArticle.content =response.data.content? RichTextEditor.createValueFromString(response.data.content, "markdown") : RichTextEditor.createEmptyValue(); + this.setState({ + ...this.state, article: newArticle + }) + }); + } + }; + updateFile(event) { + this.setState({ file: event.target.files[0] }) + } updateField(event) { var article = { ...this.state.article, [event.target.name]: event.target.value }; this.setState( { article: article } ); - } + } onChangeRte = (value) => { - var article = { ...this.state.article,content: value }; + var article = { ...this.state.article, content: value }; this.setState({ article: article }); }; render() { @@ -38,36 +60,43 @@ class ArticleCreate extends React.Component { if (this.state.redirectTo) { } return ( -
    -
    - - -
    -
    - - -
    -
    - -
    -
    - +
    +
    + + - - + ); } } diff --git a/almanac-web/src/containers/Article/ArticleEdit.js b/almanac-web/src/containers/Article/ArticleEdit.js index ccd1ca0..85b1dc6 100644 --- a/almanac-web/src/containers/Article/ArticleEdit.js +++ b/almanac-web/src/containers/Article/ArticleEdit.js @@ -33,6 +33,7 @@ class ArticleCreate extends React.Component { } componentWillReceiveProps(newProps) { var article = { ...newProps.article }; + article.content = article.content ? RichTextEditor.createValueFromString(article.content, "markdown") : RichTextEditor.createEmptyValue(); this.setState({ article: article }) } diff --git a/almanac-web/src/containers/Article/ArticleList.js b/almanac-web/src/containers/Article/ArticleList.js index a2634e1..ceca430 100644 --- a/almanac-web/src/containers/Article/ArticleList.js +++ b/almanac-web/src/containers/Article/ArticleList.js @@ -7,7 +7,8 @@ import axios from 'axios'; const mapStateToProps = state => ({ ...state, link: state.link, - currentUser: state.common.currentUser + currentUser: state.common.currentUser, + token:state.common.token }); class ArticleList extends React.Component { constructor() { @@ -18,9 +19,17 @@ class ArticleList extends React.Component { } } componentWillMount() { - if (this.props.book) { - axios.get(this.props.book.book.articles.href) - .then(response => this.setState({ ...this.state, articles: response.data })) + if (this.props.book + && this.props.book.book.articles + && this.props.book.book.articles.href) { + const req = { + url: this.props.book.book.articles.href, + headers: { Authorization: "bearer " + this.props.token }, + method: 'GET' + }; + axios.get(req.url,req) + .then(response => { + this.setState({ ...this.state, articles: response.data })}) } } @@ -45,7 +54,7 @@ class ArticleList extends React.Component { { articles.map(article => { return ( -
  • +
  • ); }) } diff --git a/almanac-web/src/containers/Article/ArticlePreview.js b/almanac-web/src/containers/Article/ArticlePreview.js index 3294f3a..78a94ab 100644 --- a/almanac-web/src/containers/Article/ArticlePreview.js +++ b/almanac-web/src/containers/Article/ArticlePreview.js @@ -13,7 +13,7 @@ const ArticlePreview = props => {
    {article.title}
    {article.created && -
    } +
    } diff --git a/almanac-web/src/containers/Article/index.js b/almanac-web/src/containers/Article/index.js index bd53cf9..a1cd720 100644 --- a/almanac-web/src/containers/Article/index.js +++ b/almanac-web/src/containers/Article/index.js @@ -50,11 +50,9 @@ class Book extends React.Component {
    edit -
    - -
    + + To book
    - to book
    @@ -63,11 +61,16 @@ class Book extends React.Component { {article.annotation}

    -
    -
    -
    -

    -
    + {article.pageCount && +
    +

    Page count: {article.pageCount}

    +
    } +
    + +
    +
    +
    +

    diff --git a/almanac-web/src/containers/Authentication/Login.js b/almanac-web/src/containers/Authentication/Login.js index e0fa092..147b1f2 100644 --- a/almanac-web/src/containers/Authentication/Login.js +++ b/almanac-web/src/containers/Authentication/Login.js @@ -6,8 +6,11 @@ import { connect } from 'react-redux'; import { UPDATE_FIELD_AUTH, LOGIN, - LOGIN_PAGE_UNLOADED + LOGIN_PAGE_UNLOADED, + SIGN_IN } from '../../constants/actionTypes'; +import OAuthProvider from './OAuthProvider'; +import { providerConfig } from '../../constants/config'; const mapStateToProps = state => ({ ...state.auth }); @@ -33,6 +36,17 @@ class Login extends React.Component { }; } + onOAuthProviderLogin = (data) => { + let token = JSON.stringify(data.code) || JSON.stringify(data); + window.localStorage.setItem('OAuthProvider_token', token); + this.setState({ token: token }); + window.location = "/home" + } + + onOAuthProviderLoginFailure = (err) => { + console.log("something wrong") + console.error(err); + } componentWillUnmount() { this.props.onUnload(); } @@ -82,9 +96,12 @@ class Login extends React.Component { disabled={this.props.inProgress}> Sign in - + diff --git a/almanac-web/src/containers/Authentication/OAuth2.js b/almanac-web/src/containers/Authentication/OAuth2.js new file mode 100644 index 0000000..f26bf12 --- /dev/null +++ b/almanac-web/src/containers/Authentication/OAuth2.js @@ -0,0 +1,41 @@ +import React, { Component } from 'react'; +import Popup from './Popup'; +import qs from 'querystring'; + +class OAuth2 extends Component { + constructor (props) { + super(props); + this.state = { popupOpen: false }; + this.handleClick = this.handleClick.bind(this); + } + + handleClick () { + this.setState({ popupOpen: true }); + console.log('clicked on button'); + } + + render () { + const props = this.props; + + const childrenWithProps = React.Children.map(props.children, (child) => { + return React.cloneElement(child, { onClick: this.handleClick }); + }); + + const params = { + client_id: props.clientId, + redirect_uri: props.redirectUri, + scope: props.scope, + display: 'popup', + response_type: 'token' + }; + + const url = props.authorizationUrl + + return
    + + {childrenWithProps} +
    ; + } +} + +export default OAuth2; diff --git a/almanac-web/src/containers/Authentication/OAuthProvider.js b/almanac-web/src/containers/Authentication/OAuthProvider.js new file mode 100644 index 0000000..09e55ee --- /dev/null +++ b/almanac-web/src/containers/Authentication/OAuthProvider.js @@ -0,0 +1,20 @@ +import React from 'react'; +import OAuth2 from './OAuth2'; + +export const OAuthProvider = props => { + const { config, textDisplay, className, successCallback, errorCallback } = props; + config.successCallback = successCallback; + config.errorCallback = errorCallback; + return ( + + + + ); +}; + +OAuthProvider.defaultProps = { + textDisplay: 'Sign in with OAuthProvider' +}; + + +export default OAuthProvider; diff --git a/almanac-web/src/containers/Authentication/Popup.js b/almanac-web/src/containers/Authentication/Popup.js new file mode 100644 index 0000000..e06e259 --- /dev/null +++ b/almanac-web/src/containers/Authentication/Popup.js @@ -0,0 +1,88 @@ +import React from 'react'; +import qs from 'querystring'; +import url from 'url'; +import Promise from 'bluebird'; + +class Popup extends React.Component { + constructor(props) { + super(props); + } + + componentDidUpdate() { + if (this.props.open) { + this.openPopup(); + } + } + + openPopup() { + const props = this.props; + const width = props.width || 500; + const height = props.height || 500; + + const options = { + width: width, + height: height, + top: window.screenY + ((window.outerHeight - height) / 2.5), + left: window.screenX + ((window.outerWidth - width) / 2) + }; + + const popup = window.open(props.popupUrl, '_blank', qs.stringify(options, ',')); + + if (props.popupUrl === 'about:blank') { + popup.document.body.innerHTML = 'Loading...'; + } + + this.pollPopup(popup).then(props.successCallback).catch(props.errorCallback); + } + + pollPopup(window) { + const props = this.props; + + return new Promise((resolve, reject) => { + const redirectUri = url.parse(props.redirectUri); + const redirectUriPath = redirectUri.host + redirectUri.pathname; + + const polling = setInterval(() => { + if (!window || window.closed || window.closed === undefined) { + clearInterval(polling); + reject(new Error('The popup window was closed')); + } + try { + const popupUrlPath = window.location.host + window.location.pathname; + + if (popupUrlPath === redirectUriPath) { + if (window.location.search || window.location.hash) { + const query = qs.parse(window.location.search.substring(1).replace(/\/$/, '')); + const hash = qs.parse(window.location.hash.substring(1).replace(/[\/$]/, '')); + const params = Object.assign({}, query, hash); + if (params.error) { + reject(new Error(params.error)); + } else { + resolve(params); + } + } else { + reject(new Error('OAuth redirect has occurred but no query or hash parameters were found.')); + } + // cleanup + clearInterval(polling); + window.close(); + } + } catch (error) { + // Ignore DOMException: Blocked a frame with origin from accessing a cross-origin frame. + // A hack to get around same-origin security policy errors in Internet Explorer. + } + }, 500); + }); + } + + handleClick() { + console.log('clicked on button'); + } + + render() { + return null; + } +} + + +export default Popup; diff --git a/almanac-web/src/containers/Book/BookCreate.js b/almanac-web/src/containers/Book/BookCreate.js index 47f2039..eeee641 100644 --- a/almanac-web/src/containers/Book/BookCreate.js +++ b/almanac-web/src/containers/Book/BookCreate.js @@ -6,10 +6,12 @@ import { connect } from 'react-redux'; import { CREATE_BOOK } from '../../constants/actionTypes'; +import ListErrors from '../Authentication/ListErrors'; const mapStateToProps = state => ({ ...state.book, - currentUser: state.common.currentUser + currentUser: state.common.currentUser, + token: state.common.token }); const mapDispatchToProps = dispatch => ({ onSubmit: payload => @@ -23,16 +25,63 @@ class BookCreate extends React.Component { } this.updateField = this.updateField.bind(this); this.createBook = this.createBook.bind(this); + this.parseFile = this.parseFile.bind(this); + this.updateFile = this.updateFile.bind(this); }; createBook() { var book = this.state.book; book.author = this.props.currentUser; const payload = agent.Books.create( { ...book }); - this.setState({ book: {}}); - + this.setState({ book: {} }); + this.props.history.push(`/`) }; + parseFile() { + if (this.state.file) { + const url = `http://localhost:8080/files/books`; + const formData = new FormData(); + formData.append('file', this.state.file) + const config = { + headers: { + 'content-type': 'multipart/form-data', + Authorization: "bearer " + this.props.token + } + } + axios.post(url, formData, config) + .then(response => { + let book = response.data; + this.setState({ ...this.state, book: book }); + + this.props.history.push(`/books/${book.bookId}`) + }) + .catch( + error => { + if (error.response) { + switch (error.response.status) { + case 400: + this.setState({ error: error.response.data.content }); + break; + case 500: + alert("Ka-boom") + break; + } + + } else if (error.request) { + alert("Server doesn't send response") + console.log(error.request); + } else { + // Something happened in setting up the request that triggered an Error + console.log('Error', error.message); + } + + } + ); + } + }; + updateFile(event) { + this.setState({ file: event.target.files[0] }) + } updateField(event) { var book = { ...this.state.book, [event.target.name]: event.target.value }; this.setState( @@ -41,49 +90,61 @@ class BookCreate extends React.Component { } render() { var book = this.props.book; - + return ( -
    -
    - - -
    -
    - -
    -
    - -
    +
    + +
    + + +
    +
    + +
    +
    + +
    -
    - +
    + +
    + {this.state.error && +
    + {this.state.error} +
    } + +
    - - +
    ); } } diff --git a/almanac-web/src/containers/Book/BookList.js b/almanac-web/src/containers/Book/BookList.js index 75dbb1e..13a02aa 100644 --- a/almanac-web/src/containers/Book/BookList.js +++ b/almanac-web/src/containers/Book/BookList.js @@ -8,6 +8,12 @@ const BookList = props => {
    Loading...
    ); } + const changeSize = e => { + console.dir(e.target.value) + localStorage.setItem("page_size", e.target.value) + this.state = { size: e.target.value } + + } if (props.books.length === 0) { return ( @@ -18,11 +24,19 @@ const BookList = props => { } return ( +
    + + { props.books.map(book => { return ( - + ); }) } diff --git a/almanac-web/src/containers/Book/BookPreview.js b/almanac-web/src/containers/Book/BookPreview.js index 785c4ae..d75920a 100644 --- a/almanac-web/src/containers/Book/BookPreview.js +++ b/almanac-web/src/containers/Book/BookPreview.js @@ -2,11 +2,11 @@ import React from 'react'; import { Link } from 'react-router-dom'; import axios from 'axios'; import { connect } from 'react-redux'; -import Profilepreview from '../Profile/ProfilePreview'; const mapStateToProps = state => ({ ...state.book, - currentUser: state.common.currentUser + currentUser: state.auth.currentUser, + token: state.common.token }); class BookPreview extends React.Component { @@ -17,8 +17,15 @@ class BookPreview extends React.Component { } } componentWillMount() { - if (this.props.book) { - axios.get(this.props.book.author && this.props.book.author.href) + if (this.props.book + && this.props.book.author + && this.props.book.author.href) { + let req = { + url: this.props.book.author.href, + headers: { Authorization: "bearer " + this.props.token }, + method: 'GET' + }; + axios.get(req.url, req) .then(response => { this.setState({ ...this.state, author: response.data }) }) @@ -33,10 +40,13 @@ class BookPreview extends React.Component {
    -

    {book.title}

    +
    {book.title}

    -

    {book.description}

    +

    {book.description + && (book.description.length < 120 + ? book.description + : book.description.substring(0, 120) + "...")}

    diff --git a/almanac-web/src/containers/Book/index.js b/almanac-web/src/containers/Book/index.js index d8f3dd5..c0b9a52 100644 --- a/almanac-web/src/containers/Book/index.js +++ b/almanac-web/src/containers/Book/index.js @@ -12,7 +12,8 @@ import axios from 'axios'; const mapStateToProps = state => ({ ...state.book, - currentUser: state.common.currentUser + currentUser: state.common.currentUser, + token: state.common.token }); const mapDispatchToProps = dispatch => ({ @@ -30,7 +31,17 @@ class Book extends React.Component { } downloadBook() { var links = this.props.book._links; - axios.get(links && links.download.href) + + if (links + && links.download + && links.download.href) { + let req = { + url: links.download.href, + headers: { Authorization: "bearer " + this.props.token }, + method: 'GET' + } + axios.get(req) + } } componentWillMount() { var getBook = agent.Books.get(this.props.match.params.id); @@ -57,7 +68,7 @@ class Book extends React.Component { {this.props.book._links && this.props.book._links.download &&
    download} @@ -85,7 +96,7 @@ class Book extends React.Component {
    -

    Size: {book.size}

    +

    Size: {book.pageCount}

    State: {book.state}

    diff --git a/almanac-web/src/containers/Profile/ProfilePreview.js b/almanac-web/src/containers/Profile/ProfilePreview.js index 2dc0ff9..0bce4db 100644 --- a/almanac-web/src/containers/Profile/ProfilePreview.js +++ b/almanac-web/src/containers/Profile/ProfilePreview.js @@ -6,7 +6,8 @@ import auth from '../../reducers/auth'; const mapStateToProps = state => ({ ...state.book, - currentUser: state.common.currentUser + currentUser: state.common.currentUser, + token: state.common.token }); class ProfilePreview extends React.Component { @@ -19,7 +20,12 @@ class ProfilePreview extends React.Component { componentWillMount() { console.dir(this.props.link) if (this.props.link) { - axios.get(this.props.link) + const req = { + url: this.props.link, + headers: { Authorization: "bearer " + this.props.token }, + method: 'GET' + }; + axios.get(req.url,req) .then(response => this.setState({ ...this.state, author: response.data })) } } diff --git a/almanac-web/src/containers/Profile/index.js b/almanac-web/src/containers/Profile/index.js index a6a50e7..edf56f5 100644 --- a/almanac-web/src/containers/Profile/index.js +++ b/almanac-web/src/containers/Profile/index.js @@ -6,7 +6,6 @@ import { PROFILE_PAGE_LOADED, PROFILE_PAGE_UNLOADED } from '../../constants/actionTypes'; -import axios from 'axios'; import { Link } from 'react-router-dom'; const mapStateToProps = state => ({ @@ -33,8 +32,6 @@ class Profile extends React.Component { componentWillMount() { var getProfile = agent.Profile.get(this.props.match.params.id); this.props.onLoad(Promise.all([getProfile])); - console.dir(this.props) - } componentWillUnmount() { this.props.onUnload(); @@ -80,8 +77,8 @@ class Profile extends React.Component {
    Books as coauthor:
    {booksAsSubAuthor.length === 0 &&
    There is no any book as coauthor
    - } - + } +
      { booksAsSubAuthor && booksAsSubAuthor.map(book =>
    1. @@ -91,6 +88,8 @@ class Profile extends React.Component {
    2. )}
    + + Create book
    diff --git a/almanac-web/src/middleware.js b/almanac-web/src/middleware.js index 070ad03..7497d91 100644 --- a/almanac-web/src/middleware.js +++ b/almanac-web/src/middleware.js @@ -47,13 +47,15 @@ const promiseMiddleware = store => next => action => { }; const localStorageMiddleware = store => next => action => { - if (action.type === REGISTER || action.type === LOGIN) { + if (action.type === LOGIN) { if (!action.error) { - window.localStorage.setItem('jwt', action.payload.user.token); - agent.setToken(action.payload.user.token); + window.localStorage.setItem('access_token', action.payload.access_token); + window.localStorage.setItem('refresh_token', action.payload.refresh_token); + agent.setToken(action.payload.access_token); } } else if (action.type === LOGOUT) { - window.localStorage.setItem('jwt', ''); + window.localStorage.setItem('access_token',''); + window.localStorage.setItem('refresh_token',''); agent.setToken(null); } diff --git a/almanac-web/src/reducers/auth.js b/almanac-web/src/reducers/auth.js index 6e83838..d3b63ea 100644 --- a/almanac-web/src/reducers/auth.js +++ b/almanac-web/src/reducers/auth.js @@ -4,7 +4,8 @@ import { LOGIN_PAGE_UNLOADED, REGISTER_PAGE_UNLOADED, ASYNC_START, - UPDATE_FIELD_AUTH + UPDATE_FIELD_AUTH, + SIGN_IN } from '../constants/actionTypes'; export default (state = {}, action) => { diff --git a/almanac-web/src/reducers/book.js b/almanac-web/src/reducers/book.js index a57b8e1..958c04e 100644 --- a/almanac-web/src/reducers/book.js +++ b/almanac-web/src/reducers/book.js @@ -11,29 +11,11 @@ export default (state = {}, action) => { var book = action.payload[0]; return { ...state, - book: book + book }; } - case CREATE_ARTICLE: - return { - ...state, redirectTo: '/' - }; case BOOK_PAGE_UNLOADED: return {}; - // case ADD_COMMENT: - // return { - // ...state, - // commentErrors: action.error ? action.payload.errors : null, - // comments: action.error ? - // null : - // (state.comments || []).concat([action.payload.comment]) - // }; - // case DELETE_COMMENT: - // const commentId = action.commentId - // return { - // ...state, - // comments: state.comments.filter(comment => comment.id !== commentId) - // }; default: return state; } diff --git a/almanac-web/src/reducers/bookList.js b/almanac-web/src/reducers/bookList.js index 1c5e26d..2a30331 100644 --- a/almanac-web/src/reducers/bookList.js +++ b/almanac-web/src/reducers/bookList.js @@ -16,15 +16,17 @@ export default (state = {}, action) => { currentPage: action.payload.page.number }; case HOME_PAGE_LOADED: - return { - ...state, - pager: action.payload[0].page, - books: action.payload[0]._embedded - ? action.payload[0]._embedded.bookResponseList - : [], - booksCount: action.payload[0].page.totalElements, - currentPage: 0 - }; + return !action.error ? + { + ...state, + pager: action.payload[0].page, + books: action.payload[0]._embedded + ? action.payload[0]._embedded.bookResponseList + : [], + booksCount: action.payload[0].page.totalElements, + currentPage: 0 + } + : { ...state, error: action.payload }; case HOME_PAGE_UNLOADED: return {}; default: diff --git a/almanac-web/src/reducers/common.js b/almanac-web/src/reducers/common.js index fb9b83d..2ee4e07 100644 --- a/almanac-web/src/reducers/common.js +++ b/almanac-web/src/reducers/common.js @@ -14,7 +14,8 @@ import { PROFILE_FAVORITES_PAGE_UNLOADED, SETTINGS_PAGE_UNLOADED, LOGIN_PAGE_UNLOADED, - REGISTER_PAGE_UNLOADED + REGISTER_PAGE_UNLOADED, + SIGN_IN } from '../constants/actionTypes'; const defaultState = { @@ -29,13 +30,17 @@ export default (state = defaultState, action) => { return { ...state, token: action.token || null, - appLoaded: true, - currentUser: action.payload ? action.payload.user : null + appLoaded: true }; case REDIRECT: return { ...state, redirectTo: null }; case LOGOUT: return { ...state, redirectTo: '/', token: null, currentUser: null }; + case SIGN_IN: + return { + ...state, + currentUser: action.payload + }; case ARTICLE_SUBMITTED: const redirectUrl = `/article/${action.payload.article.slug}`; return { ...state, redirectTo: redirectUrl }; @@ -50,7 +55,7 @@ export default (state = defaultState, action) => { return { ...state, redirectTo: action.error ? null : '/', - token: action.error ? null : action.payload.user.token, + token: action.error ? null : action.payload.access_token, currentUser: action.error ? null : action.payload.user }; case DELETE_ARTICLE: diff --git a/build.gradle b/build.gradle index 3d950a6..e16826d 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,13 @@ dependencies { 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.oauth.boot:spring-security-oauth2-autoconfigure:2.1.3.RELEASE") + compile("org.apache.poi:poi:4.1.0") + 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("com.h2database:h2") 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..17da984 --- /dev/null +++ b/src/main/java/fic/writer/domain/audit/SpringSecurityAuditorAware.java @@ -0,0 +1,24 @@ +package fic.writer.domain.audit; + +import fic.writer.domain.entity.User; +import fic.writer.domain.service.UserService; +import fic.writer.web.config.security.authorization.CustomUserDetails; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.AuditorAware; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +public class SpringSecurityAuditorAware implements AuditorAware { + @Autowired + private UserService userService; + + @Override + public Optional getCurrentAuditor() { + Optional user = Optional.ofNullable(((CustomUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getUser()); + return user; + } +} + diff --git a/src/main/java/fic/writer/domain/entity/Article.java b/src/main/java/fic/writer/domain/entity/Article.java index 5e1f6f1..c701389 100644 --- a/src/main/java/fic/writer/domain/entity/Article.java +++ b/src/main/java/fic/writer/domain/entity/Article.java @@ -1,6 +1,7 @@ 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; @@ -17,6 +18,7 @@ @Builder @EntityListeners(AuditingEntityListener.class) public class Article { + private static final int CHARS_IN_PAGE = 1800; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -25,11 +27,13 @@ public class Article { private Date created; @LastModifiedDate private Date lastModify; - @Column(columnDefinition = "text") + @Column(columnDefinition = "LONGTEXT") private String content; private String annotation; @ManyToOne(fetch = FetchType.LAZY) private Book book; @OneToMany(cascade = CascadeType.REMOVE) private Set actorStates; + @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 index d3ad3e8..0c5b495 100644 --- a/src/main/java/fic/writer/domain/entity/Book.java +++ b/src/main/java/fic/writer/domain/entity/Book.java @@ -3,6 +3,8 @@ 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 java.util.Set; @@ -13,12 +15,14 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@EntityListeners(AuditingEntityListener.class) public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @ManyToOne + @CreatedBy private User author; @ManyToMany(fetch = FetchType.LAZY) @JoinTable(name = "book_subauthors", @@ -30,14 +34,17 @@ public class Book { @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.EAGER, orphanRemoval = true) + @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")}, @@ -51,4 +58,17 @@ public class Book { ) @Singular("actors") private Set actors; + + @PostLoad + private void calculatePageCount() { + this.pageCount = articles.stream().mapToLong(Article::getPageCount).sum(); + } + + @PostPersist + private void updateAuthor() { + if (author != null) { + + } + } + } diff --git a/src/main/java/fic/writer/domain/entity/User.java b/src/main/java/fic/writer/domain/entity/User.java index d84375a..d53b816 100644 --- a/src/main/java/fic/writer/domain/entity/User.java +++ b/src/main/java/fic/writer/domain/entity/User.java @@ -24,4 +24,5 @@ public class User { @OneToMany(fetch = FetchType.LAZY) @Singular("booksAsAuthor") private Set booksAsAuthor; + private String email; } diff --git a/src/main/java/fic/writer/domain/entity/auth/CustomUser.java b/src/main/java/fic/writer/domain/entity/auth/CustomUser.java index 22f2961..e583939 100644 --- a/src/main/java/fic/writer/domain/entity/auth/CustomUser.java +++ b/src/main/java/fic/writer/domain/entity/auth/CustomUser.java @@ -17,6 +17,5 @@ public class CustomUser { private Long id; @OneToOne(fetch = FetchType.EAGER) private User profile; - private String email; private String password; } diff --git a/src/main/java/fic/writer/domain/entity/auth/OauthUser.java b/src/main/java/fic/writer/domain/entity/auth/OauthUser.java index d9c38b4..4369f7a 100644 --- a/src/main/java/fic/writer/domain/entity/auth/OauthUser.java +++ b/src/main/java/fic/writer/domain/entity/auth/OauthUser.java @@ -16,7 +16,7 @@ public class OauthUser { @Id @GeneratedValue private Long id; - @OneToOne(fetch = FetchType.EAGER) + @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL) private User profile; private String token; private Date expireDate; diff --git a/src/main/java/fic/writer/domain/entity/dto/BookSearchDto.java b/src/main/java/fic/writer/domain/entity/dto/BookSearchDto.java new file mode 100644 index 0000000..04666d2 --- /dev/null +++ b/src/main/java/fic/writer/domain/entity/dto/BookSearchDto.java @@ -0,0 +1,4 @@ +package fic.writer.domain.entity.dto; + +public class BookSearchDto { +} 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/CustomUserRepository.java b/src/main/java/fic/writer/domain/repository/CustomUserRepository.java new file mode 100644 index 0000000..ec897a1 --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/CustomUserRepository.java @@ -0,0 +1,10 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.auth.CustomUser; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CustomUserRepository extends JpaRepository { + Optional findByProfileId(Long id); +} diff --git a/src/main/java/fic/writer/domain/repository/OauthUserRepository.java b/src/main/java/fic/writer/domain/repository/OauthUserRepository.java new file mode 100644 index 0000000..56e26d3 --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/OauthUserRepository.java @@ -0,0 +1,7 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.auth.OauthUser; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OauthUserRepository extends JpaRepository { +} diff --git a/src/main/java/fic/writer/domain/repository/UserRepository.java b/src/main/java/fic/writer/domain/repository/UserRepository.java index 361fabf..131e193 100644 --- a/src/main/java/fic/writer/domain/repository/UserRepository.java +++ b/src/main/java/fic/writer/domain/repository/UserRepository.java @@ -7,4 +7,6 @@ public interface UserRepository extends JpaRepository { Optional findByUsername(String username); + + Optional findByEmail(String email); } diff --git a/src/main/java/fic/writer/domain/service/ArticleService.java b/src/main/java/fic/writer/domain/service/ArticleService.java index 8129e9a..214eee0 100644 --- a/src/main/java/fic/writer/domain/service/ArticleService.java +++ b/src/main/java/fic/writer/domain/service/ArticleService.java @@ -4,6 +4,7 @@ import fic.writer.domain.entity.dto.ArticleDto; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.web.multipart.MultipartFile; import java.util.List; import java.util.Optional; @@ -22,4 +23,6 @@ public interface ArticleService { void delete(Article article); void deleteById(Long aLong); + + String parseArticleContentFromFile(MultipartFile multipartFile); } 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/OauthUserService.java b/src/main/java/fic/writer/domain/service/OauthUserService.java new file mode 100644 index 0000000..b0c9ddc --- /dev/null +++ b/src/main/java/fic/writer/domain/service/OauthUserService.java @@ -0,0 +1,6 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.auth.OauthUser; + +public interface OauthUserService extends CrudService { +} diff --git a/src/main/java/fic/writer/domain/service/UserService.java b/src/main/java/fic/writer/domain/service/UserService.java index 5df0850..10061aa 100644 --- a/src/main/java/fic/writer/domain/service/UserService.java +++ b/src/main/java/fic/writer/domain/service/UserService.java @@ -17,8 +17,12 @@ public interface UserService { Optional findByUsername(String username); + Optional findByEmail(String email); + User create(UserDto user); + User addBookAsAuthor(Long userId, Long bookId); + User update(Long userId, UserDto user); void delete(User user); 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..f30c26d --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/ArticleParser.java @@ -0,0 +1,9 @@ +package fic.writer.domain.service.files; + +public interface ArticleParser { + String getTitle(); + + String getAnnotation(); + + String getContent(); +} 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..e92fa57 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/BookParser.java @@ -0,0 +1,19 @@ +package fic.writer.domain.service.files; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.User; +import fic.writer.domain.entity.enums.State; + +import java.util.Set; + +public interface BookParser { + String getTitle(); + + Set getCoAuthors(); + + String getDescription(); + + State getState(); + + Set
    getArticles(); +} 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..dc18ea9 --- /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) { + String fileContent = ""; + try { + XWPFDocument document = new XWPFDocument(file.getInputStream()); + List paragraphs = document.getParagraphs(); + for (int i = 0; i < paragraphs.size(); i++) { + fileContent += paragraphs.get(i).getParagraphText(); + } + } catch (IOException e) { + throw new RuntimeException(); + } + return fileContent; + } +} 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..0105fa2 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/impl/JsonArticleParser.java @@ -0,0 +1,40 @@ +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..eedeafa --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/impl/XmlArticleParser.java @@ -0,0 +1,57 @@ +package fic.writer.domain.service.files.impl; + +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; + + public XmlArticleParser(MultipartFile multipartFile) { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder dBuilder = null; + Document doc; + try { + dBuilder = dbFactory.newDocumentBuilder(); + doc = dBuilder.parse(multipartFile.getInputStream()); + } catch (ParserConfigurationException e) { + e.printStackTrace(); + throw new RuntimeException(); + } catch (SAXException e) { + e.printStackTrace(); + throw new RuntimeException(); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + doc.getDocumentElement().normalize(); + element = doc.getDocumentElement(); + String title = element.getElementsByTagName("book-title").item(0).getFirstChild().getNodeValue(); + String annotation = element.getElementsByTagName("annotation").item(0).getFirstChild().getNodeValue(); + } + + @Override + public String getTitle() { + String title = element.getElementsByTagName("title").item(0).getFirstChild().getNodeValue(); + return title; + } + + @Override + public String getAnnotation() { + String annotation = element.getElementsByTagName("annotation").item(0).getFirstChild().getNodeValue(); + return null; + } + + @Override + public String getContent() { + String annotation = element.getElementsByTagName("section").item(0).getFirstChild().getNodeValue(); + return null; + } +} 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..6afab7b --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/impl/XmlBookParser.java @@ -0,0 +1,138 @@ +package fic.writer.domain.service.files.impl; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.User; +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.HashSet; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; + +public class XmlBookParser implements BookParser { + private Document doc; + private XPath xPath; + + public XmlBookParser(MultipartFile file) { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder dBuilder = null; + try { + dBuilder = dbFactory.newDocumentBuilder(); + doc = dBuilder.parse(file.getInputStream()); + } catch (ParserConfigurationException e) { + e.printStackTrace(); + throw new RuntimeException(); + } catch (SAXException e) { + e.printStackTrace(); + throw new RuntimeException(); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + + xPath = XPathFactory.newInstance().newXPath(); + + doc.getDocumentElement().normalize(); + } + + @Override + public String getTitle() { + String title = ""; + try { + XPathExpression expr = xPath.compile("//FictionBook/description/title-info/book-title"); + title = (String) expr.evaluate(doc, XPathConstants.STRING); + } catch (XPathExpressionException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + title = title.replaceAll("(\\s)\\1", "$1"); + return title; + } + + @Override + public Set getCoAuthors() { + return new HashSet<>(); + } + + @Override + public String getDescription() { + String description = ""; + try { + XPathExpression expr = xPath.compile("//FictionBook/description/title-info/annotation"); + description = (String) expr.evaluate(doc, XPathConstants.STRING); + } catch (XPathExpressionException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + description = description.trim().replaceAll("(\\s)\\1", "$1"); + return description; + } + + @Override + public State getState() { + return null; + } + + @Override + public Set
    getArticles() { + Set
    articles = new LinkedHashSet<>(); + try { + XPathExpression sectionExpression = xPath.compile("//body"); + Element bodyElements = (Element) sectionExpression.evaluate(doc, XPathConstants.NODE); + NodeList articlesNode = bodyElements.getElementsByTagName("section"); + for (int i = 0; i < articlesNode.getLength(); i++) { + Element node = (Element) articlesNode.item(i); + Optional titleNode = getNode(node, "title", 0); + String title = titleNode.map(Node::getTextContent) + .orElse(""); + title = title.trim().replaceAll("(\\s)\\1", "$1"); + + Optional annotationNode = getNode(node, "annotation", 0); + String annotation = annotationNode.map(Node::getTextContent) + .orElse(""); + annotation = annotation.trim().replaceAll("(\\s)\\1", "$1"); + StringBuilder content = new StringBuilder(); + NodeList nodeList = node.getElementsByTagName("p"); + for (int j = 0; j < nodeList.getLength(); j++) { + if (nodeList.item(j).getParentNode().getNodeName() == "section") { + content.append("\n" + nodeList.item(j).getTextContent() + "\n"); + } + } + articles.add(Article.builder() + .title(title) + .annotation(annotation) + .content(content.toString()) + .build()); + } + + } catch (XPathExpressionException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + return articles; + } + + 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; + + } +} diff --git a/src/main/java/fic/writer/domain/service/impl/ArticleServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/ArticleServiceImpl.java index 3ad1f61..ab90e65 100644 --- a/src/main/java/fic/writer/domain/service/impl/ArticleServiceImpl.java +++ b/src/main/java/fic/writer/domain/service/impl/ArticleServiceImpl.java @@ -4,12 +4,17 @@ import fic.writer.domain.entity.dto.ArticleDto; import fic.writer.domain.repository.ArticleRepository; import fic.writer.domain.service.ArticleService; +import fic.writer.domain.service.BookService; +import org.apache.tomcat.util.http.fileupload.IOUtils; 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.web.multipart.MultipartFile; import javax.persistence.EntityExistsException; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.util.List; import java.util.Optional; @@ -17,6 +22,8 @@ public class ArticleServiceImpl implements ArticleService { private ArticleRepository articleRepository; + private BookService bookService; + @Autowired public ArticleServiceImpl(ArticleRepository articleRepository) { this.articleRepository = articleRepository; @@ -59,6 +66,19 @@ public void deleteById(Long id) { articleRepository.deleteById(id); } + @Override + public String parseArticleContentFromFile(MultipartFile multipartFile) { + String fileContent = ""; + try { + ByteArrayOutputStream stringWriter = new ByteArrayOutputStream(); + IOUtils.copy(multipartFile.getInputStream(), stringWriter); + fileContent = stringWriter.toString(); + } catch (IOException e) { + throw new RuntimeException(); + } + return fileContent; + } + private void flushArticleDtoToArticle(Article article, ArticleDto articleDto) { if (articleDto.getTitle() != null) { diff --git a/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java index 8d850e8..513c9f5 100644 --- a/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java +++ b/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java @@ -17,6 +17,7 @@ import javax.persistence.EntityListeners; import javax.persistence.EntityNotFoundException; import java.io.IOException; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -53,7 +54,8 @@ public Optional findById(Long id) { public Book create(BookDto bookDto) { Book book = Book.builder().build(); flushBookDtoToBook(book, bookDto); - return bookRepository.save(book); + Book savedBook = bookRepository.save(book); + return savedBook; } @Override @@ -68,8 +70,10 @@ public Book addArticle(Long bookId, ArticleDto articleDto) { Book book = bookRepository.getOne(bookId); Article article = Article.builder().build(); flushArticleDtoToArticle(article, articleDto); - article.setBook(Book.builder().id(bookId).build()); - book.getArticles().add(article); + article.setBook(book); + Set
    articles = new HashSet<>(book.getArticles()); + articles.add(article); + book.setArticles(articles); bookRepository.save(book); return book; } 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..66c2d2a --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/FileServiceImpl.java @@ -0,0 +1,97 @@ +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.files.impl.*; +import fic.writer.domain.utils.FileExtention; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Objects; + +@Service +public class FileServiceImpl implements FileService { + @Override + public String parseText(MultipartFile file) { + FileExtention extension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + TextParser parser = selectTextParser(extension); + return parser.parseFile(file); + } + + public Article parseArticle(MultipartFile file) { + FileExtention extension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + ArticleParser parser = selectArticleParser(extension, file); + return Article.builder() + .title(parser.getTitle()) + .annotation(parser.getAnnotation()) + .content(parser.getContent()) + .build(); + } + + public Book parseBook(MultipartFile file) { + FileExtention extension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + BookParser parser = selectBookParser(extension, file); + return Book.builder() + .title(parser.getTitle()) + .description(parser.getDescription()) + .articles(parser.getArticles()) + .build(); + } + + private BookParser selectBookParser(FileExtention fileExtention, MultipartFile file) { + BookParser parser; + switch (fileExtention) { + case FB2: + parser = new XmlBookParser(file); + break; + default: + throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); + } + return parser; + } + + private ArticleParser selectArticleParser(FileExtention fileExtention, MultipartFile file) { + ArticleParser parser; + switch (fileExtention) { + case XML: + parser = new XmlArticleParser(file); + break; + case JSON: + parser = new JsonArticleParser(file); + break; + case FB2: + parser = new XmlArticleParser(file); + default: + throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); + } + return parser; + } + + private TextParser selectTextParser(FileExtention fileExtention) { + TextParser parser; + switch (fileExtention) { + case TXT: + parser = new TxtTextParser(); + break; + case DOCX: + parser = new DocxTextParser(); + break; + default: + throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); + } + return parser; + } + + private FileExtention getExtension(String fileName) { + int pointIndex = fileName.lastIndexOf('.'); + int pointPosition = pointIndex + 1; + return pointIndex == -1 + ? FileExtention.TXT + : FileExtention.valueOf(fileName.substring(pointPosition).toUpperCase()); + } + +} diff --git a/src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java new file mode 100644 index 0000000..33d1e13 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java @@ -0,0 +1,48 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.auth.OauthUser; +import fic.writer.domain.repository.OauthUserRepository; +import fic.writer.domain.service.OauthUserService; +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 OauthUserServiceImpl implements OauthUserService { + @Autowired + private OauthUserRepository oauthUserRepository; + + @Override + public List findAll() { + return oauthUserRepository.findAll(); + } + + @Override + public Page findPage(Pageable pageable) { + return null; + } + + @Override + public Optional findById(Long id) { + return oauthUserRepository.findById(id); + } + + @Override + public OauthUser save(OauthUser oauthUser) { + return oauthUserRepository.save(oauthUser); + } + + @Override + public void delete(OauthUser oauthUser) { + oauthUserRepository.delete(oauthUser); + } + + @Override + public void deleteById(Long id) { + oauthUserRepository.deleteById(id); + } +} diff --git a/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java index 30dba9e..47b71b1 100644 --- a/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java +++ b/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java @@ -1,5 +1,6 @@ package fic.writer.domain.service.impl; +import fic.writer.domain.entity.Book; import fic.writer.domain.entity.User; import fic.writer.domain.entity.dto.UserDto; import fic.writer.domain.repository.UserRepository; @@ -42,6 +43,11 @@ public Optional findByUsername(String username) { return userRepository.findByUsername(username); } + @Override + public Optional findByEmail(String email) { + return userRepository.findByEmail(email); + } + @Override public User create(UserDto userDto) { User user = User.builder().build(); @@ -50,6 +56,14 @@ public User create(UserDto userDto) { } + @Override + public User addBookAsAuthor(Long userId, Long bookId) { + User user = userRepository.findById(userId).get(); + Book book = Book.builder().id(bookId).build(); + user.getBooksAsAuthor().add(book); + return userRepository.save(user); + } + @Override public User update(Long userId, UserDto userDto) { User user = userRepository.findById(userId).orElseThrow(EntityNotFoundException::new); diff --git a/src/main/java/fic/writer/domain/utils/FileExtention.java b/src/main/java/fic/writer/domain/utils/FileExtention.java new file mode 100644 index 0000000..85dd74b --- /dev/null +++ b/src/main/java/fic/writer/domain/utils/FileExtention.java @@ -0,0 +1,5 @@ +package fic.writer.domain.utils; + +public enum FileExtention { + TXT, DOCX, XML, JSON, FB2 +} 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..3c6e008 --- /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.User; +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..c268a56 --- /dev/null +++ b/src/main/java/fic/writer/web/config/database/init/UserLoader.java @@ -0,0 +1,37 @@ +package fic.writer.web.config.database.init; + +import fic.writer.domain.entity.User; +import fic.writer.domain.entity.auth.CustomUser; +import fic.writer.domain.repository.CustomUserRepository; +import fic.writer.domain.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class UserLoader implements ApplicationRunner { + @Autowired + UserRepository userRepository; + @Autowired + CustomUserRepository customUserRepository; + + @Override + public void run(ApplicationArguments args) throws Exception { + User user = User.builder() + .id(1L) + .information("first user information") + .username("user@mail.cc") + .email("firstUser@mail.com") + .build(); + userRepository.save(user); + CustomUser customUser = CustomUser.builder() + .id(1L) + .password("qwerty") + .profile(user) + .build(); + customUserRepository.save(customUser); + + + } +} diff --git a/src/main/java/fic/writer/web/config/security/CorsEnabledAuthorizationServerSecurityConfiguration.java b/src/main/java/fic/writer/web/config/security/CorsEnabledAuthorizationServerSecurityConfiguration.java new file mode 100644 index 0000000..2bdd37a --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/CorsEnabledAuthorizationServerSecurityConfiguration.java @@ -0,0 +1,37 @@ +package fic.writer.web.config.security; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerEndpointsConfiguration; +import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerSecurityConfiguration; +import org.springframework.security.web.access.channel.ChannelProcessingFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +@Import(AuthorizationServerEndpointsConfiguration.class) +@Order(-1) +public class CorsEnabledAuthorizationServerSecurityConfiguration extends AuthorizationServerSecurityConfiguration { + + @Override + protected void configure(HttpSecurity http) throws Exception { + super.configure(http); + CorsConfigurationSource source = corsConfigurationSource(); + http.addFilterBefore(new CorsFilter(source), ChannelProcessingFilter.class); + } + + private CorsConfigurationSource corsConfigurationSource() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOrigin("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("POST"); + //more config + source.registerCorsConfiguration("/**", config); + return source; + } +} \ No newline at end of file diff --git a/src/main/java/fic/writer/web/config/security/OauthConfig.java b/src/main/java/fic/writer/web/config/security/OauthConfig.java new file mode 100644 index 0000000..f321ce1 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/OauthConfig.java @@ -0,0 +1,85 @@ +package fic.writer.web.config.security; + +import fic.writer.web.config.security.authorization.UserDetailServiceImpl; +import fic.writer.web.config.security.oauth.ClientResources; +import fic.writer.web.config.security.oauth.externalService.OauthExternalServerAuthenticationSuccessHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.SecurityProperties; +import org.springframework.boot.autoconfigure.security.oauth2.resource.UserInfoTokenServices; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +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.oauth2.client.OAuth2ClientContext; +import org.springframework.security.oauth2.client.OAuth2RestTemplate; +import org.springframework.security.oauth2.client.filter.OAuth2ClientAuthenticationProcessingFilter; +import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; +import org.springframework.security.oauth2.config.annotation.web.configuration.EnableOAuth2Client; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.web.filter.CompositeFilter; + +import javax.servlet.Filter; +import java.util.ArrayList; +import java.util.List; + +@Configuration +@EnableOAuth2Client +@EnableAuthorizationServer +@Order(SecurityProperties.BASIC_AUTH_ORDER) +public class OauthConfig extends WebSecurityConfigurerAdapter { + @Autowired + private OAuth2ClientContext oauth2ClientContext; + @Autowired + private UserDetailServiceImpl userDetailService; + @Autowired + private OauthExternalServerAuthenticationSuccessHandler successHandler; + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.antMatcher("/**") + .authorizeRequests().anyRequest().authenticated() + .mvcMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .mvcMatchers("/login**").permitAll() + .anyRequest().authenticated() + .and() + .cors().disable() + .addFilterBefore(ssoFilter(), BasicAuthenticationFilter.class); + } + + @Override + protected void configure(AuthenticationManagerBuilder auth) throws Exception { + auth.userDetailsService(userDetailService); + } + + @Bean + @ConfigurationProperties("github") + public ClientResources github() { + return new ClientResources(); + } + + private Filter ssoFilter() { + CompositeFilter filter = new CompositeFilter(); + List filters = new ArrayList<>(); +// filters.add(ssoFilter(facebook(), "/login/facebook")); + filters.add(ssoFilter(github(), "/login/github")); + filter.setFilters(filters); + return filter; + } + + private Filter ssoFilter(ClientResources client, String path) { + OAuth2ClientAuthenticationProcessingFilter filter = new OAuth2ClientAuthenticationProcessingFilter(path); + OAuth2RestTemplate template = new OAuth2RestTemplate(client.getClient(), oauth2ClientContext); + filter.setRestTemplate(template); + UserInfoTokenServices tokenServices = new UserInfoTokenServices( + client.getResource().getUserInfoUri(), client.getClient().getClientId()); + tokenServices.setRestTemplate(template); + filter.setTokenServices(tokenServices); + filter.setAuthenticationSuccessHandler(successHandler); + return filter; + } + +} 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..b872e81 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/SecurityConfig.java @@ -0,0 +1,28 @@ +package fic.writer.web.config.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; + +@Configuration +public class SecurityConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurerAdapter() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**").allowedOrigins("http://localhost:3000"); + } + }; + } + +} diff --git a/src/main/java/fic/writer/web/config/security/authorization/CustomUserDetails.java b/src/main/java/fic/writer/web/config/security/authorization/CustomUserDetails.java new file mode 100644 index 0000000..7b4cab6 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/authorization/CustomUserDetails.java @@ -0,0 +1,56 @@ +package fic.writer.web.config.security.authorization; + +import fic.writer.domain.entity.User; +import lombok.Getter; +import org.assertj.core.util.Lists; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; + +public class CustomUserDetails implements UserDetails { + @Getter + private User user; + private String password; + + public CustomUserDetails(User user, String password) { + this.user = user; + this.password = password; + } + + @Override + public Collection getAuthorities() { + return Lists.list(new SimpleGrantedAuthority("USER")); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return user.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; + } +} diff --git a/src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java b/src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java new file mode 100644 index 0000000..35ba739 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java @@ -0,0 +1,38 @@ +package fic.writer.web.config.security.authorization; + +import fic.writer.domain.entity.User; +import fic.writer.domain.entity.auth.CustomUser; +import fic.writer.domain.repository.CustomUserRepository; +import fic.writer.domain.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; + +@Service +public class UserDetailServiceImpl implements UserDetailsService { + @Autowired + private UserRepository userRepository; + @Autowired + private CustomUserRepository customUserRepository; + @Autowired + private PasswordEncoder passwordEncoder; + + @Override + @Transactional + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByEmail(username).orElseThrow(() -> new BadCredentialsException("username: " + username + " not found")); + CustomUser customUser = customUserRepository.findByProfileId(user.getId()).orElseThrow(() -> new BadCredentialsException("username: " + user.getEmail() + " not found")); + + CustomUserDetails customUserDetails = new CustomUserDetails(user, passwordEncoder.encode(customUser.getPassword())); + + return customUserDetails; + + } + +} \ No newline at end of file diff --git a/src/main/java/fic/writer/web/config/security/oauth/ClientResources.java b/src/main/java/fic/writer/web/config/security/oauth/ClientResources.java new file mode 100644 index 0000000..1d33b4c --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/oauth/ClientResources.java @@ -0,0 +1,21 @@ +package fic.writer.web.config.security.oauth; + +import org.springframework.boot.autoconfigure.security.oauth2.resource.ResourceServerProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeResourceDetails; + +public class ClientResources { + @NestedConfigurationProperty + private AuthorizationCodeResourceDetails client = new AuthorizationCodeResourceDetails(); + + @NestedConfigurationProperty + private ResourceServerProperties resource = new ResourceServerProperties(); + + public AuthorizationCodeResourceDetails getClient() { + return client; + } + + public ResourceServerProperties getResource() { + return resource; + } +} diff --git a/src/main/java/fic/writer/web/config/security/oauth/ResourceServerConfiguration.java b/src/main/java/fic/writer/web/config/security/oauth/ResourceServerConfiguration.java new file mode 100644 index 0000000..3f658b4 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/oauth/ResourceServerConfiguration.java @@ -0,0 +1,23 @@ +package fic.writer.web.config.security.oauth; + +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; +import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; + +@Configuration +@EnableResourceServer +public class ResourceServerConfiguration + extends ResourceServerConfigurerAdapter { + @Override + public void configure(HttpSecurity http) throws Exception { + http + .antMatcher("/**") + .authorizeRequests() + .mvcMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .mvcMatchers(HttpMethod.OPTIONS, "/login**").permitAll() + .anyRequest().authenticated() + .and().csrf().disable(); + } +} diff --git a/src/main/java/fic/writer/web/config/security/oauth/externalService/OauthExternalServerAuthenticationSuccessHandler.java b/src/main/java/fic/writer/web/config/security/oauth/externalService/OauthExternalServerAuthenticationSuccessHandler.java new file mode 100644 index 0000000..5a4c422 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/oauth/externalService/OauthExternalServerAuthenticationSuccessHandler.java @@ -0,0 +1,51 @@ +package fic.writer.web.config.security.oauth.externalService; + + +import fic.writer.domain.entity.auth.OauthUser; +import fic.writer.domain.service.OauthUserService; +import fic.writer.domain.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.OAuth2ClientContext; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; + +@Component +public class OauthExternalServerAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + @Autowired + private OAuth2ClientContext oauth2ClientContext; + @Autowired + private OauthUserService oauthUserService; + @Autowired + private UserService userService; + + @Override + public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { + super.onAuthenticationSuccess(httpServletRequest, httpServletResponse, authentication); + OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) authentication; + String username = oAuth2Authentication.getName(); + if (!userService.findByUsername(username).isPresent()) { + OauthUser oauthUser = createNewOauthUser(oAuth2Authentication.getUserAuthentication()); + SecurityContextHolder.getContext().setAuthentication(oAuth2Authentication); + } +// OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest); + httpServletResponse.getWriter().write("token:" + oauth2ClientContext.getAccessTokenRequest()); + } + + private OauthUser createNewOauthUser(Authentication authentication) { + Long id = new Long(((Map) authentication.getDetails()).get("id").toString()); + OauthUser oauthUser = new OauthUser(); + + return oauthUser; + } + +} + diff --git a/src/main/java/fic/writer/web/controller/ArticleController.java b/src/main/java/fic/writer/web/controller/ArticleController.java index 4016fbb..7a1b197 100644 --- a/src/main/java/fic/writer/web/controller/ArticleController.java +++ b/src/main/java/fic/writer/web/controller/ArticleController.java @@ -15,7 +15,6 @@ @RestController @RequestMapping(value = "/books/{bookId}/articles", produces = MediaType.APPLICATION_JSON_VALUE) -@CrossOrigin(origins = "http://localhost:3000") public class ArticleController { private static final String ID_TEMPLATE_PATH = "/{articleId}"; private static final String ID_TEMPLATE = "articleId"; @@ -58,4 +57,6 @@ public void deleteArticle(@PathVariable(BOOK_ID_TEMPLATE) Long bookId, @PathVari 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/BookController.java b/src/main/java/fic/writer/web/controller/BookController.java index 1969ea6..58a1407 100644 --- a/src/main/java/fic/writer/web/controller/BookController.java +++ b/src/main/java/fic/writer/web/controller/BookController.java @@ -3,6 +3,7 @@ import fic.writer.domain.entity.Book; import fic.writer.domain.entity.dto.BookDto; import fic.writer.domain.service.BookService; +import fic.writer.domain.service.UserService; import fic.writer.web.response.BookResponse; import fic.writer.web.response.PageResponse; import org.springframework.beans.factory.annotation.Autowired; @@ -20,18 +21,20 @@ @RestController @RequestMapping(value = "/books", produces = MediaType.APPLICATION_JSON_VALUE) -@CrossOrigin(origins = "http://localhost:3000") public class BookController { private static final String ID_TEMPLATE_PATH = "/{bookId}"; private static final String ID_TEMPLATE = "bookId"; private BookService bookService; + private UserService userService; @Autowired - public BookController(BookService bookService) { + public BookController(BookService bookService, UserService userService) { this.bookService = bookService; + this.userService = userService; } + @GetMapping public PageResponse getAllBooks(Pageable pageable) { Page resourcePage = bookService.findPage(pageable).map(BookResponse::new); @@ -49,6 +52,7 @@ public BookResponse getBookById(@PathVariable(ID_TEMPLATE) Long id) { @ResponseStatus(HttpStatus.CREATED) public BookResponse createBook(@RequestBody BookDto book) { Book savedBook = bookService.create(book); + userService.addBookAsAuthor(savedBook.getAuthor().getId(), savedBook.getId()); return new BookResponse(savedBook); } 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..775b433 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/FileController.java @@ -0,0 +1,56 @@ +package fic.writer.web.controller; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.dto.BookDto; +import fic.writer.domain.repository.BookRepository; +import fic.writer.domain.service.BookService; +import fic.writer.domain.service.FileService; +import fic.writer.domain.service.UserService; +import fic.writer.web.response.BookResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.HashMap; +import java.util.Map; + +@RestController +public class FileController { + @Autowired + private FileService fileService; + @Autowired + private BookService bookService; + @Autowired + private UserService userService; + @Autowired + private BookRepository bookRepository; + + @PostMapping("/files") + @ResponseStatus(HttpStatus.CREATED) + public Resource takeArticleContentFromFile(@RequestParam("file") MultipartFile file) { + Map map = new HashMap<>(); + return new Resource<>(fileService.parseText(file)); + } + + @PostMapping("/files/books") + @ResponseStatus(HttpStatus.CREATED) + public BookResponse takeBookFromFile(@RequestParam("file") MultipartFile file) { + Map map = new HashMap<>(); + Book book = fileService.parseBook(file); + Book createdBook = bookService.create(BookDto.of(book)); + userService.addBookAsAuthor(createdBook.getAuthor().getId(), createdBook.getId()); + + createdBook.setArticles(book.getArticles()); + createdBook.getArticles().forEach(a -> { + a.setBook(createdBook); + userService.addBookAsAuthor(createdBook.getAuthor().getId(), createdBook.getId()); + }); + + return new BookResponse(createdBook); + } +} 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/UserController.java b/src/main/java/fic/writer/web/controller/UserController.java index dd1f123..504ffd5 100644 --- a/src/main/java/fic/writer/web/controller/UserController.java +++ b/src/main/java/fic/writer/web/controller/UserController.java @@ -9,12 +9,12 @@ import org.springframework.web.bind.annotation.*; import javax.persistence.EntityNotFoundException; +import java.security.Principal; import java.util.List; import java.util.stream.Collectors; @RestController @RequestMapping("/users") -@CrossOrigin(origins = "http://localhost:3000") public class UserController { private static final String ID_TEMPLATE_PATH = "/{userId}"; private static final String ID_TEMPLATE = "userId"; @@ -58,4 +58,10 @@ public UserResponse updateUser(@PathVariable(ID_TEMPLATE) Long id, @RequestBody public void deleteUser(Long id) { userService.deleteById(id); } + + @RequestMapping({"/user", "/me"}) + public UserResponse user(Principal principal) { + UserResponse user = userService.findByEmail(principal.getName()).map(UserResponse::new).orElseThrow(EntityNotFoundException::new); + return user; + } } diff --git a/src/main/java/fic/writer/web/response/ArticleResponse.java b/src/main/java/fic/writer/web/response/ArticleResponse.java index ca96f1e..8adfab1 100644 --- a/src/main/java/fic/writer/web/response/ArticleResponse.java +++ b/src/main/java/fic/writer/web/response/ArticleResponse.java @@ -21,6 +21,7 @@ public class ArticleResponse extends ResourceSupport { private Date created; private String content; private String annotation; + private Long pageCount; public ArticleResponse(Article article) { articleId = article.getId(); @@ -28,6 +29,7 @@ public ArticleResponse(Article article) { created = article.getCreated(); content = article.getContent(); annotation = article.getAnnotation(); + pageCount = article.getPageCount(); addSelfLink(articleId); } diff --git a/src/main/java/fic/writer/web/response/BookResponse.java b/src/main/java/fic/writer/web/response/BookResponse.java index 5baaa2c..3415032 100644 --- a/src/main/java/fic/writer/web/response/BookResponse.java +++ b/src/main/java/fic/writer/web/response/BookResponse.java @@ -35,10 +35,12 @@ public class BookResponse extends ResourceSupport { 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(UserController.class, authorId).getUserById(authorId)).withRel("author"); diff --git a/src/main/resources/application-oauth-acme.yml b/src/main/resources/application-oauth-acme.yml new file mode 100644 index 0000000..2b33412 --- /dev/null +++ b/src/main/resources/application-oauth-acme.yml @@ -0,0 +1,7 @@ +security: + oauth2: + client: + client-id: acme + client-secret: acmesecret + scope: read,write + auto-approve-scopes: '.*' \ No newline at end of file diff --git a/src/main/resources/application-oauth-github.yml b/src/main/resources/application-oauth-github.yml new file mode 100644 index 0000000..f1f0c16 --- /dev/null +++ b/src/main/resources/application-oauth-github.yml @@ -0,0 +1,9 @@ +github: + client: + clientId: 98ec518608b7facf2a4b + clientSecret: a0f0d1c9bf39eeb3600baa6f34c5a4ef317e901e + accessTokenUri: https://github.com/login/oauth/access_token + userAuthorizationUri: https://github.com/login/oauth/authorize + clientAuthenticationScheme: form + resource: + userInfoUri: https://api.github.com/user \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 596c249..34e47cb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,9 +1,16 @@ spring: + main: + allow-bean-definition-overriding: true profiles: - active: db-mysql + active: db-mysql, oauth-github, oauth-acme + servlet: + multipart: + max-file-size: 5MB + max-request-size: 5MB + location: ${user.dir}/files/temp logging: level: org: - springframework: INFO + springframework: DEBUG server: - port: 8080 \ No newline at end of file + port: 8080 diff --git a/src/main/resources/client.yml b/src/main/resources/client.yml new file mode 100644 index 0000000..8cfa6f7 --- /dev/null +++ b/src/main/resources/client.yml @@ -0,0 +1,12 @@ +server: + port: 9999 + context-path: /client +security: + oauth2: + client: + client-id: acme + client-secret: acmesecret + access-token-uri: http://localhost:8080/oauth/token + user-authorization-uri: http://localhost:8080/oauth/authorize + resource: + user-info-uri: http://localhost:8080/me \ No newline at end of file diff --git a/src/main/resources/docker/stack.yml b/src/main/resources/docker/stack.yml new file mode 100644 index 0000000..c5900c2 --- /dev/null +++ b/src/main/resources/docker/stack.yml @@ -0,0 +1,11 @@ +# Use root/example as user/password credentials +version: '3.1' + +services: + + db: + image: mysql + command: --default-authentication-plugin=mysql_native_password + restart: always + environment: + MYSQL_ROOT_PASSWORD: "admin" \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..231d9fd --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,64 @@ + + + + + + Demo + + + + + + + + +

    Login

    +
    + With github: click here +
    + + + + + \ No newline at end of file diff --git a/src/test/java/fic/writer/domain/service/ActorArticleActorStateTest.java b/src/test/java/fic/writer/domain/service/ActorArticleActorStateTest.java new file mode 100644 index 0000000..a700e9d --- /dev/null +++ b/src/test/java/fic/writer/domain/service/ActorArticleActorStateTest.java @@ -0,0 +1,35 @@ +package fic.writer.domain.service; + +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.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class ActorArticleActorStateTest { + @Autowired + ActorStateService actorStateService; + @Autowired + ArticleService articleService; + @Autowired + ActorService actorService; + + + @Test + public void findActorStateByArticle_whenCorrect_shouldExist() { + assertTrue(actorStateService.findForActorByArticle(1L, 1L).isPresent()); + } + + @Test + public void findActorStateForActor_whenCorrect_shouldExist() { + Pageable pageable = new PageRequest(0, 10); + assertEquals(2L, actorStateService.findAllByActor(1L, pageable).getTotalElements()); + } +} diff --git a/src/test/java/fic/writer/domain/service/ArticleServiceTest.java b/src/test/java/fic/writer/domain/service/ArticleServiceTest.java index 9c52f6f..fbbf633 100644 --- a/src/test/java/fic/writer/domain/service/ArticleServiceTest.java +++ b/src/test/java/fic/writer/domain/service/ArticleServiceTest.java @@ -6,7 +6,6 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.test.context.junit4.SpringRunner; import java.util.Date; @@ -15,7 +14,6 @@ @RunWith(SpringRunner.class) @SpringBootTest -@EnableJpaAuditing public class ArticleServiceTest { @Autowired private ArticleService articleService; diff --git a/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java b/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java index 3b2b564..113c42b 100644 --- a/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java +++ b/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java @@ -7,7 +7,6 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.transaction.annotation.Transactional; @@ -15,7 +14,6 @@ @RunWith(SpringRunner.class) @SpringBootTest -@EnableJpaAuditing @Transactional public class BookAndArticleServicesTest { @Autowired 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/web/controller/UserControllerTest.java b/src/test/java/fic/writer/web/controller/UserControllerTest.java index 3c2eb61..4f2ddd5 100644 --- a/src/test/java/fic/writer/web/controller/UserControllerTest.java +++ b/src/test/java/fic/writer/web/controller/UserControllerTest.java @@ -41,10 +41,14 @@ public class UserControllerTest { @Test public void getUsers_whenDtoIsEmpty_shouldReturnOk() throws Exception { + final long ID = 1L; + final String USERNAME = "testUsername"; + List userList = new ArrayList<>(); - User user = new User(); - user.setId(1L); - user.setUsername("testUsername"); + User user = User.builder() + .id(ID) + .username(USERNAME) + .build(); userList.add(user); Mockito.when(userService.findAll()).thenReturn(userList); @@ -57,9 +61,11 @@ public void getUsers_whenDtoIsEmpty_shouldReturnOk() throws Exception { @Test public void getUserById_whenUserExists_shouldReturnOk() throws Exception { final long ID = 1L; - User user = new User(); - user.setId(ID); - user.setUsername("testUsername"); + final String USERNAME = "testUsername"; + User user = User.builder() + .id(ID) + .username(USERNAME) + .build(); Mockito.when(userService.findById(1L)).thenReturn(Optional.of(user)); @@ -72,7 +78,9 @@ public void getUserById_whenUserExists_shouldReturnOk() throws Exception { @Test public void createUser() throws Exception { final Long USER_ID = 1L; - final String username = "testUsername", about = "about", information = "inform"; + final String username = "testUsername", + about = "about", + information = "inform"; UserDto dto = new UserDto(username, about, information); ObjectMapper mapper = new ObjectMapper(); @@ -81,8 +89,6 @@ public void createUser() throws Exception { .username(username) .about(about) .information(information) - .booksAsAuthor(new HashSet<>()) - .booksAsSubAuthor(new HashSet<>()) .build(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); diff --git a/src/test/resources/application-oauth-github b/src/test/resources/application-oauth-github new file mode 100644 index 0000000..b88ebbe --- /dev/null +++ b/src/test/resources/application-oauth-github @@ -0,0 +1,9 @@ +github: + client: + clientId: Iv1.4143c85e5e24f051 + clientSecret: 42c69107f5b37624d1f7bf0d839247d9240dcfcc + accessTokenUri: https://github.com/login/oauth/access_token + userAuthorizationUri: https://github.com/login/oauth/authorize + clientAuthenticationScheme: form + resource: + userInfoUri: https://api.github.com/user \ No newline at end of file diff --git a/src/test/resources/application-oauth-react-client b/src/test/resources/application-oauth-react-client new file mode 100644 index 0000000..2b33412 --- /dev/null +++ b/src/test/resources/application-oauth-react-client @@ -0,0 +1,7 @@ +security: + oauth2: + client: + client-id: acme + client-secret: acmesecret + scope: read,write + auto-approve-scopes: '.*' \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 5828953..530ddd8 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,6 +1,8 @@ spring: profiles: - active: db-mysql + active: db-mysql, oauth-github, oauth-react-client + main: + allow-bean-definition-overriding: true jpa: hibernate: ddl-auto: create-drop @@ -11,5 +13,4 @@ spring: logging: level: org: - springframework: INFO - + springframework: INFO \ 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 From 1ab2986dc82fadb48708750b83833b62a7cdf125 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Wed, 24 Apr 2019 12:04:59 +0300 Subject: [PATCH 15/24] change db to postgreSQL in docker --- build.gradle | 2 + .../fic/writer/domain/entity/Article.java | 7 ++-- .../java/fic/writer/domain/entity/Book.java | 5 ++- .../java/fic/writer/domain/entity/User.java | 1 + .../resources/application-db-postgresql.yml | 23 +++++++++++ src/main/resources/application.yml | 2 +- .../domain/service/ArticleServiceTest.java | 11 ++++++ .../service/BookAndArticleServicesTest.java | 13 +++++++ .../domain/service/BookServiceTest.java | 11 ++++++ src/test/resources/data/user.sql | 10 +++-- src/test/resources/db/docker-compose.yml | 39 +++++++++++++++++++ 11 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/application-db-postgresql.yml create mode 100644 src/test/resources/db/docker-compose.yml diff --git a/build.gradle b/build.gradle index e16826d..7a0c37d 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,7 @@ dependencies { 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.oauth.boot:spring-security-oauth2-autoconfigure:2.1.3.RELEASE") compile("org.apache.poi:poi:4.1.0") @@ -42,6 +43,7 @@ dependencies { 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") diff --git a/src/main/java/fic/writer/domain/entity/Article.java b/src/main/java/fic/writer/domain/entity/Article.java index c701389..03e2622 100644 --- a/src/main/java/fic/writer/domain/entity/Article.java +++ b/src/main/java/fic/writer/domain/entity/Article.java @@ -20,14 +20,14 @@ public class Article { private static final int CHARS_IN_PAGE = 1800; @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String title; @CreatedDate private Date created; @LastModifiedDate private Date lastModify; - @Column(columnDefinition = "LONGTEXT") + @Column(columnDefinition = "TEXT") private String content; private String annotation; @ManyToOne(fetch = FetchType.LAZY) @@ -35,5 +35,6 @@ public class Article { @OneToMany(cascade = CascadeType.REMOVE) private Set actorStates; @Formula("ceil( CHAR_LENGTH(content)/" + CHARS_IN_PAGE + ")") - private Long pageCount; + 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 index 0c5b495..c1a4d29 100644 --- a/src/main/java/fic/writer/domain/entity/Book.java +++ b/src/main/java/fic/writer/domain/entity/Book.java @@ -61,7 +61,10 @@ public class Book { @PostLoad private void calculatePageCount() { - this.pageCount = articles.stream().mapToLong(Article::getPageCount).sum(); + this.pageCount = 0L; + if (articles != null) { + pageCount = articles.stream().mapToLong(Article::getPageCount).sum(); + } } @PostPersist diff --git a/src/main/java/fic/writer/domain/entity/User.java b/src/main/java/fic/writer/domain/entity/User.java index d53b816..4279ad0 100644 --- a/src/main/java/fic/writer/domain/entity/User.java +++ b/src/main/java/fic/writer/domain/entity/User.java @@ -11,6 +11,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Table(name = "profile") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/resources/application-db-postgresql.yml b/src/main/resources/application-db-postgresql.yml new file mode 100644 index 0000000..b87ee54 --- /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/user.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.yml b/src/main/resources/application.yml index 34e47cb..2c89133 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,7 +2,7 @@ spring: main: allow-bean-definition-overriding: true profiles: - active: db-mysql, oauth-github, oauth-acme + active: db-postgresql, oauth-github, oauth-acme servlet: multipart: max-file-size: 5MB diff --git a/src/test/java/fic/writer/domain/service/ArticleServiceTest.java b/src/test/java/fic/writer/domain/service/ArticleServiceTest.java index fbbf633..d70180b 100644 --- a/src/test/java/fic/writer/domain/service/ArticleServiceTest.java +++ b/src/test/java/fic/writer/domain/service/ArticleServiceTest.java @@ -1,11 +1,16 @@ package fic.writer.domain.service; import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.User; import fic.writer.domain.entity.dto.ArticleDto; +import fic.writer.web.config.security.authorization.CustomUserDetails; +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.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.junit4.SpringRunner; import java.util.Date; @@ -18,6 +23,12 @@ public class ArticleServiceTest { @Autowired private ArticleService articleService; + @Before + public void setUserInSecurityContext() { + CustomUserDetails customUserDetails = new CustomUserDetails(User.builder().id(1L).build(), "qwerty"); + TestingAuthenticationToken token = new TestingAuthenticationToken(customUserDetails, null); + SecurityContextHolder.getContext().setAuthentication(token); + } @Test public void updateArticle_whenUpdateTitle_shouldChangeTitle() { diff --git a/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java b/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java index 113c42b..8d16ed6 100644 --- a/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java +++ b/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java @@ -2,11 +2,17 @@ import fic.writer.domain.entity.Article; import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.User; import fic.writer.domain.entity.dto.ArticleDto; +import fic.writer.web.config.security.authorization.CustomUserDetails; +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.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.transaction.annotation.Transactional; @@ -15,12 +21,19 @@ @RunWith(SpringRunner.class) @SpringBootTest @Transactional +@WithMockUser(username = "Bella") public class BookAndArticleServicesTest { @Autowired private ArticleService articleService; @Autowired private BookService bookService; + @Before + public void setUserInSecurityContext() { + CustomUserDetails customUserDetails = new CustomUserDetails(User.builder().id(1L).build(), "qwerty"); + TestingAuthenticationToken token = new TestingAuthenticationToken(customUserDetails, null); + SecurityContextHolder.getContext().setAuthentication(token); + } @Test public void createArticle_shouldFindByGeneratedId() { final Long BOOK_ID = 1L; diff --git a/src/test/java/fic/writer/domain/service/BookServiceTest.java b/src/test/java/fic/writer/domain/service/BookServiceTest.java index 279574c..2af4561 100644 --- a/src/test/java/fic/writer/domain/service/BookServiceTest.java +++ b/src/test/java/fic/writer/domain/service/BookServiceTest.java @@ -1,12 +1,17 @@ package fic.writer.domain.service; import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.User; import fic.writer.domain.entity.dto.BookDto; +import fic.writer.web.config.security.authorization.CustomUserDetails; +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.dao.EmptyResultDataAccessException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.junit4.SpringRunner; import static org.junit.Assert.*; @@ -17,6 +22,12 @@ public class BookServiceTest { @Autowired private BookService bookService; + @Before + public void setUserInSecurityContext() { + CustomUserDetails customUserDetails = new CustomUserDetails(User.builder().id(1L).build(), "qwerty"); + TestingAuthenticationToken token = new TestingAuthenticationToken(customUserDetails, null); + SecurityContextHolder.getContext().setAuthentication(token); + } @Test public void createBook_shouldChangeCount() { final int SIZE_BEFORE = bookService.findAll().size(); diff --git a/src/test/resources/data/user.sql b/src/test/resources/data/user.sql index dd5d16b..2a5a1cd 100644 --- a/src/test/resources/data/user.sql +++ b/src/test/resources/data/user.sql @@ -1,4 +1,6 @@ -INSERT INTO user(id,username)VALUES(123,'delete user'); -INSERT INTO user(id,username)VALUES(1,'test user'); -INSERT INTO user(id,username)VALUES(3,'Bella'); -INSERT INTO user(id,username)VALUES(4,'Bella junior'); \ No newline at end of file +INSERT INTO profile(id,username)VALUES(123,'delete user'); +INSERT INTO profile(id,username)VALUES(1,'test user'); +INSERT INTO profile(id,username)VALUES(3,'Bella'); +INSERT INTO profile(id,username)VALUES(4,'Bella junior'); + +INSERT INTO custom_user(id,password)VALUES(1,'qwerty'); \ No newline at end of file diff --git a/src/test/resources/db/docker-compose.yml b/src/test/resources/db/docker-compose.yml new file mode 100644 index 0000000..24ea90a --- /dev/null +++ b/src/test/resources/db/docker-compose.yml @@ -0,0 +1,39 @@ +# Use postgres/example user/password credentials +version: '3.1' + +services: + postgres: + container_name: postgres_container + image: postgres + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + PGDATA: /data/postgres + POSTGRES_DB: ${POSTGRES_DB:-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: + - "${PGADMIN_PORT:-5050}:80" + networks: + - postgres + +networks: + postgres: + driver: bridge +volumes: + postgres: + pgadmin: \ No newline at end of file From 173325d00e2c4ffe4fd662b70610f6f463f5f299 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Wed, 24 Apr 2019 12:15:29 +0300 Subject: [PATCH 16/24] Add public folder. Allow users to sign in. Allow users to download book. Allow users to create book from file. --- almanac-web/public/favicon.ico | Bin 0 -> 3870 bytes almanac-web/public/index.html | 18 +++ almanac-web/public/manifest.json | 15 ++ almanac-web/src/App.js | 33 ++-- almanac-web/src/Header.js | 3 +- almanac-web/src/agent.js | 26 ++-- almanac-web/src/components/HeaderList.js | 87 +++++------ almanac-web/src/components/ListPagination.js | 12 +- almanac-web/src/constants/actionTypes.js | 2 + almanac-web/src/constants/commonConstants.js | 2 +- almanac-web/src/constants/config.js | 9 ++ .../src/containers/Article/ArticleCreate.js | 99 +++++++----- .../src/containers/Article/ArticleEdit.js | 1 + .../src/containers/Article/ArticleList.js | 19 ++- .../src/containers/Article/ArticlePreview.js | 2 +- almanac-web/src/containers/Article/index.js | 21 +-- .../src/containers/Authentication/Login.js | 21 ++- .../src/containers/Authentication/OAuth2.js | 41 +++++ .../Authentication/OAuthProvider.js | 20 +++ .../src/containers/Authentication/Popup.js | 88 +++++++++++ almanac-web/src/containers/Book/BookCreate.js | 145 +++++++++++++----- almanac-web/src/containers/Book/BookList.js | 16 +- .../src/containers/Book/BookPreview.js | 22 ++- almanac-web/src/containers/Book/index.js | 19 ++- .../src/containers/Profile/ProfilePreview.js | 10 +- almanac-web/src/containers/Profile/index.js | 9 +- almanac-web/src/middleware.js | 10 +- almanac-web/src/reducers/auth.js | 3 +- almanac-web/src/reducers/book.js | 20 +-- almanac-web/src/reducers/bookList.js | 20 +-- almanac-web/src/reducers/common.js | 13 +- 31 files changed, 582 insertions(+), 224 deletions(-) create mode 100644 almanac-web/public/favicon.ico create mode 100644 almanac-web/public/index.html create mode 100644 almanac-web/public/manifest.json create mode 100644 almanac-web/src/constants/config.js create mode 100644 almanac-web/src/containers/Authentication/OAuth2.js create mode 100644 almanac-web/src/containers/Authentication/OAuthProvider.js create mode 100644 almanac-web/src/containers/Authentication/Popup.js diff --git a/almanac-web/public/favicon.ico b/almanac-web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 zcma);c{J4h9>;%nil|2-o+rCuEF-(I%-F}ijC~o(k~HKAkr0)!FCj~d>`RtpD?8b; zXOC1OD!V*IsqUwzbMF1)-gEDD=A573Z-&G7^LoAC9|WO7Xc0Cx1g^Zu0u_SjAPB3vGa^W|sj)80f#V0@M_CAZTIO(t--xg= z!sii`1giyH7EKL_+Wi0ab<)&E_0KD!3Rp2^HNB*K2@PHCs4PWSA32*-^7d{9nH2_E zmC{C*N*)(vEF1_aMamw2A{ZH5aIDqiabnFdJ|y0%aS|64E$`s2ccV~3lR!u<){eS` z#^Mx6o(iP1Ix%4dv`t@!&Za-K@mTm#vadc{0aWDV*_%EiGK7qMC_(`exc>-$Gb9~W!w_^{*pYRm~G zBN{nA;cm^w$VWg1O^^<6vY`1XCD|s_zv*g*5&V#wv&s#h$xlUilPe4U@I&UXZbL z0)%9Uj&@yd03n;!7do+bfixH^FeZ-Ema}s;DQX2gY+7g0s(9;`8GyvPY1*vxiF&|w z>!vA~GA<~JUqH}d;DfBSi^IT*#lrzXl$fNpq0_T1tA+`A$1?(gLb?e#0>UELvljtQ zK+*74m0jn&)5yk8mLBv;=@}c{t0ztT<v;Avck$S6D`Z)^c0(jiwKhQsn|LDRY&w(Fmi91I7H6S;b0XM{e zXp0~(T@k_r-!jkLwd1_Vre^v$G4|kh4}=Gi?$AaJ)3I+^m|Zyj#*?Kp@w(lQdJZf4 z#|IJW5z+S^e9@(6hW6N~{pj8|NO*>1)E=%?nNUAkmv~OY&ZV;m-%?pQ_11)hAr0oAwILrlsGawpxx4D43J&K=n+p3WLnlDsQ$b(9+4 z?mO^hmV^F8MV{4Lx>(Q=aHhQ1){0d*(e&s%G=i5rq3;t{JC zmgbn5Nkl)t@fPH$v;af26lyhH!k+#}_&aBK4baYPbZy$5aFx4}ka&qxl z$=Rh$W;U)>-=S-0=?7FH9dUAd2(q#4TCAHky!$^~;Dz^j|8_wuKc*YzfdAht@Q&ror?91Dm!N03=4=O!a)I*0q~p0g$Fm$pmr$ zb;wD;STDIi$@M%y1>p&_>%?UP($15gou_ue1u0!4(%81;qcIW8NyxFEvXpiJ|H4wz z*mFT(qVx1FKufG11hByuX%lPk4t#WZ{>8ka2efjY`~;AL6vWyQKpJun2nRiZYDij$ zP>4jQXPaP$UC$yIVgGa)jDV;F0l^n(V=HMRB5)20V7&r$jmk{UUIe zVjKroK}JAbD>B`2cwNQ&GDLx8{pg`7hbA~grk|W6LgiZ`8y`{Iq0i>t!3p2}MS6S+ zO_ruKyAElt)rdS>CtF7j{&6rP-#c=7evGMt7B6`7HG|-(WL`bDUAjyn+k$mx$CH;q2Dz4x;cPP$hW=`pFfLO)!jaCL@V2+F)So3}vg|%O*^T1j>C2lx zsURO-zIJC$^$g2byVbRIo^w>UxK}74^TqUiRR#7s_X$e)$6iYG1(PcW7un-va-S&u zHk9-6Zn&>T==A)lM^D~bk{&rFzCi35>UR!ZjQkdSiNX*-;l4z9j*7|q`TBl~Au`5& z+c)*8?#-tgUR$Zd%Q3bs96w6k7q@#tUn`5rj+r@_sAVVLqco|6O{ILX&U-&-cbVa3 zY?ngHR@%l{;`ri%H*0EhBWrGjv!LE4db?HEWb5mu*t@{kv|XwK8?npOshmzf=vZA@ zVSN9sL~!sn?r(AK)Q7Jk2(|M67Uy3I{eRy z_l&Y@A>;vjkWN5I2xvFFTLX0i+`{qz7C_@bo`ZUzDugfq4+>a3?1v%)O+YTd6@Ul7 zAfLfm=nhZ`)P~&v90$&UcF+yXm9sq!qCx3^9gzIcO|Y(js^Fj)Rvq>nQAHI92ap=P z10A4@prk+AGWCb`2)dQYFuR$|H6iDE8p}9a?#nV2}LBCoCf(Xi2@szia7#gY>b|l!-U`c}@ zLdhvQjc!BdLJvYvzzzngnw51yRYCqh4}$oRCy-z|v3Hc*d|?^Wj=l~18*E~*cR_kU z{XsxM1i{V*4GujHQ3DBpl2w4FgFR48Nma@HPgnyKoIEY-MqmMeY=I<%oG~l!f<+FN z1ZY^;10j4M4#HYXP zw5eJpA_y(>uLQ~OucgxDLuf}fVs272FaMxhn4xnDGIyLXnw>Xsd^J8XhcWIwIoQ9} z%FoSJTAGW(SRGwJwb=@pY7r$uQRK3Zd~XbxU)ts!4XsJrCycrWSI?e!IqwqIR8+Jh zlRjZ`UO1I!BtJR_2~7AbkbSm%XQqxEPkz6BTGWx8e}nQ=w7bZ|eVP4?*Tb!$(R)iC z9)&%bS*u(lXqzitAN)Oo=&Ytn>%Hzjc<5liuPi>zC_nw;Z0AE3Y$Jao_Q90R-gl~5 z_xAb2J%eArrC1CN4G$}-zVvCqF1;H;abAu6G*+PDHSYFx@Tdbfox*uEd3}BUyYY-l zTfEsOqsi#f9^FoLO;ChK<554qkri&Av~SIM*{fEYRE?vH7pTAOmu2pz3X?Wn*!ROX ztd54huAk&mFBemMooL33RV-*1f0Q3_(7hl$<#*|WF9P!;r;4_+X~k~uKEqdzZ$5Al zV63XN@)j$FN#cCD;ek1R#l zv%pGrhB~KWgoCj%GT?%{@@o(AJGt*PG#l3i>lhmb_twKH^EYvacVY-6bsCl5*^~L0 zonm@lk2UvvTKr2RS%}T>^~EYqdL1q4nD%0n&Xqr^cK^`J5W;lRRB^R-O8b&HENO||mo0xaD+S=I8RTlIfVgqN@SXDr2&-)we--K7w= zJVU8?Z+7k9dy;s;^gDkQa`0nz6N{T?(A&Iz)2!DEecLyRa&FI!id#5Z7B*O2=PsR0 zEvc|8{NS^)!d)MDX(97Xw}m&kEO@5jqRaDZ!+%`wYOI<23q|&js`&o4xvjP7D_xv@ z5hEwpsp{HezI9!~6O{~)lLR@oF7?J7i>1|5a~UuoN=q&6N}EJPV_GD`&M*v8Y`^2j zKII*d_@Fi$+i*YEW+Hbzn{iQk~yP z>7N{S4)r*!NwQ`(qcN#8SRQsNK6>{)X12nbF`*7#ecO7I)Q$uZsV+xS4E7aUn+U(K baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/almanac-web/public/index.html b/almanac-web/public/index.html new file mode 100644 index 0000000..bd79eb9 --- /dev/null +++ b/almanac-web/public/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + Almanac + + +
    + + + diff --git a/almanac-web/public/manifest.json b/almanac-web/public/manifest.json new file mode 100644 index 0000000..1f2f141 --- /dev/null +++ b/almanac-web/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/almanac-web/src/App.js b/almanac-web/src/App.js index fadf377..487d0b5 100644 --- a/almanac-web/src/App.js +++ b/almanac-web/src/App.js @@ -2,7 +2,7 @@ import agent from './agent'; import Header from './Header'; import React from 'react'; import { connect } from 'react-redux'; -import { APP_LOAD, REDIRECT } from './constants/actionTypes'; +import { APP_LOAD, REDIRECT, SIGN_IN, LOGOUT } from './constants/actionTypes'; import { Route, Switch } from 'react-router-dom'; import Home from './containers/Home'; import { store } from './store'; @@ -25,7 +25,7 @@ const mapStateToProps = state => { appLoaded: state.common.appLoaded, appName: state.common.appName, currentUser: state.common.currentUser, - redirectTo: state.common.redirectTo + redirectTo: state.common.redirectTo, } }; @@ -33,33 +33,47 @@ const mapDispatchToProps = dispatch => ({ onLoad: (payload, token) => dispatch({ type: APP_LOAD, payload, token, skipTracking: true }), onRedirect: () => - dispatch({ type: REDIRECT }) + dispatch({ type: REDIRECT }), + onLoadProfile: (payload) => + dispatch({ type: SIGN_IN, payload }), + onClickLogout: () => dispatch({ type: LOGOUT }) }); + class App extends React.Component { componentWillReceiveProps(nextProps) { if (nextProps.redirectTo) { - // this.context.router.replace(nextProps.redirectTo); store.dispatch(push(nextProps.redirectTo)); this.props.onRedirect(); } } componentWillMount() { - const token = window.localStorage.getItem('jwt'); + const token = window.localStorage.getItem('access_token'); if (token) { agent.setToken(token); + this.props.onLoadProfile(agent.Auth.current()) } this.props.onLoad(token ? agent.Auth.current() : null, token); } + logout = () => { + this.props.onClickLogout(); + } render() { + if (!(window.location.pathname === "/login" || + window.location.pathname === "/register") && + !window.localStorage.getItem('access_token')) { + store.dispatch(push("/login")); + } + if (this.props.appLoaded) { return (
    + currentUser={this.props.currentUser} + onClickLogout={this.logout} /> @@ -80,14 +94,11 @@ class App extends React.Component {
    + currentUser={this.props.currentUser} + onClickLogout={this.logout} />
    ); } } -// App.contextTypes = { -// router: PropTypes.object.isRequired -// }; - export default connect(mapStateToProps, mapDispatchToProps)(App); diff --git a/almanac-web/src/Header.js b/almanac-web/src/Header.js index 68a6fba..702e7f8 100644 --- a/almanac-web/src/Header.js +++ b/almanac-web/src/Header.js @@ -11,8 +11,7 @@ class Header extends React.Component { {this.props.appName.toLowerCase()} - - +
    ); diff --git a/almanac-web/src/agent.js b/almanac-web/src/agent.js index d1ad137..bf81019 100644 --- a/almanac-web/src/agent.js +++ b/almanac-web/src/agent.js @@ -1,6 +1,6 @@ import superagentPromise from 'superagent-promise'; import _superagent from 'superagent'; -import { PAGE_SIZE } from './constants/commonConstants' +import { DEFAULT_PAGE_SIZE } from './constants/commonConstants' const superagent = superagentPromise(_superagent, global.Promise); @@ -12,9 +12,13 @@ const responseBody = res => res.body; let token = null; const tokenPlugin = req => { if (token) { - req.set('authorization', `Token ${token}`); + req.set('authorization', `Bearer ${token}`); } } +let basicAuth = "YWNtZTphY21lc2VjcmV0"; +const basicPlugin = req => { + req.set('authorization', `Basic ${basicAuth}`) +} const requests = { del: url => @@ -24,8 +28,11 @@ const requests = { put: (url, body) => superagent.put(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody), post: (url, body) => - superagent.post(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody) + superagent.post(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody), + postWithBasic: (url, body) => + superagent.post(`${API_ROOT}${url}?grant_type=password&username=${body.username}&password=${body.password}`).use(basicPlugin).then(responseBody) }; + const directRequest = { del: url => superagent.del(`${url}`).use(tokenPlugin).then(responseBody), @@ -39,9 +46,9 @@ const directRequest = { const Auth = { current: () => - requests.get('/user'), + requests.get('/users/user'), login: (email, password) => - requests.post('/users/login', { user: { email, password } }), + requests.postWithBasic('/oauth/token', { username: email, password: password }), register: (username, email, password) => requests.post('/users', { username, email, password }), save: user => @@ -53,10 +60,9 @@ const Tags = { }; const limit = (size, page) => `size=${size}&page=${page ? page : 0}`; -const omitSlug = article => Object.assign({}, article, { slug: undefined }) const Books = { all: page => - requests.get(`/books?${limit(PAGE_SIZE, page)}`), + requests.get(`/books?${limit(localStorage.getItem("page_size") ? localStorage.getItem("page_size") : DEFAULT_PAGE_SIZE, page)}`), del: slug => requests.del(`/books/${slug}`), get: slug => @@ -78,12 +84,10 @@ const Articles = { }; const Profile = { - follow: username => - requests.post(`/profiles/${username}/follow`), get: id => requests.get(`/users/${id}`), - unfollow: username => - requests.del(`/profiles/${username}/follow`) + me: () => + requests.get(`/users/me`) }; export default { diff --git a/almanac-web/src/components/HeaderList.js b/almanac-web/src/components/HeaderList.js index f6d0023..9d6ed35 100644 --- a/almanac-web/src/components/HeaderList.js +++ b/almanac-web/src/components/HeaderList.js @@ -1,57 +1,54 @@ import React from 'react'; import { Link } from 'react-router-dom'; - const HeaderList = props => { - return ( -
      -
    • - - Home - -
    • +class HeaderList extends React.Component { + render() { + let props = this.props; + return ( +
        - {!props.currentUser &&
      • - - Sign in - -
      • } - - {!props.currentUser && -
      • - - Sign up - -
      • - } - - {props.currentUser && -
      • - -  New Post + + Home
      • - } - {props.currentUser && -
      • - -  Settings - -
      • - } - {props.currentUser && -
      • - - {props.currentUser.username} - {props.currentUser.username} + {!props.currentUser && +
      • + + Sign in -
      • - } -
      - ); + } + + {!props.currentUser && +
    • + + Sign up + +
    • + } + + {props.currentUser && +
    • + + {/* {props.currentUser.username} */} + {props.currentUser.username} + +
    • + } + {props.currentUser && + + } +
    + ); + } } + export default HeaderList; diff --git a/almanac-web/src/components/ListPagination.js b/almanac-web/src/components/ListPagination.js index 4296588..f0d56b7 100644 --- a/almanac-web/src/components/ListPagination.js +++ b/almanac-web/src/components/ListPagination.js @@ -2,7 +2,7 @@ import React from 'react'; import agent from '../agent'; import { connect } from 'react-redux'; import { SET_PAGE } from '../constants/actionTypes'; -import { PAGE_SIZE } from '../constants/commonConstants'; +import { DEFAULT_PAGE_SIZE } from '../constants/commonConstants'; const mapDispatchToProps = dispatch => ({ onSetPage: (page, payload) => @@ -10,7 +10,10 @@ const mapDispatchToProps = dispatch => ({ }); const ListPagination = props => { - if (props.booksCount <= PAGE_SIZE) { + let pageSize = localStorage.getItem("page_size") + ? localStorage.getItem("page_size") + : DEFAULT_PAGE_SIZE; + if (props.booksCount <= pageSize) { return null; } @@ -20,7 +23,7 @@ const ListPagination = props => { } const setPage = page => { - props.onSetPage(page, agent.Books.all(page)) + props.onSetPage(page, agent.Books.all(page)) }; return ( @@ -36,7 +39,7 @@ const ListPagination = props => { }; return (
  • @@ -46,7 +49,6 @@ const ListPagination = props => { ); }) } - ); diff --git a/almanac-web/src/constants/actionTypes.js b/almanac-web/src/constants/actionTypes.js index f8cbc0a..dd49735 100644 --- a/almanac-web/src/constants/actionTypes.js +++ b/almanac-web/src/constants/actionTypes.js @@ -42,4 +42,6 @@ export const CREATE_ARTICLE = 'CREATE_ARTICLE'; export const DELETE_ARTICLE = 'DELETE_ARTICLE'; export const UPDATE_ARTICLE = 'UPDATE_ARTICLE'; export const BOOK_UPDATED = 'BOOK_UPDATED'; +export const SIGN_IN = 'SIGN_IN'; + diff --git a/almanac-web/src/constants/commonConstants.js b/almanac-web/src/constants/commonConstants.js index 2f38546..54c4a4a 100644 --- a/almanac-web/src/constants/commonConstants.js +++ b/almanac-web/src/constants/commonConstants.js @@ -1 +1 @@ -export const PAGE_SIZE = 2; \ No newline at end of file +export const DEFAULT_PAGE_SIZE = 2; \ No newline at end of file diff --git a/almanac-web/src/constants/config.js b/almanac-web/src/constants/config.js new file mode 100644 index 0000000..a9649da --- /dev/null +++ b/almanac-web/src/constants/config.js @@ -0,0 +1,9 @@ + +export const providerConfig = { + clientId : 'react-client', + redirectUri : window.location.origin + '/login', + authorizationUrl: 'http://localhost:8080/login/github', + scope :'', + width : 1080, + height : 640 + }; \ No newline at end of file diff --git a/almanac-web/src/containers/Article/ArticleCreate.js b/almanac-web/src/containers/Article/ArticleCreate.js index e606670..e8ebe6f 100644 --- a/almanac-web/src/containers/Article/ArticleCreate.js +++ b/almanac-web/src/containers/Article/ArticleCreate.js @@ -1,10 +1,7 @@ import React from 'react'; import agent from '../../agent'; -import { - CREATE_ARTICLE -} from '../../constants/actionTypes'; -import { Editor } from '@tinymce/tinymce-react'; import RichTextEditor from 'react-rte'; +import axios from 'axios'; class ArticleCreate extends React.Component { constructor() { @@ -14,6 +11,8 @@ class ArticleCreate extends React.Component { } this.updateField = this.updateField.bind(this); this.createArticle = this.createArticle.bind(this); + this.parseFile = this.parseFile.bind(this); + this.updateFile = this.updateFile.bind(this); }; createArticle() { var article = this.state.article; @@ -23,14 +22,37 @@ class ArticleCreate extends React.Component { this.setState({ article: { content: RichTextEditor.createEmptyValue() } }); this.props.history.push(`/books/${this.props.match.params.bookId}/edit`) }; + parseFile() { + if (this.state.file) { + const url = `http://localhost:8080/files`; + const formData = new FormData(); + formData.append('file', this.state.file) + const config = { + headers: { + 'content-type': 'multipart/form-data' + } + } + axios.post(url, formData, config) + .then(response => { + let newArticle = this.state.article; + newArticle.content =response.data.content? RichTextEditor.createValueFromString(response.data.content, "markdown") : RichTextEditor.createEmptyValue(); + this.setState({ + ...this.state, article: newArticle + }) + }); + } + }; + updateFile(event) { + this.setState({ file: event.target.files[0] }) + } updateField(event) { var article = { ...this.state.article, [event.target.name]: event.target.value }; this.setState( { article: article } ); - } + } onChangeRte = (value) => { - var article = { ...this.state.article,content: value }; + var article = { ...this.state.article, content: value }; this.setState({ article: article }); }; render() { @@ -38,36 +60,43 @@ class ArticleCreate extends React.Component { if (this.state.redirectTo) { } return ( -
    -
    - - -
    -
    - - -
    -
    - -
    -
    - +
    +
    + + -
  • - + ); } } diff --git a/almanac-web/src/containers/Article/ArticleEdit.js b/almanac-web/src/containers/Article/ArticleEdit.js index ccd1ca0..85b1dc6 100644 --- a/almanac-web/src/containers/Article/ArticleEdit.js +++ b/almanac-web/src/containers/Article/ArticleEdit.js @@ -33,6 +33,7 @@ class ArticleCreate extends React.Component { } componentWillReceiveProps(newProps) { var article = { ...newProps.article }; + article.content = article.content ? RichTextEditor.createValueFromString(article.content, "markdown") : RichTextEditor.createEmptyValue(); this.setState({ article: article }) } diff --git a/almanac-web/src/containers/Article/ArticleList.js b/almanac-web/src/containers/Article/ArticleList.js index a2634e1..ceca430 100644 --- a/almanac-web/src/containers/Article/ArticleList.js +++ b/almanac-web/src/containers/Article/ArticleList.js @@ -7,7 +7,8 @@ import axios from 'axios'; const mapStateToProps = state => ({ ...state, link: state.link, - currentUser: state.common.currentUser + currentUser: state.common.currentUser, + token:state.common.token }); class ArticleList extends React.Component { constructor() { @@ -18,9 +19,17 @@ class ArticleList extends React.Component { } } componentWillMount() { - if (this.props.book) { - axios.get(this.props.book.book.articles.href) - .then(response => this.setState({ ...this.state, articles: response.data })) + if (this.props.book + && this.props.book.book.articles + && this.props.book.book.articles.href) { + const req = { + url: this.props.book.book.articles.href, + headers: { Authorization: "bearer " + this.props.token }, + method: 'GET' + }; + axios.get(req.url,req) + .then(response => { + this.setState({ ...this.state, articles: response.data })}) } } @@ -45,7 +54,7 @@ class ArticleList extends React.Component { { articles.map(article => { return ( -
  • +
  • ); }) } diff --git a/almanac-web/src/containers/Article/ArticlePreview.js b/almanac-web/src/containers/Article/ArticlePreview.js index 3294f3a..78a94ab 100644 --- a/almanac-web/src/containers/Article/ArticlePreview.js +++ b/almanac-web/src/containers/Article/ArticlePreview.js @@ -13,7 +13,7 @@ const ArticlePreview = props => {
    {article.title}
    {article.created && -
    } +
    } diff --git a/almanac-web/src/containers/Article/index.js b/almanac-web/src/containers/Article/index.js index bd53cf9..a1cd720 100644 --- a/almanac-web/src/containers/Article/index.js +++ b/almanac-web/src/containers/Article/index.js @@ -50,11 +50,9 @@ class Book extends React.Component {
    edit -
    - -
    + + To book
    - to book
    @@ -63,11 +61,16 @@ class Book extends React.Component { {article.annotation}

    -
    -
    -
    -

    -
    + {article.pageCount && +
    +

    Page count: {article.pageCount}

    +
    } +
    + +
    +
    +
    +

    diff --git a/almanac-web/src/containers/Authentication/Login.js b/almanac-web/src/containers/Authentication/Login.js index e0fa092..147b1f2 100644 --- a/almanac-web/src/containers/Authentication/Login.js +++ b/almanac-web/src/containers/Authentication/Login.js @@ -6,8 +6,11 @@ import { connect } from 'react-redux'; import { UPDATE_FIELD_AUTH, LOGIN, - LOGIN_PAGE_UNLOADED + LOGIN_PAGE_UNLOADED, + SIGN_IN } from '../../constants/actionTypes'; +import OAuthProvider from './OAuthProvider'; +import { providerConfig } from '../../constants/config'; const mapStateToProps = state => ({ ...state.auth }); @@ -33,6 +36,17 @@ class Login extends React.Component { }; } + onOAuthProviderLogin = (data) => { + let token = JSON.stringify(data.code) || JSON.stringify(data); + window.localStorage.setItem('OAuthProvider_token', token); + this.setState({ token: token }); + window.location = "/home" + } + + onOAuthProviderLoginFailure = (err) => { + console.log("something wrong") + console.error(err); + } componentWillUnmount() { this.props.onUnload(); } @@ -82,9 +96,12 @@ class Login extends React.Component { disabled={this.props.inProgress}> Sign in - + diff --git a/almanac-web/src/containers/Authentication/OAuth2.js b/almanac-web/src/containers/Authentication/OAuth2.js new file mode 100644 index 0000000..f26bf12 --- /dev/null +++ b/almanac-web/src/containers/Authentication/OAuth2.js @@ -0,0 +1,41 @@ +import React, { Component } from 'react'; +import Popup from './Popup'; +import qs from 'querystring'; + +class OAuth2 extends Component { + constructor (props) { + super(props); + this.state = { popupOpen: false }; + this.handleClick = this.handleClick.bind(this); + } + + handleClick () { + this.setState({ popupOpen: true }); + console.log('clicked on button'); + } + + render () { + const props = this.props; + + const childrenWithProps = React.Children.map(props.children, (child) => { + return React.cloneElement(child, { onClick: this.handleClick }); + }); + + const params = { + client_id: props.clientId, + redirect_uri: props.redirectUri, + scope: props.scope, + display: 'popup', + response_type: 'token' + }; + + const url = props.authorizationUrl + + return
    + + {childrenWithProps} +
    ; + } +} + +export default OAuth2; diff --git a/almanac-web/src/containers/Authentication/OAuthProvider.js b/almanac-web/src/containers/Authentication/OAuthProvider.js new file mode 100644 index 0000000..09e55ee --- /dev/null +++ b/almanac-web/src/containers/Authentication/OAuthProvider.js @@ -0,0 +1,20 @@ +import React from 'react'; +import OAuth2 from './OAuth2'; + +export const OAuthProvider = props => { + const { config, textDisplay, className, successCallback, errorCallback } = props; + config.successCallback = successCallback; + config.errorCallback = errorCallback; + return ( + + + + ); +}; + +OAuthProvider.defaultProps = { + textDisplay: 'Sign in with OAuthProvider' +}; + + +export default OAuthProvider; diff --git a/almanac-web/src/containers/Authentication/Popup.js b/almanac-web/src/containers/Authentication/Popup.js new file mode 100644 index 0000000..e06e259 --- /dev/null +++ b/almanac-web/src/containers/Authentication/Popup.js @@ -0,0 +1,88 @@ +import React from 'react'; +import qs from 'querystring'; +import url from 'url'; +import Promise from 'bluebird'; + +class Popup extends React.Component { + constructor(props) { + super(props); + } + + componentDidUpdate() { + if (this.props.open) { + this.openPopup(); + } + } + + openPopup() { + const props = this.props; + const width = props.width || 500; + const height = props.height || 500; + + const options = { + width: width, + height: height, + top: window.screenY + ((window.outerHeight - height) / 2.5), + left: window.screenX + ((window.outerWidth - width) / 2) + }; + + const popup = window.open(props.popupUrl, '_blank', qs.stringify(options, ',')); + + if (props.popupUrl === 'about:blank') { + popup.document.body.innerHTML = 'Loading...'; + } + + this.pollPopup(popup).then(props.successCallback).catch(props.errorCallback); + } + + pollPopup(window) { + const props = this.props; + + return new Promise((resolve, reject) => { + const redirectUri = url.parse(props.redirectUri); + const redirectUriPath = redirectUri.host + redirectUri.pathname; + + const polling = setInterval(() => { + if (!window || window.closed || window.closed === undefined) { + clearInterval(polling); + reject(new Error('The popup window was closed')); + } + try { + const popupUrlPath = window.location.host + window.location.pathname; + + if (popupUrlPath === redirectUriPath) { + if (window.location.search || window.location.hash) { + const query = qs.parse(window.location.search.substring(1).replace(/\/$/, '')); + const hash = qs.parse(window.location.hash.substring(1).replace(/[\/$]/, '')); + const params = Object.assign({}, query, hash); + if (params.error) { + reject(new Error(params.error)); + } else { + resolve(params); + } + } else { + reject(new Error('OAuth redirect has occurred but no query or hash parameters were found.')); + } + // cleanup + clearInterval(polling); + window.close(); + } + } catch (error) { + // Ignore DOMException: Blocked a frame with origin from accessing a cross-origin frame. + // A hack to get around same-origin security policy errors in Internet Explorer. + } + }, 500); + }); + } + + handleClick() { + console.log('clicked on button'); + } + + render() { + return null; + } +} + + +export default Popup; diff --git a/almanac-web/src/containers/Book/BookCreate.js b/almanac-web/src/containers/Book/BookCreate.js index 47f2039..eeee641 100644 --- a/almanac-web/src/containers/Book/BookCreate.js +++ b/almanac-web/src/containers/Book/BookCreate.js @@ -6,10 +6,12 @@ import { connect } from 'react-redux'; import { CREATE_BOOK } from '../../constants/actionTypes'; +import ListErrors from '../Authentication/ListErrors'; const mapStateToProps = state => ({ ...state.book, - currentUser: state.common.currentUser + currentUser: state.common.currentUser, + token: state.common.token }); const mapDispatchToProps = dispatch => ({ onSubmit: payload => @@ -23,16 +25,63 @@ class BookCreate extends React.Component { } this.updateField = this.updateField.bind(this); this.createBook = this.createBook.bind(this); + this.parseFile = this.parseFile.bind(this); + this.updateFile = this.updateFile.bind(this); }; createBook() { var book = this.state.book; book.author = this.props.currentUser; const payload = agent.Books.create( { ...book }); - this.setState({ book: {}}); - + this.setState({ book: {} }); + this.props.history.push(`/`) }; + parseFile() { + if (this.state.file) { + const url = `http://localhost:8080/files/books`; + const formData = new FormData(); + formData.append('file', this.state.file) + const config = { + headers: { + 'content-type': 'multipart/form-data', + Authorization: "bearer " + this.props.token + } + } + axios.post(url, formData, config) + .then(response => { + let book = response.data; + this.setState({ ...this.state, book: book }); + + this.props.history.push(`/books/${book.bookId}`) + }) + .catch( + error => { + if (error.response) { + switch (error.response.status) { + case 400: + this.setState({ error: error.response.data.content }); + break; + case 500: + alert("Ka-boom") + break; + } + + } else if (error.request) { + alert("Server doesn't send response") + console.log(error.request); + } else { + // Something happened in setting up the request that triggered an Error + console.log('Error', error.message); + } + + } + ); + } + }; + updateFile(event) { + this.setState({ file: event.target.files[0] }) + } updateField(event) { var book = { ...this.state.book, [event.target.name]: event.target.value }; this.setState( @@ -41,49 +90,61 @@ class BookCreate extends React.Component { } render() { var book = this.props.book; - + return ( -
    -
    - - -
    -
    - -
    -
    - -
    +
    + +
    + + +
    +
    + +
    +
    + +
    -
    - +
    + +
    + {this.state.error && +
    + {this.state.error} +
    } + +
    - - +
    ); } } diff --git a/almanac-web/src/containers/Book/BookList.js b/almanac-web/src/containers/Book/BookList.js index 75dbb1e..13a02aa 100644 --- a/almanac-web/src/containers/Book/BookList.js +++ b/almanac-web/src/containers/Book/BookList.js @@ -8,6 +8,12 @@ const BookList = props => {
    Loading...
    ); } + const changeSize = e => { + console.dir(e.target.value) + localStorage.setItem("page_size", e.target.value) + this.state = { size: e.target.value } + + } if (props.books.length === 0) { return ( @@ -18,11 +24,19 @@ const BookList = props => { } return ( +
    + + { props.books.map(book => { return ( - + ); }) } diff --git a/almanac-web/src/containers/Book/BookPreview.js b/almanac-web/src/containers/Book/BookPreview.js index 785c4ae..d75920a 100644 --- a/almanac-web/src/containers/Book/BookPreview.js +++ b/almanac-web/src/containers/Book/BookPreview.js @@ -2,11 +2,11 @@ import React from 'react'; import { Link } from 'react-router-dom'; import axios from 'axios'; import { connect } from 'react-redux'; -import Profilepreview from '../Profile/ProfilePreview'; const mapStateToProps = state => ({ ...state.book, - currentUser: state.common.currentUser + currentUser: state.auth.currentUser, + token: state.common.token }); class BookPreview extends React.Component { @@ -17,8 +17,15 @@ class BookPreview extends React.Component { } } componentWillMount() { - if (this.props.book) { - axios.get(this.props.book.author && this.props.book.author.href) + if (this.props.book + && this.props.book.author + && this.props.book.author.href) { + let req = { + url: this.props.book.author.href, + headers: { Authorization: "bearer " + this.props.token }, + method: 'GET' + }; + axios.get(req.url, req) .then(response => { this.setState({ ...this.state, author: response.data }) }) @@ -33,10 +40,13 @@ class BookPreview extends React.Component {
    -

    {book.title}

    +
    {book.title}

    -

    {book.description}

    +

    {book.description + && (book.description.length < 120 + ? book.description + : book.description.substring(0, 120) + "...")}

    diff --git a/almanac-web/src/containers/Book/index.js b/almanac-web/src/containers/Book/index.js index d8f3dd5..c0b9a52 100644 --- a/almanac-web/src/containers/Book/index.js +++ b/almanac-web/src/containers/Book/index.js @@ -12,7 +12,8 @@ import axios from 'axios'; const mapStateToProps = state => ({ ...state.book, - currentUser: state.common.currentUser + currentUser: state.common.currentUser, + token: state.common.token }); const mapDispatchToProps = dispatch => ({ @@ -30,7 +31,17 @@ class Book extends React.Component { } downloadBook() { var links = this.props.book._links; - axios.get(links && links.download.href) + + if (links + && links.download + && links.download.href) { + let req = { + url: links.download.href, + headers: { Authorization: "bearer " + this.props.token }, + method: 'GET' + } + axios.get(req) + } } componentWillMount() { var getBook = agent.Books.get(this.props.match.params.id); @@ -57,7 +68,7 @@ class Book extends React.Component { {this.props.book._links && this.props.book._links.download &&
    download} @@ -85,7 +96,7 @@ class Book extends React.Component {
    -

    Size: {book.size}

    +

    Size: {book.pageCount}

    State: {book.state}

    diff --git a/almanac-web/src/containers/Profile/ProfilePreview.js b/almanac-web/src/containers/Profile/ProfilePreview.js index 2dc0ff9..0bce4db 100644 --- a/almanac-web/src/containers/Profile/ProfilePreview.js +++ b/almanac-web/src/containers/Profile/ProfilePreview.js @@ -6,7 +6,8 @@ import auth from '../../reducers/auth'; const mapStateToProps = state => ({ ...state.book, - currentUser: state.common.currentUser + currentUser: state.common.currentUser, + token: state.common.token }); class ProfilePreview extends React.Component { @@ -19,7 +20,12 @@ class ProfilePreview extends React.Component { componentWillMount() { console.dir(this.props.link) if (this.props.link) { - axios.get(this.props.link) + const req = { + url: this.props.link, + headers: { Authorization: "bearer " + this.props.token }, + method: 'GET' + }; + axios.get(req.url,req) .then(response => this.setState({ ...this.state, author: response.data })) } } diff --git a/almanac-web/src/containers/Profile/index.js b/almanac-web/src/containers/Profile/index.js index a6a50e7..edf56f5 100644 --- a/almanac-web/src/containers/Profile/index.js +++ b/almanac-web/src/containers/Profile/index.js @@ -6,7 +6,6 @@ import { PROFILE_PAGE_LOADED, PROFILE_PAGE_UNLOADED } from '../../constants/actionTypes'; -import axios from 'axios'; import { Link } from 'react-router-dom'; const mapStateToProps = state => ({ @@ -33,8 +32,6 @@ class Profile extends React.Component { componentWillMount() { var getProfile = agent.Profile.get(this.props.match.params.id); this.props.onLoad(Promise.all([getProfile])); - console.dir(this.props) - } componentWillUnmount() { this.props.onUnload(); @@ -80,8 +77,8 @@ class Profile extends React.Component {
    Books as coauthor:
    {booksAsSubAuthor.length === 0 &&
    There is no any book as coauthor
    - } - + } +
      { booksAsSubAuthor && booksAsSubAuthor.map(book =>
    1. @@ -91,6 +88,8 @@ class Profile extends React.Component {
    2. )}
    + + Create book
    diff --git a/almanac-web/src/middleware.js b/almanac-web/src/middleware.js index 070ad03..7497d91 100644 --- a/almanac-web/src/middleware.js +++ b/almanac-web/src/middleware.js @@ -47,13 +47,15 @@ const promiseMiddleware = store => next => action => { }; const localStorageMiddleware = store => next => action => { - if (action.type === REGISTER || action.type === LOGIN) { + if (action.type === LOGIN) { if (!action.error) { - window.localStorage.setItem('jwt', action.payload.user.token); - agent.setToken(action.payload.user.token); + window.localStorage.setItem('access_token', action.payload.access_token); + window.localStorage.setItem('refresh_token', action.payload.refresh_token); + agent.setToken(action.payload.access_token); } } else if (action.type === LOGOUT) { - window.localStorage.setItem('jwt', ''); + window.localStorage.setItem('access_token',''); + window.localStorage.setItem('refresh_token',''); agent.setToken(null); } diff --git a/almanac-web/src/reducers/auth.js b/almanac-web/src/reducers/auth.js index 6e83838..d3b63ea 100644 --- a/almanac-web/src/reducers/auth.js +++ b/almanac-web/src/reducers/auth.js @@ -4,7 +4,8 @@ import { LOGIN_PAGE_UNLOADED, REGISTER_PAGE_UNLOADED, ASYNC_START, - UPDATE_FIELD_AUTH + UPDATE_FIELD_AUTH, + SIGN_IN } from '../constants/actionTypes'; export default (state = {}, action) => { diff --git a/almanac-web/src/reducers/book.js b/almanac-web/src/reducers/book.js index a57b8e1..958c04e 100644 --- a/almanac-web/src/reducers/book.js +++ b/almanac-web/src/reducers/book.js @@ -11,29 +11,11 @@ export default (state = {}, action) => { var book = action.payload[0]; return { ...state, - book: book + book }; } - case CREATE_ARTICLE: - return { - ...state, redirectTo: '/' - }; case BOOK_PAGE_UNLOADED: return {}; - // case ADD_COMMENT: - // return { - // ...state, - // commentErrors: action.error ? action.payload.errors : null, - // comments: action.error ? - // null : - // (state.comments || []).concat([action.payload.comment]) - // }; - // case DELETE_COMMENT: - // const commentId = action.commentId - // return { - // ...state, - // comments: state.comments.filter(comment => comment.id !== commentId) - // }; default: return state; } diff --git a/almanac-web/src/reducers/bookList.js b/almanac-web/src/reducers/bookList.js index 1c5e26d..2a30331 100644 --- a/almanac-web/src/reducers/bookList.js +++ b/almanac-web/src/reducers/bookList.js @@ -16,15 +16,17 @@ export default (state = {}, action) => { currentPage: action.payload.page.number }; case HOME_PAGE_LOADED: - return { - ...state, - pager: action.payload[0].page, - books: action.payload[0]._embedded - ? action.payload[0]._embedded.bookResponseList - : [], - booksCount: action.payload[0].page.totalElements, - currentPage: 0 - }; + return !action.error ? + { + ...state, + pager: action.payload[0].page, + books: action.payload[0]._embedded + ? action.payload[0]._embedded.bookResponseList + : [], + booksCount: action.payload[0].page.totalElements, + currentPage: 0 + } + : { ...state, error: action.payload }; case HOME_PAGE_UNLOADED: return {}; default: diff --git a/almanac-web/src/reducers/common.js b/almanac-web/src/reducers/common.js index fb9b83d..2ee4e07 100644 --- a/almanac-web/src/reducers/common.js +++ b/almanac-web/src/reducers/common.js @@ -14,7 +14,8 @@ import { PROFILE_FAVORITES_PAGE_UNLOADED, SETTINGS_PAGE_UNLOADED, LOGIN_PAGE_UNLOADED, - REGISTER_PAGE_UNLOADED + REGISTER_PAGE_UNLOADED, + SIGN_IN } from '../constants/actionTypes'; const defaultState = { @@ -29,13 +30,17 @@ export default (state = defaultState, action) => { return { ...state, token: action.token || null, - appLoaded: true, - currentUser: action.payload ? action.payload.user : null + appLoaded: true }; case REDIRECT: return { ...state, redirectTo: null }; case LOGOUT: return { ...state, redirectTo: '/', token: null, currentUser: null }; + case SIGN_IN: + return { + ...state, + currentUser: action.payload + }; case ARTICLE_SUBMITTED: const redirectUrl = `/article/${action.payload.article.slug}`; return { ...state, redirectTo: redirectUrl }; @@ -50,7 +55,7 @@ export default (state = defaultState, action) => { return { ...state, redirectTo: action.error ? null : '/', - token: action.error ? null : action.payload.user.token, + token: action.error ? null : action.payload.access_token, currentUser: action.error ? null : action.payload.user }; case DELETE_ARTICLE: From e735029b1b5934b18df81b0957f82d7294525953 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Wed, 24 Apr 2019 12:46:09 +0300 Subject: [PATCH 17/24] Allow download and upload files. --- build.gradle | 4 + .../writer/domain/service/FileService.java | 13 + .../domain/service/files/ArticleParser.java | 9 + .../domain/service/files/BookParser.java | 19 + .../domain/service/files/TextParser.java | 7 + .../service/files/impl/DocxTextParser.java | 28 + .../service/files/impl/JsonArticleParser.java | 40 + .../service/files/impl/TxtTextParser.java | 24 + .../service/files/impl/XmlArticleParser.java | 57 + .../service/files/impl/XmlBookParser.java | 138 + .../domain/service/impl/FileServiceImpl.java | 97 + .../writer/web/controller/FileController.java | 56 + .../web/controller/FileControllerAdvice.java | 17 + .../domain/service/FileServiceTest.java | 37 + src/test/resources/files/ValidArticle.json | 5 + src/test/resources/files/t.fb2 | 3484 +++++++++++++++++ 16 files changed, 4035 insertions(+) create mode 100644 src/main/java/fic/writer/domain/service/FileService.java create mode 100644 src/main/java/fic/writer/domain/service/files/ArticleParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/BookParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/TextParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/impl/DocxTextParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/impl/JsonArticleParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/impl/TxtTextParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/impl/XmlArticleParser.java create mode 100644 src/main/java/fic/writer/domain/service/files/impl/XmlBookParser.java create mode 100644 src/main/java/fic/writer/domain/service/impl/FileServiceImpl.java create mode 100644 src/main/java/fic/writer/web/controller/FileController.java create mode 100644 src/main/java/fic/writer/web/controller/FileControllerAdvice.java create mode 100644 src/test/java/fic/writer/domain/service/FileServiceTest.java create mode 100644 src/test/resources/files/ValidArticle.json create mode 100644 src/test/resources/files/t.fb2 diff --git a/build.gradle b/build.gradle index 3d950a6..c6bddcf 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,10 @@ dependencies { compile("org.springframework.boot:spring-boot-starter-test") compile("org.springframework.boot:spring-boot-starter-hateoas") + compile("org.apache.poi:poi:4.1.0") + 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("com.h2database:h2") 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/files/ArticleParser.java b/src/main/java/fic/writer/domain/service/files/ArticleParser.java new file mode 100644 index 0000000..f30c26d --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/ArticleParser.java @@ -0,0 +1,9 @@ +package fic.writer.domain.service.files; + +public interface ArticleParser { + String getTitle(); + + String getAnnotation(); + + String getContent(); +} 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..e92fa57 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/BookParser.java @@ -0,0 +1,19 @@ +package fic.writer.domain.service.files; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.User; +import fic.writer.domain.entity.enums.State; + +import java.util.Set; + +public interface BookParser { + String getTitle(); + + Set getCoAuthors(); + + String getDescription(); + + State getState(); + + Set
    getArticles(); +} 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..dc18ea9 --- /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) { + String fileContent = ""; + try { + XWPFDocument document = new XWPFDocument(file.getInputStream()); + List paragraphs = document.getParagraphs(); + for (int i = 0; i < paragraphs.size(); i++) { + fileContent += paragraphs.get(i).getParagraphText(); + } + } catch (IOException e) { + throw new RuntimeException(); + } + return fileContent; + } +} 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..0105fa2 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/impl/JsonArticleParser.java @@ -0,0 +1,40 @@ +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..eedeafa --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/impl/XmlArticleParser.java @@ -0,0 +1,57 @@ +package fic.writer.domain.service.files.impl; + +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; + + public XmlArticleParser(MultipartFile multipartFile) { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder dBuilder = null; + Document doc; + try { + dBuilder = dbFactory.newDocumentBuilder(); + doc = dBuilder.parse(multipartFile.getInputStream()); + } catch (ParserConfigurationException e) { + e.printStackTrace(); + throw new RuntimeException(); + } catch (SAXException e) { + e.printStackTrace(); + throw new RuntimeException(); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + doc.getDocumentElement().normalize(); + element = doc.getDocumentElement(); + String title = element.getElementsByTagName("book-title").item(0).getFirstChild().getNodeValue(); + String annotation = element.getElementsByTagName("annotation").item(0).getFirstChild().getNodeValue(); + } + + @Override + public String getTitle() { + String title = element.getElementsByTagName("title").item(0).getFirstChild().getNodeValue(); + return title; + } + + @Override + public String getAnnotation() { + String annotation = element.getElementsByTagName("annotation").item(0).getFirstChild().getNodeValue(); + return null; + } + + @Override + public String getContent() { + String annotation = element.getElementsByTagName("section").item(0).getFirstChild().getNodeValue(); + return null; + } +} 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..6afab7b --- /dev/null +++ b/src/main/java/fic/writer/domain/service/files/impl/XmlBookParser.java @@ -0,0 +1,138 @@ +package fic.writer.domain.service.files.impl; + +import fic.writer.domain.entity.Article; +import fic.writer.domain.entity.User; +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.HashSet; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; + +public class XmlBookParser implements BookParser { + private Document doc; + private XPath xPath; + + public XmlBookParser(MultipartFile file) { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder dBuilder = null; + try { + dBuilder = dbFactory.newDocumentBuilder(); + doc = dBuilder.parse(file.getInputStream()); + } catch (ParserConfigurationException e) { + e.printStackTrace(); + throw new RuntimeException(); + } catch (SAXException e) { + e.printStackTrace(); + throw new RuntimeException(); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + + xPath = XPathFactory.newInstance().newXPath(); + + doc.getDocumentElement().normalize(); + } + + @Override + public String getTitle() { + String title = ""; + try { + XPathExpression expr = xPath.compile("//FictionBook/description/title-info/book-title"); + title = (String) expr.evaluate(doc, XPathConstants.STRING); + } catch (XPathExpressionException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + title = title.replaceAll("(\\s)\\1", "$1"); + return title; + } + + @Override + public Set getCoAuthors() { + return new HashSet<>(); + } + + @Override + public String getDescription() { + String description = ""; + try { + XPathExpression expr = xPath.compile("//FictionBook/description/title-info/annotation"); + description = (String) expr.evaluate(doc, XPathConstants.STRING); + } catch (XPathExpressionException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + description = description.trim().replaceAll("(\\s)\\1", "$1"); + return description; + } + + @Override + public State getState() { + return null; + } + + @Override + public Set
    getArticles() { + Set
    articles = new LinkedHashSet<>(); + try { + XPathExpression sectionExpression = xPath.compile("//body"); + Element bodyElements = (Element) sectionExpression.evaluate(doc, XPathConstants.NODE); + NodeList articlesNode = bodyElements.getElementsByTagName("section"); + for (int i = 0; i < articlesNode.getLength(); i++) { + Element node = (Element) articlesNode.item(i); + Optional titleNode = getNode(node, "title", 0); + String title = titleNode.map(Node::getTextContent) + .orElse(""); + title = title.trim().replaceAll("(\\s)\\1", "$1"); + + Optional annotationNode = getNode(node, "annotation", 0); + String annotation = annotationNode.map(Node::getTextContent) + .orElse(""); + annotation = annotation.trim().replaceAll("(\\s)\\1", "$1"); + StringBuilder content = new StringBuilder(); + NodeList nodeList = node.getElementsByTagName("p"); + for (int j = 0; j < nodeList.getLength(); j++) { + if (nodeList.item(j).getParentNode().getNodeName() == "section") { + content.append("\n" + nodeList.item(j).getTextContent() + "\n"); + } + } + articles.add(Article.builder() + .title(title) + .annotation(annotation) + .content(content.toString()) + .build()); + } + + } catch (XPathExpressionException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + return articles; + } + + 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; + + } +} 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..66c2d2a --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/FileServiceImpl.java @@ -0,0 +1,97 @@ +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.files.impl.*; +import fic.writer.domain.utils.FileExtention; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Objects; + +@Service +public class FileServiceImpl implements FileService { + @Override + public String parseText(MultipartFile file) { + FileExtention extension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + TextParser parser = selectTextParser(extension); + return parser.parseFile(file); + } + + public Article parseArticle(MultipartFile file) { + FileExtention extension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + ArticleParser parser = selectArticleParser(extension, file); + return Article.builder() + .title(parser.getTitle()) + .annotation(parser.getAnnotation()) + .content(parser.getContent()) + .build(); + } + + public Book parseBook(MultipartFile file) { + FileExtention extension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + BookParser parser = selectBookParser(extension, file); + return Book.builder() + .title(parser.getTitle()) + .description(parser.getDescription()) + .articles(parser.getArticles()) + .build(); + } + + private BookParser selectBookParser(FileExtention fileExtention, MultipartFile file) { + BookParser parser; + switch (fileExtention) { + case FB2: + parser = new XmlBookParser(file); + break; + default: + throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); + } + return parser; + } + + private ArticleParser selectArticleParser(FileExtention fileExtention, MultipartFile file) { + ArticleParser parser; + switch (fileExtention) { + case XML: + parser = new XmlArticleParser(file); + break; + case JSON: + parser = new JsonArticleParser(file); + break; + case FB2: + parser = new XmlArticleParser(file); + default: + throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); + } + return parser; + } + + private TextParser selectTextParser(FileExtention fileExtention) { + TextParser parser; + switch (fileExtention) { + case TXT: + parser = new TxtTextParser(); + break; + case DOCX: + parser = new DocxTextParser(); + break; + default: + throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); + } + return parser; + } + + private FileExtention getExtension(String fileName) { + int pointIndex = fileName.lastIndexOf('.'); + int pointPosition = pointIndex + 1; + return pointIndex == -1 + ? FileExtention.TXT + : FileExtention.valueOf(fileName.substring(pointPosition).toUpperCase()); + } + +} 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..775b433 --- /dev/null +++ b/src/main/java/fic/writer/web/controller/FileController.java @@ -0,0 +1,56 @@ +package fic.writer.web.controller; + +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.dto.BookDto; +import fic.writer.domain.repository.BookRepository; +import fic.writer.domain.service.BookService; +import fic.writer.domain.service.FileService; +import fic.writer.domain.service.UserService; +import fic.writer.web.response.BookResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.HashMap; +import java.util.Map; + +@RestController +public class FileController { + @Autowired + private FileService fileService; + @Autowired + private BookService bookService; + @Autowired + private UserService userService; + @Autowired + private BookRepository bookRepository; + + @PostMapping("/files") + @ResponseStatus(HttpStatus.CREATED) + public Resource takeArticleContentFromFile(@RequestParam("file") MultipartFile file) { + Map map = new HashMap<>(); + return new Resource<>(fileService.parseText(file)); + } + + @PostMapping("/files/books") + @ResponseStatus(HttpStatus.CREATED) + public BookResponse takeBookFromFile(@RequestParam("file") MultipartFile file) { + Map map = new HashMap<>(); + Book book = fileService.parseBook(file); + Book createdBook = bookService.create(BookDto.of(book)); + userService.addBookAsAuthor(createdBook.getAuthor().getId(), createdBook.getId()); + + createdBook.setArticles(book.getArticles()); + createdBook.getArticles().forEach(a -> { + a.setBook(createdBook); + userService.addBookAsAuthor(createdBook.getAuthor().getId(), createdBook.getId()); + }); + + return new BookResponse(createdBook); + } +} 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/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/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 From 37dd2a0e8efa164ac441726abfd99c540d78648e Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Wed, 24 Apr 2019 13:17:24 +0300 Subject: [PATCH 18/24] Add token-based security --- build.gradle | 2 + .../java/fic/writer/domain/entity/User.java | 1 + .../writer/domain/entity/auth/CustomUser.java | 1 - .../writer/domain/entity/auth/OauthUser.java | 2 +- .../repository/CustomUserRepository.java | 10 ++++ .../repository/OauthUserRepository.java | 7 +++ .../domain/repository/UserRepository.java | 2 + .../domain/service/OauthUserService.java | 6 ++ .../writer/domain/service/UserService.java | 4 ++ .../service/impl/OauthUserServiceImpl.java | 48 ++++++++++++++++ .../domain/service/impl/UserServiceImpl.java | 14 +++++ .../web/config/database/init/UserLoader.java | 37 ++++++++++++ ...horizationServerSecurityConfiguration.java | 37 ++++++++++++ .../web/config/security/SecurityConfig.java | 28 ++++++++++ .../authorization/CustomUserDetails.java | 56 +++++++++++++++++++ .../authorization/UserDetailServiceImpl.java | 38 +++++++++++++ .../oauth/ResourceServerConfiguration.java | 22 ++++++++ .../writer/web/controller/UserController.java | 8 ++- src/main/resources/application-oauth-acme.yml | 7 +++ src/main/resources/application.yml | 13 ++++- .../resources/application-oauth-react-client | 7 +++ src/test/resources/application.yml | 7 ++- 22 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 src/main/java/fic/writer/domain/repository/CustomUserRepository.java create mode 100644 src/main/java/fic/writer/domain/repository/OauthUserRepository.java create mode 100644 src/main/java/fic/writer/domain/service/OauthUserService.java create mode 100644 src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java create mode 100644 src/main/java/fic/writer/web/config/database/init/UserLoader.java create mode 100644 src/main/java/fic/writer/web/config/security/CorsEnabledAuthorizationServerSecurityConfiguration.java create mode 100644 src/main/java/fic/writer/web/config/security/SecurityConfig.java create mode 100644 src/main/java/fic/writer/web/config/security/authorization/CustomUserDetails.java create mode 100644 src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java create mode 100644 src/main/java/fic/writer/web/config/security/oauth/ResourceServerConfiguration.java create mode 100644 src/main/resources/application-oauth-acme.yml create mode 100644 src/test/resources/application-oauth-react-client diff --git a/build.gradle b/build.gradle index c6bddcf..e16826d 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,8 @@ dependencies { 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.oauth.boot:spring-security-oauth2-autoconfigure:2.1.3.RELEASE") compile("org.apache.poi:poi:4.1.0") compile("fr.opensagres.xdocreport:org.apache.poi.xwpf.converter.xhtml:1.0.4") diff --git a/src/main/java/fic/writer/domain/entity/User.java b/src/main/java/fic/writer/domain/entity/User.java index d84375a..d53b816 100644 --- a/src/main/java/fic/writer/domain/entity/User.java +++ b/src/main/java/fic/writer/domain/entity/User.java @@ -24,4 +24,5 @@ public class User { @OneToMany(fetch = FetchType.LAZY) @Singular("booksAsAuthor") private Set booksAsAuthor; + private String email; } diff --git a/src/main/java/fic/writer/domain/entity/auth/CustomUser.java b/src/main/java/fic/writer/domain/entity/auth/CustomUser.java index 22f2961..e583939 100644 --- a/src/main/java/fic/writer/domain/entity/auth/CustomUser.java +++ b/src/main/java/fic/writer/domain/entity/auth/CustomUser.java @@ -17,6 +17,5 @@ public class CustomUser { private Long id; @OneToOne(fetch = FetchType.EAGER) private User profile; - private String email; private String password; } diff --git a/src/main/java/fic/writer/domain/entity/auth/OauthUser.java b/src/main/java/fic/writer/domain/entity/auth/OauthUser.java index d9c38b4..4369f7a 100644 --- a/src/main/java/fic/writer/domain/entity/auth/OauthUser.java +++ b/src/main/java/fic/writer/domain/entity/auth/OauthUser.java @@ -16,7 +16,7 @@ public class OauthUser { @Id @GeneratedValue private Long id; - @OneToOne(fetch = FetchType.EAGER) + @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL) private User profile; private String token; private Date expireDate; diff --git a/src/main/java/fic/writer/domain/repository/CustomUserRepository.java b/src/main/java/fic/writer/domain/repository/CustomUserRepository.java new file mode 100644 index 0000000..ec897a1 --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/CustomUserRepository.java @@ -0,0 +1,10 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.auth.CustomUser; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CustomUserRepository extends JpaRepository { + Optional findByProfileId(Long id); +} diff --git a/src/main/java/fic/writer/domain/repository/OauthUserRepository.java b/src/main/java/fic/writer/domain/repository/OauthUserRepository.java new file mode 100644 index 0000000..56e26d3 --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/OauthUserRepository.java @@ -0,0 +1,7 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.auth.OauthUser; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OauthUserRepository extends JpaRepository { +} diff --git a/src/main/java/fic/writer/domain/repository/UserRepository.java b/src/main/java/fic/writer/domain/repository/UserRepository.java index 361fabf..131e193 100644 --- a/src/main/java/fic/writer/domain/repository/UserRepository.java +++ b/src/main/java/fic/writer/domain/repository/UserRepository.java @@ -7,4 +7,6 @@ public interface UserRepository extends JpaRepository { Optional findByUsername(String username); + + Optional findByEmail(String email); } diff --git a/src/main/java/fic/writer/domain/service/OauthUserService.java b/src/main/java/fic/writer/domain/service/OauthUserService.java new file mode 100644 index 0000000..b0c9ddc --- /dev/null +++ b/src/main/java/fic/writer/domain/service/OauthUserService.java @@ -0,0 +1,6 @@ +package fic.writer.domain.service; + +import fic.writer.domain.entity.auth.OauthUser; + +public interface OauthUserService extends CrudService { +} diff --git a/src/main/java/fic/writer/domain/service/UserService.java b/src/main/java/fic/writer/domain/service/UserService.java index 5df0850..10061aa 100644 --- a/src/main/java/fic/writer/domain/service/UserService.java +++ b/src/main/java/fic/writer/domain/service/UserService.java @@ -17,8 +17,12 @@ public interface UserService { Optional findByUsername(String username); + Optional findByEmail(String email); + User create(UserDto user); + User addBookAsAuthor(Long userId, Long bookId); + User update(Long userId, UserDto user); void delete(User user); diff --git a/src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java new file mode 100644 index 0000000..33d1e13 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java @@ -0,0 +1,48 @@ +package fic.writer.domain.service.impl; + +import fic.writer.domain.entity.auth.OauthUser; +import fic.writer.domain.repository.OauthUserRepository; +import fic.writer.domain.service.OauthUserService; +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 OauthUserServiceImpl implements OauthUserService { + @Autowired + private OauthUserRepository oauthUserRepository; + + @Override + public List findAll() { + return oauthUserRepository.findAll(); + } + + @Override + public Page findPage(Pageable pageable) { + return null; + } + + @Override + public Optional findById(Long id) { + return oauthUserRepository.findById(id); + } + + @Override + public OauthUser save(OauthUser oauthUser) { + return oauthUserRepository.save(oauthUser); + } + + @Override + public void delete(OauthUser oauthUser) { + oauthUserRepository.delete(oauthUser); + } + + @Override + public void deleteById(Long id) { + oauthUserRepository.deleteById(id); + } +} diff --git a/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java index 30dba9e..47b71b1 100644 --- a/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java +++ b/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java @@ -1,5 +1,6 @@ package fic.writer.domain.service.impl; +import fic.writer.domain.entity.Book; import fic.writer.domain.entity.User; import fic.writer.domain.entity.dto.UserDto; import fic.writer.domain.repository.UserRepository; @@ -42,6 +43,11 @@ public Optional findByUsername(String username) { return userRepository.findByUsername(username); } + @Override + public Optional findByEmail(String email) { + return userRepository.findByEmail(email); + } + @Override public User create(UserDto userDto) { User user = User.builder().build(); @@ -50,6 +56,14 @@ public User create(UserDto userDto) { } + @Override + public User addBookAsAuthor(Long userId, Long bookId) { + User user = userRepository.findById(userId).get(); + Book book = Book.builder().id(bookId).build(); + user.getBooksAsAuthor().add(book); + return userRepository.save(user); + } + @Override public User update(Long userId, UserDto userDto) { User user = userRepository.findById(userId).orElseThrow(EntityNotFoundException::new); 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..c268a56 --- /dev/null +++ b/src/main/java/fic/writer/web/config/database/init/UserLoader.java @@ -0,0 +1,37 @@ +package fic.writer.web.config.database.init; + +import fic.writer.domain.entity.User; +import fic.writer.domain.entity.auth.CustomUser; +import fic.writer.domain.repository.CustomUserRepository; +import fic.writer.domain.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class UserLoader implements ApplicationRunner { + @Autowired + UserRepository userRepository; + @Autowired + CustomUserRepository customUserRepository; + + @Override + public void run(ApplicationArguments args) throws Exception { + User user = User.builder() + .id(1L) + .information("first user information") + .username("user@mail.cc") + .email("firstUser@mail.com") + .build(); + userRepository.save(user); + CustomUser customUser = CustomUser.builder() + .id(1L) + .password("qwerty") + .profile(user) + .build(); + customUserRepository.save(customUser); + + + } +} diff --git a/src/main/java/fic/writer/web/config/security/CorsEnabledAuthorizationServerSecurityConfiguration.java b/src/main/java/fic/writer/web/config/security/CorsEnabledAuthorizationServerSecurityConfiguration.java new file mode 100644 index 0000000..2bdd37a --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/CorsEnabledAuthorizationServerSecurityConfiguration.java @@ -0,0 +1,37 @@ +package fic.writer.web.config.security; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerEndpointsConfiguration; +import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerSecurityConfiguration; +import org.springframework.security.web.access.channel.ChannelProcessingFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +@Import(AuthorizationServerEndpointsConfiguration.class) +@Order(-1) +public class CorsEnabledAuthorizationServerSecurityConfiguration extends AuthorizationServerSecurityConfiguration { + + @Override + protected void configure(HttpSecurity http) throws Exception { + super.configure(http); + CorsConfigurationSource source = corsConfigurationSource(); + http.addFilterBefore(new CorsFilter(source), ChannelProcessingFilter.class); + } + + private CorsConfigurationSource corsConfigurationSource() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOrigin("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("POST"); + //more config + source.registerCorsConfiguration("/**", config); + return source; + } +} \ No newline at end of file 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..b872e81 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/SecurityConfig.java @@ -0,0 +1,28 @@ +package fic.writer.web.config.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; + +@Configuration +public class SecurityConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurerAdapter() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**").allowedOrigins("http://localhost:3000"); + } + }; + } + +} diff --git a/src/main/java/fic/writer/web/config/security/authorization/CustomUserDetails.java b/src/main/java/fic/writer/web/config/security/authorization/CustomUserDetails.java new file mode 100644 index 0000000..7b4cab6 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/authorization/CustomUserDetails.java @@ -0,0 +1,56 @@ +package fic.writer.web.config.security.authorization; + +import fic.writer.domain.entity.User; +import lombok.Getter; +import org.assertj.core.util.Lists; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; + +public class CustomUserDetails implements UserDetails { + @Getter + private User user; + private String password; + + public CustomUserDetails(User user, String password) { + this.user = user; + this.password = password; + } + + @Override + public Collection getAuthorities() { + return Lists.list(new SimpleGrantedAuthority("USER")); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return user.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; + } +} diff --git a/src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java b/src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java new file mode 100644 index 0000000..35ba739 --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java @@ -0,0 +1,38 @@ +package fic.writer.web.config.security.authorization; + +import fic.writer.domain.entity.User; +import fic.writer.domain.entity.auth.CustomUser; +import fic.writer.domain.repository.CustomUserRepository; +import fic.writer.domain.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; + +@Service +public class UserDetailServiceImpl implements UserDetailsService { + @Autowired + private UserRepository userRepository; + @Autowired + private CustomUserRepository customUserRepository; + @Autowired + private PasswordEncoder passwordEncoder; + + @Override + @Transactional + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByEmail(username).orElseThrow(() -> new BadCredentialsException("username: " + username + " not found")); + CustomUser customUser = customUserRepository.findByProfileId(user.getId()).orElseThrow(() -> new BadCredentialsException("username: " + user.getEmail() + " not found")); + + CustomUserDetails customUserDetails = new CustomUserDetails(user, passwordEncoder.encode(customUser.getPassword())); + + return customUserDetails; + + } + +} \ No newline at end of file diff --git a/src/main/java/fic/writer/web/config/security/oauth/ResourceServerConfiguration.java b/src/main/java/fic/writer/web/config/security/oauth/ResourceServerConfiguration.java new file mode 100644 index 0000000..f89935c --- /dev/null +++ b/src/main/java/fic/writer/web/config/security/oauth/ResourceServerConfiguration.java @@ -0,0 +1,22 @@ +package fic.writer.web.config.security.oauth; + +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; +import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; + +@Configuration +@EnableResourceServer +public class ResourceServerConfiguration + extends ResourceServerConfigurerAdapter { + @Override + public void configure(HttpSecurity http) throws Exception { + http + .antMatcher("/**") + .authorizeRequests() + .mvcMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .anyRequest().authenticated() + .and().csrf().disable(); + } +} diff --git a/src/main/java/fic/writer/web/controller/UserController.java b/src/main/java/fic/writer/web/controller/UserController.java index dd1f123..504ffd5 100644 --- a/src/main/java/fic/writer/web/controller/UserController.java +++ b/src/main/java/fic/writer/web/controller/UserController.java @@ -9,12 +9,12 @@ import org.springframework.web.bind.annotation.*; import javax.persistence.EntityNotFoundException; +import java.security.Principal; import java.util.List; import java.util.stream.Collectors; @RestController @RequestMapping("/users") -@CrossOrigin(origins = "http://localhost:3000") public class UserController { private static final String ID_TEMPLATE_PATH = "/{userId}"; private static final String ID_TEMPLATE = "userId"; @@ -58,4 +58,10 @@ public UserResponse updateUser(@PathVariable(ID_TEMPLATE) Long id, @RequestBody public void deleteUser(Long id) { userService.deleteById(id); } + + @RequestMapping({"/user", "/me"}) + public UserResponse user(Principal principal) { + UserResponse user = userService.findByEmail(principal.getName()).map(UserResponse::new).orElseThrow(EntityNotFoundException::new); + return user; + } } diff --git a/src/main/resources/application-oauth-acme.yml b/src/main/resources/application-oauth-acme.yml new file mode 100644 index 0000000..2b33412 --- /dev/null +++ b/src/main/resources/application-oauth-acme.yml @@ -0,0 +1,7 @@ +security: + oauth2: + client: + client-id: acme + client-secret: acmesecret + scope: read,write + auto-approve-scopes: '.*' \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 596c249..34e47cb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,9 +1,16 @@ spring: + main: + allow-bean-definition-overriding: true profiles: - active: db-mysql + active: db-mysql, oauth-github, oauth-acme + servlet: + multipart: + max-file-size: 5MB + max-request-size: 5MB + location: ${user.dir}/files/temp logging: level: org: - springframework: INFO + springframework: DEBUG server: - port: 8080 \ No newline at end of file + port: 8080 diff --git a/src/test/resources/application-oauth-react-client b/src/test/resources/application-oauth-react-client new file mode 100644 index 0000000..2b33412 --- /dev/null +++ b/src/test/resources/application-oauth-react-client @@ -0,0 +1,7 @@ +security: + oauth2: + client: + client-id: acme + client-secret: acmesecret + scope: read,write + auto-approve-scopes: '.*' \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 5828953..530ddd8 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,6 +1,8 @@ spring: profiles: - active: db-mysql + active: db-mysql, oauth-github, oauth-react-client + main: + allow-bean-definition-overriding: true jpa: hibernate: ddl-auto: create-drop @@ -11,5 +13,4 @@ spring: logging: level: org: - springframework: INFO - + springframework: INFO \ No newline at end of file From dc3d5faa7f4c191b6553ba279b66b85c0636e044 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Wed, 24 Apr 2019 14:04:10 +0300 Subject: [PATCH 19/24] Set book author by audit --- .../audit/SpringSecurityAuditorAware.java | 23 +++++++++++++++++++ .../java/fic/writer/domain/entity/Book.java | 22 +++++++++++++++++- .../domain/service/impl/BookServiceImpl.java | 10 +++++--- .../web/config/audit/PersistenceConfig.java | 18 +++++++++++++++ .../writer/web/controller/BookController.java | 8 +++++-- .../domain/service/ArticleServiceTest.java | 2 -- .../service/BookAndArticleServicesTest.java | 2 -- 7 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 src/main/java/fic/writer/domain/audit/SpringSecurityAuditorAware.java create mode 100644 src/main/java/fic/writer/web/config/audit/PersistenceConfig.java 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..69dd208 --- /dev/null +++ b/src/main/java/fic/writer/domain/audit/SpringSecurityAuditorAware.java @@ -0,0 +1,23 @@ +package fic.writer.domain.audit; + +import fic.writer.domain.entity.User; +import fic.writer.domain.service.UserService; +import fic.writer.web.config.security.authorization.CustomUserDetails; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.AuditorAware; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +public class SpringSecurityAuditorAware implements AuditorAware { + @Autowired + private UserService userService; + + @Override + public Optional getCurrentAuditor() { + Optional user = Optional.ofNullable(((CustomUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getUser()); + return user; + } +} \ 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 index d3ad3e8..0c5b495 100644 --- a/src/main/java/fic/writer/domain/entity/Book.java +++ b/src/main/java/fic/writer/domain/entity/Book.java @@ -3,6 +3,8 @@ 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 java.util.Set; @@ -13,12 +15,14 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@EntityListeners(AuditingEntityListener.class) public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @ManyToOne + @CreatedBy private User author; @ManyToMany(fetch = FetchType.LAZY) @JoinTable(name = "book_subauthors", @@ -30,14 +34,17 @@ public class Book { @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.EAGER, orphanRemoval = true) + @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")}, @@ -51,4 +58,17 @@ public class Book { ) @Singular("actors") private Set actors; + + @PostLoad + private void calculatePageCount() { + this.pageCount = articles.stream().mapToLong(Article::getPageCount).sum(); + } + + @PostPersist + private void updateAuthor() { + if (author != null) { + + } + } + } diff --git a/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java index 8d850e8..513c9f5 100644 --- a/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java +++ b/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java @@ -17,6 +17,7 @@ import javax.persistence.EntityListeners; import javax.persistence.EntityNotFoundException; import java.io.IOException; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -53,7 +54,8 @@ public Optional findById(Long id) { public Book create(BookDto bookDto) { Book book = Book.builder().build(); flushBookDtoToBook(book, bookDto); - return bookRepository.save(book); + Book savedBook = bookRepository.save(book); + return savedBook; } @Override @@ -68,8 +70,10 @@ public Book addArticle(Long bookId, ArticleDto articleDto) { Book book = bookRepository.getOne(bookId); Article article = Article.builder().build(); flushArticleDtoToArticle(article, articleDto); - article.setBook(Book.builder().id(bookId).build()); - book.getArticles().add(article); + article.setBook(book); + Set
    articles = new HashSet<>(book.getArticles()); + articles.add(article); + book.setArticles(articles); bookRepository.save(book); return book; } 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..3c6e008 --- /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.User; +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/controller/BookController.java b/src/main/java/fic/writer/web/controller/BookController.java index 1969ea6..58a1407 100644 --- a/src/main/java/fic/writer/web/controller/BookController.java +++ b/src/main/java/fic/writer/web/controller/BookController.java @@ -3,6 +3,7 @@ import fic.writer.domain.entity.Book; import fic.writer.domain.entity.dto.BookDto; import fic.writer.domain.service.BookService; +import fic.writer.domain.service.UserService; import fic.writer.web.response.BookResponse; import fic.writer.web.response.PageResponse; import org.springframework.beans.factory.annotation.Autowired; @@ -20,18 +21,20 @@ @RestController @RequestMapping(value = "/books", produces = MediaType.APPLICATION_JSON_VALUE) -@CrossOrigin(origins = "http://localhost:3000") public class BookController { private static final String ID_TEMPLATE_PATH = "/{bookId}"; private static final String ID_TEMPLATE = "bookId"; private BookService bookService; + private UserService userService; @Autowired - public BookController(BookService bookService) { + public BookController(BookService bookService, UserService userService) { this.bookService = bookService; + this.userService = userService; } + @GetMapping public PageResponse getAllBooks(Pageable pageable) { Page resourcePage = bookService.findPage(pageable).map(BookResponse::new); @@ -49,6 +52,7 @@ public BookResponse getBookById(@PathVariable(ID_TEMPLATE) Long id) { @ResponseStatus(HttpStatus.CREATED) public BookResponse createBook(@RequestBody BookDto book) { Book savedBook = bookService.create(book); + userService.addBookAsAuthor(savedBook.getAuthor().getId(), savedBook.getId()); return new BookResponse(savedBook); } diff --git a/src/test/java/fic/writer/domain/service/ArticleServiceTest.java b/src/test/java/fic/writer/domain/service/ArticleServiceTest.java index 9c52f6f..fbbf633 100644 --- a/src/test/java/fic/writer/domain/service/ArticleServiceTest.java +++ b/src/test/java/fic/writer/domain/service/ArticleServiceTest.java @@ -6,7 +6,6 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.test.context.junit4.SpringRunner; import java.util.Date; @@ -15,7 +14,6 @@ @RunWith(SpringRunner.class) @SpringBootTest -@EnableJpaAuditing public class ArticleServiceTest { @Autowired private ArticleService articleService; diff --git a/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java b/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java index 3b2b564..113c42b 100644 --- a/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java +++ b/src/test/java/fic/writer/domain/service/BookAndArticleServicesTest.java @@ -7,7 +7,6 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.transaction.annotation.Transactional; @@ -15,7 +14,6 @@ @RunWith(SpringRunner.class) @SpringBootTest -@EnableJpaAuditing @Transactional public class BookAndArticleServicesTest { @Autowired From 34508c25c98bd22163f68f09528265db7fab842c Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Wed, 24 Apr 2019 14:20:27 +0300 Subject: [PATCH 20/24] Calculate count of pages in article --- src/main/java/fic/writer/domain/entity/Article.java | 6 +++++- .../java/fic/writer/web/controller/ArticleController.java | 1 - src/main/java/fic/writer/web/response/ArticleResponse.java | 2 ++ src/main/java/fic/writer/web/response/BookResponse.java | 2 ++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/fic/writer/domain/entity/Article.java b/src/main/java/fic/writer/domain/entity/Article.java index 5e1f6f1..c701389 100644 --- a/src/main/java/fic/writer/domain/entity/Article.java +++ b/src/main/java/fic/writer/domain/entity/Article.java @@ -1,6 +1,7 @@ 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; @@ -17,6 +18,7 @@ @Builder @EntityListeners(AuditingEntityListener.class) public class Article { + private static final int CHARS_IN_PAGE = 1800; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -25,11 +27,13 @@ public class Article { private Date created; @LastModifiedDate private Date lastModify; - @Column(columnDefinition = "text") + @Column(columnDefinition = "LONGTEXT") private String content; private String annotation; @ManyToOne(fetch = FetchType.LAZY) private Book book; @OneToMany(cascade = CascadeType.REMOVE) private Set actorStates; + @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/web/controller/ArticleController.java b/src/main/java/fic/writer/web/controller/ArticleController.java index 4016fbb..5de11e1 100644 --- a/src/main/java/fic/writer/web/controller/ArticleController.java +++ b/src/main/java/fic/writer/web/controller/ArticleController.java @@ -15,7 +15,6 @@ @RestController @RequestMapping(value = "/books/{bookId}/articles", produces = MediaType.APPLICATION_JSON_VALUE) -@CrossOrigin(origins = "http://localhost:3000") public class ArticleController { private static final String ID_TEMPLATE_PATH = "/{articleId}"; private static final String ID_TEMPLATE = "articleId"; diff --git a/src/main/java/fic/writer/web/response/ArticleResponse.java b/src/main/java/fic/writer/web/response/ArticleResponse.java index ca96f1e..8adfab1 100644 --- a/src/main/java/fic/writer/web/response/ArticleResponse.java +++ b/src/main/java/fic/writer/web/response/ArticleResponse.java @@ -21,6 +21,7 @@ public class ArticleResponse extends ResourceSupport { private Date created; private String content; private String annotation; + private Long pageCount; public ArticleResponse(Article article) { articleId = article.getId(); @@ -28,6 +29,7 @@ public ArticleResponse(Article article) { created = article.getCreated(); content = article.getContent(); annotation = article.getAnnotation(); + pageCount = article.getPageCount(); addSelfLink(articleId); } diff --git a/src/main/java/fic/writer/web/response/BookResponse.java b/src/main/java/fic/writer/web/response/BookResponse.java index 5baaa2c..3415032 100644 --- a/src/main/java/fic/writer/web/response/BookResponse.java +++ b/src/main/java/fic/writer/web/response/BookResponse.java @@ -35,10 +35,12 @@ public class BookResponse extends ResourceSupport { 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(UserController.class, authorId).getUserById(authorId)).withRel("author"); From 91985851a86dc982d96bbce764d6627b10166012 Mon Sep 17 00:00:00 2001 From: "Uladzislau.Barkou" Date: Mon, 29 Apr 2019 10:35:42 +0300 Subject: [PATCH 21/24] some refactoring --- .../audit/SpringSecurityAuditorAware.java | 18 ++- .../java/fic/writer/domain/entity/Book.java | 13 +- .../domain/entity/{User.java => Profile.java} | 8 +- ...stomUser.java => EmbeddedUserDetails.java} | 6 +- .../{OauthUser.java => OauthUserDetails.java} | 6 +- .../dto/{UserDto.java => ProfileDto.java} | 12 +- .../repository/CustomUserRepository.java | 10 -- .../EmbeddedUserDetailsRepository.java | 10 ++ .../repository/OauthUserRepository.java | 4 +- .../domain/repository/ProfileRepository.java | 12 ++ .../domain/repository/UserRepository.java | 12 -- .../writer/domain/service/ArticleService.java | 9 +- .../writer/domain/service/BookService.java | 6 +- .../domain/service/OauthUserService.java | 4 +- .../writer/domain/service/ProfileService.java | 33 +++++ .../writer/domain/service/UserService.java | 31 ---- .../writer/domain/service/WriterService.java | 10 ++ .../domain/service/files/ArticleParser.java | 10 ++ .../domain/service/files/BookParser.java | 13 +- .../service/files/impl/DocxTextParser.java | 6 +- .../service/files/impl/JsonArticleParser.java | 1 + .../service/files/impl/XmlArticleParser.java | 34 ++--- .../service/files/impl/XmlBookParser.java | 134 ++++++++++-------- .../domain/service/helper/ActorFlusher.java | 18 +++ .../domain/service/helper/ArticleFlusher.java | 18 +++ .../domain/service/helper/BookFlusher.java | 22 +++ .../service/helper/BookStringConstructor.java | 108 ++++++++++++++ .../service/helper/FileParserFactory.java | 14 ++ .../service/helper/FileParserFactoryImpl.java | 70 +++++++++ .../domain/service/helper/ProfileFlusher.java | 18 +++ .../domain/service/impl/ActorServiceImpl.java | 34 ++--- .../service/impl/ActorStateServiceImpl.java | 28 ++-- .../service/impl/ArticleServiceImpl.java | 47 ++---- .../domain/service/impl/BookServiceImpl.java | 96 ++++--------- .../domain/service/impl/FileServiceImpl.java | 82 ++--------- .../service/impl/OauthUserServiceImpl.java | 16 +-- .../service/impl/ProfileServiceImpl.java | 93 ++++++++++++ .../domain/service/impl/UserServiceImpl.java | 96 ------------- .../service/impl/WriterServiceImpl.java | 62 ++++++++ .../web/config/audit/PersistenceConfig.java | 4 +- .../web/config/database/init/UserLoader.java | 26 ++-- ...tails.java => EmbeddedProfileDetails.java} | 12 +- .../authorization/UserDetailServiceImpl.java | 31 ++-- ...nalServerAuthenticationSuccessHandler.java | 16 +-- .../web/controller/ArticleController.java | 4 +- .../writer/web/controller/BookController.java | 29 +--- .../writer/web/controller/FileController.java | 69 +++++---- .../writer/web/controller/UserController.java | 38 ++--- .../fic/writer/web/response/BookResponse.java | 5 +- .../fic/writer/web/response/UserResponse.java | 16 +-- src/main/resources/application-db-mysql.yml | 2 +- .../resources/application-db-postgresql.yml | 2 +- .../resources/application-oauth-github.yml | 2 +- src/main/resources/application.yml | 2 +- src/main/resources/data/user.sql | 4 +- src/main/resources/docker/stack.yml | 2 +- src/main/resources/static/index.html | 8 +- .../domain/service/ArticleServiceTest.java | 40 +++++- .../service/BookAndArticleServicesTest.java | 8 +- .../domain/service/BookServiceTest.java | 8 +- .../domain/service/ProfileServiceTest.java | 42 ++++++ .../domain/service/UserServiceTest.java | 42 ------ .../domain/service/WriterServiceTest.java | 65 +++++++++ ...erTest.java => ProfileControllerTest.java} | 44 +++--- src/test/resources/application-oauth-github | 2 +- src/test/resources/data/article.sql | 2 + src/test/resources/data/book.sql | 3 + src/test/resources/data/book_article.sql | 7 +- src/test/resources/data/user.sql | 6 +- src/test/resources/db/docker-compose.yml | 2 +- 70 files changed, 1042 insertions(+), 725 deletions(-) rename src/main/java/fic/writer/domain/entity/{User.java => Profile.java} (76%) rename src/main/java/fic/writer/domain/entity/auth/{CustomUser.java => EmbeddedUserDetails.java} (72%) rename src/main/java/fic/writer/domain/entity/auth/{OauthUser.java => OauthUserDetails.java} (77%) rename src/main/java/fic/writer/domain/entity/dto/{UserDto.java => ProfileDto.java} (50%) delete mode 100644 src/main/java/fic/writer/domain/repository/CustomUserRepository.java create mode 100644 src/main/java/fic/writer/domain/repository/EmbeddedUserDetailsRepository.java create mode 100644 src/main/java/fic/writer/domain/repository/ProfileRepository.java delete mode 100644 src/main/java/fic/writer/domain/repository/UserRepository.java create mode 100644 src/main/java/fic/writer/domain/service/ProfileService.java delete mode 100644 src/main/java/fic/writer/domain/service/UserService.java create mode 100644 src/main/java/fic/writer/domain/service/WriterService.java create mode 100644 src/main/java/fic/writer/domain/service/helper/ActorFlusher.java create mode 100644 src/main/java/fic/writer/domain/service/helper/ArticleFlusher.java create mode 100644 src/main/java/fic/writer/domain/service/helper/BookFlusher.java create mode 100644 src/main/java/fic/writer/domain/service/helper/BookStringConstructor.java create mode 100644 src/main/java/fic/writer/domain/service/helper/FileParserFactory.java create mode 100644 src/main/java/fic/writer/domain/service/helper/FileParserFactoryImpl.java create mode 100644 src/main/java/fic/writer/domain/service/helper/ProfileFlusher.java create mode 100644 src/main/java/fic/writer/domain/service/impl/ProfileServiceImpl.java delete mode 100644 src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java create mode 100644 src/main/java/fic/writer/domain/service/impl/WriterServiceImpl.java rename src/main/java/fic/writer/web/config/security/authorization/{CustomUserDetails.java => EmbeddedProfileDetails.java} (79%) create mode 100644 src/test/java/fic/writer/domain/service/ProfileServiceTest.java delete mode 100644 src/test/java/fic/writer/domain/service/UserServiceTest.java create mode 100644 src/test/java/fic/writer/domain/service/WriterServiceTest.java rename src/test/java/fic/writer/web/controller/{UserControllerTest.java => ProfileControllerTest.java} (74%) diff --git a/src/main/java/fic/writer/domain/audit/SpringSecurityAuditorAware.java b/src/main/java/fic/writer/domain/audit/SpringSecurityAuditorAware.java index 69dd208..9ce6aae 100644 --- a/src/main/java/fic/writer/domain/audit/SpringSecurityAuditorAware.java +++ b/src/main/java/fic/writer/domain/audit/SpringSecurityAuditorAware.java @@ -1,8 +1,8 @@ package fic.writer.domain.audit; -import fic.writer.domain.entity.User; -import fic.writer.domain.service.UserService; -import fic.writer.web.config.security.authorization.CustomUserDetails; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.service.ProfileService; +import fic.writer.web.config.security.authorization.EmbeddedProfileDetails; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.AuditorAware; import org.springframework.security.core.context.SecurityContextHolder; @@ -11,13 +11,17 @@ import java.util.Optional; @Component -public class SpringSecurityAuditorAware implements AuditorAware { +public class SpringSecurityAuditorAware implements AuditorAware { @Autowired - private UserService userService; + private ProfileService profileService; @Override - public Optional getCurrentAuditor() { - Optional user = Optional.ofNullable(((CustomUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getUser()); + public Optional getCurrentAuditor() { + Optional user = Optional.empty(); + if (SecurityContextHolder.getContext().getAuthentication() != null) { + user = Optional.ofNullable(((EmbeddedProfileDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getProfile()); + } + return user; } } \ 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 index c1a4d29..42251ba 100644 --- a/src/main/java/fic/writer/domain/entity/Book.java +++ b/src/main/java/fic/writer/domain/entity/Book.java @@ -23,14 +23,14 @@ public class Book { private String title; @ManyToOne @CreatedBy - private User author; + private Profile author; @ManyToMany(fetch = FetchType.LAZY) @JoinTable(name = "book_subauthors", joinColumns = {@JoinColumn(name = "book_id")}, inverseJoinColumns = {@JoinColumn(name = "user_id")} ) - @Singular("subAuthors") - private Set subAuthors; + @Singular("coauthors") + private Set coauthors; @OneToMany(fetch = FetchType.EAGER) @Singular("source") private Set source; @@ -67,11 +67,4 @@ private void calculatePageCount() { } } - @PostPersist - private void updateAuthor() { - if (author != null) { - - } - } - } diff --git a/src/main/java/fic/writer/domain/entity/User.java b/src/main/java/fic/writer/domain/entity/Profile.java similarity index 76% rename from src/main/java/fic/writer/domain/entity/User.java rename to src/main/java/fic/writer/domain/entity/Profile.java index 4279ad0..d1af2b1 100644 --- a/src/main/java/fic/writer/domain/entity/User.java +++ b/src/main/java/fic/writer/domain/entity/Profile.java @@ -12,16 +12,16 @@ @AllArgsConstructor @Builder @Table(name = "profile") -public class User { +public class Profile { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; private String about; private String information; - @ManyToMany(mappedBy = "subAuthors", fetch = FetchType.LAZY) - @Singular("booksAsSubAuthor") - private Set booksAsSubAuthor; + @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/CustomUser.java b/src/main/java/fic/writer/domain/entity/auth/EmbeddedUserDetails.java similarity index 72% rename from src/main/java/fic/writer/domain/entity/auth/CustomUser.java rename to src/main/java/fic/writer/domain/entity/auth/EmbeddedUserDetails.java index e583939..689f79d 100644 --- a/src/main/java/fic/writer/domain/entity/auth/CustomUser.java +++ b/src/main/java/fic/writer/domain/entity/auth/EmbeddedUserDetails.java @@ -1,6 +1,6 @@ package fic.writer.domain.entity.auth; -import fic.writer.domain.entity.User; +import fic.writer.domain.entity.Profile; import lombok.*; import javax.persistence.*; @@ -11,11 +11,11 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class CustomUser { +public class EmbeddedUserDetails { @Id @GeneratedValue private Long id; @OneToOne(fetch = FetchType.EAGER) - private User profile; + private Profile profile; private String password; } diff --git a/src/main/java/fic/writer/domain/entity/auth/OauthUser.java b/src/main/java/fic/writer/domain/entity/auth/OauthUserDetails.java similarity index 77% rename from src/main/java/fic/writer/domain/entity/auth/OauthUser.java rename to src/main/java/fic/writer/domain/entity/auth/OauthUserDetails.java index 4369f7a..eee6339 100644 --- a/src/main/java/fic/writer/domain/entity/auth/OauthUser.java +++ b/src/main/java/fic/writer/domain/entity/auth/OauthUserDetails.java @@ -1,6 +1,6 @@ package fic.writer.domain.entity.auth; -import fic.writer.domain.entity.User; +import fic.writer.domain.entity.Profile; import lombok.*; import javax.persistence.*; @@ -12,12 +12,12 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class OauthUser { +public class OauthUserDetails { @Id @GeneratedValue private Long id; @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL) - private User profile; + private Profile profile; private String token; private Date expireDate; } diff --git a/src/main/java/fic/writer/domain/entity/dto/UserDto.java b/src/main/java/fic/writer/domain/entity/dto/ProfileDto.java similarity index 50% rename from src/main/java/fic/writer/domain/entity/dto/UserDto.java rename to src/main/java/fic/writer/domain/entity/dto/ProfileDto.java index 716a430..c445239 100644 --- a/src/main/java/fic/writer/domain/entity/dto/UserDto.java +++ b/src/main/java/fic/writer/domain/entity/dto/ProfileDto.java @@ -1,6 +1,6 @@ package fic.writer.domain.entity.dto; -import fic.writer.domain.entity.User; +import fic.writer.domain.entity.Profile; import lombok.*; @Builder @@ -8,16 +8,16 @@ @Setter @NoArgsConstructor @AllArgsConstructor -public class UserDto { +public class ProfileDto { private String username; private String about; private String information; - public static UserDto of(User user) { + public static ProfileDto of(Profile profile) { return builder() - .username(user.getUsername()) - .about(user.getAbout()) - .information(user.getInformation()) + .username(profile.getUsername()) + .about(profile.getAbout()) + .information(profile.getInformation()) .build(); } } diff --git a/src/main/java/fic/writer/domain/repository/CustomUserRepository.java b/src/main/java/fic/writer/domain/repository/CustomUserRepository.java deleted file mode 100644 index ec897a1..0000000 --- a/src/main/java/fic/writer/domain/repository/CustomUserRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package fic.writer.domain.repository; - -import fic.writer.domain.entity.auth.CustomUser; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface CustomUserRepository extends JpaRepository { - Optional findByProfileId(Long id); -} diff --git a/src/main/java/fic/writer/domain/repository/EmbeddedUserDetailsRepository.java b/src/main/java/fic/writer/domain/repository/EmbeddedUserDetailsRepository.java new file mode 100644 index 0000000..d8361b7 --- /dev/null +++ b/src/main/java/fic/writer/domain/repository/EmbeddedUserDetailsRepository.java @@ -0,0 +1,10 @@ +package fic.writer.domain.repository; + +import fic.writer.domain.entity.auth.EmbeddedUserDetails; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EmbeddedUserDetailsRepository extends JpaRepository { + Optional findByProfileId(Long id); +} diff --git a/src/main/java/fic/writer/domain/repository/OauthUserRepository.java b/src/main/java/fic/writer/domain/repository/OauthUserRepository.java index 56e26d3..0e7c45a 100644 --- a/src/main/java/fic/writer/domain/repository/OauthUserRepository.java +++ b/src/main/java/fic/writer/domain/repository/OauthUserRepository.java @@ -1,7 +1,7 @@ package fic.writer.domain.repository; -import fic.writer.domain.entity.auth.OauthUser; +import fic.writer.domain.entity.auth.OauthUserDetails; import org.springframework.data.jpa.repository.JpaRepository; -public interface OauthUserRepository extends JpaRepository { +public interface OauthUserRepository extends JpaRepository { } 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/repository/UserRepository.java b/src/main/java/fic/writer/domain/repository/UserRepository.java deleted file mode 100644 index 131e193..0000000 --- a/src/main/java/fic/writer/domain/repository/UserRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package fic.writer.domain.repository; - -import fic.writer.domain.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface UserRepository extends JpaRepository { - Optional findByUsername(String username); - - Optional findByEmail(String email); -} diff --git a/src/main/java/fic/writer/domain/service/ArticleService.java b/src/main/java/fic/writer/domain/service/ArticleService.java index 214eee0..6d45f38 100644 --- a/src/main/java/fic/writer/domain/service/ArticleService.java +++ b/src/main/java/fic/writer/domain/service/ArticleService.java @@ -4,7 +4,6 @@ import fic.writer.domain.entity.dto.ArticleDto; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.web.multipart.MultipartFile; import java.util.List; import java.util.Optional; @@ -12,17 +11,15 @@ public interface ArticleService { List
    findAll(); - List
    findAllForBook(Long bookId); + List
    findAllArticlesForBook(Long bookId); Page
    findPage(Pageable pageable); Article update(Long id, ArticleDto articleDto); - Optional
    findById(Long aLong); + Optional
    findById(Long articleId); void delete(Article article); - void deleteById(Long aLong); - - String parseArticleContentFromFile(MultipartFile multipartFile); + 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 index 49a027d..f426a32 100644 --- a/src/main/java/fic/writer/domain/service/BookService.java +++ b/src/main/java/fic/writer/domain/service/BookService.java @@ -16,10 +16,12 @@ public interface BookService { Page findPage(Pageable pageable); - Optional findById(Long aLong); + Optional findById(Long bookId); Book create(BookDto bookDto); + Book save(Book book); + Book update(Long id, BookDto bookDto); Book addArticle(Long bookId, ArticleDto articleDto); @@ -28,7 +30,7 @@ public interface BookService { void delete(Book book); - void deleteById(Long aLong); + void deleteById(Long bookId); byte[] getBookAsByteArray(Long bookId) throws IOException; } diff --git a/src/main/java/fic/writer/domain/service/OauthUserService.java b/src/main/java/fic/writer/domain/service/OauthUserService.java index b0c9ddc..dd5faa0 100644 --- a/src/main/java/fic/writer/domain/service/OauthUserService.java +++ b/src/main/java/fic/writer/domain/service/OauthUserService.java @@ -1,6 +1,6 @@ package fic.writer.domain.service; -import fic.writer.domain.entity.auth.OauthUser; +import fic.writer.domain.entity.auth.OauthUserDetails; -public interface OauthUserService extends CrudService { +public interface OauthUserService 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..6a94734 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/ProfileService.java @@ -0,0 +1,33 @@ +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); +} diff --git a/src/main/java/fic/writer/domain/service/UserService.java b/src/main/java/fic/writer/domain/service/UserService.java deleted file mode 100644 index 10061aa..0000000 --- a/src/main/java/fic/writer/domain/service/UserService.java +++ /dev/null @@ -1,31 +0,0 @@ -package fic.writer.domain.service; - -import fic.writer.domain.entity.User; -import fic.writer.domain.entity.dto.UserDto; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.List; -import java.util.Optional; - -public interface UserService { - List findAll(); - - Page findPage(Pageable pageable); - - Optional findById(Long aLong); - - Optional findByUsername(String username); - - Optional findByEmail(String email); - - User create(UserDto user); - - User addBookAsAuthor(Long userId, Long bookId); - - User update(Long userId, UserDto user); - - void delete(User user); - - void deleteById(Long aLong); -} 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..b17dfac --- /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 createBook(BookDto bookDto); + + void deleteBook(Long bookId); +} diff --git a/src/main/java/fic/writer/domain/service/files/ArticleParser.java b/src/main/java/fic/writer/domain/service/files/ArticleParser.java index f30c26d..f4f07a2 100644 --- a/src/main/java/fic/writer/domain/service/files/ArticleParser.java +++ b/src/main/java/fic/writer/domain/service/files/ArticleParser.java @@ -1,9 +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 index e92fa57..077d941 100644 --- a/src/main/java/fic/writer/domain/service/files/BookParser.java +++ b/src/main/java/fic/writer/domain/service/files/BookParser.java @@ -1,7 +1,8 @@ package fic.writer.domain.service.files; import fic.writer.domain.entity.Article; -import fic.writer.domain.entity.User; +import fic.writer.domain.entity.Book; +import fic.writer.domain.entity.Profile; import fic.writer.domain.entity.enums.State; import java.util.Set; @@ -9,11 +10,19 @@ public interface BookParser { String getTitle(); - Set getCoAuthors(); + 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/impl/DocxTextParser.java b/src/main/java/fic/writer/domain/service/files/impl/DocxTextParser.java index dc18ea9..2b54ccd 100644 --- a/src/main/java/fic/writer/domain/service/files/impl/DocxTextParser.java +++ b/src/main/java/fic/writer/domain/service/files/impl/DocxTextParser.java @@ -13,16 +13,16 @@ public class DocxTextParser implements TextParser { @Override public String parseFile(MultipartFile file) { - String fileContent = ""; + StringBuilder fileContent = new StringBuilder(); try { XWPFDocument document = new XWPFDocument(file.getInputStream()); List paragraphs = document.getParagraphs(); for (int i = 0; i < paragraphs.size(); i++) { - fileContent += paragraphs.get(i).getParagraphText(); + fileContent.append(paragraphs.get(i).getParagraphText()); } } catch (IOException e) { throw new RuntimeException(); } - return fileContent; + 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 index 0105fa2..64e3668 100644 --- a/src/main/java/fic/writer/domain/service/files/impl/JsonArticleParser.java +++ b/src/main/java/fic/writer/domain/service/files/impl/JsonArticleParser.java @@ -37,4 +37,5 @@ public String getAnnotation() { public String getContent() { return jsonObject.path("content").textValue(); } + } 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 index eedeafa..9d67d80 100644 --- a/src/main/java/fic/writer/domain/service/files/impl/XmlArticleParser.java +++ b/src/main/java/fic/writer/domain/service/files/impl/XmlArticleParser.java @@ -1,5 +1,6 @@ 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; @@ -14,6 +15,15 @@ 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; @@ -21,37 +31,29 @@ public XmlArticleParser(MultipartFile multipartFile) { try { dBuilder = dbFactory.newDocumentBuilder(); doc = dBuilder.parse(multipartFile.getInputStream()); - } catch (ParserConfigurationException e) { - e.printStackTrace(); - throw new RuntimeException(); - } catch (SAXException e) { + } catch (ParserConfigurationException | SAXException | IOException e) { e.printStackTrace(); - throw new RuntimeException(); - } catch (IOException e) { - e.printStackTrace(); - throw new RuntimeException(); + throw new RuntimeException(e.getMessage()); } doc.getDocumentElement().normalize(); element = doc.getDocumentElement(); - String title = element.getElementsByTagName("book-title").item(0).getFirstChild().getNodeValue(); - String annotation = element.getElementsByTagName("annotation").item(0).getFirstChild().getNodeValue(); } + @Override public String getTitle() { - String title = element.getElementsByTagName("title").item(0).getFirstChild().getNodeValue(); - return title; + return element.getElementsByTagName("title").item(0).getFirstChild().getNodeValue(); } @Override public String getAnnotation() { - String annotation = element.getElementsByTagName("annotation").item(0).getFirstChild().getNodeValue(); - return null; + return element.getElementsByTagName("annotation").item(0).getFirstChild().getNodeValue(); } @Override public String getContent() { - String annotation = element.getElementsByTagName("section").item(0).getFirstChild().getNodeValue(); - return null; + 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 index 6afab7b..3a21458 100644 --- a/src/main/java/fic/writer/domain/service/files/impl/XmlBookParser.java +++ b/src/main/java/fic/writer/domain/service/files/impl/XmlBookParser.java @@ -1,7 +1,7 @@ package fic.writer.domain.service.files.impl; import fic.writer.domain.entity.Article; -import fic.writer.domain.entity.User; +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; @@ -16,116 +16,127 @@ import javax.xml.parsers.ParserConfigurationException; import javax.xml.xpath.*; import java.io.IOException; -import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Optional; import java.util.Set; public class XmlBookParser implements BookParser { - private Document doc; + 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(); - doc = dBuilder.parse(file.getInputStream()); - } catch (ParserConfigurationException e) { - e.printStackTrace(); - throw new RuntimeException(); - } catch (SAXException e) { + xmlDocument = dBuilder.parse(file.getInputStream()); + } catch (ParserConfigurationException + | SAXException + | IOException e) { e.printStackTrace(); - throw new RuntimeException(); - } catch (IOException e) { - e.printStackTrace(); - throw new RuntimeException(); + throw new RuntimeException(e.getMessage()); } - xPath = XPathFactory.newInstance().newXPath(); - - doc.getDocumentElement().normalize(); + 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("//FictionBook/description/title-info/book-title"); - title = (String) expr.evaluate(doc, XPathConstants.STRING); + XPathExpression expr = xPath.compile(BOOK_TITLE_PATH); + title = (String) expr.evaluate(xmlDocument, XPathConstants.STRING); } catch (XPathExpressionException e) { e.printStackTrace(); throw new RuntimeException(); } - title = title.replaceAll("(\\s)\\1", "$1"); - return title; - } - - @Override - public Set getCoAuthors() { - return new HashSet<>(); + return trimAndNormalizeSpace(title); } @Override public String getDescription() { + final String BOOK_DESCRIPTION_PATH = "//FictionBook/description/title-info/annotation"; String description = ""; try { - XPathExpression expr = xPath.compile("//FictionBook/description/title-info/annotation"); - description = (String) expr.evaluate(doc, XPathConstants.STRING); + XPathExpression expr = xPath.compile(BOOK_DESCRIPTION_PATH); + description = (String) expr.evaluate(xmlDocument, XPathConstants.STRING); } catch (XPathExpressionException e) { e.printStackTrace(); - throw new RuntimeException(); + throw new RuntimeException(e.getMessage()); } - description = description.trim().replaceAll("(\\s)\\1", "$1"); - return description; + + return trimAndNormalizeSpace(description); + } + + @Override + public Set getCoauthors() { + throw new UnsupportedOperationException("fb2 doesn't contain this field"); } @Override public State getState() { - return null; + 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 sectionExpression = xPath.compile("//body"); - Element bodyElements = (Element) sectionExpression.evaluate(doc, XPathConstants.NODE); - NodeList articlesNode = bodyElements.getElementsByTagName("section"); - for (int i = 0; i < articlesNode.getLength(); i++) { - Element node = (Element) articlesNode.item(i); - Optional titleNode = getNode(node, "title", 0); - String title = titleNode.map(Node::getTextContent) - .orElse(""); - title = title.trim().replaceAll("(\\s)\\1", "$1"); - - Optional annotationNode = getNode(node, "annotation", 0); - String annotation = annotationNode.map(Node::getTextContent) - .orElse(""); - annotation = annotation.trim().replaceAll("(\\s)\\1", "$1"); - StringBuilder content = new StringBuilder(); - NodeList nodeList = node.getElementsByTagName("p"); - for (int j = 0; j < nodeList.getLength(); j++) { - if (nodeList.item(j).getParentNode().getNodeName() == "section") { - content.append("\n" + nodeList.item(j).getTextContent() + "\n"); - } - } - articles.add(Article.builder() - .title(title) - .annotation(annotation) - .content(content.toString()) - .build()); - } - + XPathExpression sectionPathExpression = xPath.compile(ARTICLE_CONTAINER_PATH); + bodyElement = (Element) sectionPathExpression.evaluate(xmlDocument, XPathConstants.NODE); } catch (XPathExpressionException e) { e.printStackTrace(); - throw new RuntimeException(); + 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 Optional getNode(Element element, String tagName, int index) { + 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; @@ -133,6 +144,9 @@ private Optional getNode(Element element, String tagName, int index) { ? 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/ActorFlusher.java b/src/main/java/fic/writer/domain/service/helper/ActorFlusher.java new file mode 100644 index 0000000..d574926 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/ActorFlusher.java @@ -0,0 +1,18 @@ +package fic.writer.domain.service.helper; + +import fic.writer.domain.entity.Actor; +import fic.writer.domain.entity.dto.ActorDto; + +public class ActorFlusher { + public static void flushArticleDtoToArticle(Actor actor, ActorDto actorDto) { + if (actorDto.getName() != null) { + actor.setName(actorDto.getName()); + } + if (actorDto.getActorStates() != null) { + actor.setActorStates(actorDto.getActorStates()); + } + if (actorDto.getDescription() != null) { + actor.setDescription(actorDto.getDescription()); + } + } +} diff --git a/src/main/java/fic/writer/domain/service/helper/ArticleFlusher.java b/src/main/java/fic/writer/domain/service/helper/ArticleFlusher.java new file mode 100644 index 0000000..d55de12 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/ArticleFlusher.java @@ -0,0 +1,18 @@ +package fic.writer.domain.service.helper; + +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/BookFlusher.java b/src/main/java/fic/writer/domain/service/helper/BookFlusher.java new file mode 100644 index 0000000..a553103 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/BookFlusher.java @@ -0,0 +1,22 @@ +package fic.writer.domain.service.helper; + +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()); + } + } +} 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..4dfdf42 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/BookStringConstructor.java @@ -0,0 +1,108 @@ +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 BookStringConstructor { + private static String titleHeader = "Title"; + private static String descriptionHeader = "Description"; + private static String authorHeader = "Author"; + private static String coauthorsHeader = "Coauthor"; + private static String sizeHeader = "Size"; + private static String stateHeader = "State"; + private static String contentHeader = "Content"; + private static String annotationHeader = "Annotation"; + private static String coauthorSeparator = ","; + private BookStringBuilder bookStringBuilder = new BookStringBuilder(); + + public String convertBookToSimpleText(Book book) { + writeTitle(book); + writeDescription(book); + writeAuthor(book); + writeCoauthors(book); + writeSize(book); + writeState(book); + writeArticleHeaders(book); + writeArticlesContent(book); + return bookStringBuilder.getContent(); + } + + private void writeTitle(Book book) { + bookStringBuilder.addDescription(titleHeader, book.getTitle()); + } + + private void writeDescription(Book book) { + bookStringBuilder.addDescription(descriptionHeader, book.getDescription()); + } + + private void writeAuthor(Book book) { + bookStringBuilder.addDescription(authorHeader, book.getAuthor().getUsername()); + } + + private void writeCoauthors(Book book) { + String coauthors = book.getCoauthors() + .stream() + .map(Profile::getUsername) + .reduce((b, a) -> b + coauthorSeparator + a) + .orElse(""); + bookStringBuilder.addDescription(coauthorsHeader, coauthors); + } + + private void writeSize(Book book) { + if (book.getSize() != null) { + bookStringBuilder.addDescription(sizeHeader, book.getSize().name()); + } + } + + private void writeState(Book book) { + if (book.getSize() != null) { + bookStringBuilder.addDescription(stateHeader, book.getState().name()); + } + } + + private void writeArticleHeaders(Book book) { + bookStringBuilder.addParagraph(contentHeader); + int articleCounter = 0; + Set
    articles = book.getArticles(); + for (Article article : articles) { + articleCounter++; + String contentRow = articleCounter + ")" + article.getTitle(); + bookStringBuilder.addParagraph(contentRow); + } + } + + private void writeArticlesContent(Book book) { + Set
    articles = book.getArticles(); + for (Article article : articles) { + bookStringBuilder.addParagraph(article.getTitle()); + bookStringBuilder.addDescription(annotationHeader, article.getAnnotation()); + bookStringBuilder.addParagraph(article.getContent()); + } + } + + private 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/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..5580df6 --- /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.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 fic.writer.domain.utils.FileExtention; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Objects; + +public class FileParserFactoryImpl implements FileParserFactory { + @Override + public TextParser getTextParser(MultipartFile file) { + FileExtention fileExtention = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + TextParser parser; + switch (fileExtention) { + case TXT: + parser = new TxtTextParser(); + break; + case DOCX: + parser = new DocxTextParser(); + break; + default: + throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); + } + return parser; + } + + @Override + public ArticleParser getArticleParser(MultipartFile file) { + FileExtention fileExtention = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + ArticleParser parser; + switch (fileExtention) { + case XML: + parser = new XmlArticleParser(file); + break; + case JSON: + parser = new JsonArticleParser(file); + break; + case FB2: + parser = new XmlArticleParser(file); + default: + throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); + } + return parser; + } + + @Override + public BookParser getBookParser(MultipartFile file) { + FileExtention fileExtention = getExtension(Objects.requireNonNull(file.getOriginalFilename())); + BookParser parser; + switch (fileExtention) { + case FB2: + parser = new XmlBookParser(file); + break; + default: + throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); + } + return parser; + } + + private FileExtention getExtension(String fileName) { + int pointIndex = fileName.lastIndexOf('.'); + int pointPosition = pointIndex + 1; + return pointIndex == -1 + ? FileExtention.TXT + : FileExtention.valueOf(fileName.substring(pointPosition).toUpperCase()); + } +} diff --git a/src/main/java/fic/writer/domain/service/helper/ProfileFlusher.java b/src/main/java/fic/writer/domain/service/helper/ProfileFlusher.java new file mode 100644 index 0000000..c2e3e71 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/helper/ProfileFlusher.java @@ -0,0 +1,18 @@ +package fic.writer.domain.service.helper; + +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()); + } + } +} diff --git a/src/main/java/fic/writer/domain/service/impl/ActorServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/ActorServiceImpl.java index c67d57d..c659ec1 100644 --- a/src/main/java/fic/writer/domain/service/impl/ActorServiceImpl.java +++ b/src/main/java/fic/writer/domain/service/impl/ActorServiceImpl.java @@ -4,6 +4,7 @@ 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.ActorFlusher; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -38,27 +39,22 @@ public Optional findById(Long id) { } @Override - public Actor update(Long id, ActorDto actorDto) { - Actor actor = actorRepository.findById(id).orElseThrow(EntityNotFoundException::new); - flushArticleDtoToArticle(actor, actorDto); - return actorRepository.save(actor); + public Actor getOne(Long id) { + return actorRepository.getOne(id); } - private void flushArticleDtoToArticle(Actor actor, ActorDto actorDto) { - if (actorDto.getName() != null) { - actor.setName(actorDto.getName()); - } - if (actorDto.getActorStates() != null) { - actor.setActorStates(actorDto.getActorStates()); - } - if (actorDto.getDescription() != null) { - actor.setDescription(actorDto.getDescription()); - } + @Override + public Actor create(ActorDto actorDto) { + Actor actor = Actor.builder().build(); + ActorFlusher.flushArticleDtoToArticle(actor, actorDto); + return actorRepository.save(actor); } @Override - public Actor getOne(Long id) { - return actorRepository.getOne(id); + public Actor update(Long id, ActorDto actorDto) { + Actor actor = actorRepository.findById(id).orElseThrow(EntityNotFoundException::new); + ActorFlusher.flushArticleDtoToArticle(actor, actorDto); + return actorRepository.save(actor); } @Override @@ -66,10 +62,4 @@ public void deleteById(Long id) { actorRepository.deleteById(id); } - @Override - public Actor create(ActorDto actorDto) { - Actor actor = Actor.builder().build(); - flushArticleDtoToArticle(actor, actorDto); - return actorRepository.save(actor); - } } diff --git a/src/main/java/fic/writer/domain/service/impl/ActorStateServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/ActorStateServiceImpl.java index 3749d67..1fd364f 100644 --- a/src/main/java/fic/writer/domain/service/impl/ActorStateServiceImpl.java +++ b/src/main/java/fic/writer/domain/service/impl/ActorStateServiceImpl.java @@ -2,8 +2,6 @@ import fic.writer.domain.entity.ActorState; import fic.writer.domain.entity.ActorStateId; -import fic.writer.domain.entity.dto.ActorStateDto; -import fic.writer.domain.repository.ActorRepository; import fic.writer.domain.repository.ActorStateRepository; import fic.writer.domain.service.ActorStateService; import org.springframework.beans.factory.annotation.Autowired; @@ -17,7 +15,6 @@ @Service public class ActorStateServiceImpl implements ActorStateService { private ActorStateRepository actorStateRepository; - private ActorRepository actorRepository; @Autowired public ActorStateServiceImpl(ActorStateRepository actorStateRepository) { @@ -29,7 +26,6 @@ public List findAll() { return actorStateRepository.findAll(); } - @Override public Page findPage(Pageable pageable) { return actorStateRepository.findAll(pageable); @@ -40,16 +36,6 @@ public Optional findById(ActorStateId id) { return actorStateRepository.findById(id); } - @Override - public void delete(ActorState actorState) { - actorStateRepository.delete(actorState); - } - - @Override - public void deleteById(ActorStateId id) { - actorStateRepository.deleteById(id); - } - @Override public Page findAllByActor(Long id, Pageable pageable) { return actorStateRepository.findAllByIdActorId(id, pageable); @@ -60,11 +46,13 @@ public Optional findForActorByArticle(Long actorId, Long articleId) return actorStateRepository.findAByIdActorIdAndIdArticleId(actorId, articleId); } - private ActorState actorStateDtoForActorState(ActorStateDto actorState) { - return ActorState.builder() - .id(actorState.getId()) - .title(actorState.getTitle()) - .content(actorState.getContent()) - .build(); + @Override + public void delete(ActorState actorState) { + actorStateRepository.delete(actorState); + } + + @Override + public void deleteById(ActorStateId id) { + actorStateRepository.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 index ab90e65..e4aecb0 100644 --- a/src/main/java/fic/writer/domain/service/impl/ArticleServiceImpl.java +++ b/src/main/java/fic/writer/domain/service/impl/ArticleServiceImpl.java @@ -5,16 +5,13 @@ import fic.writer.domain.repository.ArticleRepository; import fic.writer.domain.service.ArticleService; import fic.writer.domain.service.BookService; -import org.apache.tomcat.util.http.fileupload.IOUtils; +import fic.writer.domain.service.helper.ArticleFlusher; 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.web.multipart.MultipartFile; import javax.persistence.EntityExistsException; -import java.io.ByteArrayOutputStream; -import java.io.IOException; import java.util.List; import java.util.Optional; @@ -34,10 +31,6 @@ public List
    findAll() { return articleRepository.findAll(); } - @Override - public List
    findAllForBook(Long bookId) { - return articleRepository.findAllByBookId(bookId); - } @Override public Page
    findPage(Pageable pageable) { @@ -45,10 +38,8 @@ public Page
    findPage(Pageable pageable) { } @Override - public Article update(Long id, ArticleDto articleDto) { - Article article = articleRepository.findById(id).orElseThrow(EntityExistsException::new); - flushArticleDtoToArticle(article, articleDto); - return articleRepository.save(article); + public List
    findAllArticlesForBook(Long bookId) { + return articleRepository.findAllByBookId(bookId); } @Override @@ -56,6 +47,13 @@ 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); @@ -66,29 +64,4 @@ public void deleteById(Long id) { articleRepository.deleteById(id); } - @Override - public String parseArticleContentFromFile(MultipartFile multipartFile) { - String fileContent = ""; - try { - ByteArrayOutputStream stringWriter = new ByteArrayOutputStream(); - IOUtils.copy(multipartFile.getInputStream(), stringWriter); - fileContent = stringWriter.toString(); - } catch (IOException e) { - throw new RuntimeException(); - } - return fileContent; - } - - - private 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/impl/BookServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java index 513c9f5..2be7315 100644 --- a/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java +++ b/src/main/java/fic/writer/domain/service/impl/BookServiceImpl.java @@ -2,11 +2,13 @@ import fic.writer.domain.entity.Article; import fic.writer.domain.entity.Book; -import fic.writer.domain.entity.User; 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.ArticleFlusher; +import fic.writer.domain.service.helper.BookFlusher; +import fic.writer.domain.service.helper.BookStringConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -29,7 +31,6 @@ public class BookServiceImpl implements BookService { private BookRepository bookRepository; @Autowired - public BookServiceImpl(BookRepository bookRepository) { this.bookRepository = bookRepository; } @@ -39,7 +40,6 @@ public List findAll() { return bookRepository.findAll(); } - @Override public Page findPage(Pageable pageable) { return bookRepository.findAll(pageable); @@ -53,23 +53,39 @@ public Optional findById(Long id) { @Override public Book create(BookDto bookDto) { Book book = Book.builder().build(); - flushBookDtoToBook(book, bookDto); + BookFlusher.flushBookDtoToBook(book, 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); - flushBookDtoToBook(book, bookDto); + 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(); - flushArticleDtoToArticle(article, articleDto); + ArticleFlusher.flushArticleDtoToArticle(article, articleDto); article.setBook(book); Set
    articles = new HashSet<>(book.getArticles()); articles.add(article); @@ -86,73 +102,13 @@ public Book removeArticle(Long bookId, Long articleId) { return book; } - private 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()); - } - } - - @Override - public void delete(Book book) { - bookRepository.delete(book); - } - - @Override - public void deleteById(Long id) { - bookRepository.deleteById(id); - } - @Override public byte[] getBookAsByteArray(Long bookId) throws IOException { Book book = bookRepository.findById(bookId).orElseThrow(EntityNotFoundException::new); - String output = ""; - output += "Title:" + book.getTitle(); - output += " \nDescription:" + book.getDescription(); - if (book.getAuthor() != null) { - output += " \nAuthor:" + book.getAuthor().getUsername(); - } - String coauthors = book.getSubAuthors().stream().map(User::getUsername).reduce((b, a) -> b + "," + a).orElse(""); - output += " \nCoauthor:" + coauthors; - if (book.getSize() != null) { - output += " \nSize:" + book.getSize().name(); - } - if (book.getSize() != null) { - output += " \nState:" + book.getState().name(); - } - int counter = 0; - String content = " \nContent: \n"; - Set
    articles = book.getArticles(); - for (Article article : articles) { - content += " \n" + ++counter + ". " + article.getTitle(); - } - output += content; - - for (Article article : articles) { - output += " \n" + article.getTitle(); - output += " \nAnnotation:" + article.getAnnotation(); - output += " \n" + article.getContent(); - } - return output.getBytes(); + BookStringConstructor bookStringConstructor = new BookStringConstructor(); + String bookAsString = bookStringConstructor.convertBookToSimpleText(book); + return bookAsString.getBytes(); } - private 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()); - } - } + } diff --git a/src/main/java/fic/writer/domain/service/impl/FileServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/FileServiceImpl.java index 66c2d2a..e2cf73e 100644 --- a/src/main/java/fic/writer/domain/service/impl/FileServiceImpl.java +++ b/src/main/java/fic/writer/domain/service/impl/FileServiceImpl.java @@ -6,92 +6,28 @@ 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 fic.writer.domain.utils.FileExtention; +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; -import java.util.Objects; - @Service public class FileServiceImpl implements FileService { + FileParserFactory fileParserFactory = new FileParserFactoryImpl(); + @Override public String parseText(MultipartFile file) { - FileExtention extension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); - TextParser parser = selectTextParser(extension); + TextParser parser = fileParserFactory.getTextParser(file); return parser.parseFile(file); } public Article parseArticle(MultipartFile file) { - FileExtention extension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); - ArticleParser parser = selectArticleParser(extension, file); - return Article.builder() - .title(parser.getTitle()) - .annotation(parser.getAnnotation()) - .content(parser.getContent()) - .build(); + ArticleParser parser = fileParserFactory.getArticleParser(file); + return parser.parse(); } public Book parseBook(MultipartFile file) { - FileExtention extension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); - BookParser parser = selectBookParser(extension, file); - return Book.builder() - .title(parser.getTitle()) - .description(parser.getDescription()) - .articles(parser.getArticles()) - .build(); - } - - private BookParser selectBookParser(FileExtention fileExtention, MultipartFile file) { - BookParser parser; - switch (fileExtention) { - case FB2: - parser = new XmlBookParser(file); - break; - default: - throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); - } - return parser; - } - - private ArticleParser selectArticleParser(FileExtention fileExtention, MultipartFile file) { - ArticleParser parser; - switch (fileExtention) { - case XML: - parser = new XmlArticleParser(file); - break; - case JSON: - parser = new JsonArticleParser(file); - break; - case FB2: - parser = new XmlArticleParser(file); - default: - throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); - } - return parser; + BookParser parser = fileParserFactory.getBookParser(file); + return parser.parse(); } - - private TextParser selectTextParser(FileExtention fileExtention) { - TextParser parser; - switch (fileExtention) { - case TXT: - parser = new TxtTextParser(); - break; - case DOCX: - parser = new DocxTextParser(); - break; - default: - throw new EnumConstantNotPresentException(FileExtention.class, fileExtention.toString()); - } - return parser; - } - - private FileExtention getExtension(String fileName) { - int pointIndex = fileName.lastIndexOf('.'); - int pointPosition = pointIndex + 1; - return pointIndex == -1 - ? FileExtention.TXT - : FileExtention.valueOf(fileName.substring(pointPosition).toUpperCase()); - } - } diff --git a/src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java index 33d1e13..95912cb 100644 --- a/src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java +++ b/src/main/java/fic/writer/domain/service/impl/OauthUserServiceImpl.java @@ -1,6 +1,6 @@ package fic.writer.domain.service.impl; -import fic.writer.domain.entity.auth.OauthUser; +import fic.writer.domain.entity.auth.OauthUserDetails; import fic.writer.domain.repository.OauthUserRepository; import fic.writer.domain.service.OauthUserService; import org.springframework.beans.factory.annotation.Autowired; @@ -17,28 +17,28 @@ public class OauthUserServiceImpl implements OauthUserService { private OauthUserRepository oauthUserRepository; @Override - public List findAll() { + public List findAll() { return oauthUserRepository.findAll(); } @Override - public Page findPage(Pageable pageable) { + public Page findPage(Pageable pageable) { return null; } @Override - public Optional findById(Long id) { + public Optional findById(Long id) { return oauthUserRepository.findById(id); } @Override - public OauthUser save(OauthUser oauthUser) { - return oauthUserRepository.save(oauthUser); + public OauthUserDetails save(OauthUserDetails oauthUserDetails) { + return oauthUserRepository.save(oauthUserDetails); } @Override - public void delete(OauthUser oauthUser) { - oauthUserRepository.delete(oauthUser); + public void delete(OauthUserDetails oauthUserDetails) { + oauthUserRepository.delete(oauthUserDetails); } @Override 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..44c3034 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/ProfileServiceImpl.java @@ -0,0 +1,93 @@ +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.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 { + private ProfileRepository profileRepository; + + @Autowired + public ProfileServiceImpl(ProfileRepository profileRepository) { + this.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 = Profile.builder().build(); + ProfileFlusher.flushProfileDtoToProfile(profile, profileDto); + return profileRepository.save(profile); + } + + @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 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); + } + + +} diff --git a/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java b/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java deleted file mode 100644 index 47b71b1..0000000 --- a/src/main/java/fic/writer/domain/service/impl/UserServiceImpl.java +++ /dev/null @@ -1,96 +0,0 @@ -package fic.writer.domain.service.impl; - -import fic.writer.domain.entity.Book; -import fic.writer.domain.entity.User; -import fic.writer.domain.entity.dto.UserDto; -import fic.writer.domain.repository.UserRepository; -import fic.writer.domain.service.UserService; -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 UserServiceImpl implements UserService { - private UserRepository userRepository; - - @Autowired - public UserServiceImpl(UserRepository userRepository) { - this.userRepository = userRepository; - } - - @Override - public List findAll() { - return userRepository.findAll(); - } - - @Override - public Page findPage(Pageable pageable) { - return userRepository.findAll(pageable); - } - - @Override - public Optional findById(Long id) { - return userRepository.findById(id); - } - - @Override - public Optional findByUsername(String username) { - return userRepository.findByUsername(username); - } - - @Override - public Optional findByEmail(String email) { - return userRepository.findByEmail(email); - } - - @Override - public User create(UserDto userDto) { - User user = User.builder().build(); - flushUserDtoToUser(user, userDto); - return userRepository.save(user); - - } - - @Override - public User addBookAsAuthor(Long userId, Long bookId) { - User user = userRepository.findById(userId).get(); - Book book = Book.builder().id(bookId).build(); - user.getBooksAsAuthor().add(book); - return userRepository.save(user); - } - - @Override - public User update(Long userId, UserDto userDto) { - User user = userRepository.findById(userId).orElseThrow(EntityNotFoundException::new); - flushUserDtoToUser(user, userDto); - userRepository.save(user); - return user; - } - - private void flushUserDtoToUser(User user, UserDto userDto) { - if (userDto.getUsername() != null) { - user.setUsername(userDto.getUsername()); - } - if (userDto.getAbout() != null) { - user.setAbout(userDto.getAbout()); - } - if (userDto.getInformation() != null) { - user.setInformation(userDto.getInformation()); - } - } - - @Override - public void delete(User user) { - userRepository.delete(user); - } - - @Override - public void deleteById(Long id) { - userRepository.deleteById(id); - } -} 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..fd5aa97 --- /dev/null +++ b/src/main/java/fic/writer/domain/service/impl/WriterServiceImpl.java @@ -0,0 +1,62 @@ +package fic.writer.domain.service.impl; + +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 fic.writer.web.config.security.authorization.EmbeddedProfileDetails; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class WriterServiceImpl implements WriterService { + private BookService bookService; + private ProfileService profileService; + + @Autowired + public WriterServiceImpl(BookService bookService, ProfileService profileService) { + this.bookService = bookService; + this.profileService = profileService; + } + + @Override + public Book createBook(BookDto bookDto) { + Book book = bookService.create(bookDto); + Optional currentProfile = getCurrentProfile(); + currentProfile.ifPresent(profile -> { + Profile updatedProfile = profileService.addBookAsAuthor(profile.getId(), book.getId()); + profileService.save(updatedProfile); + }); + + return book; + } + + @Override + public void deleteBook(Long bookId) { + Optional currentProfile = getCurrentProfile(); + currentProfile.ifPresent(profile -> { + profile.getBooksAsAuthor().removeIf(book -> bookId.equals(book.getId())); + profile.getBooksAsCoauthor().removeIf(book -> bookId.equals(book.getId())); + profileService.save(profile); + }); + } + + private Optional getCurrentProfile() { + Optional user = Optional.empty(); + if (SecurityContextHolder.getContext().getAuthentication() != null) { + user = Optional.ofNullable( + ((EmbeddedProfileDetails) + SecurityContextHolder + .getContext() + .getAuthentication() + .getPrincipal() + ).getProfile()); + } + return user; + } +} diff --git a/src/main/java/fic/writer/web/config/audit/PersistenceConfig.java b/src/main/java/fic/writer/web/config/audit/PersistenceConfig.java index 3c6e008..e4f3abd 100644 --- a/src/main/java/fic/writer/web/config/audit/PersistenceConfig.java +++ b/src/main/java/fic/writer/web/config/audit/PersistenceConfig.java @@ -1,7 +1,7 @@ package fic.writer.web.config.audit; import fic.writer.domain.audit.SpringSecurityAuditorAware; -import fic.writer.domain.entity.User; +import fic.writer.domain.entity.Profile; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.domain.AuditorAware; @@ -12,7 +12,7 @@ public class PersistenceConfig { @Bean - public AuditorAware auditorProvider() { + 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 index c268a56..a8a89ac 100644 --- a/src/main/java/fic/writer/web/config/database/init/UserLoader.java +++ b/src/main/java/fic/writer/web/config/database/init/UserLoader.java @@ -1,9 +1,9 @@ package fic.writer.web.config.database.init; -import fic.writer.domain.entity.User; -import fic.writer.domain.entity.auth.CustomUser; -import fic.writer.domain.repository.CustomUserRepository; -import fic.writer.domain.repository.UserRepository; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.auth.EmbeddedUserDetails; +import fic.writer.domain.repository.EmbeddedUserDetailsRepository; +import fic.writer.domain.repository.ProfileRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; @@ -12,25 +12,25 @@ @Component public class UserLoader implements ApplicationRunner { @Autowired - UserRepository userRepository; + ProfileRepository profileRepository; @Autowired - CustomUserRepository customUserRepository; + EmbeddedUserDetailsRepository embeddedUserDetailsRepository; @Override public void run(ApplicationArguments args) throws Exception { - User user = User.builder() + Profile profile = Profile.builder() .id(1L) - .information("first user information") - .username("user@mail.cc") + .information("first profile information") + .username("profile@mail.cc") .email("firstUser@mail.com") .build(); - userRepository.save(user); - CustomUser customUser = CustomUser.builder() + profileRepository.save(profile); + EmbeddedUserDetails embeddedUserDetails = EmbeddedUserDetails.builder() .id(1L) .password("qwerty") - .profile(user) + .profile(profile) .build(); - customUserRepository.save(customUser); + embeddedUserDetailsRepository.save(embeddedUserDetails); } diff --git a/src/main/java/fic/writer/web/config/security/authorization/CustomUserDetails.java b/src/main/java/fic/writer/web/config/security/authorization/EmbeddedProfileDetails.java similarity index 79% rename from src/main/java/fic/writer/web/config/security/authorization/CustomUserDetails.java rename to src/main/java/fic/writer/web/config/security/authorization/EmbeddedProfileDetails.java index 7b4cab6..a958093 100644 --- a/src/main/java/fic/writer/web/config/security/authorization/CustomUserDetails.java +++ b/src/main/java/fic/writer/web/config/security/authorization/EmbeddedProfileDetails.java @@ -1,6 +1,6 @@ package fic.writer.web.config.security.authorization; -import fic.writer.domain.entity.User; +import fic.writer.domain.entity.Profile; import lombok.Getter; import org.assertj.core.util.Lists; import org.springframework.security.core.GrantedAuthority; @@ -9,13 +9,13 @@ import java.util.Collection; -public class CustomUserDetails implements UserDetails { +public class EmbeddedProfileDetails implements UserDetails { @Getter - private User user; + private Profile profile; private String password; - public CustomUserDetails(User user, String password) { - this.user = user; + public EmbeddedProfileDetails(Profile profile, String password) { + this.profile = profile; this.password = password; } @@ -31,7 +31,7 @@ public String getPassword() { @Override public String getUsername() { - return user.getEmail(); + return profile.getEmail(); } @Override diff --git a/src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java b/src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java index 35ba739..943090e 100644 --- a/src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java +++ b/src/main/java/fic/writer/web/config/security/authorization/UserDetailServiceImpl.java @@ -1,9 +1,9 @@ package fic.writer.web.config.security.authorization; -import fic.writer.domain.entity.User; -import fic.writer.domain.entity.auth.CustomUser; -import fic.writer.domain.repository.CustomUserRepository; -import fic.writer.domain.repository.UserRepository; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.auth.EmbeddedUserDetails; +import fic.writer.domain.repository.EmbeddedUserDetailsRepository; +import fic.writer.domain.repository.ProfileRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.userdetails.UserDetails; @@ -16,23 +16,24 @@ @Service public class UserDetailServiceImpl implements UserDetailsService { - @Autowired - private UserRepository userRepository; - @Autowired - private CustomUserRepository customUserRepository; - @Autowired + private ProfileRepository profileRepository; + private EmbeddedUserDetailsRepository embeddedUserDetailsRepository; private PasswordEncoder passwordEncoder; + @Autowired + public UserDetailServiceImpl(ProfileRepository profileRepository, EmbeddedUserDetailsRepository embeddedUserDetailsRepository, PasswordEncoder passwordEncoder) { + this.profileRepository = profileRepository; + this.embeddedUserDetailsRepository = embeddedUserDetailsRepository; + this.passwordEncoder = passwordEncoder; + } + @Override @Transactional public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - User user = userRepository.findByEmail(username).orElseThrow(() -> new BadCredentialsException("username: " + username + " not found")); - CustomUser customUser = customUserRepository.findByProfileId(user.getId()).orElseThrow(() -> new BadCredentialsException("username: " + user.getEmail() + " not found")); - - CustomUserDetails customUserDetails = new CustomUserDetails(user, passwordEncoder.encode(customUser.getPassword())); - - return customUserDetails; + Profile profile = profileRepository.findByEmail(username).orElseThrow(() -> new BadCredentialsException("username: " + username + " not found")); + EmbeddedUserDetails embeddedUserDetails = embeddedUserDetailsRepository.findByProfileId(profile.getId()).orElseThrow(() -> new BadCredentialsException("username: " + profile.getEmail() + " not found")); + return new EmbeddedProfileDetails(profile, passwordEncoder.encode(embeddedUserDetails.getPassword())); } } \ No newline at end of file diff --git a/src/main/java/fic/writer/web/config/security/oauth/externalService/OauthExternalServerAuthenticationSuccessHandler.java b/src/main/java/fic/writer/web/config/security/oauth/externalService/OauthExternalServerAuthenticationSuccessHandler.java index 5a4c422..f124d74 100644 --- a/src/main/java/fic/writer/web/config/security/oauth/externalService/OauthExternalServerAuthenticationSuccessHandler.java +++ b/src/main/java/fic/writer/web/config/security/oauth/externalService/OauthExternalServerAuthenticationSuccessHandler.java @@ -1,9 +1,9 @@ package fic.writer.web.config.security.oauth.externalService; -import fic.writer.domain.entity.auth.OauthUser; +import fic.writer.domain.entity.auth.OauthUserDetails; import fic.writer.domain.service.OauthUserService; -import fic.writer.domain.service.UserService; +import fic.writer.domain.service.ProfileService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -25,26 +25,26 @@ public class OauthExternalServerAuthenticationSuccessHandler extends SavedReques @Autowired private OauthUserService oauthUserService; @Autowired - private UserService userService; + private ProfileService profileService; @Override public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { super.onAuthenticationSuccess(httpServletRequest, httpServletResponse, authentication); OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) authentication; String username = oAuth2Authentication.getName(); - if (!userService.findByUsername(username).isPresent()) { - OauthUser oauthUser = createNewOauthUser(oAuth2Authentication.getUserAuthentication()); + if (!profileService.findByUsername(username).isPresent()) { + OauthUserDetails oauthUserDetails = createNewOauthUser(oAuth2Authentication.getUserAuthentication()); SecurityContextHolder.getContext().setAuthentication(oAuth2Authentication); } // OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest); httpServletResponse.getWriter().write("token:" + oauth2ClientContext.getAccessTokenRequest()); } - private OauthUser createNewOauthUser(Authentication authentication) { + private OauthUserDetails createNewOauthUser(Authentication authentication) { Long id = new Long(((Map) authentication.getDetails()).get("id").toString()); - OauthUser oauthUser = new OauthUser(); + OauthUserDetails oauthUserDetails = new OauthUserDetails(); - return oauthUser; + return oauthUserDetails; } } diff --git a/src/main/java/fic/writer/web/controller/ArticleController.java b/src/main/java/fic/writer/web/controller/ArticleController.java index 7a1b197..aa8412e 100644 --- a/src/main/java/fic/writer/web/controller/ArticleController.java +++ b/src/main/java/fic/writer/web/controller/ArticleController.java @@ -27,13 +27,13 @@ public class ArticleController { @GetMapping public List getAllArticles(@PathVariable(BOOK_ID_TEMPLATE) Long bookId) { - List list = articleService.findAllForBook(bookId).stream().map(ArticleResponse::new).collect(Collectors.toList()); + List list = 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 articleService.findAllForBook(bookId).stream(). + return articleService.findAllArticlesForBook(bookId).stream(). filter(article -> article.getId().equals(articleId)) .map(ArticleResponse::new) .findFirst() diff --git a/src/main/java/fic/writer/web/controller/BookController.java b/src/main/java/fic/writer/web/controller/BookController.java index 58a1407..0a6007b 100644 --- a/src/main/java/fic/writer/web/controller/BookController.java +++ b/src/main/java/fic/writer/web/controller/BookController.java @@ -3,21 +3,18 @@ import fic.writer.domain.entity.Book; import fic.writer.domain.entity.dto.BookDto; import fic.writer.domain.service.BookService; -import fic.writer.domain.service.UserService; +import fic.writer.domain.service.ProfileService; +import fic.writer.domain.service.WriterService; import fic.writer.web.response.BookResponse; import fic.writer.web.response.PageResponse; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.ByteArrayResource; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -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 javax.persistence.EntityNotFoundException; -import java.io.IOException; @RestController @RequestMapping(value = "/books", produces = MediaType.APPLICATION_JSON_VALUE) @@ -26,12 +23,13 @@ public class BookController { private static final String ID_TEMPLATE = "bookId"; private BookService bookService; - private UserService userService; + private ProfileService profileService; + private WriterService writerService; @Autowired - public BookController(BookService bookService, UserService userService) { + public BookController(BookService bookService, ProfileService profileService) { this.bookService = bookService; - this.userService = userService; + this.profileService = profileService; } @@ -51,8 +49,7 @@ public BookResponse getBookById(@PathVariable(ID_TEMPLATE) Long id) { @PostMapping @ResponseStatus(HttpStatus.CREATED) public BookResponse createBook(@RequestBody BookDto book) { - Book savedBook = bookService.create(book); - userService.addBookAsAuthor(savedBook.getAuthor().getId(), savedBook.getId()); + Book savedBook = writerService.createBook(book); return new BookResponse(savedBook); } @@ -68,19 +65,7 @@ public HttpStatus deleteBook(Long id) { return HttpStatus.NO_CONTENT; } - @GetMapping(ID_TEMPLATE_PATH + "/download") - public ResponseEntity downloadBook(@PathVariable(ID_TEMPLATE) Long id) throws IOException { - byte[] bytes = bookService.getBookAsByteArray(id); - Book book = bookService.findById(id).get(); - ByteArrayResource resource = new ByteArrayResource(bytes); - MediaType mediaType = MediaType.TEXT_PLAIN; - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + book.getTitle() + ".txt") - .contentType(mediaType) - .contentLength(bytes.length) - .body(resource); - } } diff --git a/src/main/java/fic/writer/web/controller/FileController.java b/src/main/java/fic/writer/web/controller/FileController.java index 775b433..a5cafd0 100644 --- a/src/main/java/fic/writer/web/controller/FileController.java +++ b/src/main/java/fic/writer/web/controller/FileController.java @@ -1,56 +1,67 @@ package fic.writer.web.controller; import fic.writer.domain.entity.Book; -import fic.writer.domain.entity.dto.BookDto; -import fic.writer.domain.repository.BookRepository; import fic.writer.domain.service.BookService; import fic.writer.domain.service.FileService; -import fic.writer.domain.service.UserService; +import fic.writer.domain.service.ProfileService; +import fic.writer.domain.service.WriterService; import fic.writer.web.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.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.util.HashMap; -import java.util.Map; +import javax.persistence.EntityNotFoundException; +import java.io.IOException; @RestController public class FileController { - @Autowired + private static final String BOOK_DOWNLOAD_TEMPLATE_PATH = "/books/{bookId}/download"; + private static final String FILE_CONTENT_PARSE_PATH = "/files/content"; + private static final String PARSE_FILE_AS_BOOK_PATH = "/files/books"; + + private static final String BOOK_ID_TEMPLATE = "bookId"; + private static final String FILE_PARAM_NAME = "file"; + private FileService fileService; - @Autowired private BookService bookService; + @Autowired - private UserService userService; - @Autowired - private BookRepository bookRepository; + public FileController(FileService fileService, BookService bookService, ProfileService profileService, WriterService writerService) { + this.fileService = fileService; + this.bookService = bookService; + } - @PostMapping("/files") + @PostMapping(FILE_CONTENT_PARSE_PATH) @ResponseStatus(HttpStatus.CREATED) - public Resource takeArticleContentFromFile(@RequestParam("file") MultipartFile file) { - Map map = new HashMap<>(); + public Resource takeArticleContentFromFile(@RequestParam(FILE_PARAM_NAME) MultipartFile file) { return new Resource<>(fileService.parseText(file)); } - @PostMapping("/files/books") + @PostMapping(PARSE_FILE_AS_BOOK_PATH) @ResponseStatus(HttpStatus.CREATED) - public BookResponse takeBookFromFile(@RequestParam("file") MultipartFile file) { - Map map = new HashMap<>(); + public BookResponse takeBookFromFile(@RequestParam(FILE_PARAM_NAME) MultipartFile file) { Book book = fileService.parseBook(file); - Book createdBook = bookService.create(BookDto.of(book)); - userService.addBookAsAuthor(createdBook.getAuthor().getId(), createdBook.getId()); - - createdBook.setArticles(book.getArticles()); - createdBook.getArticles().forEach(a -> { - a.setBook(createdBook); - userService.addBookAsAuthor(createdBook.getAuthor().getId(), createdBook.getId()); - }); + bookService.save(book); + return new BookResponse(book); + } - return new BookResponse(createdBook); + @GetMapping(BOOK_DOWNLOAD_TEMPLATE_PATH) + public ResponseEntity downloadBook(@PathVariable(BOOK_ID_TEMPLATE) Long id) throws IOException { + Book book = bookService.findById(id).orElseThrow(EntityNotFoundException::new); + final String filenameHeader = "attachment;filename=" + book.getTitle() + ".txt"; + byte[] bookAsByteArray = bookService.getBookAsByteArray(id); + 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); } } diff --git a/src/main/java/fic/writer/web/controller/UserController.java b/src/main/java/fic/writer/web/controller/UserController.java index 504ffd5..7db31bd 100644 --- a/src/main/java/fic/writer/web/controller/UserController.java +++ b/src/main/java/fic/writer/web/controller/UserController.java @@ -1,8 +1,8 @@ package fic.writer.web.controller; -import fic.writer.domain.entity.User; -import fic.writer.domain.entity.dto.UserDto; -import fic.writer.domain.service.UserService; +import fic.writer.domain.entity.Profile; +import fic.writer.domain.entity.dto.ProfileDto; +import fic.writer.domain.service.ProfileService; import fic.writer.web.response.UserResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -19,49 +19,51 @@ public class UserController { private static final String ID_TEMPLATE_PATH = "/{userId}"; private static final String ID_TEMPLATE = "userId"; - private UserService userService; + private ProfileService profileService; @Autowired - public UserController(UserService userService) { - this.userService = userService; + public UserController(ProfileService profileService) { + this.profileService = profileService; } @GetMapping public List getAllUsers() { - return userService.findAll().stream() + return profileService.findAll().stream() .map(UserResponse::new) .collect(Collectors.toList()); } @GetMapping(ID_TEMPLATE_PATH) public UserResponse getUserById(@PathVariable(ID_TEMPLATE) Long id) { - return userService.findById(id) + return profileService.findById(id) .map(UserResponse::new) .orElseThrow(EntityNotFoundException::new); } @PostMapping @ResponseStatus(HttpStatus.CREATED) - public UserResponse createUser(@RequestBody UserDto user) { - User savedUser = userService.create(user); - return new UserResponse(savedUser); + public UserResponse createUser(@RequestBody ProfileDto user) { + Profile savedProfile = profileService.create(user); + return new UserResponse(savedProfile); } @PutMapping(ID_TEMPLATE_PATH) - public UserResponse updateUser(@PathVariable(ID_TEMPLATE) Long id, @RequestBody UserDto userDto) { - User savedUser = userService.update(id, userDto); - return new UserResponse(savedUser); + public UserResponse updateUser(@PathVariable(ID_TEMPLATE) Long id, @RequestBody ProfileDto profileDto) { + Profile savedProfile = profileService.update(id, profileDto); + return new UserResponse(savedProfile); } @DeleteMapping(ID_TEMPLATE_PATH) @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteUser(Long id) { - userService.deleteById(id); + profileService.deleteById(id); } @RequestMapping({"/user", "/me"}) - public UserResponse user(Principal principal) { - UserResponse user = userService.findByEmail(principal.getName()).map(UserResponse::new).orElseThrow(EntityNotFoundException::new); - return user; + public UserResponse getLoggedProfile(Principal principal) { + String username = principal.getName(); + return profileService.findByEmail(username) + .map(UserResponse::new) + .orElseThrow(EntityNotFoundException::new); } } diff --git a/src/main/java/fic/writer/web/response/BookResponse.java b/src/main/java/fic/writer/web/response/BookResponse.java index 3415032..b68c4f6 100644 --- a/src/main/java/fic/writer/web/response/BookResponse.java +++ b/src/main/java/fic/writer/web/response/BookResponse.java @@ -6,6 +6,7 @@ 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.UserController; import lombok.AllArgsConstructor; import lombok.Getter; @@ -45,7 +46,7 @@ public BookResponse(Book book) { Long authorId = book.getAuthor().getId(); author = linkTo(methodOn(UserController.class, authorId).getUserById(authorId)).withRel("author"); } - subAuthors = book.getSubAuthors().stream().map(author -> + subAuthors = book.getCoauthors().stream().map(author -> linkTo(methodOn(UserController.class, author.getId()).getUserById(author.getId())) .withRel("subauthor")).collect(Collectors.toSet()); source = book.getSource().stream().map(BookResponse::new).map(ResourceSupport::getId).collect(Collectors.toSet()); @@ -66,7 +67,7 @@ private void addSelfLink(Long id) { private void addDownloadLink(Long id) { try { - add(linkTo(methodOn(BookController.class, id).downloadBook(id)).withRel("download")); + add(linkTo(methodOn(FileController.class, id).downloadBook(id)).withRel("download")); } catch (IOException e) { e.printStackTrace(); } diff --git a/src/main/java/fic/writer/web/response/UserResponse.java b/src/main/java/fic/writer/web/response/UserResponse.java index bc6d942..e2ad54e 100644 --- a/src/main/java/fic/writer/web/response/UserResponse.java +++ b/src/main/java/fic/writer/web/response/UserResponse.java @@ -1,6 +1,6 @@ package fic.writer.web.response; -import fic.writer.domain.entity.User; +import fic.writer.domain.entity.Profile; import fic.writer.web.controller.UserController; import lombok.AllArgsConstructor; import lombok.Getter; @@ -29,13 +29,13 @@ public class UserResponse extends ResourceSupport { private Set booksAsSubAuthor; private Set booksAsAuthor; - public UserResponse(User user) { - this.userId = user.getId(); - username = user.getUsername(); - about = user.getAbout(); - information = user.getInformation(); - booksAsSubAuthor = user.getBooksAsSubAuthor().stream().map(BookResponse::new).collect(Collectors.toSet()); - booksAsAuthor = user.getBooksAsAuthor().stream().map(BookResponse::new).collect(Collectors.toSet()); + public UserResponse(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()); addSelfLink(userId); } diff --git a/src/main/resources/application-db-mysql.yml b/src/main/resources/application-db-mysql.yml index c27a72f..5758ac9 100644 --- a/src/main/resources/application-db-mysql.yml +++ b/src/main/resources/application-db-mysql.yml @@ -13,5 +13,5 @@ spring: dialect: org.hibernate.dialect.MySQL5InnoDBDialect hbm2ddl: import_files_sql_extractor: org.hibernate.tool.hbm2ddl.MultipleLinesSqlCommandExtractor - import_files: data/user.sql, data/actor.sql, data/book.sql, data/article.sql,data/book_article.sql, data/actor_state.sql + 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-db-postgresql.yml b/src/main/resources/application-db-postgresql.yml index b87ee54..8711d9b 100644 --- a/src/main/resources/application-db-postgresql.yml +++ b/src/main/resources/application-db-postgresql.yml @@ -19,5 +19,5 @@ spring: dialect: org.hibernate.dialect.PostgreSQLDialect hbm2ddl: import_files_sql_extractor: org.hibernate.tool.hbm2ddl.MultipleLinesSqlCommandExtractor - import_files: data/user.sql, data/actor.sql, data/book.sql, data/article.sql,data/book_article.sql, data/actor_state.sql + 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-oauth-github.yml b/src/main/resources/application-oauth-github.yml index f1f0c16..fece6d6 100644 --- a/src/main/resources/application-oauth-github.yml +++ b/src/main/resources/application-oauth-github.yml @@ -6,4 +6,4 @@ github: userAuthorizationUri: https://github.com/login/oauth/authorize clientAuthenticationScheme: form resource: - userInfoUri: https://api.github.com/user \ No newline at end of file + userInfoUri: https://api.github.com/getLoggedProfile \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 306efb9..a715722 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,7 +7,7 @@ spring: multipart: max-file-size: 5MB max-request-size: 5MB - location: ${user.dir}/files/temp + location: ${getLoggedProfile.dir}/files/temp logging: level: org: diff --git a/src/main/resources/data/user.sql b/src/main/resources/data/user.sql index deb354d..834b1c4 100644 --- a/src/main/resources/data/user.sql +++ b/src/main/resources/data/user.sql @@ -1,2 +1,2 @@ -INSERT INTO user(id,about, information, username)VALUES(1,'I am author',' zaraza-takaja@mail.ru','Zaraza takaja'); -INSERT INTO user(id,about, information, username)VALUES(2,'I am author, too',' zaraza-takaja@mail.ru','@uthor'); \ No newline at end of file +INSERT INTO profile(id,about, information, username)VALUES(1,'I am author',' zaraza-takaja@mail.ru','Zaraza takaja'); +INSERT INTO profile(id,about, information, username)VALUES(2,'I am author, too',' zaraza-takaja@mail.ru','@uthor'); \ No newline at end of file diff --git a/src/main/resources/docker/stack.yml b/src/main/resources/docker/stack.yml index c5900c2..15a1873 100644 --- a/src/main/resources/docker/stack.yml +++ b/src/main/resources/docker/stack.yml @@ -1,4 +1,4 @@ -# Use root/example as user/password credentials +# Use root/example as getLoggedProfile/password credentials version: '3.1' services: diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 231d9fd..dcb6755 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -19,7 +19,7 @@

    Login

    With github: click here
    - - - \ No newline at end of file diff --git a/src/test/resources/application-oauth2-config.yml b/src/test/resources/application-oauth2-config.yml index 56b6463..89f8bb8 100644 --- a/src/test/resources/application-oauth2-config.yml +++ b/src/test/resources/application-oauth2-config.yml @@ -29,11 +29,5 @@ app: tokenSecret: 926D96C90030DD58429D2751AC1BDBBC tokenExpirationMsec: 864000000 oauth2: - # After successfully authenticating with the OAuth2 Provider, - # we'll be generating an auth token for the user and sending the token to the - # redirectUri mentioned by the client in the /oauth2/authorize request. - # We're not using cookies because they won't work well in mobile clients. authorizedRedirectUris: - - http://localhost:3000/oauth2/redirect - - myandroidapp://oauth2/redirect - - myiosapp://oauth2/redirect \ No newline at end of file + - http://localhost:3000/oauth2/redirect \ No newline at end of file diff --git a/src/test/resources/docker/docker-compose.yml b/src/test/resources/docker/docker-compose.yml deleted file mode 100644 index 6f3b873..0000000 --- a/src/test/resources/docker/docker-compose.yml +++ /dev/null @@ -1,39 +0,0 @@ -# Use postgres/example getLoggedProfile/password credentials -version: '3.1' - -services: - postgres: - container_name: postgres_container - image: postgres - environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} - PGDATA: /data/postgres - POSTGRES_DB: ${POSTGRES_DB:-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: - - "${PGADMIN_PORT:-5050}:80" - networks: - - postgres - -networks: - postgres: - driver: bridge -volumes: - postgres: - pgadmin: \ No newline at end of file