Added /api on frontend, identity banned users on profile with alert

This commit is contained in:
nise.moe 2024-06-10 14:28:53 +02:00
parent f22b139e51
commit 1907c22b16
12 changed files with 213 additions and 26 deletions

View File

@ -28,6 +28,12 @@ data class UserDetails(
val stolen_replays: Int = 0 val stolen_replays: Int = 0
) )
data class UserDetailsExtended(
val userDetails: UserDetails,
val is_banned: Boolean = false,
val approximate_ban_date: OffsetDateTime? = null
)
@Serializable @Serializable
@AllowCacheSerialization @AllowCacheSerialization
data class Statistics( data class Statistics(

View File

@ -26,6 +26,8 @@ class UserDetailsController(
val suspicious_scores: List<SuspiciousScoreEntry>, val suspicious_scores: List<SuspiciousScoreEntry>,
val similar_replays: List<SimilarReplayEntry>, val similar_replays: List<SimilarReplayEntry>,
val total_scores: Int = 0, val total_scores: Int = 0,
val is_banned: Boolean,
val approximate_ban_date: String?,
) )
data class UserQueueRequest( data class UserQueueRequest(
@ -56,18 +58,22 @@ class UserDetailsController(
@PostMapping("user-details") @PostMapping("user-details")
fun getUserDetails(@RequestBody request: UserDetailsRequest): ResponseEntity<UserDetailsResponse> { fun getUserDetails(@RequestBody request: UserDetailsRequest): ResponseEntity<UserDetailsResponse> {
val userDetails = this.userService.getUserDetails(username = request.userId) val userDetailsExtended = this.userService.getUserDetails(username = request.userId)
?: return ResponseEntity.notFound().build() ?: return ResponseEntity.notFound().build()
val userId = userDetailsExtended.userDetails.user_id
var suspiciousScoresCondition = this.scoreService.getDefaultCondition() var suspiciousScoresCondition = this.scoreService.getDefaultCondition()
suspiciousScoresCondition = suspiciousScoresCondition.and(SCORES.USER_ID.eq(userDetails.user_id)) suspiciousScoresCondition = suspiciousScoresCondition.and(SCORES.USER_ID.eq(userId))
val response = UserDetailsResponse( val response = UserDetailsResponse(
user_details = userDetails, user_details = userDetailsExtended.userDetails,
queue_details = this.userQueueService.getUserQueueDetails(userDetails.user_id), queue_details = this.userQueueService.getUserQueueDetails(userId),
suspicious_scores = this.scoreService.getSuspiciousScores(suspiciousScoresCondition), suspicious_scores = this.scoreService.getSuspiciousScores(suspiciousScoresCondition),
similar_replays = this.scoreService.getSimilarReplaysForUserId(userDetails.user_id), similar_replays = this.scoreService.getSimilarReplaysForUserId(userId),
total_scores = this.userService.getTotalUserScores(userDetails.user_id) total_scores = this.userService.getTotalUserScores(userId),
is_banned = userDetailsExtended.is_banned,
approximate_ban_date = userDetailsExtended.approximate_ban_date?.toString()
) )
return ResponseEntity.ok(response) return ResponseEntity.ok(response)
} }

View File

@ -1,19 +1,16 @@
package com.nisemoe.nise.database package com.nisemoe.nise.database
import com.nisemoe.generated.tables.records.UpdateUserQueueRecord
import com.nisemoe.generated.tables.records.UsersRecord import com.nisemoe.generated.tables.records.UsersRecord
import com.nisemoe.generated.tables.references.SCORES import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.generated.tables.references.UPDATE_USER_QUEUE
import com.nisemoe.generated.tables.references.USERS import com.nisemoe.generated.tables.references.USERS
import com.nisemoe.nise.Format import com.nisemoe.nise.Format
import com.nisemoe.nise.UserDetails import com.nisemoe.nise.UserDetails
import com.nisemoe.nise.UserQueueDetails import com.nisemoe.nise.UserDetailsExtended
import com.nisemoe.nise.osu.OsuApi import com.nisemoe.nise.osu.OsuApi
import com.nisemoe.nise.osu.OsuApiModels import com.nisemoe.nise.osu.OsuApiModels
import org.jooq.DSLContext import org.jooq.DSLContext
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
@ -74,13 +71,13 @@ class UserService(
return this.mapUserToDatabase(apiUser) return this.mapUserToDatabase(apiUser)
} }
fun getUserDetails(username: String): UserDetails? { fun getUserDetails(username: String): UserDetailsExtended? {
val user = dslContext.selectFrom(USERS) val user = dslContext.selectFrom(USERS)
.where(USERS.USERNAME.equalIgnoreCase(username.lowercase())) .where(USERS.USERNAME.equalIgnoreCase(username.lowercase()))
.fetchOneInto(UsersRecord::class.java) .fetchOneInto(UsersRecord::class.java)
if (user != null) { if (user != null) {
return UserDetails( val userDetails = UserDetails(
user.userId!!, user.userId!!,
user.username!!, user.username!!,
user.rank, user.rank,
@ -91,6 +88,11 @@ class UserService(
user.countryRank, user.countryRank,
user.playcount user.playcount
) )
return UserDetailsExtended(
userDetails = userDetails,
is_banned = user.isBanned ?: false,
approximate_ban_date = user.approxBanDate
)
} }
// The database does NOT have the user; we will now use the osu!api // The database does NOT have the user; we will now use the osu!api
@ -100,7 +102,9 @@ class UserService(
// Persist to database // Persist to database
insertApiUser(apiUser) insertApiUser(apiUser)
return this.mapUserToDatabase(apiUser) return UserDetailsExtended(
userDetails = this.mapUserToDatabase(apiUser)
)
} }
fun insertApiUser(apiUser: OsuApiModels.UserExtended) { fun insertApiUser(apiUser: OsuApiModels.UserExtended) {

View File

@ -4,13 +4,19 @@
<p> <p>
if you'd like to retrieve data from our database, you are invited to use the same endpoints meant for the frontend but in a programmatic way. <u>currently there's no rate limits.</u> if you'd like to retrieve data from our database, you are invited to use the same endpoints meant for the frontend but in a programmatic way. <u>currently there's no rate limits.</u>
</p> </p>
<div class="alert mb-2 text-center">
<p>
if you have any issues related to CloudFlare, let me know on discord. currently all api requests are routed via CF so it's possible it might block certain IPs.
</p>
</div>
<p>as of today, you MUST pass the following parameters:</p> <p>as of today, you MUST pass the following parameters:</p>
<ul> <ul>
<li>The <code>X-NISE-API</code> header with a value of <code>20240218</code> in every request.</li> <li>The <code>X-NISE-API</code> header with a value of <code>20240218</code> in every request. This should prevent random shit from breaking in the future (if you keep passing it)</li>
<li>The <code>Accept</code> header with a value of <code>application/json</code> (if you want JSON instead of XML)</li> <li>The <code>Accept</code> header with a value of <code>application/json</code> (if you want JSON instead of XML)</li>
</ul> </ul>
<h1 class="mt-4">## scores search</h1> <h1 class="mt-4">## scores search</h1>
<p>score search is based on predicates. a predicate is a list of specifications/conditions to match results. predicates can be combined with operators such as <code>AND</code> and <code>OR</code></p> <p>score search is based on predicates. a predicate is a list of specifications/conditions to match results. predicates can be combined with operators such as <code>AND</code> and <code>OR</code></p>
<p style="font-weight: bold; color: orange">COMING SOON; the route exists but its cancer for api users to form the requests.</p>
<h1 class="mt-4">## get single score</h1> <h1 class="mt-4">## get single score</h1>
<p>if you have a <code>replay_id</code>, you can retrieve all the information 'bout that score.</p> <p>if you have a <code>replay_id</code>, you can retrieve all the information 'bout that score.</p>
@ -19,7 +25,110 @@
<li><strong>METHOD:</strong> GET</li> <li><strong>METHOD:</strong> GET</li>
</ul> </ul>
Example: Example:
<code style="font-size: 12px">curl -H "X-NISE-API: 20240218" -H "Accept: application/json" https://nise.moe/api/score/3808640439</code> <br>
<app-code-with-copy-button>
curl -H "X-NISE-API: 20240218" -H "Accept: application/json" https://nise.moe/api/score/3808640439
</app-code-with-copy-button>
<h1 class="mt-4">## get single replay (data)</h1>
<p>if you have a <code>replay_id</code>, you can retrieve the data that is used for the replay viewer <i>(replay.nise.moe)</i>
<p>it contains the beatmap data, the full replay data, and all calculated judgements (by circleguard)</p>
<ul>
<li><strong>ENDPOINT:</strong> <code>/api/score/&#123;replay_id&#125;/replay</code></li>
<li><strong>METHOD:</strong> GET</li>
</ul>
Example:
<br>
<app-code-with-copy-button>
curl -H "X-NISE-API: 20240218" -H "Accept: application/json" https://nise.moe/api/score/3808640439/replay
</app-code-with-copy-button>
<h1 class="mt-4">## get single replay (.osr)</h1>
<p>if you have a <code>replay_id</code>, you can retrieve the <i>emulated</i> .osr replay file.</p>
<p><i>wtf is an emulated .osr replay file?</i> 🡒 the osu!api does NOT return a "real" replay, so we fill the rest of the details.</p>
<p style="font-weight: bold; color: orange">COMING SOON</p>
<h1 class="mt-4">## get user details</h1>
<p>if you have an <code>user_id</code>, you can retrieve everything we know 'bout that user.
<p style="font-weight: bold; color: #fa5c5c">>> <strong>user_id</strong> is the username. sry.</p>
<ul>
<li><strong>ENDPOINT:</strong> <code>/api/user-details</code></li>
<li><strong>METHOD:</strong> POST</li>
<li><strong>POST FIELDS:</strong> user_id: str (*required)</li>
<li><strong>POST FORMAT:</strong> JSON only</li>
</ul>
Example:
<br>
<app-code-with-copy-button>
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '&#123;"userId": "degenerate"&#125;' https://nise.moe/api/user-details
</app-code-with-copy-button>
<h1 class="mt-4">## get suspicious scores</h1>
<p>returns a list of suspicious scores (e.g. cvUR <= 25, basically <span style="font-size: 18px" class="board">/sus/</span>)</p>
<p>it is not <i>live</i>, but rather updates every N minutes depending on server load (usually ~10 minutes)</p>
<ul>
<li><strong>ENDPOINT:</strong> <code>/api/suspicious-scores</code></li>
<li><strong>METHOD:</strong> GET</li>
</ul>
Example:
<br>
<app-code-with-copy-button>
curl -H "X-NISE-API: 20240218" -H "Accept: application/json" https://nise.moe/api/suspicious-scores
</app-code-with-copy-button>
<h1 class="mt-4">## get stolen replays</h1>
<p>returns a list of all replay pairs with < 10 similarity ratio (basically <span style="font-size: 18px" class="board">/nu/</span>)</p>
<p>it is not <i>live</i>, but rather updates every N minutes depending on server load (usually ~10 minutes)</p>
<ul>
<li><strong>ENDPOINT:</strong> <code>/api/similar-replays</code></li>
<li><strong>METHOD:</strong> GET</li>
</ul>
Example:
<br>
<app-code-with-copy-button>
curl -H "X-NISE-API: 20240218" -H "Accept: application/json" https://nise.moe/api/similar-replays
</app-code-with-copy-button>
<h1 class="mt-4">## get banlist</h1>
<p>returns a list of all <i>possibly<strong>*</strong></i> banned users<br><strong>*</strong>(so users for which the osu!web page returned the <code>User not found! ;_;</code> response.)</p>
<p style="font-weight: bold; color: #ff6dff">>> page size aint configurable, it's 100 atm. maybe a TODO.</p>
<ul>
<li><strong>ENDPOINT:</strong> <code>/api/banlist</code></li>
<li><strong>METHOD:</strong> POST</li>
<li><strong>POST FIELDS:</strong> page: int (*required, starts at 1)</li>
<li><strong>POST FORMAT:</strong> JSON only</li>
</ul>
Example:
<br>
<app-code-with-copy-button>
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '&#123;"page": 1&#125;' https://nise.moe/api/banlist
</app-code-with-copy-button>
<h1 class="mt-4">## analyze replay file (.osr)</h1>
<p>if you have an .osr file, you can analyze it and get results + an always-online static page for it.</p>
<ul>
<li><strong>ENDPOINT:</strong> <code>/api/analyze</code></li>
<li><strong>METHOD:</strong> POST</li>
<li><strong>POST FIELDS:</strong> replay: file (*required, an .osr file)</li>
</ul>
<app-code-with-copy-button>
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -F "replay=&#64;replay1.osr" https://nise.moe/api/analyze
</app-code-with-copy-button>
<p>the response will include an <code>id</code> parameter, which identifies the replay you've uploaded.</p>
<p>that id will be subsequently available at:</p>
<ul>
<li><strong>WEB INTERFACE</strong>: https://nise.moe/c/&#123;id&#125;</li>
<li><strong>API:</strong> https://nise.moe/api/user-scores/&#123;id&#125;</li>
</ul>
<hr>
<div class="mb-2 text-center">
<p>
thanks for using the api. if you have any issues or requests, let me know on discord.
</p>
<p><strong>plz,</strong> try to not abuse it too much.</p>
</div>
</div> </div>
</div> </div>

View File

@ -2,18 +2,22 @@ import { Component } from '@angular/core';
import {CalculatePageRangePipe} from "../../corelib/calculate-page-range.pipe"; import {CalculatePageRangePipe} from "../../corelib/calculate-page-range.pipe";
import {CuteLoadingComponent} from "../../corelib/components/cute-loading/cute-loading.component"; import {CuteLoadingComponent} from "../../corelib/components/cute-loading/cute-loading.component";
import {DatePipe, DecimalPipe, NgForOf, NgIf} from "@angular/common"; import {DatePipe, DecimalPipe, NgForOf, NgIf} from "@angular/common";
import {
CodeWithCopyButtonComponent
} from "../../corelib/components/code-with-copy-button/code-with-copy-button.component";
@Component({ @Component({
selector: 'app-api', selector: 'app-api',
standalone: true, standalone: true,
imports: [ imports: [
CalculatePageRangePipe, CalculatePageRangePipe,
CuteLoadingComponent, CuteLoadingComponent,
DatePipe, DatePipe,
DecimalPipe, DecimalPipe,
NgForOf, NgForOf,
NgIf NgIf,
], CodeWithCopyButtonComponent
],
templateUrl: './api.component.html', templateUrl: './api.component.html',
styleUrl: './api.component.css' styleUrl: './api.component.css'
}) })

View File

@ -35,5 +35,5 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
<div class="text-center version"> <div class="text-center version">
v20240508 v20240510
</div> </div>

View File

@ -19,6 +19,17 @@
</ng-container> </ng-container>
</h1> </h1>
<ng-container *ngIf="this.userInfo.is_banned && this.userInfo.approximate_ban_date">
<div class="alert alert-danger mb-2 text-center">
<p>
uh oh! this user <i>might</i> be banned.
</p>
<p>last time ({{ this.userInfo.approximate_ban_date | date: 'medium' }}) we checked their osu!web profile,<br> we got the dreaded <code>User not found! ;_;</code> response.</p>
</div>
</ng-container>
<div class="mb-2 mt-2 btn-group"> <div class="mb-2 mt-2 btn-group">
<a [href]="'https://osu.ppy.sh/users/' + this.userInfo.user_details.user_id + '/osu'" <a [href]="'https://osu.ppy.sh/users/' + this.userInfo.user_details.user_id + '/osu'"
class="btn btn-outline-secondary btn-sm" target="_blank"> class="btn btn-outline-secondary btn-sm" target="_blank">

View File

@ -3,7 +3,7 @@ import {SimilarReplay, SuspiciousScore} from "../replays";
import { HttpClient } from "@angular/common/http"; import { HttpClient } from "@angular/common/http";
import {catchError, EMPTY, finalize, Observable, Subscription} from "rxjs"; import {catchError, EMPTY, finalize, Observable, Subscription} from "rxjs";
import {environment} from "../../environments/environment"; import {environment} from "../../environments/environment";
import {DecimalPipe, JsonPipe, NgForOf, NgIf, NgOptimizedImage} from "@angular/common"; import {DatePipe, DecimalPipe, JsonPipe, NgForOf, NgIf, NgOptimizedImage} from "@angular/common";
import {ActivatedRoute, RouterLink} from "@angular/router"; import {ActivatedRoute, RouterLink} from "@angular/router";
import {UserDetails, UserQueueDetails} from "../userDetails"; import {UserDetails, UserQueueDetails} from "../userDetails";
import {calculateTimeAgo, countryCodeToFlag, formatDuration} from "../format"; import {calculateTimeAgo, countryCodeToFlag, formatDuration} from "../format";
@ -21,6 +21,8 @@ interface UserInfo {
suspicious_scores: SuspiciousScore[]; suspicious_scores: SuspiciousScore[];
similar_replays: SimilarReplay[]; similar_replays: SimilarReplay[];
total_scores: number; total_scores: number;
is_banned: boolean;
approximate_ban_date: string;
} }
interface UserQueueWebsocketPacket { interface UserQueueWebsocketPacket {
@ -42,7 +44,8 @@ interface UserScoresFilter {
RouterLink, RouterLink,
NgIf, NgIf,
NgOptimizedImage, NgOptimizedImage,
CuteLoadingComponent CuteLoadingComponent,
DatePipe
], ],
templateUrl: './view-user.component.html', templateUrl: './view-user.component.html',
styleUrl: './view-user.component.css' styleUrl: './view-user.component.css'

View File

@ -200,6 +200,11 @@ a.btn-success:hover {
border: 1px dotted #b3b8c3; border: 1px dotted #b3b8c3;
} }
.alert-danger {
color: #de7979;
border-color: #de7979;
}
.mb-2 { .mb-2 {
margin-bottom: 20px; margin-bottom: 20px;
} }

View File

@ -0,0 +1,7 @@
<ng-container *ngIf="this.textToCopy; else ngContent">
<code style="font-size: 12px; margin-right: 10px">{{ this.textToCopy }}</code> <button (click)="copyToClipboard()">copy</button>
</ng-container>
<ng-template #ngContent>
<ng-content ></ng-content>
</ng-template>

View File

@ -0,0 +1,32 @@
import {AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input} from '@angular/core';
import {NgIf} from "@angular/common";
@Component({
selector: 'app-code-with-copy-button',
standalone: true,
imports: [
NgIf
],
templateUrl: './code-with-copy-button.component.html',
styleUrl: './code-with-copy-button.component.css'
})
export class CodeWithCopyButtonComponent implements AfterViewInit {
@Input() textToCopy!: string;
constructor(private elRef: ElementRef, private cdr: ChangeDetectorRef) {}
ngAfterViewInit() {
this.textToCopy = this.elRef.nativeElement.innerText.trim();
this.cdr.detectChanges();
}
copyToClipboard() {
navigator.clipboard.writeText(this.textToCopy).then(() => {
}).catch(err => {
console.error('could not copy text:', err);
});
}
}