mirror of
https://github.com/ryanwtf7/hianime-api.git
synced 2026-04-17 21:41:44 +00:00
initial commit
This commit is contained in:
48
src/controllers/allGenres.controller.ts
Normal file
48
src/controllers/allGenres.controller.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
const allGenres: string[] = [
|
||||
'action',
|
||||
'adventure',
|
||||
'cars',
|
||||
'comedy',
|
||||
'dementia',
|
||||
'demons',
|
||||
'drama',
|
||||
'ecchi',
|
||||
'fantasy',
|
||||
'game',
|
||||
'harem',
|
||||
'historical',
|
||||
'horror',
|
||||
'isekai',
|
||||
'josei',
|
||||
'kids',
|
||||
'magic',
|
||||
'martial arts',
|
||||
'mecha',
|
||||
'military',
|
||||
'music',
|
||||
'mystery',
|
||||
'parody',
|
||||
'police',
|
||||
'psychological',
|
||||
'romance',
|
||||
'samurai',
|
||||
'school',
|
||||
'sci-fi',
|
||||
'seinen',
|
||||
'shoujo',
|
||||
'shoujo ai',
|
||||
'shounen',
|
||||
'shounen ai',
|
||||
'slice of life',
|
||||
'space',
|
||||
'sports',
|
||||
'super power',
|
||||
'supernatural',
|
||||
'thriller',
|
||||
'vampire',
|
||||
];
|
||||
const allGenresController = (): string[] => {
|
||||
return allGenres;
|
||||
};
|
||||
|
||||
export default allGenresController;
|
||||
21
src/controllers/characterDetail.controller.ts
Normal file
21
src/controllers/characterDetail.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Context } from 'hono';
|
||||
import { extractCharacterDetail, CharacterDetail } from '../extractor/extractCharacterDetail';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
import { validationError } from '../utils/errors';
|
||||
|
||||
const characterDetailConroller = async (c: Context): Promise<CharacterDetail> => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
if (!id) throw new validationError('id is required');
|
||||
|
||||
const result = await axiosInstance(`/${id.replace(':', '/')}`);
|
||||
if (!result.success || !result.data) {
|
||||
throw new validationError(result.message || 'make sure given endpoint is correct');
|
||||
}
|
||||
|
||||
const response = extractCharacterDetail(result.data);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export default characterDetailConroller;
|
||||
39
src/controllers/characters.controller.ts
Normal file
39
src/controllers/characters.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Context } from 'hono';
|
||||
import config from '../config/config';
|
||||
import { validationError } from '../utils/errors';
|
||||
import { extractCharacters, CharactersResponse } from '../extractor/extractCharacters';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
|
||||
const charactersController = async (c: Context): Promise<CharactersResponse> => {
|
||||
try {
|
||||
const id = c.req.param('id');
|
||||
const page = c.req.query('page') || '1';
|
||||
|
||||
if (!id) throw new validationError('id is required');
|
||||
|
||||
const idNum = id.split('-').pop();
|
||||
const endpoint = `/ajax/character/list/${idNum}?page=${page}`;
|
||||
|
||||
const result = await axiosInstance(endpoint, {
|
||||
headers: { Referer: `${config.baseurl}/home` },
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new validationError(result.message || 'characters not found');
|
||||
}
|
||||
|
||||
const response = extractCharacters(result.data);
|
||||
|
||||
return response;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
console.log(err.message);
|
||||
} else {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
throw new validationError('characters not found');
|
||||
}
|
||||
};
|
||||
|
||||
export default charactersController;
|
||||
20
src/controllers/detailpage.controller.ts
Normal file
20
src/controllers/detailpage.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Context } from 'hono';
|
||||
import { extractDetailpage } from '../extractor/extractDetailpage';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
import { validationError } from '../utils/errors';
|
||||
import { DetailAnime } from '../types/anime';
|
||||
|
||||
const detailpageController = async (c: Context): Promise<DetailAnime> => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const result = await axiosInstance(`/${id}`);
|
||||
if (!result.success || !result.data) {
|
||||
throw new validationError(
|
||||
result.message || 'Failed to fetch detail page',
|
||||
'maybe id is incorrect : ' + id
|
||||
);
|
||||
}
|
||||
return extractDetailpage(result.data);
|
||||
};
|
||||
|
||||
export default detailpageController;
|
||||
29
src/controllers/episodes.controller.ts
Normal file
29
src/controllers/episodes.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Context } from 'hono';
|
||||
import config from '../config/config';
|
||||
import { validationError } from '../utils/errors';
|
||||
import { extractEpisodes, Episode } from '../extractor/extractEpisodes';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
|
||||
const episodesController = async (c: Context): Promise<Episode[]> => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
if (!id) throw new validationError('id is required');
|
||||
|
||||
const idNum = id.split('-').at(-1);
|
||||
const ajaxUrl = `/ajax/v2/episode/list/${idNum}`;
|
||||
|
||||
const result = await axiosInstance(ajaxUrl, {
|
||||
headers: { Referer: `${config.baseurl}/watch/${id}` },
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new validationError(result.message || 'make sure the id is correct', {
|
||||
validIdEX: 'one-piece-100',
|
||||
});
|
||||
}
|
||||
|
||||
const response = extractEpisodes(result.data);
|
||||
return response;
|
||||
};
|
||||
|
||||
export default episodesController;
|
||||
106
src/controllers/filter.controller.ts
Normal file
106
src/controllers/filter.controller.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Context } from 'hono';
|
||||
import filterOptions from '../utils/filter';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
import { validationError } from '../utils/errors';
|
||||
import { extractListPage, ListPageResponse } from '../extractor/extractListpage';
|
||||
|
||||
const filterController = async (c: Context): Promise<ListPageResponse> => {
|
||||
const {
|
||||
// will receive string and send as a string
|
||||
keyword = null,
|
||||
sort = null,
|
||||
|
||||
// will recieve an array as string will "," saparated and send as "," saparated string
|
||||
genres = null,
|
||||
|
||||
// will recieve as string and send as index of that string "see filterOptions"
|
||||
type = null,
|
||||
status = null,
|
||||
rated = null,
|
||||
score = null,
|
||||
season = null,
|
||||
language = null,
|
||||
page = '1',
|
||||
} = c.req.query();
|
||||
|
||||
const pageNum = Number(page);
|
||||
const queryArr = [
|
||||
{ title: 'keyword', val: keyword },
|
||||
{ title: 'sort', val: sort },
|
||||
{ title: 'type', val: type },
|
||||
{ title: 'status', val: status },
|
||||
{ title: 'rated', val: rated },
|
||||
{ title: 'score', val: score },
|
||||
{ title: 'season', val: season },
|
||||
{ title: 'language', val: language },
|
||||
{ title: 'genres', val: genres },
|
||||
];
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
queryArr.forEach(v => {
|
||||
if (v.val) {
|
||||
switch (v.title) {
|
||||
case 'keyword':
|
||||
params.set('keyword', formatKeyword(v.val));
|
||||
break;
|
||||
case 'genres':
|
||||
params.set('genres', formatGenres(v.val));
|
||||
break;
|
||||
case 'sort': {
|
||||
const formattedSort = formatSort(v.val);
|
||||
if (formattedSort) params.set('sort', formattedSort);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const formattedOption = formatOption(v.title, v.val);
|
||||
if (formattedOption) params.set(v.title, formattedOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (pageNum > 1) params.set('page', String(pageNum));
|
||||
|
||||
const endpoint = keyword ? '/search' : '/filter';
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
|
||||
|
||||
const result = await axiosInstance(url);
|
||||
|
||||
console.log(result.message);
|
||||
|
||||
if (!result.success || !result.data)
|
||||
throw new validationError(result.message || 'something went wrong will queries');
|
||||
const response = extractListPage(result.data);
|
||||
return response;
|
||||
};
|
||||
|
||||
const formatKeyword = (v: string) => v.toLowerCase();
|
||||
|
||||
const formatSort = (v: string) => {
|
||||
const index = filterOptions.sort.indexOf(v.toLowerCase().replace(' ', '_'));
|
||||
if (index === -1) return null;
|
||||
return filterOptions.sort[index];
|
||||
};
|
||||
|
||||
const formatGenres = (v: string) => {
|
||||
let indexes = v
|
||||
.split(',')
|
||||
.map(genre =>
|
||||
(filterOptions as Record<string, string[]>).genres.indexOf(
|
||||
genre.toLowerCase().replaceAll(' ', '_')
|
||||
)
|
||||
)
|
||||
.filter(i => i !== -1)
|
||||
.map(i => i + 1);
|
||||
|
||||
return indexes.length > 0 ? indexes.join(',') : '';
|
||||
};
|
||||
|
||||
const formatOption = (k: string, v: string) => {
|
||||
const index = (filterOptions as Record<string, string[]>)[k].indexOf(v);
|
||||
if (index === -1) return null;
|
||||
return index.toString();
|
||||
};
|
||||
export default filterController;
|
||||
18
src/controllers/homepage.controller.ts
Normal file
18
src/controllers/homepage.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
import { validationError } from '../utils/errors';
|
||||
import { extractHomepage } from '../extractor/extractHomepage';
|
||||
import { HomePage } from '../types/anime';
|
||||
|
||||
const homepageController = async (): Promise<HomePage> => {
|
||||
console.log('Fetching homepage data from external API...');
|
||||
const result = await axiosInstance('/home');
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.error('Homepage fetch failed:', result.message);
|
||||
throw new validationError(result.message || 'Failed to fetch homepage');
|
||||
}
|
||||
|
||||
return extractHomepage(result.data);
|
||||
};
|
||||
|
||||
export default homepageController;
|
||||
60
src/controllers/listpage.controller.ts
Normal file
60
src/controllers/listpage.controller.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Context } from 'hono';
|
||||
import { extractListPage, ListPageResponse } from '../extractor/extractListpage';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
import { NotFoundError, validationError } from '../utils/errors';
|
||||
|
||||
const listpageController = async (c: Context): Promise<ListPageResponse> => {
|
||||
const validateQueries = [
|
||||
'top-airing',
|
||||
'most-popular',
|
||||
'most-favorite',
|
||||
'completed',
|
||||
'recently-added',
|
||||
'recently-updated',
|
||||
'top-upcoming',
|
||||
'genre',
|
||||
'producer',
|
||||
'az-list',
|
||||
'subbed-anime',
|
||||
'dubbed-anime',
|
||||
'movie',
|
||||
'tv',
|
||||
'ova',
|
||||
'ona',
|
||||
'special',
|
||||
'events',
|
||||
];
|
||||
const query = c.req.param('query')?.toLowerCase() || '';
|
||||
|
||||
if (!validateQueries.includes(query))
|
||||
throw new validationError('invalid query', { validateQueries });
|
||||
|
||||
let category = c.req.param('category') || null;
|
||||
|
||||
const page = c.req.query('page') || '1';
|
||||
|
||||
if ((query === 'genre' || query === 'producer') && !category) {
|
||||
throw new validationError(`category is require for query ${query}`);
|
||||
}
|
||||
if (query !== 'genre' && query !== 'producer' && query !== 'az-list' && category) {
|
||||
category = null;
|
||||
}
|
||||
|
||||
let nromalizeCategory = category && category.replaceAll(' ', '-').toLowerCase();
|
||||
if (nromalizeCategory === 'martial-arts') nromalizeCategory = 'marial-arts';
|
||||
const endpoint = category
|
||||
? `/${query}/${nromalizeCategory}?page=${page}`
|
||||
: `/${query}?page=${page}`;
|
||||
|
||||
const result = await axiosInstance(endpoint);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new validationError(result.message || 'make sure given endpoint is correct');
|
||||
}
|
||||
const response = extractListPage(result.data);
|
||||
|
||||
if (response.response.length < 1) throw new NotFoundError();
|
||||
return response;
|
||||
};
|
||||
|
||||
export default listpageController;
|
||||
21
src/controllers/news.controller.ts
Normal file
21
src/controllers/news.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Context } from 'hono';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
import { validationError } from '../utils/errors';
|
||||
import { extractNews, NewsResponse } from '../extractor/extractNews';
|
||||
|
||||
const newsController = async (c: Context): Promise<NewsResponse> => {
|
||||
const page = c.req.query('page') || '1';
|
||||
|
||||
console.log(`Fetching news page ${page} from external API...`);
|
||||
const endpoint = page === '1' ? '/news' : `/news?page=${page}`;
|
||||
const result = await axiosInstance(endpoint);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.error('News fetch failed:', result.message);
|
||||
throw new validationError(result.message || 'Failed to fetch news');
|
||||
}
|
||||
|
||||
return extractNews(result.data);
|
||||
};
|
||||
|
||||
export default newsController;
|
||||
21
src/controllers/nextEpisodeSchedule.controller.ts
Normal file
21
src/controllers/nextEpisodeSchedule.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Context } from 'hono';
|
||||
import { extractNextEpisodeSchedule } from '../extractor/extractNextEpisodeSchedule';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
import { validationError } from '../utils/errors';
|
||||
|
||||
const nextEpisodeSchaduleController = async (c: Context): Promise<unknown> => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
if (!id) throw new validationError('id is required');
|
||||
|
||||
const data = await axiosInstance('/watch/' + id);
|
||||
|
||||
if (!data.success || !data.data)
|
||||
throw new validationError(data.message || 'make sure id is correct');
|
||||
|
||||
const response = extractNextEpisodeSchedule(data.data);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export default nextEpisodeSchaduleController;
|
||||
33
src/controllers/random.controller.ts
Normal file
33
src/controllers/random.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Context } from 'hono';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
import { validationError } from '../utils/errors';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
const randomController = async (_c: Context): Promise<{ id: string }> => {
|
||||
console.log('Fetching random anime...');
|
||||
const result = await axiosInstance('/home');
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.error('Random anime fetch failed:', result.message);
|
||||
throw new validationError(result.message || 'Failed to fetch homepage for random selection');
|
||||
}
|
||||
|
||||
const $ = cheerio.load(result.data);
|
||||
|
||||
const animes: string[] = [];
|
||||
$('.flw-item').each((i, el) => {
|
||||
const link = $(el).find('.film-name .dynamic-name').attr('href');
|
||||
const id = link?.split('/').pop();
|
||||
if (id) animes.push(id);
|
||||
});
|
||||
|
||||
if (animes.length === 0) {
|
||||
throw new validationError('No anime found');
|
||||
}
|
||||
|
||||
const randomId = animes[Math.floor(Math.random() * animes.length)];
|
||||
|
||||
return { id: randomId };
|
||||
};
|
||||
|
||||
export default randomController;
|
||||
84
src/controllers/schedules.controller.ts
Normal file
84
src/controllers/schedules.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Context } from 'hono';
|
||||
import config from '../config/config';
|
||||
import { validationError } from '../utils/errors';
|
||||
import { extractSchedule, ScheduledAnime } from '../extractor/extractSchedule';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
|
||||
export interface ScheduleResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
[date: string]: ScheduledAnime[];
|
||||
};
|
||||
}
|
||||
|
||||
async function schedulesController(c: Context): Promise<ScheduleResponse> {
|
||||
const today = new Date();
|
||||
const dateParam = c.req.query('date');
|
||||
|
||||
let startDate = today;
|
||||
if (dateParam) {
|
||||
const [year, month, day] = dateParam.split('-').map(Number);
|
||||
startDate = new Date(year, month - 1, day);
|
||||
if (isNaN(startDate.getTime())) {
|
||||
throw new validationError('Invalid date format. Use YYYY-MM-DD');
|
||||
}
|
||||
}
|
||||
|
||||
const dates: string[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
dates.push(`${year}-${month}-${day}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const promises = dates.map(async date => {
|
||||
const ajaxUrl = `/ajax/schedule/list?tzOffset=-330&date=${date}`;
|
||||
try {
|
||||
const result = await axiosInstance(ajaxUrl, {
|
||||
headers: { Referer: `${config.baseurl}/home` },
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || 'Failed to fetch');
|
||||
}
|
||||
|
||||
const jsonData = JSON.parse(result.data);
|
||||
return {
|
||||
date,
|
||||
shows: extractSchedule(jsonData.html),
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error(`Failed to fetch schedule for ${date}: ${errorMessage}`);
|
||||
return {
|
||||
date,
|
||||
shows: [] as ScheduledAnime[],
|
||||
error: 'Failed to fetch',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Format response to map dates to shows
|
||||
const response: { [date: string]: ScheduledAnime[] } = {};
|
||||
results.forEach(result => {
|
||||
response[result.date] = result.shows;
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(errorMessage);
|
||||
throw new validationError('Failed to fetch schedules');
|
||||
}
|
||||
}
|
||||
|
||||
export default schedulesController;
|
||||
30
src/controllers/search.controller.ts
Normal file
30
src/controllers/search.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Context } from 'hono';
|
||||
import { extractListPage, ListPageResponse } from '../extractor/extractListpage';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
import { NotFoundError, validationError } from '../utils/errors';
|
||||
|
||||
const searchController = async (c: Context): Promise<ListPageResponse> => {
|
||||
const keyword = c.req.query('keyword') || null;
|
||||
const page = c.req.query('page') || '1';
|
||||
|
||||
if (!keyword) throw new validationError('query is required');
|
||||
|
||||
const noSpaceKeyword = keyword.trim().toLowerCase().replace(/\s+/g, '+');
|
||||
|
||||
const endpoint = `/search?keyword=${noSpaceKeyword}&page=${page}`;
|
||||
const result = await axiosInstance(endpoint);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new validationError(result.message || 'make sure given endpoint is correct');
|
||||
}
|
||||
|
||||
const response = extractListPage(result.data);
|
||||
|
||||
if (response.response.length < 1) {
|
||||
throw new NotFoundError('page not found');
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export default searchController;
|
||||
32
src/controllers/suggestion.controller.ts
Normal file
32
src/controllers/suggestion.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Context } from 'hono';
|
||||
import config from '../config/config';
|
||||
import { validationError } from '../utils/errors';
|
||||
import { extractSuggestions, Suggestion } from '../extractor/extractSuggestions';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
|
||||
const suggestionController = async (c: Context): Promise<Suggestion[]> => {
|
||||
const keyword = c.req.query('keyword') || null;
|
||||
|
||||
if (!keyword) throw new validationError('query is required');
|
||||
|
||||
const noSpaceKeyword = keyword.trim().toLowerCase().replace(/\s+/g, '+');
|
||||
const endpoint = `/ajax/search/suggest?keyword=${noSpaceKeyword}`;
|
||||
|
||||
const result = await axiosInstance(endpoint, {
|
||||
headers: { Referer: `${config.baseurl}/home` },
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new validationError(result.message || 'suggestion not found');
|
||||
}
|
||||
|
||||
// Parse HTML from JSON if necessary, or just use result.data if it's already HTML
|
||||
// In hianime API, suggestion ajax usually returns JSON with { status, html }
|
||||
// but my axiosInstance returns response.text() which is the raw JSON string.
|
||||
const jsonData = JSON.parse(result.data);
|
||||
const response = extractSuggestions(jsonData.html);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export default suggestionController;
|
||||
18
src/controllers/topSearch.controller.ts
Normal file
18
src/controllers/topSearch.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Context } from 'hono';
|
||||
import { axiosInstance } from '../services/axiosInstance';
|
||||
import { validationError } from '../utils/errors';
|
||||
import { extractTopSearch, TopSearchAnime } from '../extractor/extractTopSearch';
|
||||
|
||||
const topSearchController = async (_c: Context): Promise<TopSearchAnime[]> => {
|
||||
console.log('Fetching top search data from external API...');
|
||||
const result = await axiosInstance('/');
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.error('Top search fetch failed:', result.message);
|
||||
throw new validationError(result.message || 'Failed to fetch top search');
|
||||
}
|
||||
|
||||
return extractTopSearch(result.data);
|
||||
};
|
||||
|
||||
export default topSearchController;
|
||||
Reference in New Issue
Block a user