Skip to content

Commit ca73493

Browse files
committed
ntehelper comments
1 parent 5b53dbc commit ca73493

3 files changed

Lines changed: 583 additions & 3 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
CREATE TABLE ntehelper_marker_comment_vote (
2+
comment_id bigint NOT NULL,
3+
user_id bigint NOT NULL,
4+
value integer NOT NULL,
5+
created_at timestamptz NOT NULL DEFAULT now(),
6+
updated_at timestamptz NOT NULL DEFAULT now(),
7+
PRIMARY KEY (comment_id, user_id),
8+
CONSTRAINT ntehelper_marker_comment_vote_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES ntehelper_marker_comment (id) ON DELETE CASCADE,
9+
CONSTRAINT ntehelper_marker_comment_vote_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
10+
CONSTRAINT ntehelper_marker_comment_vote_value_check CHECK (value IN (-1, 1))
11+
);
12+
13+
CREATE INDEX ntehelper_marker_comment_vote_user_idx
14+
ON ntehelper_marker_comment_vote (user_id, updated_at DESC);
15+
16+
CREATE INDEX ntehelper_marker_comment_user_created_idx
17+
ON ntehelper_marker_comment (user_id, created_at DESC);

src/api/ntehelper/mod.rs

Lines changed: 320 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use actix_session::Session;
2-
use actix_web::{get, patch, put, web, HttpRequest, HttpResponse, Responder};
2+
use actix_web::{delete, get, patch, post, put, web, HttpRequest, HttpResponse, Responder};
33
use serde::{Deserialize, Serialize};
44
use std::collections::HashMap;
55

@@ -23,13 +23,35 @@ const MAX_COMPLETIONS_PER_KIND: usize = 5_000;
2323
const MAX_PATCH_OPERATIONS: usize = 5_000;
2424
const MAX_SETTING_BYTES: usize = 256 * 1024;
2525
const PUBLIC_NTE_ORIGIN: &str = "https://nte.stardb.gg";
26+
const DEFAULT_MARKER_COMMENT_LIMIT: i64 = 10;
27+
const MAX_MARKER_COMMENT_LIMIT: i64 = 50;
28+
const MAX_MARKER_COMMENT_BODY_CHARS: usize = 250;
29+
const MARKER_COMMENT_CREATE_LIMIT_PER_WINDOW: i64 = 60;
2630

