diff --git a/index.js b/index.js index dd4885c..293900c 100644 --- a/index.js +++ b/index.js @@ -58,6 +58,7 @@ import { featuredTagsRemoveController, } from "./lib/controllers/featured-tags.js"; import { resolveController } from "./lib/controllers/resolve.js"; +import { publicProfileController } from "./lib/controllers/public-profile.js"; import { refollowPauseController, refollowResumeController, @@ -158,11 +159,9 @@ export default class ActivityPubEndpoint { return self._fedifyMiddleware(req, res, next); }); - // HTML fallback for actor URL — redirect browsers to the site homepage. + // HTML fallback for actor URL — serve a public profile page. // Fedify only serves JSON-LD; browsers get 406 and fall through here. - router.get("/users/:identifier", (req, res) => { - res.redirect(self._publicationUrl || "/"); - }); + router.get("/users/:identifier", publicProfileController(self)); // Catch-all for federation paths that Fedify didn't handle (e.g. GET // on inbox). Without this, they fall through to Indiekit's auth diff --git a/lib/controllers/public-profile.js b/lib/controllers/public-profile.js new file mode 100644 index 0000000..1e4d786 --- /dev/null +++ b/lib/controllers/public-profile.js @@ -0,0 +1,87 @@ +/** + * Public profile controller — renders a standalone HTML profile page + * for browsers visiting the actor URL (e.g. /activitypub/users/rick). + * + * Fedify handles ActivityPub clients via content negotiation; browsers + * that send Accept: text/html fall through to this controller. + */ + +export function publicProfileController(plugin) { + return async (req, res, next) => { + const identifier = req.params.identifier; + + // Only serve our own actor; unknown handles fall through to 404 + if (identifier !== plugin.options.actor.handle) { + return next(); + } + + try { + const { application } = req.app.locals; + const collections = application.collections; + + const apProfile = collections.get("ap_profile"); + const apFollowers = collections.get("ap_followers"); + const apFollowing = collections.get("ap_following"); + const apFeatured = collections.get("ap_featured"); + const postsCollection = collections.get("posts"); + + // Parallel queries for all profile data + const [profile, followerCount, followingCount, postCount, featuredDocs, recentPosts] = + await Promise.all([ + apProfile ? apProfile.findOne({}) : null, + apFollowers ? apFollowers.countDocuments() : 0, + apFollowing ? apFollowing.countDocuments() : 0, + postsCollection ? postsCollection.countDocuments() : 0, + apFeatured + ? apFeatured.find().sort({ pinnedAt: -1 }).toArray() + : [], + postsCollection + ? postsCollection + .find() + .sort({ "properties.published": -1 }) + .limit(20) + .toArray() + : [], + ]); + + // Enrich pinned posts with title/type from posts collection + const pinned = []; + for (const doc of featuredDocs) { + if (!postsCollection) break; + const post = await postsCollection.findOne({ + "properties.url": doc.postUrl, + }); + if (post?.properties) { + pinned.push({ + url: doc.postUrl, + title: + post.properties.name || + post.properties.content?.text?.slice(0, 120) || + doc.postUrl, + type: post.properties["post-type"] || "note", + published: post.properties.published, + }); + } + } + + const domain = new URL(plugin._publicationUrl).hostname; + const handle = plugin.options.actor.handle; + + res.render("activitypub-public-profile", { + profile: profile || {}, + handle, + domain, + fullHandle: `@${handle}@${domain}`, + actorUrl: `${plugin._publicationUrl}activitypub/users/${handle}`, + siteUrl: plugin._publicationUrl, + followerCount, + followingCount, + postCount, + pinned, + recentPosts: recentPosts.map((p) => p.properties), + }); + } catch (error) { + next(error); + } + }; +} diff --git a/locales/en.json b/locales/en.json index d3307ec..310cc19 100644 --- a/locales/en.json +++ b/locales/en.json @@ -50,6 +50,18 @@ "authorizedFetchHint": "When enabled, only servers with valid HTTP Signatures can fetch your actor and collections. This improves privacy but may reduce compatibility with some clients.", "save": "Save profile", "saved": "Profile saved. Changes are now visible to the fediverse.", + "public": { + "followPrompt": "Follow me on the fediverse", + "copyHandle": "Copy handle", + "copied": "Copied!", + "pinnedPosts": "Pinned posts", + "recentPosts": "Recent posts", + "joinedDate": "Joined", + "posts": "Posts", + "followers": "Followers", + "following": "Following", + "viewOnSite": "View on site" + }, "remote": { "follow": "Follow", "unfollow": "Unfollow", diff --git a/package.json b/package.json index 25b7d1a..3e587a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "1.1.19", + "version": "1.1.20", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/activitypub-public-profile.njk b/views/activitypub-public-profile.njk new file mode 100644 index 0000000..4e6e7bd --- /dev/null +++ b/views/activitypub-public-profile.njk @@ -0,0 +1,592 @@ + + + + + + {{ profile.name or handle }} (@{{ handle }}@{{ domain }}) + + + + {% if profile.icon %} + + {% endif %} + + + + + + + + {# ---- Header image ---- #} + {% if profile.image %} +
+ +
+ {% else %} +
+ {% endif %} + +
+ {# ---- Avatar + identity ---- #} +
+
+ {% if profile.icon %} + {{ profile.name or handle }} + {% else %} +
{{ (profile.name or handle)[0] | upper }}
+ {% endif %} +
+

{{ profile.name or handle }}

+
{{ fullHandle }}
+
+ + {# ---- Bio ---- #} + {% if profile.summary %} +
{{ profile.summary | safe }}
+ {% endif %} + + {# ---- Profile fields (attachments) ---- #} + {% if profile.attachments and profile.attachments.length > 0 %} +
+ {% for field in profile.attachments %} +
+
{{ field.name }}
+
+ {% if field.value and (field.value.startsWith("http://") or field.value.startsWith("https://")) %} + {{ field.value | replace("https://", "") | replace("http://", "") }} + {% else %} + {{ field.value }} + {% endif %} +
+
+ {% endfor %} +
+ {% endif %} + + {# ---- Stats bar ---- #} +
+
+ {{ postCount }} + Posts +
+
+ {{ followingCount }} + Following +
+
+ {{ followerCount }} + Followers +
+ {% if profile.createdAt %} +
+ + Joined +
+ {% endif %} +
+ + {# ---- Follow prompt ---- #} +
+ + +
+ + {# ---- Pinned posts ---- #} + {% if pinned.length > 0 %} +

Pinned posts

+
+ {% for post in pinned %} + + +
{{ post.title }}
+
+ {% endfor %} +
+ {% endif %} + + {# ---- Recent posts ---- #} + {% if recentPosts.length > 0 %} +

Recent posts

+
+ {% for post in recentPosts %} + {% set postType = post["post-type"] or "note" %} + + + {% if post.name %} +
{{ post.name }}
+ {% endif %} + {% if post.content and post.content.text %} +
{{ post.content.text | truncate(150) }}
+ {% endif %} +
+ {% endfor %} +
+ {% endif %} + + {# ---- Empty state ---- #} + {% if pinned.length === 0 and recentPosts.length === 0 %} +

No posts yet.

+ {% endif %} + + {# ---- Footer ---- #} + +
+ + + +