From f375f8dfdb7660135327390ef95073089702d057 Mon Sep 17 00:00:00 2001 From: guqing <1484563614@qq.com> Date: Tue, 10 Dec 2024 15:06:56 +0800 Subject: [PATCH] feat: add 1px transparent image in RSS item descriptions for telemetry content views --- .../run/halo/feed/TelemetryEventInfo.java | 45 ++++ .../java/run/halo/feed/TelemetryRecorder.java | 8 + .../java/run/halo/feed/RssCacheManager.java | 19 +- .../java/run/halo/feed/RssXmlBuilder.java | 40 +++- .../feed/telemetry/AcceptLanguageParser.java | 72 ++++++ .../halo/feed/telemetry/BrowserDetector.java | 222 ++++++++++++++++++ .../halo/feed/telemetry/IpAddressUtils.java | 71 ++++++ .../feed/telemetry/TelemetryEndpoint.java | 73 ++++++ .../telemetry/TelemetryRecorderDelegator.java | 79 +++++++ app/src/main/resources/1pixel.png | Bin 0 -> 68 bytes .../resources/extensions/ext-definition.yaml | 11 + .../telemetry/AcceptLanguageParserTest.java | 17 ++ .../feed/telemetry/BrowserDetectorTest.java | 51 ++++ 13 files changed, 696 insertions(+), 12 deletions(-) create mode 100644 api/src/main/java/run/halo/feed/TelemetryEventInfo.java create mode 100644 api/src/main/java/run/halo/feed/TelemetryRecorder.java create mode 100644 app/src/main/java/run/halo/feed/telemetry/AcceptLanguageParser.java create mode 100644 app/src/main/java/run/halo/feed/telemetry/BrowserDetector.java create mode 100644 app/src/main/java/run/halo/feed/telemetry/IpAddressUtils.java create mode 100644 app/src/main/java/run/halo/feed/telemetry/TelemetryEndpoint.java create mode 100644 app/src/main/java/run/halo/feed/telemetry/TelemetryRecorderDelegator.java create mode 100644 app/src/main/resources/1pixel.png create mode 100644 app/src/test/java/run/halo/feed/telemetry/AcceptLanguageParserTest.java create mode 100644 app/src/test/java/run/halo/feed/telemetry/BrowserDetectorTest.java diff --git a/api/src/main/java/run/halo/feed/TelemetryEventInfo.java b/api/src/main/java/run/halo/feed/TelemetryEventInfo.java new file mode 100644 index 0000000..6da15ad --- /dev/null +++ b/api/src/main/java/run/halo/feed/TelemetryEventInfo.java @@ -0,0 +1,45 @@ +package run.halo.feed; + +import java.util.Objects; +import lombok.Data; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; + +@Data +@Accessors(chain = true) +public class TelemetryEventInfo { + private String pageUrl; + private String screen; + private String language; + private String languageRegion; + private String title; + private String referrer; + private String ip; + private String userAgent; + private String browser; + private String os; + + @Getter(onMethod_ = @NonNull) + private HttpHeaders headers; + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TelemetryEventInfo that = (TelemetryEventInfo) o; + return Objects.equals(pageUrl, that.pageUrl) && Objects.equals(title, that.title) + && Objects.equals(referrer, that.referrer) && Objects.equals(ip, that.ip) + && Objects.equals(userAgent, that.userAgent); + } + + @Override + public int hashCode() { + return Objects.hash(pageUrl, title, referrer, ip, userAgent); + } +} diff --git a/api/src/main/java/run/halo/feed/TelemetryRecorder.java b/api/src/main/java/run/halo/feed/TelemetryRecorder.java new file mode 100644 index 0000000..9b385e3 --- /dev/null +++ b/api/src/main/java/run/halo/feed/TelemetryRecorder.java @@ -0,0 +1,8 @@ +package run.halo.feed; + +import org.pf4j.ExtensionPoint; + +public interface TelemetryRecorder extends ExtensionPoint { + + void record(TelemetryEventInfo eventInfo); +} diff --git a/app/src/main/java/run/halo/feed/RssCacheManager.java b/app/src/main/java/run/halo/feed/RssCacheManager.java index 0705502..7193fec 100644 --- a/app/src/main/java/run/halo/feed/RssCacheManager.java +++ b/app/src/main/java/run/halo/feed/RssCacheManager.java @@ -35,22 +35,23 @@ public Mono get(String key, Mono loader) { } private Mono generateRssXml(Mono loader) { - var builder = new RssXmlBuilder(); + var builder = new RssXmlBuilder() + .withGenerator("Halo v2.0"); + var rssMono = loader.doOnNext(builder::withRss2); - var generatorMono = getRssGenerator() - .doOnNext(builder::withGenerator); + + var generatorMono = systemInfoGetter.get() + .doOnNext(info -> builder.withExternalUrl(info.getUrl().toString()) + .withGenerator("Halo v" + info.getVersion().toStableVersion().toString()) + ); + var extractTagsMono = BasicProp.getBasicProp(settingFetcher) .doOnNext(prop -> builder.withExtractRssTags(prop.getRssExtraTags())); + return Mono.when(rssMono, generatorMono, extractTagsMono) .then(Mono.fromSupplier(builder::toXmlString)); } - private Mono getRssGenerator() { - return systemInfoGetter.get() - .map(info -> "Halo v" + info.getVersion().toStableVersion().toString()) - .defaultIfEmpty("Halo v2.0"); - } - @EventListener(PluginConfigUpdatedEvent.class) public void onPluginConfigUpdated() { cache.invalidateAll(); diff --git a/app/src/main/java/run/halo/feed/RssXmlBuilder.java b/app/src/main/java/run/halo/feed/RssXmlBuilder.java index a76bc39..01f714d 100644 --- a/app/src/main/java/run/halo/feed/RssXmlBuilder.java +++ b/app/src/main/java/run/halo/feed/RssXmlBuilder.java @@ -2,6 +2,7 @@ import com.google.common.base.Throwables; import java.io.StringReader; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; @@ -14,6 +15,9 @@ import org.dom4j.Element; import org.dom4j.io.SAXReader; import org.springframework.util.CollectionUtils; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; +import run.halo.feed.telemetry.TelemetryEndpoint; @Slf4j public class RssXmlBuilder { @@ -21,6 +25,7 @@ public class RssXmlBuilder { private String generator = "Halo v2.0"; private String extractRssTags; private Instant lastBuildDate = Instant.now(); + private String externalUrl; public RssXmlBuilder withRss2(RSS2 rss2) { this.rss2 = rss2; @@ -48,6 +53,11 @@ RssXmlBuilder withLastBuildDate(Instant lastBuildDate) { return this; } + RssXmlBuilder withExternalUrl(String externalUrl) { + this.externalUrl = externalUrl; + return this; + } + public String toXmlString() { Document document = DocumentHelper.createDocument(); @@ -127,18 +137,19 @@ private Element parseXmlString(String xml) throws DocumentException { } } - private static void createItemElementsToChannel(Element channel, List items) { + private void createItemElementsToChannel(Element channel, List items) { if (CollectionUtils.isEmpty(items)) { return; } items.forEach(item -> createItemElementToChannel(channel, item)); } - private static void createItemElementToChannel(Element channel, RSS2.Item item) { + private void createItemElementToChannel(Element channel, RSS2.Item item) { Element itemElement = channel.addElement("item"); itemElement.addElement("title").addCDATA(item.getTitle()); itemElement.addElement("link").addText(item.getLink()); - itemElement.addElement("description").addCDATA(item.getDescription()); + var description = getDescriptionWithTelemetry(item); + itemElement.addElement("description").addCDATA(description); itemElement.addElement("guid") .addAttribute("isPermaLink", "false") .addText(item.getGuid()); @@ -201,6 +212,29 @@ private static void createItemElementToChannel(Element channel, RSS2.Item item) }); } + private String getDescriptionWithTelemetry(RSS2.Item item) { + if (StringUtils.isBlank(externalUrl)) { + return item.getDescription(); + } + var uri = UriComponentsBuilder.fromUriString(item.getLink()) + .build(); + var telemetryBaseUri = externalUrl + TelemetryEndpoint.TELEMETRY_PATH; + var telemetryUri = UriComponentsBuilder.fromUriString(telemetryBaseUri) + .queryParam("title", UriUtils.encode(item.getTitle(), StandardCharsets.UTF_8)) + .queryParam("url", uri.getPath()) + .build(true) + .toUriString(); + + // Build the telemetry image HTML + var telemetryImageHtml = String.format( + "\"\"", + telemetryUri + ); + + // Append telemetry image to description + return telemetryImageHtml + item.getDescription(); + } + static List nullSafeList(List list) { return list == null ? List.of() : list; } diff --git a/app/src/main/java/run/halo/feed/telemetry/AcceptLanguageParser.java b/app/src/main/java/run/halo/feed/telemetry/AcceptLanguageParser.java new file mode 100644 index 0000000..6f01a15 --- /dev/null +++ b/app/src/main/java/run/halo/feed/telemetry/AcceptLanguageParser.java @@ -0,0 +1,72 @@ +package run.halo.feed.telemetry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import lombok.experimental.UtilityClass; +import org.springframework.lang.NonNull; + +@UtilityClass +public class AcceptLanguageParser { + + public record Language(String code, String script, String region, double quality) { + @Override + public String toString() { + return "Language{" + + "code='" + code + '\'' + + ", script='" + script + '\'' + + ", region='" + region + '\'' + + ", quality=" + quality + + '}'; + } + } + + private static final Pattern REGEX = Pattern.compile( + "((([a-zA-Z]+(-[a-zA-Z0-9]+){0,2})|\\*)(;q=[0-1](\\.[0-9]+)?)?)*"); + + @NonNull + public static List parseAcceptLanguage(String acceptLanguage) { + if (acceptLanguage == null || acceptLanguage.isEmpty()) { + return Collections.emptyList(); + } + + List languages = new ArrayList<>(); + Matcher matcher = REGEX.matcher(acceptLanguage); + + while (matcher.find()) { + String match = matcher.group(); + if (match == null || match.isEmpty()) { + continue; + } + + String[] parts = match.split(";"); + String ietfTag = parts[0]; + String[] ietfComponents = ietfTag.split("-"); + String code = ietfComponents[0]; + String script = ietfComponents.length == 3 ? ietfComponents[1] : null; + String region = ietfComponents.length == 3 ? ietfComponents[2] + : ietfComponents.length == 2 ? ietfComponents[1] : null; + + double quality = 1.0; + if (parts.length > 1 && parts[1].startsWith("q=")) { + try { + quality = Double.parseDouble(parts[1].substring(2)); + } catch (NumberFormatException e) { + // ignore + } + } + + languages.add(new Language(code, script, region, quality)); + } + + return languages.stream() + .filter(Objects::nonNull) + .sorted(Comparator.comparingDouble((Language l) -> l.quality).reversed()) + .collect(Collectors.toList()); + } +} diff --git a/app/src/main/java/run/halo/feed/telemetry/BrowserDetector.java b/app/src/main/java/run/halo/feed/telemetry/BrowserDetector.java new file mode 100644 index 0000000..b8c8101 --- /dev/null +++ b/app/src/main/java/run/halo/feed/telemetry/BrowserDetector.java @@ -0,0 +1,222 @@ +package run.halo.feed.telemetry; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.experimental.UtilityClass; +import org.apache.commons.lang3.StringUtils; + +@UtilityClass +public class BrowserDetector { + static final String UNKNOWN = "Unknown"; + + private static final List OPERATING_SYSTEM_RULES = Arrays.asList( + new OperatingSystemRule("iOS", Pattern.compile("iP(hone|od|ad)")), + new OperatingSystemRule("Android OS", Pattern.compile("Android")), + new OperatingSystemRule("Windows 10", Pattern.compile("Windows NT 10.0")), + new OperatingSystemRule("Windows 8.1", Pattern.compile("Windows NT 6.3")), + new OperatingSystemRule("Windows 8", Pattern.compile("Windows NT 6.2")), + new OperatingSystemRule("Windows 7", Pattern.compile("Windows NT 6.1")), + new OperatingSystemRule("Windows Vista", Pattern.compile("Windows NT 6.0")), + new OperatingSystemRule("Windows XP", Pattern.compile("Windows NT 5.1|Windows XP")), + new OperatingSystemRule("Windows 2000", Pattern.compile("Windows NT 5.0|Windows 2000")), + new OperatingSystemRule("Mac OS", Pattern.compile("Macintosh.*Mac OS X ([0-9_]+)")), + new OperatingSystemRule("Chrome OS", Pattern.compile("CrOS")), + new OperatingSystemRule("Linux", Pattern.compile("(Linux|X11)")), + new OperatingSystemRule("BlackBerry OS", Pattern.compile("BlackBerry|BB10")), + new OperatingSystemRule("Windows CE", Pattern.compile("Windows CE|WinCE")), + new OperatingSystemRule("QNX", Pattern.compile("QNX")), + new OperatingSystemRule("BeOS", Pattern.compile("BeOS")), + new OperatingSystemRule("Open BSD", Pattern.compile("OpenBSD")), + new OperatingSystemRule("Sun OS", Pattern.compile("SunOS")) + ); + + // High-priority patterns with associated application names + private static final Map HIGH_PRIORITY_PATTERNS = new LinkedHashMap<>(); + + static { + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("AOLShield/([0-9._]+)"), "AOLShield"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("(?!Chrom.*OPR)Chrom(?:e|ium)/([0-9.]+)"), + "Chrome"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("Version/([0-9._]+).*Safari"), "Safari"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("Firefox/([0-9.]+)"), "Firefox"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("Edge/([0-9.]+)"), "Edge"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("OPR/([0-9.]+)"), "Opera"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("CriOS/([0-9.]+)"), "Chrome iOS"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("EdgiOS/([0-9.]+)"), "Edge iOS"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("YaBrowser/([0-9.]+)"), "Yandex Browser"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("KAKAOTALK\\s([0-9.]+)"), "KakaoTalk"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("SamsungBrowser/([0-9.]+)"), "Samsung Browser"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("\\bSilk/([0-9._-]+)\\b"), "Silk"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("MiuiBrowser/([0-9.]+)$"), "Miui Browser"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("BeakerBrowser/([0-9.]+)"), "Beaker Browser"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("PhantomJS/([0-9.]+)"), "PhantomJS"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("^curl/([0-9.]+)$"), "Curl"); + HIGH_PRIORITY_PATTERNS.put(Pattern.compile("bot|crawler|spider|crawl(er|ing)"), "Bot"); + } + + // Fallback general patterns + private static final List GENERAL_PATTERNS = Arrays.asList( + // Match "ApplicationName/Version (Additional Info)" format + Pattern.compile("([a-zA-Z0-9]+(?:/[0-9.]+)?)\\s*\\(([^;]+)(?:;\\s(.+))?\\)"), + // Match "ApplicationName/Version" + Pattern.compile("([a-zA-Z0-9]+(?:/[0-9.]+)?)"), + // Match general application name (last resort) + Pattern.compile("([a-zA-Z0-9]+(?:\\s[a-zA-Z0-9]+)*)") + ); + + public static BrowserInfo detectBrowser(String userAgent) { + UserAgentInfo userAgentInfo = parseUserAgent(userAgent); + String browser = userAgentInfo.application(); + String version = userAgentInfo.version(); + String os = detectOsInternal(userAgent); + String screen = guessScreen(browser, os).toString(); + return new BrowserInfo(browser, version, os, screen); + } + + private static UserAgentInfo parseUserAgent(String userAgent) { + if (userAgent == null || userAgent.isEmpty()) { + return new UserAgentInfo("Unknown", null, null); + } + + // Try high-priority patterns first + for (Map.Entry entry : HIGH_PRIORITY_PATTERNS.entrySet()) { + Matcher matcher = entry.getKey().matcher(userAgent); + if (matcher.find()) { + String application = entry.getValue(); + String version = + matcher.group(1); // Most high-priority patterns have version in group 1 + return new UserAgentInfo(application, version, null); + } + } + + // Try general patterns as a fallback + for (Pattern pattern : GENERAL_PATTERNS) { + Matcher matcher = pattern.matcher(userAgent); + if (matcher.find()) { + String application = matcher.group(1); + String version = matcher.groupCount() > 1 ? matcher.group(2) : null; + String additionalInfo = matcher.groupCount() > 2 ? matcher.group(3) : null; + return new UserAgentInfo(application, version, additionalInfo); + } + } + + // Fallback for unrecognized user agents + return new UserAgentInfo(userAgent, null, null); + } + + private static String detectOsInternal(String userAgent) { + if (userAgent == null || userAgent.isEmpty()) { + return null; + } + String osName = null; + for (OperatingSystemRule rule : OPERATING_SYSTEM_RULES) { + Matcher matcher = rule.regex.matcher(userAgent); + if (matcher.find()) { + osName = rule.os; + break; + } + } + return osName; + } + + public static ScreenResolution guessScreen(String browser, String os) { + if (browser == null || os == null) { + // Default fallback resolution + return new ScreenResolution(1920, 1080); + } + + if (StringUtils.isBlank(os)) { + // Default fallback for unknown cases + // Assume Full HD as a safe default + return ScreenResolution.defaultResolution(); + } + + // Common resolutions based on OS and Browser + if (os.contains("Windows") || os.contains("Mac OS")) { + // Desktop OS + if (browser.contains("chrome") || browser.contains("firefox") || browser.contains( + "edge")) { + // Full HD is most common + return new ScreenResolution(1920, 1080); + } else if (browser.contains("safari")) { + // Many Mac users have Retina displays + return new ScreenResolution(2560, 1440); + } + } else if (os.contains("Android OS")) { + // Mobile OS + // Full HD in portrait mode + return new ScreenResolution(1080, 1920); + } else if (os.contains("iOS")) { + // iOS devices (e.g., iPhone, iPad) + if (browser.contains("safari") || browser.contains("crios")) { + // iPhone 12 resolution + return new ScreenResolution(1170, 2532); + } else { + // iPad Pro resolution + return new ScreenResolution(2048, 2732); + } + } else if (os.contains("Linux") || os.contains("Chrome OS")) { + // Common for Chromebooks + return new ScreenResolution(1366, 768); + } + return ScreenResolution.defaultResolution(); + } + + public record ScreenResolution(int width, int height) { + @Override + public String toString() { + return width + "x" + height; + } + + public static ScreenResolution defaultResolution() { + return new ScreenResolution(1920, 1080); + } + } + + private record UserAgentRule(String browser, Pattern regex) { + } + + private record OperatingSystemRule(String os, Pattern regex) { + } + + public record UserAgentInfo(String application, String version, String additionalInfo) { + @Override + public String toString() { + return "UserAgentInfo{" + + "application='" + application + '\'' + + ", version='" + version + '\'' + + ", additionalInfo='" + additionalInfo + '\'' + + '}'; + } + } + + public record BrowserInfo(String name, String version, String os, String screen) { + public BrowserInfo { + if (name == null) { + name = UNKNOWN; + } + if (os == null) { + os = UNKNOWN; + } + } + + public String nameVersion() { + if (StringUtils.isBlank(version)) { + return name; + } + if (!UNKNOWN.equals(name)) { + return name + " " + version; + } + return name; + } + + @Override + public String toString() { + return "BrowserInfo{name='" + name + "', version='" + version + "', os='" + os + "'}"; + } + } +} diff --git a/app/src/main/java/run/halo/feed/telemetry/IpAddressUtils.java b/app/src/main/java/run/halo/feed/telemetry/IpAddressUtils.java new file mode 100644 index 0000000..318be2c --- /dev/null +++ b/app/src/main/java/run/halo/feed/telemetry/IpAddressUtils.java @@ -0,0 +1,71 @@ +package run.halo.feed.telemetry; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.server.ServerRequest; + +/** + * Ip address utils. + * Code from internet. + */ +@Slf4j +public class IpAddressUtils { + public static final String UNKNOWN = "unknown"; + + private static final String[] IP_HEADER_NAMES = { + "X-Forwarded-For", + "X-Real-IP", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "CF-Connecting-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", + "HTTP_CLIENT_IP", + "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", + "HTTP_VIA", + "REMOTE_ADDR", + }; + + /** + * Gets the IP address from request. + * + * @param request is server http request + * @return IP address if found, otherwise {@link #UNKNOWN}. + */ + public static String getClientIp(ServerHttpRequest request) { + for (String header : IP_HEADER_NAMES) { + String ipList = request.getHeaders().getFirst(header); + if (StringUtils.hasText(ipList) && !UNKNOWN.equalsIgnoreCase(ipList)) { + String[] ips = ipList.trim().split("[,;]"); + for (String ip : ips) { + if (StringUtils.hasText(ip) && !UNKNOWN.equalsIgnoreCase(ip)) { + return ip; + } + } + } + } + var remoteAddress = request.getRemoteAddress(); + return remoteAddress == null || remoteAddress.isUnresolved() + ? UNKNOWN : remoteAddress.getAddress().getHostAddress(); + } + + + /** + * Gets the ip address from request. + * + * @param request http request + * @return ip address if found, otherwise {@link #UNKNOWN}. + */ + public static String getIpAddress(ServerRequest request) { + try { + return getClientIp(request.exchange().getRequest()); + } catch (Exception e) { + log.warn("Failed to obtain client IP, and fallback to unknown.", e); + return UNKNOWN; + } + } + +} diff --git a/app/src/main/java/run/halo/feed/telemetry/TelemetryEndpoint.java b/app/src/main/java/run/halo/feed/telemetry/TelemetryEndpoint.java new file mode 100644 index 0000000..d0a8e45 --- /dev/null +++ b/app/src/main/java/run/halo/feed/telemetry/TelemetryEndpoint.java @@ -0,0 +1,73 @@ +package run.halo.feed.telemetry; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +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 run.halo.feed.TelemetryEventInfo; + +@Component +@RequiredArgsConstructor +public class TelemetryEndpoint { + public static final String TELEMETRY_PATH = "/plugins/feed/assets/telemetry.gif"; + static final Resource ONE_PIXEL; + private final TelemetryRecorderDelegator telemetryRecorderDelegator; + + static { + // RSS readers may thumbnail images, and using base64 images may cause RSS readers to + // fail to parse correctly. + ONE_PIXEL = new ClassPathResource("1pixel.png", TelemetryEndpoint.class.getClassLoader()); + } + + @Bean + public RouterFunction telemetryImageRouter() { + return RouterFunctions.route() + .GET(TELEMETRY_PATH, request -> { + telemetryRecorderDelegator.record(createEventInfo(request)); + return ServerResponse.ok() + .header(HttpHeaders.CONTENT_TYPE, MediaType.IMAGE_GIF_VALUE) + .cacheControl(CacheControl.noCache()) + .bodyValue(ONE_PIXEL); + }) + .build(); + } + + private TelemetryEventInfo createEventInfo(ServerRequest request) { + var userAgent = request.headers().firstHeader(HttpHeaders.USER_AGENT); + var browser = BrowserDetector.detectBrowser(userAgent); + var eventInfo = new TelemetryEventInfo() + .setTitle(queryParamOrNull(request, "title")) + .setPageUrl(queryParamOrNull(request, "url")) + .setBrowser(browser.nameVersion()) + .setOs(browser.os()) + .setIp(IpAddressUtils.getIpAddress(request)) + .setReferrer(request.headers().firstHeader(HttpHeaders.REFERER)) + .setScreen(browser.screen()) + .setUserAgent(userAgent) + .setHeaders(request.headers().asHttpHeaders()); + + var acceptLang = request.headers().firstHeader(HttpHeaders.ACCEPT_LANGUAGE); + var languages = AcceptLanguageParser.parseAcceptLanguage(acceptLang); + if (!CollectionUtils.isEmpty(languages)) { + var lang = languages.get(0); + eventInfo.setLanguage(languages.get(0).code()); + if (lang.region() != null) { + eventInfo.setLanguageRegion(lang.code() + "-" + lang.region()); + } + } + return eventInfo; + } + + private static String queryParamOrNull(ServerRequest request, String name) { + return request.queryParam(name).orElse(null); + } +} diff --git a/app/src/main/java/run/halo/feed/telemetry/TelemetryRecorderDelegator.java b/app/src/main/java/run/halo/feed/telemetry/TelemetryRecorderDelegator.java new file mode 100644 index 0000000..dcb843b --- /dev/null +++ b/app/src/main/java/run/halo/feed/telemetry/TelemetryRecorderDelegator.java @@ -0,0 +1,79 @@ +package run.halo.feed.telemetry; + +import java.time.Duration; +import java.time.Instant; +import org.springframework.context.SmartLifecycle; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import run.halo.app.extension.controller.Controller; +import run.halo.app.extension.controller.ControllerBuilder; +import run.halo.app.extension.controller.DefaultController; +import run.halo.app.extension.controller.DefaultQueue; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.extension.controller.RequestQueue; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; +import run.halo.feed.TelemetryEventInfo; +import run.halo.feed.TelemetryRecorder; + +@Component +public class TelemetryRecorderDelegator implements Reconciler, SmartLifecycle { + protected volatile boolean running = false; + + private final RequestQueue queue; + + protected final Controller controller; + + private final ExtensionGetter extensionGetter; + + public TelemetryRecorderDelegator(ExtensionGetter extensionGetter) { + this.extensionGetter = extensionGetter; + this.queue = new DefaultQueue<>(Instant::now); + this.controller = this.setupWith(null); + } + + /** + * Add telemetry event to queue to process it in another thread. + */ + public void record(TelemetryEventInfo telemetryEventInfo) { + queue.addImmediately(telemetryEventInfo); + } + + @Override + public Result reconcile(TelemetryEventInfo eventInfo) { + extensionGetter.getEnabledExtensions(TelemetryRecorder.class) + .doOnNext(recorder -> recorder.record(eventInfo)) + .onErrorResume(Throwable.class, e -> Mono.empty()) + .then() + .block(); + return Result.doNotRetry(); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return new DefaultController<>( + TelemetryRecorderDelegator.class.getName(), + this, + queue, + null, + Duration.ofMillis(100), + Duration.ofMinutes(10) + ); + } + + @Override + public void start() { + controller.start(); + running = true; + } + + @Override + public void stop() { + running = false; + controller.dispose(); + } + + @Override + public boolean isRunning() { + return running; + } +} diff --git a/app/src/main/resources/1pixel.png b/app/src/main/resources/1pixel.png new file mode 100644 index 0000000000000000000000000000000000000000..909c66db1740b7c1b41eb4db6c414a7ab5bb6a23 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$DG5Lh8v~O;;{|;n Oi^0>?&t;ucLK6U5DhwL{ literal 0 HcmV?d00001 diff --git a/app/src/main/resources/extensions/ext-definition.yaml b/app/src/main/resources/extensions/ext-definition.yaml index cb4211f..e587a7c 100644 --- a/app/src/main/resources/extensions/ext-definition.yaml +++ b/app/src/main/resources/extensions/ext-definition.yaml @@ -10,6 +10,17 @@ spec: icon: "/plugins/PluginFeed/assets/logo.svg" --- apiVersion: plugin.halo.run/v1alpha1 +kind: ExtensionPointDefinition +metadata: + name: feed-telemetry-recorder +spec: + className: run.halo.feed.TelemetryRecorder + displayName: "遥测内容访问量记录器" + description: "用于扩展 RSS 内容访问量的存储方式,如上报到 Umami" + type: MULTI_INSTANCE + icon: "/plugins/PluginFeed/assets/logo.svg" +--- +apiVersion: plugin.halo.run/v1alpha1 kind: ExtensionDefinition metadata: name: feed-category-post-rss-item diff --git a/app/src/test/java/run/halo/feed/telemetry/AcceptLanguageParserTest.java b/app/src/test/java/run/halo/feed/telemetry/AcceptLanguageParserTest.java new file mode 100644 index 0000000..40c2118 --- /dev/null +++ b/app/src/test/java/run/halo/feed/telemetry/AcceptLanguageParserTest.java @@ -0,0 +1,17 @@ +package run.halo.feed.telemetry; + +import static org.assertj.core.api.Assertions.assertThat; +import static run.halo.feed.telemetry.AcceptLanguageParser.parseAcceptLanguage; + +import org.junit.jupiter.api.Test; + +class AcceptLanguageParserTest { + + @Test + void parseLangTest() { + String acceptLanguage = "en-US;q=0.9,fr-CA,fr;q=0.8,en;q=0.7,*;q=0.5"; + var languages = parseAcceptLanguage(acceptLanguage); + assertThat(languages).hasSize(5); + assertThat(languages.get(0).code()).isEqualTo("fr"); + } +} \ No newline at end of file diff --git a/app/src/test/java/run/halo/feed/telemetry/BrowserDetectorTest.java b/app/src/test/java/run/halo/feed/telemetry/BrowserDetectorTest.java new file mode 100644 index 0000000..79beef8 --- /dev/null +++ b/app/src/test/java/run/halo/feed/telemetry/BrowserDetectorTest.java @@ -0,0 +1,51 @@ +package run.halo.feed.telemetry; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class BrowserDetectorTest { + + @ParameterizedTest + @MethodSource("provideUserAgentTestCases") + void detectorBrowserTest(String userAgent, String expectedBrowser, String expectedVersion) { + var result = BrowserDetector.detectBrowser(userAgent); + + assertEquals(expectedBrowser, result.name(), "Browser name mismatch"); + assertEquals(expectedVersion, result.version(), "Version mismatch"); + } + + static Stream provideUserAgentTestCases() { + return Stream.of( + Arguments.of( + "NetNewsWire (RSS Reader; https://netnewswire.com/)", + "NetNewsWire", + "RSS Reader"), + Arguments.of( + "NetNewsWire/5.1", + "NetNewsWire/5.1", + null), + Arguments.of( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + "Mozilla/5.0", + "Macintosh"), + Arguments.of( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like " + + "Gecko) Chrome/131.0.0.0 Safari/537.36", + "Chrome", + "131.0.0.0" + ), + Arguments.of( + "SomeApp (Version 1.0; Details Here)", + "SomeApp", + "Version 1.0"), + Arguments.of( + "UnknownApp", + "UnknownApp", + null) + ); + } +} \ No newline at end of file