feat: sharelink 资源下载管理插件 v1.1.0
- 下载资源管理与 /download/{slug} 下载链接
- 下载统计(次数/去重人数/明细/CSV 导出)
- 按资源邮箱验证(Halo 通知中心发信,互认评论插件已验证邮箱)
- 本地附件防直链(/upload/** 404)
- 文章引用扫描
- Console 前端:Vue3 + ui-plugin-bundler-kit
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package run.halo.sharelink;
|
||||
|
||||
import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.extension.Scheme;
|
||||
import run.halo.app.extension.SchemeManager;
|
||||
import run.halo.app.extension.index.IndexSpec;
|
||||
import run.halo.app.plugin.BasePlugin;
|
||||
import run.halo.app.plugin.PluginContext;
|
||||
import run.halo.sharelink.model.DownloadRecord;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
import run.halo.sharelink.model.VerifiedDownloader;
|
||||
|
||||
@Component
|
||||
public class SharelinkPlugin extends BasePlugin {
|
||||
|
||||
private final SchemeManager schemeManager;
|
||||
|
||||
public SharelinkPlugin(PluginContext pluginContext, SchemeManager schemeManager) {
|
||||
super(pluginContext);
|
||||
this.schemeManager = schemeManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
schemeManager.register(DownloadResource.class, indexSpecs -> indexSpecs.add(
|
||||
new IndexSpec()
|
||||
.setName("spec.slug")
|
||||
.setUnique(true)
|
||||
.setIndexFunc(simpleAttribute(DownloadResource.class,
|
||||
resource -> resource.getSpec() == null ? null : resource.getSpec().getSlug()))));
|
||||
schemeManager.register(DownloadRecord.class, indexSpecs -> {
|
||||
indexSpecs.add(new IndexSpec()
|
||||
.setName("spec.resourceSlug")
|
||||
.setIndexFunc(simpleAttribute(DownloadRecord.class,
|
||||
record -> record.getSpec() == null ? null : record.getSpec().getResourceSlug())));
|
||||
indexSpecs.add(new IndexSpec()
|
||||
.setName("spec.downloadedAt")
|
||||
.setIndexFunc(simpleAttribute(DownloadRecord.class,
|
||||
record -> record.getSpec() == null || record.getSpec().getDownloadedAt() == null
|
||||
? null : record.getSpec().getDownloadedAt().toString())));
|
||||
});
|
||||
schemeManager.register(VerifiedDownloader.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
schemeManager.unregister(Scheme.buildFromType(DownloadResource.class));
|
||||
schemeManager.unregister(Scheme.buildFromType(DownloadRecord.class));
|
||||
schemeManager.unregister(Scheme.buildFromType(VerifiedDownloader.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package run.halo.sharelink;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
|
||||
/**
|
||||
* Typed access to this plugin's setting groups ({@code basic} and {@code emailVerify}).
|
||||
* Every getter falls back to built-in defaults and never returns {@link Mono#empty()}.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SharelinkSettingConfigGetter {
|
||||
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
|
||||
public Mono<BasicConfig> getBasicConfig() {
|
||||
return settingFetcher.fetch(BasicConfig.GROUP, BasicConfig.class)
|
||||
.defaultIfEmpty(new BasicConfig());
|
||||
}
|
||||
|
||||
public Mono<EmailVerifyConfig> getEmailVerifyConfig() {
|
||||
return settingFetcher.fetch(EmailVerifyConfig.GROUP, EmailVerifyConfig.class)
|
||||
.defaultIfEmpty(new EmailVerifyConfig());
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class BasicConfig {
|
||||
public static final String GROUP = "basic";
|
||||
|
||||
private int tokenTtlSeconds = 60;
|
||||
|
||||
private int dedupeWindowMinutes = 10;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class EmailVerifyConfig {
|
||||
public static final String GROUP = "emailVerify";
|
||||
|
||||
private int codeExpireMinutes = 10;
|
||||
|
||||
private int resendIntervalSeconds = 60;
|
||||
|
||||
private int dailySendLimitPerEmail = 5;
|
||||
|
||||
private int maxVerifyAttempts = 5;
|
||||
|
||||
private int ipHourlySendLimit = 20;
|
||||
|
||||
private boolean trustCommentVerified = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package run.halo.sharelink.console;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.sharelink.model.DownloadRecord;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
import run.halo.sharelink.protect.UploadProtectFilter;
|
||||
import run.halo.sharelink.reference.PostReferenceService;
|
||||
|
||||
/**
|
||||
* Console endpoints for download resource administration, mounted under
|
||||
* {@code /apis/console.api.sharelink.halo.run/v1alpha1}, so authentication and RBAC are
|
||||
* enforced by Halo.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ConsoleEndpoint implements CustomEndpoint {
|
||||
|
||||
private static final Pattern SLUG_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,63}");
|
||||
private static final int STATS_CONCURRENCY = 8;
|
||||
private static final int MAX_PAGE_SIZE = 200;
|
||||
private static final MediaType CSV_MEDIA_TYPE =
|
||||
MediaType.parseMediaType("text/csv; charset=UTF-8");
|
||||
private static final String CSV_DISPOSITION =
|
||||
"attachment; filename=\"download-records.csv\"";
|
||||
/**
|
||||
* BOM prefix so spreadsheet applications detect the UTF-8 encoding.
|
||||
*/
|
||||
private static final String CSV_BOM = "\uFEFF";
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final PostReferenceService referenceService;
|
||||
private final UploadProtectFilter uploadProtectFilter;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
return RouterFunctions.route()
|
||||
.GET("download-resources", this::listResources)
|
||||
.POST("download-resources", this::createResource)
|
||||
.GET("download-resources/{name}", this::getResource)
|
||||
.PUT("download-resources/{name}", this::updateResource)
|
||||
.DELETE("download-resources/{name}", this::deleteResource)
|
||||
.GET("download-records", this::listRecords)
|
||||
.DELETE("download-records/{name}", this::deleteRecord)
|
||||
.GET("download-records/-/export", this::exportRecords)
|
||||
.GET("references", this::getReferences)
|
||||
.POST("references/-/refresh", this::refreshReferences)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ---------- resources ----------
|
||||
|
||||
private Mono<ServerResponse> listResources(ServerRequest request) {
|
||||
var references = referenceService.references()
|
||||
.onErrorResume(e -> Mono.just(Map.<String, List<PostReferenceService.PostRef>>of()));
|
||||
return references.flatMap(refMap -> client.listAll(DownloadResource.class,
|
||||
new ListOptions(), Sort.unsorted())
|
||||
.sort(Comparator.comparing(ConsoleEndpoint::creationTimestamp,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.flatMap(resource -> toVo(resource, refMap), STATS_CONCURRENCY)
|
||||
.collectList())
|
||||
.flatMap(vos -> ServerResponse.ok().bodyValue(vos));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> createResource(ServerRequest request) {
|
||||
return request.bodyToMono(ResourceRequest.class)
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||
.flatMap(body -> validateResourceRequest(body, null)
|
||||
.then(Mono.defer(() -> {
|
||||
var resource = new DownloadResource();
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(UUID.randomUUID().toString());
|
||||
resource.setMetadata(metadata);
|
||||
var spec = new DownloadResource.Spec();
|
||||
applyRequest(spec, body);
|
||||
resource.setSpec(spec);
|
||||
var status = new DownloadResource.Status();
|
||||
status.setDownloadCount(0);
|
||||
resource.setStatus(status);
|
||||
return client.create(resource);
|
||||
})))
|
||||
.doOnSuccess(created -> uploadProtectFilter.invalidate())
|
||||
.flatMap(this::toVo)
|
||||
.flatMap(vo -> ServerResponse.ok().bodyValue(vo));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getResource(ServerRequest request) {
|
||||
return client.fetch(DownloadResource.class, request.pathVariable("name"))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "下载资源不存在")))
|
||||
.flatMap(this::toVo)
|
||||
.flatMap(vo -> ServerResponse.ok().bodyValue(vo));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> updateResource(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return request.bodyToMono(ResourceRequest.class)
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||
.flatMap(body -> client.fetch(DownloadResource.class, name)
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "下载资源不存在")))
|
||||
.flatMap(resource -> validateResourceRequest(body, name)
|
||||
.then(Mono.defer(() -> {
|
||||
applyRequest(resource.getSpec(), body);
|
||||
return client.update(resource);
|
||||
}))))
|
||||
.doOnSuccess(updated -> uploadProtectFilter.invalidate())
|
||||
.flatMap(this::toVo)
|
||||
.flatMap(vo -> ServerResponse.ok().bodyValue(vo));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> deleteResource(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return client.fetch(DownloadResource.class, name)
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "下载资源不存在")))
|
||||
.flatMap(resource -> {
|
||||
var slug = resource.getSpec().getSlug();
|
||||
return client.delete(resource)
|
||||
.then(deleteRecordsOfSlug(slug));
|
||||
})
|
||||
.doOnSuccess(ignored -> uploadProtectFilter.invalidate())
|
||||
.then(ServerResponse.ok().build());
|
||||
}
|
||||
|
||||
private Mono<Void> deleteRecordsOfSlug(String slug) {
|
||||
if (StringUtils.isBlank(slug)) {
|
||||
return Mono.empty();
|
||||
}
|
||||
return recordsOfSlug(slug)
|
||||
.flatMap(client::delete)
|
||||
.then();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates slug format/uniqueness and attachment existence.
|
||||
*
|
||||
* @param selfName the name of the resource being updated, excluded from the
|
||||
* uniqueness check; {@code null} on creation
|
||||
*/
|
||||
private Mono<Void> validateResourceRequest(ResourceRequest body, String selfName) {
|
||||
var slug = StringUtils.trimToNull(body.slug());
|
||||
if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) {
|
||||
return Mono.error(new ServerWebInputException(
|
||||
"slug 格式不正确,需匹配 [a-z0-9][a-z0-9-]{0,63}"));
|
||||
}
|
||||
if (StringUtils.isBlank(body.attachmentName())) {
|
||||
return Mono.error(new ServerWebInputException("附件不能为空"));
|
||||
}
|
||||
var slugAvailable = findBySlug(slug)
|
||||
.filter(existing -> selfName == null
|
||||
|| !selfName.equals(existing.getMetadata().getName()))
|
||||
.flatMap(existing -> Mono.error(new ResponseStatusException(
|
||||
HttpStatus.CONFLICT, "slug 已被其他资源占用")))
|
||||
.then();
|
||||
var attachmentExists = client.fetch(Attachment.class, body.attachmentName())
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("附件不存在")))
|
||||
.then();
|
||||
return Mono.when(slugAvailable, attachmentExists);
|
||||
}
|
||||
|
||||
private static void applyRequest(DownloadResource.Spec spec, ResourceRequest body) {
|
||||
spec.setSlug(body.slug().trim());
|
||||
spec.setDisplayName(StringUtils.defaultIfBlank(
|
||||
StringUtils.trimToNull(body.displayName()), spec.getSlug()));
|
||||
spec.setDescription(StringUtils.trimToNull(body.description()));
|
||||
spec.setAttachmentName(body.attachmentName().trim());
|
||||
spec.setRequireEmailVerify(body.requireEmailVerify());
|
||||
spec.setEnabled(body.enabled());
|
||||
}
|
||||
|
||||
// ---------- records ----------
|
||||
|
||||
private Mono<ServerResponse> listRecords(ServerRequest request) {
|
||||
var resourceSlug = request.queryParam("resourceSlug")
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
var page = parsePositiveInt(request, "page", 1);
|
||||
var size = Math.min(parsePositiveInt(request, "size", 20), MAX_PAGE_SIZE);
|
||||
var optionsBuilder = ListOptions.builder();
|
||||
if (resourceSlug != null) {
|
||||
optionsBuilder.fieldQuery(QueryFactory.equal("spec.resourceSlug", resourceSlug));
|
||||
}
|
||||
var pageRequest = PageRequestImpl.of(page, size,
|
||||
Sort.by(Sort.Direction.DESC, "spec.downloadedAt"));
|
||||
return client.listBy(DownloadRecord.class, optionsBuilder.build(), pageRequest)
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> deleteRecord(ServerRequest request) {
|
||||
return client.fetch(DownloadRecord.class, request.pathVariable("name"))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "下载记录不存在")))
|
||||
.flatMap(client::delete)
|
||||
.then(ServerResponse.ok().build());
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> exportRecords(ServerRequest request) {
|
||||
var resourceSlug = request.queryParam("resourceSlug")
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
var records = resourceSlug == null
|
||||
? client.listAll(DownloadRecord.class, new ListOptions(), Sort.unsorted())
|
||||
: recordsOfSlug(resourceSlug);
|
||||
return records
|
||||
.sort(Comparator.comparing(ConsoleEndpoint::downloadedAt,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.collectList()
|
||||
.map(ConsoleEndpoint::toCsv)
|
||||
.flatMap(csv -> ServerResponse.ok()
|
||||
.contentType(CSV_MEDIA_TYPE)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, CSV_DISPOSITION)
|
||||
.bodyValue(csv.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
// ---------- references ----------
|
||||
|
||||
private Mono<ServerResponse> getReferences(ServerRequest request) {
|
||||
return referenceService.references()
|
||||
.flatMap(refs -> ServerResponse.ok().bodyValue(refs));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> refreshReferences(ServerRequest request) {
|
||||
return referenceService.refresh()
|
||||
.flatMap(refs -> ServerResponse.ok().bodyValue(refs));
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private Mono<ResourceVo> toVo(DownloadResource resource) {
|
||||
return referenceService.references()
|
||||
.onErrorResume(e -> Mono.just(Map.of()))
|
||||
.flatMap(refMap -> toVo(resource, refMap));
|
||||
}
|
||||
|
||||
private Mono<ResourceVo> toVo(DownloadResource resource,
|
||||
Map<String, List<PostReferenceService.PostRef>> refMap) {
|
||||
var spec = resource.getSpec();
|
||||
var slug = spec == null ? null : spec.getSlug();
|
||||
var records = StringUtils.isBlank(slug)
|
||||
? Mono.just(List.<DownloadRecord>of())
|
||||
: recordsOfSlug(slug).collectList();
|
||||
return records.map(recordList -> {
|
||||
var identities = new LinkedHashSet<String>();
|
||||
for (var record : recordList) {
|
||||
var recordSpec = record.getSpec();
|
||||
if (recordSpec == null) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(recordSpec.getEmail())) {
|
||||
identities.add("e:" + recordSpec.getEmail());
|
||||
} else if (StringUtils.isNotBlank(recordSpec.getIp())) {
|
||||
identities.add("i:" + recordSpec.getIp());
|
||||
}
|
||||
}
|
||||
var referenceCount = slug == null ? 0
|
||||
: refMap.getOrDefault(slug, List.of()).size();
|
||||
var stats = new ResourceStats(recordList.size(), identities.size(),
|
||||
referenceCount);
|
||||
return ResourceVo.from(resource, stats);
|
||||
});
|
||||
}
|
||||
|
||||
private Flux<DownloadRecord> recordsOfSlug(String slug) {
|
||||
var options = ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.equal("spec.resourceSlug", slug))
|
||||
.build();
|
||||
return client.listAll(DownloadRecord.class, options, Sort.unsorted());
|
||||
}
|
||||
|
||||
private Mono<DownloadResource> findBySlug(String slug) {
|
||||
var options = ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.equal("spec.slug", slug))
|
||||
.build();
|
||||
return client.listAll(DownloadResource.class, options, Sort.unsorted()).next();
|
||||
}
|
||||
|
||||
private static int parsePositiveInt(ServerRequest request, String name, int fallback) {
|
||||
return request.queryParam(name)
|
||||
.map(value -> {
|
||||
try {
|
||||
return Math.max(1, Integer.parseInt(value.trim()));
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
})
|
||||
.orElse(fallback);
|
||||
}
|
||||
|
||||
private static Instant creationTimestamp(DownloadResource resource) {
|
||||
return resource.getMetadata() == null ? null
|
||||
: resource.getMetadata().getCreationTimestamp();
|
||||
}
|
||||
|
||||
private static Instant downloadedAt(DownloadRecord record) {
|
||||
return record.getSpec() == null ? null : record.getSpec().getDownloadedAt();
|
||||
}
|
||||
|
||||
private static String toCsv(List<DownloadRecord> records) {
|
||||
var csv = new StringBuilder(CSV_BOM);
|
||||
csv.append("下载时间,资源Slug,邮箱,IP,User-Agent");
|
||||
for (var record : records) {
|
||||
var spec = record.getSpec();
|
||||
csv.append('\n')
|
||||
.append(csvField(instantText(downloadedAt(record)))).append(',')
|
||||
.append(csvField(spec == null ? null : spec.getResourceSlug())).append(',')
|
||||
.append(csvField(spec == null ? null : spec.getEmail())).append(',')
|
||||
.append(csvField(spec == null ? null : spec.getIp())).append(',')
|
||||
.append(csvField(spec == null ? null : spec.getUserAgent()));
|
||||
}
|
||||
return csv.toString();
|
||||
}
|
||||
|
||||
private static String instantText(Instant instant) {
|
||||
return instant == null ? "" : instant.toString();
|
||||
}
|
||||
|
||||
private static String csvField(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (value.contains(",") || value.contains("\"") || value.contains("\n")
|
||||
|| value.contains("\r")) {
|
||||
return '"' + value.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return GroupVersion.parseAPIVersion("console.api.sharelink.halo.run/v1alpha1");
|
||||
}
|
||||
|
||||
public record ResourceRequest(String slug, String displayName, String description,
|
||||
String attachmentName, boolean requireEmailVerify,
|
||||
boolean enabled) {
|
||||
}
|
||||
|
||||
public record ResourceStats(long downloadCount, long downloaderCount,
|
||||
long referenceCount) {
|
||||
}
|
||||
|
||||
public record ResourceVo(String name, String slug, String displayName,
|
||||
String description, String attachmentName,
|
||||
boolean requireEmailVerify, boolean enabled,
|
||||
String downloadUrl, Instant creationTimestamp,
|
||||
ResourceStats stats) {
|
||||
|
||||
static ResourceVo from(DownloadResource resource, ResourceStats stats) {
|
||||
var spec = resource.getSpec();
|
||||
var name = resource.getMetadata() == null ? null
|
||||
: resource.getMetadata().getName();
|
||||
var creationTimestamp = ConsoleEndpoint.creationTimestamp(resource);
|
||||
if (spec == null) {
|
||||
return new ResourceVo(name, null, null, null, null, false, false, null,
|
||||
creationTimestamp, stats);
|
||||
}
|
||||
return new ResourceVo(name, spec.getSlug(), spec.getDisplayName(),
|
||||
spec.getDescription(), spec.getAttachmentName(),
|
||||
spec.isRequireEmailVerify(), spec.isEnabled(),
|
||||
"/download/" + spec.getSlug(), creationTimestamp, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
/**
|
||||
* Renders the self-contained HTML pages for the public download flow (no external CSS/JS
|
||||
* dependencies). All dynamic values are HTML-escaped before being embedded.
|
||||
*/
|
||||
public final class DownloadPageRenderer {
|
||||
|
||||
private DownloadPageRenderer() {
|
||||
}
|
||||
|
||||
public static String renderDownloadPage(String slug, String displayName,
|
||||
String description, boolean requireEmailVerify) {
|
||||
return DOWNLOAD_PAGE
|
||||
.replace("__TITLE__", escapeHtml(displayName))
|
||||
.replace("__SLUG__", escapeHtml(slug))
|
||||
.replace("__DISPLAY_NAME__", escapeHtml(displayName))
|
||||
.replace("__DESCRIPTION__", description == null ? "" : escapeHtml(description))
|
||||
.replace("__REQUIRE_VERIFY__", String.valueOf(requireEmailVerify));
|
||||
}
|
||||
|
||||
public static String renderNotFoundPage(String slug) {
|
||||
return renderErrorPage(404, "下载资源不存在或已停用");
|
||||
}
|
||||
|
||||
public static String renderErrorPage(int status, String message) {
|
||||
return ERROR_PAGE
|
||||
.replace("__STATUS__", String.valueOf(status))
|
||||
.replace("__MESSAGE__", escapeHtml(message));
|
||||
}
|
||||
|
||||
static String escapeHtml(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
private static final String SHARED_STYLE = """
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px 16px; color: #1f2937;
|
||||
background: linear-gradient(160deg, #eef2ff 0%, #f5f7fb 45%, #eef7f4 100%);
|
||||
}
|
||||
#app { width: 100%; max-width: 440px; }
|
||||
.card {
|
||||
background: #fff; border-radius: 20px; width: 100%; max-width: 440px;
|
||||
min-height: 520px; padding: 40px 36px 32px; text-align: center;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
box-shadow: 0 1px 2px rgba(16,24,40,.04),
|
||||
0 12px 32px -8px rgba(16,24,40,.12);
|
||||
border: 1px solid rgba(226,232,240,.8);
|
||||
}
|
||||
""";
|
||||
|
||||
private static final String ERROR_PAGE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>__STATUS__ - 无法下载</title>
|
||||
<style>
|
||||
__SHARED_STYLE__
|
||||
.status-badge {
|
||||
width: 64px; height: 64px; margin: 0 auto; border-radius: 50%;
|
||||
background: #fef2f2; color: #dc2626;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; font-weight: 700;
|
||||
}
|
||||
.msg { margin-top: 18px; color: #4b5563; font-size: 15px; line-height: 1.7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status-badge">__STATUS__</div>
|
||||
<p class="msg">__MESSAGE__</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__SHARED_STYLE__", SHARED_STYLE);
|
||||
|
||||
private static final String DOWNLOAD_PAGE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>__TITLE__ - 资源下载</title>
|
||||
<style>
|
||||
__SHARED_STYLE__
|
||||
.icon-wrap {
|
||||
width: 72px; height: 72px; margin: 0 auto 4px; border-radius: 20px;
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.icon-wrap svg { width: 36px; height: 36px; }
|
||||
h1 {
|
||||
margin-top: 18px; font-size: 21px; font-weight: 600; color: #111827;
|
||||
line-height: 1.4; word-break: break-word;
|
||||
}
|
||||
.desc {
|
||||
margin-top: 10px; color: #6b7280; font-size: 14px; line-height: 1.8;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.desc:empty { display: none; }
|
||||
.section { margin-top: 26px; }
|
||||
.section .fields { display: flex; flex-direction: column; gap: 10px; }
|
||||
input {
|
||||
width: 100%; padding: 11px 14px; border: 1px solid #d1d5db;
|
||||
border-radius: 10px; font-size: 14px; outline: none; background: #fff;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,.12); }
|
||||
.code-row { display: flex; gap: 10px; }
|
||||
.code-row input { flex: 1; min-width: 0; }
|
||||
.code-row button {
|
||||
flex-shrink: 0; padding: 0 16px; border: 1px solid #4f46e5;
|
||||
background: #fff; color: #4f46e5; border-radius: 10px; font-size: 13px;
|
||||
font-weight: 500; cursor: pointer; white-space: nowrap;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.code-row button:hover:not(:disabled) { background: #eef2ff; }
|
||||
.code-row button:disabled { border-color: #d1d5db; color: #9ca3af; cursor: not-allowed; }
|
||||
#download-btn {
|
||||
width: 100%; margin-top: 26px; padding: 13px 0; border: none;
|
||||
border-radius: 12px; color: #fff; font-size: 15px; font-weight: 600;
|
||||
cursor: pointer; letter-spacing: .05em;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
|
||||
box-shadow: 0 4px 14px -2px rgba(79,70,229,.45);
|
||||
transition: transform .12s, box-shadow .15s, opacity .15s;
|
||||
}
|
||||
#download-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px -2px rgba(79,70,229,.5);
|
||||
}
|
||||
#download-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
#download-btn:disabled { opacity: .55; cursor: not-allowed; box-shadow: none; }
|
||||
.tip {
|
||||
margin-top: 14px; font-size: 13px; color: #059669; display: flex;
|
||||
align-items: center; justify-content: center; gap: 5px;
|
||||
}
|
||||
.msg { margin-top: 16px; font-size: 13px; line-height: 1.6; min-height: 20px; }
|
||||
.msg.error { color: #dc2626; }
|
||||
.msg.info { color: #059669; }
|
||||
.footer {
|
||||
margin-top: 22px; font-size: 12px; color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" data-slug="__SLUG__" data-require-verify="__REQUIRE_VERIFY__">
|
||||
<div class="card">
|
||||
<div class="icon-wrap">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#4f46e5" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>__DISPLAY_NAME__</h1>
|
||||
<p class="desc">__DESCRIPTION__</p>
|
||||
<div id="verify-section" class="section" hidden>
|
||||
<div class="fields">
|
||||
<input id="email" type="email" placeholder="请输入邮箱地址" autocomplete="email">
|
||||
<div class="code-row">
|
||||
<input id="code" type="text" placeholder="验证码" maxlength="6"
|
||||
autocomplete="off" inputmode="numeric">
|
||||
<button id="send-btn" type="button">发送验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="verified-tip" class="tip" hidden>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||
stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
该邮箱已完成验证,可直接下载。
|
||||
</p>
|
||||
</div>
|
||||
<button id="download-btn" type="button">立即下载</button>
|
||||
<p id="msg" class="msg"></p>
|
||||
<p class="footer">安全下载 · 由 Sharelink 提供</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var app = document.getElementById('app');
|
||||
var slug = app.dataset.slug;
|
||||
var requireVerify = app.dataset.requireVerify === 'true';
|
||||
var API = '/apis/api.sharelink.halo.run/v1alpha1';
|
||||
var msg = document.getElementById('msg');
|
||||
var downloadBtn = document.getElementById('download-btn');
|
||||
|
||||
function showMsg(text, isError) {
|
||||
msg.textContent = text || '';
|
||||
msg.className = 'msg ' + (isError ? 'error' : 'info');
|
||||
}
|
||||
|
||||
function postJson(url, body) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (resp) {
|
||||
return resp.json().catch(function () { return null; }).then(function (data) {
|
||||
if (!resp.ok) {
|
||||
var detail = data && (data.detail || data.title)
|
||||
? (data.detail || data.title) : ('请求失败 (' + resp.status + ')');
|
||||
throw new Error(detail);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var emailInput = document.getElementById('email');
|
||||
var codeInput = document.getElementById('code');
|
||||
var sendBtn = document.getElementById('send-btn');
|
||||
var verifiedTip = document.getElementById('verified-tip');
|
||||
var emailVerified = false;
|
||||
|
||||
if (requireVerify) {
|
||||
document.getElementById('verify-section').hidden = false;
|
||||
|
||||
var checkTimer = null;
|
||||
emailInput.addEventListener('input', function () {
|
||||
emailVerified = false;
|
||||
verifiedTip.hidden = true;
|
||||
clearTimeout(checkTimer);
|
||||
var email = emailInput.value.trim();
|
||||
if (!email || email.indexOf('@') < 0) { return; }
|
||||
checkTimer = setTimeout(function () {
|
||||
postJson(API + '/email-verify/-/check', { email: email })
|
||||
.then(function (res) {
|
||||
if (res.verified) {
|
||||
emailVerified = true;
|
||||
verifiedTip.hidden = false;
|
||||
}
|
||||
})
|
||||
.catch(function () { /* 忽略,回退到验证码流程 */ });
|
||||
}, 500);
|
||||
});
|
||||
|
||||
sendBtn.addEventListener('click', function () {
|
||||
var email = emailInput.value.trim();
|
||||
if (!email) { showMsg('请先输入邮箱地址', true); return; }
|
||||
sendBtn.disabled = true;
|
||||
postJson(API + '/email-verify/-/send', { email: email })
|
||||
.then(function (res) {
|
||||
showMsg('验证码已发送,请查收邮件(' + res.expireMinutes + ' 分钟内有效)', false);
|
||||
startCountdown(res.resendAfterSeconds || 60);
|
||||
})
|
||||
.catch(function (e) {
|
||||
showMsg(e.message, true);
|
||||
sendBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
function startCountdown(seconds) {
|
||||
var remaining = seconds;
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = remaining + ' 秒后重发';
|
||||
var timer = setInterval(function () {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timer);
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = '发送验证码';
|
||||
} else {
|
||||
sendBtn.textContent = remaining + ' 秒后重发';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
downloadBtn.addEventListener('click', function () {
|
||||
showMsg('', false);
|
||||
var body = { slug: slug };
|
||||
if (requireVerify) {
|
||||
var email = emailInput.value.trim();
|
||||
if (!email) { showMsg('请输入邮箱地址', true); return; }
|
||||
body.email = email;
|
||||
if (!emailVerified) {
|
||||
var code = codeInput.value.trim();
|
||||
if (!code) {
|
||||
showMsg('请输入邮箱验证码;若该邮箱之前已验证过,可直接下载', true);
|
||||
return;
|
||||
}
|
||||
body.code = code;
|
||||
}
|
||||
}
|
||||
downloadBtn.disabled = true;
|
||||
postJson(API + '/downloads/-/token', body)
|
||||
.then(function (res) {
|
||||
showMsg('开始下载…', false);
|
||||
window.location.href = res.fileUrl;
|
||||
setTimeout(function () { downloadBtn.disabled = false; }, 3000);
|
||||
})
|
||||
.catch(function (e) {
|
||||
showMsg(e.message, true);
|
||||
downloadBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__SHARED_STYLE__", SHARED_STYLE);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
|
||||
/**
|
||||
* Shared lookup helpers for {@link DownloadResource}.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DownloadResourceService {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
/**
|
||||
* Finds a resource by its (unique) slug.
|
||||
*/
|
||||
public Mono<DownloadResource> findBySlug(String slug) {
|
||||
var options = ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.equal("spec.slug", slug))
|
||||
.build();
|
||||
return client.listAll(DownloadResource.class, options, Sort.unsorted())
|
||||
.next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
import run.halo.sharelink.emailcode.EmailCodeManager;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
import run.halo.sharelink.util.EmailUtils;
|
||||
import run.halo.sharelink.util.IpUtils;
|
||||
import run.halo.sharelink.verify.VerifiedEmailService;
|
||||
|
||||
/**
|
||||
* Public endpoint exchanging a (possibly verified) identity for a one-time download
|
||||
* token. Mounted under {@code /apis/api.sharelink.halo.run/v1alpha1} and opened to
|
||||
* anonymous visitors via the aggregate-to-anonymous role template.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DownloadTokenEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||
private final VerifiedEmailService verifiedEmailService;
|
||||
private final EmailCodeManager emailCodeManager;
|
||||
private final DownloadTokenManager tokenManager;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
return RouterFunctions.route()
|
||||
.POST("downloads/-/token", this::issueToken)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> issueToken(ServerRequest request) {
|
||||
return request.bodyToMono(TokenRequest.class)
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||
.flatMap(body -> {
|
||||
var slug = StringUtils.trimToNull(body.slug());
|
||||
if (slug == null) {
|
||||
return Mono.error(new ServerWebInputException("slug 不能为空"));
|
||||
}
|
||||
return findBySlug(slug)
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "下载资源不存在")))
|
||||
.flatMap(resource -> doIssueToken(request, resource, body));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> doIssueToken(ServerRequest request,
|
||||
DownloadResource resource,
|
||||
TokenRequest body) {
|
||||
var spec = resource.getSpec();
|
||||
if (!spec.isEnabled()) {
|
||||
return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "该资源已停用"));
|
||||
}
|
||||
var clientIp = IpUtils.clientIp(request.exchange().getRequest());
|
||||
return settingConfigGetter.getBasicConfig()
|
||||
.flatMap(basic -> {
|
||||
if (!spec.isRequireEmailVerify()) {
|
||||
return respondWithToken(spec, null, clientIp, basic);
|
||||
}
|
||||
var email = EmailUtils.normalizeEmail(body.email());
|
||||
if (email == null || !EmailUtils.isValidEmail(email)) {
|
||||
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
||||
}
|
||||
return verifiedEmailService.isVerified(email)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("查询邮箱验证状态失败,按未验证处理: {}", email, e);
|
||||
return Mono.just(false);
|
||||
})
|
||||
.flatMap(verified -> {
|
||||
if (verified) {
|
||||
return respondWithToken(spec, email, clientIp, basic);
|
||||
}
|
||||
var code = StringUtils.trimToNull(body.code());
|
||||
if (code == null) {
|
||||
return Mono.error(new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "请先完成邮箱验证"));
|
||||
}
|
||||
return settingConfigGetter.getEmailVerifyConfig()
|
||||
.flatMap(config -> emailCodeManager.verify(email, code, config)
|
||||
.flatMap(valid -> {
|
||||
if (!valid) {
|
||||
return Mono.error(new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "验证码错误或已过期,请重新获取"));
|
||||
}
|
||||
return verifiedEmailService.recordVerified(email, clientIp)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("登记已验证邮箱失败: {}", email, e);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then(respondWithToken(spec, email, clientIp, basic));
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> respondWithToken(DownloadResource.Spec spec, String email,
|
||||
String clientIp,
|
||||
SharelinkSettingConfigGetter.BasicConfig basic) {
|
||||
var token = tokenManager.issue(spec.getSlug(), email, clientIp);
|
||||
var fileUrl = "/download/" + spec.getSlug() + "/file?token=" + token;
|
||||
return ServerResponse.ok().bodyValue(new TokenResponse(token, fileUrl));
|
||||
}
|
||||
|
||||
private Mono<DownloadResource> findBySlug(String slug) {
|
||||
var options = ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.equal("spec.slug", slug))
|
||||
.build();
|
||||
return client.listAll(DownloadResource.class, options, Sort.unsorted()).next();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return GroupVersion.parseAPIVersion("api.sharelink.halo.run/v1alpha1");
|
||||
}
|
||||
|
||||
public record TokenRequest(String slug, String email, String code) {
|
||||
}
|
||||
|
||||
public record TokenResponse(String token, String fileUrl) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Issues and consumes one-time download tokens. A token is bound to a resource slug (and
|
||||
* the verified email / client ip for auditing) and becomes invalid as soon as it is
|
||||
* consumed or its TTL (settings {@code basic.tokenTtlSeconds}) elapses.
|
||||
*/
|
||||
@Component
|
||||
public class DownloadTokenManager {
|
||||
|
||||
/**
|
||||
* Fallback eviction for never-consumed tokens; the effective expiration is checked
|
||||
* against the configured TTL on consumption.
|
||||
*/
|
||||
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
|
||||
|
||||
private final Cache<String, TokenPayload> cache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(CACHE_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
public String issue(String slug, String email, String ip) {
|
||||
var token = UUID.randomUUID().toString();
|
||||
cache.put(token, new TokenPayload(slug, email, ip, Instant.now()));
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes the token: it is removed from the cache no matter the outcome, so a token
|
||||
* can never be used twice.
|
||||
*
|
||||
* @return the payload when the token exists and has not expired, otherwise
|
||||
* {@code null}
|
||||
*/
|
||||
public TokenPayload consume(String token, long ttlSeconds) {
|
||||
if (StringUtils.isBlank(token)) {
|
||||
return null;
|
||||
}
|
||||
var payload = cache.getIfPresent(token);
|
||||
if (payload == null) {
|
||||
return null;
|
||||
}
|
||||
cache.invalidate(token);
|
||||
if (Instant.now().isAfter(payload.issuedAt().plusSeconds(ttlSeconds))) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
public record TokenPayload(String slug, String email, String ip, Instant issuedAt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.retry.Retry;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
import run.halo.sharelink.model.DownloadRecord;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
import run.halo.sharelink.util.IpUtils;
|
||||
|
||||
/**
|
||||
* Public download routes, registered as {@link RouterFunction} beans which Halo collects
|
||||
* from the plugin context:
|
||||
* <ul>
|
||||
* <li>{@code GET /download/{slug}} — the self-contained HTML download page</li>
|
||||
* <li>{@code GET /download/{slug}/file?token=...} — consumes a one-time token, records
|
||||
* the download, then streams the file</li>
|
||||
* </ul>
|
||||
* All responses are {@code Cache-Control: no-store}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DownloadWebRouter {
|
||||
|
||||
private static final MediaType HTML_UTF8 =
|
||||
new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8);
|
||||
|
||||
/**
|
||||
* Fallback eviction for dedupe entries; the effective window is checked against the
|
||||
* configured value on each hit.
|
||||
*/
|
||||
private static final Duration DEDUPE_CACHE_TTL = Duration.ofDays(1);
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||
private final DownloadTokenManager tokenManager;
|
||||
private final FileStreamer fileStreamer;
|
||||
|
||||
private final Cache<String, Instant> dedupeCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(DEDUPE_CACHE_TTL)
|
||||
.maximumSize(50_000)
|
||||
.build();
|
||||
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> downloadPageRoute() {
|
||||
return RouterFunctions.route()
|
||||
.GET("/download/{slug}", this::renderPage)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> downloadFileRoute() {
|
||||
return RouterFunctions.route()
|
||||
.GET("/download/{slug}/file", this::streamFile)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> renderPage(ServerRequest request) {
|
||||
var slug = request.pathVariable("slug");
|
||||
return findBySlug(slug)
|
||||
.filter(resource -> resource.getSpec().isEnabled())
|
||||
.flatMap(resource -> {
|
||||
var spec = resource.getSpec();
|
||||
var displayName = StringUtils.firstNonBlank(spec.getDisplayName(),
|
||||
spec.getSlug());
|
||||
var html = DownloadPageRenderer.renderDownloadPage(spec.getSlug(),
|
||||
displayName, spec.getDescription(), spec.isRequireEmailVerify());
|
||||
return ServerResponse.ok()
|
||||
.contentType(HTML_UTF8)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.bodyValue(html);
|
||||
})
|
||||
.switchIfEmpty(notFoundPage(slug));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> streamFile(ServerRequest request) {
|
||||
var slug = request.pathVariable("slug");
|
||||
var token = request.queryParam("token").orElse(null);
|
||||
return settingConfigGetter.getBasicConfig()
|
||||
.flatMap(basic -> {
|
||||
var payload = tokenManager.consume(token, basic.getTokenTtlSeconds());
|
||||
if (payload == null || !slug.equals(payload.slug())) {
|
||||
// Token missing, reused or expired: send the visitor back to the
|
||||
// download page to obtain a fresh one.
|
||||
return redirectToPage(slug);
|
||||
}
|
||||
return findBySlug(slug)
|
||||
.filter(resource -> resource.getSpec().isEnabled())
|
||||
.flatMap(resource -> fileStreamer.resolveAvailable(resource)
|
||||
.flatMap(attachment -> recordDownload(request, resource, payload,
|
||||
basic)
|
||||
.then(fileStreamer.stream(request, resource.getSpec(),
|
||||
attachment)))
|
||||
.switchIfEmpty(FileStreamer.errorPage(HttpStatus.NOT_FOUND,
|
||||
"附件暂不可用")))
|
||||
.switchIfEmpty(notFoundPage(slug));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the download (DownloadRecord + redundant counter). Best effort: failures
|
||||
* are logged and never block the download itself. Resources without email
|
||||
* verification dedupe repeated downloads from the same IP inside the configured
|
||||
* window (no record, no count, but the download still proceeds).
|
||||
*/
|
||||
private Mono<Void> recordDownload(ServerRequest request, DownloadResource resource,
|
||||
DownloadTokenManager.TokenPayload payload,
|
||||
SharelinkSettingConfigGetter.BasicConfig basic) {
|
||||
var spec = resource.getSpec();
|
||||
if (!spec.isRequireEmailVerify()
|
||||
&& isDuplicate(spec.getSlug(), payload.ip(), basic.getDedupeWindowMinutes())) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var record = new DownloadRecord();
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(UUID.randomUUID().toString());
|
||||
record.setMetadata(metadata);
|
||||
var recordSpec = new DownloadRecord.Spec();
|
||||
recordSpec.setResourceSlug(spec.getSlug());
|
||||
recordSpec.setEmail(payload.email());
|
||||
recordSpec.setIp(payload.ip());
|
||||
recordSpec.setUserAgent(
|
||||
StringUtils.truncate(request.headers().firstHeader("User-Agent"), 500));
|
||||
recordSpec.setDownloadedAt(Instant.now());
|
||||
record.setSpec(recordSpec);
|
||||
return client.create(record)
|
||||
.then(incrementDownloadCount(resource.getMetadata().getName()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("记录下载失败: slug={}", spec.getSlug(), e);
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code true} when this slug+ip pair was already seen inside the dedupe
|
||||
* window
|
||||
*/
|
||||
private boolean isDuplicate(String slug, String ip, int windowMinutes) {
|
||||
if (windowMinutes <= 0) {
|
||||
return false;
|
||||
}
|
||||
var key = slug + "|" + (ip == null ? "-" : ip);
|
||||
var now = Instant.now();
|
||||
var last = dedupeCache.getIfPresent(key);
|
||||
if (last != null && now.isBefore(last.plusSeconds(windowMinutes * 60L))) {
|
||||
return true;
|
||||
}
|
||||
dedupeCache.put(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private Mono<Void> incrementDownloadCount(String resourceName) {
|
||||
return Mono.defer(() -> client.fetch(DownloadResource.class, resourceName)
|
||||
.flatMap(resource -> {
|
||||
var status = resource.getStatus();
|
||||
if (status == null) {
|
||||
status = new DownloadResource.Status();
|
||||
resource.setStatus(status);
|
||||
}
|
||||
status.setDownloadCount(status.getDownloadCount() + 1);
|
||||
return client.update(resource);
|
||||
}))
|
||||
// Optimistic-lock retry for concurrent downloads of the same resource.
|
||||
.retryWhen(Retry.max(3)
|
||||
.filter(OptimisticLockingFailureException.class::isInstance))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("更新下载计数失败: {}", resourceName, e);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<DownloadResource> findBySlug(String slug) {
|
||||
var options = ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.equal("spec.slug", slug))
|
||||
.build();
|
||||
return client.listAll(DownloadResource.class, options, Sort.unsorted()).next();
|
||||
}
|
||||
|
||||
private static Mono<ServerResponse> redirectToPage(String slug) {
|
||||
return ServerResponse.status(HttpStatus.FOUND)
|
||||
.location(URI.create("/download/" + slug))
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Mono<ServerResponse> notFoundPage(String slug) {
|
||||
return ServerResponse.status(HttpStatus.NOT_FOUND)
|
||||
.contentType(HTML_UTF8)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.bodyValue(DownloadPageRenderer.renderNotFoundPage(slug));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.attachment.Constant;
|
||||
import run.halo.app.extension.MetadataUtil;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.ExternalUrlSupplier;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
|
||||
/**
|
||||
* Streams the attachment bytes of a download resource to the visitor.
|
||||
*
|
||||
* <p>For the local storage policy the file is read directly from disk
|
||||
* ({work-dir}/attachments/{local-relative-path}); for other policies the file is fetched
|
||||
* through a loopback HTTP request to its permalink (carrying the internal secret header
|
||||
* so {@code UploadProtectFilter} lets it pass). The body is forwarded as a
|
||||
* {@link DataBuffer} stream without buffering the whole file in memory.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class FileStreamer {
|
||||
|
||||
private static final MediaType HTML_UTF8 =
|
||||
new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8);
|
||||
|
||||
private static final int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ExternalUrlSupplier externalUrlSupplier;
|
||||
private final InternalRequestSecret internalRequestSecret;
|
||||
private final Environment environment;
|
||||
|
||||
private final WebClient webClient = WebClient.builder().build();
|
||||
|
||||
/**
|
||||
* Resolves the attachment for the resource and emits it only when its bytes are
|
||||
* actually available (local file exists, or a permalink is present for the loopback
|
||||
* fallback). Emits empty otherwise, so callers can avoid counting failed downloads.
|
||||
*/
|
||||
public Mono<Attachment> resolveAvailable(DownloadResource resource) {
|
||||
return client.fetch(Attachment.class, resource.getSpec().getAttachmentName())
|
||||
.filter(attachment -> {
|
||||
if (resolveLocalFile(attachment) != null) {
|
||||
return true;
|
||||
}
|
||||
var permalink = attachment.getStatus() == null
|
||||
? null : attachment.getStatus().getPermalink();
|
||||
return StringUtils.isNotBlank(permalink);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams an attachment previously resolved via {@link #resolveAvailable}.
|
||||
*/
|
||||
public Mono<ServerResponse> stream(ServerRequest request, DownloadResource.Spec spec,
|
||||
Attachment attachment) {
|
||||
var localFile = resolveLocalFile(attachment);
|
||||
if (localFile != null) {
|
||||
return streamLocalFile(localFile, spec, attachment);
|
||||
}
|
||||
return streamViaLoopback(request, spec, attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated kept for compatibility; prefer {@link #resolveAvailable} +
|
||||
* {@link #stream(ServerRequest, DownloadResource.Spec, Attachment)}.
|
||||
*/
|
||||
@Deprecated
|
||||
public Mono<ServerResponse> stream(ServerRequest request, DownloadResource resource) {
|
||||
var spec = resource.getSpec();
|
||||
return client.fetch(Attachment.class, spec.getAttachmentName())
|
||||
.flatMap(attachment -> {
|
||||
var localFile = resolveLocalFile(attachment);
|
||||
if (localFile != null) {
|
||||
return streamLocalFile(localFile, spec, attachment);
|
||||
}
|
||||
return streamViaLoopback(request, spec, attachment);
|
||||
})
|
||||
.switchIfEmpty(errorPage(HttpStatus.NOT_FOUND, "附件不存在"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the on-disk path for attachments stored by the local storage policy,
|
||||
* or {@code null} when the attachment is not a local file.
|
||||
*/
|
||||
private Path resolveLocalFile(Attachment attachment) {
|
||||
var annotations = MetadataUtil.nullSafeAnnotations(attachment);
|
||||
var relPath = annotations.get(Constant.LOCAL_REL_PATH_ANNO_KEY);
|
||||
if (StringUtils.isBlank(relPath)) {
|
||||
return null;
|
||||
}
|
||||
var workDir = environment.getProperty("halo.work-dir",
|
||||
System.getProperty("user.home") + "/.halo2");
|
||||
var attachmentsRoot = Paths.get(workDir).resolve("attachments").normalize();
|
||||
var file = attachmentsRoot.resolve(relPath).normalize();
|
||||
if (!file.startsWith(attachmentsRoot)) {
|
||||
log.warn("附件路径越界,已拒绝: {}", relPath);
|
||||
return null;
|
||||
}
|
||||
if (!Files.isRegularFile(file)) {
|
||||
log.warn("附件文件不存在: {}", file);
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> streamLocalFile(Path file, DownloadResource.Spec spec,
|
||||
Attachment attachment) {
|
||||
return Mono.fromCallable(() -> Files.size(file))
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.flatMap(size -> {
|
||||
var filename = resolveFilename(spec, attachment);
|
||||
var builder = ServerResponse.ok()
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition(filename))
|
||||
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(size));
|
||||
var mediaType = mediaTypeOf(attachment);
|
||||
if (mediaType != null) {
|
||||
builder.contentType(mediaType);
|
||||
}
|
||||
var body = DataBufferUtils.read(file,
|
||||
DefaultDataBufferFactory.sharedInstance, BUFFER_SIZE);
|
||||
return builder.body(body, DataBuffer.class);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> streamViaLoopback(ServerRequest request,
|
||||
DownloadResource.Spec spec, Attachment attachment) {
|
||||
var permalink = attachment.getStatus() == null
|
||||
? null : attachment.getStatus().getPermalink();
|
||||
if (StringUtils.isBlank(permalink)) {
|
||||
return errorPage(HttpStatus.NOT_FOUND, "附件暂不可用");
|
||||
}
|
||||
final URI fileUri;
|
||||
try {
|
||||
fileUri = externalUrlSupplier.getURL(request.exchange().getRequest())
|
||||
.toURI().resolve(permalink);
|
||||
} catch (URISyntaxException e) {
|
||||
log.warn("拼接附件下载地址失败: {}", permalink, e);
|
||||
return errorPage(HttpStatus.INTERNAL_SERVER_ERROR, "附件地址解析失败");
|
||||
}
|
||||
return webClient.get()
|
||||
.uri(fileUri)
|
||||
.header(InternalRequestSecret.HEADER, internalRequestSecret.value())
|
||||
.exchangeToMono(response -> {
|
||||
if (!response.statusCode().is2xxSuccessful()) {
|
||||
log.warn("回环请求附件失败: {} -> {}", fileUri, response.statusCode());
|
||||
return response.releaseBody()
|
||||
.then(errorPage(HttpStatus.NOT_FOUND, "附件暂不可用"));
|
||||
}
|
||||
var filename = resolveFilename(spec, attachment);
|
||||
var builder = ServerResponse.ok()
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
contentDisposition(filename));
|
||||
response.headers().contentType().ifPresent(builder::contentType);
|
||||
var contentLength = response.headers().contentLength();
|
||||
if (contentLength.isPresent()) {
|
||||
builder.header(HttpHeaders.CONTENT_LENGTH,
|
||||
String.valueOf(contentLength.getAsLong()));
|
||||
}
|
||||
return builder.body(response.bodyToFlux(DataBuffer.class),
|
||||
DataBuffer.class);
|
||||
});
|
||||
}
|
||||
|
||||
private static MediaType mediaTypeOf(Attachment attachment) {
|
||||
var mediaType = attachment.getSpec() == null
|
||||
? null : attachment.getSpec().getMediaType();
|
||||
if (StringUtils.isBlank(mediaType)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return MediaType.parseMediaType(mediaType);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveFilename(DownloadResource.Spec spec, Attachment attachment) {
|
||||
var attachmentName = attachment.getSpec() == null
|
||||
? null : attachment.getSpec().getDisplayName();
|
||||
var filename = StringUtils.firstNonBlank(spec.getDisplayName(), attachmentName,
|
||||
"download");
|
||||
// Append the original extension when the display name carries none.
|
||||
if (StringUtils.isNotBlank(attachmentName)
|
||||
&& attachmentName.contains(".")
|
||||
&& !filename.contains(".")) {
|
||||
filename += attachmentName.substring(attachmentName.lastIndexOf('.'));
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
private static String contentDisposition(String filename) {
|
||||
var encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8)
|
||||
.replace("+", "%20");
|
||||
return "attachment; filename*=UTF-8''" + encoded;
|
||||
}
|
||||
|
||||
public static Mono<ServerResponse> errorPage(HttpStatus status, String message) {
|
||||
return ServerResponse.status(status)
|
||||
.contentType(HTML_UTF8)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.bodyValue(DownloadPageRenderer.renderErrorPage(status.value(), message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* A random secret generated at plugin startup. {@code FileStreamer} attaches it as the
|
||||
* {@value #HEADER} header on its loopback requests so that
|
||||
* {@code UploadProtectFilter} lets them through, while external requests to the same
|
||||
* {@code /upload/**} permalinks are rejected.
|
||||
*/
|
||||
@Component
|
||||
public class InternalRequestSecret {
|
||||
|
||||
public static final String HEADER = "X-Sharelink-Internal";
|
||||
|
||||
private final String value = UUID.randomUUID().toString();
|
||||
|
||||
public String value() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public boolean matches(String headerValue) {
|
||||
return headerValue != null
|
||||
&& MessageDigest.isEqual(value.getBytes(StandardCharsets.UTF_8),
|
||||
headerValue.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package run.halo.sharelink.emailcode;
|
||||
|
||||
import java.net.URI;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
|
||||
public interface EmailCodeManager {
|
||||
|
||||
/**
|
||||
* Sends a verification code to the given email. Fails with
|
||||
* {@link SendRateLimitedException} when any rate limit is exceeded.
|
||||
*
|
||||
* @param email normalized email (lowercased and trimmed)
|
||||
* @param clientIp client ip for auxiliary rate limiting, may be null
|
||||
* @param config email verify config
|
||||
*/
|
||||
Mono<Void> sendCode(String email, String clientIp,
|
||||
SharelinkSettingConfigGetter.EmailVerifyConfig config);
|
||||
|
||||
/**
|
||||
* Verifies the code for the given email. Each invocation accumulates attempts and the
|
||||
* code is invalidated once the max attempts is exceeded or the verification succeeds.
|
||||
*/
|
||||
Mono<Boolean> verify(String email, String code,
|
||||
SharelinkSettingConfigGetter.EmailVerifyConfig config);
|
||||
|
||||
Mono<Void> invalidate(String email);
|
||||
|
||||
class SendRateLimitedException extends ResponseStatusException {
|
||||
public static final String TYPE =
|
||||
"https://www.halo.run/probs/email-code-send-rate-limited";
|
||||
|
||||
public SendRateLimitedException(Type type) {
|
||||
super(HttpStatus.TOO_MANY_REQUESTS, type.detail);
|
||||
setType(URI.create(TYPE));
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
RESEND_TOO_FREQUENT("验证码发送过于频繁,请稍后再试"),
|
||||
DAILY_LIMIT_EXCEEDED("今日验证码发送次数已达上限,请明天再试"),
|
||||
IP_HOURLY_LIMIT_EXCEEDED("当前网络环境验证码发送过于频繁,请稍后再试");
|
||||
|
||||
private final String detail;
|
||||
|
||||
Type(String detail) {
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package run.halo.sharelink.emailcode;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
|
||||
/**
|
||||
* In-memory verification code store: one active code per email, one-time use, resend /
|
||||
* daily / ip-hourly rate limits, and a constant-time comparison.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EmailCodeManagerImpl implements EmailCodeManager {
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final int CODE_BOUND = 1_000_000;
|
||||
/**
|
||||
* The real expiration is checked against the config on verification, this TTL is only
|
||||
* a fallback to evict stale cache entries.
|
||||
*/
|
||||
private static final Duration CODE_CACHE_TTL = Duration.ofHours(1);
|
||||
private static final Duration RESEND_CACHE_TTL = Duration.ofMinutes(10);
|
||||
private static final Duration DAILY_LIMIT_TTL = Duration.ofDays(1);
|
||||
private static final Duration IP_HOURLY_LIMIT_TTL = Duration.ofHours(1);
|
||||
|
||||
private final Cache<String, EmailCodeEntry> codeCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(CODE_CACHE_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
private final Cache<String, Instant> resendCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(RESEND_CACHE_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
private final Cache<String, AtomicInteger> dailySendCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(DAILY_LIMIT_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
private final Cache<String, AtomicInteger> ipHourlySendCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(IP_HOURLY_LIMIT_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
private final EmailCodeNotificationSender notificationSender;
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendCode(String email, String clientIp,
|
||||
SharelinkSettingConfigGetter.EmailVerifyConfig config) {
|
||||
return Mono.defer(() -> {
|
||||
checkRateLimits(email, clientIp, config);
|
||||
var code = generateCode();
|
||||
return notificationSender.sendVerificationCode(email, email, code,
|
||||
config.getCodeExpireMinutes())
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
// Record the code and rate limits only after the notification is emitted,
|
||||
// so failures neither count against the quota nor overwrite the old code.
|
||||
.doOnSuccess(sent -> recordSent(email, clientIp, code));
|
||||
});
|
||||
}
|
||||
|
||||
private void checkRateLimits(String email, String clientIp,
|
||||
SharelinkSettingConfigGetter.EmailVerifyConfig config) {
|
||||
var lastSentAt = resendCache.getIfPresent(email);
|
||||
if (lastSentAt != null
|
||||
&& Instant.now().isBefore(lastSentAt.plusSeconds(config.getResendIntervalSeconds()))) {
|
||||
throw new SendRateLimitedException(SendRateLimitedException.Type.RESEND_TOO_FREQUENT);
|
||||
}
|
||||
var dailyCounter = dailySendCache.getIfPresent(email);
|
||||
if (dailyCounter != null && dailyCounter.get() >= config.getDailySendLimitPerEmail()) {
|
||||
throw new SendRateLimitedException(
|
||||
SendRateLimitedException.Type.DAILY_LIMIT_EXCEEDED);
|
||||
}
|
||||
if (StringUtils.isNotBlank(clientIp)) {
|
||||
var ipCounter = ipHourlySendCache.getIfPresent(clientIp);
|
||||
if (ipCounter != null && ipCounter.get() >= config.getIpHourlySendLimit()) {
|
||||
throw new SendRateLimitedException(
|
||||
SendRateLimitedException.Type.IP_HOURLY_LIMIT_EXCEEDED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void recordSent(String email, String clientIp, String code) {
|
||||
// Resending overwrites the previous code.
|
||||
codeCache.put(email, new EmailCodeEntry(code, Instant.now(), new AtomicInteger()));
|
||||
resendCache.put(email, Instant.now());
|
||||
incrementCounter(dailySendCache, email);
|
||||
if (StringUtils.isNotBlank(clientIp)) {
|
||||
incrementCounter(ipHourlySendCache, clientIp);
|
||||
}
|
||||
}
|
||||
|
||||
private static void incrementCounter(Cache<String, AtomicInteger> cache, String key) {
|
||||
var counter = cache.getIfPresent(key);
|
||||
if (counter == null) {
|
||||
counter = new AtomicInteger();
|
||||
cache.put(key, counter);
|
||||
}
|
||||
counter.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> verify(String email, String code,
|
||||
SharelinkSettingConfigGetter.EmailVerifyConfig config) {
|
||||
return Mono.fromSupplier(() -> {
|
||||
var entry = codeCache.getIfPresent(email);
|
||||
if (entry == null) {
|
||||
return false;
|
||||
}
|
||||
if (entry.attempts().incrementAndGet() > config.getMaxVerifyAttempts()) {
|
||||
codeCache.invalidate(email);
|
||||
return false;
|
||||
}
|
||||
if (Instant.now()
|
||||
.isAfter(entry.createdAt().plusSeconds(config.getCodeExpireMinutes() * 60L))) {
|
||||
codeCache.invalidate(email);
|
||||
return false;
|
||||
}
|
||||
var matched = MessageDigest.isEqual(entry.code().getBytes(),
|
||||
code.getBytes());
|
||||
if (matched) {
|
||||
// One-time use.
|
||||
codeCache.invalidate(email);
|
||||
}
|
||||
return matched;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> invalidate(String email) {
|
||||
codeCache.invalidate(email);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
private static String generateCode() {
|
||||
return String.format(Locale.ROOT, "%06d", RANDOM.nextInt(CODE_BOUND));
|
||||
}
|
||||
|
||||
record EmailCodeEntry(String code, Instant createdAt, AtomicInteger attempts) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package run.halo.sharelink.emailcode;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.notification.Reason;
|
||||
import run.halo.app.core.extension.notification.Subscription;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.notification.NotificationCenter;
|
||||
import run.halo.app.notification.NotificationReasonEmitter;
|
||||
import run.halo.app.notification.UserIdentity;
|
||||
|
||||
/**
|
||||
* Sends email verification codes through the Halo notification chain. The email address
|
||||
* is resolved by the core subscriber email resolver from the anonymous user identity
|
||||
* ({@code anonymousUser#<email>}), so an email notifier (SMTP) must be configured in the
|
||||
* console first.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EmailCodeNotificationSender {
|
||||
public static final String REASON_TYPE = "sharelink-download-verification";
|
||||
|
||||
private final NotificationReasonEmitter reasonEmitter;
|
||||
private final NotificationCenter notificationCenter;
|
||||
|
||||
public Mono<Void> sendVerificationCode(String email, String displayName, String code,
|
||||
long expirationMinutes) {
|
||||
var identity = UserIdentity.anonymousWithEmail(email);
|
||||
var interestReason = createInterestReason(identity);
|
||||
var subscribe = notificationCenter.subscribe(
|
||||
createSubscriber(identity), interestReason);
|
||||
var emitReason = reasonEmitter.emit(REASON_TYPE, builder -> builder
|
||||
.attribute("code", code)
|
||||
.attribute("expirationAtMinutes", String.valueOf(expirationMinutes))
|
||||
.attribute("username", displayName)
|
||||
.author(identity)
|
||||
.subject(Reason.Subject.builder()
|
||||
.apiVersion(interestReason.getSubject().getApiVersion())
|
||||
.kind(User.KIND)
|
||||
.name(identity.name())
|
||||
.title("资源下载邮箱验证:" + email)
|
||||
.build()));
|
||||
return Mono.when(subscribe).then(emitReason);
|
||||
}
|
||||
|
||||
public Mono<Void> unsubscribe(String email) {
|
||||
var identity = UserIdentity.anonymousWithEmail(email);
|
||||
return notificationCenter.unsubscribe(createSubscriber(identity),
|
||||
createInterestReason(identity));
|
||||
}
|
||||
|
||||
private static Subscription.Subscriber createSubscriber(UserIdentity identity) {
|
||||
var subscriber = new Subscription.Subscriber();
|
||||
subscriber.setName(identity.name());
|
||||
return subscriber;
|
||||
}
|
||||
|
||||
private static Subscription.InterestReason createInterestReason(UserIdentity identity) {
|
||||
var interestReason = new Subscription.InterestReason();
|
||||
interestReason.setReasonType(REASON_TYPE);
|
||||
interestReason.setSubject(Subscription.ReasonSubject.builder()
|
||||
.apiVersion(new GroupVersion(User.GROUP, User.KIND).toString())
|
||||
.kind(User.KIND)
|
||||
.name(identity.name())
|
||||
.build());
|
||||
return interestReason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package run.halo.sharelink.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* A single download event. The metadata name is a random UUID; {@code spec.resourceSlug}
|
||||
* is indexed for per-resource queries.
|
||||
*/
|
||||
@GVK(group = "sharelink.halo.run", version = "v1alpha1", kind = "DownloadRecord",
|
||||
plural = "downloadrecords", singular = "downloadrecord")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class DownloadRecord extends AbstractExtension {
|
||||
|
||||
private Spec spec;
|
||||
|
||||
@Data
|
||||
public static class Spec {
|
||||
|
||||
private String resourceSlug;
|
||||
|
||||
/**
|
||||
* Verified email of the downloader; blank for resources without email
|
||||
* verification.
|
||||
*/
|
||||
private String email;
|
||||
|
||||
private String ip;
|
||||
|
||||
private String userAgent;
|
||||
|
||||
private Instant downloadedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package run.halo.sharelink.model;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* A downloadable resource registered from an attachment. The {@code spec.slug} is the
|
||||
* public URL identifier ({@code /download/{slug}}) and carries a unique index.
|
||||
*/
|
||||
@GVK(group = "sharelink.halo.run", version = "v1alpha1", kind = "DownloadResource",
|
||||
plural = "downloadresources", singular = "downloadresource")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class DownloadResource extends AbstractExtension {
|
||||
|
||||
private Spec spec;
|
||||
|
||||
private Status status;
|
||||
|
||||
@Data
|
||||
public static class Spec {
|
||||
|
||||
/**
|
||||
* URL identifier of the resource, unique across all resources. Matches
|
||||
* {@code [a-z0-9][a-z0-9-]{0,63}}.
|
||||
*/
|
||||
private String slug;
|
||||
|
||||
private String displayName;
|
||||
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* Metadata name of the associated {@code Attachment} extension.
|
||||
*/
|
||||
private String attachmentName;
|
||||
|
||||
private boolean requireEmailVerify;
|
||||
|
||||
private boolean enabled;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Status {
|
||||
|
||||
/**
|
||||
* Redundant download counter; the authoritative source is DownloadRecord.
|
||||
*/
|
||||
private long downloadCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package run.halo.sharelink.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* Registry entry for an email address that has passed download email verification. The
|
||||
* metadata name is the SHA-256 hex of the normalized email, which keeps it unique and
|
||||
* stable across verifications.
|
||||
*/
|
||||
@GVK(group = "sharelink.halo.run", version = "v1alpha1", kind = "VerifiedDownloader",
|
||||
plural = "verifieddownloaders", singular = "verifieddownloader")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class VerifiedDownloader extends AbstractExtension {
|
||||
|
||||
private Spec spec;
|
||||
|
||||
@Data
|
||||
public static class Spec {
|
||||
|
||||
/**
|
||||
* The normalized (trimmed, lower-cased) email address.
|
||||
*/
|
||||
private String email;
|
||||
|
||||
private Instant firstVerifiedAt;
|
||||
|
||||
private Instant lastVerifiedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package run.halo.sharelink.protect;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Set;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.security.AdditionalWebFilter;
|
||||
import run.halo.sharelink.download.InternalRequestSecret;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
|
||||
/**
|
||||
* Blocks direct access to {@code /upload/**} permalinks of attachments that are
|
||||
* registered as (enabled) download resources, so visitors must go through the download
|
||||
* page. Loopback requests from {@code FileStreamer} carrying the internal secret header
|
||||
* are always allowed. The protected permalink set is cached for 60 seconds and can be
|
||||
* invalidated explicitly after console mutations.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class UploadProtectFilter implements AdditionalWebFilter {
|
||||
|
||||
private static final String UPLOAD_PREFIX = "/upload/";
|
||||
private static final String CACHE_KEY = "protected-permalinks";
|
||||
private static final Duration CACHE_TTL = Duration.ofSeconds(60);
|
||||
private static final int PERMALINK_LOAD_CONCURRENCY = 8;
|
||||
|
||||
private static final byte[] NOT_FOUND_BODY = ("{\"type\":\"about:blank\","
|
||||
+ "\"title\":\"Not Found\",\"status\":404,\"detail\":\"资源不存在\"}")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final InternalRequestSecret internalRequestSecret;
|
||||
|
||||
private final Cache<String, Mono<Set<String>>> permalinkCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(CACHE_TTL)
|
||||
.maximumSize(1)
|
||||
.build();
|
||||
|
||||
/**
|
||||
* Drops the cached permalink set; called by the console endpoint after resource
|
||||
* create/update/delete.
|
||||
*/
|
||||
public void invalidate() {
|
||||
permalinkCache.invalidateAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Mono<Void> filter(@NonNull ServerWebExchange exchange,
|
||||
@NonNull WebFilterChain chain) {
|
||||
var request = exchange.getRequest();
|
||||
var method = request.getMethod();
|
||||
if (method != HttpMethod.GET && method != HttpMethod.HEAD) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
var rawPath = request.getURI().getRawPath();
|
||||
if (rawPath == null || !rawPath.startsWith(UPLOAD_PREFIX)) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
if (internalRequestSecret.matches(
|
||||
request.getHeaders().getFirst(InternalRequestSecret.HEADER))) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
var decodedPath = request.getURI().getPath();
|
||||
return protectedPermalinks()
|
||||
.flatMap(permalinks -> {
|
||||
if (permalinks.contains(rawPath) || permalinks.contains(decodedPath)) {
|
||||
return notFound(exchange);
|
||||
}
|
||||
return chain.filter(exchange);
|
||||
})
|
||||
// Fail open: a lookup failure must not break unrelated /upload/ requests.
|
||||
.onErrorResume(e -> {
|
||||
log.warn("查询受保护附件列表失败,放行本次请求: {}", rawPath, e);
|
||||
return chain.filter(exchange);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> notFound(ServerWebExchange exchange) {
|
||||
var response = exchange.getResponse();
|
||||
response.setStatusCode(HttpStatus.NOT_FOUND);
|
||||
response.getHeaders().setContentType(MediaType.APPLICATION_PROBLEM_JSON);
|
||||
return response.writeWith(
|
||||
Mono.just(response.bufferFactory().wrap(NOT_FOUND_BODY)));
|
||||
}
|
||||
|
||||
private Mono<Set<String>> protectedPermalinks() {
|
||||
var cached = permalinkCache.getIfPresent(CACHE_KEY);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
var loading = client.listAll(DownloadResource.class, new ListOptions(),
|
||||
Sort.unsorted())
|
||||
.filter(resource -> resource.getSpec() != null
|
||||
&& resource.getSpec().isEnabled()
|
||||
&& StringUtils.isNotBlank(resource.getSpec().getAttachmentName()))
|
||||
.flatMap(resource -> client.fetch(Attachment.class,
|
||||
resource.getSpec().getAttachmentName())
|
||||
.flatMap(attachment -> Mono.justOrEmpty(
|
||||
attachment.getStatus() == null
|
||||
? null : attachment.getStatus().getPermalink()))
|
||||
.onErrorResume(e -> Mono.empty()),
|
||||
PERMALINK_LOAD_CONCURRENCY)
|
||||
.filter(permalink -> permalink.startsWith(UPLOAD_PREFIX))
|
||||
.collect(Collectors.toSet())
|
||||
.map(Collections::unmodifiableSet)
|
||||
.cache()
|
||||
// Never keep a failed load in the cache.
|
||||
.doOnError(e -> permalinkCache.invalidate(CACHE_KEY));
|
||||
permalinkCache.put(CACHE_KEY, loading);
|
||||
return loading;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return SecurityWebFiltersOrder.AUTHORIZATION.getOrder() + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package run.halo.sharelink.reference;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.core.extension.content.Snapshot;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
|
||||
/**
|
||||
* Scans published posts for occurrences of {@code /download/{slug}} and produces a
|
||||
* resource-slug → referencing-posts map. The result is cached for 5 minutes; a failed
|
||||
* post never breaks the whole scan.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PostReferenceService {
|
||||
|
||||
private static final String CACHE_KEY = "references";
|
||||
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
|
||||
private static final int SCAN_CONCURRENCY = 8;
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
private final Cache<String, Mono<Map<String, List<PostRef>>>> referenceCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(CACHE_TTL)
|
||||
.maximumSize(1)
|
||||
.build();
|
||||
|
||||
public Mono<Map<String, List<PostRef>>> references() {
|
||||
var cached = referenceCache.getIfPresent(CACHE_KEY);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
var loading = scan()
|
||||
.cache()
|
||||
// Never keep a failed scan in the cache.
|
||||
.doOnError(e -> referenceCache.invalidate(CACHE_KEY));
|
||||
referenceCache.put(CACHE_KEY, loading);
|
||||
return loading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a rescan, discarding the cached result.
|
||||
*/
|
||||
public Mono<Map<String, List<PostRef>>> refresh() {
|
||||
referenceCache.invalidateAll();
|
||||
return references();
|
||||
}
|
||||
|
||||
private Mono<Map<String, List<PostRef>>> scan() {
|
||||
return client.listAll(DownloadResource.class, new ListOptions(), Sort.unsorted())
|
||||
.map(resource -> resource.getSpec() == null ? null : resource.getSpec().getSlug())
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.collectList()
|
||||
.flatMap(slugs -> {
|
||||
if (slugs.isEmpty()) {
|
||||
return Mono.just(Map.<String, List<PostRef>>of());
|
||||
}
|
||||
return client.listAll(Post.class, new ListOptions(), Sort.unsorted())
|
||||
.filter(post -> !post.isDeleted()
|
||||
&& post.getSpec() != null
|
||||
&& StringUtils.isNotBlank(post.getSpec().getReleaseSnapshot()))
|
||||
.flatMap(post -> referencesOfPost(post, slugs)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("扫描文章引用失败,跳过: {}",
|
||||
post.getMetadata().getName(), e);
|
||||
return Flux.empty();
|
||||
}),
|
||||
SCAN_CONCURRENCY)
|
||||
.collectList()
|
||||
.map(PostReferenceService::groupBySlug);
|
||||
});
|
||||
}
|
||||
|
||||
private Flux<Map.Entry<String, PostRef>> referencesOfPost(Post post, List<String> slugs) {
|
||||
return client.fetch(Snapshot.class, post.getSpec().getReleaseSnapshot())
|
||||
.flatMapMany(snapshot -> {
|
||||
var snapshotSpec = snapshot.getSpec();
|
||||
var content = snapshotSpec == null ? null
|
||||
: StringUtils.firstNonBlank(snapshotSpec.getRawPatch(),
|
||||
snapshotSpec.getContentPatch());
|
||||
if (content == null || !content.contains("/download/")) {
|
||||
return Flux.empty();
|
||||
}
|
||||
var ref = PostRef.from(post);
|
||||
return Flux.fromIterable(slugs)
|
||||
.filter(slug -> content.contains("/download/" + slug))
|
||||
.map(slug -> Map.entry(slug, ref));
|
||||
});
|
||||
}
|
||||
|
||||
private static Map<String, List<PostRef>> groupBySlug(
|
||||
List<Map.Entry<String, PostRef>> entries) {
|
||||
Map<String, List<PostRef>> result = new LinkedHashMap<>();
|
||||
for (var entry : entries) {
|
||||
result.computeIfAbsent(entry.getKey(), key -> new ArrayList<>())
|
||||
.add(entry.getValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public record PostRef(String postName, String title, String permalink,
|
||||
String editorUrl) {
|
||||
|
||||
static PostRef from(Post post) {
|
||||
var name = post.getMetadata().getName();
|
||||
var title = post.getSpec() == null || StringUtils.isBlank(post.getSpec().getTitle())
|
||||
? name : post.getSpec().getTitle();
|
||||
var permalink = post.getStatus() == null ? null : post.getStatus().getPermalink();
|
||||
return new PostRef(name, title, permalink,
|
||||
"/console/posts/editor?name=" + name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package run.halo.sharelink.util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
/**
|
||||
* Email normalization and hashing helpers shared by the verification flow.
|
||||
*/
|
||||
public final class EmailUtils {
|
||||
|
||||
private static final Pattern EMAIL_PATTERN =
|
||||
Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$");
|
||||
|
||||
private EmailUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an email address (trimmed, lower-cased).
|
||||
*
|
||||
* @return the normalized email, or {@code null} when blank
|
||||
*/
|
||||
public static String normalizeEmail(String email) {
|
||||
if (email == null) {
|
||||
return null;
|
||||
}
|
||||
var normalized = email.trim().toLowerCase(Locale.ROOT);
|
||||
return StringUtils.isBlank(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
public static boolean isValidEmail(String email) {
|
||||
return email != null && EMAIL_PATTERN.matcher(email).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 hex of the given value; used as the deterministic metadata name of
|
||||
* {@code VerifiedDownloader} (and the comment plugin's {@code VerifiedCommenter}).
|
||||
*/
|
||||
public static String sha256Hex(String value) {
|
||||
try {
|
||||
var digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of()
|
||||
.formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 algorithm is unavailable", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package run.halo.sharelink.util;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
|
||||
/**
|
||||
* Resolves the client IP address of a request: the first {@code X-Forwarded-For} segment
|
||||
* wins, falling back to the remote address of the connection.
|
||||
*/
|
||||
public final class IpUtils {
|
||||
|
||||
private static final String X_FORWARDED_FOR = "X-Forwarded-For";
|
||||
|
||||
private IpUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the client IP address.
|
||||
*
|
||||
* @param request the current request
|
||||
* @return the client IP, or {@code null} when neither a forwarded header nor a remote
|
||||
* address is available
|
||||
*/
|
||||
public static String clientIp(ServerHttpRequest request) {
|
||||
var forwardedFor = request.getHeaders().getFirst(X_FORWARDED_FOR);
|
||||
if (StringUtils.isNotBlank(forwardedFor)) {
|
||||
return forwardedFor.split(",")[0].trim();
|
||||
}
|
||||
var remoteAddress = request.getRemoteAddress();
|
||||
if (remoteAddress == null) {
|
||||
return null;
|
||||
}
|
||||
var address = remoteAddress.getAddress();
|
||||
return address != null ? address.getHostAddress() : remoteAddress.getHostString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package run.halo.sharelink.verify;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
import run.halo.sharelink.emailcode.EmailCodeManager;
|
||||
import run.halo.sharelink.util.EmailUtils;
|
||||
import run.halo.sharelink.util.IpUtils;
|
||||
|
||||
/**
|
||||
* Public endpoints for the download email verification flow, mounted under
|
||||
* {@code /apis/api.sharelink.halo.run/v1alpha1} and opened to anonymous visitors via the
|
||||
* aggregate-to-anonymous role template.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EmailVerifyEndpoint implements CustomEndpoint {
|
||||
|
||||
private final EmailCodeManager emailCodeManager;
|
||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||
private final VerifiedEmailService verifiedEmailService;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
return RouterFunctions.route()
|
||||
.POST("email-verify/-/send", this::sendCode)
|
||||
.POST("email-verify/-/check", this::checkVerified)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> sendCode(ServerRequest request) {
|
||||
return settingConfigGetter.getEmailVerifyConfig()
|
||||
.flatMap(config -> request.bodyToMono(SendCodeRequest.class)
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||
.flatMap(body -> {
|
||||
var email = EmailUtils.normalizeEmail(body.email());
|
||||
if (email == null || !EmailUtils.isValidEmail(email)) {
|
||||
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
||||
}
|
||||
var clientIp = IpUtils.clientIp(request.exchange().getRequest());
|
||||
return emailCodeManager.sendCode(email, clientIp, config)
|
||||
.then(ServerResponse.ok().bodyValue(new SendCodeResponse(true,
|
||||
config.getCodeExpireMinutes(),
|
||||
config.getResendIntervalSeconds())));
|
||||
}));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> checkVerified(ServerRequest request) {
|
||||
return request.bodyToMono(CheckRequest.class)
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||
.flatMap(body -> {
|
||||
var email = EmailUtils.normalizeEmail(body.email());
|
||||
if (email == null || !EmailUtils.isValidEmail(email)) {
|
||||
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
||||
}
|
||||
return verifiedEmailService.isVerified(email)
|
||||
// 登记查询失败按未验证处理,前端会回退到发送验证码流程。
|
||||
.onErrorResume(e -> {
|
||||
log.warn("查询邮箱验证状态失败,按未验证处理: {}", email, e);
|
||||
return Mono.just(false);
|
||||
})
|
||||
.flatMap(verified -> ServerResponse.ok()
|
||||
.bodyValue(new CheckResponse(verified)));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return GroupVersion.parseAPIVersion("api.sharelink.halo.run/v1alpha1");
|
||||
}
|
||||
|
||||
public record SendCodeRequest(String email) {
|
||||
}
|
||||
|
||||
public record SendCodeResponse(boolean sent, long expireMinutes, long resendAfterSeconds) {
|
||||
}
|
||||
|
||||
public record CheckRequest(String email) {
|
||||
}
|
||||
|
||||
public record CheckResponse(boolean verified) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package run.halo.sharelink.verify;
|
||||
|
||||
import java.time.Instant;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.extension.GroupVersionKind;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
import run.halo.sharelink.model.VerifiedDownloader;
|
||||
import run.halo.sharelink.util.EmailUtils;
|
||||
|
||||
/**
|
||||
* Unified "is this email verified" check for the download flow. Looks up this plugin's
|
||||
* own {@link VerifiedDownloader} registry first; when {@code trustCommentVerified} is
|
||||
* enabled it also honors the comment plugin's {@code VerifiedCommenter} registry
|
||||
* (resolved at runtime by GVK + SHA-256 name, degrading gracefully when the comment
|
||||
* plugin is absent).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class VerifiedEmailService {
|
||||
|
||||
private static final GroupVersionKind COMMENT_VERIFIED_COMMENTER_GVK =
|
||||
GroupVersionKind.fromAPIVersionAndKind("commentwidget.halo.run/v1alpha1",
|
||||
"VerifiedCommenter");
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||
|
||||
public Mono<Boolean> isVerified(String email) {
|
||||
var normalizedEmail = EmailUtils.normalizeEmail(email);
|
||||
if (normalizedEmail == null) {
|
||||
return Mono.just(false);
|
||||
}
|
||||
// Look up by the deterministic metadata name (SHA-256 of the normalized email).
|
||||
return client.fetch(VerifiedDownloader.class, EmailUtils.sha256Hex(normalizedEmail))
|
||||
.map(downloader -> downloader.getSpec() != null
|
||||
&& normalizedEmail.equals(downloader.getSpec().getEmail()))
|
||||
.defaultIfEmpty(false)
|
||||
.flatMap(verified -> verified ? Mono.just(true)
|
||||
: isCommentVerified(normalizedEmail));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the comment plugin's verified registry. Any failure (plugin absent, scheme
|
||||
* unregistered, store error) degrades to "not verified" without affecting the main
|
||||
* flow.
|
||||
*/
|
||||
private Mono<Boolean> isCommentVerified(String normalizedEmail) {
|
||||
return settingConfigGetter.getEmailVerifyConfig()
|
||||
.flatMap(config -> {
|
||||
if (!config.isTrustCommentVerified()) {
|
||||
return Mono.just(false);
|
||||
}
|
||||
return client.fetch(COMMENT_VERIFIED_COMMENTER_GVK,
|
||||
EmailUtils.sha256Hex(normalizedEmail))
|
||||
.map(unstructured -> true)
|
||||
.defaultIfEmpty(false)
|
||||
.onErrorResume(e -> {
|
||||
log.debug("查询评论插件已验证邮箱失败,按未验证处理: {}", normalizedEmail, e);
|
||||
return Mono.just(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers (or refreshes) the email in this plugin's own verified registry.
|
||||
*/
|
||||
public Mono<Void> recordVerified(String email, String ip) {
|
||||
var normalizedEmail = EmailUtils.normalizeEmail(email);
|
||||
if (normalizedEmail == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var name = EmailUtils.sha256Hex(normalizedEmail);
|
||||
var now = Instant.now();
|
||||
// Retried once to tolerate a concurrent registration of the same email.
|
||||
return Mono.defer(() -> upsertVerified(name, normalizedEmail, now))
|
||||
.retry(1)
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<VerifiedDownloader> upsertVerified(String name, String email, Instant now) {
|
||||
return client.fetch(VerifiedDownloader.class, name)
|
||||
.flatMap(existing -> {
|
||||
existing.getSpec().setLastVerifiedAt(now);
|
||||
return client.update(existing);
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
var downloader = new VerifiedDownloader();
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(name);
|
||||
downloader.setMetadata(metadata);
|
||||
var spec = new VerifiedDownloader.Spec();
|
||||
spec.setEmail(email);
|
||||
spec.setFirstVerifiedAt(now);
|
||||
spec.setLastVerifiedAt(now);
|
||||
downloader.setSpec(spec);
|
||||
return client.create(downloader);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
apiVersion: notification.halo.run/v1alpha1
|
||||
kind: ReasonType
|
||||
metadata:
|
||||
name: sharelink-download-verification
|
||||
spec:
|
||||
displayName: "资源下载邮箱验证"
|
||||
description: "访客下载需要邮箱验证的资源时,向其邮箱发送数字验证码。"
|
||||
properties:
|
||||
- name: username
|
||||
type: string
|
||||
- name: code
|
||||
type: string
|
||||
- name: expirationAtMinutes
|
||||
type: string
|
||||
---
|
||||
apiVersion: notification.halo.run/v1alpha1
|
||||
kind: NotificationTemplate
|
||||
metadata:
|
||||
name: template-sharelink-download-verification
|
||||
spec:
|
||||
reasonSelector:
|
||||
reasonType: sharelink-download-verification
|
||||
language: default
|
||||
template:
|
||||
title: "资源下载验证码-[(${site.title})]"
|
||||
rawBody: |
|
||||
【[(${site.title})]】你的资源下载验证码是:[(${code})],请在 [(${expirationAtMinutes})] 分钟内完成验证。若不是你本人操作,请忽略。
|
||||
htmlBody: |
|
||||
<div class="notification-content">
|
||||
<div class="head">
|
||||
<p class="honorific" th:text="|${username} 你好:|"></p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p>你正在 [(${site.title})] 下载资源,使用下面的验证码验证邮箱:</p>
|
||||
<div class="verify-code" style="font-size:24px;line-height:24px;color:#333;">
|
||||
<b th:text="${code}"></b>
|
||||
</div>
|
||||
<p th:text="|验证码 ${expirationAtMinutes} 分钟内有效。若不是你本人操作,请忽略。|"></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: sharelink-public-apis
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
halo.run/hidden: "true"
|
||||
rbac.authorization.halo.run/aggregate-to-anonymous: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "资源下载管理"
|
||||
rbac.authorization.halo.run/display-name: "Sharelink Public APIs"
|
||||
rules:
|
||||
- apiGroups: [ "api.sharelink.halo.run" ]
|
||||
resources: [ "email-verify/send" ]
|
||||
verbs: [ "create" ]
|
||||
- apiGroups: [ "api.sharelink.halo.run" ]
|
||||
resources: [ "email-verify/check" ]
|
||||
verbs: [ "create" ]
|
||||
- apiGroups: [ "api.sharelink.halo.run" ]
|
||||
resources: [ "downloads/token" ]
|
||||
verbs: [ "create" ]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: role-template-sharelink-view
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "资源下载管理"
|
||||
rbac.authorization.halo.run/display-name: "下载资源查看"
|
||||
rbac.authorization.halo.run/ui-permissions: '["plugin:sharelink:view"]'
|
||||
rules:
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "download-resources", "download-records", "references" ]
|
||||
verbs: [ "get", "list" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "download-records/export" ]
|
||||
resourceNames: [ "-" ]
|
||||
verbs: [ "get" ]
|
||||
# 引用扫描需要读取已发布文章及其快照内容
|
||||
- apiGroups: [ "content.halo.run" ]
|
||||
resources: [ "posts", "snapshots" ]
|
||||
verbs: [ "get", "list" ]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: role-template-sharelink-manage
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "资源下载管理"
|
||||
rbac.authorization.halo.run/display-name: "下载资源管理"
|
||||
rbac.authorization.halo.run/ui-permissions: '["plugin:sharelink:manage"]'
|
||||
rbac.authorization.halo.run/dependencies: '["role-template-sharelink-view"]'
|
||||
rules:
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "download-resources" ]
|
||||
verbs: [ "create", "update", "delete" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "download-records" ]
|
||||
verbs: [ "delete" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "references/refresh" ]
|
||||
resourceNames: [ "-" ]
|
||||
verbs: [ "create" ]
|
||||
@@ -0,0 +1,75 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: sharelink-settings
|
||||
spec:
|
||||
forms:
|
||||
- group: basic
|
||||
label: 基本设置
|
||||
formSchema:
|
||||
- $formkit: number
|
||||
name: tokenTtlSeconds
|
||||
key: tokenTtlSeconds
|
||||
label: 下载令牌有效期(秒)
|
||||
help: 访客在下载页获取下载令牌后,需在该时间内开始下载。
|
||||
min: 10
|
||||
max: 600
|
||||
value: 60
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: dedupeWindowMinutes
|
||||
key: dedupeWindowMinutes
|
||||
label: 免验证资源下载去重窗口(分钟)
|
||||
help: 不需要邮箱验证的资源,同一 IP 在该时间窗口内重复下载只计 1 次。
|
||||
min: 0
|
||||
max: 1440
|
||||
value: 10
|
||||
validation: required
|
||||
- group: emailVerify
|
||||
label: 邮箱验证
|
||||
formSchema:
|
||||
- $formkit: number
|
||||
name: codeExpireMinutes
|
||||
key: codeExpireMinutes
|
||||
label: 验证码有效期(分钟)
|
||||
min: 1
|
||||
max: 30
|
||||
value: 10
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: resendIntervalSeconds
|
||||
key: resendIntervalSeconds
|
||||
label: 重发间隔(秒)
|
||||
min: 30
|
||||
value: 60
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: dailySendLimitPerEmail
|
||||
key: dailySendLimitPerEmail
|
||||
label: 同一邮箱每日发送上限
|
||||
min: 1
|
||||
max: 50
|
||||
value: 5
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: maxVerifyAttempts
|
||||
key: maxVerifyAttempts
|
||||
label: 验证码最大错误尝试次数
|
||||
min: 1
|
||||
max: 10
|
||||
value: 5
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: ipHourlySendLimit
|
||||
key: ipHourlySendLimit
|
||||
label: 同一 IP 每小时发送上限
|
||||
min: 1
|
||||
max: 200
|
||||
value: 20
|
||||
validation: required
|
||||
- $formkit: checkbox
|
||||
name: trustCommentVerified
|
||||
key: trustCommentVerified
|
||||
label: 信任评论插件已验证的邮箱
|
||||
help: 开启后,已在评论组件中通过邮箱验证的访客,下载需要验证的资源时无需再次验证(需安装并启用评论组件)。
|
||||
value: true
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><rect id="r4" width="512" height="512" x="0" y="0" rx="0" fill="url(#r5)" stroke="#FFFFFF" stroke-width="0" stroke-opacity="100%" paint-order="stroke"></rect><clipPath id="clip"><use xlink:href="#r4"></use></clipPath><defs><linearGradient id="r5" gradientUnits="userSpaceOnUse" gradientTransform="rotate(-135)" style="transform-origin: center center;"><stop stop-color="#0D6FD8"></stop><stop offset="1" stop-color="#0A89FC"></stop></linearGradient></defs><svg xmlns="http://www.w3.org/2000/svg" width="352" height="352" viewBox="0 0 24 24" x="80" y="80" alignment-baseline="middle" style="color: rgb(255, 255, 255);"><path fill="currentColor" d="M13 12H16L12 16L8 12H11V8H13V12ZM15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918Z"/></svg></svg>
|
||||
|
After Width: | Height: | Size: 998 B |
@@ -0,0 +1,20 @@
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: Plugin
|
||||
metadata:
|
||||
name: sharelink
|
||||
spec:
|
||||
enabled: true
|
||||
requires: ">=2.22.0"
|
||||
author:
|
||||
name: ssy
|
||||
website: https://git.ali.songshiyu.cn/ssy
|
||||
logo: logo.svg
|
||||
homepage: https://git.ali.songshiyu.cn/ssy/sharelink
|
||||
repo: https://git.ali.songshiyu.cn/ssy/sharelink
|
||||
issues: https://git.ali.songshiyu.cn/ssy/sharelink/issues
|
||||
displayName: "资源下载管理"
|
||||
description: "管理文章中的资源下载链接:下载统计、按资源的邮箱验证、防止附件直链下载、文章引用扫描。"
|
||||
configMapName: sharelink-configmap
|
||||
settingName: sharelink-settings
|
||||
license:
|
||||
- name: "GPL-3.0"
|
||||
Reference in New Issue
Block a user