Skip to content

Commit 63a6669

Browse files
committed
Implement PATCH /users, DELETE /users
1 parent 123a3ff commit 63a6669

File tree

11 files changed

+426
-58
lines changed

11 files changed

+426
-58
lines changed

crates/core/src/api.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,10 @@ pub fn ok<T>(status: StatusCode, data: T) -> Response<T> {
464464
}
465465
}
466466

467+
pub fn no_content() -> Response<()> {
468+
from_default(StatusCode::NO_CONTENT)
469+
}
470+
467471
pub fn from_default<T: Default>(status: StatusCode) -> Response<T> {
468472
ok(status, T::default())
469473
}

crates/database/src/schema/postgresql.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ diesel::table! {
171171
#[max_length = 64]
172172
name -> Nullable<Varchar>,
173173
id -> Text,
174-
prefers_gravatar -> Nullable<Bool>,
174+
prefers_gravatar -> Bool,
175175
}
176176
}
177177

crates/database/src/schema/sqlite.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ diesel::table! {
160160
admin -> Bool,
161161
name -> Nullable<Text>,
162162
id -> Text,
163-
prefers_gravatar -> Nullable<Bool>,
163+
prefers_gravatar -> Bool,
164164
}
165165
}
166166

crates/server/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// See the License for the specific language governing permissions and
1414
// limitations under the License.
1515

16-
#![feature(never_type, decl_macro)]
16+
#![feature(never_type, decl_macro, let_chains)]
1717

1818
mod state;
1919
pub use state::*;

crates/server/src/ops/db/user.rs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -75,21 +75,21 @@ pub async fn get<ID: Into<NameOrUlid>>(ctx: &ServerContext, id: ID) -> eyre::Res
7575
})
7676
}
7777

