Add dashboard

1) Move cache polling out of StatusController.

2) Simplify view logic.

3) Make recent transaction json output work.

4) Add dashboard view.
This commit is contained in:
Moxie Marlinspike 2014-07-15 19:33:09 -07:00
parent e437de4f15
commit f1cc87dcea
28 changed files with 860 additions and 366 deletions

View File

@ -1 +1 @@
web: java $JAVA_OPTS -Ddw.http.port=$PORT -Ddw.http.adminPort=$PORT -Ddw.github.user=$GITHUB_USER -Ddw.github.token=$GITHUB_TOKEN -Ddw.github.repositories_heroku="$GITHUB_REPOSITORIES" -Ddw.coinbase.apiKey=$COINBASE_API_KEY -Ddw.github.webhook.password=$GITHUB_WEBHOOK_PASSWORD -jar target/BitHub-0.1.jar server
web: java $JAVA_OPTS -Ddw.server.type=simple -Ddw.server.applicationContextPath=/ -Ddw.server.connector.type=http -Ddw.server.connector.port=$PORT -Ddw.github.user=$GITHUB_USER -Ddw.github.token=$GITHUB_TOKEN -Ddw.github.repositories_heroku="$GITHUB_REPOSITORIES" -Ddw.coinbase.apiKey=$COINBASE_API_KEY -Ddw.github.webhook.password=$GITHUB_WEBHOOK_PASSWORD -Ddw.organization.name="$ORGANIZATION_NAME" -Ddw.organization.donationUrl=$DONATION_URL -jar target/BitHub-0.1.jar server

View File