2731
#[derive(OpenApi)]
2832
#[openapi(
2933
tags((name = "ntehelper")),
30-
paths(get_me, get_state, put_state, patch_completions, put_setting, get_achievement_stats),
34+
paths(
35+
get_me,
36+
get_state,
37+
put_state,
38+
patch_completions,
39+
put_setting,
40+
get_achievement_stats,
41+
get_marker_comments,
42+
post_marker_comment,
43+
patch_marker_comment,
44+
delete_marker_comment,
45+
put_marker_comment_vote
46+
),
3147
components(schemas(
3248
MeResponse,
49+
MarkerCommentCreateRequest,
50+
MarkerCommentListQuery,
51+
MarkerCommentListResponse,
52+
MarkerCommentResponse,
53+
MarkerCommentUpdateRequest,
54+
MarkerCommentVoteRequest,
3355
StateResponse,
3456
StateCompletions,
3557
StateSettings,
@@ -51,7 +73,12 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
5173
.service(put_state)
5274
.service(patch_completions)
5375
.service(put_setting)
54-
.service(get_achievement_stats);
76+
.service(get_achievement_stats)
77+
.service(get_marker_comments)
78+
.service(post_marker_comment)
79+
.service(patch_marker_comment)
80+
.service(delete_marker_comment)
81+
.service(put_marker_comment_vote);
5582
}
5683

5784
#[derive(Serialize, ToSchema)]
@@ -118,6 +145,58 @@ struct AchievementStatsResponse {
118145
achievements: HashMap<String, AchievementStats>,
119146
}
120147

148+
#[derive(Deserialize, ToSchema)]
149+
struct MarkerCommentListQuery {
150+
#[serde(rename = "markerKey")]
151+
marker_key: String,
152+
limit: Option<i64>,
153+
cursor: Option<String>,
154+
}
155+
156+
#[derive(Serialize, ToSchema)]
157+
struct MarkerCommentListResponse {
158+
comments: Vec<MarkerCommentResponse>,
159+
#[serde(rename = "nextCursor")]
160+
next_cursor: Option<String>,
161+
}
162+
163+
#[derive(Serialize, ToSchema)]
164+
struct MarkerCommentResponse {
165+
id: String,
166+
#[serde(rename = "markerKey")]
167+
marker_key: String,
168+
username: String,
169+
body: String,
170+
#[serde(rename = "createdAt")]
171+
created_at: chrono::DateTime<chrono::Utc>,
172+
#[serde(rename = "updatedAt")]
173+
updated_at: chrono::DateTime<chrono::Utc>,
174+
score: i64,
175+
upvotes: i64,
176+
downvotes: i64,
177+
#[serde(rename = "viewerVote")]
178+
viewer_vote: i32,
179+
#[serde(rename = "ownedByViewer")]
180+
owned_by_viewer: bool,
181+
}
182+
183+
#[derive(Deserialize, ToSchema)]
184+
struct MarkerCommentCreateRequest {
185+
#[serde(rename = "markerKey")]
186+
marker_key: String,
187+
body: String,
188+
}
189+
190+
#[derive(Deserialize, ToSchema)]
191+
struct MarkerCommentUpdateRequest {
192+
body: String,
193+
}
194+
195+
#[derive(Deserialize, ToSchema)]
196+
struct MarkerCommentVoteRequest {
197+
value: i32,
198+
}
199+
121200
#[utoipa::path(
122201
tag = "ntehelper",
123202
get,
@@ -316,6 +395,217 @@ async fn get_achievement_stats(pool: web::Data<PgPool>) -> ApiResult<impl Respon
316395
}))
317396
}
318397

