11use 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 } ;
33use serde:: { Deserialize , Serialize } ;
44use std:: collections:: HashMap ;
55
@@ -23,13 +23,35 @@ const MAX_COMPLETIONS_PER_KIND: usize = 5_000;
2323const MAX_PATCH_OPERATIONS : usize = 5_000 ;
2424const MAX_SETTING_BYTES : usize = 256 * 1024 ;
2525const 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+
319609async 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+
343642fn 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) ]
518835mod tests {
519836 use super :: valid_nte_origin;
0 commit comments