@ -38,6 +38,8 @@ $ heroku config:set GITHUB_TOKEN=your_bithub_authtoken
$ heroku config:set GITHUB_WEBHOOK_PASSWORD=your_webhook_password
$ heroku config:set GITHUB_REPOSITORIES="[{\"url\" : \"https://github.com/youraccount/yourrepo\"}, {\"url\" : \"https://github.com/youraccount/yourotherrepo\"}]"
$ heroku config:set COINBASE_API_KEY=your_api_key
$ heroku config:set ORGANIZATION_NAME=your_organization_name
$ heroku config:set DONATION_URL=your_donation_url
$ git remote add your_heroku_remote
$ git push heroku master
```

View File

@ -1,8 +1,14 @@
organization:
name: # Your name (eg. Open Whisper Systems)
donationUrl: # A Coinbase link where you can receive donations (eg. https://coinbase.com/checkouts/d29fd4c37ca442393e32fdcb95304701)
github:
user: # Your BitHub instance's GitHub username.
token: # Your BitHub instance's GitHub auth token.
webhook:
password: # HTTP basic auth. The username defaults to "bithub".
repositories: # A list of repository URLs to support payouts for.
- url: # A repository's URL
mode: # Either MONEYMONEY (default) or FREEBIE.

View File

@ -56,6 +56,11 @@
<artifactId>dropwizard-views</artifactId>
<version>${dropwizard.version}</version>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-views-mustache</artifactId>
<version>${dropwizard.version}</version>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-servlets</artifactId>

View File

@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import org.whispersystems.bithub.config.BithubConfiguration;
import org.whispersystems.bithub.config.CoinbaseConfiguration;
import org.whispersystems.bithub.config.GithubConfiguration;
import org.whispersystems.bithub.config.OrganizationConfiguration;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
@ -43,6 +44,11 @@ public class BithubServerConfiguration extends Configuration {
@Valid
private BithubConfiguration bithub = new BithubConfiguration();
@Valid
@NotNull
@JsonProperty
private OrganizationConfiguration organization;
public GithubConfiguration getGithubConfiguration() {
return github;
@ -56,4 +62,7 @@ public class BithubServerConfiguration extends Configuration {
return bithub;
}
public OrganizationConfiguration getOrganizationConfiguration() {
return organization;
}
}

View File

@ -22,10 +22,12 @@ import org.whispersystems.bithub.auth.GithubWebhookAuthenticator;
import org.whispersystems.bithub.client.CoinbaseClient;
import org.whispersystems.bithub.client.GithubClient;
import org.whispersystems.bithub.config.RepositoryConfiguration;
import org.whispersystems.bithub.controllers.DashboardController;
import org.whispersystems.bithub.controllers.GithubController;
import org.whispersystems.bithub.controllers.StatusController;
import org.whispersystems.bithub.mappers.IOExceptionMapper;
import org.whispersystems.bithub.mappers.UnauthorizedHookExceptionMapper;
import org.whispersystems.bithub.storage.CacheManager;
import javax.servlet.DispatcherType;
import java.math.BigDecimal;
@ -60,14 +62,22 @@ public class BithubService extends Application<BithubServerConfiguration> {
String githubWebhookPwd = config.getGithubConfiguration().getWebhookConfiguration().getPassword();
List<RepositoryConfiguration> githubRepositories = config.getGithubConfiguration().getRepositories();
BigDecimal payoutRate = config.getBithubConfiguration().getPayoutRate();
GithubClient githubClient = new GithubClient(githubUser, githubToken);
CoinbaseClient coinbaseClient = new CoinbaseClient(config.getCoinbaseConfiguration().getApiKey());
String organizationName = config.getOrganizationConfiguration().getName();
String donationUrl = config.getOrganizationConfiguration().getDonationUrl().toExternalForm();
GithubClient githubClient = new GithubClient(githubUser, githubToken);
CoinbaseClient coinbaseClient = new CoinbaseClient(config.getCoinbaseConfiguration().getApiKey());
CacheManager cacheManager = new CacheManager(coinbaseClient, githubClient, githubRepositories, payoutRate);
environment.servlets().addFilter("CORS", CrossOriginFilter.class)
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/*");
environment.lifecycle().manage(cacheManager);
environment.jersey().register(new GithubController(githubRepositories, githubClient, coinbaseClient, payoutRate));
environment.jersey().register(new StatusController(coinbaseClient, payoutRate));
environment.jersey().register(new StatusController(cacheManager, githubRepositories));
environment.jersey().register(new DashboardController(organizationName, donationUrl, cacheManager));
environment.jersey().register(new IOExceptionMapper());
environment.jersey().register(new UnauthorizedHookExceptionMapper());
environment.jersey().register(new BasicAuthProvider<>(new GithubWebhookAuthenticator(githubWebhookUser, githubWebhookPwd),

View File

@ -30,8 +30,8 @@ import org.whispersystems.bithub.entities.BalanceResponse;
import org.whispersystems.bithub.entities.BitcoinTransaction;
import org.whispersystems.bithub.entities.BitcoinTransactionResponse;
import org.whispersystems.bithub.entities.ExchangeRate;
import org.whispersystems.bithub.entities.RecentTransactionsResponse;
import org.whispersystems.bithub.entities.Transaction;
import org.whispersystems.bithub.entities.CoinbseRecentTransactionsResponse;
import org.whispersystems.bithub.entities.CoinbaseTransaction;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
@ -59,12 +59,12 @@ public class CoinbaseClient {
this.client = Client.create(getClientConfig());
}
public List<Transaction> getRecentTransactions() throws IOException {
public List<CoinbaseTransaction> getRecentTransactions() throws IOException {
try {
return client.resource(COINBASE_URL)
.path(RECENT_TRANSACTIONS_PATH)
.queryParam("api_key", apiKey)
.get(RecentTransactionsResponse.class).getTransactions();
.get(CoinbseRecentTransactionsResponse.class).getTransactions();
} catch (UniformInterfaceException | ClientHandlerException e) {
throw new IOException(e);
}

View File

@ -42,8 +42,10 @@ import javax.ws.rs.core.MediaType;
*/
public class GithubClient {
private static final String GITHUB_URL = "https://api.github.com/";
private static final String COMMENT_PATH = "/repos/%s/%s/commits/%s/comments";
private static final String GITHUB_URL = "https://api.github.com/";
private static final String COMMENT_PATH = "/repos/%s/%s/commits/%s/comments";
private static final String COMMIT_PATH = "/repos/%s/%s/git/commits/%s";
private static final String REPOSITORY_PATH = "/repos/%s/%s";
private final Logger logger = LoggerFactory.getLogger(GithubClient.class);
@ -55,6 +57,37 @@ public class GithubClient {
this.client = Client.create(getClientConfig());
}
public String getCommitDescription(String commitUrl) {
String[] commitUrlParts = commitUrl.split("/");
String owner = commitUrlParts[commitUrlParts.length - 4];
String repository = commitUrlParts[commitUrlParts.length - 3];
String commit = commitUrlParts[commitUrlParts.length - 1];
String path = String.format(COMMIT_PATH, owner, repository, commit);
WebResource resource = client.resource(GITHUB_URL).path(path);
Commit response = resource.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.header("Authorization", authorizationHeader)
.get(Commit.class);
return response.getMessage();
}
public Repository getRepository(String url) {
String[] urlParts = url.split("/");
String owner = urlParts[urlParts.length - 2];
String name = urlParts[urlParts.length - 1];
String path = String.format(REPOSITORY_PATH, owner, name);
WebResource resource = client.resource(GITHUB_URL).path(path);
return resource.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.header("Authorization", authorizationHeader)
.get(Repository.class);
}
public void addCommitComment(Repository repository, Commit commit, String comment) {
try {
String path = String.format(COMMENT_PATH, repository.getOwner().getName(),

View File

@ -0,0 +1,26 @@
package org.whispersystems.bithub.config;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.Valid;
import java.net.URL;
public class OrganizationConfiguration {
@JsonProperty
@NotEmpty
private String name;
@JsonProperty
@Valid
private URL donationUrl;
public String getName() {
return name;
}
public URL getDonationUrl() {
return donationUrl;
}
}

View File

@ -0,0 +1,39 @@
package org.whispersystems.bithub.controllers;
import com.codahale.metrics.annotation.Timed;
import org.whispersystems.bithub.storage.CacheManager;
import org.whispersystems.bithub.views.DashboardView;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
@Path("/")
public class DashboardController {
private final CacheManager cacheManager;
private final String organizationName;
private final String donationUrl;
public DashboardController(String organizationName, String donationUrl,
CacheManager cacheManager)
{
this.organizationName = organizationName;
this.donationUrl = donationUrl;
this.cacheManager = cacheManager;
}
@Timed
@GET
@Produces(MediaType.TEXT_HTML)
public DashboardView getDashboard() {
return new DashboardView(organizationName, donationUrl,
cacheManager.getCurrentPaymentAmount(),
cacheManager.getRepositories(),
cacheManager.getRecentTransactions());
}
}

View File

@ -20,28 +20,27 @@ package org.whispersystems.bithub.controllers;
import com.codahale.metrics.annotation.Timed;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.bithub.client.CoinbaseClient;
import org.whispersystems.bithub.entities.Payment;
import org.whispersystems.bithub.config.RepositoryConfiguration;
import org.whispersystems.bithub.entities.Repositories;
import org.whispersystems.bithub.entities.Repository;
import org.whispersystems.bithub.entities.Transaction;
import org.whispersystems.bithub.util.Badge;
import org.whispersystems.bithub.views.RecentTransactionsView;
import org.whispersystems.bithub.entities.Transactions;
import org.whispersystems.bithub.storage.CacheManager;
import org.whispersystems.bithub.storage.CurrentPayment;
import org.whispersystems.bithub.views.TransactionsView;
import javax.ws.rs.Consumes;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.CacheControl;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import io.dropwizard.jersey.caching.CacheControl;
/**
* Handles incoming API calls for BitHub instance status information.
@ -51,123 +50,65 @@ import java.util.concurrent.atomic.AtomicReference;
@Path("/v1/status")
public class StatusController {
private static final int UPDATE_FREQUENCY_MILLIS = 60 * 1000;
private final Logger logger = LoggerFactory.getLogger(StatusController.class);
private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
private final Logger logger = LoggerFactory.getLogger(StatusController.class);
private final List<RepositoryConfiguration> repositoryConfiguration;
private final CacheManager coinbaseManager;
private final AtomicReference<CurrentPayment> cachedPaymentStatus;
private final AtomicReference<RecentTransactionsView> cachedTransactions;
private final BigDecimal payoutRate;
public StatusController(CoinbaseClient coinbaseClient, BigDecimal payoutRate) throws IOException {
this.payoutRate = payoutRate;
this.cachedPaymentStatus = new AtomicReference<>(createCurrentPaymentForBalance(coinbaseClient));
this.cachedTransactions = new AtomicReference<>(createRecentTransactionsView(coinbaseClient));
initializeUpdates(coinbaseClient);
public StatusController(CacheManager coinbaseManager,
List<RepositoryConfiguration> repositoryConfiguration)
throws IOException
{
this.coinbaseManager = coinbaseManager;
this.repositoryConfiguration = repositoryConfiguration;
}
@Timed
@GET
@Path("/transactions")
@Consumes({MediaType.APPLICATION_JSON, MediaType.TEXT_HTML})
public RecentTransactionsView getTransactions()
throws IOException
public Response getTransactions(@QueryParam("format") @DefaultValue("html") String format)
throws IOException
{
return cachedTransactions.get();
List<Transaction> recentTransactions = coinbaseManager.getRecentTransactions();
switch (format) {
case "html": return Response.ok(new TransactionsView(recentTransactions), MediaType.TEXT_HTML_TYPE).build();
case "json":
default: return Response.ok(new Transactions(recentTransactions), MediaType.APPLICATION_JSON_TYPE).build();
}
}
@Timed
@GET
@Path("/repositories")
@Produces(MediaType.APPLICATION_JSON)
public Repositories getRepositories() {
List<Repository> repositories = new LinkedList<>();
for (RepositoryConfiguration configuration : repositoryConfiguration) {
repositories.add(new Repository(configuration.getUrl()));
}
return new Repositories(repositories);
}
@Timed
@GET
@Path("/payment/commit")
@CacheControl(noCache = true)
public Response getCurrentCommitPrice(@QueryParam("format") @DefaultValue("png") String format)
throws IOException
{
CacheControl cacheControl = new CacheControl();
cacheControl.setNoCache(true);
CurrentPayment currentPayment = coinbaseManager.getCurrentPaymentAmount();
switch (format) {
case "json":
return Response.ok(cachedPaymentStatus.get().getEntity(), MediaType.APPLICATION_JSON_TYPE).cacheControl(cacheControl).build();
return Response.ok(currentPayment.getEntity(), MediaType.APPLICATION_JSON_TYPE).build();
case "png_small":
return Response.ok(cachedPaymentStatus.get().getSmallBadge(), "image/png").cacheControl(cacheControl).build();
return Response.ok(currentPayment.getSmallBadge(), "image/png").build();
default:
return Response.ok(cachedPaymentStatus.get().getBadge(), "image/png").cacheControl(cacheControl).build();
return Response.ok(currentPayment.getBadge(), "image/png").build();
}
}
private CurrentPayment createCurrentPaymentForBalance(CoinbaseClient coinbaseClient)
throws IOException
{
BigDecimal currentBalance = coinbaseClient.getAccountBalance();
BigDecimal paymentBtc = currentBalance.multiply(payoutRate);
BigDecimal exchangeRate = coinbaseClient.getExchangeRate();
BigDecimal paymentUsd = paymentBtc.multiply(exchangeRate);
paymentUsd = paymentUsd.setScale(2, RoundingMode.CEILING);
return new CurrentPayment(Badge.createFor(paymentUsd.toPlainString()),
Badge.createSmallFor(paymentUsd.toPlainString()),
new Payment(paymentUsd.toPlainString()));
}
private RecentTransactionsView createRecentTransactionsView(CoinbaseClient coinbaseClient)
throws IOException
{
List<Transaction> recentTransactions = coinbaseClient.getRecentTransactions();
BigDecimal exchangeRate = coinbaseClient.getExchangeRate();
return new RecentTransactionsView(recentTransactions, exchangeRate);
}
public void initializeUpdates(final CoinbaseClient coinbaseClient) {
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
try {
CurrentPayment currentPayment = createCurrentPaymentForBalance(coinbaseClient);
cachedPaymentStatus.set(currentPayment);
} catch (IOException e) {
logger.warn("Failed to update badge", e);
}
}
}, UPDATE_FREQUENCY_MILLIS, UPDATE_FREQUENCY_MILLIS, TimeUnit.MILLISECONDS);
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
try {
RecentTransactionsView view = createRecentTransactionsView(coinbaseClient);
cachedTransactions.set(view);
} catch (IOException e) {
logger.warn("Failed to update recent transactions", e);
}
}
}, UPDATE_FREQUENCY_MILLIS, UPDATE_FREQUENCY_MILLIS, TimeUnit.MILLISECONDS);
}
private class CurrentPayment {
private final byte[] badge;
private final byte[] smallBadge;
private final Payment entity;
private CurrentPayment(byte[] badge, byte[] smallBadge, Payment entity) {
this.badge = badge;
this.smallBadge = smallBadge;
this.entity = entity;
}
private byte[] getBadge() {
return badge;
}
private byte[] getSmallBadge() {
return smallBadge;
}
private Payment getEntity() {
return entity;
}
}
}

View File

@ -0,0 +1,65 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.whispersystems.bithub.entities;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;
@JsonIgnoreProperties(ignoreUnknown = true)
public class CoinbaseTransaction {
@JsonProperty(value = "created_at")
@NotEmpty
private String createdTime;
@JsonProperty
@NotNull
private Amount amount;
@JsonProperty(value = "recipient_address")
private String recipientAddress;
@JsonProperty
private String notes;
public String getCreatedTime() {
return createdTime;
}
public String getAmount() {
return amount.getAmount();
}
public String getRecipientAddress() {
return recipientAddress;
}
public String getNotes() {
return notes;
}
public boolean isSentTransaction() {
BigDecimal amount = new BigDecimal(getAmount());
return amount.compareTo(new BigDecimal(0.0)) < 0;
}
}

View File

@ -23,12 +23,12 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import javax.validation.constraints.NotNull;
@JsonIgnoreProperties(ignoreUnknown = true)
public class TransactionWrapper {
public class CoinbaseTransactionWrapper {
@JsonProperty
@NotNull
private Transaction transaction;
private CoinbaseTransaction transaction;
public Transaction getTransaction() {
public CoinbaseTransaction getTransaction() {
return transaction;
}
}

View File

@ -25,16 +25,16 @@ import java.util.LinkedList;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public class RecentTransactionsResponse {
public class CoinbseRecentTransactionsResponse {
@JsonProperty
@NotNull
private List<TransactionWrapper> transactions;
private List<CoinbaseTransactionWrapper> transactions;
public List<Transaction> getTransactions() {
List<Transaction> rawTransactions = new LinkedList<Transaction>();
public List<CoinbaseTransaction> getTransactions() {
List<CoinbaseTransaction> rawTransactions = new LinkedList<CoinbaseTransaction>();
for (TransactionWrapper transactionWrapper : transactions) {
for (CoinbaseTransactionWrapper transactionWrapper : transactions) {
rawTransactions.add(transactionWrapper.getTransaction());
}

View File

@ -26,4 +26,8 @@ public class Payment {
public Payment(String payment) {
this.payment = payment;
}
public String getPayment() {
return payment;
}
}

View File

@ -0,0 +1,15 @@
package org.whispersystems.bithub.entities;
import java.util.List;
public class Repositories {
public List<Repository> repositories;
public Repositories() {}
public Repositories(List<Repository> repositories) {
this.repositories = repositories;
}
}

View File

@ -38,6 +38,15 @@ public class Repository {
@NotEmpty
private String name;
@JsonProperty
private String description;
public Repository() {}
public Repository(String url) {
this.url = url;
}
public Author getOwner() {
return owner;
}
@ -49,4 +58,8 @@ public class Repository {
public String getUrl() {
return url;
}
public String getDescription() {
return description;
}
}

View File

@ -1,58 +1,62 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.whispersystems.bithub.entities;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
@JsonIgnoreProperties(ignoreUnknown = true)
public class Transaction {
@JsonProperty(value = "created_at")
@NotEmpty
private String createdTime;
@JsonProperty
private String destination;
@JsonProperty
@NotNull
private Amount amount;
@JsonProperty(value = "recipient_address")
private String recipientAddress;
private String amount;
@JsonProperty
private String notes;
private String commitUrl;
public String getCreatedTime() {
return createdTime;
@JsonProperty
private String commitSha;
@JsonProperty
private String timestamp;
@JsonProperty
private String description;
public Transaction() {}
public Transaction(String destination, String amount, String commitUrl,
String commitSha, String timestamp, String description)
{
this.destination = destination;
this.amount = amount;
this.commitUrl = commitUrl;
this.commitSha = commitSha;
this.timestamp = timestamp;
this.description = description;
}
public String getDestination() {
return destination;
}
public String getAmount() {
return amount.getAmount();
return amount;
}
public String getRecipientAddress() {
return recipientAddress;
public String getCommitUrl() {
return commitUrl;
}
public String getNotes() {
return notes;
public String getCommitSha() {
return commitSha;
}
public String getTimestamp() {
return timestamp;
}
public String getDescription() {
return description;
}
}

View File

@ -0,0 +1,19 @@
package org.whispersystems.bithub.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class Transactions {
@JsonProperty
private List<Transaction> transactions;
public Transactions() {}
public Transactions(List<Transaction> transactions) {
this.transactions = transactions;
}
}

View File

@ -1,40 +0,0 @@
package org.whispersystems.bithub.filters;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class CorsHeaderFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException
{
if (response instanceof HttpServletResponse) {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
httpServletResponse.addHeader("Access-Control-Allow-Origin", "*");
if ("OPTIONS".equals(httpServletRequest.getMethod())) {
httpServletResponse.addHeader("Access-Control-Allow-Headers", "Content-Type");
}
}
chain.doFilter(request, response);
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
}

View File

@ -0,0 +1,160 @@
package org.whispersystems.bithub.storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.bithub.client.CoinbaseClient;
import org.whispersystems.bithub.client.GithubClient;
import org.whispersystems.bithub.config.RepositoryConfiguration;
import org.whispersystems.bithub.entities.CoinbaseTransaction;
import org.whispersystems.bithub.entities.Payment;
import org.whispersystems.bithub.entities.Repository;
import org.whispersystems.bithub.entities.Transaction;
import org.whispersystems.bithub.util.Badge;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.ParseException;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import io.dropwizard.lifecycle.Managed;
public class CacheManager implements Managed {
private static final int UPDATE_FREQUENCY_MILLIS = 60 * 1000;
private final Logger logger = LoggerFactory.getLogger(CacheManager.class);
private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
private final CoinbaseClient coinbaseClient;
private final GithubClient githubClient;
private final BigDecimal payoutRate;
private final List<RepositoryConfiguration> repositories;
private AtomicReference<CurrentPayment> cachedPaymentStatus;
private AtomicReference<List<Transaction>> cachedTransactions;
private AtomicReference<List<Repository>> cachedRepositories;
public CacheManager(CoinbaseClient coinbaseClient,
GithubClient githubClient,
List<RepositoryConfiguration> repositories,
BigDecimal payoutRate)
{
this.coinbaseClient = coinbaseClient;
this.githubClient = githubClient;
this.payoutRate = payoutRate;
this.repositories = repositories;
}
@Override
public void start() throws Exception {
this.cachedPaymentStatus = new AtomicReference<>(createCurrentPaymentForBalance(coinbaseClient));
this.cachedTransactions = new AtomicReference<>(createRecentTransactions(coinbaseClient));
this.cachedRepositories = new AtomicReference<>(createRepositories(githubClient, repositories));
initializeUpdates(coinbaseClient, githubClient, repositories);
}
@Override
public void stop() throws Exception {
this.executor.shutdownNow();
}
public List<Transaction> getRecentTransactions() {
return cachedTransactions.get();
}
public CurrentPayment getCurrentPaymentAmount() {
return cachedPaymentStatus.get();
}
public List<Repository> getRepositories() {
return cachedRepositories.get();
}
public void initializeUpdates(final CoinbaseClient coinbaseClient,
final GithubClient githubClient,
final List<RepositoryConfiguration> repoConfigs)
{
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
try {
CurrentPayment currentPayment = createCurrentPaymentForBalance(coinbaseClient);
List<Transaction> transactions = createRecentTransactions (coinbaseClient);
List<Repository> repositories = createRepositories(githubClient, repoConfigs);
cachedPaymentStatus.set(currentPayment);
cachedTransactions.set(transactions);
cachedRepositories.set(repositories);
} catch (IOException e) {
logger.warn("Failed to update badge", e);
}
}
}, UPDATE_FREQUENCY_MILLIS, UPDATE_FREQUENCY_MILLIS, TimeUnit.MILLISECONDS);
}
private List<Repository> createRepositories(GithubClient githubClient,
List<RepositoryConfiguration> configured)
{
List<Repository> repositoryList = new LinkedList<>();
for (RepositoryConfiguration repository : configured) {
repositoryList.add(githubClient.getRepository(repository.getUrl()));
}
return repositoryList;
}
private CurrentPayment createCurrentPaymentForBalance(CoinbaseClient coinbaseClient)
throws IOException
{
BigDecimal currentBalance = coinbaseClient.getAccountBalance();
BigDecimal paymentBtc = currentBalance.multiply(payoutRate);
BigDecimal exchangeRate = coinbaseClient.getExchangeRate();
BigDecimal paymentUsd = paymentBtc.multiply(exchangeRate);
paymentUsd = paymentUsd.setScale(2, RoundingMode.CEILING);
return new CurrentPayment(Badge.createFor(paymentUsd.toPlainString()),
Badge.createSmallFor(paymentUsd.toPlainString()),
new Payment(paymentUsd.toPlainString()));
}
private List<Transaction> createRecentTransactions(CoinbaseClient coinbaseClient)
throws IOException
{
List<CoinbaseTransaction> recentTransactions = coinbaseClient.getRecentTransactions();
BigDecimal exchangeRate = coinbaseClient.getExchangeRate();
List<Transaction> transactions = new LinkedList<>();
for (CoinbaseTransaction coinbaseTransaction : recentTransactions) {
try {
if (coinbaseTransaction.isSentTransaction()) {
CoinbaseTransactionParser parser = new CoinbaseTransactionParser(coinbaseTransaction);
String url = parser.parseUrlFromMessage();
String sha = parser.parseShaFromUrl(url);
String description = githubClient.getCommitDescription(url);
transactions.add(new Transaction(parser.parseDestinationFromMessage(),
parser.parseAmountInDollars(exchangeRate),
url, sha, parser.parseTimestamp(),
description));
if (transactions.size() >= 10)
break;
}
} catch (ParseException e) {
logger.warn("Parse", e);
}
}
return transactions;
}
}

View File

@ -0,0 +1,73 @@
package org.whispersystems.bithub.storage;
import org.apache.commons.lang3.StringEscapeUtils;
import org.ocpsoft.prettytime.PrettyTime;
import org.whispersystems.bithub.entities.CoinbaseTransaction;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.ParseException;
import java.text.SimpleDateFormat;
public class CoinbaseTransactionParser {
private final CoinbaseTransaction coinbaseTransaction;
public CoinbaseTransactionParser(CoinbaseTransaction coinbaseTransaction) {
this.coinbaseTransaction = coinbaseTransaction;
}
public String parseAmountInDollars(BigDecimal exchangeRate) {
return new BigDecimal(coinbaseTransaction.getAmount()).abs()
.multiply(exchangeRate)
.setScale(2, RoundingMode.CEILING)
.toPlainString();
}
public String parseTimestamp() throws ParseException {
String timestamp = coinbaseTransaction.getCreatedTime();
int offendingColon = timestamp.lastIndexOf(':');
String fixedTimestamp = timestamp.substring(0, offendingColon) + timestamp.substring(offendingColon + 1);
return new PrettyTime().format(new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(fixedTimestamp));
}
public String parseDestinationFromMessage() {
String message = StringEscapeUtils.unescapeHtml4(coinbaseTransaction.getNotes());
int startToken = message.indexOf("__");
if (startToken == -1) {
return "Unknown";
}
int endToken = message.indexOf("__", startToken + 1);
if (endToken == -1) {
return "Unknown";
}
return message.substring(startToken+2, endToken);
}
public String parseUrlFromMessage() throws ParseException {
String message = StringEscapeUtils.unescapeHtml4(coinbaseTransaction.getNotes());
int urlIndex = message.indexOf("https://");
return message.substring(urlIndex).trim();
}
public String parseShaFromUrl(String url) throws ParseException {
if (url == null) {
throw new ParseException("No url", 0);
}
String[] parts = url.split("/");
String fullHash = parts[parts.length-1];
if (fullHash.length() < 8) {
throw new ParseException("Not long enough", 0);
}
return fullHash.substring(0, 8);
}
}

View File

@ -0,0 +1,29 @@
package org.whispersystems.bithub.storage;
import org.whispersystems.bithub.entities.Payment;
public class CurrentPayment {
private final byte[] badge;
private final byte[] smallBadge;
private final Payment entity;
protected CurrentPayment(byte[] badge, byte[] smallBadge, Payment entity) {
this.badge = badge;
this.smallBadge = smallBadge;
this.entity = entity;
}
public byte[] getBadge() {
return badge;
}
public byte[] getSmallBadge() {
return smallBadge;
}
public Payment getEntity() {
return entity;
}
}

View File

@ -0,0 +1,56 @@
package org.whispersystems.bithub.views;
import org.whispersystems.bithub.entities.Repository;
import org.whispersystems.bithub.entities.Transaction;
import org.whispersystems.bithub.storage.CurrentPayment;
import java.util.List;
import io.dropwizard.views.View;
public class DashboardView extends View {
private final String organizationName;
private final String donationUrl;
private final CurrentPayment currentPayment;
private final List<Repository> repositories;
private final List<Transaction> transactions;
public DashboardView(String organizationName, String donationUrl,
CurrentPayment currentPayment,
List<Repository> repositories,
List<Transaction> transactions)
{
super("dashboard.mustache");
this.organizationName = organizationName;
this.donationUrl = donationUrl;
this.currentPayment = currentPayment;
this.repositories = repositories;
this.transactions = transactions;
}
public String getPayment() {
return currentPayment.getEntity().getPayment();
}
public String getOrganizationName() {
return organizationName;
}
public String getDonationUrl() {
return donationUrl;
}
public List<Repository> getRepositories() {
return repositories;
}
public List<Transaction> getTransactions() {
return transactions;
}
public String getRepositoriesCount() {
return String.valueOf(repositories.size());
}
}

View File

@ -1,129 +0,0 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.whispersystems.bithub.views;
import org.apache.commons.lang3.StringEscapeUtils;
import org.ocpsoft.prettytime.PrettyTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.ParseException;
import java.text.SimpleDateFormat;
/**
* A rendered HTML view of an individual BitHub transaction.
*
* @author Moxie Marlinspike
*/
public class TransactionView {
private final Logger logger = LoggerFactory.getLogger(RecentTransactionsView.class);
private final String destination;
private final String amount;
private final String commitUrl;
private final String commitSha;
private final String timestamp;
public TransactionView(BigDecimal exchangeRate, String amount,
String timestamp, String message)
throws ParseException
{
this.amount = getAmountInDollars(exchangeRate, amount);
this.destination = parseDestinationFromMessage(message);
this.timestamp = parseTimestamp(timestamp);
this.commitUrl = parseUrlFromMessage(message);
this.commitSha = parseShaFromUrl(commitUrl);
}
private String getAmountInDollars(BigDecimal exchangeRate, String amount) {
return new BigDecimal(amount).abs()
.multiply(exchangeRate)
.setScale(2, RoundingMode.CEILING)
.toPlainString();
}
private String parseTimestamp(String timestamp) throws ParseException {
int offendingColon = timestamp.lastIndexOf(':');
String fixedTimestamp = timestamp.substring(0, offendingColon) + timestamp.substring(offendingColon + 1);
return new PrettyTime().format(new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(fixedTimestamp));
}
private String parseDestinationFromMessage(String message) {
message = StringEscapeUtils.unescapeHtml4(message);
int startToken = message.indexOf("__");
if (startToken == -1) {
return "Unknown";
}
int endToken = message.indexOf("__", startToken + 1);
if (endToken == -1) {
return "Unknown";
}
return message.substring(startToken+2, endToken);
}
private String parseUrlFromMessage(String message) throws ParseException {
message = StringEscapeUtils.unescapeHtml4(message);
int urlIndex = message.indexOf("https://");
return message.substring(urlIndex).trim();
}
private String parseShaFromUrl(String url) throws ParseException {
if (url == null) {
throw new ParseException("No url", 0);
}
String[] parts = url.split("/");
String fullHash = parts[parts.length-1];
if (fullHash.length() < 8) {
throw new ParseException("Not long enough", 0);
}
return fullHash.substring(0, 8);
}
public String getDestination() {
return destination;
}
public String getAmount() {
return amount;
}
public String getCommitUrl() {
return commitUrl;
}
public String getCommitSha() {
return commitSha;
}
public String getTimestamp() {
return timestamp;
}
}

View File

@ -19,6 +19,7 @@ package org.whispersystems.bithub.views;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.bithub.entities.CoinbaseTransaction;
import org.whispersystems.bithub.entities.Transaction;
import java.math.BigDecimal;
@ -33,34 +34,16 @@ import io.dropwizard.views.View;
*
* @author Moxie Marlinspike
*/
public class RecentTransactionsView extends View {
public class TransactionsView extends View {
private final Logger logger = LoggerFactory.getLogger(RecentTransactionsView.class);
private final List<TransactionView> transactions = new LinkedList<>();
private final List<Transaction> transactions;
public RecentTransactionsView(List<Transaction> recentTransactions, BigDecimal exchangeRate) {
public TransactionsView(List<Transaction> transactions) {
super("recent_transactions.mustache");
for (Transaction transaction : recentTransactions) {
try {
if (isSentTransaction(transaction)) {
transactions.add(new TransactionView(exchangeRate,
transaction.getAmount(),
transaction.getCreatedTime(),
transaction.getNotes()));
}
} catch (ParseException e) {
logger.warn("Error parsing: ", e);
}
}
this.transactions = transactions;
}
private boolean isSentTransaction(Transaction transaction) {
BigDecimal amount = new BigDecimal(transaction.getAmount());
return amount.compareTo(new BigDecimal(0.0)) < 0;
}
public List<TransactionView> getTransactions() {
public List<Transaction> getTransactions() {
return transactions;
}
}

View File

@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<link rel="icon" href="../../favicon.ico">
<title>BitHub :: Dashboard </title>
<!-- Bootstrap core CSS -->
<link href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css" rel="stylesheet">
<!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
<style type="text/css">
body {
padding-top: 20px;
}
pre {
white-space: pre-wrap; /* css-3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
padding: 0px;
border: 0px;
background-color: transparent;
font-family: monospace;
}
/* top tags */
.hero-widget { text-align: center; padding-top: 20px; padding-bottom: 20px; }
.hero-widget .icon { display: block; font-size: 96px; line-height: 96px; margin-bottom: 10px; text-align: center; }
.hero-widget var { display: block; height: 64px; font-size: 64px; line-height: 64px; font-style: normal; }
.hero-widget label { font-size: 17px; }
.hero-widget .options { margin-top: 10px; }
/* repositories */
.repository {
margin-top: 20px;
padding: 40px 0px 20px 0px;
background-color: #f7f7f7;
-moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3);
-webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3);
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3);
}
.repository-name {
font-size: 50px;
padding: 15px;
}
.repository-name a {
text-decoration: none;
}
.repository-description {
font-size: 20px;
padding: 15px;
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-sm-3">
<h1>BitHub</h1>
<p class="lead">{{organizationName}}</p>
<p><a class="btn btn-lg btn-success" href="{{donationUrl}}" role="button">Donate BTC today</a></p>
</div>
<div class="col-sm-3">
<div class="hero-widget well well-sm">
<div class="icon">
<i class="glyphicon glyphicon-usd"></i>
</div>
<div class="text">
<var>{{payment}}</var>
<label class="text-muted">USD per commit</label>
</div>
</div>
</div>
<div class="col-sm-3">
<div class="hero-widget well well-sm">
<div class="icon">
<i class="glyphicon glyphicon-tags"></i>
</div>
<div class="text">
<var>0</var>
<label class="text-muted">open bounties</label>
</div>
</div>
</div>
<div class="col-sm-3">
<div class="hero-widget well well-sm">
<div class="icon">
<i class="glyphicon glyphicon-book"></i>
</div>
<div class="text">
<var>{{repositoriesCount}}</var>
<label class="text-muted">repositories</label>
</div>
</div>
</div>
</div>
<h2 class="sub-header">Recent payments</h2>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>USD</th>
<th>Author</th>
<th>Description</th>
<th>Commit</th>
</tr>
</thead>
<tbody id="transactions_target">
{{#transactions}}
<tr>
<td>${{amount}} USD</td>
<td><a href="https://github.com/{{destination}}">{{destination}}</a></td>
<td><pre>{{description}}</pre></td>
<td><a href="{{commitUrl}}">{{commitSha}}</a></td>
</tr>
{{/transactions}}
</tbody>
</table>
</div>
<h2 class="sub-header">Repositories</h2>
<div class="row">
{{#repositories}}
<div class="col-xs-12 col-sm-6 col-md-6 col-lg-6">
<div class="repository">
<div class="repository-name">
<a href="{{url}}">{{name}}</a>
</div>
<div class="repository-description">
<p>{{description}}</p>
</div>
</div>
</div>
{{/repositories}}
</div>
</div>
</body>
</html>

View File

@ -4,15 +4,20 @@ import com.sun.jersey.api.client.ClientResponse;
import org.junit.ClassRule;
import org.junit.Test;
import org.whispersystems.bithub.client.CoinbaseClient;
import org.whispersystems.bithub.client.GithubClient;
import org.whispersystems.bithub.config.RepositoryConfiguration;
import org.whispersystems.bithub.controllers.StatusController;
import org.whispersystems.bithub.entities.RecentTransactionsResponse;
import org.whispersystems.bithub.entities.CoinbseRecentTransactionsResponse;
import org.whispersystems.bithub.entities.Repository;
import org.whispersystems.bithub.storage.CacheManager;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.LinkedList;
import io.dropwizard.testing.junit.ResourceTestRule;
import static org.fest.assertions.api.Assertions.assertThat;
import static org.mockito.Matchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.whispersystems.bithub.tests.util.JsonHelper.fromJson;
@ -20,25 +25,31 @@ import static org.whispersystems.bithub.tests.util.JsonHelper.jsonFixture;
public class StatusControllerTest {
private static final BigDecimal PAYOUT_RATE = new BigDecimal(0.02);
private static final BigDecimal BALANCE = new BigDecimal(10.01);
private static final BigDecimal EXCHANGE_RATE = new BigDecimal(1.0);
private static final BigDecimal PAYOUT_RATE = new BigDecimal(0.02 );
private static final BigDecimal BALANCE = new BigDecimal(10.01);
private static final BigDecimal EXCHANGE_RATE = new BigDecimal(1.0 );
private static final CoinbaseClient coinbaseClient = mock(CoinbaseClient.class);
private static final GithubClient githubClient = mock(GithubClient.class );
@ClassRule
public static ResourceTestRule resources;
static {
try {
when(coinbaseClient.getRecentTransactions()).thenReturn(fromJson(jsonFixture("payloads/transactions.json"), RecentTransactionsResponse.class).getTransactions());
when(coinbaseClient.getRecentTransactions()).thenReturn(fromJson(jsonFixture("payloads/transactions.json"), CoinbseRecentTransactionsResponse.class).getTransactions());
when(coinbaseClient.getAccountBalance()).thenReturn(BALANCE);
when(coinbaseClient.getExchangeRate()).thenReturn(EXCHANGE_RATE);
CacheManager coinbaseManager = new CacheManager(coinbaseClient, githubClient,
new LinkedList<RepositoryConfiguration>(),
PAYOUT_RATE);
coinbaseManager.start();
resources = ResourceTestRule.builder()
.addResource(new StatusController(coinbaseClient, PAYOUT_RATE))
.addResource(new StatusController(coinbaseManager, null))
.build();
} catch (IOException e) {
} catch (Exception e) {
throw new AssertionError(e);
}
}
@ -62,7 +73,7 @@ public class StatusControllerTest {
@Test
public void testTransactionsJson() throws Exception {
ClientResponse response = resources.client().resource("/v1/status/transactions/").accept(MediaType.APPLICATION_JSON_TYPE)
ClientResponse response = resources.client().resource("/v1/status/transactions/?format=json").accept(MediaType.APPLICATION_JSON_TYPE)
.get(ClientResponse.class);
assertThat(response.getStatus()).isEqualTo(200);