diff --git a/app/adapters/api-token.js b/app/adapters/api-token.js new file mode 100644 index 00000000000..2cccb38a90a --- /dev/null +++ b/app/adapters/api-token.js @@ -0,0 +1,8 @@ +import DS from 'ember-data'; + +export default DS.RESTAdapter.extend({ + namespace: 'me', + pathForType() { + return 'tokens'; + } +}); diff --git a/app/components/api-token-row.js b/app/components/api-token-row.js new file mode 100644 index 00000000000..2aaea4052ae --- /dev/null +++ b/app/components/api-token-row.js @@ -0,0 +1,43 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + emptyName: Ember.computed.empty('api_token.name'), + disableCreate: Ember.computed.or('api_token.isSaving', 'emptyName'), + serverError: null, + + didInsertElement() { + if (this.get('api_token.isNew')) { + this.$('input').focus(); + } + }, + + actions: { + saveToken() { + this.get('api_token') + .save() + .then(() => this.set('serverError', null)) + .catch(err => { + let msg; + if (err.errors && err.errors[0] && err.errors[0].detail) { + msg = `An error occurred while saving this token, ${err.errors[0].detail}`; + } else { + msg = 'An unknown error occurred while saving this token'; + } + this.set('serverError', msg); + }); + }, + revokeToken() { + this.get('api_token') + .destroyRecord() + .catch(err => { + let msg; + if (err.errors && err.errors[0] && err.errors[0].detail) { + msg = `An error occurred while revoking this token, ${err.errors[0].detail}`; + } else { + msg = 'An unknown error occurred while revoking this token'; + } + this.set('serverError', msg); + }); + }, + } +}); diff --git a/app/controllers/me/index.js b/app/controllers/me/index.js index 70022cc0855..36ea46a76f0 100644 --- a/app/controllers/me/index.js +++ b/app/controllers/me/index.js @@ -3,6 +3,9 @@ import Ember from 'ember'; const { inject: { service } } = Ember; export default Ember.Controller.extend({ + tokenSort: ['created_at:desc'], + + sortedTokens: Ember.computed.sort('model.api_tokens', 'tokenSort'), ajax: service(), @@ -10,26 +13,14 @@ export default Ember.Controller.extend({ isResetting: false, + newTokens: Ember.computed.filterBy('model.api_tokens', 'isNew', true), + disableCreate: Ember.computed.notEmpty('newTokens'), + actions: { - resetToken() { - this.set('isResetting', true); - - this.get('ajax').put('/me/reset_token').then((d) => { - this.get('model').set('api_token', d.api_token); - }).catch((reason) => { - let msg; - if (reason.status === 403) { - msg = 'A login is required to perform this action'; - } else { - msg = 'An unknown error occurred'; - } - this.get('flashMessages').queue(msg); - // TODO: this should be an action, the route state machine - // should receive signals not external transitions - this.transitionToRoute('index'); - }).finally(() => { - this.set('isResetting', false); + startNewToken() { + this.get('store').createRecord('api-token', { + created_at: new Date(Date.now() + 2000), }); - } + }, } }); diff --git a/app/models/api-token.js b/app/models/api-token.js new file mode 100644 index 00000000000..2d9e01ca6d6 --- /dev/null +++ b/app/models/api-token.js @@ -0,0 +1,8 @@ +import DS from 'ember-data'; + +export default DS.Model.extend({ + name: DS.attr('string'), + token: DS.attr('string'), + created_at: DS.attr('date'), + last_used_at: DS.attr('date'), +}); diff --git a/app/models/user.js b/app/models/user.js index 3ea87236bc7..36cca400565 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -4,7 +4,6 @@ export default DS.Model.extend({ email: DS.attr('string'), name: DS.attr('string'), login: DS.attr('string'), - api_token: DS.attr('string'), avatar: DS.attr('string'), url: DS.attr('string'), kind: DS.attr('string'), diff --git a/app/routes/application.js b/app/routes/application.js index fc8b380bfd8..1b27eb5e29f 100644 --- a/app/routes/application.js +++ b/app/routes/application.js @@ -12,9 +12,7 @@ export default Ember.Route.extend({ if (this.session.get('isLoggedIn') && this.session.get('currentUser') === null) { this.get('ajax').request('/me').then((response) => { - let user = this.store.push(this.store.normalize('user', response.user)); - user.set('api_token', response.api_token); - this.session.set('currentUser', user); + this.session.set('currentUser', this.store.push(this.store.normalize('user', response.user))); }).catch(() => this.session.logoutUser()).finally(() => { window.currentUserDetected = true; Ember.$(window).trigger('currentUserDetected'); diff --git a/app/routes/login.js b/app/routes/login.js index 0917843a67d..5f479245684 100644 --- a/app/routes/login.js +++ b/app/routes/login.js @@ -67,7 +67,6 @@ export default Ember.Route.extend({ } let user = this.store.push(this.store.normalize('user', data.user)); - user.set('api_token', data.api_token); let transition = this.session.get('savedTransition'); this.session.loginUser(user); if (transition) { diff --git a/app/routes/me/index.js b/app/routes/me/index.js index 6186e7d6e8d..afee5ad96ab 100644 --- a/app/routes/me/index.js +++ b/app/routes/me/index.js @@ -3,6 +3,9 @@ import AuthenticatedRoute from '../../mixins/authenticated-route'; export default Ember.Route.extend(AuthenticatedRoute, { model() { - return this.session.get('currentUser'); + return { + user: this.session.get('currentUser'), + api_tokens: this.get('store').findAll('api-token'), + }; }, }); diff --git a/app/serializers/api-token.js b/app/serializers/api-token.js new file mode 100644 index 00000000000..090579c4503 --- /dev/null +++ b/app/serializers/api-token.js @@ -0,0 +1,7 @@ +import DS from 'ember-data'; + +export default DS.RESTSerializer.extend({ + payloadKeyFromModelName() { + return 'api_token'; + } +}); diff --git a/app/styles/home.scss b/app/styles/home.scss index ef60beb1bf0..fe469aad5d7 100644 --- a/app/styles/home.scss +++ b/app/styles/home.scss @@ -1,6 +1,9 @@ @mixin button($start, $end) { - $s2: darken($start, 5%); - $e2: darken($end, 5%); + $start_dark: darken($start, 5%); + $end_dark: darken($end, 5%); + $start_light: lighten($start, 5%); + $end_light: lighten($end, 5%); + padding: 15px 40px; display: inline-block; color: $main-color; @@ -17,8 +20,12 @@ margin-right: 10px; } - &:hover { @include vertical-gradient($s2, $e2); outline: 0; } - &.active { @include vertical-gradient($s2, $e2); outline: 0; } + &:hover { @include vertical-gradient($start_dark, $end_dark); outline: 0; } + &.active { @include vertical-gradient($start_dark, $end_dark); outline: 0; } + &[disabled] { + @include vertical-gradient($start_light, $end_light); + color: $main-color-light; + } } .yellow-button { @@ -26,6 +33,11 @@ vertical-align: middle; } +button.small { + padding: 10px 20px; + @include border-radius(30px); +} + .tan-button { @include button(rgb(232, 227, 199), rgb(214, 205, 153)); } diff --git a/app/styles/me.scss b/app/styles/me.scss index 289ecb7c6de..377b3483da6 100644 --- a/app/styles/me.scss +++ b/app/styles/me.scss @@ -86,3 +86,66 @@ &:hover { background-color: darken($bg, 10%); } } } + +.me-subheading { + @include display-flex; + .right { + @include flex(2); + @include display-flex; + @include justify-content(flex-end); + @include align-self(center); + } +} + +#tokens { + background-color: $main-bg-dark; + @include display-flex; + @include flex-direction(column); + .row { + width: 100%; + border: 1px solid #d5d3cb; + border-bottom-width: 0px; + &:last-child { border-bottom-width: 1px; } + padding: 10px 20px; + @include display-flex; + @include align-items(center); + .name { + @include flex(1); + margin-right: 0.4em; + font-weight: bold; + } + .dates { + @include flex(content); + @include display-flex; + @include flex-direction(column); + @include align-items(flex-end); + margin-right: 0.4em; + } + .actions { + @include display-flex; + @include align-items(center); + img { margin-left: 10px } + } + } + .create-token { + .name { + input { + width: 100%; + } + padding-right: 20px; + margin-right: 0px; + } + background-color: $main-bg-dark; + } + .new-token { + border-top-width: 0px; + @include flex-direction(column); + @include justify-content(stretch); + } + .error { + border-top-width: 0px; + font-weight: bold; + color: rgb(216, 0, 41); + padding: 0px 10px 10px 20px; + } +} diff --git a/app/templates/components/api-token-row.hbs b/app/templates/components/api-token-row.hbs new file mode 100644 index 00000000000..346e5f447aa --- /dev/null +++ b/app/templates/components/api-token-row.hbs @@ -0,0 +1,74 @@ +
+
+ {{#if api_token.isNew}} + {{input + type="text" + placeholder="New token name" + disabled=api_token.isSaving + value=api_token.name + autofocus=true + enter="saveToken"}} + {{else}} + {{ api_token.name }} + {{/if}} +
+ +
+ + {{#unless api_token.isNew}} +
+
+ + Created {{moment-from-now api_token.created_at}} + +
+ {{#if api_token.last_used_at}} +
+ + Last used {{moment-from-now api_token.last_used_at}} + +
+ {{else}} +
+ Never used +
+ {{/if}} +
+ {{/unless}} + +
+ {{#if api_token.isNew}} + + {{else}} + + {{/if}} + {{#if api_token.isSaving}} + + {{/if}} +
+
+ +{{#if serverError}} +
+
+ {{ serverError }} +
+
+{{/if}} + +{{#if api_token.token}} +
+
+ Please record this token somewhere, you cannot retrieve + its value again. For use on the command line you can save it to ~/.cargo/config + with: + +
cargo login {{ api_token.token }}
+
+
+{{/if}} diff --git a/app/templates/me/index.hbs b/app/templates/me/index.hbs index 14f0c177f40..d5766f611e0 100644 --- a/app/templates/me/index.hbs +++ b/app/templates/me/index.hbs @@ -9,33 +9,36 @@

Profile Information

- {{#user-link user=model }} {{user-avatar user=model size='medium'}} {{/user-link}} + {{#user-link user=model.user }} {{user-avatar user=model.user size='medium'}} {{/user-link}}
Name
-
{{ model.name }}
+
{{ model.user.name }}
GitHub Account
-
{{ model.login }}
+
{{ model.user.login }}
Email
-
{{ model.email }}
+
{{ model.user.email }}
-

API Access

+
+

API Access

+
+ +
+
-

Your API key is {{ model.api_token }}

If you want to use package commands from the command line, you'll need a - ~/.cargo/config which can be generated with: + ~/.cargo/config, the first step to creating this + is to generate a new token using the button above.

-
cargo login {{ model.api_token }}
- - - +
+ {{#each sortedTokens as |api_token|}} + {{api-token-row api_token=api_token}} + {{/each}} +
diff --git a/migrations/20170428154714_multiple_api_tokens/down.sql b/migrations/20170428154714_multiple_api_tokens/down.sql new file mode 100644 index 00000000000..1d115ccb8a5 --- /dev/null +++ b/migrations/20170428154714_multiple_api_tokens/down.sql @@ -0,0 +1 @@ +DROP TABLE api_tokens; diff --git a/migrations/20170428154714_multiple_api_tokens/up.sql b/migrations/20170428154714_multiple_api_tokens/up.sql new file mode 100644 index 00000000000..681ae3814eb --- /dev/null +++ b/migrations/20170428154714_multiple_api_tokens/up.sql @@ -0,0 +1,16 @@ +CREATE TABLE api_tokens ( + id SERIAL PRIMARY KEY, + user_id integer NOT NULL REFERENCES users(id), + token character varying DEFAULT random_string(32) NOT NULL UNIQUE, + name character varying NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + last_used_at timestamp without time zone +); + +CREATE INDEX ON api_tokens (token); + +INSERT INTO api_tokens (user_id, token, name) + SELECT id, api_token, 'Initial token' FROM users; + +-- To be done in a cleanup migration later. +-- ALTER TABLE users DROP COLUMN api_token; diff --git a/src/lib.rs b/src/lib.rs index 55388b830cc..feb2331256d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -85,6 +85,7 @@ pub mod krate; pub mod model; pub mod owner; pub mod schema; +pub mod token; pub mod upload; pub mod uploaders; pub mod user; @@ -164,8 +165,10 @@ pub fn middleware(app: Arc) -> MiddlewareBuilder { router.get("/authorize", C(user::github_access_token)); router.get("/logout", C(user::logout)); router.get("/me", C(user::me)); - router.put("/me/reset_token", C(user::reset_token)); router.get("/me/updates", C(user::updates)); + router.get("/me/tokens", C(token::list)); + router.post("/me/tokens", C(token::new)); + router.delete("/me/tokens/:id", C(token::revoke)); router.get("/summary", C(krate::summary)); let env = app.config.env; diff --git a/src/schema.rs b/src/schema.rs index 9f973a040ba..fafb0c0dedb 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,5 +1,16 @@ // This file can be regenerated with `diesel print-schema` +table! { + api_tokens (id) { + id -> Int4, + user_id -> Int4, + token -> Varchar, + name -> Varchar, + created_at -> Timestamp, + last_used_at -> Nullable, + } +} + table! { badges (crate_id, badge_type) { @@ -132,7 +143,6 @@ table! { id -> Int4, email -> Nullable, gh_access_token -> Varchar, - api_token -> Varchar, gh_login -> Varchar, name -> Nullable, gh_avatar -> Nullable, diff --git a/src/tests/all.rs b/src/tests/all.rs index a1e13f16774..b93af52ad38 100644 --- a/src/tests/all.rs +++ b/src/tests/all.rs @@ -34,6 +34,7 @@ use cargo_registry::upload as u; use cargo_registry::user::NewUser; use cargo_registry::owner::{CrateOwner, NewTeam, Team}; use cargo_registry::version::NewVersion; +use cargo_registry::user::AuthenticationSource; use cargo_registry::{User, Crate, Version, Dependency, Category, Model, Replica}; use conduit::{Request, Method}; use conduit_test::MockRequest; @@ -87,6 +88,7 @@ mod keyword; mod krate; mod record; mod team; +mod token; mod user; mod version; @@ -212,7 +214,6 @@ fn user(login: &str) -> User { name: None, gh_avatar: None, gh_access_token: "some random token".into(), - api_token: "some random token".into(), } } @@ -455,6 +456,9 @@ fn mock_user(req: &mut Request, u: User) -> User { fn sign_in_as(req: &mut Request, user: &User) { req.mut_extensions().insert(user.clone()); + req.mut_extensions().insert( + AuthenticationSource::SessionCookie, + ); } fn sign_in(req: &mut Request, app: &App) { diff --git a/src/tests/http-data/krate_new_krate_with_token b/src/tests/http-data/krate_new_krate_with_token new file mode 100644 index 00000000000..204372eb899 --- /dev/null +++ b/src/tests/http-data/krate_new_krate_with_token @@ -0,0 +1,21 @@ +===REQUEST 339 +PUT http://alexcrichton-test.s3.amazonaws.com/crates/foo_new/foo_new-1.0.0.crate HTTP/1.1 +Accept: */* +Proxy-Connection: Keep-Alive +Authorization: AWS AKIAJF3GEK7N44BACDZA:GDxGb6r3SIqo9wXuzHrgMNWekwk= +Content-Length: 0 +Host: alexcrichton-test.s3.amazonaws.com +Content-Type: application/x-tar +Date: Sun, 28 Jun 2015 14:07:17 -0700 + + +===RESPONSE 258 +HTTP/1.1 200 +x-amz-request-id: CB0E925D8E3AB3E8 +x-amz-id-2: SiaMwszM1p2TzXlLauvZ6kRKcUCg7HoyBW29vts42w9ArrLwkJWl8vuvPuGFkpM6XGH+YXN852g= +date: Sun, 28 Jun 2015 21:07:51 GMT +etag: "d41d8cd98f00b204e9800998ecf8427e" +content-length: 0 +server: AmazonS3 + + diff --git a/src/tests/krate.rs b/src/tests/krate.rs index ba003d53160..feff51d9c9b 100644 --- a/src/tests/krate.rs +++ b/src/tests/krate.rs @@ -17,6 +17,7 @@ use cargo_registry::download::EncodableVersionDownload; use cargo_registry::git; use cargo_registry::keyword::EncodableKeyword; use cargo_registry::krate::{Crate, EncodableCrate, MAX_NAME_LENGTH}; +use cargo_registry::token::ApiToken; use cargo_registry::owner::EncodableOwner; use cargo_registry::schema::versions; use cargo_registry::upload as u; @@ -478,6 +479,24 @@ fn new_krate() { assert_eq!(json.krate.max_version, "1.0.0"); } +#[test] +fn new_krate_with_token() { + let (_b, app, middle) = ::app(); + let mut req = ::new_req(app.clone(), "foo_new", "1.0.0"); + + { + let conn = t!(app.diesel_database.get()); + let user = t!(::new_user("foo").create_or_update(&conn)); + let token = t!(ApiToken::insert(&conn, user.id, "bar")); + req.header("Authorization", &token.token); + } + + let mut response = ok_resp!(middle.call(&mut req)); + let json: GoodCrate = ::json(&mut response); + assert_eq!(json.krate.name, "foo_new"); + assert_eq!(json.krate.max_version, "1.0.0"); +} + #[test] fn new_krate_with_reserved_name() { fn test_bad_name(name: &str) { diff --git a/src/tests/token.rs b/src/tests/token.rs new file mode 100644 index 00000000000..e3323e0b1cb --- /dev/null +++ b/src/tests/token.rs @@ -0,0 +1,441 @@ +use std::collections::HashSet; + +use conduit::{Handler, Method}; + +use cargo_registry::token::{ApiToken, EncodableApiToken, EncodableApiTokenWithToken}; + +#[derive(RustcDecodable)] +struct ListResponse { + api_tokens: Vec, +} +#[derive(RustcDecodable)] +struct NewResponse { + api_token: EncodableApiTokenWithToken, +} +#[derive(RustcDecodable)] +struct RevokedResponse {} + +macro_rules! assert_contains { + ($e:expr, $f:expr) => { + if !$e.contains($f) { + panic!(format!("expected '{}' to contain '{}'", $e, $f)); + } + } +} + +#[test] +fn list_logged_out() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Get, "/me/tokens"); + + let response = t_resp!(middle.call(&mut req)); + assert_eq!(response.status.0, 403); +} + +#[test] +fn list_empty() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Get, "/me/tokens"); + + let user = { + let conn = t!(app.diesel_database.get()); + t!(::new_user("foo").create_or_update(&conn)) + }; + ::sign_in_as(&mut req, &user); + + let mut response = ok_resp!(middle.call(&mut req)); + let json: ListResponse = ::json(&mut response); + + assert_eq!(json.api_tokens.len(), 0); +} + +#[test] +fn list_tokens() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Get, "/me/tokens"); + + let (user, tokens); + { + let conn = t!(app.diesel_database.get()); + user = t!(::new_user("foo").create_or_update(&conn)); + tokens = vec![ + t!(ApiToken::insert(&conn, user.id, "bar")), + t!(ApiToken::insert(&conn, user.id, "baz")), + ]; + } + ::sign_in_as(&mut req, &user); + + let mut response = ok_resp!(middle.call(&mut req)); + let json: ListResponse = ::json(&mut response); + + assert_eq!(json.api_tokens.len(), tokens.len()); + assert_eq!( + json.api_tokens + .into_iter() + .map(|t| t.name) + .collect::>(), + tokens.into_iter().map(|t| t.name).collect::>() + ); +} + +#[test] +fn create_token_logged_out() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Post, "/me/tokens"); + + req.with_body(br#"{ "api_token": { "name": "bar" } }"#); + + let response = t_resp!(middle.call(&mut req)); + assert_eq!(response.status.0, 403); +} + +#[test] +fn create_token_invalid_request() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Post, "/me/tokens"); + + let user = { + let conn = t!(app.diesel_database.get()); + t!(::new_user("foo").create_or_update(&conn)) + }; + ::sign_in_as(&mut req, &user); + req.with_body(br#"{ "name": "" }"#); + + let mut response = t_resp!(middle.call(&mut req)); + let json: ::Bad = ::json(&mut response); + + assert_eq!(response.status.0, 400); + assert_contains!(json.errors[0].detail, "invalid new token request"); +} + +#[test] +fn create_token_no_name() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Post, "/me/tokens"); + + let user = { + let conn = t!(app.diesel_database.get()); + t!(::new_user("foo").create_or_update(&conn)) + }; + ::sign_in_as(&mut req, &user); + req.with_body(br#"{ "api_token": { "name": "" } }"#); + + let mut response = t_resp!(middle.call(&mut req)); + let json: ::Bad = ::json(&mut response); + + assert_eq!(response.status.0, 400); + assert_eq!(json.errors[0].detail, "name must have a value"); +} + +#[test] +fn create_token_long_body() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Post, "/me/tokens"); + + let user = { + let conn = t!(app.diesel_database.get()); + t!(::new_user("foo").create_or_update(&conn)) + }; + ::sign_in_as(&mut req, &user); + req.with_body(&[5; 5192]); // Send a request with a 5kB body of 5's + + let mut response = t_resp!(middle.call(&mut req)); + let json: ::Bad = ::json(&mut response); + + assert_eq!(response.status.0, 400); + assert_contains!(json.errors[0].detail, "max post size"); +} + +#[test] +fn create_token_exceeded_tokens_per_user() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Post, "/me/tokens"); + + let user; + { + let conn = t!(app.diesel_database.get()); + user = t!(::new_user("foo").create_or_update(&conn)); + for i in 0..1000 { + t!(ApiToken::insert(&conn, user.id, &format!("token {}", i))); + } + }; + ::sign_in_as(&mut req, &user); + req.with_body(br#"{ "api_token": { "name": "bar" } }"#); + + let mut response = t_resp!(middle.call(&mut req)); + let json: ::Bad = ::json(&mut response); + + assert_eq!(response.status.0, 400); + assert_contains!(json.errors[0].detail, "maximum tokens per user"); +} + +#[test] +fn create_token_success() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Post, "/me/tokens"); + + let user = { + let conn = t!(app.diesel_database.get()); + t!(::new_user("foo").create_or_update(&conn)) + }; + ::sign_in_as(&mut req, &user); + req.with_body(br#"{ "api_token": { "name": "bar" } }"#); + + let mut response = ok_resp!(middle.call(&mut req)); + let json: NewResponse = ::json(&mut response); + + assert_eq!(json.api_token.name, "bar"); + assert!(!json.api_token.token.is_empty()); + + let conn = t!(app.diesel_database.get()); + let tokens = t!(ApiToken::find_for_user(&conn, user.id)); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].name, "bar"); + assert_eq!(tokens[0].token, json.api_token.token); + assert_eq!(tokens[0].last_used_at, None); +} + +#[test] +fn create_token_multiple_have_different_values() { + let (_b, app, middle) = ::app(); + + let user = { + let conn = t!(app.clone().diesel_database.get()); + t!(::new_user("foo").create_or_update(&conn)) + }; + + let first = { + let mut req = ::req(app.clone(), Method::Post, "/me/tokens"); + ::sign_in_as(&mut req, &user); + req.with_body(br#"{ "api_token": { "name": "bar" } }"#); + ::json::(&mut ok_resp!(middle.call(&mut req))) + }; + + let second = { + let mut req = ::req(app.clone(), Method::Post, "/me/tokens"); + ::sign_in_as(&mut req, &user); + req.with_body(br#"{ "api_token": { "name": "bar" } }"#); + ::json::(&mut ok_resp!(middle.call(&mut req))) + }; + + assert_ne!(first.api_token.token, second.api_token.token); +} + +#[test] +fn create_token_multiple_users_have_different_values() { + let (_b, app, middle) = ::app(); + + let first_user = { + let conn = t!(app.clone().diesel_database.get()); + t!(::new_user("foo").create_or_update(&conn)) + }; + + let second_user = { + let conn = t!(app.clone().diesel_database.get()); + t!(::new_user("bar").create_or_update(&conn)) + }; + + let first_token = { + let mut req = ::req(app.clone(), Method::Post, "/me/tokens"); + ::sign_in_as(&mut req, &first_user); + req.with_body(br#"{ "api_token": { "name": "baz" } }"#); + ::json::(&mut ok_resp!(middle.call(&mut req))) + }; + + let second_token = { + let mut req = ::req(app.clone(), Method::Post, "/me/tokens"); + ::sign_in_as(&mut req, &second_user); + req.with_body(br#"{ "api_token": { "name": "baz" } }"#); + ::json::(&mut ok_resp!(middle.call(&mut req))) + }; + + assert_ne!(first_token.api_token.token, second_token.api_token.token); +} + +#[test] +fn create_token_with_token() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Post, "/me/tokens"); + + let (user, token); + { + let conn = t!(app.diesel_database.get()); + user = t!(::new_user("foo").create_or_update(&conn)); + token = t!(ApiToken::insert(&conn, user.id, "bar")); + } + req.header("Authorization", &token.token); + req.with_body(br#"{ "api_token": { "name": "baz" } }"#); + + let mut response = t_resp!(middle.call(&mut req)); + let json: ::Bad = ::json(&mut response); + + assert_eq!(response.status.0, 400); + assert_contains!( + json.errors[0].detail, + "cannot use an API token to create a new API token" + ); +} + +#[test] +fn revoke_token_non_existing() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Delete, "/me/tokens/5"); + + let user = { + let conn = t!(app.diesel_database.get()); + t!(::new_user("foo").create_or_update(&conn)) + }; + ::sign_in_as(&mut req, &user); + + let mut response = ok_resp!(middle.call(&mut req)); + ::json::(&mut response); +} + +#[test] +fn revoke_token_doesnt_revoke_other_users_token() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Delete, "/me/tokens"); + + // Create one user with a token and sign in with a different user + let (user1, token, user2); + { + let conn = t!(app.diesel_database.get()); + user1 = t!(::new_user("foo").create_or_update(&conn)); + token = t!(ApiToken::insert(&conn, user1.id, "bar")); + user2 = t!(::new_user("baz").create_or_update(&conn)) + }; + ::sign_in_as(&mut req, &user2); + + // List tokens for first user contains the token + { + let conn = t!(app.diesel_database.get()); + let tokens = t!(ApiToken::find_for_user(&conn, user1.id)); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].name, token.name); + } + + // Try revoke the token as second user + { + req.with_path(&format!("/me/tokens/{}", token.id)); + + let mut response = ok_resp!(middle.call(&mut req)); + ::json::(&mut response); + } + + // List tokens for first user still contains the token + { + let conn = t!(app.diesel_database.get()); + let tokens = t!(ApiToken::find_for_user(&conn, user1.id)); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].name, token.name); + } +} + +#[test] +fn revoke_token_success() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Delete, "/me/tokens"); + + let (user, token); + { + let conn = t!(app.diesel_database.get()); + user = t!(::new_user("foo").create_or_update(&conn)); + token = t!(ApiToken::insert(&conn, user.id, "bar")); + } + ::sign_in_as(&mut req, &user); + + // List tokens contains the token + { + let conn = t!(app.diesel_database.get()); + let tokens = ApiToken::find_for_user(&conn, user.id).unwrap(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].name, token.name); + } + + // Revoke the token + { + req.with_path(&format!("/me/tokens/{}", token.id)); + + let mut response = ok_resp!(middle.call(&mut req)); + ::json::(&mut response); + } + + // List tokens no longer contains the token + { + let conn = t!(app.diesel_database.get()); + let tokens = ApiToken::find_for_user(&conn, user.id).unwrap(); + assert_eq!(tokens.len(), 0); + } +} + +#[test] +fn token_gives_access_to_me() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Get, "/me"); + + let response = t_resp!(middle.call(&mut req)); + assert_eq!(response.status.0, 403); + + let (user, token); + { + let conn = t!(app.diesel_database.get()); + user = t!(::new_user("foo").create_or_update(&conn)); + token = t!(ApiToken::insert(&conn, user.id, "bar")); + } + req.header("Authorization", &token.token); + + let mut response = ok_resp!(middle.call(&mut req)); + let json: ::user::UserShowResponse = ::json(&mut response); + + assert_eq!(json.user.email, user.email); +} + +#[test] +fn using_token_updates_last_used_at() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Get, "/me"); + let response = t_resp!(middle.call(&mut req)); + assert_eq!(response.status.0, 403); + + let (user, token); + { + let conn = t!(app.diesel_database.get()); + user = t!(::new_user("foo").create_or_update(&conn)); + token = t!(ApiToken::insert(&conn, user.id, "bar")); + } + req.header("Authorization", &token.token); + assert!(token.last_used_at.is_none()); + + ok_resp!(middle.call(&mut req)); + + let token = { + let conn = t!(app.diesel_database.get()); + t!(ApiToken::find_for_user(&conn, user.id)).pop().unwrap() + }; + assert!(token.last_used_at.is_some()); + + // Would check that it updates the timestamp here, but the timestamp is + // based on the start of the database transaction so it doesn't work in + // this test framework. +} + +#[test] +fn deleted_token_does_not_give_access_to_me() { + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Get, "/me"); + + let response = t_resp!(middle.call(&mut req)); + assert_eq!(response.status.0, 403); + + let token; + { + let conn = t!(app.diesel_database.get()); + let user = t!(::new_user("foo").create_or_update(&conn)); + token = t!(ApiToken::insert(&conn, user.id, "bar")); + t!(ApiToken::delete(&conn, user.id, token.id)); + } + req.header("Authorization", &token.token); + + let response = t_resp!(middle.call(&mut req)); + assert_eq!(response.status.0, 403); +} diff --git a/src/tests/user.rs b/src/tests/user.rs index c2f78b473ce..c78405a5dc3 100644 --- a/src/tests/user.rs +++ b/src/tests/user.rs @@ -1,7 +1,9 @@ +use std::sync::atomic::Ordering; + use conduit::{Handler, Method}; use cargo_registry::Model; -use cargo_registry::db::RequestTransaction; +use cargo_registry::token::ApiToken; use cargo_registry::krate::EncodableCrate; use cargo_registry::user::{User, NewUser, EncodableUser}; use cargo_registry::version::EncodableVersion; @@ -12,13 +14,8 @@ struct AuthResponse { state: String, } #[derive(RustcDecodable)] -struct MeResponse { - user: EncodableUser, - api_token: String, -} -#[derive(RustcDecodable)] -struct UserShowResponse { - user: EncodableUser, +pub struct UserShowResponse { + pub user: EncodableUser, } #[test] @@ -46,7 +43,6 @@ fn user_insert() { let tx = t!(conn.transaction()); let user = t!(User::find_or_insert(&tx, 1, "foo", None, None, None, "bar")); - assert_eq!(t!(User::find_by_api_token(&tx, &user.api_token)), user); assert_eq!(t!(User::find(&tx, user.id)), user); assert_eq!( @@ -72,10 +68,11 @@ fn me() { assert_eq!(response.status.0, 403); let user = ::mock_user(&mut req, ::user("foo")); + let mut response = ok_resp!(middle.call(&mut req)); - let json: MeResponse = ::json(&mut response); + let json: UserShowResponse = ::json(&mut response); + assert_eq!(json.user.email, user.email); - assert_eq!(json.api_token, user.api_token); } #[test] @@ -101,18 +98,6 @@ fn show() { assert_eq!(Some("https://github.com/bar".into()), json.user.url); } -#[test] -fn reset_token() { - let (_b, app, middle) = ::app(); - let mut req = ::req(app, Method::Put, "/me/reset_token"); - let user = User::find_or_insert(req.tx().unwrap(), 1, "foo", None, None, None, "bar").unwrap(); - ::sign_in_as(&mut req, &user); - ok_resp!(middle.call(&mut req)); - - let u2 = User::find(req.tx().unwrap(), user.id).unwrap(); - assert!(u2.api_token != user.api_token); -} - #[test] fn crates_by_user_id() { let (_b, app, middle) = ::app(); @@ -220,3 +205,21 @@ fn following() { bad_resp!(middle.call(req.with_query("page=0"))); } + +#[test] +fn updating_existing_user_doesnt_change_api_token() { + let (_b, app, _middle) = ::app(); + let conn = t!(app.diesel_database.get()); + + let gh_user_id = ::NEXT_ID.fetch_add(1, Ordering::SeqCst) as i32; + + let original_user = + t!(NewUser::new(gh_user_id, "foo", None, None, None, "foo_token").create_or_update(&conn)); + let token = t!(ApiToken::insert(&conn, original_user.id, "foo")); + + t!(NewUser::new(gh_user_id, "bar", None, None, None, "bar_token").create_or_update(&conn)); + let user = t!(User::find_by_api_token(&conn, &token.token)); + + assert_eq!("bar", user.gh_login); + assert_eq!("bar_token", user.gh_access_token); +} diff --git a/src/token.rs b/src/token.rs new file mode 100644 index 00000000000..99e399e9deb --- /dev/null +++ b/src/token.rs @@ -0,0 +1,207 @@ +use diesel; +use diesel::prelude::*; +use diesel::pg::PgConnection; +use conduit::{Request, Response}; +use time::Timespec; +use conduit_router::RequestParams; +use rustc_serialize::json; + +use db::RequestTransaction; +use user::{RequestUser, AuthenticationSource}; +use util::{RequestUtils, CargoResult, ChainError, bad_request, read_fill}; +use schema::api_tokens; + +/// The model representing a row in the `api_tokens` database table. +#[derive(Clone, Debug, PartialEq, Eq, Identifiable, Queryable)] +pub struct ApiToken { + pub id: i32, + pub user_id: i32, + pub token: String, + pub name: String, + pub created_at: Timespec, + pub last_used_at: Option, +} + +/// The serialization format for the `ApiToken` model without its token value. +#[derive(RustcDecodable, RustcEncodable)] +pub struct EncodableApiToken { + pub id: i32, + pub name: String, + pub created_at: String, + pub last_used_at: Option, +} + +/// The serialization format for the `ApiToken` model with its token value. +/// This should only be used when initially creating a new token to minimize +/// the chance of token leaks. +#[derive(RustcDecodable, RustcEncodable)] +pub struct EncodableApiTokenWithToken { + pub id: i32, + pub name: String, + pub token: String, + pub created_at: String, + pub last_used_at: Option, +} + +impl ApiToken { + /// Generates a new named API token for a user + pub fn insert(conn: &PgConnection, user_id: i32, name: &str) -> CargoResult { + #[table_name = "api_tokens"] + #[derive(Insertable, AsChangeset)] + struct NewApiToken<'a> { + user_id: i32, + name: &'a str, + } + + diesel::insert(&NewApiToken { + user_id: user_id, + name: name, + }).into(api_tokens::table) + .get_result::(conn) + .map_err(From::from) + } + + /// Deletes the provided API token if it belongs to the provided user + pub fn delete(conn: &PgConnection, user_id: i32, id: i32) -> CargoResult<()> { + diesel::delete(api_tokens::table.find(id).filter( + api_tokens::user_id.eq(user_id), + )).execute(conn)?; + Ok(()) + } + + pub fn find_for_user(conn: &PgConnection, user_id: i32) -> CargoResult> { + api_tokens::table + .filter(api_tokens::user_id.eq(user_id)) + .order(api_tokens::created_at.desc()) + .load::(conn) + .map_err(From::from) + } + + pub fn count_for_user(conn: &PgConnection, user_id: i32) -> CargoResult { + api_tokens::table + .filter(api_tokens::user_id.eq(user_id)) + .count() + .get_result::(conn) + .map(|count| count as u64) + .map_err(From::from) + } + + /// Converts this `ApiToken` model into an `EncodableApiToken` for JSON + /// serialization. + pub fn encodable(self) -> EncodableApiToken { + EncodableApiToken { + id: self.id, + name: self.name, + created_at: ::encode_time(self.created_at), + last_used_at: self.last_used_at.map(::encode_time), + } + } + + /// Converts this `ApiToken` model into an `EncodableApiToken` including + /// the actual token value for JSON serialization. This should only be + /// used when initially creating a new token to minimize the chance of + /// token leaks. + pub fn encodable_with_token(self) -> EncodableApiTokenWithToken { + EncodableApiTokenWithToken { + id: self.id, + name: self.name, + token: self.token, + created_at: ::encode_time(self.created_at), + last_used_at: self.last_used_at.map(::encode_time), + } + } +} + +/// Handles the `GET /me/tokens` route. +pub fn list(req: &mut Request) -> CargoResult { + let db_conn = &*req.db_conn()?; + let user_id = req.user()?.id; + let tokens = ApiToken::find_for_user(db_conn, user_id)? + .into_iter() + .map(ApiToken::encodable) + .collect(); + #[derive(RustcEncodable)] + struct R { + api_tokens: Vec, + } + Ok(req.json(&R { api_tokens: tokens })) +} + +/// Handles the `POST /me/tokens` route. +pub fn new(req: &mut Request) -> CargoResult { + /// The incoming serialization format for the `ApiToken` model. + #[derive(RustcDecodable, RustcEncodable)] + struct NewApiToken { + name: String, + } + + /// The incoming serialization format for the `ApiToken` model. + #[derive(RustcDecodable, RustcEncodable)] + struct NewApiTokenRequest { + api_token: NewApiToken, + } + + if req.authentication_source()? != AuthenticationSource::SessionCookie { + return Err(bad_request( + "cannot use an API token to create a new API token", + )); + } + + let max_post_size = 2000; + let length = req.content_length().chain_error(|| { + bad_request("missing header: Content-Length") + })?; + + if length > max_post_size { + return Err(bad_request(&format!("max post size is: {}", max_post_size))); + } + + let mut json = vec![0; length as usize]; + read_fill(req.body(), &mut json)?; + + let json = String::from_utf8(json).map_err(|_| { + bad_request(&"json body was not valid utf-8") + })?; + + let new: NewApiTokenRequest = json::decode(&json).map_err(|e| { + bad_request(&format!("invalid new token request: {:?}", e)) + })?; + + let name = &new.api_token.name; + if name.len() < 1 { + return Err(bad_request("name must have a value")); + } + + let user = req.user()?; + + let max_token_per_user = 500; + let count = ApiToken::count_for_user(&*req.db_conn()?, user.id)?; + if count >= max_token_per_user { + return Err(bad_request(&format!( + "maximum tokens per user is: {}", + max_token_per_user + ))); + } + + let api_token = ApiToken::insert(&*req.db_conn()?, user.id, name)?; + + #[derive(RustcEncodable)] + struct R { + api_token: EncodableApiTokenWithToken, + } + Ok(req.json(&R { api_token: api_token.encodable_with_token() })) +} + +/// Handles the `DELETE /me/tokens/:id` route. +pub fn revoke(req: &mut Request) -> CargoResult { + let user = req.user()?; + let id = req.params()["id"].parse().map_err(|e| { + bad_request(&format!("invalid token id: {:?}", e)) + })?; + + ApiToken::delete(&*req.db_conn()?, user.id, id)?; + + #[derive(RustcEncodable)] + struct R {} + Ok(req.json(&R {})) +} diff --git a/src/user/middleware.rs b/src/user/middleware.rs index 5c84c83aaf9..9eea3dca85f 100644 --- a/src/user/middleware.rs +++ b/src/user/middleware.rs @@ -11,6 +11,12 @@ use util::errors::{CargoResult, Unauthorized, ChainError, std_error}; pub struct Middleware; +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum AuthenticationSource { + SessionCookie, + ApiToken, +} + impl conduit_middleware::Middleware for Middleware { fn before(&self, req: &mut Request) -> Result<(), Box> { // Check if the request has a session cookie with a `user_id` property inside @@ -18,45 +24,37 @@ impl conduit_middleware::Middleware for Middleware { req.session().get("user_id").and_then(|s| s.parse().ok()) }; - let user = match id { - - // `user_id` was found on the session - Some(id) => { - - // Look for a user in the database with the given `user_id` - match User::find(req.tx().map_err(std_error)?, id) { - Ok(user) => user, - Err(..) => return Ok(()), - } + if let Some(id) = id { + // If it did, look for a user in the database with the given `user_id` + if let Ok(user) = User::find(req.tx().map_err(std_error)?, id) { + // Attach the `User` model from the database to the request + req.mut_extensions().insert(user); + req.mut_extensions().insert( + AuthenticationSource::SessionCookie, + ); } - - // `user_id` was *not* found on the session - None => { - - // Look for an `Authorization` header on the request - match req.headers().find("Authorization") { - Some(headers) => { - - // Look for a user in the database with a matching API token - let tx = req.tx().map_err(std_error)?; - match User::find_by_api_token(tx, headers[0]) { - Ok(user) => user, - Err(..) => return Ok(()), - } - } - None => return Ok(()), - } + } else { + // Otherwise, look for an `Authorization` header on the request + // and try to find a user in the database with a matching API token + let user = if let Some(headers) = req.headers().find("Authorization") { + User::find_by_api_token(&*req.db_conn().map_err(std_error)?, headers[0]).ok() + } else { + None + }; + if let Some(user) = user { + // Attach the `User` model from the database to the request + req.mut_extensions().insert(user); + req.mut_extensions().insert(AuthenticationSource::ApiToken); } - }; + } - // Attach the `User` model from the database to the request - req.mut_extensions().insert(user); Ok(()) } } pub trait RequestUser { fn user(&self) -> CargoResult<&User>; + fn authentication_source(&self) -> CargoResult; } impl<'a> RequestUser for Request + 'a { @@ -65,4 +63,11 @@ impl<'a> RequestUser for Request + 'a { || Unauthorized, ) } + + fn authentication_source(&self) -> CargoResult { + self.extensions() + .find::() + .cloned() + .chain_error(|| Unauthorized) + } } diff --git a/src/user/mod.rs b/src/user/mod.rs index 7def99dea95..abf1002f043 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -19,7 +19,7 @@ use {http, Model, Version}; use owner::{Owner, OwnerKind, CrateOwner}; use krate::Crate; -pub use self::middleware::{Middleware, RequestUser}; +pub use self::middleware::{Middleware, RequestUser, AuthenticationSource}; pub mod middleware; @@ -29,7 +29,6 @@ pub struct User { pub id: i32, pub email: Option, pub gh_access_token: String, - pub api_token: String, pub gh_login: String, pub name: Option, pub gh_avatar: Option, @@ -105,16 +104,16 @@ impl User { } /// Queries the database for a user with a certain `api_token` value. - pub fn find_by_api_token(conn: &GenericConnection, token: &str) -> CargoResult { - let stmt = conn.prepare( - "SELECT * FROM users \ - WHERE api_token = $1 LIMIT 1", - )?; - let rows = stmt.query(&[&token])?; - rows.iter() - .next() - .map(|r| Model::from_row(&r)) - .chain_error(|| NotFound) + pub fn find_by_api_token(conn: &PgConnection, token_: &str) -> CargoResult { + use diesel::update; + use diesel::expression::now; + use schema::api_tokens::dsl::{api_tokens, token, user_id, last_used_at}; + use schema::users::dsl::{users, id}; + let user_id_ = update(api_tokens.filter(token.eq(token_))) + .set(last_used_at.eq(now.nullable())) + .returning(user_id) + .get_result::(conn)?; + Ok(users.filter(id.eq(user_id_)).get_result(conn)?) } /// Updates a user or inserts a new user into the database. @@ -203,7 +202,6 @@ impl Model for User { id: row.get("id"), email: row.get("email"), gh_access_token: row.get("gh_access_token"), - api_token: row.get("api_token"), gh_login: row.get("gh_login"), gh_id: row.get("gh_id"), name: row.get("name"), @@ -336,41 +334,13 @@ pub fn logout(req: &mut Request) -> CargoResult { Ok(req.json(&true)) } -/// Handles the `GET /me/reset_token` route. -pub fn reset_token(req: &mut Request) -> CargoResult { - let user = req.user()?; - - let conn = req.tx()?; - let rows = conn.query( - "UPDATE users SET api_token = DEFAULT \ - WHERE id = $1 RETURNING api_token", - &[&user.id], - )?; - let token = rows.iter().next().map(|r| r.get("api_token")).chain_error( - || NotFound, - )?; - - #[derive(RustcEncodable)] - struct R { - api_token: String, - } - Ok(req.json(&R { api_token: token })) -} - /// Handles the `GET /me` route. pub fn me(req: &mut Request) -> CargoResult { - let user = req.user()?; - #[derive(RustcEncodable)] struct R { user: EncodableUser, - api_token: String, } - let token = user.api_token.clone(); - Ok(req.json(&R { - user: user.clone().encodable(), - api_token: token, - })) + Ok(req.json(&R { user: req.user()?.clone().encodable() })) } /// Handles the `GET /users/:user_id` route. @@ -450,54 +420,3 @@ pub fn updates(req: &mut Request) -> CargoResult { meta: Meta { more: more }, })) } - -#[cfg(test)] -mod tests { - use super::*; - use diesel::pg::PgConnection; - use dotenv::dotenv; - use std::env; - - fn connection() -> PgConnection { - let _ = dotenv(); - let database_url = - env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set to run tests"); - let conn = PgConnection::establish(&database_url).unwrap(); - conn.begin_test_transaction().unwrap(); - conn - } - - #[test] - fn new_users_have_different_api_tokens() { - let conn = connection(); - let user1 = NewUser::new(1, "foo", None, None, None, "foo") - .create_or_update(&conn) - .unwrap(); - let user2 = NewUser::new(2, "bar", None, None, None, "bar") - .create_or_update(&conn) - .unwrap(); - - assert_ne!(user1.id, user2.id); - assert_ne!(user1.api_token, user2.api_token); - assert_eq!(32, user1.api_token.len()); - } - - #[test] - fn updating_existing_user_doesnt_change_api_token() { - let conn = connection(); - let user_after_insert = NewUser::new(1, "foo", None, None, None, "foo") - .create_or_update(&conn) - .unwrap(); - let original_token = user_after_insert.api_token; - NewUser::new(1, "bar", None, None, None, "bar_token") - .create_or_update(&conn) - .unwrap(); - let mut users = users::table.load::(&conn).unwrap(); - assert_eq!(1, users.len()); - let user = users.pop().unwrap(); - - assert_eq!("bar", user.gh_login); - assert_eq!("bar_token", user.gh_access_token); - assert_eq!(original_token, user.api_token); - } -} diff --git a/src/util/errors.rs b/src/util/errors.rs index a3737e29cf7..2982154b202 100644 --- a/src/util/errors.rs +++ b/src/util/errors.rs @@ -271,6 +271,28 @@ impl fmt::Display for Unauthorized { } } +struct BadRequest(String); + +impl CargoError for BadRequest { + fn description(&self) -> &str { + self.0.as_ref() + } + + fn response(&self) -> Option { + let mut response = json_response(&Bad { + errors: vec![StringError { detail: self.0.clone() }], + }); + response.status = (400, "Bad Request"); + Some(response) + } +} + +impl fmt::Display for BadRequest { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.0.fmt(f) + } +} + pub fn internal_error(error: &str, detail: &str) -> Box { Box::new(ConcreteCargoError { description: error.to_string(), @@ -298,6 +320,17 @@ pub fn human(error: &S) -> Box { }) } +/// This is intended to be used for errors being sent back to the Ember +/// frontend, not to cargo as cargo does not handle non-200 response codes well +/// (see https://github.com/rust-lang/cargo/issues/3995), but Ember requires +/// non-200 response codes for its stores to work properly. +/// +/// Since this is going back to the UI these errors are treated the same as +/// `human` errors, other than the HTTP status code. +pub fn bad_request(error: &S) -> Box { + Box::new(BadRequest(error.to_string())) +} + pub fn std_error(e: Box) -> Box { #[derive(Debug)] struct E(Box); diff --git a/src/util/mod.rs b/src/util/mod.rs index 02a8d2e4696..32dfae580f3 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -12,7 +12,7 @@ use conduit_router::{RouteBuilder, RequestParams}; use db::RequestTransaction; use self::errors::NotFound; -pub use self::errors::{CargoError, CargoResult, internal, human, internal_error}; +pub use self::errors::{CargoError, CargoResult, internal, human, bad_request, internal_error}; pub use self::errors::{ChainError, std_error}; pub use self::hasher::HashingReader; pub use self::head::Head;