398+
#[utoipa::path(
399+
tag = "ntehelper",
400+
get,
401+
path = "/api/ntehelper/marker-comments",
402+
responses((status = 200, description = "Marker comments", body = MarkerCommentListResponse))
403+
)]
404+
#[get("/api/ntehelper/marker-comments")]
405+
async fn get_marker_comments(
406+
query: web::Query<MarkerCommentListQuery>,
407+
session: Session,
408+
pool: web::Data<PgPool>,
409+
) -> ApiResult<impl Responder> {
410+
if !valid_marker_key(&query.marker_key) {
411+
return Ok(HttpResponse::BadRequest().finish());
412+
}
413+
414+
let viewer_username = session.get::<String>("username").ok().flatten();
415+
let limit = query
416+
.limit
417+
.unwrap_or(DEFAULT_MARKER_COMMENT_LIMIT)
418+
.clamp(1, MAX_MARKER_COMMENT_LIMIT);
419+
let offset = query
420+
.cursor
421+
.as_deref()
422+
.and_then(|cursor| cursor.parse::<i64>().ok())
423+
.filter(|offset| *offset >= 0)
424+
.unwrap_or(0);
425+
426+
let mut comments = database::ntehelper::list_marker_comments(
427+
&query.marker_key,
428+
viewer_username.as_deref(),
429+
limit + 1,
430+
offset,
431+
&pool,
432+
)
433+
.await?;
434+
let next_cursor = if comments.len() > limit as usize {
435+
comments.truncate(limit as usize);
436+
Some((offset + limit).to_string())
437+
} else {
438+
None
439+
};
440+
441+
Ok(HttpResponse::Ok().json(MarkerCommentListResponse {
442+
comments: comments
443+
.into_iter()
444+
.map(MarkerCommentResponse::from)
445+
.collect(),
446+
next_cursor,
447+
}))
448+
}
449+
450+
#[utoipa::path(
451+
tag = "ntehelper",
452+
post,
453+
path = "/api/ntehelper/marker-comments",
454+
request_body = MarkerCommentCreateRequest,
455+
responses(
456+
(status = 200, description = "Created marker comment", body = MarkerCommentResponse),
457+
(status = 400, description = "Not logged in or invalid comment"),
458+
(status = 429, description = "Comment rate limit reached"),
459+
)
460+
)]
461+
#[post("/api/ntehelper/marker-comments")]
462+
async fn post_marker_comment(
463+
request: HttpRequest,
464+
session: Session,
465+
data: web::Json<MarkerCommentCreateRequest>,
466+
pool: web::Data<PgPool>,
467+
) -> ApiResult<impl Responder> {
468+
if !valid_nte_origin(&request) {
469+
return Ok(HttpResponse::Forbidden().finish());
470+
}
471+
472+
let Ok(Some(username)) = session.get::<String>("username") else {
473+
return Ok(HttpResponse::BadRequest().finish());
474+
};
475+
476+
let body = data.body.trim();
477+
if !valid_marker_key(&data.marker_key) || !valid_marker_comment_body(body) {
478+
return Ok(HttpResponse::BadRequest().finish());
479+
}
480+
481+
if let Some(retry_after) = database::ntehelper::marker_comment_retry_after(
482+
&username,
483+
MARKER_COMMENT_CREATE_LIMIT_PER_WINDOW,
484+
&pool,
485+
)
486+
.await?
487+
{
488+
return Ok(HttpResponse::TooManyRequests()
489+
.append_header(("Retry-After", retry_after.to_string()))
490+
.finish());
491+
}
492+
493+
let comment =
494+
database::ntehelper::create_marker_comment(&username, &data.marker_key, body, &pool)
495+
.await?;
496+
497+
Ok(HttpResponse::Ok().json(MarkerCommentResponse::from(comment)))
498+
}
499+
500+
#[utoipa::path(
501+
tag = "ntehelper",
502+
patch,
503+
path = "/api/ntehelper/marker-comments/{commentId}",
504+
request_body = MarkerCommentUpdateRequest,
505+
responses((status = 200, description = "Updated marker comment", body = MarkerCommentResponse))
506+
)]
507+
#[patch("/api/ntehelper/marker-comments/{comment_id}")]
508+
async fn patch_marker_comment(
509+
request: HttpRequest,
510+
session: Session,
511+
comment_id: web::Path<i64>,
512+
data: web::Json<MarkerCommentUpdateRequest>,
513+
pool: web::Data<PgPool>,
514+
) -> ApiResult<impl Responder> {
515+
if !valid_nte_origin(&request) {
516+
return Ok(HttpResponse::Forbidden().finish());
517+
}
518+
519+
let Ok(Some(username)) = session.get::<String>("username") else {
520+
return Ok(HttpResponse::BadRequest().finish());
521+
};
522+
523+
let body = data.body.trim();
524+
if !valid_marker_comment_body(body) {
525+
return Ok(HttpResponse::BadRequest().finish());
526+
}
527+
528+
let Some(comment) =
529+
database::ntehelper::update_marker_comment(comment_id.into_inner(), &username, body, &pool)
530+
.await?
531+
else {
532+
return Ok(HttpResponse::Forbidden().finish());
533+
};
534+
535+
Ok(HttpResponse::Ok().json(MarkerCommentResponse::from(comment)))
536+
}
537+
538+
#[utoipa::path(
539+
tag = "ntehelper",
540+
delete,
541+
path = "/api/ntehelper/marker-comments/{commentId}",
542+
responses((status = 200, description = "Deleted marker comment"))
543+
)]
544+
#[delete("/api/ntehelper/marker-comments/{comment_id}")]
545+
async fn delete_marker_comment(
546+
request: HttpRequest,
547+
session: Session,
548+
comment_id: web::Path<i64>,
549+
pool: web::Data<PgPool>,
550+
) -> ApiResult<impl Responder> {
551+
if !valid_nte_origin(&request) {
552+
return Ok(HttpResponse::Forbidden().finish());
553+
}
554+
555+
let Ok(Some(username)) = session.get::<String>("username") else {
556+
return Ok(HttpResponse::BadRequest().finish());
557+
};
558+
559+
if !database::ntehelper::delete_marker_comment(comment_id.into_inner(), &username, &pool)
560+
.await?
561+
{
562+
return Ok(HttpResponse::Forbidden().finish());
563+
}
564+
565+
Ok(HttpResponse::Ok().json(json!({ "deleted": true })))
566+
}
567+
568+
#[utoipa::path(
569+
tag = "ntehelper",
570+
put,
571+
path = "/api/ntehelper/marker-comments/{commentId}/vote",
572+
request_body = MarkerCommentVoteRequest,
573+
responses((status = 200, description = "Updated marker comment vote", body = MarkerCommentResponse))
574+
)]
575+
#[put("/api/ntehelper/marker-comments/{comment_id}/vote")]
576+
async fn put_marker_comment_vote(
577+
request: HttpRequest,
578+
session: Session,
579+
comment_id: web::Path<i64>,
580+
data: web::Json<MarkerCommentVoteRequest>,
581+
pool: web::Data<PgPool>,
582+
) -> ApiResult<impl Responder> {
583+
if !valid_nte_origin(&request) {
584+
return Ok(HttpResponse::Forbidden().finish());
585+
}
586+
587+
let Ok(Some(username)) = session.get::<String>("username") else {
588+
return Ok(HttpResponse::BadRequest().finish());
589+
};
590+
591+
if !matches!(data.value, -1 | 0 | 1) {
592+
return Ok(HttpResponse::BadRequest().finish());
593+
}
594+
595+
let Some(comment) = database::ntehelper::set_marker_comment_vote(
596+
comment_id.into_inner(),
597+
&username,
598+
data.value,
599+
&pool,
600+
)
601+
.await?
602+
else {
603+
return Ok(HttpResponse::NotFound().finish());
604+
};
605+
606+
Ok(HttpResponse::Ok().json(MarkerCommentResponse::from(comment)))
607+
}
608+
319609
async fn load_state(username: &str, pool: &PgPool) -> ApiResult<StateResponse> {
320610
let completions =
321611
StateCompletions::from_db(database::ntehelper::get_completions(username, pool).await?);
@@ -340,6 +630,15 @@ fn valid_completion_id(id: &str) -> bool {
340630
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '.' | '/'))
341631
}
342632

