fix: Remove Number() conversions that corrupt uint64 IDs
JavaScript's Number type cannot accurately represent uint64 values exceeding Number.MAX_SAFE_INTEGER (2^53-1). Converting these IDs to numbers causes precision loss and API errors. Root cause found: - match/[id]/+layout.ts: `Number(params.id)` corrupted match IDs - player/[id]/+page.ts: `Number(params.id)` corrupted player IDs Example of the bug: - URL param: "3638078243082338615" (correct) - After Number(): 3638078243082339000 (rounded!) - API response: "Match 3638078243082339000 not found" Changes: - Remove Number() conversions in route loaders - Keep params.id as string throughout the application - Update API functions to only accept string (not string | number) - Update MatchesQueryParams.player_id type to string - Add comprehensive transformers for legacy API responses - Transform player stats: duo→mk_2, triple→mk_3, steamid64→id - Build full Steam avatar URLs - Make share_code optional (not always present) This ensures uint64 IDs maintain full precision from URL → API → response. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,16 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
import {
|
import {
|
||||||
parseMatch,
|
|
||||||
parseMatchRounds,
|
parseMatchRounds,
|
||||||
parseMatchWeapons,
|
parseMatchWeapons,
|
||||||
parseMatchChat,
|
parseMatchChat,
|
||||||
parseMatchParseResponse
|
parseMatchParseResponse
|
||||||
} from '$lib/schemas';
|
} from '$lib/schemas';
|
||||||
import { transformMatchesListResponse, type LegacyMatchListItem } from './transformers';
|
import {
|
||||||
|
transformMatchesListResponse,
|
||||||
|
transformMatchDetail,
|
||||||
|
type LegacyMatchListItem,
|
||||||
|
type LegacyMatchDetail
|
||||||
|
} from './transformers';
|
||||||
import type {
|
import type {
|
||||||
Match,
|
Match,
|
||||||
MatchesListResponse,
|
MatchesListResponse,
|
||||||
@@ -36,15 +40,16 @@ export const matchesAPI = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get match details with player statistics
|
* Get match details with player statistics
|
||||||
* @param matchId - Match ID (uint64)
|
* @param matchId - Match ID (uint64 as string)
|
||||||
* @returns Complete match data
|
* @returns Complete match data
|
||||||
*/
|
*/
|
||||||
async getMatch(matchId: string | number): Promise<Match> {
|
async getMatch(matchId: string): Promise<Match> {
|
||||||
const url = `/match/${matchId}`;
|
const url = `/match/${matchId}`;
|
||||||
const data = await apiClient.get<Match>(url);
|
// API returns legacy format
|
||||||
|
const data = await apiClient.get<LegacyMatchDetail>(url);
|
||||||
|
|
||||||
// Validate with Zod schema
|
// Transform legacy API response to new format
|
||||||
return parseMatch(data);
|
return transformMatchDetail(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import type { Player, PlayerMeta, TrackPlayerResponse } from '$lib/types';
|
|||||||
export const playersAPI = {
|
export const playersAPI = {
|
||||||
/**
|
/**
|
||||||
* Get player profile with match history
|
* Get player profile with match history
|
||||||
* @param steamId - Steam ID (uint64)
|
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||||
* @param beforeTime - Optional Unix timestamp for pagination
|
* @param beforeTime - Optional Unix timestamp for pagination
|
||||||
* @returns Player profile with recent matches
|
* @returns Player profile with recent matches
|
||||||
*/
|
*/
|
||||||
async getPlayer(steamId: string | number, beforeTime?: number): Promise<Player> {
|
async getPlayer(steamId: string, beforeTime?: number): Promise<Player> {
|
||||||
const url = beforeTime ? `/player/${steamId}/next/${beforeTime}` : `/player/${steamId}`;
|
const url = beforeTime ? `/player/${steamId}/next/${beforeTime}` : `/player/${steamId}`;
|
||||||
const data = await apiClient.get<Player>(url);
|
const data = await apiClient.get<Player>(url);
|
||||||
|
|
||||||
@@ -22,11 +22,11 @@ export const playersAPI = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get lightweight player metadata
|
* Get lightweight player metadata
|
||||||
* @param steamId - Steam ID
|
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||||
* @param limit - Number of recent matches to include (default: 10)
|
* @param limit - Number of recent matches to include (default: 10)
|
||||||
* @returns Player metadata
|
* @returns Player metadata
|
||||||
*/
|
*/
|
||||||
async getPlayerMeta(steamId: string | number, limit = 10): Promise<PlayerMeta> {
|
async getPlayerMeta(steamId: string, limit = 10): Promise<PlayerMeta> {
|
||||||
const url = `/player/${steamId}/meta/${limit}`;
|
const url = `/player/${steamId}/meta/${limit}`;
|
||||||
const data = await apiClient.get<PlayerMeta>(url);
|
const data = await apiClient.get<PlayerMeta>(url);
|
||||||
|
|
||||||
@@ -36,21 +36,21 @@ export const playersAPI = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add player to tracking system
|
* Add player to tracking system
|
||||||
* @param steamId - Steam ID
|
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||||
* @param authCode - Steam authentication code
|
* @param authCode - Steam authentication code
|
||||||
* @returns Success response
|
* @returns Success response
|
||||||
*/
|
*/
|
||||||
async trackPlayer(steamId: string | number, authCode: string): Promise<TrackPlayerResponse> {
|
async trackPlayer(steamId: string, authCode: string): Promise<TrackPlayerResponse> {
|
||||||
const url = `/player/${steamId}/track`;
|
const url = `/player/${steamId}/track`;
|
||||||
return apiClient.post<TrackPlayerResponse>(url, { auth_code: authCode });
|
return apiClient.post<TrackPlayerResponse>(url, { auth_code: authCode });
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove player from tracking system
|
* Remove player from tracking system
|
||||||
* @param steamId - Steam ID
|
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||||
* @returns Success response
|
* @returns Success response
|
||||||
*/
|
*/
|
||||||
async untrackPlayer(steamId: string | number): Promise<TrackPlayerResponse> {
|
async untrackPlayer(steamId: string): Promise<TrackPlayerResponse> {
|
||||||
const url = `/player/${steamId}/track`;
|
const url = `/player/${steamId}/track`;
|
||||||
return apiClient.delete<TrackPlayerResponse>(url);
|
return apiClient.delete<TrackPlayerResponse>(url);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Converts legacy CSGO:WTF API responses to the new CS2.WTF format
|
* Converts legacy CSGO:WTF API responses to the new CS2.WTF format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MatchListItem, MatchesListResponse } from '$lib/types';
|
import type { MatchListItem, MatchesListResponse, Match, MatchPlayer } from '$lib/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy API match format (from api.csgow.tf)
|
* Legacy API match format (from api.csgow.tf)
|
||||||
@@ -21,12 +21,71 @@ export interface LegacyMatchListItem {
|
|||||||
game_ban: boolean;
|
game_ban: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy API match detail format
|
||||||
|
*/
|
||||||
|
export interface LegacyMatchDetail {
|
||||||
|
match_id: string;
|
||||||
|
share_code?: string;
|
||||||
|
map: string;
|
||||||
|
date: number; // Unix timestamp
|
||||||
|
score: [number, number]; // [team_a, team_b]
|
||||||
|
duration: number;
|
||||||
|
match_result: number;
|
||||||
|
max_rounds: number;
|
||||||
|
parsed: boolean;
|
||||||
|
vac: boolean;
|
||||||
|
game_ban: boolean;
|
||||||
|
stats?: LegacyPlayerStats[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy player stats format
|
||||||
|
*/
|
||||||
|
export interface LegacyPlayerStats {
|
||||||
|
team_id: number;
|
||||||
|
kills: number;
|
||||||
|
deaths: number;
|
||||||
|
assists: number;
|
||||||
|
headshot: number;
|
||||||
|
mvp: number;
|
||||||
|
score: number;
|
||||||
|
player: {
|
||||||
|
steamid64: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
vac: boolean;
|
||||||
|
game_ban: boolean;
|
||||||
|
vanity_url?: string;
|
||||||
|
};
|
||||||
|
rank: Record<string, unknown>;
|
||||||
|
multi_kills?: {
|
||||||
|
duo?: number;
|
||||||
|
triple?: number;
|
||||||
|
quad?: number;
|
||||||
|
ace?: number;
|
||||||
|
};
|
||||||
|
dmg?: Record<string, unknown>;
|
||||||
|
flash?: {
|
||||||
|
duration?: {
|
||||||
|
self?: number;
|
||||||
|
team?: number;
|
||||||
|
enemy?: number;
|
||||||
|
};
|
||||||
|
total?: {
|
||||||
|
self?: number;
|
||||||
|
team?: number;
|
||||||
|
enemy?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform legacy match list item to new format
|
* Transform legacy match list item to new format
|
||||||
*/
|
*/
|
||||||
export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListItem {
|
export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListItem {
|
||||||
return {
|
return {
|
||||||
match_id: Number(legacy.match_id),
|
match_id: legacy.match_id, // Keep as string to preserve uint64 precision
|
||||||
map: legacy.map || 'unknown', // Handle empty map names
|
map: legacy.map || 'unknown', // Handle empty map names
|
||||||
date: new Date(legacy.date * 1000).toISOString(), // Convert Unix timestamp to ISO string
|
date: new Date(legacy.date * 1000).toISOString(), // Convert Unix timestamp to ISO string
|
||||||
score_team_a: legacy.score[0],
|
score_team_a: legacy.score[0],
|
||||||
@@ -49,3 +108,56 @@ export function transformMatchesListResponse(
|
|||||||
next_page_time: undefined
|
next_page_time: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform legacy player stats to new format
|
||||||
|
*/
|
||||||
|
export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer {
|
||||||
|
return {
|
||||||
|
id: legacy.player.steamid64,
|
||||||
|
name: legacy.player.name,
|
||||||
|
avatar: `https://avatars.steamstatic.com/${legacy.player.avatar}_full.jpg`,
|
||||||
|
team_id: legacy.team_id,
|
||||||
|
kills: legacy.kills,
|
||||||
|
deaths: legacy.deaths,
|
||||||
|
assists: legacy.assists,
|
||||||
|
headshot: legacy.headshot,
|
||||||
|
mvp: legacy.mvp,
|
||||||
|
score: legacy.score,
|
||||||
|
kast: 0, // Not provided by legacy API
|
||||||
|
// Multi-kills: map legacy names to new format
|
||||||
|
mk_2: legacy.multi_kills?.duo,
|
||||||
|
mk_3: legacy.multi_kills?.triple,
|
||||||
|
mk_4: legacy.multi_kills?.quad,
|
||||||
|
mk_5: legacy.multi_kills?.ace,
|
||||||
|
// Flash stats
|
||||||
|
flash_duration_self: legacy.flash?.duration?.self,
|
||||||
|
flash_duration_team: legacy.flash?.duration?.team,
|
||||||
|
flash_duration_enemy: legacy.flash?.duration?.enemy,
|
||||||
|
flash_total_self: legacy.flash?.total?.self,
|
||||||
|
flash_total_team: legacy.flash?.total?.team,
|
||||||
|
flash_total_enemy: legacy.flash?.total?.enemy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform legacy match detail to new format
|
||||||
|
*/
|
||||||
|
export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
|
||||||
|
return {
|
||||||
|
match_id: legacy.match_id,
|
||||||
|
share_code: legacy.share_code || undefined,
|
||||||
|
map: legacy.map || 'unknown',
|
||||||
|
date: new Date(legacy.date * 1000).toISOString(),
|
||||||
|
score_team_a: legacy.score[0],
|
||||||
|
score_team_b: legacy.score[1],
|
||||||
|
duration: legacy.duration,
|
||||||
|
match_result: legacy.match_result,
|
||||||
|
max_rounds: legacy.max_rounds,
|
||||||
|
demo_parsed: legacy.parsed,
|
||||||
|
vac_present: legacy.vac,
|
||||||
|
gameban_present: legacy.game_ban,
|
||||||
|
tick_rate: 64, // Default to 64, not provided by API
|
||||||
|
players: legacy.stats?.map(transformPlayerStats)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
|
|||||||
|
|
||||||
/** MatchParseResponse schema */
|
/** MatchParseResponse schema */
|
||||||
export const matchParseResponseSchema = z.object({
|
export const matchParseResponseSchema = z.object({
|
||||||
match_id: z.number().positive(),
|
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||||
status: z.enum(['parsing', 'queued', 'completed', 'error']),
|
status: z.enum(['parsing', 'queued', 'completed', 'error']),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
estimated_time: z.number().int().positive().optional()
|
estimated_time: z.number().int().positive().optional()
|
||||||
@@ -31,7 +31,7 @@ export const matchParseResponseSchema = z.object({
|
|||||||
|
|
||||||
/** MatchParseStatus schema */
|
/** MatchParseStatus schema */
|
||||||
export const matchParseStatusSchema = z.object({
|
export const matchParseStatusSchema = z.object({
|
||||||
match_id: z.number().positive(),
|
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||||
status: z.enum(['pending', 'parsing', 'completed', 'error']),
|
status: z.enum(['pending', 'parsing', 'completed', 'error']),
|
||||||
progress: z.number().int().min(0).max(100).optional(),
|
progress: z.number().int().min(0).max(100).optional(),
|
||||||
error_message: z.string().optional()
|
error_message: z.string().optional()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
/** MatchPlayer schema */
|
/** MatchPlayer schema */
|
||||||
export const matchPlayerSchema = z.object({
|
export const matchPlayerSchema = z.object({
|
||||||
id: z.number().positive(),
|
id: z.string().min(1), // Steam ID uint64 as string to preserve precision
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
avatar: z.string().url(),
|
avatar: z.string().url(),
|
||||||
team_id: z.number().int().min(2).max(3), // 2 = T, 3 = CT
|
team_id: z.number().int().min(2).max(3), // 2 = T, 3 = CT
|
||||||
@@ -59,10 +59,11 @@ export const matchPlayerSchema = z.object({
|
|||||||
|
|
||||||
/** Match schema */
|
/** Match schema */
|
||||||
export const matchSchema = z.object({
|
export const matchSchema = z.object({
|
||||||
match_id: z.number().positive(),
|
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||||
share_code: z
|
share_code: z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$/),
|
.regex(/^(CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5})?$/)
|
||||||
|
.optional(),
|
||||||
map: z.string().min(1),
|
map: z.string().min(1),
|
||||||
date: z.string().datetime(),
|
date: z.string().datetime(),
|
||||||
score_team_a: z.number().int().nonnegative(),
|
score_team_a: z.number().int().nonnegative(),
|
||||||
@@ -79,7 +80,7 @@ export const matchSchema = z.object({
|
|||||||
|
|
||||||
/** MatchListItem schema */
|
/** MatchListItem schema */
|
||||||
export const matchListItemSchema = z.object({
|
export const matchListItemSchema = z.object({
|
||||||
match_id: z.number().positive(),
|
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||||
map: z.string().min(1),
|
map: z.string().min(1),
|
||||||
date: z.string().datetime(),
|
date: z.string().datetime(),
|
||||||
score_team_a: z.number().int().nonnegative(),
|
score_team_a: z.number().int().nonnegative(),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { matchSchema, matchPlayerSchema } from './match.schema';
|
|||||||
|
|
||||||
/** Player schema */
|
/** Player schema */
|
||||||
export const playerSchema = z.object({
|
export const playerSchema = z.object({
|
||||||
id: z.number().positive(),
|
id: z.string().min(1), // Steam ID uint64 as string to preserve precision
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
avatar: z.string().url(),
|
avatar: z.string().url(),
|
||||||
vanity_url: z.string().optional(),
|
vanity_url: z.string().optional(),
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
* Represents a complete CS2 match with metadata and optional player stats
|
* Represents a complete CS2 match with metadata and optional player stats
|
||||||
*/
|
*/
|
||||||
export interface Match {
|
export interface Match {
|
||||||
/** Unique match identifier (uint64) */
|
/** Unique match identifier (uint64 as string to preserve precision) */
|
||||||
match_id: number;
|
match_id: string;
|
||||||
|
|
||||||
/** CS:GO/CS2 share code */
|
/** CS:GO/CS2 share code (optional, may not always be available) */
|
||||||
share_code: string;
|
share_code?: string;
|
||||||
|
|
||||||
/** Map name (e.g., "de_inferno") */
|
/** Map name (e.g., "de_inferno") */
|
||||||
map: string;
|
map: string;
|
||||||
@@ -50,7 +50,7 @@ export interface Match {
|
|||||||
* Minimal match information for lists
|
* Minimal match information for lists
|
||||||
*/
|
*/
|
||||||
export interface MatchListItem {
|
export interface MatchListItem {
|
||||||
match_id: number;
|
match_id: string;
|
||||||
map: string;
|
map: string;
|
||||||
date: string;
|
date: string;
|
||||||
score_team_a: number;
|
score_team_a: number;
|
||||||
@@ -65,8 +65,8 @@ export interface MatchListItem {
|
|||||||
* Player performance data for a specific match
|
* Player performance data for a specific match
|
||||||
*/
|
*/
|
||||||
export interface MatchPlayer {
|
export interface MatchPlayer {
|
||||||
/** Player Steam ID */
|
/** Player Steam ID (uint64 as string to preserve precision) */
|
||||||
id: number;
|
id: string;
|
||||||
|
|
||||||
/** Player display name */
|
/** Player display name */
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import type { Match, MatchPlayer } from './Match';
|
|||||||
* Represents a Steam user with CS2 statistics
|
* Represents a Steam user with CS2 statistics
|
||||||
*/
|
*/
|
||||||
export interface Player {
|
export interface Player {
|
||||||
/** Steam ID (uint64) */
|
/** Steam ID (uint64 as string to preserve precision) */
|
||||||
id: number;
|
id: string;
|
||||||
|
|
||||||
/** Steam display name */
|
/** Steam display name */
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export interface APIResponse<T> {
|
|||||||
* Match parse response
|
* Match parse response
|
||||||
*/
|
*/
|
||||||
export interface MatchParseResponse {
|
export interface MatchParseResponse {
|
||||||
match_id: number;
|
match_id: string; // uint64 as string to preserve precision
|
||||||
status: 'parsing' | 'queued' | 'completed' | 'error';
|
status: 'parsing' | 'queued' | 'completed' | 'error';
|
||||||
message: string;
|
message: string;
|
||||||
estimated_time?: number; // seconds
|
estimated_time?: number; // seconds
|
||||||
@@ -38,7 +38,7 @@ export interface MatchParseResponse {
|
|||||||
* Match parse status
|
* Match parse status
|
||||||
*/
|
*/
|
||||||
export interface MatchParseStatus {
|
export interface MatchParseStatus {
|
||||||
match_id: number;
|
match_id: string; // uint64 as string to preserve precision
|
||||||
status: 'pending' | 'parsing' | 'completed' | 'error';
|
status: 'pending' | 'parsing' | 'completed' | 'error';
|
||||||
progress?: number; // 0-100
|
progress?: number; // 0-100
|
||||||
error_message?: string;
|
error_message?: string;
|
||||||
@@ -60,7 +60,7 @@ export interface MatchesListResponse {
|
|||||||
export interface MatchesQueryParams {
|
export interface MatchesQueryParams {
|
||||||
limit?: number; // 1-100
|
limit?: number; // 1-100
|
||||||
map?: string;
|
map?: string;
|
||||||
player_id?: number;
|
player_id?: string; // Steam ID uint64 as string
|
||||||
before_time?: number; // Unix timestamp for pagination
|
before_time?: number; // Unix timestamp for pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { api } from '$lib/api';
|
|||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ params }) => {
|
export const load: LayoutLoad = async ({ params }) => {
|
||||||
const matchId = Number(params.id);
|
const matchId = params.id; // Keep as string to preserve uint64 precision
|
||||||
|
|
||||||
if (isNaN(matchId) || matchId <= 0) {
|
if (!matchId || matchId.trim() === '') {
|
||||||
throw error(400, 'Invalid match ID');
|
throw error(400, 'Invalid match ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { api } from '$lib/api';
|
|||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
export const load: PageLoad = async ({ params }) => {
|
||||||
const playerId = Number(params.id);
|
const playerId = params.id; // Keep as string to preserve uint64 precision
|
||||||
|
|
||||||
if (isNaN(playerId) || playerId <= 0) {
|
if (!playerId || playerId.trim() === '') {
|
||||||
throw error(400, 'Invalid player ID');
|
throw error(400, 'Invalid player ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user