diff --git a/.env.example b/.env.example index 19d999d..9417a97 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,8 @@ HAOBLOG_ADMIN_PASSWORD_HASH=replace-with-a-bcrypt-hash HAOBLOG_SESSION_COOKIE_SECURE=false HAOBLOG_PUBLIC_BASE_URL=http://localhost:3000 HAOBLOG_AUTHOR_NAME=Hao +# 音乐清单地址;生产必须使用绝对 HTTPS,本地可使用 http://localhost +HAOBLOG_MUSIC_MANIFEST_URL= HAOBLOG_COMMENT_SECURITY_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # Comment notification is disabled locally. Never put real SMTP credentials in this file. @@ -34,6 +36,7 @@ HAOBLOG_COMMENT_NOTIFICATION_ENABLED=false HAOBLOG_OSS_ENABLED=false # HAOBLOG_OSS_BUCKET=haoblog-bucket # HAOBLOG_OSS_REGION=cn-guangzhou +# 地域服务 Endpoint 仅用于签名;生产数据请求使用下方自定义公开域名。 # HAOBLOG_OSS_ENDPOINT=https://oss-cn-guangzhou.aliyuncs.com # HAOBLOG_OSS_ACCESS_KEY_ID=replace-with-server-access-key-id # HAOBLOG_OSS_ACCESS_KEY_SECRET=replace-with-server-access-key-secret diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b907484..4d6f859 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: - stage-4-acceptance: + stage-5-acceptance: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 9988a4f..5a2d384 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Java / Maven target/ +docs/target/ .mvn/wrapper/maven-wrapper.jar # Node / Nuxt diff --git a/AGENTS.md b/AGENTS.md index dc9e33a..1cba691 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Build HaoBlog as a low-cost developer blog and digital garden using Java and Vue - Current user request defines the task scope. - `docs/HaoBlog 完整开发计划.md` defines architecture, modules, resource budgets, APIs, data and milestones. - `docs/HaoBlog-design-language.md` defines public-site UI/UX, tokens, layout, motion and accessibility. +- `docs/target`: local/private development target plans and admission checklists. These files are not public and must not be committed or pushed; do not create the typo directory `docs/taget`. - For public-site design work, use the repo skill at `.agents/skills/haoblog-design/SKILL.md` when available. - If sources conflict, report the conflict and propose the smallest safe resolution. Do not silently override a locked decision. @@ -19,7 +20,7 @@ Build HaoBlog as a low-cost developer blog and digital garden using Java and Vue - `packages/api-client`: reserved package for generated OpenAPI TypeScript types/client. - `infra/compose/compose.dev.yml`: local PostgreSQL + pgvector development Compose configuration. - `.github/workflows/ci.yml`: API, Web and Compose configuration CI. -- `docs`: architecture and design documents. +- `docs`: architecture, design and general project documents; private stage target plans/checklists stay under ignored `docs/target` and are never part of the public repository. Do not create additional services unless the user changes the architecture. @@ -80,7 +81,7 @@ The current repository uses one root pnpm workspace and one Maven application. N - Web production build: `pnpm --dir apps/web build` - Full Chromium E2E: `pnpm --dir apps/web e2e` - Article client bundle budget: `pnpm web:budget` -- Lighthouse with an existing server: set `LHCI_EXISTING_SERVER=true`, `LHCI_BASE_URL=http://localhost` and `LHCI_ARTICLE_PATH` to `/articles/s3-08-advanced-markdown` or `/tools`, then run `pnpm web:lighthouse`; on Windows also use a task-local `TEMP`/`TMP` directory to avoid Chrome cleanup EPERM +- Lighthouse with an existing server: set `LHCI_EXISTING_SERVER=true`, `LHCI_BASE_URL=http://localhost` and run `pnpm web:lighthouse` serially with `LHCI_ARTICLE_PATH` set to `/`, `/garden`, `/tools` or `/articles/s3-08-advanced-markdown`; set `LHCI_NOINDEX=true` only for a noindex/404 audit, and use `LHCI_NUMBER_OF_RUNS`/`LHCI_CHROME_PORT` only for isolated local runner control; on Windows also use a task-local `TEMP`/`TMP` directory and never share a still-running Chrome instance between routes - Compose validation using placeholder environment values: `docker compose --env-file .env.example -f infra/compose/compose.dev.yml config` - Production Compose validation: `docker compose --env-file infra/compose/.env.ci.example -f infra/compose/compose.prod.yml config` - Compose resource and health-boundary verification: `pnpm compose:verify` diff --git a/README.md b/README.md index 2226014..aea68ff 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,27 @@ HaoBlog 是一个面向开发者的低成本个人博客与数字花园,采用 Java 21、Spring Boot、Nuxt 4、Vue 3 和 PostgreSQL/pgvector 构建。项目使用模块化单体架构,公开站点以服务端渲染为核心,管理后台通过 `/studio` 提供内容运营能力。 -项目以“极夜观测站”为设计主题,关注长文阅读、内容可发现性和低性能主机上的稳定运行。它提供文章、中文搜索、评论、工具箱、RSS、Sitemap、目录和结构化数据等能力,并通过安全 Markdown、资源边界、无 JavaScript、Save-Data 与 reduced-motion 路径保持可访问和可持续的阅读体验。 +项目以“极夜观测站”为设计主题,关注长文阅读、内容可发现性和低性能主机上的稳定运行。它提供文章、中文搜索、评论、工具箱、RSS、Sitemap、目录、结构化数据、知识星图、安全终端、双主题、可关闭的首页 Three.js、音乐频谱、404 信号修复和显式准备的 PWA 离线工具箱。公开内容保持 SSR;无 JavaScript、Save-Data、reduced-motion、触摸和重型能力失败时均保留可读路径。 + +## 本地验证 + +仓库要求 Java 21、Node.js 24.x、Corepack 管理的 pnpm 11.16.0,以及包含 Compose 的 Docker Desktop。常用检查从仓库根目录执行: + +```powershell +Push-Location apps/api +.\mvnw.cmd -DskipITs verify +.\mvnw.cmd failsafe:integration-test failsafe:verify +Pop-Location + +corepack pnpm --dir apps/web typecheck +corepack pnpm --dir apps/web test +corepack pnpm --dir apps/web build +corepack pnpm --filter @haoblog/api-client generate +corepack pnpm --filter @haoblog/api-client check +corepack pnpm web:budget +corepack pnpm compose:verify +``` + +阶段五音乐默认关闭。`HAOBLOG_MUSIC_MANIFEST_URL` 在生产环境必须是绝对 HTTPS 地址;音频不经过 Spring Boot。PWA 只在 production build 或显式测试模式注册,进入 `/tools` 后还需点击“准备离线工具”并二次确认;缓存不包含 API、Studio、文章、评论、图谱、音乐或外部资源。 + +完整开发环境、独立生产 Compose 验收、Lighthouse 串行路由和清理命令见 [本地启动指南](docs/本地启动指南.md)。阶段目标任务和准入清单属于本地私有验收资料,不纳入公开仓库。本地收口不等于远端 CI、真实 OSS/CDN 与 CORS、真实域名/HTTPS 安装、部署备案或阶段六 30 分钟 2GB 压测通过。 diff --git a/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java b/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java index 958ca94..38b8e82 100644 --- a/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java +++ b/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java @@ -13,7 +13,10 @@ import java.time.Clock; import java.text.Normalizer; import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -61,6 +64,20 @@ public PublishedBatch listPublishedBatch(int page, int size) { return new PublishedBatch(result.getContent().stream().map(this::toPublicFeedArticle).toList(), result.hasNext()); } + @org.springframework.transaction.annotation.Transactional(readOnly = true) + public List listPublicGardenArticles() { + Map grouped = new LinkedHashMap<>(); + for (var row : repository.findPublicGardenRows(java.time.Instant.now(clock))) { + var article = grouped.computeIfAbsent(row.getArticleId(), ignored -> new GardenArticleBuilder( + row.getArticleId(), row.getSlug(), row.getTitle(), row.getExcerpt(), row.getPublishedAt(), + taxonomy(row.getCategoryName(), row.getCategorySlug()))); + if (row.getTagSlug() != null && !row.getTagSlug().isBlank()) { + article.tags.add(taxonomy(row.getTagName(), row.getTagSlug())); + } + } + return grouped.values().stream().map(GardenArticleBuilder::build).toList(); + } + public SearchPage search(String query, int page, int size) { String normalizedQuery = normalizeSearchQuery(query); if (page < 0 || size < 1 || size > 20) throw new IllegalArgumentException("page/size out of range"); @@ -111,7 +128,39 @@ public UUID articleId() { public record PageResult(Page page) {} public record PublishedBatch(List items, boolean hasNext) {} public record PublicFeedArticle(UUID id, String slug, String title, String excerpt, java.time.Instant publishedAt) {} + public record GardenTaxonomy(String name, String slug) {} + public record GardenArticle(UUID id, String slug, String title, String excerpt, java.time.Instant publishedAt, + GardenTaxonomy category, List tags) {} public record SearchPage(String query, Page page) {} public record PublicSearchArticle(UUID id, String slug, String title, String excerpt, java.time.Instant publishedAt, UUID coverMediaId, boolean commentsEnabled) {} + + private static GardenTaxonomy taxonomy(String name, String slug) { + if (slug == null || slug.isBlank()) return null; + return new GardenTaxonomy(name == null || name.isBlank() ? slug : name.trim(), slug.trim().toLowerCase(Locale.ROOT)); + } + + private static final class GardenArticleBuilder { + private final UUID id; + private final String slug; + private final String title; + private final String excerpt; + private final java.time.Instant publishedAt; + private final GardenTaxonomy category; + private final java.util.Set tags = new LinkedHashSet<>(); + + private GardenArticleBuilder(UUID id, String slug, String title, String excerpt, + java.time.Instant publishedAt, GardenTaxonomy category) { + this.id = id; + this.slug = slug; + this.title = title; + this.excerpt = excerpt; + this.publishedAt = publishedAt; + this.category = category; + } + + private GardenArticle build() { + return new GardenArticle(id, slug, title, excerpt, publishedAt, category, List.copyOf(tags)); + } + } } diff --git a/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java b/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java index 8017597..228ee64 100644 --- a/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java +++ b/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java @@ -11,6 +11,7 @@ import java.time.Instant; import java.util.Optional; import java.util.UUID; +import java.util.List; public interface ArticleRevisionRepository extends JpaRepository { boolean existsByArticleId(UUID articleId); @@ -100,6 +101,25 @@ where a.status in ('PUBLISHED', 'SCHEDULED') Page searchVisible(@Param("pattern") String pattern, @Param("now") Instant now, Pageable pageable); + @Query(value = """ + select r.article_id as "articleId", r.slug as slug, r.title as title, r.excerpt as excerpt, + a.published_at as "publishedAt", + r.category_snapshot ->> 'name' as "categoryName", + r.category_snapshot ->> 'slug' as "categorySlug", + tag.value ->> 'name' as "tagName", + tag.value ->> 'slug' as "tagSlug" + from article_revision r + join article a on a.published_revision_id = r.id + left join lateral jsonb_array_elements(coalesce(r.tag_snapshot, '[]'::jsonb)) as tag(value) on true + where a.status in ('PUBLISHED', 'SCHEDULED') + and a.published_revision_id is not null + and a.published_at is not null + and a.published_at <= :now + order by a.published_at desc, a.id desc, + tag.value ->> 'slug' asc nulls last + """, nativeQuery = true) + List findPublicGardenRows(@Param("now") Instant now); + @Query(""" select r as revision, a.publishedAt as publishedAt, a.commentsEnabled as commentsEnabled from ArticleRevision r, Article a @@ -158,4 +178,16 @@ interface SearchProjection { UUID getCoverMediaId(); boolean getCommentsEnabled(); } + + interface PublicGardenRow { + UUID getArticleId(); + String getSlug(); + String getTitle(); + String getExcerpt(); + Instant getPublishedAt(); + String getCategoryName(); + String getCategorySlug(); + String getTagName(); + String getTagSlug(); + } } diff --git a/apps/api/src/main/java/io/haoblog/media/infrastructure/AliyunOssObjectStorage.java b/apps/api/src/main/java/io/haoblog/media/infrastructure/AliyunOssObjectStorage.java index 5c808e8..8cdc208 100644 --- a/apps/api/src/main/java/io/haoblog/media/infrastructure/AliyunOssObjectStorage.java +++ b/apps/api/src/main/java/io/haoblog/media/infrastructure/AliyunOssObjectStorage.java @@ -44,8 +44,9 @@ public AliyunOssObjectStorage(AliyunOssProperties properties, HttpClient httpCli throw new IllegalArgumentException("OSS upload limits must be positive"); } host = properties.getBucket() + "." + endpointHost(properties.getEndpoint()); - publicBaseUrl = trimTrailingSlash(properties.getPublicBaseUrl().isBlank() - ? "https://" + host : properties.getPublicBaseUrl()); + String configuredPublicBaseUrl = properties.getPublicBaseUrl(); + publicBaseUrl = normalizePublicBaseUrl(configuredPublicBaseUrl == null || configuredPublicBaseUrl.isBlank() + ? "https://" + host : configuredPublicBaseUrl); } @Override @@ -77,14 +78,14 @@ public UploadGrant createUploadGrant(UploadSpec spec, Instant now) { fields.put("x-oss-credential", credential); fields.put("x-oss-date", dateTime); fields.put("x-oss-signature", signature); - return new UploadGrant("https://" + host + "/", fields, expiresAt); + return new UploadGrant(publicBaseUrl + "/", fields, expiresAt); } @Override public StoredObject head(String objectKey) { if (!headSlots.tryAcquire()) throw new ObjectStorageException("OSS metadata capacity is temporarily exhausted"); try { - HttpRequest request = HttpRequest.newBuilder(URI.create("https://" + host + "/" + encodePath(objectKey))) + HttpRequest request = HttpRequest.newBuilder(URI.create(publicBaseUrl + "/" + encodePath(objectKey))) .timeout(Duration.ofSeconds(5)) .method("HEAD", HttpRequest.BodyPublishers.noBody()) .build(); @@ -166,6 +167,23 @@ private static String endpointHost(String endpoint) { return uri.getHost(); } + private static String normalizePublicBaseUrl(String value) { + URI uri; + try { + uri = URI.create(value.trim()); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException("OSS publicBaseUrl must be an HTTPS origin", exception); + } + String path = uri.getRawPath(); + if (!uri.isAbsolute() || !"https".equalsIgnoreCase(uri.getScheme()) + || uri.getHost() == null || uri.getHost().isBlank() || uri.getUserInfo() != null + || uri.getQuery() != null || uri.getFragment() != null + || (path != null && !path.isEmpty() && !"/".equals(path))) { + throw new IllegalStateException("OSS publicBaseUrl must be an HTTPS origin"); + } + return trimTrailingSlash(value.trim()); + } + private static String trimTrailingSlash(String value) { return value.replaceAll("/+$", ""); } private static String encodePath(String value) { diff --git a/apps/api/src/main/java/io/haoblog/site/application/GardenGraphService.java b/apps/api/src/main/java/io/haoblog/site/application/GardenGraphService.java new file mode 100644 index 0000000..8da371b --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/site/application/GardenGraphService.java @@ -0,0 +1,254 @@ +package io.haoblog.site.application; + +import io.haoblog.content.application.ArticleService; +import io.haoblog.toolbox.application.ToolManagementService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class GardenGraphService { + static final int MAX_NODES = 200; + static final int MAX_EDGES = 600; + + private final ArticleService articles; + private final ToolManagementService tools; + + public GardenGraphService(ArticleService articles, ToolManagementService tools) { + this.articles = articles; + this.tools = tools; + } + + @Transactional(readOnly = true) + public Graph get() { + return build(articles.listPublicGardenArticles(), tools.listPublicGardenTools()); + } + + public static Graph build(List articles, + List tools) { + Map nodes = new LinkedHashMap<>(); + Map edges = new LinkedHashMap<>(); + + for (var article : articles == null ? List.of() : articles) { + String articleId = nodeId(NodeType.ARTICLE, article.slug()); + if (articleId == null) continue; + addNode(nodes, new CandidateNode(articleId, NodeType.ARTICLE, cleanLabel(article.title(), article.slug()), + "/articles/" + article.slug(), cleanText(article.excerpt()), article.publishedAt(), 0)); + addMemberships(nodes, edges, articleId, article.category(), article.tags()); + addCoOccurrences(edges, article.tags()); + } + for (var tool : tools == null ? List.of() : tools) { + String toolId = nodeId(NodeType.TOOL, tool.slug()); + if (toolId == null) continue; + addNode(nodes, new CandidateNode(toolId, NodeType.TOOL, cleanLabel(tool.title(), tool.slug()), + "/tools?tool=" + tool.slug(), cleanText(tool.description()), null, tool.sortOrder())); + addMemberships(nodes, edges, toolId, + taxonomy(tool.categoryName(), tool.categorySlug()), + tool.tags() == null ? List.of() : tool.tags().stream() + .map(value -> taxonomy(value, value)).toList()); + addCoOccurrences(edges, tool.tags() == null ? List.of() : tool.tags().stream() + .map(value -> taxonomy(value, value)).toList()); + } + + edges.values().forEach(edge -> { + CandidateNode source = nodes.get(edge.source()); + CandidateNode target = nodes.get(edge.target()); + if (source != null) source.fullDegree += edge.weight(); + if (target != null) target.fullDegree += edge.weight(); + }); + + List orderedNodes = nodes.values().stream().sorted(NODE_ORDER).toList(); + Set selectedIds = orderedNodes.stream().limit(MAX_NODES) + .map(CandidateNode::id).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + List orderedEdges = edges.values().stream() + .filter(edge -> selectedIds.contains(edge.source()) && selectedIds.contains(edge.target())) + .sorted(EDGE_ORDER) + .toList(); + List selectedEdges = orderedEdges.stream().limit(MAX_EDGES).toList(); + Map degrees = new HashMap<>(); + for (var edge : selectedEdges) { + degrees.merge(edge.source(), 1, Integer::sum); + degrees.merge(edge.target(), 1, Integer::sum); + } + List resultNodes = orderedNodes.stream().limit(MAX_NODES) + .map(node -> new Node(node.id(), node.type(), node.label(), node.href(), node.summary(), + node.publishedAt(), degrees.getOrDefault(node.id(), 0))) + .toList(); + List resultEdges = selectedEdges.stream() + .map(edge -> new Edge(edge.source(), edge.target(), edge.kind(), edge.weight())) + .toList(); + boolean truncated = resultNodes.size() < nodes.size() || resultEdges.size() < orderedEdges.size() + || resultEdges.size() < edges.size(); + return new Graph(resultNodes, resultEdges, truncated); + } + + private static void addMemberships(Map nodes, Map edges, + String contentId, ArticleService.GardenTaxonomy category, + List tags) { + if (category != null) addMembership(nodes, edges, contentId, NodeType.CATEGORY, category); + Set seen = new LinkedHashSet<>(); + for (var tag : tags == null ? List.of() : tags) { + if (tag != null && seen.add(normalizeSlug(tag.slug()))) { + addMembership(nodes, edges, contentId, NodeType.TAG, tag); + } + } + } + + private static void addMembership(Map nodes, Map edges, + String contentId, NodeType type, ArticleService.GardenTaxonomy taxonomy) { + String taxonomyId = nodeId(type, taxonomy.slug()); + if (taxonomyId == null) return; + addNode(nodes, new CandidateNode(taxonomyId, type, cleanLabel(taxonomy.name(), taxonomy.slug()), + taxonomyHref(type, taxonomy.slug()), null, null, 0)); + addEdge(edges, contentId, taxonomyId, EdgeKind.MEMBERSHIP, 1); + } + + private static void addCoOccurrences(Map edges, + List sourceTags) { + List tags = sourceTags == null ? List.of() : sourceTags; + List ids = tags.stream().filter(tag -> tag != null && tag.slug() != null) + .map(tag -> nodeId(NodeType.TAG, tag.slug())).filter(java.util.Objects::nonNull).distinct().sorted().toList(); + for (int left = 0; left < ids.size(); left++) { + for (int right = left + 1; right < ids.size(); right++) { + addEdge(edges, ids.get(left), ids.get(right), EdgeKind.CO_OCCURRENCE, 1); + } + } + } + + private static void addEdge(Map edges, String source, String target, + EdgeKind kind, int weight) { + EdgeKey key = new EdgeKey(source, target, kind); + edges.merge(key, new CandidateEdge(source, target, kind, weight), + (left, right) -> new CandidateEdge(left.source(), left.target(), left.kind(), left.weight() + right.weight())); + } + + private static void addNode(Map nodes, CandidateNode candidate) { + nodes.merge(candidate.id(), candidate, GardenGraphService::preferNode); + } + + private static CandidateNode preferNode(CandidateNode left, CandidateNode right) { + String label = left.label().compareTo(right.label()) <= 0 ? left.label() : right.label(); + String summary = left.summary() != null ? left.summary() : right.summary(); + Instant publishedAt = left.publishedAt() != null ? left.publishedAt() : right.publishedAt(); + return new CandidateNode(left.id(), left.type(), label, left.href(), summary, publishedAt, + Math.min(left.sortOrder(), right.sortOrder())); + } + + private static final Comparator NODE_ORDER = (left, right) -> { + int type = Integer.compare(typeOrder(left.type()), typeOrder(right.type())); + if (type != 0) return type; + if (left.type() == NodeType.ARTICLE) { + int published = comparePublishedAt(left.publishedAt(), right.publishedAt()); + if (published != 0) return published; + } + if (left.type() == NodeType.TOOL) { + int sort = Integer.compare(left.sortOrder(), right.sortOrder()); + if (sort != 0) return sort; + } + if (left.type() == NodeType.TAG || left.type() == NodeType.CATEGORY) { + int degree = Integer.compare(right.fullDegree(), left.fullDegree()); + if (degree != 0) return degree; + } + return Comparator.comparing(CandidateNode::label).thenComparing(CandidateNode::id).compare(left, right); + }; + + private static final Comparator EDGE_ORDER = Comparator + .comparingInt(CandidateEdge::weight).reversed() + .thenComparing(edge -> edge.kind().name()) + .thenComparing(CandidateEdge::source) + .thenComparing(CandidateEdge::target); + + private static int typeOrder(NodeType type) { + return switch (type) { + case ARTICLE -> 0; + case TOOL -> 1; + case CATEGORY -> 2; + case TAG -> 3; + }; + } + + private static int comparePublishedAt(Instant left, Instant right) { + if (left == null && right == null) return 0; + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + } + + private static String taxonomyHref(NodeType type, String slug) { + return "/articles?" + (type == NodeType.CATEGORY ? "category" : "tag") + "=" + slug; + } + + private static String nodeId(NodeType type, String rawSlug) { + String slug = normalizeSlug(rawSlug); + return slug == null ? null : type.name().toLowerCase(Locale.ROOT) + ":" + slug; + } + + private static String normalizeSlug(String rawSlug) { + if (rawSlug == null || rawSlug.isBlank()) return null; + return java.text.Normalizer.normalize(rawSlug, java.text.Normalizer.Form.NFKC).trim().toLowerCase(Locale.ROOT); + } + + private static String cleanLabel(String label, String fallback) { + return label == null || label.isBlank() ? fallback : label.trim(); + } + + private static String cleanText(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static ArticleService.GardenTaxonomy taxonomy(String name, String slug) { + if (slug == null || slug.isBlank()) return null; + return new ArticleService.GardenTaxonomy(cleanLabel(name, slug), slug); + } + + public enum NodeType { ARTICLE, TAG, CATEGORY, TOOL } + public enum EdgeKind { MEMBERSHIP, CO_OCCURRENCE } + + public record Node(String id, NodeType type, String label, String href, String summary, + Instant publishedAt, int degree) {} + public record Edge(String source, String target, EdgeKind kind, int weight) {} + public record Graph(List nodes, List edges, boolean truncated) {} + + private record EdgeKey(String source, String target, EdgeKind kind) {} + private record CandidateEdge(String source, String target, EdgeKind kind, int weight) {} + private static final class CandidateNode { + private final String id; + private final NodeType type; + private final String label; + private final String href; + private final String summary; + private final Instant publishedAt; + private final int sortOrder; + private int fullDegree; + + private CandidateNode(String id, NodeType type, String label, String href, String summary, + Instant publishedAt, int sortOrder) { + this.id = id; + this.type = type; + this.label = label; + this.href = href; + this.summary = summary; + this.publishedAt = publishedAt; + this.sortOrder = sortOrder; + } + + String id() { return id; } + NodeType type() { return type; } + String label() { return label; } + String href() { return href; } + String summary() { return summary; } + Instant publishedAt() { return publishedAt; } + int sortOrder() { return sortOrder; } + int fullDegree() { return fullDegree; } + } +} diff --git a/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java b/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java index 5ffd22b..4a70119 100644 --- a/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java +++ b/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java @@ -23,7 +23,7 @@ public class PublicFeedService { private static final int RSS_LIMIT = 20; private static final int SITEMAP_BATCH_SIZE = 500; private static final int SITEMAP_URL_LIMIT = 50_000; - private static final int STATIC_URL_COUNT = 4; + private static final int STATIC_URL_COUNT = 5; private static final DateTimeFormatter RSS_DATE_FORMAT = DateTimeFormatter.RFC_1123_DATE_TIME; private final SiteService siteService; private final ArticleService articleService; @@ -104,7 +104,7 @@ private static String writeSitemap(SiteService.SiteResult site, List { writer.writeStartElement("urlset"); writer.writeDefaultNamespace("http://www.sitemaps.org/schemas/sitemap/0.9"); - for (String path : List.of("/", "/articles", "/tools", "/about")) { + for (String path : List.of("/", "/articles", "/garden", "/tools", "/about")) { urlElement(writer, url(site.siteUrl(), path)); } for (var article : articles) { diff --git a/apps/api/src/main/java/io/haoblog/site/application/SiteService.java b/apps/api/src/main/java/io/haoblog/site/application/SiteService.java index 103d1a2..1251662 100644 --- a/apps/api/src/main/java/io/haoblog/site/application/SiteService.java +++ b/apps/api/src/main/java/io/haoblog/site/application/SiteService.java @@ -2,9 +2,12 @@ import io.haoblog.site.persistence.SiteSettingRepository; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.net.URI; +import java.util.Arrays; +import java.util.Locale; @Service @@ -12,40 +15,71 @@ public class SiteService { private final SiteSettingRepository repository; private final String publicBaseUrl; private final String authorName; + private final String musicManifestUrl; + @Autowired public SiteService(SiteSettingRepository repository, @Value("${haoblog.site.public-base-url}") String publicBaseUrl, - @Value("${haoblog.site.author-name}") String authorName) { + @Value("${haoblog.site.author-name}") String authorName, + @Value("${haoblog.site.music-manifest-url:}") String musicManifestUrl, + @Value("${spring.profiles.active:local}") String activeProfiles) { + this(repository, publicBaseUrl, authorName, musicManifestUrl, activeProfiles, true); + } + + public SiteService(SiteSettingRepository repository, String publicBaseUrl, String authorName) { + this(repository, publicBaseUrl, authorName, null, "local", false); + } + + private SiteService(SiteSettingRepository repository, String publicBaseUrl, String authorName, + String musicManifestUrl, String activeProfiles, boolean validateManifest) { this.repository = repository; this.publicBaseUrl = normalizePublicBaseUrl(publicBaseUrl); if (authorName == null || authorName.isBlank()) { throw new IllegalArgumentException("HAOBLOG_AUTHOR_NAME must not be blank"); } this.authorName = authorName.trim(); + this.musicManifestUrl = validateManifest + ? normalizeMusicManifestUrl(musicManifestUrl, activeProfiles) + : null; } public SiteResult get() { var setting = repository.findBySiteKey("default").orElseThrow(); - return new SiteResult(setting.getTitle(), setting.getDescription(), publicBaseUrl, authorName, setting.isCommentsEnabled()); + return new SiteResult(setting.getTitle(), setting.getDescription(), publicBaseUrl, authorName, + setting.isCommentsEnabled(), setting.isMusicEnabled() && musicManifestUrl != null, + setting.isThreeDEnabled()); } public AdminSiteResult getAdmin() { var setting = repository.findBySiteKey("default").orElseThrow(); return new AdminSiteResult(setting.getTitle(), setting.getDescription(), publicBaseUrl, authorName, - setting.isCommentsEnabled(), setting.getVersion()); + setting.isCommentsEnabled(), setting.isMusicEnabled(), setting.isThreeDEnabled(), setting.getVersion()); } @org.springframework.transaction.annotation.Transactional public AdminSiteResult updateCommentsEnabled(long expectedVersion, boolean commentsEnabled) { + return updateSettings(expectedVersion, commentsEnabled, null, null); + } + + @org.springframework.transaction.annotation.Transactional + public AdminSiteResult updateSettings(long expectedVersion, Boolean commentsEnabled, + Boolean musicEnabled, Boolean threeDEnabled) { var setting = repository.findBySiteKey("default").orElseThrow(); if (setting.getVersion() != expectedVersion) { throw new io.haoblog.shared.web.ProblemException("SITE_VERSION_CONFLICT", "Site setting version conflict", "Reload the latest site settings before saving", setting.getVersion()); } - setting.setCommentsEnabled(commentsEnabled); + boolean nextMusicEnabled = musicEnabled == null ? setting.isMusicEnabled() : musicEnabled; + if (nextMusicEnabled && musicManifestUrl == null) { + throw new io.haoblog.shared.web.ProblemException("MUSIC_MANIFEST_NOT_CONFIGURED", + "Music manifest is not configured", "Configure HAOBLOG_MUSIC_MANIFEST_URL before enabling music"); + } + if (commentsEnabled != null) setting.setCommentsEnabled(commentsEnabled); + setting.setMusicEnabled(nextMusicEnabled); + if (threeDEnabled != null) setting.setThreeDEnabled(threeDEnabled); var saved = repository.saveAndFlush(setting); return new AdminSiteResult(saved.getTitle(), saved.getDescription(), publicBaseUrl, authorName, - saved.isCommentsEnabled(), saved.getVersion()); + saved.isCommentsEnabled(), saved.isMusicEnabled(), saved.isThreeDEnabled(), saved.getVersion()); } public long currentVersion() { @@ -70,12 +104,49 @@ static String normalizePublicBaseUrl(String raw) { return raw.trim().replaceFirst("/+$", ""); } - public record SiteResult(String title, String description, String siteUrl, String authorName, boolean commentsEnabled) { + static String normalizeMusicManifestUrl(String raw, String activeProfiles) { + if (raw == null || raw.isBlank()) return null; + String value = raw.trim(); + URI uri; + try { + uri = URI.create(value); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("HAOBLOG_MUSIC_MANIFEST_URL must be an absolute HTTPS URL", exception); + } + String host = uri.getHost(); + boolean absoluteHost = uri.isAbsolute() && host != null && uri.getUserInfo() == null + && uri.getFragment() == null; + boolean https = "https".equalsIgnoreCase(uri.getScheme()); + boolean localProfile = Arrays.stream((activeProfiles == null ? "" : activeProfiles).split(",")) + .map(String::trim).map(valueProfile -> valueProfile.toLowerCase(Locale.ROOT)) + .anyMatch(profile -> profile.equals("local") || profile.equals("dev") || profile.equals("test")); + boolean localhostHttp = host != null && "http".equalsIgnoreCase(uri.getScheme()) && localProfile + && SetOfLocalHosts.contains(host.toLowerCase(Locale.ROOT)); + if (!absoluteHost || (!https && !localhostHttp)) { + throw new IllegalArgumentException("HAOBLOG_MUSIC_MANIFEST_URL must be HTTPS; local/dev may use localhost HTTP"); + } + return value; + } + + private static final java.util.Set SetOfLocalHosts = java.util.Set.of("localhost", "127.0.0.1", "::1"); + + public record SiteResult(String title, String description, String siteUrl, String authorName, + boolean commentsEnabled, boolean musicEnabled, boolean threeDEnabled) { public SiteResult(String title, String description, String siteUrl, String authorName) { - this(title, description, siteUrl, authorName, true); + this(title, description, siteUrl, authorName, true, false, false); + } + public SiteResult(String title, String description, String siteUrl, String authorName, + boolean commentsEnabled) { + this(title, description, siteUrl, authorName, commentsEnabled, false, false); } } public record AdminSiteResult(String title, String description, String siteUrl, String authorName, - boolean commentsEnabled, long version) {} + boolean commentsEnabled, boolean musicEnabled, boolean threeDEnabled, + long version) { + public AdminSiteResult(String title, String description, String siteUrl, String authorName, + boolean commentsEnabled, long version) { + this(title, description, siteUrl, authorName, commentsEnabled, false, false, version); + } + } } diff --git a/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java b/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java index 34366e1..777b822 100644 --- a/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java +++ b/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java @@ -12,6 +12,8 @@ public class SiteSetting { @Column(nullable = false, length = 160) private String title; @Column(nullable = false, length = 600) private String description; @Column(name = "comments_enabled", nullable = false) private boolean commentsEnabled = true; + @Column(name = "music_enabled", nullable = false) private boolean musicEnabled = false; + @Column(name = "three_d_enabled", nullable = false) private boolean threeDEnabled = false; @Version @Column(nullable = false) private long version; protected SiteSetting() {} public SiteSetting(String siteKey, String title, String description) { @@ -20,6 +22,10 @@ public SiteSetting(String siteKey, String title, String description) { public String getTitle() { return title; } public String getDescription() { return description; } public boolean isCommentsEnabled() { return commentsEnabled; } + public boolean isMusicEnabled() { return musicEnabled; } + public boolean isThreeDEnabled() { return threeDEnabled; } public long getVersion() { return version; } public void setCommentsEnabled(boolean commentsEnabled) { this.commentsEnabled = commentsEnabled; } + public void setMusicEnabled(boolean musicEnabled) { this.musicEnabled = musicEnabled; } + public void setThreeDEnabled(boolean threeDEnabled) { this.threeDEnabled = threeDEnabled; } } diff --git a/apps/api/src/main/java/io/haoblog/site/web/AdminSiteController.java b/apps/api/src/main/java/io/haoblog/site/web/AdminSiteController.java index 9aff3df..2bb877e 100644 --- a/apps/api/src/main/java/io/haoblog/site/web/AdminSiteController.java +++ b/apps/api/src/main/java/io/haoblog/site/web/AdminSiteController.java @@ -28,20 +28,22 @@ public Response get() { @PutMapping public Response update(@RequestBody @Valid UpdateRequest request) { try { - return Response.from(service.updateCommentsEnabled(request.version(), request.commentsEnabled())); + return Response.from(service.updateSettings(request.version(), request.commentsEnabled(), + request.musicEnabled(), request.threeDEnabled())); } catch (OptimisticLockingFailureException exception) { throw new ProblemException("SITE_VERSION_CONFLICT", "Site setting version conflict", "Reload the latest site settings before saving", service.currentVersion()); } } - public record UpdateRequest(@NotNull Long version, @NotNull Boolean commentsEnabled) {} + public record UpdateRequest(@NotNull Long version, @NotNull Boolean commentsEnabled, + Boolean musicEnabled, Boolean threeDEnabled) {} public record Response(String title, String description, String siteUrl, String authorName, - boolean commentsEnabled, long version) { + boolean commentsEnabled, boolean musicEnabled, boolean threeDEnabled, long version) { static Response from(SiteService.AdminSiteResult result) { return new Response(result.title(), result.description(), result.siteUrl(), result.authorName(), - result.commentsEnabled(), result.version()); + result.commentsEnabled(), result.musicEnabled(), result.threeDEnabled(), result.version()); } } } diff --git a/apps/api/src/main/java/io/haoblog/site/web/PublicGardenController.java b/apps/api/src/main/java/io/haoblog/site/web/PublicGardenController.java new file mode 100644 index 0000000..c0de4d7 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/site/web/PublicGardenController.java @@ -0,0 +1,65 @@ +package io.haoblog.site.web; + +import io.haoblog.site.application.GardenGraphService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/public/garden") +public class PublicGardenController { + private final GardenGraphService service; + + public PublicGardenController(GardenGraphService service) { + this.service = service; + } + + @GetMapping + public ResponseEntity garden(@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) { + Response response = Response.from(service.get()); + String etag = representationHash(response); + var headers = new HttpHeaders(); + headers.setETag(etag); + headers.setCacheControl("public, max-age=0, s-maxage=60, must-revalidate"); + if (etag.equals(ifNoneMatch)) return ResponseEntity.status(304).headers(headers).build(); + return ResponseEntity.ok().headers(headers).body(response); + } + + private static String representationHash(Object value) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + return '"' + HexFormat.of().formatHex(digest.digest(value.toString().getBytes(StandardCharsets.UTF_8))) + '"'; + } catch (Exception exception) { + throw new IllegalStateException("Unable to create garden representation ETag", exception); + } + } + + public record Response(List nodes, List edges, boolean truncated) { + static Response from(GardenGraphService.Graph graph) { + return new Response(graph.nodes().stream().map(Node::from).toList(), + graph.edges().stream().map(Edge::from).toList(), graph.truncated()); + } + } + + public record Node(String id, GardenGraphService.NodeType type, String label, String href, + String summary, java.time.Instant publishedAt, int degree) { + static Node from(GardenGraphService.Node value) { + return new Node(value.id(), value.type(), value.label(), value.href(), value.summary(), + value.publishedAt(), value.degree()); + } + } + + public record Edge(String source, String target, GardenGraphService.EdgeKind kind, int weight) { + static Edge from(GardenGraphService.Edge value) { + return new Edge(value.source(), value.target(), value.kind(), value.weight()); + } + } +} diff --git a/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java b/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java index b2f6020..d9ff3b7 100644 --- a/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java +++ b/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java @@ -20,7 +20,8 @@ public class PublicSiteController { @GetMapping public ResponseEntity site(@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) { var result = service.get(); - var response = new SiteResponse(result.title(), result.description(), result.siteUrl(), result.authorName(), result.commentsEnabled()); + var response = new SiteResponse(result.title(), result.description(), result.siteUrl(), result.authorName(), + result.commentsEnabled(), result.musicEnabled(), result.threeDEnabled()); var headers = new HttpHeaders(); headers.setETag(representationHash(response)); headers.setCacheControl("public, max-age=0, s-maxage=60, must-revalidate"); @@ -39,9 +40,14 @@ private static String representationHash(Object value) { } } - public record SiteResponse(String title, String description, String siteUrl, String authorName, boolean commentsEnabled) { + public record SiteResponse(String title, String description, String siteUrl, String authorName, + boolean commentsEnabled, boolean musicEnabled, boolean threeDEnabled) { public SiteResponse(String title, String description, String siteUrl, String authorName) { - this(title, description, siteUrl, authorName, true); + this(title, description, siteUrl, authorName, true, false, false); + } + public SiteResponse(String title, String description, String siteUrl, String authorName, + boolean commentsEnabled) { + this(title, description, siteUrl, authorName, commentsEnabled, false, false); } } } diff --git a/apps/api/src/main/java/io/haoblog/toolbox/application/ToolManagementService.java b/apps/api/src/main/java/io/haoblog/toolbox/application/ToolManagementService.java index c5567a1..6549318 100644 --- a/apps/api/src/main/java/io/haoblog/toolbox/application/ToolManagementService.java +++ b/apps/api/src/main/java/io/haoblog/toolbox/application/ToolManagementService.java @@ -53,6 +53,21 @@ public PublicTools listPublicTools(String category, ToolType type, String keywor return queryPublicTools(normalizedCategory, type, normalizedKeyword); } + @Transactional(readOnly = true) + public List listPublicGardenTools() { + List activeTools = tools.findAllByStatus(ToolStatus.ACTIVE, + Sort.by(Sort.Direction.ASC, "sortOrder", "title", "id")); + Map categoryById = categories.findAllById( + activeTools.stream().map(Tool::getCategoryId).distinct().toList()).stream() + .collect(Collectors.toMap(ToolCategory::getId, categoryValue -> categoryValue)); + return activeTools.stream().map(tool -> { + ToolCategory category = categoryById.get(tool.getCategoryId()); + if (category == null) return null; + return new GardenTool(tool.getId(), tool.getTitle(), tool.getSlug(), tool.getDescription(), + category.getName(), category.getSlug(), tool.getTags(), tool.getSortOrder()); + }).filter(java.util.Objects::nonNull).toList(); + } + private PublicTools queryPublicTools(String normalizedCategory, ToolType type, String normalizedKeyword) { List activeTools = tools.findAllByStatus(ToolStatus.ACTIVE, @@ -235,4 +250,7 @@ static PublicTool from(Tool tool, ToolCategory category) { tool.getTags(), tool.getSortOrder()); } } + + public record GardenTool(UUID id, String title, String slug, String description, + String categoryName, String categorySlug, List tags, int sortOrder) {} } diff --git a/apps/api/src/main/resources/application-prod.yml b/apps/api/src/main/resources/application-prod.yml index 3700f2c..f384473 100644 --- a/apps/api/src/main/resources/application-prod.yml +++ b/apps/api/src/main/resources/application-prod.yml @@ -8,5 +8,6 @@ server: haoblog: site: public-base-url: ${HAOBLOG_PUBLIC_BASE_URL} + music-manifest-url: ${HAOBLOG_MUSIC_MANIFEST_URL:} security: session-cookie-secure: true diff --git a/apps/api/src/main/resources/application.yml b/apps/api/src/main/resources/application.yml index 0cfb731..63aea3a 100644 --- a/apps/api/src/main/resources/application.yml +++ b/apps/api/src/main/resources/application.yml @@ -73,6 +73,7 @@ haoblog: site: public-base-url: ${HAOBLOG_PUBLIC_BASE_URL:http://localhost:3000} author-name: ${HAOBLOG_AUTHOR_NAME:Hao} + music-manifest-url: ${HAOBLOG_MUSIC_MANIFEST_URL:} security: session-cookie-secure: ${HAOBLOG_SESSION_COOKIE_SECURE:false} seed: diff --git a/apps/api/src/main/resources/db/migration/V16__site_experience_flags.sql b/apps/api/src/main/resources/db/migration/V16__site_experience_flags.sql new file mode 100644 index 0000000..be3ea8f --- /dev/null +++ b/apps/api/src/main/resources/db/migration/V16__site_experience_flags.sql @@ -0,0 +1,3 @@ +ALTER TABLE site_setting + ADD COLUMN music_enabled boolean NOT NULL DEFAULT false, + ADD COLUMN three_d_enabled boolean NOT NULL DEFAULT false; diff --git a/apps/api/src/test/java/io/haoblog/AdminCommentIT.java b/apps/api/src/test/java/io/haoblog/AdminCommentIT.java index f09f095..f13b69b 100644 --- a/apps/api/src/test/java/io/haoblog/AdminCommentIT.java +++ b/apps/api/src/test/java/io/haoblog/AdminCommentIT.java @@ -65,7 +65,7 @@ static void database(DynamicPropertyRegistry registry) { @BeforeEach void seed() { jdbc.execute("TRUNCATE comment, article_revision, article CASCADE"); - jdbc.update("UPDATE site_setting SET comments_enabled=true, version=0 WHERE site_key='default'"); + jdbc.update("UPDATE site_setting SET comments_enabled=true, music_enabled=false, three_d_enabled=false, version=0 WHERE site_key='default'"); Instant now = Instant.parse("2020-01-01T00:00:00Z"); Article article = articles.saveAndFlush(new Article("moderation-signal", "Moderation signal", "Excerpt", "# body", ArticleStatus.PUBLISHED, now.minus(1, ChronoUnit.DAYS), now)); @@ -151,6 +151,31 @@ void globalSiteSwitchChangesPublicSiteAndArticleEtags() throws Exception { .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("SITE_VERSION_CONFLICT")); } + @Test + void protectsExperienceFlagsAndRequiresMusicManifest() throws Exception { + mvc.perform(get("/api/v1/admin/site")) + .andExpect(status().isUnauthorized()); + mvc.perform(put("/api/v1/admin/site").with(admin()).with(csrf()).contentType("application/json") + .content("{\"version\":0,\"commentsEnabled\":true,\"musicEnabled\":true,\"threeDEnabled\":false}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("MUSIC_MANIFEST_NOT_CONFIGURED")); + + mvc.perform(put("/api/v1/admin/site").with(admin()).with(csrf()).contentType("application/json") + .content("{\"version\":0,\"commentsEnabled\":true,\"musicEnabled\":false,\"threeDEnabled\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.threeDEnabled").value(true)) + .andExpect(jsonPath("$.version").value(1)); + mvc.perform(get("/api/v1/public/site")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.musicEnabled").value(false)) + .andExpect(jsonPath("$.threeDEnabled").value(true)) + .andExpect(jsonPath("$.musicManifestUrl").doesNotExist()); + mvc.perform(put("/api/v1/admin/site").with(admin()).with(csrf()).contentType("application/json") + .content("{\"version\":0,\"commentsEnabled\":true,\"musicEnabled\":false,\"threeDEnabled\":false}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("SITE_VERSION_CONFLICT")); + } + @Test void articleSwitchChangesPublicArticleEtag() throws Exception { var before = mvc.perform(get("/api/v1/public/articles/moderation-signal")).andExpect(status().isOk()).andReturn(); diff --git a/apps/api/src/test/java/io/haoblog/ContentModelIT.java b/apps/api/src/test/java/io/haoblog/ContentModelIT.java index 7ca0255..c9c14d2 100644 --- a/apps/api/src/test/java/io/haoblog/ContentModelIT.java +++ b/apps/api/src/test/java/io/haoblog/ContentModelIT.java @@ -57,7 +57,7 @@ static void database(DynamicPropertyRegistry registry) { @Test void migratesAllVersionsAndCreatesContentTables() { - assertEquals(15, jdbc.queryForObject("SELECT count(*) FROM flyway_schema_history", Integer.class)); + assertEquals(16, jdbc.queryForObject("SELECT count(*) FROM flyway_schema_history", Integer.class)); for (String table : List.of("article", "category", "tag", "article_tag", "article_revision", "article_preview_token", "media_asset", "media_upload", "outbox_event", "comment")) { assertEquals(1, jdbc.queryForObject( diff --git a/apps/api/src/test/java/io/haoblog/GardenGraphIT.java b/apps/api/src/test/java/io/haoblog/GardenGraphIT.java new file mode 100644 index 0000000..0a2ca63 --- /dev/null +++ b/apps/api/src/test/java/io/haoblog/GardenGraphIT.java @@ -0,0 +1,110 @@ +package io.haoblog; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +@SpringBootTest +@AutoConfigureMockMvc +class GardenGraphIT { + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("pgvector/pgvector:0.8.6-pg17"); + + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.placeholders.admin_username", () -> "admin"); + registry.add("spring.flyway.placeholders.admin_password_hash", () -> "$2a$10$0V.Xs7CLOUYSekm7RKq3Z.iY76KUan/Xbeu5vjmLpX.sVd4pcFpIu"); + } + + @Autowired MockMvc mvc; + @Autowired JdbcTemplate jdbc; + + @BeforeEach + void clean() { + jdbc.execute("TRUNCATE article_tag, article_preview_token, article_revision, article, category, tag, tool, tool_category CASCADE"); + } + + @Test + void exposesOnlyPublicSnapshotsActiveToolsAndStableConditionalResponse() throws Exception { + Instant now = Instant.parse("2026-08-23T00:00:00Z"); + UUID categoryId = UUID.randomUUID(); + jdbc.update("INSERT INTO category(id, name, slug, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + categoryId, "Backend", "backend", Timestamp.from(now), Timestamp.from(now)); + seedArticle("public-signal", "Public signal", "Published excerpt", "# secret markdown", "PUBLISHED", now.minusSeconds(60), + categoryId, "[{\"name\":\"Java\",\"slug\":\"java\"},{\"name\":\"Spring\",\"slug\":\"spring\"}]", now); + seedArticle("future-signal", "Future signal", "Future", "future markdown", "SCHEDULED", now.plusSeconds(60), + null, "[]", now); + seedArticle("draft-signal", "Draft signal", "Draft", "draft markdown", "DRAFT", null, + null, "[]", now); + seedArticle("archived-signal", "Archived signal", "Archived", "archived markdown", "ARCHIVED", now.minusSeconds(120), + null, "[]", now); + + UUID toolCategoryId = UUID.randomUUID(); + UUID toolId = UUID.randomUUID(); + jdbc.update("INSERT INTO tool_category(id, name, slug, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + toolCategoryId, "Utilities", "utilities", Timestamp.from(now), Timestamp.from(now)); + jdbc.update("INSERT INTO tool(id, category_id, type, status, title, slug, description, url, tags, created_at, updated_at) " + + "VALUES (?, ?, 'LINK', 'ACTIVE', ?, ?, ?, ?, ?::jsonb, ?, ?)", + toolId, toolCategoryId, "JSON Tool", "json-tool", "Format JSON", "https://external.example/tool", + "[\"Java\",\"Spring\"]", Timestamp.from(now), Timestamp.from(now)); + + var first = mvc.perform(get("/api/v1/public/garden")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nodes.length()").value(6)) + .andExpect(jsonPath("$.edges[?(@.kind == 'CO_OCCURRENCE')].weight").value(org.hamcrest.Matchers.contains(2))) + .andExpect(jsonPath("$.truncated").value(false)) + .andExpect(header().exists("ETag")) + .andReturn(); + String body = first.getResponse().getContentAsString(); + assertTrue(body.contains("/tools?tool=json-tool")); + assertFalse(body.contains("secret markdown")); + assertFalse(body.contains("https://external.example/tool")); + assertFalse(body.contains("future-signal")); + assertFalse(body.contains("draft-signal")); + assertFalse(body.contains("archived-signal")); + + mvc.perform(get("/api/v1/public/garden").header("If-None-Match", first.getResponse().getHeader("ETag"))) + .andExpect(status().isNotModified()).andExpect(header().exists("ETag")); + } + + private void seedArticle(String slug, String title, String excerpt, String markdown, String status, + Instant publishedAt, UUID categoryId, String tags, Instant now) { + UUID articleId = UUID.randomUUID(); + UUID revisionId = UUID.randomUUID(); + jdbc.update("INSERT INTO article(id, slug, title, excerpt, markdown_source, status, published_at, created_at, updated_at, version, category_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)", + articleId, slug, title, excerpt, markdown, status, publishedAt == null ? null : Timestamp.from(publishedAt), + Timestamp.from(now), Timestamp.from(now), categoryId); + if ("PUBLISHED".equals(status)) { + jdbc.update("INSERT INTO article_revision(id, article_id, source_version, title, slug, excerpt, markdown_source, " + + "category_snapshot, tag_snapshot, created_at) VALUES (?, ?, 0, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?)", + revisionId, articleId, title, slug, excerpt, markdown, + categoryId == null ? null : "{\"name\":\"Backend\",\"slug\":\"backend\"}", tags, Timestamp.from(now)); + jdbc.update("UPDATE article SET published_revision_id = ? WHERE id = ?", revisionId, articleId); + } + } +} diff --git a/apps/api/src/test/java/io/haoblog/PublicApiIT.java b/apps/api/src/test/java/io/haoblog/PublicApiIT.java index c252f7f..27e66c9 100644 --- a/apps/api/src/test/java/io/haoblog/PublicApiIT.java +++ b/apps/api/src/test/java/io/haoblog/PublicApiIT.java @@ -212,7 +212,7 @@ WHERE a.status IN ('PUBLISHED', 'SCHEDULED') .andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("scheduled-feed")))) .andExpect(header().exists("ETag")) .andReturn(); - assertEquals(6, count(sitemap.getResponse().getContentAsString(), "")); + assertEquals(7, count(sitemap.getResponse().getContentAsString(), "")); mvc.perform(get("/sitemap.xml").header("If-None-Match", sitemap.getResponse().getHeader("ETag"))) .andExpect(status().isNotModified()).andExpect(content().string("")); } diff --git a/apps/api/src/test/java/io/haoblog/PublicApiTest.java b/apps/api/src/test/java/io/haoblog/PublicApiTest.java index a041775..d52fdeb 100644 --- a/apps/api/src/test/java/io/haoblog/PublicApiTest.java +++ b/apps/api/src/test/java/io/haoblog/PublicApiTest.java @@ -27,7 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import com.jayway.jsonpath.JsonPath; -@WebMvcTest({PublicSiteController.class, PublicArticleController.class, PublicFeedController.class}) +@WebMvcTest({PublicSiteController.class, PublicArticleController.class, PublicFeedController.class, io.haoblog.site.web.PublicGardenController.class}) @Import({io.haoblog.shared.web.TraceIdFilter.class, io.haoblog.shared.web.GlobalExceptionHandler.class, io.haoblog.shared.web.ProblemResponseWriter.class}) class PublicApiTest { @@ -35,6 +35,7 @@ class PublicApiTest { @MockitoBean io.haoblog.site.application.SiteService siteService; @MockitoBean io.haoblog.content.application.ArticleService articleService; @MockitoBean io.haoblog.site.application.PublicFeedService publicFeedService; + @MockitoBean io.haoblog.site.application.GardenGraphService gardenGraphService; @BeforeEach void rejectInvalidServiceArguments() { @@ -105,6 +106,24 @@ void rejectInvalidServiceArguments() { } } + @Test void gardenSupportsStableGraphResponseAnd304() throws Exception { + when(gardenGraphService.get()).thenReturn(new io.haoblog.site.application.GardenGraphService.Graph( + List.of(new io.haoblog.site.application.GardenGraphService.Node( + "article:visible", io.haoblog.site.application.GardenGraphService.NodeType.ARTICLE, + "Visible", "/articles/visible", "Excerpt", Instant.parse("2026-01-01T00:00:00Z"), 1)), + List.of(new io.haoblog.site.application.GardenGraphService.Edge( + "article:visible", "tag:java", io.haoblog.site.application.GardenGraphService.EdgeKind.MEMBERSHIP, 1)), false)); + + var first = mvc.perform(get("/api/v1/public/garden")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nodes[0].href").value("/articles/visible")) + .andExpect(jsonPath("$.edges[0].kind").value("MEMBERSHIP")) + .andExpect(header().string("Cache-Control", "public, max-age=0, s-maxage=60, must-revalidate")) + .andReturn(); + mvc.perform(get("/api/v1/public/garden").header("If-None-Match", first.getResponse().getHeader("ETag"))) + .andExpect(status().isNotModified()).andExpect(content().string("")); + } + @Test void unknownStaticResourcePathReturnsSafeProblem() throws Exception { mvc.perform(get("/missing.css").header("X-Request-ID", "request-static-404")) .andExpect(status().isNotFound()) diff --git a/apps/api/src/test/java/io/haoblog/media/infrastructure/AliyunOssObjectStorageTest.java b/apps/api/src/test/java/io/haoblog/media/infrastructure/AliyunOssObjectStorageTest.java index ade70e6..6dbbddf 100644 --- a/apps/api/src/test/java/io/haoblog/media/infrastructure/AliyunOssObjectStorageTest.java +++ b/apps/api/src/test/java/io/haoblog/media/infrastructure/AliyunOssObjectStorageTest.java @@ -3,14 +3,24 @@ import io.haoblog.media.application.ObjectStorage; import org.junit.jupiter.api.Test; +import java.net.URI; import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Base64; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class AliyunOssObjectStorageTest { @Test @@ -38,6 +48,46 @@ void policyPinsBucketObjectMimeMetadataAndExactSizeWithoutNetwork() { assertEquals(Instant.parse("2030-01-01T00:05:00Z"), grant.expiresAt()); } + @Test + void dataRequestsUseTheConfiguredCustomDomain() throws Exception { + AliyunOssProperties properties = properties(); + properties.setPublicBaseUrl("https://img.haoblog.com.cn/"); + HttpClient client = mock(HttpClient.class); + @SuppressWarnings("unchecked") + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(404); + when(client.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(response); + AliyunOssObjectStorage storage = new AliyunOssObjectStorage(properties, client); + + ObjectStorage.UploadGrant grant = storage.createUploadGrant( + new ObjectStorage.UploadSpec("media/2030-01/a-safe-key.jpg", "image/jpeg", 1234, 640, 480, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + Instant.parse("2030-01-01T00:00:00Z")); + + assertEquals("https://img.haoblog.com.cn/", grant.uploadUrl()); + assertEquals("https://img.haoblog.com.cn/media/2030-01/a-safe-key.jpg", + storage.publicUrl("media/2030-01/a-safe-key.jpg")); + assertNull(storage.head("media/2030-01/a safe-key.jpg")); + var request = org.mockito.ArgumentCaptor.forClass(HttpRequest.class); + verify(client).send(request.capture(), any(HttpResponse.BodyHandler.class)); + assertEquals(URI.create("https://img.haoblog.com.cn/media/2030-01/a%20safe-key.jpg"), request.getValue().uri()); + } + + @Test + void rejectsUnsafeOrNonOriginPublicDomains() { + for (String value : List.of( + "http://img.haoblog.com.cn", + "https://user:pass@img.haoblog.com.cn", + "https://img.haoblog.com.cn/media", + "https://img.haoblog.com.cn?version=1", + "https://img.haoblog.com.cn#objects")) { + AliyunOssProperties properties = properties(); + properties.setPublicBaseUrl(value); + assertThrows(IllegalStateException.class, + () -> new AliyunOssObjectStorage(properties, HttpClient.newHttpClient())); + } + } + private static AliyunOssProperties properties() { AliyunOssProperties properties = new AliyunOssProperties(); properties.setBucket("demo-123"); diff --git a/apps/api/src/test/java/io/haoblog/site/application/GardenGraphServiceTest.java b/apps/api/src/test/java/io/haoblog/site/application/GardenGraphServiceTest.java new file mode 100644 index 0000000..f33b967 --- /dev/null +++ b/apps/api/src/test/java/io/haoblog/site/application/GardenGraphServiceTest.java @@ -0,0 +1,54 @@ +package io.haoblog.site.application; + +import io.haoblog.content.application.ArticleService; +import io.haoblog.toolbox.application.ToolManagementService; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GardenGraphServiceTest { + @Test + void normalizesTagsBuildsWeightedCoOccurrenceAndKeepsToolHrefInternal() { + var article = new ArticleService.GardenArticle(UUID.randomUUID(), "first-post", "First post", "Excerpt", + Instant.parse("2026-01-02T00:00:00Z"), + new ArticleService.GardenTaxonomy("Backend", "backend"), + List.of(new ArticleService.GardenTaxonomy("Java", "Java"), new ArticleService.GardenTaxonomy("Spring", "spring"))); + var tool = new ToolManagementService.GardenTool(UUID.randomUUID(), "JSON Tool", "json-tool", "Format JSON", + "Utilities", "utilities", List.of("java", "spring"), 1); + + var graph = GardenGraphService.build(List.of(article), List.of(tool)); + + assertEquals(6, graph.nodes().size()); + assertTrue(graph.nodes().stream().anyMatch(node -> node.id().equals("tool:json-tool") + && node.href().equals("/tools?tool=json-tool"))); + assertEquals(2, graph.edges().stream() + .filter(edge -> edge.kind() == GardenGraphService.EdgeKind.CO_OCCURRENCE) + .findFirst().orElseThrow().weight()); + assertEquals(graph, GardenGraphService.build(List.of(article), List.of(tool))); + } + + @Test + void deterministicallyClipsNodesAndEdges() { + var articles = java.util.stream.IntStream.range(0, 205).mapToObj(index -> + new ArticleService.GardenArticle(UUID.randomUUID(), "post-" + index, "Post " + index, null, + Instant.parse("2026-01-01T00:00:00Z").plusSeconds(index), null, List.of())).toList(); + var clippedNodes = GardenGraphService.build(articles, List.of()); + assertEquals(200, clippedNodes.nodes().size()); + assertTrue(clippedNodes.truncated()); + assertEquals("article:post-204", clippedNodes.nodes().get(0).id()); + + var sharedTags = java.util.stream.IntStream.range(0, 100).mapToObj(index -> + new ArticleService.GardenArticle(UUID.randomUUID(), "dense-" + index, "Dense " + index, null, + Instant.parse("2026-01-01T00:00:00Z"), null, + java.util.stream.IntStream.range(0, 6) + .mapToObj(tag -> new ArticleService.GardenTaxonomy("Tag " + tag, "tag-" + tag)).toList())).toList(); + var clippedEdges = GardenGraphService.build(sharedTags, List.of()); + assertEquals(600, clippedEdges.edges().size()); + assertTrue(clippedEdges.truncated()); + } +} diff --git a/apps/api/src/test/java/io/haoblog/site/application/PublicFeedServiceTest.java b/apps/api/src/test/java/io/haoblog/site/application/PublicFeedServiceTest.java index e98f62b..afe9c6c 100644 --- a/apps/api/src/test/java/io/haoblog/site/application/PublicFeedServiceTest.java +++ b/apps/api/src/test/java/io/haoblog/site/application/PublicFeedServiceTest.java @@ -55,7 +55,7 @@ void sitemapReadsPublishedArticlesInFiveHundredItemBatches() throws Exception { Document xml = parse(new PublicFeedService(siteService, articleService).sitemap().body()); - assertEquals(506, xml.getElementsByTagNameNS("http://www.sitemaps.org/schemas/sitemap/0.9", "url").getLength()); + assertEquals(507, xml.getElementsByTagNameNS("http://www.sitemaps.org/schemas/sitemap/0.9", "url").getLength()); verify(articleService).listPublishedBatch(eq(0), eq(500)); verify(articleService).listPublishedBatch(eq(1), eq(500)); } diff --git a/apps/api/src/test/java/io/haoblog/site/application/SiteServiceTest.java b/apps/api/src/test/java/io/haoblog/site/application/SiteServiceTest.java index 29fa11d..4faf356 100644 --- a/apps/api/src/test/java/io/haoblog/site/application/SiteServiceTest.java +++ b/apps/api/src/test/java/io/haoblog/site/application/SiteServiceTest.java @@ -33,4 +33,40 @@ void rejectsNonOriginPublicUrls() { assertThrows(IllegalArgumentException.class, () -> new SiteService(repository, "javascript:alert(1)", "Hao")); assertThrows(IllegalArgumentException.class, () -> new SiteService(repository, "https://user:pass@blog.example.test", "Hao")); } + + @Test + void validatesManifestByProfileAndRejectsEnablingWithoutConfiguration() { + var repository = mock(SiteSettingRepository.class); + var setting = new SiteSetting("default", "HaoBlog", "Night station"); + when(repository.findBySiteKey("default")).thenReturn(Optional.of(setting)); + + assertEquals("http://localhost:8080/music.json", + SiteService.normalizeMusicManifestUrl(" http://localhost:8080/music.json ", "local")); + assertEquals("https://cdn.example.test/music.json", + SiteService.normalizeMusicManifestUrl("https://cdn.example.test/music.json", "prod")); + assertThrows(IllegalArgumentException.class, + () -> SiteService.normalizeMusicManifestUrl("http://cdn.example.test/music.json", "prod")); + assertThrows(IllegalArgumentException.class, + () -> SiteService.normalizeMusicManifestUrl("http:relative", "local")); + + var service = new SiteService(repository, "https://blog.example.test", "Hao", null, "prod"); + var missing = assertThrows(io.haoblog.shared.web.ProblemException.class, + () -> service.updateSettings(0, true, true, false)); + assertEquals("MUSIC_MANIFEST_NOT_CONFIGURED", missing.getCode()); + } + + @Test + void publicMusicFlagSafelyTurnsOffWhenManifestIsMissing() { + var repository = mock(SiteSettingRepository.class); + var setting = new SiteSetting("default", "HaoBlog", "Night station"); + setting.setMusicEnabled(true); + setting.setThreeDEnabled(true); + when(repository.findBySiteKey("default")).thenReturn(Optional.of(setting)); + + var service = new SiteService(repository, "https://blog.example.test", "Hao", null, "prod"); + var result = service.get(); + + assertEquals(false, result.musicEnabled()); + assertEquals(true, result.threeDEnabled()); + } } diff --git a/apps/web/app/app.vue b/apps/web/app/app.vue index c8a7c7b..9931e0d 100644 --- a/apps/web/app/app.vue +++ b/apps/web/app/app.vue @@ -11,13 +11,17 @@ import { useDataSaver } from '~/composables/useDataSaver' const { data: site } = await usePublicSite() const { enabled: dataSaver } = useDataSaver() +const runtimeConfig = useRuntimeConfig() useHead(() => ({ htmlAttrs: { lang: 'zh-CN', 'data-save-data': dataSaver.value ? 'on' : 'off' }, - link: [{ - rel: 'alternate', - type: 'application/rss+xml', - title: `${site.value?.title || 'HaoBlog'} RSS`, - href: site.value ? publicAbsoluteUrl(site.value.siteUrl, '/rss.xml') || undefined : undefined, - }], + link: [ + { + rel: 'alternate', + type: 'application/rss+xml', + title: `${site.value?.title || 'HaoBlog'} RSS`, + href: site.value ? publicAbsoluteUrl(site.value.siteUrl, '/rss.xml') || undefined : undefined, + }, + ...(runtimeConfig.public.pwaEnabled ? [{ rel: 'manifest' as const, href: '/manifest.webmanifest' }] : []), + ], })) diff --git a/apps/web/app/assets/styles/articles.css b/apps/web/app/assets/styles/articles.css index 33eaf50..effa792 100644 --- a/apps/web/app/assets/styles/articles.css +++ b/apps/web/app/assets/styles/articles.css @@ -1,16 +1,35 @@ @import 'katex/dist/katex.min.css'; -.home-overview { position: relative; max-width: 68rem; margin-top: 12vh; } +.home-overview { position: relative; max-width: 68rem; margin-top: 0; } .home-overview::before { position: absolute; z-index: -1; inset: 0; pointer-events: none; background-image: linear-gradient(var(--color-grid) 1px, transparent 1px), linear-gradient(90deg, var(--color-grid) 1px, transparent 1px); background-size: 48px 48px; content: ''; mask-image: linear-gradient(90deg, transparent, #000 18%, #000 82%, transparent); } .home-overview.is-data-saver::before { display: none; } +.home-act { position: relative; } +.home-calibration-scene { min-height: calc(100vh - 52px); padding-top: clamp(5rem, 13vh, 10rem); } .home-calibration { display: flex; align-items: start; justify-content: space-between; gap: 2rem; padding-bottom: 1rem; border-bottom: 1px solid var(--color-border); } .home-readout, .home-coordinate, .home-identity, .log-count, .recent-index, .index-note, .observation-index, .observation-open { color: var(--color-text-muted); font: var(--text-xs)/1.5 var(--font-mono); letter-spacing: .08em; } .home-readout { margin: .75rem 0 0; } .home-coordinate { margin: 0; text-align: right; } .status-light { display: inline-block; width: .35rem; height: .35rem; margin-right: .35rem; border-radius: 50%; background: var(--color-accent); vertical-align: .08em; } .home-overview h1 { margin-top: clamp(3rem, 10vh, 7rem); } +.home-title-button { max-width: 100%; padding: 0; border: 0; color: inherit; background: transparent; font: inherit; letter-spacing: inherit; line-height: inherit; text-align: left; cursor: pointer; } +.home-title-button:hover, .home-title-button:focus-visible { color: var(--color-accent); } .home-identity { margin: 0 0 1rem; color: var(--color-accent); } .home-overview .signal-copy { max-width: 34rem; } +.home-scanline { position: absolute; top: 0; left: 0; width: 100%; height: 1px; pointer-events: none; background: var(--color-accent); opacity: 0; } +.home-starmap-scene { min-height: 42rem; padding-top: clamp(5rem, 13vh, 10rem); border-top: 1px solid var(--color-border); } +.home-act-heading { display: flex; align-items: end; justify-content: space-between; gap: 2rem; } +.home-act-heading h2, .home-recent-scene h2 { margin: .55rem 0 0; font: 700 clamp(2rem, 6vw, 4.75rem)/.95 var(--font-display); letter-spacing: -.06em; } +.home-frame-readout { margin: 0 .15rem .35rem 0; color: var(--color-text-muted); font: var(--text-xs)/1.5 var(--font-mono); letter-spacing: .08em; } +.home-starmap-copy { max-width: 42rem; margin: 1.5rem 0 0; color: var(--color-text-muted); line-height: 1.7; } +.home-static-starmap { position: relative; min-height: 19rem; margin-top: 2rem; overflow: hidden; border: 1px solid var(--color-border); background-image: linear-gradient(var(--color-grid) 1px, transparent 1px), linear-gradient(90deg, var(--color-grid) 1px, transparent 1px); background-size: 48px 48px; } +.home-static-starmap::before, .home-static-starmap::after { position: absolute; inset: 15% 12%; border: 1px solid var(--color-track); border-radius: 50%; content: ''; transform: rotate(-12deg); } +.home-static-starmap::after { inset: 22% 22%; transform: rotate(31deg); } +.home-orbit { position: absolute; z-index: 1; padding: .35rem .45rem; border: 1px solid var(--color-accent); color: var(--color-text-main); background: var(--color-bg-sub); font: var(--text-xs)/1.3 var(--font-mono); } +.home-orbit--article { top: 16%; left: 13%; }.home-orbit--tag { top: 42%; right: 13%; }.home-orbit--category { bottom: 16%; left: 25%; }.home-orbit--tool { right: 27%; bottom: 20%; } +.home-orbit-core { position: absolute; top: 50%; left: 50%; width: .8rem; height: .8rem; border: 2px solid var(--color-accent); background: var(--color-bg-base); transform: translate(-50%, -50%) rotate(45deg); } +.home-starmap-status, .home-starmap-fallback { color: var(--color-text-muted); font: var(--text-xs)/1.6 var(--font-mono); } +.home-starmap-fallback a { color: var(--color-accent); } +.home-recent-scene { margin-top: clamp(6rem, 16vh, 12rem); padding: 1.5rem 0 5rem; border-top: 1px solid var(--color-border); } .continue-reading { display: inline-flex; gap: .6rem; align-items: center; margin-top: 2.5rem; color: var(--color-accent); font: var(--text-xs)/1.5 var(--font-mono); letter-spacing: .08em; text-decoration: none; } .continue-reading span { display: inline-grid; width: 1.45rem; height: 1.45rem; place-items: center; border: 1px solid var(--color-border); transition: transform var(--motion-fast) ease, border-color var(--motion-fast) ease; } .continue-reading:hover span, .continue-reading:focus-visible span { transform: translateY(.2rem); border-color: var(--color-accent); } @@ -146,9 +165,12 @@ html[data-theme='blueprint'] .safe-markdown .shiki span { color: var(--shiki-lig @media (max-width: 480px) { .comment-signal-heading { align-items: start; flex-direction: column; gap: .5rem; }.comment-signal-fields { grid-template-columns: 1fr; gap: 0; }.comment-signal-form { padding: .8rem; }.comment-signal-cancel { top: .8rem; right: .8rem; }.comment-signal-safety { padding-right: 4.5rem; } } @media (max-width: 1023px) { .article-instrument { display: block; max-width: 45rem; margin-top: 6vh; }.article-signal { position: sticky; top: 0; z-index: 2; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: .75rem; height: auto; margin: 0 0 2rem; padding: .5rem 0; background: var(--color-bg-base); }.article-signal-track { width: 100%; height: 2px; }.article-signal-fill { width: var(--signal-progress); height: 100%; }.article-toc { position: static; margin-top: 3rem; padding: 1rem 0 0; border-left: 0; border-top: 1px solid var(--color-border); }.article-toc ol { padding-bottom: .25rem; } } @media (max-width: 767px) { .observation-entry { grid-template-columns: 1fr; gap: .4rem; padding-left: 1rem; }.article-pagination { padding-left: 1rem; }.recent-list li { grid-template-columns: 2rem 1fr; gap: .35rem .75rem; }.recent-list time { grid-column: 2; }.recent-list a { grid-column: 2; }.recent-list .recent-index { grid-row: 1 / span 2; }.home-calibration, .recent-heading { align-items: start; flex-direction: column; gap: .75rem; }.home-coordinate { text-align: left; }.observation-entry-heading { grid-template-columns: 2rem minmax(0, 1fr); }.article-cover-frame { grid-column: 2; width: min(12rem, 100%); }.observation-entry-content p { margin-top: .9rem; } } -@media (max-width: 360px) { .home-overview h1 { font-size: clamp(3rem, 18vw, 4.5rem); } .home-overview::before { display: none; } .safe-markdown { font-size: 1rem; line-height: 1.8; } .observation-entry-heading { grid-template-columns: 1.5rem minmax(0, 1fr); gap: .5rem; } .observation-index { font-size: .68rem; } } +@media (max-width: 767px) { .home-act-heading { align-items: start; flex-direction: column; gap: .75rem; }.home-starmap-scene { min-height: 35rem; }.home-static-starmap { min-height: 16rem; }.home-orbit { font-size: .68rem; }.home-orbit--article { left: 4%; }.home-orbit--tag { right: 4%; }.home-orbit--tool { right: 8%; } } +@media (max-width: 360px) { .home-overview h1 { font-size: clamp(3rem, 18vw, 4.5rem); } .home-overview::before { display: none; } .home-static-starmap { min-height: 14rem; } .safe-markdown { font-size: 1rem; line-height: 1.8; } .observation-entry-heading { grid-template-columns: 1.5rem minmax(0, 1fr); gap: .5rem; } .observation-index { font-size: .68rem; } } @media (max-width: 480px) { .search-signal-controls { align-items: stretch; flex-direction: column; } .search-signal-controls button { align-self: start; } } -@media (prefers-reduced-motion: reduce), (prefers-reduced-data: reduce) { .home-overview::before { opacity: .7; } .continue-reading span, .route-menu a { transition: none; } } +@media (prefers-reduced-motion: no-preference) { .home-scanline { animation: home-scanline 800ms 500ms ease-out both; } } +@media (prefers-reduced-motion: reduce), (prefers-reduced-data: reduce) { .home-overview::before { opacity: .7; } .home-scanline { display: none; } .continue-reading span, .route-menu a { transition: none; } } +@keyframes home-scanline { from { opacity: 0; transform: translateY(0); } 20% { opacity: .9; } to { opacity: 0; transform: translateY(calc(100vh - 52px)); } } .safe-markdown .katex { font-size: 1em; } .safe-markdown .katex-block { display: block; max-width: 100%; margin: 1.75rem 0; overflow-x: auto; padding: .25rem 0; text-align: center; } .safe-markdown .katex-error { color: var(--color-warn); font-family: var(--font-mono); font-size: .9em; } diff --git a/apps/web/app/assets/styles/shell.css b/apps/web/app/assets/styles/shell.css index 9ef44df..c7d8f56 100644 --- a/apps/web/app/assets/styles/shell.css +++ b/apps/web/app/assets/styles/shell.css @@ -2,6 +2,8 @@ html { min-width: 320px; background: var(--color-bg-base); } html, body, #__nuxt { min-height: 100%; margin: 0; } body { overflow-x: clip; background: var(--color-bg-base); color: var(--color-text-main); font-family: var(--font-body); } +html[data-theme='blueprint'] body { background-image: linear-gradient(var(--color-grid) 1px, transparent 1px), linear-gradient(90deg, var(--color-grid) 1px, transparent 1px); background-size: 48px 48px; } +html[data-theme-transition='on'] body::after { position: fixed; z-index: 100; top: 0; left: 0; width: 100%; height: 1px; pointer-events: none; background: var(--color-accent); content: ''; animation: theme-sweep 420ms ease-out both; } main, section, article, aside, nav, div { min-width: 0; } button, a { color: inherit; } a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; } @@ -18,6 +20,7 @@ h1 { max-width: 10ch; margin: 1rem 0; font-family: var(--font-display); font-siz .dock-progress { position: fixed; z-index: var(--z-dock); inset: auto 0 48px; height: 4px; background: var(--color-accent); transform: scaleX(0); transform-origin: left; } .instrument-dock { position: fixed; z-index: var(--z-dock); inset: auto 0 0; display: grid; grid-template-columns: 1fr 1fr 1fr; height: 48px; align-items: center; padding: 0 var(--space-shell); background: var(--color-bg-sub); border-top: 1px solid var(--color-border); } .dock-control { border: 0; background: transparent; text-decoration: none; cursor: pointer; } +.dock-control:hover, .dock-control:focus-visible { color: var(--color-text-main); } .dock-control--disabled { cursor: not-allowed; opacity: .52; } .dock-status { display: grid; justify-items: center; gap: 0.1rem; text-align: center; } .dock-help { color: var(--color-text-muted); font: 0.625rem/1 var(--font-mono); letter-spacing: .04em; } @@ -43,3 +46,5 @@ h1 { max-width: 10ch; margin: 1rem 0; font-family: var(--font-display); font-siz @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } } @media (prefers-reduced-motion: no-preference) { .observation-scene { animation: scene-in var(--motion-slow) ease-out both; } } @keyframes scene-in { from { opacity: 0; transform: translateY(0.75rem); } to { opacity: 1; transform: translateY(0); } } +@keyframes theme-sweep { from { opacity: 0; transform: translateX(-100%); } 18% { opacity: 1; } to { opacity: 0; transform: translateX(100%); } } +@media (prefers-reduced-motion: reduce) { html[data-theme-transition='on'] body::after { display: none; animation: none; } } diff --git a/apps/web/app/components/articles/SafeMarkdown.vue b/apps/web/app/components/articles/SafeMarkdown.vue index 568fbf1..4bdf2d0 100644 --- a/apps/web/app/components/articles/SafeMarkdown.vue +++ b/apps/web/app/components/articles/SafeMarkdown.vue @@ -58,7 +58,14 @@ function inlineNodes(value: string): VNode[] { const image = match[1] === '!' const label = match[2] ?? '' const href = match[3] ?? '' - if (image && /^https:\/\//i.test(href)) nodes.push(h('img', { src: href, alt: label, loading: 'lazy', decoding: 'async' })) + if (image && /^https:\/\//i.test(href)) nodes.push(h('img', { + src: href, + alt: label, + width: 1200, + height: 630, + loading: 'lazy', + decoding: 'async', + })) else if (!image && /^(https?:\/\/|mailto:)/i.test(href)) nodes.push(h('a', { href, rel: 'noopener noreferrer', target: '_blank' }, label)) else nodes.push(h('span', `${image ? '!' : ''}[${label}](${href})`)) cursor = start + match[0].length diff --git a/apps/web/app/components/error/SignalRepair.client.vue b/apps/web/app/components/error/SignalRepair.client.vue new file mode 100644 index 0000000..2ca2628 --- /dev/null +++ b/apps/web/app/components/error/SignalRepair.client.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/apps/web/app/components/garden/GardenCanvas.client.vue b/apps/web/app/components/garden/GardenCanvas.client.vue new file mode 100644 index 0000000..6c94ad6 --- /dev/null +++ b/apps/web/app/components/garden/GardenCanvas.client.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/apps/web/app/components/home/HomeThreeScene.client.vue b/apps/web/app/components/home/HomeThreeScene.client.vue new file mode 100644 index 0000000..9cd6ada --- /dev/null +++ b/apps/web/app/components/home/HomeThreeScene.client.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/apps/web/app/components/home/PublicHomeOverview.vue b/apps/web/app/components/home/PublicHomeOverview.vue index 16b5055..f8f9028 100644 --- a/apps/web/app/components/home/PublicHomeOverview.vue +++ b/apps/web/app/components/home/PublicHomeOverview.vue @@ -1,19 +1,121 @@ diff --git a/apps/web/app/components/music/SignalTape.client.vue b/apps/web/app/components/music/SignalTape.client.vue new file mode 100644 index 0000000..5dbb8c8 --- /dev/null +++ b/apps/web/app/components/music/SignalTape.client.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/apps/web/app/components/terminal/CommandTerminal.client.vue b/apps/web/app/components/terminal/CommandTerminal.client.vue new file mode 100644 index 0000000..700d515 --- /dev/null +++ b/apps/web/app/components/terminal/CommandTerminal.client.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/apps/web/app/error.vue b/apps/web/app/error.vue index 04356b9..d796934 100644 --- a/apps/web/app/error.vue +++ b/apps/web/app/error.vue @@ -1,13 +1,79 @@ + + - + diff --git a/apps/web/app/layouts/default.vue b/apps/web/app/layouts/default.vue index 22ee096..594531a 100644 --- a/apps/web/app/layouts/default.vue +++ b/apps/web/app/layouts/default.vue @@ -4,6 +4,8 @@
- - 命令入口即将开放 + + 打开安全终端
AI 入口即将开放
+ + + diff --git a/apps/web/app/pages/garden.vue b/apps/web/app/pages/garden.vue index a20c63f..96e2495 100644 --- a/apps/web/app/pages/garden.vue +++ b/apps/web/app/pages/garden.vue @@ -1,5 +1,129 @@ - + + + diff --git a/apps/web/app/pages/studio/settings.vue b/apps/web/app/pages/studio/settings.vue index c9492ec..b6cdac4 100644 --- a/apps/web/app/pages/studio/settings.vue +++ b/apps/web/app/pages/studio/settings.vue @@ -5,15 +5,24 @@

