mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-10 09:00:53 +08:00
Compare commits
28 Commits
codex/adju
...
codex/modi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3201f05fb | ||
|
|
da311806c1 | ||
|
|
38ee37d5be | ||
|
|
e398d8e989 | ||
|
|
85e77c265e | ||
|
|
8abdc73497 | ||
|
|
747d9c07d1 | ||
|
|
09cefbedbf | ||
|
|
d772bc182f | ||
|
|
358c53338d | ||
|
|
2110980797 | ||
|
|
1cd89eaa54 | ||
|
|
1d2e7eb96e | ||
|
|
4428e06f1d | ||
|
|
dddff54556 | ||
|
|
e7f7bbac22 | ||
|
|
37aae4ba5c | ||
|
|
54cfc98336 | ||
|
|
d42d38ff7a | ||
|
|
2b4601bd4b | ||
|
|
5071d9c6d5 | ||
|
|
cfaa4cd094 | ||
|
|
fc414794ff | ||
|
|
d8264956c3 | ||
|
|
effa7f25ca | ||
|
|
9b19fae69a | ||
|
|
ec04f64ce1 | ||
|
|
50bea76c0e |
9
.github/workflows/deploy-docs.yml
vendored
9
.github/workflows/deploy-docs.yml
vendored
@@ -1,7 +1,11 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-id:
|
||||
required: false
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -16,6 +20,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Log build
|
||||
run: echo "Running documentation deployment from build ${{ inputs.build-id }}"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
|
||||
11
.github/workflows/deploy-staging.yml
vendored
11
.github/workflows/deploy-staging.yml
vendored
@@ -5,6 +5,9 @@ on:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,3 +24,11 @@ jobs:
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: bash /opt/openisle/deploy-staging.sh
|
||||
|
||||
deploy-docs:
|
||||
needs: build-and-deploy
|
||||
if: ${{ success() }}
|
||||
uses: ./.github/workflows/deploy-docs.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
build-id: ${{ github.run_id }}
|
||||
|
||||
|
||||
@@ -246,3 +246,9 @@ https://resend.com/emails 创建账号并登录
|
||||
`RESEND_FROM_EMAIL`: **noreply@域名**
|
||||
`RESEND_API_KEY`:**刚刚复制的 Key**
|
||||