633+
fn valid_marker_key(marker_key: &str) -> bool {
634+
valid_completion_id(marker_key)
635+
}
636+
637+
fn valid_marker_comment_body(body: &str) -> bool {
638+
let char_count = body.chars().count();
639+
char_count >= 1 && char_count <= MAX_MARKER_COMMENT_BODY_CHARS
640+
}
641+
343642
fn valid_setting_namespace(namespace: &str) -> bool {
344643
SETTING_NAMESPACES.contains(&namespace)
345644
}
@@ -514,6 +813,24 @@ impl StateSettings {
514813
}
515814
}
516815

816+
impl From<database::ntehelper::DbMarkerComment> for MarkerCommentResponse {
817+
fn from(comment: database::ntehelper::DbMarkerComment) -> Self {
818+
MarkerCommentResponse {
819+
id: comment.id.to_string(),
820+
marker_key: comment.marker_key,
821+
username: comment.username,
822+
body: comment.body,
823+
created_at: comment.created_at,
824+
updated_at: comment.updated_at,
825+
score: comment.score,
826+
upvotes: comment.upvotes,
827+
downvotes: comment.downvotes,
828+
viewer_vote: comment.viewer_vote,
829+
owned_by_viewer: comment.owned_by_viewer,
830+
}
831+
}
832+
}
833+
517834
#[cfg(test)]
518835
mod tests {
519836
use super::valid_nte_origin;

0 commit comments

Comments
 (0)