正在读取站点控制面板…

-

GLOBAL / COMMENT SIGNAL

+

GLOBAL / EXPERIENCE SIGNALS

+ +

关闭后,公共文章与评论表单会在下一次请求中反映状态;公共缓存策略最长 60 秒。

+

音乐开启前必须配置服务端清单地址;音乐与首页 3D 是独立开关。

当前版本:{{ site.version }}

站点开关已写入,公共信号已刷新。

-
@@ -28,16 +37,25 @@ definePageMeta({ layout: 'studio' }) const { getSite, updateSite } = useAdminSite() const site = ref(null) -const draft = ref(true) +const commentsDraft = ref(true) +const musicDraft = ref(false) +const threeDDraft = ref(false) const loading = ref(true) const saving = ref(false) const saved = ref(false) const error = ref('') +const changed = computed(() => Boolean(site.value && ( + commentsDraft.value !== site.value.commentsEnabled + || musicDraft.value !== site.value.musicEnabled + || threeDDraft.value !== site.value.threeDEnabled +))) async function load() { try { site.value = await getSite() - draft.value = site.value.commentsEnabled + commentsDraft.value = site.value.commentsEnabled + musicDraft.value = site.value.musicEnabled + threeDDraft.value = site.value.threeDEnabled } catch (cause) { error.value = cause instanceof Error ? cause.message : '站点设置暂时不可用。' } finally { @@ -46,12 +64,17 @@ async function load() { } async function save() { - if (!site.value || saving.value || draft.value === site.value.commentsEnabled) return + if (!site.value || saving.value || !changed.value) return saving.value = true saved.value = false error.value = '' try { - site.value = await updateSite({ version: site.value.version, commentsEnabled: draft.value }) + site.value = await updateSite({ + version: site.value.version, + commentsEnabled: commentsDraft.value, + musicEnabled: musicDraft.value, + threeDEnabled: threeDDraft.value, + }) saved.value = true } catch (cause) { error.value = cause instanceof Error ? cause.message : '站点开关写入失败。' diff --git a/apps/web/app/pages/tools.vue b/apps/web/app/pages/tools.vue index a75ca4a..a12c633 100644 --- a/apps/web/app/pages/tools.vue +++ b/apps/web/app/pages/tools.vue @@ -19,18 +19,24 @@ const { data, error } = await usePublicApi('/api/v1/public/tools', default: () => emptyTools, }) +const route = useRoute() +const selectedToolSlug = computed(() => typeof route.query.tool === 'string' ? route.query.tool : '') const category = ref('') const type = ref('') const keyword = ref('') const sortMode = ref('stable') const favorites = ref(new Set()) const usage = ref>({}) -const expanded = ref(new Set()) const embeddedWorker = useEmbeddedWorker() provide(embeddedWorkerKey, embeddedWorker) +const runtimeConfig = useRuntimeConfig() +const offlineStage = ref<'idle' | 'confirm' | 'preparing' | 'ready' | 'error'>('idle') +const offlineMessage = ref('') +const offlineEnabled = computed(() => Boolean(runtimeConfig.public.pwaEnabled)) const tools = computed(() => data.value?.items || []) const categories = computed(() => data.value?.categories || []) +const expanded = ref(new Set(tools.value.filter(tool => tool.slug === selectedToolSlug.value).map(tool => tool.id))) const filteredTools = computed(() => { const query = keyword.value.trim().toLocaleLowerCase() const result = tools.value.filter((tool) => { @@ -52,6 +58,18 @@ onMounted(() => { usage.value = readUsage() }) +watch([selectedToolSlug, tools], async () => { + if (!import.meta.client) return + const slug = selectedToolSlug.value + if (!slug) return + const tool = tools.value.find(item => item.slug === slug) + if (!tool) return + expanded.value = new Set([...expanded.value, tool.id]) + await nextTick() + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + document.getElementById(`tool-${tool.slug}`)?.scrollIntoView({ block: 'center', behavior: reducedMotion ? 'auto' : 'smooth' }) +}, { immediate: true }) + function compareTools(left: PublicTool, right: PublicTool, mode: Exclude) { const leftUsage = usage.value[left.id] || { count: 0, lastUsed: '' } const rightUsage = usage.value[right.id] || { count: 0, lastUsed: '' } @@ -118,6 +136,23 @@ function safeExternalUrl(url: string | null) { return value.protocol === 'https:' && !value.username && !value.password ? value.toString() : null } catch { return null } } + +async function prepareOfflineTools() { + if (offlineStage.value === 'preparing') return + offlineStage.value = 'preparing' + offlineMessage.value = '' + try { + const { embeddedWorkerAssetUrl, preloadEmbeddedToolChunks } = await import('../utils/embeddedTools') + await preloadEmbeddedToolChunks() + const { prepareOfflineTools: prepare, sameOriginResourceUrls } = await import('../utils/pwaClient') + await prepare([...sameOriginResourceUrls(), embeddedWorkerAssetUrl()]) + offlineStage.value = 'ready' + offlineMessage.value = '五个内嵌工具及当前工具目录已准备;断网后可从最近目录快照打开。' + } catch (cause) { + offlineStage.value = 'error' + offlineMessage.value = cause instanceof Error ? cause.message : '离线工具准备失败,请保持网络连接后重试。' + } +}