|
||||
|
||||
## 开源共建和API文档
|
||||
|
||||
- API文档: https://openisle-docs.netlify.app/docs/openapi
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
高效的开源社区前后端平台
|
||||
<br><br><br>
|
||||
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
|
||||
<br><br><br>
|
||||
<a href="https://hellogithub.com/repository/nagisa77/OpenIsle" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8605546658d94cbab45182af2a02e4c8&claim_uid=p5GNFTtZl6HBAYQ" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</p>
|
||||
|
||||
## 💡 简介
|
||||
|
||||
@@ -42,6 +42,8 @@ public class CachingConfig {
|
||||
public static final String ONLINE_CACHE_NAME="openisle_online";
|
||||
// 注册验证码
|
||||
public static final String VERIFY_CACHE_NAME="openisle_verify";
|
||||
// 发帖频率限制
|
||||
public static final String LIMIT_CACHE_NAME="openisle_limit";
|
||||
|
||||
/**
|
||||
* 自定义Redis的序列化器
|
||||
|
||||
@@ -5,6 +5,9 @@ import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.models.servers.Server;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -27,6 +30,9 @@ public class OpenApiConfig {
|
||||
@Value("${springdoc.info.header}")
|
||||
private String header;
|
||||
|
||||
@Value("${springdoc.api-docs.server-url}")
|
||||
private String serverUrl;
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
SecurityScheme securityScheme = new SecurityScheme()
|
||||
@@ -37,12 +43,12 @@ public class OpenApiConfig {
|
||||
.name(header);
|
||||
|
||||
return new OpenAPI()
|
||||
.servers(List.of(new Server().url(serverUrl)))
|
||||
.info(new Info()
|
||||
.title(title)
|
||||
.description(description)
|
||||
.version(version))
|
||||
.components(new Components()
|
||||
.addSecuritySchemes("JWT", securityScheme))
|
||||
.title(title)
|
||||
.description(description)
|
||||
.version(version))
|
||||
.components(new Components().addSecuritySchemes("JWT", securityScheme))
|
||||
.addSecurityItem(new SecurityRequirement().addList("JWT"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@ public class SecurityConfig {
|
||||
"http://192.168.7.98",
|
||||
"http://192.168.7.98:3000",
|
||||
"https://petstore.swagger.io",
|
||||
// 允许自建OpenAPI地址
|
||||
"https://docs.open-isle.com",
|
||||
"https://www.docs.open-isle.com",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://")
|
||||
));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostStatus;
|
||||
import com.openisle.model.PostType;
|
||||
@@ -28,12 +29,15 @@ import com.openisle.repository.PollVoteRepository;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
import com.openisle.service.EmailSender;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
@@ -80,6 +84,8 @@ public class PostService {
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
private String websiteUrl;
|
||||
|
||||
private final RedisTemplate redisTemplate;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public PostService(PostRepository postRepository,
|
||||
UserRepository userRepository,
|
||||
@@ -102,7 +108,8 @@ public class PostService {
|
||||
ApplicationContext applicationContext,
|
||||
PointService pointService,
|
||||
PostChangeLogService postChangeLogService,
|
||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
|
||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
||||
RedisTemplate redisTemplate) {
|
||||
this.postRepository = postRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
@@ -125,6 +132,8 @@ public class PostService {
|
||||
this.pointService = pointService;
|
||||
this.postChangeLogService = postChangeLogService;
|
||||
this.publishMode = publishMode;
|
||||
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@@ -201,9 +210,9 @@ public class PostService {
|
||||
LocalDateTime endTime,
|
||||
java.util.List<String> options,
|
||||
Boolean multiple) {
|
||||
long recent = postRepository.countByAuthorAfter(username,
|
||||
java.time.LocalDateTime.now().minusMinutes(5));
|
||||
if (recent >= 1) {
|
||||
// 限制访问次数
|
||||
boolean limitResult = postRateLimit(username);
|
||||
if (!limitResult) {
|
||||
throw new RateLimitException("Too many posts");
|
||||
}
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
@@ -300,6 +309,23 @@ public class PostService {
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制发帖频率
|
||||
* @param username
|
||||
* @return
|
||||
*/
|
||||
private boolean postRateLimit(String username){
|
||||
String key = CachingConfig.LIMIT_CACHE_NAME +":posts:"+username;
|
||||
String result = (String)redisTemplate.opsForValue().get(key);
|
||||
//最近没有创建过文章
|
||||
if(StringUtils.isEmpty(result)){
|
||||
// 限制频率为5分钟
|
||||
redisTemplate.opsForValue().set(key,"1", Duration.ofMinutes(5));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void joinLottery(Long postId, String username) {
|
||||
LotteryPost post = lotteryPostRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
|
||||
@@ -108,6 +108,7 @@ rabbitmq.sharding.enabled=true
|
||||
# see https://springdoc.org/#springdoc-openapi-core-properties
|
||||
springdoc.api-docs.path=/api/v3/api-docs
|
||||
springdoc.api-docs.enabled=true
|
||||
springdoc.api-docs.server-url=${WEBSITE_URL:https://www.open-isle.com}
|
||||
springdoc.info.title=OpenIsle
|
||||
springdoc.info.description=OpenIsle Open API Documentation
|
||||
springdoc.info.version=0.0.1
|
||||
|
||||
@@ -16,6 +16,6 @@ bun dev
|
||||
|
||||
使用以下路由:
|
||||
|
||||
- `docs/frontend/` 前端技术文档
|
||||
- `docs/backend/` 后端技术文档
|
||||
- `docs/openapi/` 后端 API 文档
|
||||
- `frontend/` 前端技术文档
|
||||
- `backend/` 后端技术文档
|
||||
- `openapi/` 后端 API 文档
|
||||
|
||||
@@ -19,7 +19,7 @@ function DocsCategory({ url }: { url: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
|
||||
export default async function Page(props: PageProps<'/[[...slug]]'>) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
@@ -48,7 +48,7 @@ export async function generateStaticParams() {
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
props: PageProps<'/docs/[[...slug]]'>
|
||||
props: PageProps<'/[[...slug]]'>
|
||||
): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
@@ -28,7 +28,7 @@ function TabTitle({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-[11px]">{children}</span>;
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/docs'>) {
|
||||
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||
return (
|
||||
// @ts-ignore
|
||||
<DocsLayout
|
||||
@@ -40,7 +40,7 @@ export default function Layout({ children }: LayoutProps<'/docs'>) {
|
||||
{
|
||||
title: 'OpenIsle 前端',
|
||||
description: <TabTitle>前端开发文档</TabTitle>,
|
||||
url: '/docs/frontend',
|
||||
url: '/frontend',
|
||||
icon: (
|
||||
<TabIcon color="#4ca154">
|
||||
<CompassIcon />
|
||||
@@ -50,7 +50,7 @@ export default function Layout({ children }: LayoutProps<'/docs'>) {
|
||||
{
|
||||
title: 'OpenIsle 后端',
|
||||
description: <TabTitle>后端开发文档</TabTitle>,
|
||||
url: '/docs/backend',
|
||||
url: '/backend',
|
||||
icon: (
|
||||
<TabIcon color="#1f66f4">
|
||||
<ServerIcon />
|
||||
@@ -60,7 +60,7 @@ export default function Layout({ children }: LayoutProps<'/docs'>) {
|
||||
{
|
||||
title: 'OpenIsle API',
|
||||
description: <TabTitle>后端 API 文档</TabTitle>,
|
||||
url: '/docs/openapi',
|
||||
url: '/openapi',
|
||||
icon: (
|
||||
<TabIcon color="#677489">
|
||||
<CodeXmlIcon />
|
||||
@@ -6,7 +6,7 @@ const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/docs'>) {
|
||||
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||
return (
|
||||
<html lang="zh" className={inter.className} suppressHydrationWarning>
|
||||
<body className="flex flex-col min-h-screen">
|
||||
|
||||
@@ -40,4 +40,4 @@ backend/
|
||||
|
||||
## API 接口
|
||||
|
||||
详细的 API 接口文档请查看 [API 文档](/docs/openapi)。
|
||||
详细的 API 接口文档请查看 [API 文档](/openapi)。
|
||||
|
||||
@@ -9,6 +9,6 @@ OpenIsle 是一个现代化的社区平台,提供完整的社交功能。
|
||||
|
||||
## 快速开始
|
||||
|
||||
- [后端开发指南](/docs/backend) - 了解后端架构和开发
|
||||
- [前端开发指南](/docs/frontend) - 了解前端技术栈和组件
|
||||
- [API 文档](/docs/openapi) - 查看完整的 API 接口文档
|
||||
- [后端开发指南](/backend) - 了解后端架构和开发
|
||||
- [前端开发指南](/frontend) - 了解前端技术栈和组件
|
||||
- [API 文档](/openapi) - 查看完整的 API 接口文档
|
||||
|
||||
@@ -8,7 +8,7 @@ export function baseOptions(): BaseLayoutProps {
|
||||
githubUrl: 'https://github.com/nagisa77/OpenIsle',
|
||||
nav: {
|
||||
title: 'OpenIsle Docs',
|
||||
url: '/docs',
|
||||
url: '/',
|
||||
},
|
||||
searchToggle: {
|
||||
enabled: false,
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as ClientAdapters from './media-adapter.client';
|
||||
// See https://fumadocs.vercel.app/docs/headless/source-api for more info
|
||||
export const source = loader({
|
||||
// it assigns a URL to your pages
|
||||
baseUrl: '/docs',
|
||||
baseUrl: '/',
|
||||
source: docs.toFumadocsSource(),
|
||||
pageTree: {
|
||||
transformers: [transformerOpenAPI()],
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
<template>
|
||||
<div class="about-page">
|
||||
<BaseTabs v-model="selectedTab" :tabs="tabs">
|
||||
<div class="about-loading" v-if="isFetching">
|
||||
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="about-content"
|
||||
v-html="renderMarkdown(content)"
|
||||
@click="handleContentClick"
|
||||
></div>
|
||||
<template v-if="selectedTab === 'api'">
|
||||
<div class="about-api">
|
||||
<div class="about-api-title">调试Token</div>
|
||||
<div v-if="!authState.loggedIn" class="about-api-login">
|
||||
请<NuxtLink to="/login">登录</NuxtLink>后查看 Token
|
||||
</div>
|
||||
<div v-else class="about-api-token">
|
||||
<div class="token-row">
|
||||
<span class="token-text">{{ shortToken }}</span>
|
||||
<span @click="copyToken"><copy class="copy-icon" /></span>
|
||||
</div>
|
||||
<div class="warning-row">
|
||||
<info-icon class="warning-icon" />
|
||||
<div class="token-warning">请不要将 Token 泄露给他人</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-api-title">API文档和调试入口</div>
|
||||
<div class="about-api-link">API Playground <share /></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="about-loading" v-if="isFetching">
|
||||
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="about-content"
|
||||
v-html="renderMarkdown(content)"
|
||||
@click="handleContentClick"
|
||||
></div>
|
||||
</template>
|
||||
</BaseTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from '#imports'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
||||
import BaseTabs from '~/components/BaseTabs.vue'
|
||||
import { toast } from '~/composables/useToast'
|
||||
|
||||
export default {
|
||||
name: 'AboutPageView',
|
||||
@@ -44,11 +69,25 @@ export default {
|
||||
label: '隐私政策',
|
||||
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md',
|
||||
},
|
||||
{
|
||||
key: 'api',
|
||||
label: 'API与调试',
|
||||
},
|
||||
]
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const selectedTab = ref(tabs[0].key)
|
||||
const content = ref('')
|
||||
const token = computed(() => (authState.loggedIn ? getToken() : ''))
|
||||
|
||||
const shortToken = computed(() => {
|
||||
if (!token.value) return ''
|
||||
if (token.value.length <= 20) return token.value
|
||||
return `${token.value.slice(0, 20)}...${token.value.slice(-10)}`
|
||||
})
|
||||
|
||||
const loadContent = async (file) => {
|
||||
if (!file) return
|
||||
try {
|
||||
isFetching.value = true
|
||||
const res = await fetch(file)
|
||||
@@ -65,19 +104,58 @@ export default {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadContent(tabs[0].file)
|
||||
const initTab = route.query.tab
|
||||
if (initTab && tabs.find((t) => t.key === initTab)) {
|
||||
selectedTab.value = initTab
|
||||
const tab = tabs.find((t) => t.key === initTab)
|
||||
if (tab && tab.file) loadContent(tab.file)
|
||||
} else {
|
||||
loadContent(tabs[0].file)
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedTab, (name) => {
|
||||
const tab = tabs.find((t) => t.key === name)
|
||||
if (tab) loadContent(tab.file)
|
||||
if (tab && tab.file) loadContent(tab.file)
|
||||
router.replace({ query: { ...route.query, tab: name } })
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(name) => {
|
||||
if (name && name !== selectedTab.value && tabs.find((t) => t.key === name)) {
|
||||
selectedTab.value = name
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const copyToken = async () => {
|
||||
if (import.meta.client && token.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token.value)
|
||||
toast.success('已复制 Token')
|
||||
} catch (e) {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentClick = (e) => {
|
||||
handleMarkdownClick(e)
|
||||
}
|
||||
|
||||
return { tabs, selectedTab, content, renderMarkdown, isFetching, handleContentClick }
|
||||
return {
|
||||
tabs,
|
||||
selectedTab,
|
||||
content,
|
||||
renderMarkdown,
|
||||
isFetching,
|
||||
handleContentClick,
|
||||
authState,
|
||||
token,
|
||||
copyToken,
|
||||
shortToken,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -101,6 +179,56 @@ export default {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.about-api {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.about-api-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.warning-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.token-warning {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font: 14px;
|
||||
margin-bottom: 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.about-api-link {
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.about-api-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about-tabs {
|
||||
width: 100vw;
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
Open,
|
||||
Dislike,
|
||||
CheckOne,
|
||||
Share,
|
||||
} from '@icon-park/vue-next'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
@@ -157,4 +158,5 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component('OpenIcon', Open)
|
||||
nuxtApp.vueApp.component('Dislike', Dislike)
|
||||
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
||||
nuxtApp.vueApp.component('Share', Share)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user