├── src └── main │ └── java │ └── com │ └── joinhocus │ └── horus │ ├── http │ ├── model │ │ └── EmptyRequest.java │ ├── routes │ │ ├── invites │ │ │ ├── InviteRequest.java │ │ │ ├── create │ │ │ │ ├── model │ │ │ │ │ └── CreateInviteRequest.java │ │ │ │ └── CreateInviteHandler.java │ │ │ ├── get │ │ │ │ └── GetCreatedInvitesHandler.java │ │ │ ├── validate │ │ │ │ └── ValidateInviteHandler.java │ │ │ └── token │ │ │ │ └── SendVerificationEmailHandler.java │ │ ├── account │ │ │ ├── code │ │ │ │ ├── model │ │ │ │ │ └── InviteCodeRequest.java │ │ │ │ └── ValidateInviteCodeHandler.java │ │ │ ├── setup │ │ │ │ ├── model │ │ │ │ │ └── SetupAccountRequest.java │ │ │ │ ├── SetupAccountHandler.java │ │ │ │ └── GetUserInviterInfo.java │ │ │ ├── login │ │ │ │ ├── model │ │ │ │ │ └── AccountLoginRequest.java │ │ │ │ └── AccountLoginHandler.java │ │ │ ├── create │ │ │ │ ├── model │ │ │ │ │ ├── VerifyComboRequest.java │ │ │ │ │ └── CreateAccountRequest.java │ │ │ │ └── ValidateInviteTwitterComboHandler.java │ │ │ ├── forgotpw │ │ │ │ ├── model │ │ │ │ │ ├── ForgotPasswordResetRequest.java │ │ │ │ │ └── ForgotPasswordTokenRequest.java │ │ │ │ ├── RequestPasswordResetHandler.java │ │ │ │ └── ResetPasswordHandler.java │ │ │ ├── changepw │ │ │ │ ├── model │ │ │ │ │ └── ChangePasswordRequest.java │ │ │ │ └── ChangePasswordHandler.java │ │ │ ├── get │ │ │ │ ├── CheckAccountUsernameAvailabilityHandler.java │ │ │ │ └── GetAccountHandler.java │ │ │ └── settings │ │ │ │ └── UpdateAccountAvatarHandler.java │ │ ├── posts │ │ │ ├── like │ │ │ │ ├── model │ │ │ │ │ └── LikePostRequest.java │ │ │ │ └── LikePostHandler.java │ │ │ ├── model │ │ │ │ ├── ReplyToPostRequest.java │ │ │ │ └── CreatePostRequest.java │ │ │ ├── get │ │ │ │ ├── model │ │ │ │ │ ├── AuthorFilter.java │ │ │ │ │ └── GetFeedRequest.java │ │ │ │ └── GetPostByIdHandler.java │ │ │ └── CreatePostHandler.java │ │ ├── organization │ │ │ ├── create │ │ │ │ ├── model │ │ │ │ │ └── CreateOrganizationRequest.java │ │ │ │ └── CreateOrganizationHandler.java │ │ │ ├── getbyuser │ │ │ │ └── GetUserOrganizationsHandler.java │ │ │ └── settings │ │ │ │ └── logo │ │ │ │ └── UpdateOrganizationLogoHandler.java │ │ ├── waitlist │ │ │ ├── validate │ │ │ │ ├── model │ │ │ │ │ ├── EmailValidateWaitListRequest.java │ │ │ │ │ └── TwitterValidateWaitListRequest.java │ │ │ │ ├── EmailValidateWaitlistHandler.java │ │ │ │ └── TwitterValidateWaitListHandler.java │ │ │ ├── verify │ │ │ │ ├── model │ │ │ │ │ └── VerifyTwitterOAuthRequest.java │ │ │ │ └── VerifyWaitlistTwitterHandler.java │ │ │ ├── join │ │ │ │ └── JoinWaitlistTwitterHandler.java │ │ │ ├── register │ │ │ │ └── twitter │ │ │ │ │ └── RegisterWithTwitterHandler.java │ │ │ └── WaitlistUtil.java │ │ ├── entity │ │ │ ├── model │ │ │ │ └── FollowEntityRequest.java │ │ │ ├── SearchByHandleHandler.java │ │ │ ├── GetFollowsEntityHandler.java │ │ │ └── FollowEntityHandler.java │ │ ├── twitter │ │ │ └── info │ │ │ │ └── TwitterInfoHandler.java │ │ └── activity │ │ │ ├── GetActivityCountHandler.java │ │ │ └── GetActivityFeedHandler.java │ ├── DefinedTypesWithUserHandler.java │ ├── Response.java │ └── DefinedTypesHandler.java │ ├── post │ ├── PostPrivacy.java │ ├── PostType.java │ ├── Post.java │ ├── PostUtil.java │ └── impl │ │ ├── BasicPost.java │ │ └── PostWithExtra.java │ ├── account │ ├── AccountType.java │ ├── activity │ │ ├── NotificationType.java │ │ └── ActivityNotification.java │ ├── UserNames.java │ ├── AccountStatus.java │ ├── invite │ │ └── Invite.java │ ├── AccountAuth.java │ └── UserAccount.java │ ├── misc │ ├── follow │ │ ├── EntityType.java │ │ └── Followable.java │ ├── strgen │ │ └── RandomStringGenerator.java │ ├── function │ │ ├── TriFunction.java │ │ └── QuadFunction.java │ ├── PaginatedList.java │ ├── Pair.java │ ├── MongoIds.java │ ├── IOUtils.java │ ├── email │ │ └── EmailClient.java │ ├── gson │ │ └── ObjectIdSerializer.java │ ├── Environment.java │ ├── HandlesUtil.java │ └── Spaces.java │ ├── organization │ ├── OrganizationRole.java │ ├── OrganizationSettings.java │ ├── Organization.java │ └── SimpleOrganization.java │ ├── Main.java │ ├── slack │ ├── SlackClientExtension.java │ ├── horris │ │ └── BasicHorrisExtension.java │ ├── SlackEventsHandler.java │ ├── waitlist │ │ └── SlackWaitListExtension.java │ └── SlackClient.java │ ├── twitter │ ├── oauth │ │ ├── OAuthResponseValues.java │ │ ├── OAuthAccountResponse.java │ │ └── TwitterOauth.java │ ├── data │ │ └── TwitterUser.java │ ├── TwitterAPI.java │ └── TwitterOAuthHeaderGenerator.java │ ├── config │ ├── Config.java │ └── Configs.java │ └── db │ ├── repos │ ├── EmailInviteVerificationRepo.java │ ├── LikesRepo.java │ ├── PasswordResetRepo.java │ ├── InvitesRepo.java │ ├── WaitListRepo.java │ ├── FollowsRepo.java │ ├── ActivityRepo.java │ └── OrganizationRepo.java │ ├── config │ └── MongoConfig.java │ └── MongoDatabase.java ├── README.md ├── Dockerfile ├── .gitignore └── pom.xml /src/main/java/com/joinhocus/horus/http/model/EmptyRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.model; 2 | 3 | public class EmptyRequest { 4 | } 5 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/post/PostPrivacy.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.post; 2 | 3 | public enum PostPrivacy { 4 | PUBLIC, 5 | PRIVATE 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/account/AccountType.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.account; 2 | 3 | public enum AccountType { 4 | INVESTOR, 5 | FOUNDER, 6 | OTHER; 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/follow/EntityType.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc.follow; 2 | 3 | public enum EntityType { 4 | ACCOUNT, 5 | ORGANIZATION 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/organization/OrganizationRole.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.organization; 2 | 3 | public enum OrganizationRole { 4 | CREATOR, 5 | MEMBER 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/Main.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus; 2 | 3 | public class Main { 4 | 5 | public static void main(String[] args) { 6 | new Horus(); 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/strgen/RandomStringGenerator.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc.strgen; 2 | 3 | public interface RandomStringGenerator { 4 | 5 | String generate(int length); 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/follow/Followable.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc.follow; 2 | 3 | import org.bson.types.ObjectId; 4 | 5 | public interface Followable { 6 | ObjectId getId(); 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/account/activity/NotificationType.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.account.activity; 2 | 3 | public enum NotificationType { 4 | FOLLOW, 5 | REPLY, 6 | MENTION, 7 | LIKE 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/function/TriFunction.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc.function; 2 | 3 | @FunctionalInterface 4 | public interface TriFunction { 5 | 6 | R apply(A a, B b, C c); 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/slack/SlackClientExtension.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.slack; 2 | 3 | import com.slack.api.bolt.App; 4 | 5 | public interface SlackClientExtension { 6 | 7 | void register(App app); 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/function/QuadFunction.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc.function; 2 | 3 | @FunctionalInterface 4 | public interface QuadFunction { 5 | 6 | R apply(A a, B b, C c, D d); 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/invites/InviteRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.invites; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class InviteRequest { 7 | 8 | private final String inviteId; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/twitter/oauth/OAuthResponseValues.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.twitter.oauth; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class OAuthResponseValues { 7 | 8 | private final String token, secret; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/twitter/oauth/OAuthAccountResponse.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.twitter.oauth; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class OAuthAccountResponse { 7 | 8 | private final String userId, name; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/code/model/InviteCodeRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.code.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class InviteCodeRequest { 7 | 8 | private final String code; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/posts/like/model/LikePostRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.posts.like.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class LikePostRequest { 7 | 8 | private final String id; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Horus 2 | 3 | A heavily mongodb-reliant platform for investors to connect to their founders. 4 | Data aggregation pipelines were heavily optimized but are quite unreadable. 5 | 6 | This project was abandoned but the foundations remain strong and a lot can be stripped away and re-used. 7 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/setup/model/SetupAccountRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.setup.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class SetupAccountRequest { 7 | 8 | private final String selected; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/login/model/AccountLoginRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.login.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class AccountLoginRequest { 7 | 8 | private final String login, password; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/organization/create/model/CreateOrganizationRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.organization.create.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class CreateOrganizationRequest { 7 | private final String name, handle; 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/post/PostType.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.post; 2 | 3 | import java.util.EnumSet; 4 | 5 | public enum PostType { 6 | UPDATE, 7 | WIN, 8 | ASK; 9 | 10 | public static EnumSet ALL = EnumSet.allOf(PostType.class); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/create/model/VerifyComboRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.create.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class VerifyComboRequest { 7 | 8 | private final String inviteCode, twitterId; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/forgotpw/model/ForgotPasswordResetRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.forgotpw.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class ForgotPasswordResetRequest { 7 | 8 | private final String login; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/waitlist/validate/model/EmailValidateWaitListRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.waitlist.validate.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class EmailValidateWaitListRequest { 7 | 8 | private final String code; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/waitlist/verify/model/VerifyTwitterOAuthRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.waitlist.verify.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class VerifyTwitterOAuthRequest { 7 | 8 | private final String token, verify; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/changepw/model/ChangePasswordRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.changepw.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class ChangePasswordRequest { 7 | 8 | private final String current, changeTo; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/posts/model/ReplyToPostRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.posts.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class ReplyToPostRequest { 7 | 8 | private final String content; 9 | private final String parent; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:11-jdk 2 | 3 | RUN mkdir /app 4 | WORKDIR /app 5 | COPY target/hocus-1.0-SNAPSHOT.jar hocus.jar 6 | 7 | RUN wget -O /bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64 8 | RUN chmod +x /bin/dumb-init 9 | ENTRYPOINT ["/bin/dumb-init", "--"] 10 | 11 | CMD java -jar hocus.jar -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/waitlist/validate/model/TwitterValidateWaitListRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.waitlist.validate.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class TwitterValidateWaitListRequest { 7 | 8 | private final String twitterId, verify, token; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/forgotpw/model/ForgotPasswordTokenRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.forgotpw.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class ForgotPasswordTokenRequest { 7 | 8 | private final String token; 9 | private final String password; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/posts/get/model/AuthorFilter.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.posts.get.model; 2 | 3 | import lombok.Data; 4 | import org.bson.types.ObjectId; 5 | 6 | @Data 7 | public class AuthorFilter { 8 | private final ObjectId author; 9 | private final ObjectId organization; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/PaginatedList.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc; 2 | 3 | import com.google.gson.JsonObject; 4 | import lombok.Data; 5 | 6 | import java.util.List; 7 | 8 | @Data 9 | public class PaginatedList { 10 | private final JsonObject paginationData; 11 | private final List data; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/account/UserNames.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.account; 2 | 3 | import lombok.Data; 4 | 5 | import java.util.regex.Pattern; 6 | 7 | @Data 8 | public class UserNames { 9 | 10 | public static final Pattern NAME_PATTERN = Pattern.compile("^(\\w){1,15}$"); 11 | 12 | private final String name, display; 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/entity/model/FollowEntityRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.entity.model; 2 | 3 | import com.joinhocus.horus.misc.follow.EntityType; 4 | import lombok.Data; 5 | 6 | @Data 7 | public class FollowEntityRequest { 8 | private final String id; 9 | private final EntityType type; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/invites/create/model/CreateInviteRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.invites.create.model; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class CreateInviteRequest { 7 | 8 | private final String sendTo; 9 | private final String orgId; 10 | private final boolean handleDeliver; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/create/model/CreateAccountRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.create.model; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | @AllArgsConstructor 7 | @Getter 8 | public class CreateAccountRequest { 9 | 10 | private final String email, password, name, username; 11 | private final String inviteCode; 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/config/Config.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.config; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | @Target(ElementType.TYPE) 9 | @Retention(RetentionPolicy.RUNTIME) 10 | public @interface Config { 11 | String directory(); 12 | 13 | String name(); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/posts/get/model/GetFeedRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.posts.get.model; 2 | 3 | import com.joinhocus.horus.post.PostType; 4 | import lombok.Data; 5 | 6 | import java.util.Set; 7 | 8 | @Data 9 | public class GetFeedRequest { 10 | 11 | private final int page; 12 | private final Set types; 13 | private final AuthorFilter authorFilter; 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/Pair.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | @AllArgsConstructor 7 | @Getter 8 | public class Pair { 9 | 10 | private final Key key; 11 | private final Value value; 12 | 13 | public static Pair of(Key key, Value value) { 14 | return new Pair<>(key, value); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/organization/OrganizationSettings.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.organization; 2 | 3 | import lombok.Getter; 4 | import org.bson.Document; 5 | 6 | @Getter 7 | public class OrganizationSettings { 8 | 9 | private final String logo; 10 | 11 | public OrganizationSettings(Document document) { 12 | this.logo = document.getString("logo"); 13 | } 14 | 15 | public OrganizationSettings() { 16 | this.logo = null; 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/posts/model/CreatePostRequest.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.posts.model; 2 | 3 | import com.joinhocus.horus.post.PostPrivacy; 4 | import com.joinhocus.horus.post.PostType; 5 | import lombok.Data; 6 | 7 | @Data 8 | public class CreatePostRequest { 9 | 10 | private final String content; 11 | private final PostType type; 12 | private final PostPrivacy privacy; 13 | private final String organizationId; 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/account/AccountStatus.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.account; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | @AllArgsConstructor 7 | @Getter 8 | public enum AccountStatus { 9 | AVAILABLE(""), 10 | USERNAME_TAKEN("an account with that username already exists"), 11 | EMAIL_TAKEN("an account with that email already exists"), 12 | UNKNOWN("unknown reason, please report this."); 13 | 14 | private final String message; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/MongoIds.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc; 2 | 3 | import lombok.experimental.UtilityClass; 4 | import org.bson.types.ObjectId; 5 | import org.jetbrains.annotations.Nullable; 6 | 7 | @UtilityClass 8 | public class MongoIds { 9 | 10 | @Nullable 11 | public ObjectId parseId(String id) { 12 | try { 13 | return new ObjectId(id); 14 | } catch (Exception e) { 15 | return null; 16 | } 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/slack/horris/BasicHorrisExtension.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.slack.horris; 2 | 3 | import com.joinhocus.horus.slack.SlackClientExtension; 4 | import com.slack.api.bolt.App; 5 | 6 | public class BasicHorrisExtension implements SlackClientExtension { 7 | @Override 8 | public void register(App app) { 9 | app.command("/horris", (request, context) -> { 10 | return context.ack("Hocus pocus, it's time to focus! :sparkles:"); 11 | }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/twitter/data/TwitterUser.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.twitter.data; 2 | 3 | import com.google.gson.annotations.SerializedName; 4 | import lombok.Data; 5 | 6 | @Data 7 | public class TwitterUser { 8 | 9 | private final String name, location, url; 10 | @SerializedName("screen_name") 11 | private final String handle; 12 | @SerializedName("id_str") 13 | private final String id; 14 | private final boolean verified; 15 | @SerializedName("followers_count") 16 | private final int followers; 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/post/Post.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.post; 2 | 3 | import com.joinhocus.horus.account.UserAccount; 4 | import com.joinhocus.horus.organization.Organization; 5 | import org.bson.types.ObjectId; 6 | 7 | import java.util.Date; 8 | import java.util.List; 9 | 10 | public interface Post { 11 | 12 | ObjectId getId(); 13 | 14 | PostType type(); 15 | 16 | PostPrivacy privacy(); 17 | 18 | String content(); 19 | 20 | UserAccount author(); 21 | 22 | Organization organization(); 23 | 24 | Date postTime(); 25 | 26 | ObjectId parent(); 27 | 28 | void addComment(Post comment); 29 | 30 | List getComments(); 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/IOUtils.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc; 2 | 3 | import lombok.experimental.UtilityClass; 4 | 5 | import java.io.ByteArrayOutputStream; 6 | import java.io.IOException; 7 | import java.io.InputStream; 8 | 9 | @UtilityClass 10 | public class IOUtils { 11 | 12 | public byte[] readBytes(InputStream stream) throws IOException { 13 | ByteArrayOutputStream buffer = new ByteArrayOutputStream(); 14 | int nRead; 15 | byte[] data = new byte[16384]; 16 | 17 | while ((nRead = stream.read(data, 0, data.length)) != -1) { 18 | buffer.write(data, 0, nRead); 19 | } 20 | 21 | return buffer.toByteArray(); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/email/EmailClient.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc.email; 2 | 3 | import com.wildbit.java.postmark.Postmark; 4 | import com.wildbit.java.postmark.client.ApiClient; 5 | 6 | public class EmailClient { 7 | 8 | private final String API_TOKEN = ""; 9 | private static EmailClient instance; 10 | private final ApiClient apiClient; 11 | 12 | public EmailClient() { 13 | this.apiClient = Postmark.getApiClient(API_TOKEN); 14 | } 15 | 16 | public ApiClient getClient() { 17 | return apiClient; 18 | } 19 | 20 | public static EmailClient getInstance() { 21 | if (instance == null) { 22 | instance = new EmailClient(); 23 | } 24 | 25 | return instance; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/gson/ObjectIdSerializer.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc.gson; 2 | 3 | import com.google.gson.JsonDeserializationContext; 4 | import com.google.gson.JsonDeserializer; 5 | import com.google.gson.JsonElement; 6 | import com.google.gson.JsonParseException; 7 | import com.google.gson.JsonPrimitive; 8 | import com.google.gson.JsonSerializationContext; 9 | import com.google.gson.JsonSerializer; 10 | import org.bson.types.ObjectId; 11 | 12 | import java.lang.reflect.Type; 13 | 14 | public class ObjectIdSerializer implements JsonSerializer, JsonDeserializer { 15 | 16 | @Override 17 | public ObjectId deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext jsonDeserializationContext) throws JsonParseException { 18 | return new ObjectId(jsonElement.getAsString()); 19 | } 20 | 21 | @Override 22 | public JsonElement serialize(ObjectId id, Type type, JsonSerializationContext jsonSerializationContext) { 23 | return new JsonPrimitive(id.toHexString()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/db/repos/EmailInviteVerificationRepo.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.db.repos; 2 | 3 | import com.google.common.base.Preconditions; 4 | import com.joinhocus.horus.account.invite.Invite; 5 | import com.joinhocus.horus.db.AsyncMongoRepo; 6 | import com.mongodb.client.model.Filters; 7 | import org.bson.Document; 8 | 9 | import java.util.concurrent.CompletableFuture; 10 | import java.util.function.Function; 11 | 12 | public class EmailInviteVerificationRepo extends AsyncMongoRepo { 13 | public EmailInviteVerificationRepo() { 14 | super("hocus", "email_invite_verification"); 15 | } 16 | 17 | public CompletableFuture insertVerification(Invite invite, String code) { 18 | Preconditions.checkNotNull(invite.getEmail()); 19 | 20 | return this.insertOne(new Document() 21 | .append("inviteId", invite.getInviteId()) 22 | .append("code", code) 23 | ).thenApply(opt -> null); 24 | } 25 | 26 | public CompletableFuture getByCode(String code) { 27 | return this.findFirst(Filters.eq("code", code), Function.identity()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/twitter/info/TwitterInfoHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.twitter.info; 2 | 3 | import com.joinhocus.horus.http.DefinedTypesHandler; 4 | import com.joinhocus.horus.http.Response; 5 | import com.joinhocus.horus.http.model.EmptyRequest; 6 | import com.joinhocus.horus.twitter.TwitterAPI; 7 | import io.javalin.core.validation.BodyValidator; 8 | import io.javalin.http.Context; 9 | import org.slf4j.Logger; 10 | 11 | import java.util.concurrent.CompletableFuture; 12 | 13 | public class TwitterInfoHandler implements DefinedTypesHandler { 14 | @Override 15 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 16 | String twitterId = context.pathParam("twitterId"); 17 | return TwitterAPI.fetchUserInfo(twitterId).thenApply(user -> { 18 | return Response.of(Response.Type.OKAY) 19 | .append("handle", user.getHandle()) 20 | .append("name", user.getName()); 21 | }); 22 | } 23 | 24 | @Override 25 | public Class requestClass() { 26 | return EmptyRequest.class; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/get/CheckAccountUsernameAvailabilityHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.get; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.http.DefinedTypesHandler; 5 | import com.joinhocus.horus.http.Response; 6 | import com.joinhocus.horus.http.model.EmptyRequest; 7 | import com.joinhocus.horus.misc.HandlesUtil; 8 | import io.javalin.core.validation.BodyValidator; 9 | import io.javalin.http.Context; 10 | import org.slf4j.Logger; 11 | 12 | import java.util.concurrent.CompletableFuture; 13 | 14 | public class CheckAccountUsernameAvailabilityHandler implements DefinedTypesHandler { 15 | @Override 16 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 17 | String name = context.queryParam("username"); 18 | if (Strings.isNullOrEmpty(name)) { 19 | return wrap(Response.of(Response.Type.BAD_REQUEST)); 20 | } 21 | return HandlesUtil.isHandleAvailable(name).thenApply(available -> { 22 | return Response.of(Response.Type.OKAY).append("available", available); 23 | }); 24 | } 25 | 26 | @Override 27 | public Class requestClass() { 28 | return EmptyRequest.class; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/config/Configs.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.config; 2 | 3 | import com.google.common.base.Preconditions; 4 | import com.google.gson.Gson; 5 | import com.google.gson.GsonBuilder; 6 | 7 | import java.io.File; 8 | import java.io.FileInputStream; 9 | import java.io.FileNotFoundException; 10 | import java.io.InputStreamReader; 11 | import java.util.function.Function; 12 | 13 | public class Configs { 14 | private static final Gson GSON = new GsonBuilder().serializeNulls().create(); 15 | 16 | public static T load(Class clazz, Function, T> fallback) { 17 | Config config = clazz.getAnnotation(Config.class); 18 | Preconditions.checkNotNull(config, "Config not present"); 19 | 20 | T object = null; 21 | File file = new File( 22 | "configs/" + config.directory(), 23 | config.name() + ".json" 24 | ); 25 | try (InputStreamReader reader = new InputStreamReader(new FileInputStream(file))) { 26 | object = GSON.fromJson(reader, clazz); 27 | } catch (Exception e) { 28 | if (e instanceof FileNotFoundException) { 29 | if (fallback != null) { 30 | object = fallback.apply(clazz); 31 | } 32 | } 33 | 34 | e.printStackTrace(); 35 | } 36 | 37 | return object; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/Environment.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc; 2 | 3 | import com.google.common.base.Strings; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | 9 | @AllArgsConstructor 10 | @Getter 11 | public enum Environment { 12 | PRODUCTION("https://joinhocus.com"), 13 | REVIEW("https://review.feed.horris.dev"), // TODO change this when working on branches w/ frontend 14 | STAGING("https://staging.horris.dev"), 15 | DEVELOPMENT("http://localhost:3000"); 16 | 17 | private final String url; 18 | private static final Environment current; 19 | 20 | static { 21 | Logger logger = LoggerFactory.getLogger(Environment.class); 22 | String env = System.getenv("ENVIRONMENT"); 23 | if (Strings.isNullOrEmpty(env)) { 24 | if (!Strings.isNullOrEmpty(System.getenv("GIT_HASH"))) { 25 | current = PRODUCTION; 26 | } else { 27 | current = DEVELOPMENT; 28 | } 29 | } else { 30 | current = Environment.valueOf(env); 31 | } 32 | 33 | logger.info("Loaded environment {}", current.name()); 34 | } 35 | 36 | public static boolean isDev() { 37 | return current == DEVELOPMENT || current == STAGING; 38 | } 39 | 40 | public static Environment current() { 41 | return current; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/waitlist/join/JoinWaitlistTwitterHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.waitlist.join; 2 | 3 | import com.joinhocus.horus.http.DefinedTypesHandler; 4 | import com.joinhocus.horus.http.Response; 5 | import com.joinhocus.horus.http.model.EmptyRequest; 6 | import com.joinhocus.horus.misc.Environment; 7 | import com.joinhocus.horus.twitter.TwitterAPI; 8 | import io.javalin.core.validation.BodyValidator; 9 | import io.javalin.http.Context; 10 | import io.javalin.http.InternalServerErrorResponse; 11 | import org.slf4j.Logger; 12 | 13 | import java.util.concurrent.CompletableFuture; 14 | 15 | public class JoinWaitlistTwitterHandler implements DefinedTypesHandler { 16 | 17 | private final String CALLBACK_URL = Environment.current().getUrl() + "/join"; 18 | 19 | @Override 20 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 21 | return TwitterAPI.OAUTH.getRequestToken(CALLBACK_URL).thenApply(res -> { 22 | return Response.of(Response.Type.OKAY).append("token", res.getToken()); 23 | }).exceptionally(err -> { 24 | logger.error("", err); 25 | throw new InternalServerErrorResponse(); 26 | }); 27 | } 28 | 29 | @Override 30 | public Class requestClass() { 31 | return EmptyRequest.class; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/waitlist/register/twitter/RegisterWithTwitterHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.waitlist.register.twitter; 2 | 3 | import com.joinhocus.horus.http.DefinedTypesHandler; 4 | import com.joinhocus.horus.http.Response; 5 | import com.joinhocus.horus.http.model.EmptyRequest; 6 | import com.joinhocus.horus.misc.Environment; 7 | import com.joinhocus.horus.twitter.TwitterAPI; 8 | import io.javalin.core.validation.BodyValidator; 9 | import io.javalin.http.Context; 10 | import io.javalin.http.InternalServerErrorResponse; 11 | import org.slf4j.Logger; 12 | 13 | import java.util.concurrent.CompletableFuture; 14 | 15 | public class RegisterWithTwitterHandler implements DefinedTypesHandler { 16 | 17 | private final String CALLBACK_URL = Environment.current().getUrl() + "/flow/twitter"; 18 | @Override 19 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 20 | return TwitterAPI.OAUTH.getRequestToken(CALLBACK_URL).thenApply(res -> { 21 | return Response.of(Response.Type.OKAY).append("token", res.getToken()); 22 | }).exceptionally(err -> { 23 | logger.error("", err); 24 | throw new InternalServerErrorResponse(); 25 | }); 26 | } 27 | 28 | @Override 29 | public Class requestClass() { 30 | return EmptyRequest.class; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/account/activity/ActivityNotification.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.account.activity; 2 | 3 | import com.joinhocus.horus.misc.follow.EntityType; 4 | import lombok.Getter; 5 | import lombok.RequiredArgsConstructor; 6 | import lombok.ToString; 7 | import org.bson.Document; 8 | import org.bson.types.ObjectId; 9 | 10 | import java.util.Date; 11 | import java.util.List; 12 | 13 | @RequiredArgsConstructor 14 | @Getter 15 | @ToString 16 | public class ActivityNotification { 17 | 18 | private final ObjectId id; 19 | private final ObjectId actor; 20 | private final EntityType entityType; 21 | private final List receivers; 22 | private final Date when; 23 | private final NotificationType notificationType; 24 | private final Document extra; 25 | 26 | private boolean wasSeen; 27 | 28 | public ActivityNotification(Document document) { 29 | this.id = document.getObjectId("_id"); 30 | this.actor = document.getObjectId("actor"); 31 | this.entityType = EntityType.valueOf(document.getString("entity")); 32 | //noinspection unchecked 33 | this.receivers = (List) document.get("receivers"); 34 | this.when = document.getDate("when"); 35 | this.notificationType = NotificationType.valueOf(document.getString("type")); 36 | this.extra = document.get("extra", Document.class); 37 | this.wasSeen = document.getBoolean("wasSeen", false); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/db/config/MongoConfig.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.db.config; 2 | 3 | import com.joinhocus.horus.config.Config; 4 | import com.mongodb.ServerAddress; 5 | 6 | import java.util.ArrayList; 7 | import java.util.List; 8 | 9 | @Config(directory = "database", name = "mongo") 10 | public class MongoConfig { 11 | 12 | private final String username, password; 13 | private final List addresses; 14 | 15 | public MongoConfig( 16 | String username, 17 | String password, 18 | List addresses) 19 | { 20 | this.username = username; 21 | this.password = password; 22 | this.addresses = addresses; 23 | } 24 | 25 | public String getUsername() { 26 | return username; 27 | } 28 | 29 | public String getPassword() { 30 | return password; 31 | } 32 | 33 | public List getAddresses() { 34 | return addresses; 35 | } 36 | 37 | public List asServerAddresses() { 38 | List addresses = new ArrayList<>(getAddresses().size()); 39 | for (String address : getAddresses()) { 40 | addresses.add(new ServerAddress(address)); 41 | } 42 | 43 | return addresses; 44 | } 45 | 46 | @Override 47 | public String toString() { 48 | return "MongoConfig{" + 49 | "username='" + username + '\'' + 50 | ", password='" + password + '\'' + 51 | ", addresses=" + addresses + 52 | '}'; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/entity/SearchByHandleHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.entity; 2 | 3 | import com.google.common.base.Strings; 4 | import com.google.gson.JsonArray; 5 | import com.google.gson.JsonObject; 6 | import com.joinhocus.horus.account.UserAccount; 7 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 8 | import com.joinhocus.horus.http.Response; 9 | import com.joinhocus.horus.http.model.EmptyRequest; 10 | import com.joinhocus.horus.misc.HandlesUtil; 11 | import io.javalin.core.validation.BodyValidator; 12 | import io.javalin.http.Context; 13 | import org.slf4j.Logger; 14 | 15 | import java.util.concurrent.CompletableFuture; 16 | 17 | public class SearchByHandleHandler implements DefinedTypesWithUserHandler { 18 | @Override 19 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 20 | String query = context.queryParam("query"); 21 | if (Strings.isNullOrEmpty(query)) { 22 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("query cannot be empty")); 23 | } 24 | 25 | return HandlesUtil.getHandlesByQuery(query).thenApply(list -> { 26 | JsonArray array = new JsonArray(); 27 | for (JsonObject object : list) { 28 | array.add(object); 29 | } 30 | 31 | return Response.of(Response.Type.OKAY).append("results", array); 32 | }); 33 | } 34 | 35 | @Override 36 | public Class requestClass() { 37 | return EmptyRequest.class; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/db/repos/LikesRepo.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.db.repos; 2 | 3 | import com.joinhocus.horus.account.UserAccount; 4 | import com.joinhocus.horus.db.AsyncMongoRepo; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.mongodb.client.model.Filters; 7 | import org.bson.Document; 8 | import org.bson.types.ObjectId; 9 | 10 | import java.util.concurrent.CompletableFuture; 11 | 12 | public class LikesRepo extends AsyncMongoRepo { 13 | public LikesRepo() { 14 | super("hocus", "likes"); 15 | } 16 | 17 | public CompletableFuture hasLiked(UserAccount liker, ObjectId postId) { 18 | return this.checkExists(Filters.and( 19 | Filters.eq("liker", liker.getId()), 20 | Filters.eq("post", postId) 21 | )); 22 | } 23 | 24 | public CompletableFuture createLike(UserAccount liker, ObjectId postId) { 25 | return this.insertOne( 26 | new Document() 27 | .append("liker", liker.getId()) 28 | .append("post", postId) 29 | ).thenApply(ignored -> null).thenCompose(aVoid -> { 30 | return MongoDatabase.getInstance().getRepo(PostsRepo.class).incLikes(postId, 1); 31 | }); 32 | } 33 | 34 | public CompletableFuture deleteLike(UserAccount liker, ObjectId postId) { 35 | return this.deleteOne(Filters.and( 36 | Filters.eq("liker", liker.getId()), 37 | Filters.eq("post", postId) 38 | )).thenCompose(ignored -> { 39 | return MongoDatabase.getInstance().getRepo(PostsRepo.class).incLikes(postId, -1); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/settings/UpdateAccountAvatarHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.settings; 2 | 3 | import com.joinhocus.horus.account.UserAccount; 4 | import com.joinhocus.horus.db.MongoDatabase; 5 | import com.joinhocus.horus.db.repos.AccountsRepo; 6 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 7 | import com.joinhocus.horus.http.Response; 8 | import com.joinhocus.horus.http.model.EmptyRequest; 9 | import io.javalin.core.validation.BodyValidator; 10 | import io.javalin.http.Context; 11 | import io.javalin.http.UploadedFile; 12 | import org.slf4j.Logger; 13 | 14 | import java.util.concurrent.CompletableFuture; 15 | 16 | public class UpdateAccountAvatarHandler implements DefinedTypesWithUserHandler { 17 | @Override 18 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 19 | UploadedFile file = context.uploadedFile("file"); 20 | if (file == null) { 21 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("file cannot be empty")); 22 | } 23 | 24 | return MongoDatabase.getInstance().getRepo(AccountsRepo.class).setAvatar( 25 | account.getId(), 26 | file 27 | ).thenApply(success -> { 28 | if (!success) { 29 | return Response.of(Response.Type.BAD_REQUEST).setMessage("failed to update avatar"); 30 | } 31 | 32 | return Response.of(Response.Type.OKAY); 33 | }); 34 | } 35 | 36 | @Override 37 | public Class requestClass() { 38 | return EmptyRequest.class; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/get/GetAccountHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.get; 2 | 3 | import com.google.gson.JsonObject; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 6 | import com.joinhocus.horus.http.Response; 7 | import com.joinhocus.horus.http.model.EmptyRequest; 8 | import io.javalin.core.validation.BodyValidator; 9 | import io.javalin.http.Context; 10 | import org.bson.Document; 11 | import org.slf4j.Logger; 12 | 13 | import java.util.concurrent.CompletableFuture; 14 | 15 | public class GetAccountHandler implements DefinedTypesWithUserHandler { 16 | @Override 17 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 18 | JsonObject object = new JsonObject(); 19 | 20 | object.addProperty("id", account.getId().toHexString()); 21 | object.addProperty("handle", account.getUsernames().getDisplay()); 22 | object.addProperty("name", account.getName()); 23 | object.addProperty("finishedSetup", account.isFinishedAccountSetup()); 24 | object.addProperty("email", account.getEmail()); 25 | 26 | Document extra = account.getExtra(); 27 | JsonObject extraInfo = new JsonObject(); 28 | if (extra != null) { 29 | int invites = extra.getInteger("invites", 0); 30 | extraInfo.addProperty("invites", invites); 31 | } 32 | 33 | object.add("extra", extraInfo); 34 | return wrap(Response.of(Response.Type.OKAY).append("account", object)); 35 | } 36 | 37 | @Override 38 | public Class requestClass() { 39 | return EmptyRequest.class; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/entity/GetFollowsEntityHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.entity; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.joinhocus.horus.db.repos.FollowsRepo; 7 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 8 | import com.joinhocus.horus.http.Response; 9 | import com.joinhocus.horus.http.model.EmptyRequest; 10 | import com.joinhocus.horus.misc.MongoIds; 11 | import io.javalin.core.validation.BodyValidator; 12 | import io.javalin.http.Context; 13 | import org.bson.types.ObjectId; 14 | import org.slf4j.Logger; 15 | 16 | import java.util.concurrent.CompletableFuture; 17 | 18 | public class GetFollowsEntityHandler implements DefinedTypesWithUserHandler { 19 | @Override 20 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 21 | String id = context.queryParam("id"); 22 | if (Strings.isNullOrEmpty(id)) { 23 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("id cannot be empty")); 24 | } 25 | ObjectId mongoId = MongoIds.parseId(id); 26 | if (mongoId == null) { 27 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("malformed id")); 28 | } 29 | 30 | return MongoDatabase.getInstance().getRepo(FollowsRepo.class).follows(account, mongoId).thenApply(follows -> { 31 | return Response.of(Response.Type.OKAY).append("follows", follows); 32 | }); 33 | } 34 | 35 | @Override 36 | public Class requestClass() { 37 | return EmptyRequest.class; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/account/invite/Invite.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.account.invite; 2 | 3 | import com.google.common.base.Strings; 4 | import lombok.Getter; 5 | import org.bson.Document; 6 | import org.bson.types.ObjectId; 7 | 8 | @Getter 9 | public class Invite { 10 | 11 | // either of these two MUST be present 12 | private String inviteId; 13 | 14 | private final String twitterId, email; 15 | private final ObjectId inviter; // if this is null = horris invited. 16 | private final String orgId; 17 | private final String code; 18 | 19 | private boolean wasClaimed; 20 | 21 | public Invite(Document document) { 22 | this.inviteId = document.getObjectId("_id").toHexString(); 23 | this.twitterId = document.getString("twitterId"); 24 | this.email = document.getString("email"); 25 | this.inviter = document.getObjectId("inviter"); 26 | this.orgId = document.getString("orgId"); 27 | this.code = document.getString("code"); 28 | this.wasClaimed = document.getBoolean("claimed", false); 29 | } 30 | 31 | public Invite(String twitterId, String email, ObjectId inviter, String orgId, String code) { 32 | if (Strings.isNullOrEmpty(twitterId) && Strings.isNullOrEmpty(email)) { 33 | throw new IllegalStateException("twitterId or email must be provided"); 34 | } 35 | this.twitterId = twitterId; 36 | this.email = email; 37 | this.inviter = inviter; 38 | this.orgId = orgId; 39 | this.code = code; 40 | } 41 | 42 | public Document toDocument() { 43 | return new Document() 44 | .append("twitterId", this.twitterId) 45 | .append("email", this.email) 46 | .append("inviter", this.inviter) 47 | .append("orgId", this.orgId) 48 | .append("code", this.code) 49 | .append("generatedAt", System.currentTimeMillis()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/db/repos/PasswordResetRepo.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.db.repos; 2 | 3 | import com.joinhocus.horus.account.UserAccount; 4 | import com.joinhocus.horus.db.AsyncMongoRepo; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.mongodb.client.model.Filters; 7 | import com.mongodb.client.model.Updates; 8 | import org.bson.Document; 9 | import org.bson.types.ObjectId; 10 | 11 | import java.util.UUID; 12 | import java.util.concurrent.CompletableFuture; 13 | import java.util.function.Function; 14 | 15 | public class PasswordResetRepo extends AsyncMongoRepo { 16 | public PasswordResetRepo() { 17 | super("hocus", "password_resets"); 18 | } 19 | 20 | public CompletableFuture createResetCode(UserAccount account, UUID code, String ip) { 21 | return this.insertOne(new Document() 22 | .append("for", account.getId()) 23 | .append("code", code.toString()) 24 | .append("createdAt", System.currentTimeMillis()) 25 | .append("requestingIp", ip) 26 | ); 27 | } 28 | 29 | public CompletableFuture consumeCode(String code) { 30 | return this.updateOne(Filters.eq("code", code), Updates.set("consumed", true)).thenApply(success -> null); 31 | } 32 | 33 | public CompletableFuture getUserAccountFor(String token) { 34 | return this.findFirst(Filters.and( 35 | Filters.eq("code", token), 36 | Filters.exists("consumed", false) 37 | ), Function.identity()) 38 | .thenCompose(opt -> { 39 | if (opt == null) { 40 | return CompletableFuture.completedFuture(null); 41 | } 42 | 43 | ObjectId forUser = opt.getObjectId("for"); 44 | return MongoDatabase.getInstance().getRepo(AccountsRepo.class) 45 | .findById(forUser); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/code/ValidateInviteCodeHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.code; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.db.MongoDatabase; 5 | import com.joinhocus.horus.db.repos.InvitesRepo; 6 | import com.joinhocus.horus.http.DefinedTypesHandler; 7 | import com.joinhocus.horus.http.Response; 8 | import com.joinhocus.horus.http.routes.account.code.model.InviteCodeRequest; 9 | import io.javalin.core.validation.BodyValidator; 10 | import io.javalin.http.Context; 11 | import org.slf4j.Logger; 12 | 13 | import java.util.concurrent.CompletableFuture; 14 | 15 | public class ValidateInviteCodeHandler implements DefinedTypesHandler { 16 | @Override 17 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 18 | InviteCodeRequest request = validator 19 | .check("code", req -> !Strings.isNullOrEmpty(req.getCode()), "code cannot be empty") 20 | .get(); 21 | return runPipeline(request.getCode()); 22 | } 23 | 24 | private CompletableFuture runPipeline(String code) { 25 | return MongoDatabase.getInstance().getRepo(InvitesRepo.class) 26 | .getInviteByCode(code) 27 | .thenCompose(invite -> { 28 | if (invite == null) { 29 | return wrap(Response.of(Response.Type.OKAY).append("valid", false)); 30 | } 31 | if (invite.isWasClaimed()) { 32 | return wrap(Response.of(Response.Type.OKAY).append("valid", false)); 33 | } 34 | 35 | return wrap(Response.of(Response.Type.OKAY).append("valid", true)); 36 | }); 37 | } 38 | 39 | @Override 40 | public Class requestClass() { 41 | return InviteCodeRequest.class; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/slack/SlackEventsHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.slack; 2 | 3 | import com.google.common.base.Joiner; 4 | import com.slack.api.bolt.request.Request; 5 | import io.javalin.http.Context; 6 | import io.javalin.http.Handler; 7 | import io.javalin.http.InternalServerErrorResponse; 8 | import org.jetbrains.annotations.NotNull; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | import java.util.List; 13 | import java.util.Map; 14 | 15 | public class SlackEventsHandler implements Handler { 16 | 17 | @Override 18 | public void handle(@NotNull Context context) { 19 | Logger logger = LoggerFactory.getLogger(getClass()); 20 | Request slackReq = SlackClient.getInstance().transform(context); 21 | if (slackReq != null) { 22 | com.slack.api.bolt.context.Context slackContext = slackReq.getContext(); 23 | slackContext.setBotToken(SlackClient.XOXB_TOKEN); 24 | try { 25 | com.slack.api.bolt.response.Response slackResponse = SlackClient.getInstance().getApp().run(slackReq); 26 | if (slackResponse != null) { 27 | context.status(slackResponse.getStatusCode()); 28 | for (Map.Entry> header : slackResponse.getHeaders().entrySet()) { 29 | String name = header.getKey(); 30 | context.header(name, Joiner.on(",").join(header.getValue())); 31 | } 32 | if (slackResponse.getBody() != null) { 33 | context.result(slackResponse.getBody()); 34 | } else { 35 | context.status(404); // not found 36 | // if it's null we let it time out, slack handles that for us 37 | } 38 | } 39 | } catch (Exception e) { 40 | logger.error("", e); 41 | context.json(new InternalServerErrorResponse()); 42 | } 43 | } 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/DefinedTypesWithUserHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http; 2 | 3 | import com.joinhocus.horus.account.AccountAuth; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.joinhocus.horus.db.repos.AccountsRepo; 7 | import com.joinhocus.horus.misc.CompletableFutures; 8 | import io.javalin.core.validation.BodyValidator; 9 | import io.javalin.http.BadRequestResponse; 10 | import io.javalin.http.Context; 11 | import org.slf4j.Logger; 12 | 13 | import java.util.concurrent.CompletableFuture; 14 | 15 | public interface DefinedTypesWithUserHandler extends DefinedTypesHandler { 16 | 17 | @Override 18 | default CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 19 | String token = context.header("Authorization"); 20 | if (token == null) { 21 | throw new BadRequestResponse("Not logged in"); 22 | } 23 | if (!token.startsWith("Bearer ")) { 24 | throw new BadRequestResponse("Malformed Authorization header"); 25 | } 26 | token = token.replaceFirst("Bearer ", ""); 27 | String account = AccountAuth.getAccountFromJwt(token); 28 | return MongoDatabase.getInstance().getRepo(AccountsRepo.class) 29 | .findByLogin(account) 30 | .thenCompose(userAccount -> { 31 | if (userAccount == null) { 32 | return wrap(Response.of(Response.Type.UNAUTHORIZED)); 33 | } 34 | 35 | try { 36 | return handle(userAccount, validator, context, logger); 37 | } catch (Exception e) { 38 | return CompletableFutures.failedFuture(e); 39 | } 40 | }); 41 | } 42 | 43 | CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception; 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/activity/GetActivityCountHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.activity; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.joinhocus.horus.db.repos.ActivityRepo; 7 | import com.joinhocus.horus.db.repos.OrganizationRepo; 8 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 9 | import com.joinhocus.horus.http.Response; 10 | import com.joinhocus.horus.http.model.EmptyRequest; 11 | import com.joinhocus.horus.organization.Organization; 12 | import io.javalin.core.validation.BodyValidator; 13 | import io.javalin.http.Context; 14 | import org.bson.types.ObjectId; 15 | import org.slf4j.Logger; 16 | 17 | import java.util.concurrent.CompletableFuture; 18 | import java.util.stream.Collectors; 19 | 20 | public class GetActivityCountHandler implements DefinedTypesWithUserHandler { 21 | @Override 22 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 23 | return MongoDatabase.getInstance().getRepo(OrganizationRepo.class).getOrganizations(account.getId()).thenApply(orgs -> { 24 | return ImmutableList.builder() 25 | .add(account.getId()) 26 | .addAll(orgs.stream().map(Organization::getId).collect(Collectors.toList())) 27 | .build(); 28 | }).thenCompose(ids -> { 29 | return MongoDatabase.getInstance().getRepo(ActivityRepo.class).countActivity( 30 | account.getId(), 31 | ids 32 | ); 33 | }).thenApply(notifications -> { 34 | return Response.of(Response.Type.OKAY).append("amount", notifications); 35 | }); 36 | } 37 | 38 | @Override 39 | public Class requestClass() { 40 | return EmptyRequest.class; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/changepw/ChangePasswordHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.changepw; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.joinhocus.horus.db.repos.AccountsRepo; 7 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 8 | import com.joinhocus.horus.http.Response; 9 | import com.joinhocus.horus.http.routes.account.changepw.model.ChangePasswordRequest; 10 | import io.javalin.core.validation.BodyValidator; 11 | import io.javalin.http.Context; 12 | import org.slf4j.Logger; 13 | 14 | import java.util.concurrent.CompletableFuture; 15 | 16 | public class ChangePasswordHandler implements DefinedTypesWithUserHandler { 17 | @Override 18 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 19 | ChangePasswordRequest request = validator 20 | .check("current", req -> !Strings.isNullOrEmpty(req.getCurrent()), "current cannot be empty") 21 | .check("changeTo", req -> !Strings.isNullOrEmpty(req.getChangeTo()), "changeTo cannot be empty") 22 | .get(); 23 | 24 | boolean isValid = account.passwordsMatch(request.getCurrent()); 25 | if (!isValid) { 26 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("Incorrect current password provided.")); 27 | } 28 | 29 | return MongoDatabase.getInstance().getRepo(AccountsRepo.class).changePassword(account.getId(), request.getChangeTo()).thenApply(success -> { 30 | if (success) { 31 | return Response.of(Response.Type.OKAY); 32 | } 33 | 34 | return Response.of(Response.Type.BAD_REQUEST).setMessage("Failed to update password"); 35 | }); 36 | } 37 | 38 | @Override 39 | public Class requestClass() { 40 | return ChangePasswordRequest.class; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/setup/SetupAccountHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.setup; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.AccountType; 5 | import com.joinhocus.horus.account.UserAccount; 6 | import com.joinhocus.horus.db.MongoDatabase; 7 | import com.joinhocus.horus.db.repos.AccountsRepo; 8 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 9 | import com.joinhocus.horus.http.Response; 10 | import com.joinhocus.horus.http.routes.account.setup.model.SetupAccountRequest; 11 | import com.joinhocus.horus.misc.CompletableFutures; 12 | import io.javalin.core.validation.BodyValidator; 13 | import io.javalin.http.Context; 14 | import org.slf4j.Logger; 15 | 16 | import java.util.concurrent.CompletableFuture; 17 | 18 | public class SetupAccountHandler implements DefinedTypesWithUserHandler { 19 | @Override 20 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 21 | SetupAccountRequest request = validator 22 | .check("selected", req -> !Strings.isNullOrEmpty(req.getSelected()), "selected cannot be empty") 23 | .get(); 24 | if (account.isFinishedAccountSetup()) { 25 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("This account has already been setup")); 26 | } 27 | AccountType type = AccountType.valueOf(request.getSelected().toUpperCase()); 28 | return MongoDatabase.getInstance().getRepo(AccountsRepo.class).setAccountType( 29 | account.getId(), 30 | type 31 | ).thenCompose(success -> { 32 | if (!success) { 33 | return CompletableFutures.failedFuture(new IllegalStateException("database failed to acknowledge update")); 34 | } 35 | 36 | return wrap(Response.of(Response.Type.OKAY)); 37 | }); 38 | } 39 | 40 | @Override 41 | public Class requestClass() { 42 | return SetupAccountRequest.class; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/account/AccountAuth.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.account; 2 | 3 | import com.auth0.jwt.JWT; 4 | import com.auth0.jwt.JWTVerifier; 5 | import com.auth0.jwt.algorithms.Algorithm; 6 | import com.auth0.jwt.interfaces.Claim; 7 | import com.auth0.jwt.interfaces.DecodedJWT; 8 | import de.mkammerer.argon2.Argon2; 9 | import de.mkammerer.argon2.Argon2Factory; 10 | 11 | import java.util.Date; 12 | import java.util.regex.Pattern; 13 | 14 | public final class AccountAuth { 15 | 16 | private static final String SECRET = ""; 17 | private static final Algorithm HMAC = Algorithm.HMAC256(SECRET); 18 | private static final String ISSUER = "horus"; 19 | 20 | public static final Argon2 ARGON_2 = Argon2Factory.create( 21 | Argon2Factory.Argon2Types.ARGON2id, 22 | 32, 23 | 64 24 | ); 25 | 26 | private static final Pattern EMAIL_PATTERN = Pattern.compile("(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])"); 27 | 28 | public static String getToken(UserAccount account) { 29 | return JWT.create() 30 | .withIssuer(ISSUER) 31 | .withClaim("account", account.getUsernames().getName()) 32 | .withIssuedAt(new Date()) 33 | .sign(HMAC); 34 | } 35 | 36 | public static String getAccountFromJwt(String token) { 37 | JWTVerifier verifier = JWT.require(HMAC) 38 | .withIssuer(ISSUER) 39 | .build(); 40 | 41 | DecodedJWT jwt = verifier.verify(token); 42 | Claim account = jwt.getClaim("account"); 43 | return account.asString(); 44 | } 45 | 46 | public static boolean isValidEmail(String email) { 47 | return EMAIL_PATTERN.matcher(email).matches(); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/create/ValidateInviteTwitterComboHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.create; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.db.MongoDatabase; 5 | import com.joinhocus.horus.db.repos.WaitListRepo; 6 | import com.joinhocus.horus.http.DefinedTypesHandler; 7 | import com.joinhocus.horus.http.Response; 8 | import com.joinhocus.horus.http.routes.account.create.model.VerifyComboRequest; 9 | import com.joinhocus.horus.twitter.TwitterAPI; 10 | import io.javalin.core.validation.BodyValidator; 11 | import io.javalin.http.Context; 12 | import org.slf4j.Logger; 13 | 14 | import java.util.concurrent.CompletableFuture; 15 | 16 | public class ValidateInviteTwitterComboHandler implements DefinedTypesHandler { 17 | @Override 18 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 19 | VerifyComboRequest request = validator 20 | .check("inviteCode", req -> !Strings.isNullOrEmpty(req.getInviteCode()), "inviteCode cannot be empty") 21 | .check("twitterId", req -> !Strings.isNullOrEmpty(req.getTwitterId()), "twitterId cannot be empty") 22 | .get(); 23 | 24 | return MongoDatabase.getInstance().getRepo(WaitListRepo.class) 25 | .checkCombination(request.getInviteCode(), request.getTwitterId()) 26 | .thenCompose(valid -> { 27 | if (!valid) { 28 | return wrap(Response.of(Response.Type.OKAY).append("valid", false)); 29 | } 30 | 31 | return doComplexPipeline(request.getTwitterId()); 32 | }); 33 | } 34 | 35 | private CompletableFuture doComplexPipeline(String twitterId) { 36 | return TwitterAPI.fetchUserInfo(twitterId).thenApply(user -> { 37 | return Response.of(Response.Type.OKAY).append("valid", true).append("handle", user.getHandle()); 38 | }); 39 | } 40 | 41 | @Override 42 | public Class requestClass() { 43 | return VerifyComboRequest.class; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/db/repos/InvitesRepo.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.db.repos; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import com.joinhocus.horus.account.invite.Invite; 5 | import com.joinhocus.horus.db.AsyncMongoRepo; 6 | import com.mongodb.client.model.Filters; 7 | import com.mongodb.client.model.Updates; 8 | import org.bson.Document; 9 | import org.bson.types.ObjectId; 10 | 11 | import java.util.concurrent.CompletableFuture; 12 | import java.util.function.Function; 13 | 14 | public class InvitesRepo extends AsyncMongoRepo { 15 | public InvitesRepo() { 16 | super("hocus", "invites"); 17 | } 18 | 19 | public CompletableFuture insertInvite(Invite invite) { 20 | return this.insertOne(invite.toDocument()); 21 | } 22 | 23 | public CompletableFuture getInviteById(ObjectId id) { 24 | return this.findFirst( 25 | Filters.eq("_id", id), 26 | Function.identity() 27 | ); 28 | } 29 | 30 | public CompletableFuture getInviteByCode(String code) { 31 | return this.findFirst( 32 | Filters.eq("code", code), 33 | Invite::new 34 | ); 35 | } 36 | 37 | public CompletableFuture checkExists(String code) { 38 | return this.checkExists( 39 | Filters.and( 40 | Filters.eq("code", code), 41 | Filters.exists("claimed", false) 42 | ) 43 | ); 44 | } 45 | 46 | public CompletableFuture checkTwitterExists(String code) { 47 | return this.checkExists(Filters.eq("twitterId", code)); 48 | } 49 | 50 | public CompletableFuture checkEmailExists(String code) { 51 | return this.checkExists(Filters.eq("email", code)); 52 | } 53 | 54 | public CompletableFuture claim(String code) { 55 | return this.updateOne( 56 | Filters.eq("code", code), 57 | Updates.set("claimed", true) 58 | ).thenApply(aBoolean -> null); 59 | } 60 | 61 | public CompletableFuture> getInvitesFrom(ObjectId inviter) { 62 | return this.find(Filters.eq("inviter", inviter), null, Invite::new); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/organization/getbyuser/GetUserOrganizationsHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.organization.getbyuser; 2 | 3 | import com.google.gson.JsonArray; 4 | import com.google.gson.JsonObject; 5 | import com.joinhocus.horus.account.UserAccount; 6 | import com.joinhocus.horus.db.MongoDatabase; 7 | import com.joinhocus.horus.db.repos.OrganizationRepo; 8 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 9 | import com.joinhocus.horus.http.Response; 10 | import com.joinhocus.horus.http.model.EmptyRequest; 11 | import com.joinhocus.horus.organization.Organization; 12 | import io.javalin.core.validation.BodyValidator; 13 | import io.javalin.http.Context; 14 | import org.slf4j.Logger; 15 | 16 | import java.util.concurrent.CompletableFuture; 17 | 18 | public class GetUserOrganizationsHandler implements DefinedTypesWithUserHandler { 19 | @Override 20 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 21 | return MongoDatabase.getInstance().getRepo(OrganizationRepo.class) 22 | .getOrganizations(account.getId()) 23 | .thenApply(organizations -> { 24 | JsonArray orgs = new JsonArray(); 25 | for (Organization organization : organizations) { 26 | JsonObject object = new JsonObject(); 27 | object.addProperty("id", organization.getId().toHexString()); 28 | object.addProperty("name", organization.getName()); 29 | object.addProperty("handle", organization.getHandle()); 30 | 31 | JsonObject settings = new JsonObject(); 32 | settings.addProperty("logo", organization.getSettings().getLogo()); 33 | object.add("settings", settings); 34 | 35 | orgs.add(object); 36 | } 37 | 38 | return Response.of(Response.Type.OKAY).append("organizations", orgs); 39 | }); 40 | } 41 | 42 | @Override 43 | public Class requestClass() { 44 | return EmptyRequest.class; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/slack/waitlist/SlackWaitListExtension.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.slack.waitlist; 2 | 3 | import com.joinhocus.horus.slack.SlackClientExtension; 4 | import com.slack.api.bolt.App; 5 | import com.slack.api.bolt.response.Response; 6 | import com.slack.api.methods.response.views.ViewsOpenResponse; 7 | import com.slack.api.model.block.Blocks; 8 | import com.slack.api.model.block.composition.BlockCompositions; 9 | import com.slack.api.model.block.element.BlockElements; 10 | import com.slack.api.model.view.Views; 11 | 12 | public class SlackWaitListExtension implements SlackClientExtension { 13 | @Override 14 | public void register(App app) { 15 | app.blockAction("give_access", (request, context) -> { 16 | context.respond("Not implemented yet!"); 17 | return context.ack(); 18 | }); 19 | 20 | app.command("/waitlist", (request, context) -> { 21 | ViewsOpenResponse viewsOpen = context.client().viewsOpen(r -> r.triggerId(context.getTriggerId()).view( 22 | Views.view(view -> view 23 | .callbackId("invite_direct") 24 | .type("modal") 25 | .notifyOnClose(true) 26 | .title(Views.viewTitle(title -> title.type("plain_text").text("Invite a Twitter user to Hocus"))) 27 | .submit(Views.viewSubmit(submit -> submit.type("plain_text").text("Invite!"))) 28 | .close(Views.viewClose(close -> close.type("plain_text").text("Cancel"))) 29 | .blocks(Blocks.asBlocks( 30 | Blocks.input(input -> { 31 | input.blockId("handle-block") 32 | .element(BlockElements.plainTextInput(pti -> pti.actionId("handle-action"))) 33 | .label(BlockCompositions.plainText("Twitter Handle")); 34 | return input; 35 | }) 36 | ))) 37 | )); 38 | if (viewsOpen.isOk()) return context.ack(); 39 | return Response.builder().statusCode(500).body(viewsOpen.getError()).build(); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/post/PostUtil.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.post; 2 | 3 | import com.google.gson.JsonArray; 4 | import com.google.gson.JsonObject; 5 | import com.joinhocus.horus.account.UserAccount; 6 | import com.joinhocus.horus.organization.Organization; 7 | import com.joinhocus.horus.post.impl.PostWithExtra; 8 | import lombok.experimental.UtilityClass; 9 | 10 | @UtilityClass 11 | public class PostUtil { 12 | 13 | public JsonObject recursive(Post post) { 14 | JsonObject parent = new JsonObject(); 15 | JsonArray replies = new JsonArray(); 16 | 17 | parent.addProperty("id", post.getId().toHexString()); 18 | UserAccount authorAccount = post.author(); 19 | JsonObject author = new JsonObject(); 20 | if (authorAccount != null) { 21 | author.addProperty("id", authorAccount.getId().toHexString()); 22 | author.addProperty("name", authorAccount.getName()); 23 | author.addProperty("handle", authorAccount.getUsernames().getDisplay()); 24 | } 25 | 26 | JsonObject organization = new JsonObject(); 27 | Organization organizationObject = post.organization(); 28 | if (organizationObject != null) { 29 | organization.addProperty("id", organizationObject.getId().toHexString()); 30 | organization.addProperty("name", organizationObject.getName()); 31 | organization.addProperty("logo", organizationObject.getSettings().getLogo()); 32 | } 33 | 34 | parent.addProperty("content", post.content()); 35 | if (post.type() != null) { 36 | parent.addProperty("type", post.type().name()); 37 | } 38 | parent.addProperty("postTime", post.postTime().getTime()); 39 | 40 | if (post instanceof PostWithExtra) { 41 | PostWithExtra extra = (PostWithExtra) post; 42 | JsonObject extraData = extra.getExtra(); 43 | parent.add("extra", extraData); 44 | } 45 | 46 | for (Post comment : post.getComments()) { 47 | replies.add(recursive(comment)); 48 | } 49 | 50 | parent.add("organization", organization); 51 | parent.add("author", author); 52 | if (replies.size() > 0) { 53 | parent.add("replies", replies); 54 | } 55 | return parent; 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/HandlesUtil.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import com.google.gson.JsonObject; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.joinhocus.horus.db.repos.AccountsRepo; 7 | import com.joinhocus.horus.db.repos.OrganizationRepo; 8 | import lombok.experimental.UtilityClass; 9 | import org.bson.types.ObjectId; 10 | 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | import java.util.concurrent.CompletableFuture; 14 | import java.util.regex.Pattern; 15 | 16 | @UtilityClass 17 | public class HandlesUtil { 18 | 19 | public final Pattern TAG_PATTERN = Pattern.compile("@\\[(.*?)]"); 20 | 21 | public CompletableFuture isHandleAvailable(String handle) { 22 | return CompletableFutures.combine( 23 | MongoDatabase.getInstance().getRepo(AccountsRepo.class).isUsernameAvailable(handle), 24 | MongoDatabase.getInstance().getRepo(OrganizationRepo.class).isHandleAvailable(handle), 25 | (resA, resB) -> !resA && !resB 26 | ).toCompletableFuture(); 27 | } 28 | 29 | public CompletableFuture> getHandlesByQuery(String query) { 30 | return CompletableFutures.combine( 31 | MongoDatabase.getInstance().getRepo(AccountsRepo.class).searchByHandle(query), 32 | MongoDatabase.getInstance().getRepo(OrganizationRepo.class).searchByHandle(query), 33 | (resA, resB) -> ImmutableList.builder() 34 | .addAll(resA) 35 | .addAll(resB) 36 | .build() 37 | ).toCompletableFuture(); 38 | } 39 | 40 | public CompletableFuture> getEntitiesBy(List handles) { 41 | return CompletableFutures.combine( 42 | MongoDatabase.getInstance().getRepo(AccountsRepo.class).getAllByHandles(handles), 43 | MongoDatabase.getInstance().getRepo(OrganizationRepo.class).getAllByHandles(handles), 44 | (resA, resB) -> { 45 | List ids = new ArrayList<>(resA.size() + resB.size()); 46 | ids.addAll(resA); 47 | ids.addAll(resB); 48 | return ids; 49 | } 50 | ).toCompletableFuture(); 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/misc/Spaces.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.misc; 2 | 3 | import com.amazonaws.auth.AWSCredentials; 4 | import com.amazonaws.auth.AWSCredentialsProvider; 5 | import com.amazonaws.auth.AWSStaticCredentialsProvider; 6 | import com.amazonaws.auth.BasicAWSCredentials; 7 | import com.amazonaws.client.builder.AwsClientBuilder; 8 | import com.amazonaws.services.s3.AmazonS3; 9 | import com.amazonaws.services.s3.AmazonS3ClientBuilder; 10 | import com.amazonaws.services.s3.model.CannedAccessControlList; 11 | import com.amazonaws.services.s3.model.ObjectMetadata; 12 | import com.amazonaws.services.s3.model.PutObjectRequest; 13 | import io.javalin.http.UploadedFile; 14 | import lombok.experimental.UtilityClass; 15 | import org.slf4j.Logger; 16 | import org.slf4j.LoggerFactory; 17 | 18 | import java.io.ByteArrayInputStream; 19 | 20 | @UtilityClass 21 | public class Spaces { 22 | 23 | private final Logger LOGGER = LoggerFactory.getLogger(Spaces.class); 24 | private final AWSCredentials CREDS = new BasicAWSCredentials("", ""); 25 | private final AWSCredentialsProvider CRED_PROVIDER = new AWSStaticCredentialsProvider(CREDS); 26 | 27 | private final String EDGE_BASE = ""; 28 | 29 | private final AmazonS3 SPACE = AmazonS3ClientBuilder 30 | .standard() 31 | .withCredentials(CRED_PROVIDER) 32 | .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration( 33 | "sfo2.digitaloceanspaces.com", 34 | "sfo2" 35 | )) 36 | .build(); 37 | 38 | public String uploadImage(UploadedFile file, String location, String name) throws Exception { 39 | byte[] bytes = IOUtils.readBytes(file.getContent()); 40 | long length = bytes.length; 41 | ObjectMetadata meta = new ObjectMetadata(); 42 | meta.setContentLength(length); 43 | meta.setContentType(file.getContentType()); 44 | String path = "images/" + location + "/" + name + file.getExtension(); 45 | 46 | // we do a new array here because it's already read from the stream 47 | PutObjectRequest request = new PutObjectRequest("hocus-media", path, new ByteArrayInputStream(bytes), meta) 48 | .withCannedAcl(CannedAccessControlList.PublicRead); 49 | SPACE.putObject(request); 50 | return EDGE_BASE + path; 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/login/AccountLoginHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.login; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.AccountAuth; 5 | import com.joinhocus.horus.account.UserNames; 6 | import com.joinhocus.horus.db.MongoDatabase; 7 | import com.joinhocus.horus.db.repos.AccountsRepo; 8 | import com.joinhocus.horus.http.DefinedTypesHandler; 9 | import com.joinhocus.horus.http.Response; 10 | import com.joinhocus.horus.http.routes.account.login.model.AccountLoginRequest; 11 | import io.javalin.core.validation.BodyValidator; 12 | import io.javalin.http.Context; 13 | import org.slf4j.Logger; 14 | 15 | import java.util.concurrent.CompletableFuture; 16 | 17 | public class AccountLoginHandler implements DefinedTypesHandler { 18 | 19 | @Override 20 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 21 | AccountLoginRequest request = validator 22 | .check("login", req -> !Strings.isNullOrEmpty(req.getLogin()), "login cannot be empty") 23 | .check("login", req -> { 24 | if (UserNames.NAME_PATTERN.matcher(req.getLogin()).find()) { 25 | return true; 26 | } 27 | 28 | return AccountAuth.isValidEmail(req.getLogin()); 29 | }, "login field must be an email or username") 30 | .check("password", req -> !Strings.isNullOrEmpty(req.getPassword()), "password cannot be empty") 31 | .get(); 32 | AccountsRepo repo = MongoDatabase.getInstance().getRepo(AccountsRepo.class); 33 | return repo.findBasicByLogin( 34 | request.getLogin() 35 | ).thenApply(account -> { 36 | if (account == null) { 37 | return Response.of(Response.Type.BAD_REQUEST).setMessage("Invalid login or password provided"); 38 | } 39 | 40 | if (!account.passwordsMatch(request.getPassword())) { 41 | return Response.of(Response.Type.FORBIDDEN).setMessage("Invalid login or password provided"); 42 | } 43 | 44 | return Response.of(Response.Type.OKAY).append("token", AccountAuth.getToken(account)); 45 | }); 46 | } 47 | 48 | @Override 49 | public Class requestClass() { 50 | return AccountLoginRequest.class; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/organization/settings/logo/UpdateOrganizationLogoHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.organization.settings.logo; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.joinhocus.horus.db.repos.OrganizationRepo; 7 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 8 | import com.joinhocus.horus.http.Response; 9 | import com.joinhocus.horus.http.model.EmptyRequest; 10 | import com.joinhocus.horus.misc.MongoIds; 11 | import io.javalin.core.validation.BodyValidator; 12 | import io.javalin.http.Context; 13 | import io.javalin.http.UploadedFile; 14 | import org.bson.types.ObjectId; 15 | import org.slf4j.Logger; 16 | 17 | import java.util.concurrent.CompletableFuture; 18 | 19 | public class UpdateOrganizationLogoHandler implements DefinedTypesWithUserHandler { 20 | @Override 21 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 22 | String id = context.queryParam("orgId"); 23 | if (Strings.isNullOrEmpty(id)) { 24 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("orgId cannot be an empty query param")); 25 | } 26 | ObjectId orgId = MongoIds.parseId(id); 27 | if (orgId == null) { 28 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("Invalid organization id provided")); 29 | } 30 | UploadedFile file = context.uploadedFile("file"); 31 | if (file == null) { 32 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("file cannot be empty")); 33 | } 34 | 35 | return MongoDatabase.getInstance().getRepo(OrganizationRepo.class).isMemberOf(orgId, account.getId()).thenCompose(isMember -> { 36 | if (!isMember) { 37 | return wrap(Response.of(Response.Type.UNAUTHORIZED)); 38 | } 39 | 40 | return MongoDatabase.getInstance().getRepo(OrganizationRepo.class).setOrganizationLogo( 41 | orgId, 42 | file 43 | ).thenApply(success -> { 44 | if (!success) { 45 | return Response.of(Response.Type.BAD_REQUEST).setMessage("failed to update organization logo"); 46 | } 47 | 48 | return Response.of(Response.Type.OKAY); 49 | }); 50 | }); 51 | } 52 | 53 | @Override 54 | public Class requestClass() { 55 | return EmptyRequest.class; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/post/impl/BasicPost.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.post.impl; 2 | 3 | import com.joinhocus.horus.account.UserAccount; 4 | import com.joinhocus.horus.organization.Organization; 5 | import com.joinhocus.horus.post.Post; 6 | import com.joinhocus.horus.post.PostPrivacy; 7 | import com.joinhocus.horus.post.PostType; 8 | import lombok.ToString; 9 | import org.bson.types.ObjectId; 10 | 11 | import java.util.Date; 12 | import java.util.List; 13 | 14 | @ToString 15 | public class BasicPost implements Post { 16 | 17 | private final ObjectId id; 18 | private final PostType type; 19 | private final PostPrivacy privacy; 20 | private final String content; 21 | private final UserAccount author; 22 | private final Organization organization; 23 | private final Date postTime; 24 | private final ObjectId parent; 25 | private final List comments; 26 | 27 | public BasicPost( 28 | ObjectId id, 29 | PostType type, 30 | PostPrivacy privacy, 31 | String content, 32 | UserAccount author, 33 | Organization organization, 34 | Date postTime, 35 | ObjectId parent, 36 | List comments 37 | ) { 38 | this.id = id; 39 | this.type = type; 40 | this.privacy = privacy; 41 | this.content = content; 42 | this.comments = comments; 43 | this.author = author; 44 | this.organization = organization; 45 | this.postTime = postTime; 46 | this.parent = parent; 47 | } 48 | 49 | @Override 50 | public ObjectId getId() { 51 | return this.id; 52 | } 53 | 54 | @Override 55 | public PostType type() { 56 | return this.type; 57 | } 58 | 59 | @Override 60 | public PostPrivacy privacy() { 61 | return this.privacy; 62 | } 63 | 64 | @Override 65 | public String content() { 66 | return this.content; 67 | } 68 | 69 | @Override 70 | public UserAccount author() { 71 | return this.author; 72 | } 73 | 74 | @Override 75 | public Organization organization() { 76 | return this.organization; 77 | } 78 | 79 | @Override 80 | public Date postTime() { 81 | return this.postTime; 82 | } 83 | 84 | @Override 85 | public ObjectId parent() { 86 | return this.parent; 87 | } 88 | 89 | @Override 90 | public List getComments() { 91 | return comments; 92 | } 93 | 94 | @Override 95 | public void addComment(Post comment) { 96 | this.comments.add(comment); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/waitlist/validate/EmailValidateWaitlistHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.waitlist.validate; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.db.MongoDatabase; 5 | import com.joinhocus.horus.db.repos.EmailInviteVerificationRepo; 6 | import com.joinhocus.horus.db.repos.InvitesRepo; 7 | import com.joinhocus.horus.http.DefinedTypesHandler; 8 | import com.joinhocus.horus.http.Response; 9 | import com.joinhocus.horus.http.routes.waitlist.validate.model.EmailValidateWaitListRequest; 10 | import com.joinhocus.horus.misc.MongoIds; 11 | import io.javalin.core.validation.BodyValidator; 12 | import io.javalin.http.Context; 13 | import org.bson.types.ObjectId; 14 | import org.slf4j.Logger; 15 | 16 | import java.util.concurrent.CompletableFuture; 17 | 18 | public class EmailValidateWaitlistHandler implements DefinedTypesHandler { 19 | @Override 20 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 21 | EmailValidateWaitListRequest request = validator 22 | .check("code", req -> !Strings.isNullOrEmpty(req.getCode()), "code cannot be empty") 23 | .get(); 24 | return MongoDatabase.getInstance() 25 | .getRepo(EmailInviteVerificationRepo.class) 26 | .getByCode(request.getCode()) 27 | .thenCompose(document -> { 28 | if (document == null) { 29 | return wrap(Response.of(Response.Type.OKAY).append("accepted", false)); 30 | } 31 | 32 | String inviteId = document.getString("inviteId"); 33 | return runInvitePipeline(inviteId); 34 | }); 35 | } 36 | 37 | private CompletableFuture runInvitePipeline(String inviteId) { 38 | ObjectId id = MongoIds.parseId(inviteId); 39 | if (id == null) { 40 | return wrap(Response.of(Response.Type.OKAY).append("accepted", false)); 41 | } 42 | return MongoDatabase.getInstance().getRepo(InvitesRepo.class) 43 | .getInviteById(id) 44 | .thenCompose(document -> { 45 | if (document == null) { 46 | return wrap(Response.of(Response.Type.OKAY).append("accepted", false)); 47 | } 48 | return wrap(Response.of(Response.Type.OKAY).append("accepted", true).append("code", document.getString("code"))); 49 | }); 50 | } 51 | 52 | @Override 53 | public Class requestClass() { 54 | return EmailValidateWaitListRequest.class; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/waitlist/verify/VerifyWaitlistTwitterHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.waitlist.verify; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.db.MongoDatabase; 5 | import com.joinhocus.horus.db.repos.WaitListRepo; 6 | import com.joinhocus.horus.http.DefinedTypesHandler; 7 | import com.joinhocus.horus.http.Response; 8 | import com.joinhocus.horus.http.routes.waitlist.WaitlistUtil; 9 | import com.joinhocus.horus.http.routes.waitlist.verify.model.VerifyTwitterOAuthRequest; 10 | import com.joinhocus.horus.twitter.TwitterAPI; 11 | import com.joinhocus.horus.twitter.oauth.OAuthAccountResponse; 12 | import io.javalin.core.validation.BodyValidator; 13 | import io.javalin.http.Context; 14 | import org.slf4j.Logger; 15 | 16 | import java.util.concurrent.CompletableFuture; 17 | 18 | public class VerifyWaitlistTwitterHandler implements DefinedTypesHandler { 19 | 20 | @Override 21 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 22 | VerifyTwitterOAuthRequest request = validator 23 | .check("token", req -> !Strings.isNullOrEmpty(req.getToken()), "token cannot be empty") 24 | .check("verify", req -> !Strings.isNullOrEmpty(req.getVerify()), "verify cannot be empty") 25 | .get(); 26 | 27 | return TwitterAPI.OAUTH.getOauthToken(request.getToken(), request.getVerify()).thenCompose(this::handleExists); 28 | } 29 | 30 | private CompletableFuture handleExists(OAuthAccountResponse response) { 31 | return MongoDatabase.getInstance().getRepo(WaitListRepo.class).checkIfExists(response.getUserId()).thenCompose(res -> { 32 | if (res != null) { 33 | return wrap(Response.of(Response.Type.OKAY).append("twitter", response.getName()).append("existed", true)); 34 | } 35 | return joinWaitlist(response); 36 | }); 37 | } 38 | 39 | public CompletableFuture joinWaitlist(OAuthAccountResponse response) { 40 | return MongoDatabase.getInstance().getRepo(WaitListRepo.class) 41 | .createWaitlistUser(response.getUserId(), response.getName()) 42 | .thenApply(res -> { 43 | WaitlistUtil.sendToSlack(res.getKey().getUserId(), res.getValue()); // this gets executed in a seperate thread pool 44 | return Response.of(Response.Type.OKAY).append("twitter", res.getKey().getName()).append("joined", true); 45 | }); 46 | } 47 | 48 | @Override 49 | public Class requestClass() { 50 | return VerifyTwitterOAuthRequest.class; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/posts/get/GetPostByIdHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.posts.get; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.joinhocus.horus.db.repos.PostsRepo; 7 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 8 | import com.joinhocus.horus.http.Response; 9 | import com.joinhocus.horus.http.model.EmptyRequest; 10 | import com.joinhocus.horus.misc.MongoIds; 11 | import com.joinhocus.horus.post.PostUtil; 12 | import io.javalin.core.validation.BodyValidator; 13 | import io.javalin.http.Context; 14 | import org.bson.types.ObjectId; 15 | import org.slf4j.Logger; 16 | 17 | import java.util.concurrent.CompletableFuture; 18 | 19 | public class GetPostByIdHandler implements DefinedTypesWithUserHandler { 20 | @Override 21 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 22 | String id = context.queryParam("id"); 23 | boolean complex = getBooleanParam("full", context); 24 | if (Strings.isNullOrEmpty(id)) { 25 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("id cannot be empty")); 26 | } 27 | ObjectId mongoId = MongoIds.parseId(id); 28 | if (mongoId == null) { 29 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("malformed post id")); 30 | } 31 | 32 | if (complex) { 33 | return getFullPost(mongoId, account); 34 | } 35 | 36 | return getSimplePost(mongoId, account); 37 | } 38 | 39 | private CompletableFuture getFullPost(ObjectId id, UserAccount requester) { 40 | return MongoDatabase.getInstance().getRepo(PostsRepo.class).getByIdComplex(id, requester).thenApply(post -> { 41 | if (post == null) { 42 | return Response.of(Response.Type.NOT_FOUND); 43 | } 44 | 45 | return Response.of(Response.Type.OKAY).append("post", PostUtil.recursive(post)); 46 | }); 47 | } 48 | 49 | private CompletableFuture getSimplePost(ObjectId id, UserAccount requester) { 50 | return MongoDatabase.getInstance().getRepo(PostsRepo.class).getById(id, requester).thenApply(post -> { 51 | if (post == null) { 52 | return Response.of(Response.Type.NOT_FOUND); 53 | } 54 | 55 | return Response.of(Response.Type.OKAY).append("post", PostUtil.recursive(post)); 56 | }); 57 | } 58 | 59 | @Override 60 | public Class requestClass() { 61 | return EmptyRequest.class; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/setup/GetUserInviterInfo.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.setup; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.account.invite.Invite; 6 | import com.joinhocus.horus.db.MongoDatabase; 7 | import com.joinhocus.horus.db.repos.AccountsRepo; 8 | import com.joinhocus.horus.db.repos.InvitesRepo; 9 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 10 | import com.joinhocus.horus.http.Response; 11 | import com.joinhocus.horus.http.model.EmptyRequest; 12 | import io.javalin.core.validation.BodyValidator; 13 | import io.javalin.http.Context; 14 | import org.bson.Document; 15 | import org.slf4j.Logger; 16 | 17 | import java.util.concurrent.CompletableFuture; 18 | 19 | public class GetUserInviterInfo implements DefinedTypesWithUserHandler { 20 | 21 | private final Response HORRIS_INVITE = Response.of(Response.Type.OKAY).append("inviter", "horris").append("org", "Hocus"); 22 | 23 | @Override 24 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 25 | Document document = account.getDocument(); 26 | String claimedCode = document.getString("acceptedCode"); 27 | if (Strings.isNullOrEmpty(claimedCode)) { 28 | return wrap(HORRIS_INVITE); 29 | } 30 | return runPipeline(claimedCode); 31 | } 32 | 33 | private CompletableFuture runPipeline(String code) { 34 | return MongoDatabase.getInstance().getRepo(InvitesRepo.class).getInviteByCode(code).thenCompose(invite -> { 35 | if (invite == null) { 36 | return wrap(HORRIS_INVITE); 37 | } 38 | if (invite.getInviter() == null) { 39 | return wrap(HORRIS_INVITE); 40 | } 41 | return runSecondary(invite); 42 | }); 43 | } 44 | 45 | private CompletableFuture runSecondary(Invite invite) { 46 | return MongoDatabase.getInstance().getRepo(AccountsRepo.class).findById(invite.getInviter()) 47 | .thenCompose(account -> { 48 | if (account == null) { 49 | return wrap(HORRIS_INVITE); 50 | } 51 | 52 | return getOrganizationInfo(invite, account); 53 | }); 54 | } 55 | 56 | private CompletableFuture getOrganizationInfo(Invite invite, UserAccount inviter) { 57 | // TODO: do org stuff 58 | return wrap(Response.of(Response.Type.OKAY).append("inviter", inviter.getUsernames().getDisplay()).append("org", invite.getOrgId())); 59 | } 60 | 61 | @Override 62 | public Class requestClass() { 63 | return EmptyRequest.class; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/forgotpw/RequestPasswordResetHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.forgotpw; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.AccountAuth; 5 | import com.joinhocus.horus.account.UserAccount; 6 | import com.joinhocus.horus.account.UserNames; 7 | import com.joinhocus.horus.db.MongoDatabase; 8 | import com.joinhocus.horus.db.repos.AccountsRepo; 9 | import com.joinhocus.horus.db.repos.PasswordResetRepo; 10 | import com.joinhocus.horus.http.DefinedTypesHandler; 11 | import com.joinhocus.horus.http.Response; 12 | import com.joinhocus.horus.http.routes.account.forgotpw.model.ForgotPasswordResetRequest; 13 | import io.javalin.core.validation.BodyValidator; 14 | import io.javalin.http.Context; 15 | import org.slf4j.Logger; 16 | 17 | import java.util.UUID; 18 | import java.util.concurrent.CompletableFuture; 19 | 20 | public class RequestPasswordResetHandler implements DefinedTypesHandler { 21 | @Override 22 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 23 | ForgotPasswordResetRequest request = validator 24 | .check("login", req -> !Strings.isNullOrEmpty(req.getLogin()), "login cannot be empty") 25 | .check("login", req -> { 26 | if (UserNames.NAME_PATTERN.matcher(req.getLogin()).find()) { 27 | return true; 28 | } 29 | 30 | return AccountAuth.isValidEmail(req.getLogin()); 31 | }, "login field must be an email or username") 32 | .get(); 33 | return MongoDatabase.getInstance().getRepo(AccountsRepo.class).findBasicByLogin(request.getLogin()).thenCompose(account -> { 34 | if (account == null) { 35 | return wrap(Response.of(Response.Type.OKAY)); 36 | } 37 | 38 | UUID uuid = UUID.randomUUID(); 39 | return createResetCode(account, uuid, context.ip()); 40 | }); 41 | } 42 | 43 | private CompletableFuture createResetCode(UserAccount account, UUID uuid, String ipAddress) { 44 | return MongoDatabase.getInstance().getRepo(PasswordResetRepo.class) 45 | .createResetCode(account, uuid, ipAddress) 46 | .thenCompose(opt -> { 47 | return sendEmail(); 48 | }).thenApply(aVoid -> { 49 | return Response.of(Response.Type.OKAY); 50 | }); 51 | } 52 | 53 | private CompletableFuture sendEmail() { 54 | // todo 55 | return CompletableFuture.completedFuture(null); 56 | } 57 | 58 | @Override 59 | public Class requestClass() { 60 | return ForgotPasswordResetRequest.class; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/Response.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http; 2 | 3 | import com.google.gson.JsonElement; 4 | import com.google.gson.JsonObject; 5 | import lombok.Getter; 6 | 7 | @Getter 8 | public class Response { 9 | 10 | private final int code; 11 | private String message; 12 | private final boolean success; 13 | 14 | private final JsonObject object; 15 | 16 | private Response(Type type) { 17 | this.code = type.code; 18 | this.message = type.message; 19 | this.success = type.success; 20 | 21 | this.object = new JsonObject(); 22 | this.object.addProperty("status_code", this.code); 23 | this.object.addProperty("success", this.success); 24 | this.object.addProperty("message", this.message); 25 | } 26 | 27 | public static Response of(Type type) { 28 | return new Response(type); 29 | } 30 | 31 | public Response append(String key, String value) { 32 | this.object.addProperty(key, value); 33 | return this; 34 | } 35 | 36 | public Response append(String key, Number value) { 37 | this.object.addProperty(key, value); 38 | return this; 39 | } 40 | 41 | public Response append(String key, boolean value) { 42 | this.object.addProperty(key, value); 43 | return this; 44 | } 45 | 46 | public Response append(String key, JsonElement value) { 47 | this.object.add(key, value); 48 | return this; 49 | } 50 | 51 | public Response setMessage(String message) { 52 | this.message = message; 53 | this.object.addProperty("message", message); 54 | return this; 55 | } 56 | 57 | public JsonObject toJSON() { 58 | return this.object; 59 | } 60 | 61 | public enum Type { 62 | UNAUTHORIZED(401, "Unauthorized"), 63 | FORBIDDEN(403, "Forbidden"), 64 | NOT_FOUND(404, "Not Found"), 65 | RATE_LIMIT(420, "Too many requests. Rate limit exceeded"), 66 | BAD_REQUEST(400, "Bad Request"), 67 | INTERNAL_SERVER_EXCEPTION(500, "Internal server error"), 68 | OKAY(200, "Successful", true), 69 | OKAY_CREATED(201, "Successful", true), 70 | 71 | BETA_FEATURE(418, "This is a beta feature"), 72 | FEATURE_DISABLED(419, "This feature is temporarily disabled"); 73 | 74 | int code; 75 | String message; 76 | boolean success; 77 | 78 | Type(int code, String message, boolean success) { 79 | this.code = code; 80 | this.message = message; 81 | this.success = success; 82 | } 83 | 84 | Type(int code, String message) { 85 | this(code, message, false); 86 | } 87 | 88 | public int getStatusCode() { 89 | return code; 90 | } 91 | 92 | public String getMessage() { 93 | return message; 94 | } 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/invites/get/GetCreatedInvitesHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.invites.get; 2 | 3 | import com.google.common.base.Strings; 4 | import com.google.gson.JsonArray; 5 | import com.google.gson.JsonObject; 6 | import com.joinhocus.horus.account.UserAccount; 7 | import com.joinhocus.horus.account.invite.Invite; 8 | import com.joinhocus.horus.db.MongoDatabase; 9 | import com.joinhocus.horus.db.repos.InvitesRepo; 10 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 11 | import com.joinhocus.horus.http.Response; 12 | import com.joinhocus.horus.http.model.EmptyRequest; 13 | import com.joinhocus.horus.misc.CompletableFutures; 14 | import com.joinhocus.horus.twitter.TwitterAPI; 15 | import io.javalin.core.validation.BodyValidator; 16 | import io.javalin.http.Context; 17 | import org.slf4j.Logger; 18 | 19 | import java.util.ArrayList; 20 | import java.util.List; 21 | import java.util.concurrent.CompletableFuture; 22 | 23 | public class GetCreatedInvitesHandler implements DefinedTypesWithUserHandler { 24 | @Override 25 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 26 | return MongoDatabase.getInstance().getRepo(InvitesRepo.class).getInvitesFrom(account.getId()).thenCompose(in -> { 27 | List> futureInvites = new ArrayList<>(); 28 | for (Invite invite : in) { 29 | if (!Strings.isNullOrEmpty(invite.getTwitterId())) { 30 | futureInvites.add(TwitterAPI.fetchUserInfo(invite.getTwitterId()).thenApply(user -> { 31 | JsonObject out = new JsonObject(); 32 | out.addProperty("display", "@" + user.getHandle()); 33 | out.addProperty("claimed", invite.isWasClaimed()); 34 | 35 | return out; 36 | })); 37 | } else { 38 | futureInvites.add(CompletableFuture.completedFuture(emailInviteToJson(invite))); 39 | } 40 | } 41 | 42 | return CompletableFutures.asList(futureInvites); 43 | }).thenApply(in -> { 44 | JsonArray invites = new JsonArray(); 45 | for (JsonObject invite : in) { 46 | invites.add(invite); 47 | } 48 | 49 | return Response.of(Response.Type.OKAY).append("invites", invites); 50 | }); 51 | } 52 | 53 | private JsonObject emailInviteToJson(Invite invite) { 54 | JsonObject object = new JsonObject(); 55 | object.addProperty("display", invite.getEmail()); 56 | object.addProperty("claimed", invite.isWasClaimed()); 57 | 58 | return object; 59 | } 60 | 61 | @Override 62 | public Class requestClass() { 63 | return EmptyRequest.class; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/organization/Organization.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.organization; 2 | 3 | import com.joinhocus.horus.account.UserAccount; 4 | import com.joinhocus.horus.misc.follow.Followable; 5 | import org.bson.types.ObjectId; 6 | 7 | import java.util.Map; 8 | import java.util.concurrent.CompletableFuture; 9 | 10 | public interface Organization extends Followable { 11 | 12 | ObjectId getId(); 13 | 14 | String getName(); 15 | 16 | String getHandle(); 17 | 18 | OrganizationSettings getSettings(); 19 | 20 | Map getSeats(); 21 | 22 | CompletableFuture> getUserSeats(); 23 | 24 | // default CompletableFuture getFromDocument(Document document) { 25 | // ObjectId orgId = document.getObjectId("_id"); 26 | // Document nameDoc = document.get("name", Document.class); 27 | // String name = nameDoc.getString("display"); 28 | // String handle = nameDoc.getString("handle"); 29 | // OrganizationSettings settings; 30 | // if (document.containsKey("settings")) { 31 | // settings = new OrganizationSettings(document.get("settings", Document.class)); 32 | // } else { 33 | // settings = new OrganizationSettings(); 34 | // } 35 | // 36 | // if (document.containsKey("seats")) { 37 | // //noinspection unchecked 38 | // List members = (List) document.get("seats"); 39 | // 40 | // Map roleMap = new HashMap<>(); 41 | // Map> seatMap = new HashMap<>(); 42 | // for (Document member : members) { 43 | // ObjectId id = member.getObjectId("id"); 44 | // OrganizationRole role = OrganizationRole.valueOf(member.getString("role")); 45 | // 46 | // roleMap.put(id, role); 47 | // seatMap.put(id, MongoDatabase.getInstance().getRepo(AccountsRepo.class).findById(id)); 48 | // } 49 | // 50 | // return CompletableFutures.asMap(seatMap).thenApply(outMap -> { 51 | // Map seats = new HashMap<>(outMap.size()); 52 | // outMap.forEach((id, account) -> { 53 | // seats.put(account, roleMap.get(id)); 54 | // }); 55 | // 56 | // return new SimpleOrganization( 57 | // name, 58 | // handle, 59 | // settings, 60 | // orgId, 61 | // seats 62 | // ); 63 | // }); 64 | // } 65 | // 66 | // return CompletableFuture.completedFuture(new SimpleOrganization( 67 | // name, 68 | // handle, 69 | // settings, 70 | // orgId, 71 | // Collections.emptyMap() 72 | // )); 73 | // } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/post/impl/PostWithExtra.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.post.impl; 2 | 3 | import com.google.gson.JsonObject; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.organization.Organization; 6 | import com.joinhocus.horus.post.Post; 7 | import com.joinhocus.horus.post.PostPrivacy; 8 | import com.joinhocus.horus.post.PostType; 9 | import lombok.ToString; 10 | import org.bson.types.ObjectId; 11 | 12 | import java.util.Date; 13 | import java.util.List; 14 | 15 | @ToString 16 | public class PostWithExtra implements Post { 17 | 18 | private final ObjectId id; 19 | private final PostType type; 20 | private final PostPrivacy privacy; 21 | private final String content; 22 | private final UserAccount author; 23 | private final Organization organization; 24 | private final Date postTime; 25 | private final ObjectId parent; 26 | private final List comments; 27 | private final JsonObject extra; 28 | 29 | public PostWithExtra( 30 | ObjectId id, 31 | PostType type, 32 | PostPrivacy privacy, 33 | String content, 34 | UserAccount author, 35 | Organization organization, 36 | Date postTime, 37 | ObjectId parent, 38 | List comments, 39 | JsonObject extra 40 | ) { 41 | this.id = id; 42 | this.type = type; 43 | this.privacy = privacy; 44 | this.content = content; 45 | this.comments = comments; 46 | this.author = author; 47 | this.organization = organization; 48 | this.postTime = postTime; 49 | this.parent = parent; 50 | this.extra = extra; 51 | } 52 | 53 | @Override 54 | public ObjectId getId() { 55 | return this.id; 56 | } 57 | 58 | @Override 59 | public PostType type() { 60 | return this.type; 61 | } 62 | 63 | @Override 64 | public PostPrivacy privacy() { 65 | return this.privacy; 66 | } 67 | 68 | @Override 69 | public String content() { 70 | return this.content; 71 | } 72 | 73 | @Override 74 | public UserAccount author() { 75 | return this.author; 76 | } 77 | 78 | @Override 79 | public Organization organization() { 80 | return this.organization; 81 | } 82 | 83 | @Override 84 | public Date postTime() { 85 | return this.postTime; 86 | } 87 | 88 | @Override 89 | public ObjectId parent() { 90 | return this.parent; 91 | } 92 | 93 | @Override 94 | public List getComments() { 95 | return comments; 96 | } 97 | 98 | @Override 99 | public void addComment(Post comment) { 100 | this.comments.add(comment); 101 | } 102 | 103 | public JsonObject getExtra() { 104 | return extra; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/invites/validate/ValidateInviteHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.invites.validate; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.invite.Invite; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.joinhocus.horus.db.repos.InvitesRepo; 7 | import com.joinhocus.horus.http.DefinedTypesHandler; 8 | import com.joinhocus.horus.http.Response; 9 | import com.joinhocus.horus.http.model.EmptyRequest; 10 | import com.joinhocus.horus.misc.MongoIds; 11 | import com.joinhocus.horus.twitter.TwitterAPI; 12 | import io.javalin.core.validation.BodyValidator; 13 | import io.javalin.http.Context; 14 | import org.bson.types.ObjectId; 15 | import org.slf4j.Logger; 16 | 17 | import java.util.concurrent.CompletableFuture; 18 | 19 | public class ValidateInviteHandler implements DefinedTypesHandler { 20 | @Override 21 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 22 | String inviteId = context.pathParam("inviteId"); 23 | if (Strings.isNullOrEmpty(inviteId)) { 24 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("invalid inviteId provided, cannot be empty")); 25 | } 26 | 27 | ObjectId actualId = MongoIds.parseId(inviteId); 28 | if (actualId == null) { 29 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("Invalid inviteId provided")); 30 | } 31 | return runPipeline(actualId); 32 | } 33 | 34 | private CompletableFuture runPipeline(ObjectId inviteId) { 35 | return MongoDatabase.getInstance().getRepo(InvitesRepo.class).getInviteById(inviteId).thenCompose(document -> { 36 | if (document == null) { 37 | // if not present we display it as claimed 38 | return wrap(Response.of(Response.Type.OKAY).append("claimed", true)); 39 | } 40 | 41 | boolean claimed = document.getBoolean("claimed", false); 42 | if (claimed) { 43 | return wrap(Response.of(Response.Type.OKAY).append("claimed", true)); 44 | } 45 | 46 | Invite invite = new Invite(document); 47 | if (invite.getTwitterId() != null) { 48 | return runTwitterCheck(invite.getTwitterId()); 49 | } 50 | 51 | // we don't want to expose the invitee's email, so we won't return it. 52 | return wrap(Response.of(Response.Type.OKAY).append("claimed", false).append("isEmail", true)); 53 | }); 54 | } 55 | 56 | private CompletableFuture runTwitterCheck(String twitterId) { 57 | return TwitterAPI.fetchUserInfo(twitterId).thenApply(user -> { 58 | return Response.of(Response.Type.OKAY).append("claimed", false).append("twitterId", twitterId).append("handle", user.getHandle()); 59 | }); 60 | } 61 | 62 | @Override 63 | public Class requestClass() { 64 | return EmptyRequest.class; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/db/repos/WaitListRepo.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.db.repos; 2 | 3 | import com.joinhocus.horus.account.invite.Invite; 4 | import com.joinhocus.horus.db.AsyncMongoRepo; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.joinhocus.horus.misc.CompletableFutures; 7 | import com.joinhocus.horus.misc.Pair; 8 | import com.joinhocus.horus.misc.strgen.WordStringGenerator; 9 | import com.joinhocus.horus.twitter.oauth.OAuthAccountResponse; 10 | import com.mongodb.client.model.Filters; 11 | import org.bson.Document; 12 | 13 | import java.util.Objects; 14 | import java.util.concurrent.CompletableFuture; 15 | import java.util.function.Function; 16 | 17 | public class WaitListRepo extends AsyncMongoRepo { 18 | 19 | private final WordStringGenerator GENERATOR = new WordStringGenerator(); 20 | 21 | public WaitListRepo() { 22 | super("hocus", "waitlist"); 23 | } 24 | 25 | public CompletableFuture delete(Document document) { 26 | return this.deleteOne(document); 27 | } 28 | 29 | public CompletableFuture checkIfExists(String userId) { 30 | return this.findFirst(Filters.or( 31 | Filters.eq("userId", userId), 32 | Filters.eq("name", userId) 33 | ), Function.identity()); 34 | } 35 | 36 | public CompletableFuture> createWaitlistUser(String userId, String name) { 37 | String code = GENERATOR.generate(4); 38 | Invite invite = new Invite( 39 | userId, 40 | null, 41 | null, 42 | null, 43 | code 44 | ); 45 | return MongoDatabase.getInstance().getRepo(InvitesRepo.class).insertInvite(invite).thenCompose(res -> { 46 | if (res == null) { 47 | return CompletableFutures.failedFuture(new RuntimeException("failed to insert invite")); 48 | } 49 | 50 | return this.insertOne(new Document() 51 | .append("userId", userId) 52 | .append("name", name) 53 | .append("waitingSince", System.currentTimeMillis()) 54 | .append("accepted", false) 55 | .append("inviteId", res.getObjectId("_id").toHexString()) 56 | ).thenApply(doc -> new Pair<>(new OAuthAccountResponse(userId, name), res.getObjectId("_id").toHexString())); 57 | }); 58 | } 59 | 60 | public CompletableFuture checkCombination(String inviteCode, String twitterId) { 61 | return this.findFirst(Filters.and( 62 | Filters.eq("inviteCode", inviteCode), 63 | Filters.eq("userId", twitterId) 64 | ), Objects::nonNull); 65 | } 66 | 67 | public CompletableFuture consume(String invite) { 68 | return this.deleteOne(Filters.eq( 69 | "inviteCode", invite 70 | )).thenApply(aBoolean -> null); 71 | } 72 | 73 | public CompletableFuture getByInviteId(String inviteId) { 74 | return this.findFirst(Filters.eq("inviteId", inviteId), Function.identity()); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/db/repos/FollowsRepo.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.db.repos; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import com.google.common.collect.Lists; 5 | import com.joinhocus.horus.db.AsyncMongoRepo; 6 | import com.joinhocus.horus.db.MongoDatabase; 7 | import com.joinhocus.horus.misc.follow.EntityType; 8 | import com.joinhocus.horus.misc.follow.Followable; 9 | import com.joinhocus.horus.organization.Organization; 10 | import com.mongodb.client.model.Filters; 11 | import com.mongodb.client.model.IndexModel; 12 | import com.mongodb.client.model.Indexes; 13 | import com.mongodb.client.model.Projections; 14 | import org.bson.Document; 15 | import org.bson.types.ObjectId; 16 | 17 | import java.util.Date; 18 | import java.util.concurrent.CompletableFuture; 19 | 20 | public class FollowsRepo extends AsyncMongoRepo { 21 | public FollowsRepo() { 22 | super("hocus", "follows"); 23 | } 24 | 25 | @Override 26 | public void postBoot() { 27 | this.createIndexes(Lists.newArrayList( 28 | new IndexModel(Indexes.descending("follower"), INDEX_BACKGROUND), 29 | new IndexModel(Indexes.descending("followee"), INDEX_BACKGROUND) 30 | )); 31 | } 32 | 33 | public CompletableFuture follows(Followable follower, ObjectId followee) { 34 | return this.checkExists(Filters.and( 35 | Filters.eq("follower", follower.getId()), 36 | Filters.eq("followee", followee) 37 | )); 38 | } 39 | 40 | public CompletableFuture follow(Followable follower, ObjectId followee, EntityType type) { 41 | return this.insertOne( 42 | new Document() 43 | .append("follower", follower.getId()) 44 | .append("followee", followee) 45 | .append("since", new Date()) 46 | .append("kind", type.name()) 47 | ).thenApply(doc -> null); 48 | } 49 | 50 | public CompletableFuture> getFollowedOrganizations(Followable follower) { 51 | return this.find( 52 | Filters.and( 53 | Filters.eq("follower", follower.getId()), 54 | Filters.eq("kind", EntityType.ORGANIZATION.name()) 55 | ), 56 | Projections.include("followee"), 57 | doc -> doc.getObjectId("followee") 58 | ).thenCompose(objectIds -> MongoDatabase.getInstance().getRepo(OrganizationRepo.class).getByIds(objectIds)); 59 | } 60 | 61 | public CompletableFuture unfollow(Followable follower, ObjectId followee) { 62 | return this.deleteOne( 63 | Filters.and( 64 | Filters.eq("follower", follower.getId()), 65 | Filters.eq("followee", followee) 66 | ) 67 | ).thenApply(doc -> null); 68 | } 69 | 70 | public CompletableFuture getFollowers(Followable account) { 71 | return this.count(Filters.eq("followee", account.getId())); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/slack/SlackClient.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.slack; 2 | 3 | import com.google.common.collect.Lists; 4 | import com.slack.api.Slack; 5 | import com.slack.api.SlackConfig; 6 | import com.slack.api.bolt.App; 7 | import com.slack.api.bolt.AppConfig; 8 | import com.slack.api.bolt.request.Request; 9 | import com.slack.api.bolt.request.RequestHeaders; 10 | import com.slack.api.bolt.util.SlackRequestParser; 11 | import com.slack.api.methods.MethodsClient; 12 | import io.javalin.http.Context; 13 | 14 | import java.lang.reflect.Field; 15 | import java.util.ArrayList; 16 | import java.util.HashMap; 17 | import java.util.List; 18 | import java.util.Map; 19 | 20 | public class SlackClient { 21 | 22 | private static SlackClient instance; 23 | private MethodsClient client; 24 | public static final String XOXB_TOKEN = ""; 25 | @SuppressWarnings("FieldCanBeLocal") 26 | private final String SIGNING_SECRET = ""; 27 | 28 | private final App app; 29 | private final SlackRequestParser parser; 30 | 31 | public SlackClient() { 32 | try { 33 | SlackConfig config = SlackConfig.class.newInstance(); 34 | Field field = SlackConfig.class.getDeclaredField("httpClientResponseHandlers"); 35 | field.setAccessible(true); 36 | //noinspection rawtypes 37 | field.set(config, new ArrayList()); 38 | this.client = Slack.getInstance(config).methods(XOXB_TOKEN); 39 | } catch (Exception e) { 40 | e.printStackTrace(); 41 | this.client = Slack.getInstance().methods(XOXB_TOKEN); 42 | } 43 | 44 | AppConfig appConfig = AppConfig.builder() 45 | .signingSecret(SIGNING_SECRET) 46 | .singleTeamBotToken(XOXB_TOKEN) 47 | .build(); 48 | 49 | this.app = new App(appConfig); 50 | this.parser = new SlackRequestParser(appConfig); 51 | this.app.start(); 52 | } 53 | 54 | public MethodsClient getClient() { 55 | return client; 56 | } 57 | 58 | public static SlackClient getInstance() { 59 | if (instance == null) { 60 | instance = new SlackClient(); 61 | } 62 | 63 | return instance; 64 | } 65 | 66 | public App getApp() { 67 | return app; 68 | } 69 | 70 | public Request transform(Context context) { 71 | String body = context.body(); 72 | SlackRequestParser.HttpRequest rawRequest = SlackRequestParser.HttpRequest.builder() 73 | .requestUri(context.url()) 74 | .queryString(context.queryParamMap()) 75 | .requestBody(body) 76 | .remoteAddress(context.ip()) 77 | .headers(new RequestHeaders(toSlackMap(context.headerMap()))) 78 | .build(); 79 | return parser.parse(rawRequest); 80 | } 81 | 82 | private Map> toSlackMap(Map headers) { 83 | Map> slackMap = new HashMap<>(); 84 | headers.forEach((key, value) -> slackMap.put(key, Lists.newArrayList(value.split(",")))); 85 | return slackMap; 86 | } 87 | 88 | public void registerExtension(SlackClientExtension extension) { 89 | extension.register(this.app); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/organization/create/CreateOrganizationHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.organization.create; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.account.UserNames; 6 | import com.joinhocus.horus.db.MongoDatabase; 7 | import com.joinhocus.horus.db.repos.OrganizationRepo; 8 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 9 | import com.joinhocus.horus.http.Response; 10 | import com.joinhocus.horus.http.routes.organization.create.model.CreateOrganizationRequest; 11 | import com.joinhocus.horus.misc.HandlesUtil; 12 | import com.joinhocus.horus.organization.Organization; 13 | import com.joinhocus.horus.organization.SimpleOrganization; 14 | import io.javalin.core.validation.BodyValidator; 15 | import io.javalin.http.Context; 16 | import org.slf4j.Logger; 17 | 18 | import java.util.concurrent.CompletableFuture; 19 | 20 | public class CreateOrganizationHandler implements DefinedTypesWithUserHandler { 21 | @Override 22 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 23 | CreateOrganizationRequest request = validator 24 | .check("name", req -> !Strings.isNullOrEmpty(req.getName()), "name cannot be empty") 25 | .check("name", req -> req.getName().trim().length() <= 50, "name cannot be longer than 50 characters") 26 | .check("handle", req -> !Strings.isNullOrEmpty(req.getHandle()), "username cannot be empty") 27 | .check("handle", req -> UserNames.NAME_PATTERN.matcher(req.getHandle()).find(), "handle too long or contains invalid characters") 28 | .get(); 29 | 30 | return runPipeline(request, account); 31 | } 32 | 33 | private CompletableFuture runPipeline(CreateOrganizationRequest request, UserAccount account) { 34 | return HandlesUtil.isHandleAvailable(request.getHandle()).thenCompose(available -> { 35 | if (!available) { 36 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("That handle is not available")); 37 | } 38 | 39 | return createOrganization(request, account); 40 | }); 41 | } 42 | 43 | private CompletableFuture createOrganization(CreateOrganizationRequest request, UserAccount account) { 44 | Organization organization = new SimpleOrganization( 45 | request.getName(), 46 | request.getHandle().toLowerCase() 47 | ); 48 | 49 | return MongoDatabase.getInstance().getRepo(OrganizationRepo.class).createOrganization( 50 | organization, 51 | account 52 | ).thenApply(document -> { 53 | if (document != null) { 54 | return Response.of(Response.Type.OKAY_CREATED).append("organizationId", document.getObjectId("_id").toHexString()); 55 | } 56 | 57 | return Response.of(Response.Type.BAD_REQUEST).setMessage("failed to create organization"); 58 | }); 59 | } 60 | 61 | @Override 62 | public Class requestClass() { 63 | return CreateOrganizationRequest.class; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/account/forgotpw/ResetPasswordHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.account.forgotpw; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.db.MongoDatabase; 5 | import com.joinhocus.horus.db.repos.AccountsRepo; 6 | import com.joinhocus.horus.db.repos.PasswordResetRepo; 7 | import com.joinhocus.horus.http.DefinedTypesHandler; 8 | import com.joinhocus.horus.http.Response; 9 | import com.joinhocus.horus.http.routes.account.forgotpw.model.ForgotPasswordTokenRequest; 10 | import com.joinhocus.horus.misc.CompletableFutures; 11 | import io.javalin.core.validation.BodyValidator; 12 | import io.javalin.http.Context; 13 | import org.bson.types.ObjectId; 14 | import org.slf4j.Logger; 15 | 16 | import java.util.UUID; 17 | import java.util.concurrent.CompletableFuture; 18 | 19 | public class ResetPasswordHandler implements DefinedTypesHandler { 20 | @Override 21 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 22 | ForgotPasswordTokenRequest request = validator 23 | .check("token", req -> !Strings.isNullOrEmpty(req.getToken()), "token cannot be empty") 24 | .check("token", req -> { 25 | try { 26 | //noinspection ResultOfMethodCallIgnored 27 | UUID.fromString(req.getToken()); 28 | return true; 29 | } catch (Exception e) { 30 | return false; 31 | } 32 | }, "invalid token") 33 | .check("password", req -> !Strings.isNullOrEmpty(req.getPassword()), "password cannot be empty") 34 | .get(); 35 | 36 | return MongoDatabase.getInstance().getRepo(PasswordResetRepo.class).getUserAccountFor(request.getToken()) 37 | .thenCompose(account -> { 38 | if (account == null) { 39 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("Unknown code or account")); 40 | } 41 | return runPippeline(request, account.getId()); 42 | }); 43 | } 44 | 45 | private CompletableFuture runPippeline(ForgotPasswordTokenRequest request, ObjectId accountId) { 46 | return MongoDatabase.getInstance().getRepo(AccountsRepo.class).changePassword( 47 | accountId, 48 | request.getPassword() 49 | ).thenCompose(success -> { 50 | if (!success) { 51 | return CompletableFutures.failedFuture(new RuntimeException("failed to update password, database did not acknowledge")); 52 | } 53 | 54 | return consumeCode(request.getToken()); 55 | }); 56 | } 57 | 58 | private CompletableFuture consumeCode(String token) { 59 | return MongoDatabase.getInstance().getRepo(PasswordResetRepo.class) 60 | .consumeCode(token) 61 | .thenCompose(aVoid -> { 62 | // todo send email 63 | return wrap(Response.of(Response.Type.OKAY)); 64 | }); 65 | } 66 | 67 | @Override 68 | public Class requestClass() { 69 | return ForgotPasswordTokenRequest.class; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/account/UserAccount.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.account; 2 | 3 | import com.google.common.base.Preconditions; 4 | import com.joinhocus.horus.http.routes.account.create.model.CreateAccountRequest; 5 | import com.joinhocus.horus.misc.follow.Followable; 6 | import lombok.Getter; 7 | import lombok.ToString; 8 | import org.bson.Document; 9 | import org.bson.types.ObjectId; 10 | import org.jetbrains.annotations.NotNull; 11 | 12 | import java.util.Objects; 13 | 14 | @Getter 15 | @ToString(of = {"id", "name"}) 16 | public class UserAccount implements Followable { 17 | 18 | private final Document document; 19 | 20 | private final ObjectId id; 21 | private final String name; 22 | private final UserNames usernames; 23 | private final String email, password; 24 | private boolean finishedAccountSetup; 25 | private AccountType accountType; 26 | private final String avatar; 27 | 28 | private final Document extra; 29 | 30 | public UserAccount(@NotNull Document document) { 31 | this.document = document; 32 | 33 | this.id = document.getObjectId("_id"); 34 | this.name = document.getString("name"); 35 | this.email = document.getString("email"); 36 | this.password = document.getString("password"); 37 | if (document.containsKey("type")) { 38 | this.finishedAccountSetup = true; 39 | this.accountType = AccountType.valueOf(document.getString("type")); 40 | } 41 | this.avatar = document.getString("avatar"); 42 | 43 | Document userNames = document.get("user", Document.class); 44 | this.usernames = new UserNames( 45 | userNames.getString("name"), 46 | userNames.getString("display") 47 | ); 48 | 49 | this.extra = document.get("extra", new Document()); 50 | } 51 | 52 | public static Document generateAccount(CreateAccountRequest request) { 53 | char[] password = request.getPassword().toCharArray(); 54 | try { 55 | String hash = AccountAuth.ARGON_2.hash( 56 | 22, 57 | 65536, 58 | 1, 59 | password 60 | ); 61 | 62 | return new Document() 63 | .append("name", request.getName()) 64 | .append("email", request.getEmail().toLowerCase()) 65 | .append("user", new Document() 66 | .append("name", request.getUsername().toLowerCase()) // store it for case matching 67 | .append("display", request.getUsername()) 68 | ) 69 | .append("password", hash) 70 | .append("acceptedCode", request.getInviteCode()); // store it because we need to get info from it during other steps 71 | } finally { 72 | AccountAuth.ARGON_2.wipeArray(password); 73 | } 74 | } 75 | 76 | public boolean passwordsMatch(@NotNull String remote) { 77 | Preconditions.checkNotNull(this.password); 78 | return AccountAuth.ARGON_2.verify(this.password, remote.toCharArray()); 79 | } 80 | 81 | @Override 82 | public boolean equals(Object o) { 83 | if (this == o) return true; 84 | if (o == null || getClass() != o.getClass()) return false; 85 | UserAccount account = (UserAccount) o; 86 | return Objects.equals(id, account.id); 87 | } 88 | 89 | @Override 90 | public int hashCode() { 91 | return Objects.hash(id); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/invites/token/SendVerificationEmailHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.invites.token; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.invite.Invite; 5 | import com.joinhocus.horus.db.MongoDatabase; 6 | import com.joinhocus.horus.db.repos.EmailInviteVerificationRepo; 7 | import com.joinhocus.horus.db.repos.InvitesRepo; 8 | import com.joinhocus.horus.http.DefinedTypesHandler; 9 | import com.joinhocus.horus.http.Response; 10 | import com.joinhocus.horus.http.routes.invites.InviteRequest; 11 | import com.joinhocus.horus.misc.CompletableFutures; 12 | import com.joinhocus.horus.misc.Environment; 13 | import com.joinhocus.horus.misc.MongoIds; 14 | import com.joinhocus.horus.misc.email.EmailClient; 15 | import com.wildbit.java.postmark.client.data.model.message.Message; 16 | import io.javalin.core.validation.BodyValidator; 17 | import io.javalin.http.Context; 18 | import org.bson.types.ObjectId; 19 | import org.slf4j.Logger; 20 | 21 | import java.util.UUID; 22 | import java.util.concurrent.CompletableFuture; 23 | import java.util.concurrent.TimeUnit; 24 | 25 | public class SendVerificationEmailHandler implements DefinedTypesHandler { 26 | 27 | private final String BASE_URL = Environment.current().getUrl() + "/flow/email"; 28 | 29 | @Override 30 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 31 | decorateWithRateLimit(context, 1, TimeUnit.MINUTES); 32 | InviteRequest request = validator 33 | .check("inviteId", req -> !Strings.isNullOrEmpty(req.getInviteId()), "inviteId cannot be empty") 34 | .get(); 35 | ObjectId inviteId = MongoIds.parseId(request.getInviteId()); 36 | if (inviteId == null) { 37 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("Invalid inviteId provided")); 38 | } 39 | return runPipeline(inviteId); 40 | } 41 | 42 | private CompletableFuture runPipeline(ObjectId inviteId) { 43 | return MongoDatabase.getInstance().getRepo(InvitesRepo.class).getInviteById(inviteId).thenCompose(document -> { 44 | if (document == null) { 45 | return wrap(Response.of(Response.Type.OKAY)); // fake 46 | } 47 | boolean claimed = document.getBoolean("claimed", false); 48 | if (claimed) { 49 | return wrap(Response.of(Response.Type.OKAY).append("claimed", true)); 50 | } 51 | 52 | Invite invite = new Invite(document); 53 | return generateCodeAndEmail(invite); 54 | }); 55 | } 56 | 57 | private CompletableFuture generateCodeAndEmail(Invite invite) { 58 | String code = UUID.randomUUID().toString(); 59 | return MongoDatabase.getInstance().getRepo(EmailInviteVerificationRepo.class) 60 | .insertVerification(invite, code) 61 | .thenCompose(aVoid -> { 62 | try { 63 | Message message = new Message(invite.getEmail(), invite.getEmail(), "Invite Code", BASE_URL + code); 64 | EmailClient.getInstance().getClient().deliverMessage(message); 65 | return wrap(Response.of(Response.Type.OKAY).append("sent", true)); 66 | } catch (Exception e) { 67 | return CompletableFutures.failedFuture(e); 68 | } 69 | }); 70 | } 71 | 72 | @Override 73 | public Class requestClass() { 74 | return InviteRequest.class; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/organization/SimpleOrganization.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.organization; 2 | 3 | import com.joinhocus.horus.account.UserAccount; 4 | import com.joinhocus.horus.db.MongoDatabase; 5 | import com.joinhocus.horus.db.repos.AccountsRepo; 6 | import com.joinhocus.horus.misc.CompletableFutures; 7 | import lombok.RequiredArgsConstructor; 8 | import lombok.ToString; 9 | import org.bson.Document; 10 | import org.bson.types.ObjectId; 11 | 12 | import java.util.Collections; 13 | import java.util.HashMap; 14 | import java.util.List; 15 | import java.util.Map; 16 | import java.util.concurrent.CompletableFuture; 17 | 18 | @ToString 19 | @RequiredArgsConstructor 20 | public class SimpleOrganization implements Organization { 21 | 22 | private final String name, handle; 23 | private final OrganizationSettings settings; 24 | private final ObjectId objectId; 25 | private final Map seats; 26 | 27 | public SimpleOrganization(String name, String handle) { 28 | this.name = name; 29 | this.handle = handle; 30 | this.settings = new OrganizationSettings(); 31 | this.objectId = null; 32 | this.seats = Collections.emptyMap(); 33 | } 34 | 35 | public SimpleOrganization(Document document) { 36 | Document name = document.get("name", Document.class); 37 | this.objectId = document.getObjectId("_id"); 38 | this.name = name.getString("display"); 39 | this.handle = name.getString("handle"); 40 | 41 | if (document.containsKey("settings")) { 42 | this.settings = new OrganizationSettings(document.get("settings", Document.class)); 43 | } else { 44 | this.settings = new OrganizationSettings(); 45 | } 46 | 47 | if (document.containsKey("seats")) { 48 | //noinspection unchecked 49 | List members = (List) document.get("seats"); 50 | this.seats = new HashMap<>(members.size()); 51 | for (Document member : members) { 52 | ObjectId id = member.getObjectId("id"); 53 | OrganizationRole role = OrganizationRole.valueOf(member.getString("role")); 54 | 55 | this.seats.put(id, role); 56 | } 57 | } else { 58 | this.seats = new HashMap<>(); 59 | } 60 | } 61 | 62 | @Override 63 | public ObjectId getId() { 64 | return this.objectId; 65 | } 66 | 67 | @Override 68 | public String getName() { 69 | return this.name; 70 | } 71 | 72 | @Override 73 | public String getHandle() { 74 | return this.handle; 75 | } 76 | 77 | @Override 78 | public OrganizationSettings getSettings() { 79 | return this.settings; 80 | } 81 | 82 | @Override 83 | public Map getSeats() { 84 | return this.seats; 85 | } 86 | 87 | @Override 88 | public CompletableFuture> getUserSeats() { 89 | Map> futures = new HashMap<>(); 90 | for (Map.Entry member : this.seats.entrySet()) { 91 | futures.put(member.getKey(), MongoDatabase.getInstance().getRepo(AccountsRepo.class).findById(member.getKey())); 92 | } 93 | 94 | return CompletableFutures.asMap(futures).thenApply(outMap -> { 95 | Map roles = new HashMap<>(outMap.size()); 96 | outMap.forEach((id, account) -> { 97 | roles.put(account, seats.get(id)); 98 | }); 99 | 100 | return roles; 101 | }); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/twitter/oauth/TwitterOauth.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.twitter.oauth; 2 | 3 | import com.google.common.collect.ImmutableMap; 4 | import com.joinhocus.horus.twitter.TwitterAPI; 5 | import com.joinhocus.horus.twitter.TwitterOAuthHeaderGenerator; 6 | import kong.unirest.Unirest; 7 | 8 | import java.util.Collections; 9 | import java.util.HashMap; 10 | import java.util.Map; 11 | import java.util.concurrent.CompletableFuture; 12 | 13 | public class TwitterOauth { 14 | 15 | private final String OAUTH_URL = "https://api.twitter.com/oauth/"; 16 | 17 | public CompletableFuture getRequestToken(String callbackUrl) { 18 | String url = OAUTH_URL + "request_token"; 19 | return Unirest.post(url) 20 | .header("Authorization", TwitterAPI.HEADER_GENERATOR.generateHeader( 21 | "POST", 22 | url, 23 | ImmutableMap 24 | .builder() 25 | .put("oauth_callback", callbackUrl) 26 | .put("x_auth_access_type", "read") 27 | .build() 28 | )) 29 | .queryString("oauth_callback", callbackUrl) 30 | .queryString("x_auth_access_type", "read") 31 | .asStringAsync() 32 | .thenApply(response -> { 33 | if (!response.isSuccess()) { 34 | throw new IllegalStateException("Failed to request token, " + response.getBody()); 35 | } 36 | 37 | String strRes = response.getBody(); 38 | Map res = parseResponse(strRes); 39 | return new OAuthResponseValues( 40 | res.getOrDefault("oauth_token", ""), 41 | res.getOrDefault("oauth_token_secret", "") 42 | ); 43 | }); 44 | } 45 | 46 | public CompletableFuture getOauthToken(String token, String verifier) { 47 | TwitterOAuthHeaderGenerator generator = new TwitterOAuthHeaderGenerator( 48 | TwitterAPI.CONSUMER_KEY, 49 | TwitterAPI.CONSUMER_SECRET, 50 | token, 51 | TwitterAPI.ACCESS_TOKEN_SECRET 52 | ); 53 | String url = OAUTH_URL + "access_token"; 54 | return Unirest.post(url) 55 | .queryString("oauth_verifier", verifier) 56 | .header("Authorization", generator.generateHeader( 57 | "POST", 58 | url, 59 | Collections.emptyMap() 60 | )) 61 | .asStringAsync() 62 | .thenApply(response -> { 63 | if (!response.isSuccess()) { 64 | throw new IllegalStateException("Failed to validate tokens: " + response.getBody()); 65 | } 66 | 67 | String strRes = response.getBody(); 68 | Map res = parseResponse(strRes); 69 | return new OAuthAccountResponse( 70 | res.getOrDefault("user_id", null), 71 | res.getOrDefault("screen_name", null) 72 | ); 73 | }); 74 | } 75 | 76 | private Map parseResponse(String response) { 77 | String[] split = response.split("&"); 78 | Map res = new HashMap<>(3); 79 | for (String val : split) { 80 | String key = val.substring(0, val.indexOf('=')); 81 | String actualVal = val.substring(val.indexOf('=') + 1); 82 | 83 | res.put(key, actualVal); 84 | } 85 | return res; 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/entity/FollowEntityHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.entity; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.account.activity.ActivityNotification; 6 | import com.joinhocus.horus.account.activity.NotificationType; 7 | import com.joinhocus.horus.db.MongoDatabase; 8 | import com.joinhocus.horus.db.repos.ActivityRepo; 9 | import com.joinhocus.horus.db.repos.FollowsRepo; 10 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 11 | import com.joinhocus.horus.http.Response; 12 | import com.joinhocus.horus.http.routes.entity.model.FollowEntityRequest; 13 | import com.joinhocus.horus.misc.MongoIds; 14 | import com.joinhocus.horus.misc.follow.EntityType; 15 | import io.javalin.core.validation.BodyValidator; 16 | import io.javalin.http.Context; 17 | import org.bson.Document; 18 | import org.bson.types.ObjectId; 19 | import org.slf4j.Logger; 20 | 21 | import java.util.Collections; 22 | import java.util.Date; 23 | import java.util.concurrent.CompletableFuture; 24 | 25 | public class FollowEntityHandler implements DefinedTypesWithUserHandler { 26 | @Override 27 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 28 | FollowEntityRequest request = validator 29 | .check("id", req -> !Strings.isNullOrEmpty(req.getId()), "id cannot be empty") 30 | .check("type", req -> req.getType() != null, "type cannot be empty") 31 | .get(); 32 | 33 | ObjectId mongoId = MongoIds.parseId(request.getId()); 34 | if (mongoId == null) { 35 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("malformed entity id")); 36 | } 37 | 38 | return startPipeline(account, mongoId, request.getType()); 39 | } 40 | 41 | private CompletableFuture startPipeline(UserAccount requester, ObjectId mongoId, EntityType type) { 42 | return MongoDatabase.getInstance().getRepo(FollowsRepo.class).follows(requester, mongoId).thenCompose(follows -> { 43 | if (follows) { 44 | return doUnfollow(requester, mongoId); 45 | } 46 | 47 | return doFollow(requester, mongoId, type); 48 | }); 49 | } 50 | 51 | private CompletableFuture doFollow(UserAccount account, ObjectId id, EntityType type) { 52 | return MongoDatabase.getInstance().getRepo(FollowsRepo.class).follow(account, id, type).thenCompose(ignored -> { 53 | ActivityNotification notification = new ActivityNotification( 54 | new ObjectId(), 55 | account.getId(), 56 | type, 57 | Collections.singletonList(id), 58 | new Date(), 59 | NotificationType.FOLLOW, 60 | new Document() 61 | .append("followed", account.getId().toHexString()) 62 | .append("handle", account.getUsernames().getDisplay()) 63 | ); 64 | 65 | return MongoDatabase.getInstance().getRepo(ActivityRepo.class).insertNotification(notification).thenApply(aVoid -> { 66 | return Response.of(Response.Type.OKAY).append("followed", true); 67 | }); 68 | }); 69 | } 70 | 71 | private CompletableFuture doUnfollow(UserAccount account, ObjectId id) { 72 | return MongoDatabase.getInstance().getRepo(FollowsRepo.class).unfollow(account, id).thenApply(ignored -> { 73 | return Response.of(Response.Type.OKAY).append("followed", false); 74 | }); 75 | } 76 | 77 | @Override 78 | public Class requestClass() { 79 | return FollowEntityRequest.class; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/intellij,java,maven 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=intellij,java,maven 4 | 5 | ### Intellij ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/artifacts 37 | # .idea/compiler.xml 38 | # .idea/jarRepositories.xml 39 | # .idea/modules.xml 40 | # .idea/*.iml 41 | # .idea/modules 42 | # *.iml 43 | # *.ipr 44 | 45 | # CMake 46 | cmake-build-*/ 47 | 48 | # Mongo Explorer plugin 49 | .idea/**/mongoSettings.xml 50 | 51 | # File-based project format 52 | *.iws 53 | 54 | # IntelliJ 55 | out/ 56 | 57 | # mpeltonen/sbt-idea plugin 58 | .idea_modules/ 59 | 60 | # JIRA plugin 61 | atlassian-ide-plugin.xml 62 | 63 | # Cursive Clojure plugin 64 | .idea/replstate.xml 65 | 66 | # Crashlytics plugin (for Android Studio and IntelliJ) 67 | com_crashlytics_export_strings.xml 68 | crashlytics.properties 69 | crashlytics-build.properties 70 | fabric.properties 71 | 72 | # Editor-based Rest Client 73 | .idea/httpRequests 74 | 75 | # Android studio 3.1+ serialized cache file 76 | .idea/caches/build_file_checksums.ser 77 | 78 | ### Intellij Patch ### 79 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 80 | 81 | # *.iml 82 | # modules.xml 83 | # .idea/misc.xml 84 | # *.ipr 85 | 86 | # Sonarlint plugin 87 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 88 | .idea/**/sonarlint/ 89 | 90 | # SonarQube Plugin 91 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 92 | .idea/**/sonarIssues.xml 93 | 94 | # Markdown Navigator plugin 95 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 96 | .idea/**/markdown-navigator.xml 97 | .idea/**/markdown-navigator-enh.xml 98 | .idea/**/markdown-navigator/ 99 | 100 | # Cache file creation bug 101 | # See https://youtrack.jetbrains.com/issue/JBR-2257 102 | .idea/$CACHE_FILE$ 103 | 104 | # CodeStream plugin 105 | # https://plugins.jetbrains.com/plugin/12206-codestream 106 | .idea/codestream.xml 107 | 108 | ### Java ### 109 | # Compiled class file 110 | *.class 111 | 112 | # Log file 113 | *.log 114 | 115 | # BlueJ files 116 | *.ctxt 117 | 118 | # Mobile Tools for Java (J2ME) 119 | .mtj.tmp/ 120 | 121 | # Package Files # 122 | *.jar 123 | *.war 124 | *.nar 125 | *.ear 126 | *.zip 127 | *.tar.gz 128 | *.rar 129 | 130 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 131 | hs_err_pid* 132 | 133 | ### Maven ### 134 | target/ 135 | pom.xml.tag 136 | pom.xml.releaseBackup 137 | pom.xml.versionsBackup 138 | pom.xml.next 139 | release.properties 140 | dependency-reduced-pom.xml 141 | buildNumber.properties 142 | .mvn/timing.properties 143 | # https://github.com/takari/maven-wrapper#usage-without-binary-jar 144 | .mvn/wrapper/maven-wrapper.jar 145 | 146 | .idea/* 147 | *.iml 148 | 149 | # End of https://www.toptal.com/developers/gitignore/api/intellij,java,maven -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/twitter/TwitterAPI.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.twitter; 2 | 3 | import com.google.common.collect.ImmutableMap; 4 | import com.google.gson.Gson; 5 | import com.google.gson.GsonBuilder; 6 | import com.google.gson.JsonArray; 7 | import com.google.gson.JsonElement; 8 | import com.joinhocus.horus.misc.CompletableFutures; 9 | import com.joinhocus.horus.twitter.data.TwitterUser; 10 | import com.joinhocus.horus.twitter.oauth.TwitterOauth; 11 | import kong.unirest.JsonNode; 12 | import kong.unirest.Unirest; 13 | 14 | import java.util.concurrent.CompletableFuture; 15 | 16 | public class TwitterAPI { 17 | 18 | public static final String CONSUMER_KEY = ""; 19 | public static final String CONSUMER_SECRET = ""; 20 | public static final String ACCESS_TOKEN = ""; 21 | public static final String ACCESS_TOKEN_SECRET = ""; 22 | 23 | private static final Gson GSON = new GsonBuilder().serializeNulls().create(); 24 | 25 | public static final TwitterOauth OAUTH = new TwitterOauth(); 26 | 27 | public static final TwitterOAuthHeaderGenerator HEADER_GENERATOR = new TwitterOAuthHeaderGenerator( 28 | CONSUMER_KEY, 29 | CONSUMER_SECRET, 30 | ACCESS_TOKEN, 31 | ACCESS_TOKEN_SECRET 32 | ); 33 | 34 | public static CompletableFuture fetchUserInfo(String userId) { 35 | String url = "https://api.twitter.com/1.1/users/lookup.json"; 36 | return Unirest.get(url) 37 | .header("Authorization", HEADER_GENERATOR.generateHeader( 38 | "GET", 39 | url, 40 | ImmutableMap 41 | .builder() 42 | .put("user_id", userId) 43 | .build() 44 | )) 45 | .queryString("user_id", userId) 46 | .asJsonAsync() 47 | .thenCompose(response -> { 48 | if (!response.isSuccess()) { 49 | return CompletableFutures.failedFuture(new IllegalStateException(response.getBody().toString())); 50 | } 51 | 52 | JsonNode node = response.getBody(); 53 | JsonElement element = GSON.fromJson(node.toString(), JsonElement.class); 54 | JsonArray array = element.getAsJsonArray(); 55 | JsonElement user = array.get(0); 56 | return CompletableFuture.completedFuture( 57 | GSON.fromJson(user, TwitterUser.class) 58 | ); 59 | }); 60 | } 61 | 62 | public static CompletableFuture searchUser(String userName) { 63 | String url = "https://api.twitter.com/1.1/users/lookup.json"; 64 | return Unirest.get(url) 65 | .header("Authorization", HEADER_GENERATOR.generateHeader( 66 | "GET", 67 | url, 68 | ImmutableMap 69 | .builder() 70 | .put("screen_name", userName) 71 | .build() 72 | )) 73 | .queryString("screen_name", userName) 74 | .asJsonAsync() 75 | .thenCompose(response -> { 76 | if (!response.isSuccess()) { 77 | return CompletableFutures.failedFuture(new IllegalStateException(response.getBody().toString())); 78 | } 79 | 80 | JsonNode node = response.getBody(); 81 | JsonElement element = GSON.fromJson(node.toString(), JsonElement.class); 82 | JsonArray array = element.getAsJsonArray(); 83 | JsonElement user = array.get(0); 84 | return CompletableFuture.completedFuture( 85 | GSON.fromJson(user, TwitterUser.class) 86 | ); 87 | }); 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/db/repos/ActivityRepo.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.db.repos; 2 | 3 | import com.google.common.collect.Lists; 4 | import com.google.gson.JsonObject; 5 | import com.joinhocus.horus.account.activity.ActivityNotification; 6 | import com.joinhocus.horus.db.AsyncMongoRepo; 7 | import com.joinhocus.horus.misc.PaginatedList; 8 | import com.mongodb.client.model.Aggregates; 9 | import com.mongodb.client.model.Filters; 10 | import com.mongodb.client.model.Projections; 11 | import com.mongodb.client.model.Sorts; 12 | import com.mongodb.client.model.Updates; 13 | import org.bson.Document; 14 | import org.bson.conversions.Bson; 15 | import org.bson.types.ObjectId; 16 | 17 | import java.util.Collections; 18 | import java.util.List; 19 | import java.util.concurrent.CompletableFuture; 20 | 21 | public class ActivityRepo extends AsyncMongoRepo { 22 | public ActivityRepo() { 23 | super("hocus", "activity"); 24 | } 25 | 26 | public CompletableFuture insertNotification(ActivityNotification notification) { 27 | if (notification.getReceivers().isEmpty()) { 28 | logger.warn("Skipping insert for notification w/o receivers {}", notification); 29 | return CompletableFuture.completedFuture(null); 30 | } 31 | 32 | Document document = new Document() 33 | .append("actor", notification.getActor()) 34 | .append("entity", notification.getEntityType().name()) 35 | .append("receivers", notification.getReceivers()) 36 | .append("when", notification.getWhen()) 37 | .append("type", notification.getNotificationType().name()) 38 | .append("extra", notification.getExtra()); 39 | 40 | return this.insertOne(document).thenApply(doc -> null); 41 | } 42 | 43 | public CompletableFuture> getActivity( 44 | ObjectId requester, 45 | List receivers, 46 | int page, 47 | int limit 48 | ) { 49 | Bson filter = Filters.in("receivers", receivers); 50 | 51 | return this.count(filter).thenCompose(total -> this.aggregate(Lists.newArrayList( 52 | Aggregates.match(filter), 53 | Aggregates.sort(Sorts.descending("when")), 54 | Aggregates.project(Projections.fields( 55 | Projections.include("_id", "actor", "entity", "receivers", "when", "sentence", "type", "extra"), 56 | Projections.computed("wasSeen", new Document("$in", Lists.newArrayList( 57 | requester, 58 | new Document("$ifNull", Lists.newArrayList("$seen", Collections.emptyList())) 59 | ))) 60 | )), 61 | Aggregates.sort(Sorts.ascending("wasSeen")), 62 | Aggregates.skip(limit * page), 63 | Aggregates.limit(limit) 64 | ), ActivityNotification::new).thenApply(notifications -> { 65 | JsonObject pagination = new JsonObject(); 66 | pagination.addProperty("page", page); 67 | //noinspection IntegerDivisionInFloatingPointContext 68 | int available = (int) Math.ceil(total / limit); 69 | 70 | pagination.addProperty("pages", available == 0 ? 0 : available + 1); 71 | pagination.addProperty("hasNext", page < available); 72 | pagination.addProperty("hasPages", available != 0); 73 | 74 | return new PaginatedList<>(pagination, notifications); 75 | })); 76 | } 77 | 78 | public CompletableFuture countActivity( 79 | ObjectId requester, 80 | List receivers 81 | ) { 82 | Bson filter = Filters.and( 83 | Filters.in("receivers", receivers), 84 | Filters.nin("seen", requester) 85 | ); 86 | 87 | return this.count(filter); 88 | } 89 | 90 | public CompletableFuture markSeen(List ids, ObjectId viewer) { 91 | return this.updateMany(Filters.in("_id", ids), Updates.addToSet("seen", viewer)); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/db/MongoDatabase.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.db; 2 | 3 | import com.joinhocus.horus.config.Configs; 4 | import com.joinhocus.horus.db.config.MongoConfig; 5 | import com.joinhocus.horus.db.repos.AccountsRepo; 6 | import com.joinhocus.horus.db.repos.ActivityRepo; 7 | import com.joinhocus.horus.db.repos.EmailInviteVerificationRepo; 8 | import com.joinhocus.horus.db.repos.FollowsRepo; 9 | import com.joinhocus.horus.db.repos.InvitesRepo; 10 | import com.joinhocus.horus.db.repos.LikesRepo; 11 | import com.joinhocus.horus.db.repos.OrganizationRepo; 12 | import com.joinhocus.horus.db.repos.PasswordResetRepo; 13 | import com.joinhocus.horus.db.repos.PostsRepo; 14 | import com.joinhocus.horus.db.repos.WaitListRepo; 15 | import com.mongodb.MongoClientSettings; 16 | import com.mongodb.MongoCredential; 17 | import com.mongodb.ReadPreference; 18 | import com.mongodb.async.client.MongoClient; 19 | import com.mongodb.async.client.MongoClients; 20 | import com.mongodb.connection.ClusterConnectionMode; 21 | import com.mongodb.selector.ReadPreferenceServerSelector; 22 | 23 | import java.util.Collections; 24 | import java.util.HashMap; 25 | import java.util.Map; 26 | import java.util.concurrent.TimeUnit; 27 | 28 | public class MongoDatabase { 29 | 30 | private final Map, AsyncMongoRepo> moduleMap = new HashMap<>(); 31 | private MongoClient client; 32 | 33 | private static final MongoDatabase instance; 34 | 35 | static { 36 | instance = new MongoDatabase(); 37 | } 38 | 39 | MongoDatabase() { 40 | try { 41 | registerDatabase(); 42 | registerModules(); 43 | } catch (Throwable t) { 44 | throw new RuntimeException(t); // propagate. 45 | } 46 | } 47 | 48 | private void registerDatabase() { 49 | MongoConfig config = Configs.load(MongoConfig.class, 50 | mongoConfigClass -> new MongoConfig("root", "", Collections.emptyList()) 51 | ); 52 | // auth on admin database, that way we get access to all the rest 53 | MongoCredential credential = MongoCredential.createCredential(config.getUsername(), "admin", config.getPassword().toCharArray()); 54 | MongoClientSettings.Builder builder = MongoClientSettings.builder() 55 | .applyToClusterSettings(b -> { 56 | b.hosts(config.asServerAddresses()); 57 | b.mode(config.getAddresses().size() > 1 ? ClusterConnectionMode.MULTIPLE : ClusterConnectionMode.SINGLE); 58 | b.serverSelector(new ReadPreferenceServerSelector(ReadPreference.primary())); 59 | }).applyToConnectionPoolSettings(b -> { 60 | b.maxWaitTime(1, TimeUnit.SECONDS); 61 | b.maxConnectionIdleTime(5, TimeUnit.SECONDS); 62 | }); 63 | 64 | if (!config.getUsername().isEmpty()) { 65 | builder.credential(credential); 66 | } 67 | 68 | this.client = MongoClients.create(builder.build()); 69 | } 70 | 71 | private void registerModules() { 72 | registerRepo(new AccountsRepo()); 73 | registerRepo(new PasswordResetRepo()); 74 | registerRepo(new InvitesRepo()); 75 | registerRepo(new WaitListRepo()); 76 | registerRepo(new EmailInviteVerificationRepo()); 77 | registerRepo(new OrganizationRepo()); 78 | registerRepo(new PostsRepo()); 79 | registerRepo(new LikesRepo()); 80 | registerRepo(new FollowsRepo()); 81 | registerRepo(new ActivityRepo()); 82 | } 83 | 84 | public static MongoDatabase getInstance() { 85 | return instance; 86 | } 87 | 88 | private void registerRepo(AsyncMongoRepo repo) { 89 | repo.setDatabase(this); 90 | this.moduleMap.put(repo.getClass(), repo); 91 | repo.postBoot(); 92 | } 93 | 94 | public MongoClient getClient() { 95 | return client; 96 | } 97 | 98 | public T getRepo(Class clazz) { 99 | //noinspection unchecked 100 | return (T) moduleMap.get(clazz); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/DefinedTypesHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http; 2 | 3 | import com.google.common.base.Preconditions; 4 | import com.joinhocus.horus.http.model.EmptyRequest; 5 | import io.javalin.core.validation.BodyValidator; 6 | import io.javalin.http.BadRequestResponse; 7 | import io.javalin.http.Context; 8 | import io.javalin.http.Handler; 9 | import io.javalin.http.HttpResponseException; 10 | import io.javalin.http.InternalServerErrorResponse; 11 | import io.javalin.http.util.RateLimit; 12 | import org.jetbrains.annotations.NotNull; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | 16 | import java.util.concurrent.CompletableFuture; 17 | import java.util.concurrent.TimeUnit; 18 | 19 | /** 20 | * Defines a single structured HTTP handler for a specific Request type 21 | * all responses are handled as JSON automatically, and all the async handling is done 22 | * for us by Javalin 23 | * @param a GSON decoded request, validated by {@link BodyValidator} 24 | */ 25 | public interface DefinedTypesHandler extends Handler { 26 | 27 | CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception; 28 | 29 | default CompletableFuture wrap(Response response) { 30 | return CompletableFuture.completedFuture(response); 31 | } 32 | 33 | default void decorateWithRateLimit(Context context, int numReq, TimeUnit unit) { 34 | // todo replace with our own version which allows us to specify better parameters 35 | // and uses redis because we run as stateless services 36 | new RateLimit(context).requestPerTimeUnit(numReq, unit); 37 | } 38 | 39 | @Override 40 | default void handle(@NotNull Context context) throws Exception { 41 | // each handler gets it's own logger 42 | Logger logger = LoggerFactory.getLogger(getClass()); 43 | CompletableFuture response; 44 | if (requestClass().equals(EmptyRequest.class)) { 45 | response = handle(null, context, logger); 46 | } else { 47 | Request request = context.bodyAsClass(requestClass()); 48 | Preconditions.checkNotNull(request); 49 | response = handle(context.bodyValidator(requestClass()), context, logger); 50 | } 51 | if (response == null) { 52 | context.status(500); 53 | context.json(new InternalServerErrorResponse()); 54 | return; 55 | } 56 | // context#result runs async so easy-peasy async handling i hope? 57 | context.result(response.thenApply((object) -> { 58 | if (object == null) { 59 | throw new BadRequestResponse(); 60 | } 61 | 62 | context.status(object.getCode()); 63 | return object.toJSON().toString(); 64 | }).exceptionally(err -> { 65 | if (err.getCause() instanceof HttpResponseException) { 66 | HttpResponseException http = (HttpResponseException) err.getCause(); 67 | context.json(http); 68 | context.status(http.getStatus()); 69 | return null; 70 | } 71 | logger.error("", err); 72 | throw new InternalServerErrorResponse(err.getMessage()); 73 | })); 74 | } 75 | 76 | Class requestClass(); 77 | 78 | default int getIntParam(String param, Context context) { 79 | try { 80 | String value = context.queryParam(param); 81 | if (value == null) { 82 | throw new BadRequestResponse(param + " may not be empty"); 83 | } 84 | return Integer.parseInt(value); 85 | } catch (Exception e) { 86 | throw new BadRequestResponse(param + " must be a valid integer"); 87 | } 88 | } 89 | 90 | default boolean getBooleanParam(String param, Context context) { 91 | try { 92 | String value = context.queryParam(param); 93 | if (value == null) return false; 94 | 95 | return Boolean.parseBoolean(value); 96 | } catch (Exception e) { 97 | throw new BadRequestResponse(param + " must be a valid boolean"); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/posts/like/LikePostHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.posts.like; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.account.activity.ActivityNotification; 6 | import com.joinhocus.horus.account.activity.NotificationType; 7 | import com.joinhocus.horus.db.MongoDatabase; 8 | import com.joinhocus.horus.db.repos.ActivityRepo; 9 | import com.joinhocus.horus.db.repos.LikesRepo; 10 | import com.joinhocus.horus.db.repos.PostsRepo; 11 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 12 | import com.joinhocus.horus.http.Response; 13 | import com.joinhocus.horus.http.routes.posts.like.model.LikePostRequest; 14 | import com.joinhocus.horus.misc.MongoIds; 15 | import com.joinhocus.horus.misc.follow.EntityType; 16 | import io.javalin.core.validation.BodyValidator; 17 | import io.javalin.http.Context; 18 | import org.bson.Document; 19 | import org.bson.types.ObjectId; 20 | import org.slf4j.Logger; 21 | 22 | import java.util.ArrayList; 23 | import java.util.Date; 24 | import java.util.List; 25 | import java.util.concurrent.CompletableFuture; 26 | import java.util.concurrent.TimeUnit; 27 | 28 | public class LikePostHandler implements DefinedTypesWithUserHandler { 29 | @Override 30 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 31 | decorateWithRateLimit(context, 2, TimeUnit.SECONDS); 32 | LikePostRequest request = validator 33 | .check("id", req -> !Strings.isNullOrEmpty(req.getId()), "id cannot be empty") 34 | .get(); 35 | 36 | ObjectId id = MongoIds.parseId(request.getId()); 37 | if (id == null) { 38 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("Malformed post id")); 39 | } 40 | return startPipeline(account, id); 41 | } 42 | 43 | private CompletableFuture startPipeline(UserAccount account, ObjectId id) { 44 | return MongoDatabase.getInstance().getRepo(LikesRepo.class).hasLiked(account, id).thenCompose(liked -> { 45 | if (liked) { 46 | return deleteLike(account, id); 47 | } 48 | 49 | return like(account, id); 50 | }); 51 | } 52 | 53 | private CompletableFuture deleteLike(UserAccount account, ObjectId id) { 54 | return MongoDatabase.getInstance().getRepo(LikesRepo.class).deleteLike(account, id).thenApply(deleted -> { 55 | return Response.of(Response.Type.OKAY).append("liked", false); 56 | }); 57 | } 58 | 59 | private CompletableFuture like(UserAccount account, ObjectId id) { 60 | return MongoDatabase.getInstance().getRepo(LikesRepo.class).createLike(account, id).thenCompose(deleted -> { 61 | return MongoDatabase.getInstance().getRepo(PostsRepo.class).getById(id).thenCompose(post -> { 62 | List recipients = new ArrayList<>(); 63 | if (post.author() != null) { 64 | if (!post.author().equals(account)) { 65 | recipients.add(post.author().getId()); 66 | } 67 | } 68 | if (post.organization() != null) { 69 | if (!post.organization().getSeats().containsKey(account.getId())) { 70 | recipients.add(post.organization().getId()); 71 | } 72 | } 73 | 74 | ActivityNotification notification = new ActivityNotification( 75 | new ObjectId(), 76 | account.getId(), 77 | EntityType.ACCOUNT, 78 | recipients, 79 | new Date(), 80 | NotificationType.LIKE, 81 | new Document() 82 | .append("post", post.content()) 83 | .append("postId", post.getId().toHexString()) 84 | ); 85 | return MongoDatabase.getInstance().getRepo(ActivityRepo.class).insertNotification(notification); 86 | }); 87 | }).thenApply(ignored -> { 88 | return Response.of(Response.Type.OKAY).append("liked", true); 89 | }); 90 | } 91 | 92 | @Override 93 | public Class requestClass() { 94 | return LikePostRequest.class; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/waitlist/WaitlistUtil.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.waitlist; 2 | 3 | import com.joinhocus.horus.misc.Environment; 4 | import com.joinhocus.horus.slack.SlackClient; 5 | import com.joinhocus.horus.twitter.TwitterAPI; 6 | import com.slack.api.methods.request.chat.ChatPostMessageRequest; 7 | import com.slack.api.model.block.Blocks; 8 | import com.slack.api.model.block.composition.BlockCompositions; 9 | import com.slack.api.model.block.composition.ConfirmationDialogObject; 10 | import com.slack.api.model.block.element.BlockElements; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | 14 | import java.text.NumberFormat; 15 | import java.util.concurrent.ForkJoinPool; 16 | 17 | public class WaitlistUtil { 18 | 19 | public static void sendToSlack(String userId, String inviteId) { 20 | if (Environment.isDev()) return; // don't want to do this for dev env 21 | Logger logger = LoggerFactory.getLogger(WaitlistUtil.class); 22 | ForkJoinPool.commonPool().submit(() -> { 23 | TwitterAPI.fetchUserInfo(userId).thenAccept(user -> { 24 | try { 25 | SlackClient.getInstance().getClient().chatPostMessage( 26 | ChatPostMessageRequest.builder() 27 | .channel("#waitlist") 28 | .blocks(Blocks.asBlocks( 29 | Blocks.header(section -> section.text(BlockCompositions.plainText(":tada: New wait-list sign-up!"))), 30 | Blocks.section(section -> section.fields( 31 | BlockCompositions.asSectionFields( 32 | BlockCompositions.markdownText("*Handle*:\n"), 33 | BlockCompositions.markdownText("*Verified*:\n" + (user.isVerified() ? "Yes" : "No")), 34 | BlockCompositions.markdownText("*Location*:\n" + user.getLocation()), 35 | BlockCompositions.markdownText("*Followers*:\n" + NumberFormat.getInstance().format(user.getFollowers())) 36 | ) 37 | )), 38 | Blocks.divider(), 39 | Blocks.actions( 40 | BlockElements.asElements( 41 | BlockElements.button(button -> { 42 | button.actionId("give_access"); 43 | button.text(BlockCompositions.plainText("Give Early Access")); 44 | button.value(inviteId); 45 | button.style("primary"); 46 | button.confirm(ConfirmationDialogObject.builder() 47 | .title(BlockCompositions.plainText("Give " + user.getName() + " early access?")) 48 | .text(BlockCompositions.plainText("Please confirm if you wish to give " + user.getName() + " early access to Hocus")) 49 | .confirm(BlockCompositions.plainText("Yes")) 50 | .deny(BlockCompositions.plainText("No")) 51 | .build() 52 | ); 53 | return button; 54 | }) 55 | ) 56 | ) 57 | )) 58 | .build() 59 | ); 60 | } catch (Exception e) { 61 | logger.error("", e); 62 | } 63 | }).exceptionally(err -> { 64 | logger.error("", err); 65 | return null; 66 | }); 67 | }); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | com.joinhocus 8 | hocus 9 | 1.0-SNAPSHOT 10 | 11 | 12 | 13 | 14 | org.apache.maven.plugins 15 | maven-compiler-plugin 16 | 17 | 8 18 | 8 19 | 20 | 21 | 22 | org.apache.maven.plugins 23 | maven-shade-plugin 24 | 2.4 25 | 26 | 27 | package 28 | 29 | shade 30 | 31 | 32 | 33 | 34 | 35 | org.apache.maven.plugins 36 | maven-jar-plugin 37 | 2.4 38 | 39 | 40 | 41 | com.joinhocus.horus.Main 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | org.jetbrains 52 | annotations 53 | 20.1.0 54 | 55 | 56 | 57 | org.mongodb 58 | mongodb-driver-async 59 | 3.8.1 60 | 61 | 62 | 63 | com.google.code.gson 64 | gson 65 | 2.8.6 66 | 67 | 68 | 69 | com.google.guava 70 | guava 71 | 30.0-jre 72 | 73 | 74 | 75 | io.javalin 76 | javalin 77 | 3.11.0 78 | 79 | 80 | 81 | org.slf4j 82 | slf4j-simple 83 | 1.7.30 84 | 85 | 86 | 87 | org.projectlombok 88 | lombok 89 | 1.18.16 90 | provided 91 | 92 | 93 | 94 | de.mkammerer 95 | argon2-jvm 96 | 2.7 97 | 98 | 99 | 100 | com.auth0 101 | java-jwt 102 | 3.4.1 103 | 104 | 105 | 106 | com.konghq 107 | unirest-java 108 | 3.11.04 109 | 110 | 111 | 112 | com.slack.api 113 | bolt 114 | 1.2.1 115 | 116 | 117 | 118 | com.wildbit.java 119 | postmark 120 | 1.5.3 121 | 122 | 123 | 124 | com.amazonaws 125 | aws-java-sdk-s3 126 | 1.11.901 127 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/invites/create/CreateInviteHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.invites.create; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.AccountAuth; 5 | import com.joinhocus.horus.account.UserAccount; 6 | import com.joinhocus.horus.account.invite.Invite; 7 | import com.joinhocus.horus.db.MongoDatabase; 8 | import com.joinhocus.horus.db.repos.AccountsRepo; 9 | import com.joinhocus.horus.db.repos.InvitesRepo; 10 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 11 | import com.joinhocus.horus.http.Response; 12 | import com.joinhocus.horus.http.routes.invites.create.model.CreateInviteRequest; 13 | import com.joinhocus.horus.misc.strgen.WordStringGenerator; 14 | import com.joinhocus.horus.twitter.TwitterAPI; 15 | import io.javalin.core.validation.BodyValidator; 16 | import io.javalin.http.Context; 17 | import org.slf4j.Logger; 18 | 19 | import java.util.concurrent.CompletableFuture; 20 | 21 | public class CreateInviteHandler implements DefinedTypesWithUserHandler { 22 | 23 | private final WordStringGenerator stringGenerator = new WordStringGenerator(); 24 | 25 | @Override 26 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 27 | CreateInviteRequest request = validator 28 | .check("sendTo", req -> !Strings.isNullOrEmpty(req.getSendTo()), "sendTo cannot be empty") 29 | .get(); 30 | 31 | int invitesLeft = account.getExtra().getInteger("invites", 0); 32 | if (invitesLeft <= 0) { 33 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("You don't have any more invites left!")); 34 | } 35 | 36 | String sendTo = request.getSendTo(); 37 | boolean isEmail = AccountAuth.isValidEmail(sendTo); 38 | String code = stringGenerator.generate(4); 39 | 40 | if (!isEmail) { 41 | return TwitterAPI.searchUser(sendTo).thenCompose(user -> { 42 | if (user == null) { 43 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("Couldn't find a Twitter user with that handle")); 44 | } 45 | 46 | Invite invite = new Invite(user.getId(), null, account.getId(), request.getOrgId(), code); 47 | return checkTwitterExists(user.getId(), account, invite); 48 | }).exceptionally(err -> { 49 | return Response.of(Response.Type.BAD_REQUEST).setMessage("Couldn't find a Twitter user with that handle"); 50 | }); 51 | } 52 | 53 | Invite invite = new Invite(null, sendTo, account.getId(), request.getOrgId(), code); 54 | return checkEmailExists(sendTo, account, invite); 55 | } 56 | 57 | private CompletableFuture checkTwitterExists(String twitter, UserAccount account, Invite invite) { 58 | return MongoDatabase.getInstance().getRepo(InvitesRepo.class).checkTwitterExists(twitter).thenCompose(exists -> { 59 | if (exists) { 60 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("That user already has an invite to Hocus!")); 61 | } 62 | 63 | return insertInvite(account, invite); 64 | }); 65 | } 66 | 67 | private CompletableFuture checkEmailExists(String email, UserAccount account, Invite invite) { 68 | return MongoDatabase.getInstance().getRepo(InvitesRepo.class).checkEmailExists(email).thenCompose(exists -> { 69 | if (exists) { 70 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("That user already has an invite to Hocus!")); 71 | } 72 | 73 | return insertInvite(account, invite); 74 | }); 75 | } 76 | 77 | private CompletableFuture insertInvite(UserAccount account, Invite invite) { 78 | return MongoDatabase.getInstance().getRepo(InvitesRepo.class).insertInvite(invite).thenCompose(document -> { 79 | if (document == null) { 80 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("Failed to create invite")); 81 | } 82 | 83 | return consumeInvite(account, invite); 84 | }); 85 | } 86 | 87 | private CompletableFuture consumeInvite(UserAccount account, Invite invite) { 88 | return MongoDatabase.getInstance().getRepo(AccountsRepo.class).consumeInvite(account.getId()).thenApply(ignored -> { 89 | // TODO: send email/twitter, slack alert 90 | return Response.of(Response.Type.OKAY) 91 | .append("code", invite.getCode()) 92 | .append("inviteId", invite.getInviteId()); 93 | }); 94 | } 95 | 96 | @Override 97 | public Class requestClass() { 98 | return CreateInviteRequest.class; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/waitlist/validate/TwitterValidateWaitListHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.waitlist.validate; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.db.MongoDatabase; 5 | import com.joinhocus.horus.db.repos.InvitesRepo; 6 | import com.joinhocus.horus.db.repos.WaitListRepo; 7 | import com.joinhocus.horus.http.DefinedTypesHandler; 8 | import com.joinhocus.horus.http.Response; 9 | import com.joinhocus.horus.http.routes.waitlist.WaitlistUtil; 10 | import com.joinhocus.horus.http.routes.waitlist.validate.model.TwitterValidateWaitListRequest; 11 | import com.joinhocus.horus.misc.MongoIds; 12 | import com.joinhocus.horus.twitter.TwitterAPI; 13 | import com.joinhocus.horus.twitter.oauth.OAuthAccountResponse; 14 | import io.javalin.core.validation.BodyValidator; 15 | import io.javalin.http.Context; 16 | import org.bson.Document; 17 | import org.bson.types.ObjectId; 18 | import org.slf4j.Logger; 19 | 20 | import java.util.concurrent.CompletableFuture; 21 | 22 | public class TwitterValidateWaitListHandler implements DefinedTypesHandler { 23 | @Override 24 | public CompletableFuture handle(BodyValidator validator, Context context, Logger logger) throws Exception { 25 | TwitterValidateWaitListRequest request = validator 26 | .check("token", req -> !Strings.isNullOrEmpty(req.getToken()), "token cannot be empty") 27 | .check("verify", req -> !Strings.isNullOrEmpty(req.getVerify()), "verify cannot be empty") 28 | .check("twitterId", req -> !Strings.isNullOrEmpty(req.getTwitterId()), "twitterId cannot be empty") 29 | .get(); 30 | 31 | return MongoDatabase.getInstance().getRepo(WaitListRepo.class).checkIfExists(request.getTwitterId()).thenCompose(document -> { 32 | if (document == null) { 33 | return handleNotInWaitlist(request.getTwitterId()); 34 | } 35 | 36 | String id = document.getString("userId"); 37 | return TwitterAPI.OAUTH.getOauthToken(request.getToken(), request.getVerify()).thenCompose(response -> { 38 | if (!response.getUserId().equals(id)) { 39 | return handleJoinWaitlist(response); 40 | } 41 | 42 | return handleValidate(document); 43 | }); 44 | }); 45 | } 46 | 47 | private CompletableFuture handleNotInWaitlist(String userId) { 48 | return TwitterAPI.fetchUserInfo(userId).thenCompose(user -> { 49 | return MongoDatabase.getInstance().getRepo(WaitListRepo.class).createWaitlistUser(userId, user.getName()); 50 | }).thenApply(res -> { 51 | WaitlistUtil.sendToSlack(userId, res.getValue()); 52 | return Response.of(Response.Type.OKAY_CREATED).append("joined", true); 53 | }); 54 | } 55 | 56 | private CompletableFuture handleJoinWaitlist(OAuthAccountResponse response) { 57 | return MongoDatabase.getInstance().getRepo(WaitListRepo.class).checkIfExists(response.getUserId()).thenCompose(document -> { 58 | if (document != null) { 59 | return wrap(Response.of(Response.Type.OKAY).append("twitter", response.getName()).append("existed", true)); 60 | } 61 | 62 | return MongoDatabase.getInstance().getRepo(WaitListRepo.class) 63 | .createWaitlistUser(response.getUserId(), response.getName()).thenApply(res -> { 64 | return Response.of(Response.Type.OKAY_CREATED).append("joined", true); 65 | }); 66 | }); 67 | } 68 | 69 | private CompletableFuture handleValidate(Document document) { 70 | boolean accepted = document.getBoolean("accepted", false); 71 | if (!accepted) { 72 | return wrap(Response.of(Response.Type.OKAY).append("accepted", false)); 73 | } 74 | 75 | String inviteId = document.getString("inviteId"); 76 | return runInvitePipeline(inviteId); 77 | } 78 | 79 | private CompletableFuture runInvitePipeline(String inviteId) { 80 | ObjectId id = MongoIds.parseId(inviteId); 81 | if (id == null) { 82 | return wrap(Response.of(Response.Type.OKAY).append("accepted", false)); 83 | } 84 | return MongoDatabase.getInstance().getRepo(InvitesRepo.class) 85 | .getInviteById(id) 86 | .thenCompose(document -> { 87 | if (document == null) { 88 | return wrap(Response.of(Response.Type.OKAY).append("accepted", false)); 89 | } 90 | 91 | return wrap(Response.of(Response.Type.OKAY).append("accepted", true).append("code", document.getString("code"))); 92 | }); 93 | } 94 | 95 | @Override 96 | public Class requestClass() { 97 | return TwitterValidateWaitListRequest.class; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/activity/GetActivityFeedHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.activity; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import com.google.gson.Gson; 5 | import com.google.gson.GsonBuilder; 6 | import com.google.gson.JsonArray; 7 | import com.google.gson.JsonObject; 8 | import com.joinhocus.horus.account.UserAccount; 9 | import com.joinhocus.horus.account.activity.ActivityNotification; 10 | import com.joinhocus.horus.db.MongoDatabase; 11 | import com.joinhocus.horus.db.repos.AccountsRepo; 12 | import com.joinhocus.horus.db.repos.ActivityRepo; 13 | import com.joinhocus.horus.db.repos.OrganizationRepo; 14 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 15 | import com.joinhocus.horus.http.Response; 16 | import com.joinhocus.horus.http.model.EmptyRequest; 17 | import com.joinhocus.horus.misc.CompletableFutures; 18 | import com.joinhocus.horus.misc.PaginatedList; 19 | import com.joinhocus.horus.organization.Organization; 20 | import io.javalin.core.validation.BodyValidator; 21 | import io.javalin.http.Context; 22 | import org.bson.types.ObjectId; 23 | import org.slf4j.Logger; 24 | 25 | import java.util.ArrayList; 26 | import java.util.List; 27 | import java.util.concurrent.CompletableFuture; 28 | import java.util.stream.Collectors; 29 | 30 | public class GetActivityFeedHandler implements DefinedTypesWithUserHandler { 31 | 32 | private final Gson GSON = new GsonBuilder().serializeNulls() 33 | .create(); 34 | 35 | @Override 36 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 37 | int page = getIntParam("page", context); 38 | return doGetFeed(account, page); 39 | } 40 | 41 | private CompletableFuture doGetFeed(UserAccount account, int page) { 42 | return MongoDatabase.getInstance().getRepo(OrganizationRepo.class).getOrganizations(account.getId()).thenApply(orgs -> { 43 | return ImmutableList.builder() 44 | .add(account.getId()) 45 | .addAll(orgs.stream().map(Organization::getId).collect(Collectors.toList())) 46 | .build(); 47 | }).thenCompose(ids -> { 48 | return MongoDatabase.getInstance().getRepo(ActivityRepo.class).getActivity( 49 | account.getId(), 50 | ids, 51 | page, 52 | 20 53 | ); 54 | }).thenCompose(notifications -> { 55 | return doActivity(notifications, account); 56 | }); 57 | } 58 | 59 | private CompletableFuture doActivity(PaginatedList notifications, UserAccount requester) { 60 | return MongoDatabase.getInstance().getRepo(ActivityRepo.class).markSeen( 61 | notifications.getData().stream().map(ActivityNotification::getId).collect(Collectors.toList()), 62 | requester.getId() 63 | ).thenCompose(ignored -> { 64 | List> futures = new ArrayList<>(notifications.getData().size()); 65 | for (ActivityNotification notification : notifications.getData()) { 66 | futures.add(notificationToJson(notification)); 67 | } 68 | 69 | return CompletableFutures.asList(futures); 70 | }).thenApply(objects -> { 71 | JsonArray array = new JsonArray(); 72 | for (JsonObject object : objects) { 73 | array.add(object); 74 | } 75 | 76 | return Response.of(Response.Type.OKAY).append("activity", array).append("pagination", notifications.getPaginationData()); 77 | }); 78 | } 79 | 80 | private CompletableFuture notificationToJson(ActivityNotification notif) { 81 | JsonObject object = new JsonObject(); 82 | object.addProperty("when", notif.getWhen().getTime()); 83 | object.addProperty("type", notif.getNotificationType().name()); 84 | object.addProperty("wasSeen", notif.isWasSeen()); 85 | object.addProperty("entity", notif.getEntityType().name()); 86 | 87 | if (notif.getExtra() != null) { 88 | JsonObject extra = GSON.fromJson(notif.getExtra().toJson(), JsonObject.class); 89 | object.add("extra", extra); 90 | } 91 | 92 | return MongoDatabase.getInstance().getRepo(AccountsRepo.class).findById(notif.getActor()).thenApply(account -> { 93 | JsonObject actor = new JsonObject(); 94 | actor.addProperty("id", account.getId().toHexString()); 95 | actor.addProperty("name", account.getName()); 96 | actor.addProperty("handle", account.getUsernames().getDisplay()); 97 | actor.addProperty("avatar", account.getAvatar()); 98 | object.add("actor", actor); 99 | 100 | return object; 101 | }); 102 | } 103 | 104 | @Override 105 | public Class requestClass() { 106 | return EmptyRequest.class; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/twitter/TwitterOAuthHeaderGenerator.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.twitter; 2 | 3 | import javax.crypto.Mac; 4 | import javax.crypto.SecretKey; 5 | import javax.crypto.spec.SecretKeySpec; 6 | import java.net.URLEncoder; 7 | import java.nio.charset.StandardCharsets; 8 | import java.security.InvalidKeyException; 9 | import java.security.NoSuchAlgorithmException; 10 | import java.util.Base64; 11 | import java.util.Date; 12 | import java.util.HashMap; 13 | import java.util.LinkedHashMap; 14 | import java.util.Map; 15 | import java.util.Random; 16 | import java.util.stream.Collectors; 17 | 18 | public class TwitterOAuthHeaderGenerator { 19 | private String consumerKey; 20 | private String consumerSecret; 21 | private String signatureMethod; 22 | private String token; 23 | private String tokenSecret; 24 | private String version; 25 | 26 | public TwitterOAuthHeaderGenerator(String consumerKey, String consumerSecret, String token, String tokenSecret) { 27 | this.consumerKey = consumerKey; 28 | this.consumerSecret = consumerSecret; 29 | this.token = token; 30 | this.tokenSecret = tokenSecret; 31 | this.signatureMethod = "HMAC-SHA1"; 32 | this.version = "1.0"; 33 | } 34 | 35 | private static final String oauth_consumer_key = "oauth_consumer_key"; 36 | private static final String oauth_token = "oauth_token"; 37 | private static final String oauth_signature_method = "oauth_signature_method"; 38 | private static final String oauth_timestamp = "oauth_timestamp"; 39 | private static final String oauth_nonce = "oauth_nonce"; 40 | private static final String oauth_version = "oauth_version"; 41 | private static final String oauth_signature = "oauth_signature"; 42 | private static final String HMAC_SHA1 = "HmacSHA1"; 43 | 44 | public String generateHeader(String httpMethod, String url, Map requestParams) { 45 | StringBuilder base = new StringBuilder(); 46 | String nonce = getNonce(); 47 | String timestamp = getTimestamp(); 48 | String baseSignatureString = generateSignatureBaseString(httpMethod, url, requestParams, nonce, timestamp); 49 | String signature = encryptUsingHmacSHA1(baseSignatureString); 50 | base.append("OAuth "); 51 | append(base, oauth_consumer_key, consumerKey); 52 | append(base, oauth_token, token); 53 | append(base, oauth_signature_method, signatureMethod); 54 | append(base, oauth_timestamp, timestamp); 55 | append(base, oauth_nonce, nonce); 56 | append(base, oauth_version, version); 57 | append(base, oauth_signature, signature); 58 | base.deleteCharAt(base.length() - 1); 59 | return base.toString(); 60 | } 61 | 62 | private String generateSignatureBaseString(String httpMethod, String url, Map requestParams, String nonce, String timestamp) { 63 | Map params = new HashMap<>(); 64 | requestParams.forEach((key, value) -> put(params, key, value)); 65 | put(params, oauth_consumer_key, consumerKey); 66 | put(params, oauth_nonce, nonce); 67 | put(params, oauth_signature_method, signatureMethod); 68 | put(params, oauth_timestamp, timestamp); 69 | put(params, oauth_token, token); 70 | put(params, oauth_version, version); 71 | Map sortedParams = params.entrySet().stream().sorted(Map.Entry.comparingByKey()) 72 | .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (oldValue, newValue) -> oldValue, LinkedHashMap::new)); 73 | StringBuilder base = new StringBuilder(); 74 | sortedParams.forEach((key, value) -> base.append(key).append("=").append(value).append("&")); 75 | base.deleteCharAt(base.length() - 1); 76 | return httpMethod.toUpperCase() + "&" + encode(url) + "&" + encode(base.toString()); 77 | } 78 | 79 | private String encryptUsingHmacSHA1(String input) { 80 | String secret = encode(consumerSecret) + "&" + encode(tokenSecret); 81 | byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); 82 | SecretKey key = new SecretKeySpec(keyBytes, HMAC_SHA1); 83 | Mac mac; 84 | try { 85 | mac = Mac.getInstance(HMAC_SHA1); 86 | mac.init(key); 87 | } catch (NoSuchAlgorithmException | InvalidKeyException e) { 88 | e.printStackTrace(); 89 | return null; 90 | } 91 | byte[] signatureBytes = mac.doFinal(input.getBytes(StandardCharsets.UTF_8)); 92 | return new String(Base64.getEncoder().encode(signatureBytes)); 93 | } 94 | 95 | private String encode(String value) { 96 | String encoded = ""; 97 | try { 98 | encoded = URLEncoder.encode(value, "UTF-8"); 99 | } catch (Exception e) { 100 | e.printStackTrace(); 101 | } 102 | StringBuilder sb = new StringBuilder(); 103 | char focus; 104 | for (int i = 0; i < encoded.length(); i++) { 105 | focus = encoded.charAt(i); 106 | if (focus == '*') { 107 | sb.append("%2A"); 108 | } else if (focus == '+') { 109 | sb.append("%20"); 110 | } else if (focus == '%' && i + 1 < encoded.length() && encoded.charAt(i + 1) == '7' && encoded.charAt(i + 2) == 'E') { 111 | sb.append('~'); 112 | i += 2; 113 | } else { 114 | sb.append(focus); 115 | } 116 | } 117 | return sb.toString(); 118 | } 119 | 120 | private void put(Map map, String key, String value) { 121 | map.put(encode(key), encode(value)); 122 | } 123 | 124 | private void append(StringBuilder builder, String key, String value) { 125 | builder.append(encode(key)).append("=\"").append(encode(value)).append("\","); 126 | } 127 | 128 | private String getNonce() { 129 | int leftLimit = 48; // numeral '0' 130 | int rightLimit = 122; // letter 'z' 131 | int targetStringLength = 10; 132 | Random random = new Random(); 133 | 134 | return random.ints(leftLimit, rightLimit + 1).filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97)).limit(targetStringLength) 135 | .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString(); 136 | 137 | } 138 | 139 | private String getTimestamp() { 140 | return Math.round((new Date()).getTime() / 1000.0) + ""; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/http/routes/posts/CreatePostHandler.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.http.routes.posts; 2 | 3 | import com.google.common.base.Strings; 4 | import com.joinhocus.horus.account.UserAccount; 5 | import com.joinhocus.horus.account.activity.ActivityNotification; 6 | import com.joinhocus.horus.account.activity.NotificationType; 7 | import com.joinhocus.horus.db.MongoDatabase; 8 | import com.joinhocus.horus.db.repos.ActivityRepo; 9 | import com.joinhocus.horus.db.repos.OrganizationRepo; 10 | import com.joinhocus.horus.db.repos.PostsRepo; 11 | import com.joinhocus.horus.http.DefinedTypesWithUserHandler; 12 | import com.joinhocus.horus.http.Response; 13 | import com.joinhocus.horus.http.routes.posts.model.CreatePostRequest; 14 | import com.joinhocus.horus.misc.CompletableFutures; 15 | import com.joinhocus.horus.misc.HandlesUtil; 16 | import com.joinhocus.horus.misc.MongoIds; 17 | import com.joinhocus.horus.misc.follow.EntityType; 18 | import com.joinhocus.horus.organization.Organization; 19 | import com.joinhocus.horus.post.Post; 20 | import com.joinhocus.horus.post.impl.BasicPost; 21 | import io.javalin.core.validation.BodyValidator; 22 | import io.javalin.http.Context; 23 | import org.bson.Document; 24 | import org.bson.types.ObjectId; 25 | import org.slf4j.Logger; 26 | 27 | import java.util.ArrayList; 28 | import java.util.Collections; 29 | import java.util.Date; 30 | import java.util.List; 31 | import java.util.Objects; 32 | import java.util.concurrent.CompletableFuture; 33 | import java.util.regex.Matcher; 34 | 35 | public class CreatePostHandler implements DefinedTypesWithUserHandler { 36 | @Override 37 | public CompletableFuture handle(UserAccount account, BodyValidator validator, Context context, Logger logger) throws Exception { 38 | CreatePostRequest request = validator 39 | .check("content", req -> !Strings.isNullOrEmpty(req.getContent()), "content cannot be empty") 40 | .check("content", req -> req.getContent().trim().length() <= 240, "content must be 240 characters at max") 41 | .check("organizationId", req -> !Strings.isNullOrEmpty(req.getOrganizationId()), "organizationId cannot be empty") 42 | .check("type", req -> !Objects.isNull(req.getType()), "type cannot be empty") 43 | .check("privacy", req -> !Objects.isNull(req.getPrivacy()), "privacy cannot be empty") 44 | .get(); 45 | 46 | ObjectId organizationId = MongoIds.parseId(request.getOrganizationId()); 47 | if (organizationId == null) { 48 | return wrap(Response.of(Response.Type.BAD_REQUEST).setMessage("organizationId was not valid")); 49 | } 50 | 51 | return runPipeline(request, account, organizationId); 52 | } 53 | 54 | private CompletableFuture runPipeline(CreatePostRequest request, UserAccount account, ObjectId organizationId) { 55 | return MongoDatabase.getInstance().getRepo(OrganizationRepo.class) 56 | .isMemberOf(organizationId, account.getId()) 57 | .thenCompose(isMember -> { 58 | if (!isMember) { 59 | return wrap(Response.of(Response.Type.UNAUTHORIZED)); 60 | } 61 | return runOrganization(request, account, organizationId); 62 | }); 63 | } 64 | 65 | private CompletableFuture runOrganization(CreatePostRequest request, UserAccount account, ObjectId organization) { 66 | return MongoDatabase.getInstance().getRepo(OrganizationRepo.class) 67 | .getById(organization) 68 | .thenCompose(org -> { 69 | if (org == null) { 70 | return wrap(Response.of(Response.Type.BAD_REQUEST)); 71 | } 72 | 73 | return createPost(request, account, org); 74 | }); 75 | } 76 | 77 | private CompletableFuture createPost(CreatePostRequest request, UserAccount account, Organization organization) { 78 | Post post = new BasicPost( 79 | new ObjectId(), 80 | request.getType(), 81 | request.getPrivacy(), 82 | request.getContent(), 83 | account, 84 | organization, 85 | new Date(), 86 | null, 87 | Collections.emptyList() 88 | ); 89 | 90 | return MongoDatabase.getInstance().getRepo(PostsRepo.class).createPost(post).thenCompose(id -> { 91 | if (id != null) { 92 | return parseMentions(post); 93 | } 94 | 95 | return CompletableFutures.failedFuture(new IllegalStateException("Failed to create post")); 96 | }); 97 | } 98 | 99 | private CompletableFuture parseMentions(Post post) { 100 | String content = post.content(); 101 | Matcher matcher = HandlesUtil.TAG_PATTERN.matcher(content); 102 | List handles = new ArrayList<>(); 103 | while (matcher.find()) { 104 | String match = matcher.group(); 105 | match = match.replace("@", "").replace("[", "").replace("]", ""); 106 | handles.add(match.toLowerCase()); 107 | } 108 | 109 | return HandlesUtil.getEntitiesBy(handles).thenCompose(entities -> { 110 | if (post.organization() != null) { 111 | entities.remove(post.organization().getId()); 112 | } 113 | entities.remove(post.author().getId()); 114 | 115 | ActivityNotification notification = new ActivityNotification( 116 | new ObjectId(), 117 | post.author().getId(), 118 | EntityType.ACCOUNT, 119 | entities, 120 | new Date(), 121 | NotificationType.MENTION, 122 | new Document() 123 | .append("postId", post.getId().toHexString()) 124 | .append("post", post.content()) 125 | ); 126 | 127 | return MongoDatabase.getInstance().getRepo(ActivityRepo.class).insertNotification(notification); 128 | }).thenApply(ignored -> { 129 | return Response.of(Response.Type.OKAY).append("id", post.getId().toHexString()); 130 | }); 131 | } 132 | 133 | @Override 134 | public Class requestClass() { 135 | return CreatePostRequest.class; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/main/java/com/joinhocus/horus/db/repos/OrganizationRepo.java: -------------------------------------------------------------------------------- 1 | package com.joinhocus.horus.db.repos; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import com.google.common.collect.Lists; 5 | import com.google.gson.JsonObject; 6 | import com.joinhocus.horus.account.UserAccount; 7 | import com.joinhocus.horus.db.AsyncMongoRepo; 8 | import com.joinhocus.horus.db.MongoDatabase; 9 | import com.joinhocus.horus.misc.CompletableFutures; 10 | import com.joinhocus.horus.misc.Spaces; 11 | import com.joinhocus.horus.misc.follow.Followable; 12 | import com.joinhocus.horus.organization.Organization; 13 | import com.joinhocus.horus.organization.OrganizationRole; 14 | import com.joinhocus.horus.organization.SimpleOrganization; 15 | import com.mongodb.client.model.Filters; 16 | import com.mongodb.client.model.IndexModel; 17 | import com.mongodb.client.model.Indexes; 18 | import com.mongodb.client.model.Projections; 19 | import com.mongodb.client.model.Updates; 20 | import io.javalin.http.UploadedFile; 21 | import org.bson.Document; 22 | import org.bson.types.ObjectId; 23 | 24 | import java.util.ArrayList; 25 | import java.util.List; 26 | import java.util.Objects; 27 | import java.util.concurrent.CompletableFuture; 28 | import java.util.concurrent.ForkJoinPool; 29 | import java.util.regex.Pattern; 30 | 31 | public class OrganizationRepo extends AsyncMongoRepo { 32 | public OrganizationRepo() { 33 | super("hocus", "organizations"); 34 | } 35 | 36 | @Override 37 | public void postBoot() { 38 | createIndexes(Lists.newArrayList( 39 | new IndexModel(Indexes.ascending("name.handle"), INDEX_BACKGROUND), 40 | new IndexModel(Indexes.ascending("seats.id"), INDEX_BACKGROUND) 41 | )); 42 | } 43 | 44 | public CompletableFuture createOrganization(Organization organization, UserAccount creator) { 45 | Document name = new Document() 46 | .append("display", organization.getName()) 47 | .append("handle", organization.getHandle()); 48 | 49 | List seats = new ArrayList<>(); 50 | seats.add(new Document(). 51 | append("id", creator.getId()). 52 | append("role", "CREATOR") 53 | ); 54 | 55 | Document out = new Document() 56 | .append("name", name) 57 | .append("settings", new Document()) 58 | .append("creator", creator.getId()) 59 | .append("createdAt", System.currentTimeMillis()) 60 | .append("seats", seats); 61 | 62 | return this.insertOne(out); 63 | } 64 | 65 | public CompletableFuture isHandleAvailable(String handle) { 66 | return this.checkExists(Filters.eq("name.handle", handle.toLowerCase())); 67 | } 68 | 69 | public CompletableFuture setOrganizationLogo(ObjectId organizationId, UploadedFile file) { 70 | CompletableFuture put = new CompletableFuture<>(); 71 | ForkJoinPool.commonPool().submit(() -> { 72 | try { 73 | String url = Spaces.uploadImage(file, "orgLogos", organizationId.toHexString()); 74 | put.complete(url); 75 | } catch (Exception e) { 76 | put.completeExceptionally(e); 77 | } 78 | }); 79 | 80 | return put.thenCompose(url -> { 81 | return this.updateOne(Filters.eq("_id", organizationId), Updates.set("settings.logo", url)); 82 | }); 83 | } 84 | 85 | public CompletableFuture getById(ObjectId objectId) { 86 | return this.findFirst(Filters.eq("_id", objectId), SimpleOrganization::new); 87 | } 88 | 89 | public CompletableFuture> getByIds(List ids) { 90 | return this.find(Filters.in("_id", ids), null, SimpleOrganization::new); 91 | } 92 | 93 | public CompletableFuture getByHandle(String handle) { 94 | return this.findFirst(Filters.eq("name.handle", handle), SimpleOrganization::new); 95 | } 96 | 97 | public CompletableFuture isMemberOf(ObjectId organizationId, ObjectId account) { 98 | return this.findFirst(Filters.and( 99 | Filters.eq("_id", organizationId), 100 | Filters.elemMatch("seats", Filters.eq("id", account)) 101 | ), Objects::nonNull); 102 | } 103 | 104 | public CompletableFuture> getOrganizations(ObjectId member) { 105 | return this.find( 106 | Filters.elemMatch("seats", Filters.eq("id", member)), 107 | Projections.exclude("seats"), 108 | SimpleOrganization::new 109 | ); 110 | } 111 | 112 | public CompletableFuture> getFeedOrganizations(Followable member) { 113 | return CompletableFutures.combine( 114 | getOrganizations(member.getId()), 115 | MongoDatabase.getInstance().getRepo(FollowsRepo.class).getFollowedOrganizations(member), 116 | (resA, resB) -> ImmutableList.builder() 117 | .addAll(resA) 118 | .addAll(resB) 119 | .build() 120 | ).toCompletableFuture(); 121 | } 122 | 123 | public CompletableFuture> searchByHandle(String username) { 124 | Pattern pattern = Pattern.compile("^" + Pattern.quote(username), Pattern.CASE_INSENSITIVE); 125 | return this.find(Filters.regex("name.handle", pattern), Projections.fields( 126 | Projections.excludeId(), 127 | Projections.include("name"), 128 | Projections.include("settings.logo") 129 | ), doc -> { 130 | JsonObject object = new JsonObject(); 131 | Document name = doc.get("name", Document.class); 132 | Document settings = doc.get("settings", Document.class); 133 | object.addProperty("handle", name.getString("handle")); 134 | object.addProperty("name", name.getString("display")); 135 | object.addProperty("avatar", settings.getString("logo")); 136 | return object; 137 | }); 138 | } 139 | 140 | public CompletableFuture> getAllByHandles(List handles) { 141 | return this.find(Filters.in("name.handle", handles), null, doc -> doc.getObjectId("_id")); 142 | } 143 | 144 | public CompletableFuture addSeat(ObjectId organizationId, UserAccount account, OrganizationRole role) { 145 | return this.updateOne(Filters.eq("_id", organizationId), Updates.addToSet("seats", new Document() 146 | .append("id", account.getId()) 147 | .append("role", role.name()) 148 | )).thenApply(ignored -> null); 149 | } 150 | } 151 | --------------------------------------------------------------------------------