mirror of
https://gitee.com/dromara/RuoYi-Vue-Plus.git
synced 2026-04-02 00:33:24 +08:00
fix oss文件下载
This commit is contained in:
@@ -10,6 +10,7 @@ import org.dromara.common.oss.io.OutputStreamDownloadSubscriber;
|
|||||||
import org.dromara.common.oss.model.GetObjectResult;
|
import org.dromara.common.oss.model.GetObjectResult;
|
||||||
import org.dromara.common.oss.model.HandleAsyncResult;
|
import org.dromara.common.oss.model.HandleAsyncResult;
|
||||||
import org.dromara.common.oss.model.PutObjectResult;
|
import org.dromara.common.oss.model.PutObjectResult;
|
||||||
|
import software.amazon.awssdk.core.ResponseInputStream;
|
||||||
import software.amazon.awssdk.core.async.AsyncRequestBody;
|
import software.amazon.awssdk.core.async.AsyncRequestBody;
|
||||||
import software.amazon.awssdk.core.async.AsyncResponseTransformer;
|
import software.amazon.awssdk.core.async.AsyncResponseTransformer;
|
||||||
import software.amazon.awssdk.core.async.ResponsePublisher;
|
import software.amazon.awssdk.core.async.ResponsePublisher;
|
||||||
@@ -29,7 +30,7 @@ import java.nio.channels.WritableByteChannel;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.ZoneOffset;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -309,7 +310,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
|
|||||||
try {
|
try {
|
||||||
ResponsePublisher<GetObjectResponse> publisher = doCustomDownload(builder -> builder.bucket(bucket).key(key), AsyncResponseTransformer.toPublisher(), null);
|
ResponsePublisher<GetObjectResponse> publisher = doCustomDownload(builder -> builder.bucket(bucket).key(key), AsyncResponseTransformer.toPublisher(), null);
|
||||||
GetObjectResult getObjectResult = buildGetObjectResult(key, publisher.response());
|
GetObjectResult getObjectResult = buildGetObjectResult(key, publisher.response());
|
||||||
publisher.subscribe(downloadSubscriber);
|
publisher.subscribe(downloadSubscriber).join();
|
||||||
return getObjectResult;
|
return getObjectResult;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (e instanceof S3StorageException ex) {
|
if (e instanceof S3StorageException ex) {
|
||||||
@@ -319,6 +320,21 @@ public abstract class AbstractOssClientImpl implements OssClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T bucketDownload(String bucket, String key, BiFunction<GetObjectResult, InputStream, T> downloadTransformer) {
|
||||||
|
try {
|
||||||
|
ResponseInputStream<GetObjectResponse> responseInputStream = doCustomDownload(builder -> builder.bucket(bucket).key(key), AsyncResponseTransformer.toBlockingInputStream(), null);
|
||||||
|
GetObjectResponse response = responseInputStream.response();
|
||||||
|
GetObjectResult getObjectResult = buildGetObjectResult(key, response);
|
||||||
|
return downloadTransformer.apply(getObjectResult, responseInputStream);
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (e instanceof S3StorageException ex) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
throw S3StorageException.form(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public GetObjectResult bucketDownload(String bucket, String key, Path path) {
|
public GetObjectResult bucketDownload(String bucket, String key, Path path) {
|
||||||
try (OutputStream out = Files.newOutputStream(path)) {
|
try (OutputStream out = Files.newOutputStream(path)) {
|
||||||
@@ -362,7 +378,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
|
|||||||
return GetObjectResult.form(
|
return GetObjectResult.form(
|
||||||
key,
|
key,
|
||||||
response.eTag(),
|
response.eTag(),
|
||||||
LocalDateTime.from(response.lastModified()),
|
response.lastModified().atOffset(ZoneOffset.UTC).toLocalDateTime(),
|
||||||
response.contentLength(),
|
response.contentLength(),
|
||||||
response.contentType(),
|
response.contentType(),
|
||||||
response.contentDisposition(),
|
response.contentDisposition(),
|
||||||
@@ -446,6 +462,11 @@ public abstract class AbstractOssClientImpl implements OssClient {
|
|||||||
return bucketDownload(defaultBucket(), key, downloadSubscriber);
|
return bucketDownload(defaultBucket(), key, downloadSubscriber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T download(String key, BiFunction<GetObjectResult, InputStream, T> downloadTransformer) {
|
||||||
|
return bucketDownload(defaultBucket(), key, downloadTransformer);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public GetObjectResult download(String key, Path path) {
|
public GetObjectResult download(String key, Path path) {
|
||||||
return bucketDownload(defaultBucket(), key, path);
|
return bucketDownload(defaultBucket(), key, path);
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ public interface OssClient extends AutoCloseable {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* S3 存储客户端ID
|
* S3 存储客户端ID
|
||||||
*
|
* <p>
|
||||||
* 用于标识客户端,初始化后不允许更改
|
* 用于标识客户端,初始化后不允许更改
|
||||||
*
|
*
|
||||||
* @return S3 存储客户端ID
|
* @return S3 存储客户端ID
|
||||||
*/
|
*/
|
||||||
default String clientId(){
|
default String clientId() {
|
||||||
return IdUtil.fastSimpleUUID();
|
return IdUtil.fastSimpleUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +219,16 @@ public interface OssClient extends AutoCloseable {
|
|||||||
*/
|
*/
|
||||||
GetObjectResult bucketDownload(String bucket, String key, OutputStreamDownloadSubscriber downloadSubscriber);
|
GetObjectResult bucketDownload(String bucket, String key, OutputStreamDownloadSubscriber downloadSubscriber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将指定存储桶中的对象下载到转换器中,由使用者决定返回值。
|
||||||
|
*
|
||||||
|
* @param bucket 存储桶名称
|
||||||
|
* @param key 对象键
|
||||||
|
* @param downloadTransformer 下载转换器
|
||||||
|
* @return 下载结果
|
||||||
|
*/
|
||||||
|
<T> T bucketDownload(String bucket, String key, BiFunction<GetObjectResult, InputStream, T> downloadTransformer);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将指定存储桶中的对象下载到本地路径。
|
* 将指定存储桶中的对象下载到本地路径。
|
||||||
*
|
*
|
||||||
@@ -364,6 +374,15 @@ public interface OssClient extends AutoCloseable {
|
|||||||
*/
|
*/
|
||||||
GetObjectResult download(String key, OutputStreamDownloadSubscriber downloadSubscriber);
|
GetObjectResult download(String key, OutputStreamDownloadSubscriber downloadSubscriber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将指定存储桶中的对象下载到转换器中,由使用者决定返回值。
|
||||||
|
*
|
||||||
|
* @param key 对象键
|
||||||
|
* @param downloadTransformer 下载转换器
|
||||||
|
* @return 下载结果
|
||||||
|
*/
|
||||||
|
<T> T download(String key, BiFunction<GetObjectResult, InputStream, T> downloadTransformer);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将默认存储桶中的对象下载到本地路径。
|
* 将默认存储桶中的对象下载到本地路径。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.dromara.common.oss.io;
|
|||||||
import org.dromara.common.oss.exception.S3StorageException;
|
import org.dromara.common.oss.exception.S3StorageException;
|
||||||
|
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.channels.Channels;
|
import java.nio.channels.Channels;
|
||||||
@@ -19,11 +18,15 @@ public class OutputStreamDownloadSubscriber implements Consumer<ByteBuffer>, Aut
|
|||||||
|
|
||||||
private final WritableByteChannel channel;
|
private final WritableByteChannel channel;
|
||||||
|
|
||||||
private OutputStreamDownloadSubscriber(WritableByteChannel channel) {
|
private final boolean allowAutoClose;
|
||||||
|
|
||||||
|
private OutputStreamDownloadSubscriber(WritableByteChannel channel, boolean allowAutoClose) {
|
||||||
this.channel = channel;
|
this.channel = channel;
|
||||||
|
this.allowAutoClose = allowAutoClose;
|
||||||
}
|
}
|
||||||
|
|
||||||
private OutputStreamDownloadSubscriber(OutputStream out) {
|
private OutputStreamDownloadSubscriber(OutputStream out, boolean allowAutoClose) {
|
||||||
|
this.allowAutoClose = allowAutoClose;
|
||||||
// 创建可写入的字节通道
|
// 创建可写入的字节通道
|
||||||
if (out instanceof FileOutputStream outputStream) {
|
if (out instanceof FileOutputStream outputStream) {
|
||||||
// 如果是文件输入流,直接获取文件输出流的 Channel
|
// 如果是文件输入流,直接获取文件输出流的 Channel
|
||||||
@@ -35,18 +38,18 @@ public class OutputStreamDownloadSubscriber implements Consumer<ByteBuffer>, Aut
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void accept(ByteBuffer byteBuffer) {
|
public void accept(ByteBuffer byteBuffer) {
|
||||||
try (channel) {
|
try {
|
||||||
while (byteBuffer.hasRemaining()) {
|
while (byteBuffer.hasRemaining()) {
|
||||||
channel.write(byteBuffer);
|
channel.write(byteBuffer);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (Exception e) {
|
||||||
throw S3StorageException.form(e);
|
throw S3StorageException.form(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() throws Exception {
|
public void close() throws Exception {
|
||||||
if (channel.isOpen()) {
|
if (channel.isOpen() && allowAutoClose) {
|
||||||
channel.close();
|
channel.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,7 +61,18 @@ public class OutputStreamDownloadSubscriber implements Consumer<ByteBuffer>, Aut
|
|||||||
* @return 输出流下载订阅器
|
* @return 输出流下载订阅器
|
||||||
*/
|
*/
|
||||||
public static OutputStreamDownloadSubscriber create(OutputStream out) {
|
public static OutputStreamDownloadSubscriber create(OutputStream out) {
|
||||||
return new OutputStreamDownloadSubscriber(out);
|
return create(out, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个输出流下载订阅器
|
||||||
|
*
|
||||||
|
* @param out 输出流
|
||||||
|
* @param allowAutoClose 是否允许自动关闭流
|
||||||
|
* @return 输出流下载订阅器
|
||||||
|
*/
|
||||||
|
public static OutputStreamDownloadSubscriber create(OutputStream out, boolean allowAutoClose) {
|
||||||
|
return new OutputStreamDownloadSubscriber(out, allowAutoClose);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,7 +82,18 @@ public class OutputStreamDownloadSubscriber implements Consumer<ByteBuffer>, Aut
|
|||||||
* @return 输出流下载订阅器
|
* @return 输出流下载订阅器
|
||||||
*/
|
*/
|
||||||
public static OutputStreamDownloadSubscriber create(WritableByteChannel channel) {
|
public static OutputStreamDownloadSubscriber create(WritableByteChannel channel) {
|
||||||
return new OutputStreamDownloadSubscriber(channel);
|
return create(channel, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个输出流下载订阅器
|
||||||
|
*
|
||||||
|
* @param channel 可写字节通道
|
||||||
|
* @param allowAutoClose 是否允许自动关闭流
|
||||||
|
* @return 输出流下载订阅器
|
||||||
|
*/
|
||||||
|
public static OutputStreamDownloadSubscriber create(WritableByteChannel channel, boolean allowAutoClose) {
|
||||||
|
return new OutputStreamDownloadSubscriber(channel, allowAutoClose);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package org.dromara.system.controller.system;
|
|||||||
|
|
||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.dromara.common.core.domain.PageResult;
|
import org.dromara.common.core.domain.PageResult;
|
||||||
@@ -16,6 +15,7 @@ import org.dromara.system.domain.bo.SysOssBo;
|
|||||||
import org.dromara.system.domain.vo.SysOssVo;
|
import org.dromara.system.domain.vo.SysOssVo;
|
||||||
import org.dromara.system.service.ISysOssService;
|
import org.dromara.system.service.ISysOssService;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -83,13 +83,12 @@ public class SysOssController extends BaseController {
|
|||||||
* 下载OSS对象
|
* 下载OSS对象
|
||||||
*
|
*
|
||||||
* @param ossId OSS对象ID
|
* @param ossId OSS对象ID
|
||||||
* @param response HTTP 响应
|
|
||||||
* @throws IOException IO 异常
|
* @throws IOException IO 异常
|
||||||
*/
|
*/
|
||||||
@SaCheckPermission("system:oss:download")
|
@SaCheckPermission("system:oss:download")
|
||||||
@GetMapping("/download/{ossId}")
|
@GetMapping("/download/{ossId}")
|
||||||
public void download(@PathVariable Long ossId, HttpServletResponse response) throws IOException {
|
public ResponseEntity<byte[]> download(@PathVariable Long ossId) throws IOException {
|
||||||
ossService.download(ossId, response);
|
return ossService.download(ossId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
package org.dromara.system.service;
|
package org.dromara.system.service;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import org.dromara.common.core.domain.PageResult;
|
import org.dromara.common.core.domain.PageResult;
|
||||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||||
import org.dromara.system.domain.bo.SysOssBo;
|
import org.dromara.system.domain.bo.SysOssBo;
|
||||||
import org.dromara.system.domain.vo.SysOssVo;
|
import org.dromara.system.domain.vo.SysOssVo;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -64,9 +63,8 @@ public interface ISysOssService {
|
|||||||
* 文件下载方法,支持一次性下载完整文件
|
* 文件下载方法,支持一次性下载完整文件
|
||||||
*
|
*
|
||||||
* @param ossId OSS对象ID
|
* @param ossId OSS对象ID
|
||||||
* @param response HttpServletResponse对象,用于设置响应头和向客户端发送文件内容
|
|
||||||
*/
|
*/
|
||||||
void download(Long ossId, HttpServletResponse response) throws IOException;
|
ResponseEntity<byte[]> download(Long ossId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除OSS对象存储
|
* 删除OSS对象存储
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ package org.dromara.system.service.impl;
|
|||||||
|
|
||||||
import cn.hutool.core.bean.BeanUtil;
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
import cn.hutool.core.convert.Convert;
|
import cn.hutool.core.convert.Convert;
|
||||||
|
import cn.hutool.core.io.IoUtil;
|
||||||
import cn.hutool.core.util.ObjectUtil;
|
import cn.hutool.core.util.ObjectUtil;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.dromara.common.core.constant.CacheNames;
|
import org.dromara.common.core.constant.CacheNames;
|
||||||
import org.dromara.common.core.domain.PageResult;
|
import org.dromara.common.core.domain.PageResult;
|
||||||
@@ -21,10 +21,9 @@ import org.dromara.common.core.utils.file.FileUtils;
|
|||||||
import org.dromara.common.json.utils.JsonUtils;
|
import org.dromara.common.json.utils.JsonUtils;
|
||||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||||
import org.dromara.common.oss.client.OssClient;
|
import org.dromara.common.oss.client.OssClient;
|
||||||
import org.dromara.common.oss.factory.OssFactory;
|
|
||||||
import org.dromara.common.oss.model.GetObjectResult;
|
|
||||||
import org.dromara.common.oss.model.PutObjectResult;
|
|
||||||
import org.dromara.common.oss.enums.AccessPolicy;
|
import org.dromara.common.oss.enums.AccessPolicy;
|
||||||
|
import org.dromara.common.oss.factory.OssFactory;
|
||||||
|
import org.dromara.common.oss.model.PutObjectResult;
|
||||||
import org.dromara.common.oss.util.S3ObjectUtil;
|
import org.dromara.common.oss.util.S3ObjectUtil;
|
||||||
import org.dromara.system.domain.SysOss;
|
import org.dromara.system.domain.SysOss;
|
||||||
import org.dromara.system.domain.SysOssExt;
|
import org.dromara.system.domain.SysOssExt;
|
||||||
@@ -35,6 +34,7 @@ import org.dromara.system.service.ISysOssService;
|
|||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@@ -182,20 +182,28 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
|
|||||||
/**
|
/**
|
||||||
* 文件下载方法,支持一次性下载完整文件
|
* 文件下载方法,支持一次性下载完整文件
|
||||||
*
|
*
|
||||||
* @param ossId OSS对象ID
|
* @param ossId OSS对象ID
|
||||||
* @param response HttpServletResponse对象,用于设置响应头和向客户端发送文件内容
|
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void download(Long ossId, HttpServletResponse response) throws IOException {
|
public ResponseEntity<byte[]> download(Long ossId) {
|
||||||
SysOssVo sysOss = SpringUtils.getAopProxy(this).getById(ossId);
|
SysOssVo sysOss = SpringUtils.getAopProxy(this).getById(ossId);
|
||||||
if (ObjectUtil.isNull(sysOss)) {
|
if (ObjectUtil.isNull(sysOss)) {
|
||||||
throw new ServiceException("文件数据不存在!");
|
throw new ServiceException("文件数据不存在!");
|
||||||
}
|
}
|
||||||
FileUtils.setAttachmentResponseHeader(response, sysOss.getOriginalName());
|
String percentEncodedFileName = FileUtils.percentEncode(sysOss.getOriginalName());
|
||||||
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");
|
String contentDispositionValue = "attachment; filename=%s;filename*=utf-8''%s".formatted(percentEncodedFileName, percentEncodedFileName);
|
||||||
OssClient instance = OssFactory.instance(sysOss.getService());
|
return OssFactory.instance(sysOss.getService())
|
||||||
GetObjectResult result = instance.download(sysOss.getFileName(), response.getOutputStream());
|
.download(sysOss.getFileName(), (result, inputStream) -> {
|
||||||
response.setContentLengthLong(result.size());
|
// 构建响应实体
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header("Access-Control-Expose-Headers", "Content-Disposition,download-filename")
|
||||||
|
.header("Content-disposition", contentDispositionValue)
|
||||||
|
.header("download-filename", percentEncodedFileName)
|
||||||
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.contentLength(result.size())
|
||||||
|
.body(IoUtil.readBytes(inputStream));
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user