78-
pub async fn delete(ctx: &ServerContext, user: User) {
79-
// The actual delete is here so we can discard the result and send error reports to Sentry
80-
// and log the error if any occur since we only want to do `tokio::spawn`.
81-
#[instrument(name = "charted.server.users.delete", skip_all, fields(%user.id, %user.username))]
82-
async fn actual_delete(ctx: &ServerContext, user: User) -> eyre::Result<()> {
83-
trace!("performing deletion of user...");
78+
#[instrument(name = "charted.server.users.delete", skip_all, fields(%user.id, %user.username))]
79+
pub async fn delete(ctx: ServerContext, user: User) -> eyre::Result<()> {
80+
trace!("deleting user from database");
8481

85-
Ok(())
86-
}
82+
Ok(())
83+
}
84+
85+
async fn delete_all_repositories(ctx: &ServerContext, user: &User) -> eyre::Result<()> {
86+
Ok(())
87+
}
88+
89+
async fn delete_all_organizations(ctx: &ServerContext, user: &User) -> eyre::Result<()> {
90+
Ok(())
91+
}
8792

88-
actual_delete(ctx, user)
89-
.await
90-
.inspect_err(|e| {
91-
sentry_eyre::capture_report(e);
92-
tracing::error!(error = %e, "failed to delete user");
93-
})
94-
.ok();
93+
async fn delete_persistent_metadata(ctx: &ServerContext, user: &User) -> eyre::Result<()> {
94+
Ok(())
9595
}

crates/server/src/routing/v1/user/mod.rs

Lines changed: 198 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use crate::{
2323
extract::{Json, Path},
2424
hash_password,
2525
middleware::session::{self, Session},
26-
openapi::ApiErrorResponse,
26+
openapi::{ApiErrorResponse, EmptyApiResponse},
2727
ops, NameOrUlid, ServerContext,
2828
};
2929
use axum::{extract::State, http::StatusCode, routing, Extension, Router};
@@ -34,7 +34,7 @@ use charted_database::{
3434
};
3535
use charted_types::{
3636
payloads::user::{CreateUserPayload, PatchUserPayload},
37-
User,
37+
PGUser, SqliteUser, User,
3838
};
3939
use diesel::{backend::Backend, ExpressionMethods, QueryDsl};
4040
use eyre::Context;
@@ -282,6 +282,7 @@ pub async fn create_user(
282282

283283
let user = User {
284284
verified_publisher: false,
285+
prefers_gravatar: false,
285286
gravatar_email: None,
286287
description: None,
287288
avatar_hash: None,
@@ -386,17 +387,55 @@ pub async fn get_user(State(cx): State<ServerContext>, Path(id_or_name): Path<Na
386387
get,
387388
path = "/v1/users/@me",
388389
operation_id = "getSelfUser",
389-
tags = ["Users"]
390+
tags = ["Users"],
391+
responses(
392+
(
393+
status = 200,
394+
description = "A single user found",
395+
body = api::Response<User>,
396+
content_type = "application/json"
397+
),
398+
(
399+
status = 4XX,
400+
description = "Any occurrence when authentication fails",
401+
body = ApiErrorResponse,
402+
content_type = "application/json"
403+
)
404+
)
390405
)]
391406
pub async fn get_self(Extension(Session { user, .. }): Extension<Session>) -> api::Response<User> {
392407
api::ok(StatusCode::OK, user)
393408
}
394409

395410
/// Patch metadata about the current user.
396-
#[utoipa::path(patch, path = "/v1/users/@me", operation_id = "patchSelf", tag = "Users")]
411+
#[utoipa::path(
412+
patch,
413+
path = "/v1/users/@me",
414+
operation_id = "patchSelf",
415+
tag = "Users",
416+
request_body(
417+
content_type = "application/json",
418+
description = "Update payload for the `User` entity",
419+
content = ref("PatchUserPayload")
420+
),
421+
responses(
422+
(
423+
status = 204,
424+
description = "Patch was successfully reflected",
425+
body = EmptyApiResponse,
426+
content_type = "application/json"
427+
),
428+
(
429+
status = 4XX,
430+
description = "Any occurrence when authentication fails or if the patch couldn't be reflected",
431+
body = ApiErrorResponse,
432+
content_type = "application/json"
433+
)
434+
)
435+
)]
397436
pub async fn patch(
398437
State(cx): State<ServerContext>,
399-
Extension(Session { user, .. }): Extension<Session>,
438+
Extension(Session { mut user, .. }): Extension<Session>,
400439
Json(PatchUserPayload {
401440
prefers_gravatar,
402441
gravatar_email,
@@ -407,41 +446,177 @@ pub async fn patch(
407446
name,
408447
}): Json<PatchUserPayload>,
409448
) -> api::Result<()> {
449+
if let Some(prefers_gravatar) = prefers_gravatar {
450+
if user.prefers_gravatar != prefers_gravatar {
451+
user.prefers_gravatar = prefers_gravatar;
452+
}
453+
}
454+
455+
if let Some(gravatar_email) = gravatar_email.as_deref() {
456+
// if `old` == None, then update the description
457+
// if `old` == Some(..) && `old` != `gravatar_email`, commit update
458+
// if `old` == Some(..) && `old` == `""`, commit as `None`
459+
let old = user.gravatar_email.as_deref();
460+
if old.is_none() && !gravatar_email.is_empty() {
461+
user.gravatar_email = Some(gravatar_email.to_owned());
462+
} else if let Some(old) = old
463+
&& !old.is_empty()
464+
&& old != gravatar_email
465+
{
466+
user.gravatar_email = Some(gravatar_email.to_owned());
467+
} else if gravatar_email.is_empty() {
468+
user.description = None;
469+
}
470+
}
471+
472+
if let Some(description) = description {
473+
if description.len() > 140 {
474+
let len = description.len();
475+
return Err(api::err(
476+
StatusCode::NOT_ACCEPTABLE,
477+
(
478+
api::ErrorCode::ValidationFailed,
479+
"expected `description` to be less than 140 characters",
480+
json!({
481+
"expected": 140,
482+
"received": {
483+
"over": len - 140,
484+
"length": len
485+
}
486+
}),
487+
),
488+
));
489+
}
490+
491+
// if `old` == None, then update the description
492+
// if `old` == Some(..) && `old` != `descroption`, commit update
493+
// if `old` == Some(..) && `old` == `""`, commit as `None`
494+
let old = user.description.as_deref();
495+
if old.is_none() {
496+
user.description = Some(description);
497+
} else if let Some(old) = old
498+
&& !old.is_empty()
499+
&& old != description
500+
{
501+
user.description = Some(description);
502+
} else if description.is_empty() {
503+
user.description = None;
504+
}
505+
}
506+
507+
if let Some(username) = username {
508+
// We need to validate that the username isn't already taken, so we will get a
509+
// temporary connection.
510+
match ops::db::user::get(&cx, NameOrUlid::Name(username.clone())).await {
511+
Ok(None) => {}
512+
Ok(Some(_)) => {
513+
return Err(api::err(
514+
StatusCode::CONFLICT,
515+
(
516+
api::ErrorCode::EntityAlreadyExists,
517+
"user with username already exists",
518+
json!({"username":&username}),
519+
),
520+
))
521+
}
522+
523+
Err(e) => return Err(api::system_failure(e)),
524+
};
525+
526+
// In deserialization of the request body, it'll validate that
527+
// the name is correct anyway, so it is ok to set it here without
528+
// even more validation.
529+
user.username = username;
530+
}
531+
532+
if let Some(password) = password.as_deref() {
533+
let authz = cx.authz.as_ref();
534+
if authz.downcast::<charted_authz_local::Backend>().is_none() {
535+
return Err(api::err(
536+
StatusCode::NOT_ACCEPTABLE,
537+
(
538+
api::ErrorCode::InvalidBody,
539+
"`password` is only supported on the local authz backend",
540+
),
541+
));
542+
}
543+
544+
if password.len() < 8 {
545+
return Err(api::err(
546+
StatusCode::NOT_ACCEPTABLE,
547+
(
548+
api::ErrorCode::InvalidPassword,
549+
"`password` length was expected to be 8 characters or longer",
550+
),
551+
));
552+
}
553+
554+
user.password = Some(hash_password(password).map_err(|_| api::internal_server_error())?);
555+
}
556+
410557
let mut conn = cx
411558
.pool
412559
.get()
413560
.inspect_err(|e| {
414561
sentry::capture_error(e);
415562
tracing::error!(error = %e, "failed to establish database connection");
416563
})
417-
.map_err(|x| api::system_failure::<eyre::Report>(x.into()))?;
564+
.map_err(|_| api::internal_server_error())?;
418565

419-
let _: Result<(), diesel::result::Error> = charted_database::connection!(@raw conn {
566+
charted_database::connection!(@raw conn {
420567
PostgreSQL(conn) => conn.build_transaction().run(|txn| {
421568
use postgresql::users::{dsl, table};
422569

423-
// We have to box this query since we are doing multiple conditions
424-
let mut update = diesel::update(table.filter(dsl::id.eq(user.id))).into_boxed::<diesel::pg::Pg>();
425-
426-
// `prefers_gravatar` != null; perform update
427-
if let Some(prefers_gravatar) = prefers_gravatar {
428-
//update = update.set(dsl::prefers_gravatar.eq(prefers_gravatar));
429-
}
430-
431-
todo!()
570+
diesel::update(table.filter(dsl::id.eq(user.id)))
571+
.set(user.into_pg())
572+
.execute(txn)
573+
.map(|_| ())
432574
});
433575

434576
SQLite(conn) => conn.immediate_transaction(|txn| {
435577
use sqlite::users::{dsl, table};
436578

437-
let mut update = diesel::update(table.filter(dsl::id.eq(user.id))).into_boxed::<diesel::sqlite::Sqlite>();
438-
439-
todo!()
579+
diesel::update(table.filter(dsl::id.eq(user.id)))
580+
.set(user.into_sqlite())
581+
.execute(txn)
582+
.map(|_| ())
440583
});
441-
});
584+
})
585+
.inspect_err(|e| {
586+
sentry::capture_error(e);
587+
tracing::error!(error = %e, "failed to update user");
588+
})
589+
.map_err(|_| api::internal_server_error())?;
442590

443-
todo!()
591+
Ok(api::no_content())
444592
}
445593

446-
#[utoipa::path(delete, path = "/v1/users/@me", operation_id = "deleteSelf", tag = "Users")]
447-
pub async fn delete() {}
594+
#[utoipa::path(
595+
delete,
596+
597+
path = "/v1/users/@me",
598+
operation_id = "deleteSelf",
599+
tag = "Users",
600+
responses(
601+
(
602+
status = 204,
603+
description = "User is scheduled for deletion and will be deleted",
604+
body = EmptyApiResponse,
605+
content_type = "application/json"
606+
)
607+
)
608+
)]
609+
pub async fn delete(
610+
State(cx): State<ServerContext>,
611+
Extension(Session { user, .. }): Extension<Session>,
612+
) -> api::Result<()> {
613+
ops::db::user::delete(cx, user)
614+
.await
615+
.inspect_err(|e| {
616+
sentry_eyre::capture_report(e);
617+
tracing::error!(error = %e, "failed to delete user");
618+
})
619+
.map_err(|_| api::internal_server_error())?;
620+
621+
Ok(api::no_content())
622+
}

0 commit comments

Comments
 (0)