@rmdes/indiekit-endpoint-activitypub 1.1.18 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +82 -10
- package/lib/batch-refollow.js +1 -1
- package/lib/controllers/compose.js +1 -1
- package/lib/controllers/interactions-boost.js +2 -2
- package/lib/controllers/interactions-like.js +2 -2
- package/lib/controllers/moderation.js +2 -2
- package/lib/controllers/post-detail.js +1 -1
- package/lib/controllers/public-profile.js +87 -0
- package/lib/controllers/resolve.js +1 -1
- package/lib/federation-setup.js +86 -17
- package/lib/inbox-listeners.js +1 -1
- package/lib/jf2-to-as2.js +1 -1
- package/lib/kv-store.js +21 -0
- package/lib/timeline-store.js +1 -1
- package/locales/en.json +12 -0
- package/package.json +4 -4
- package/views/activitypub-public-profile.njk +592 -0
package/index.js
CHANGED
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
featuredTagsRemoveController,
|
|
59
59
|
} from "./lib/controllers/featured-tags.js";
|
|
60
60
|
import { resolveController } from "./lib/controllers/resolve.js";
|
|
61
|
+
import { publicProfileController } from "./lib/controllers/public-profile.js";
|
|
61
62
|
import {
|
|
62
63
|
refollowPauseController,
|
|
63
64
|
refollowResumeController,
|
|
@@ -85,6 +86,8 @@ const defaults = {
|
|
|
85
86
|
logLevel: "warning",
|
|
86
87
|
timelineRetention: 1000,
|
|
87
88
|
notificationRetentionDays: 30,
|
|
89
|
+
debugDashboard: false,
|
|
90
|
+
debugPassword: "",
|
|
88
91
|
};
|
|
89
92
|
|
|
90
93
|
export default class ActivityPubEndpoint {
|
|
@@ -158,6 +161,10 @@ export default class ActivityPubEndpoint {
|
|
|
158
161
|
return self._fedifyMiddleware(req, res, next);
|
|
159
162
|
});
|
|
160
163
|
|
|
164
|
+
// HTML fallback for actor URL — serve a public profile page.
|
|
165
|
+
// Fedify only serves JSON-LD; browsers get 406 and fall through here.
|
|
166
|
+
router.get("/users/:identifier", publicProfileController(self));
|
|
167
|
+
|
|
161
168
|
// Catch-all for federation paths that Fedify didn't handle (e.g. GET
|
|
162
169
|
// on inbox). Without this, they fall through to Indiekit's auth
|
|
163
170
|
// middleware and redirect to the login page.
|
|
@@ -500,7 +507,7 @@ export default class ActivityPubEndpoint {
|
|
|
500
507
|
}
|
|
501
508
|
|
|
502
509
|
try {
|
|
503
|
-
const { Follow } = await import("@fedify/fedify");
|
|
510
|
+
const { Follow } = await import("@fedify/fedify/vocab");
|
|
504
511
|
const handle = this.options.actor.handle;
|
|
505
512
|
const ctx = this._federation.createContext(
|
|
506
513
|
new URL(this._publicationUrl),
|
|
@@ -602,7 +609,7 @@ export default class ActivityPubEndpoint {
|
|
|
602
609
|
}
|
|
603
610
|
|
|
604
611
|
try {
|
|
605
|
-
const { Follow, Undo } = await import("@fedify/fedify");
|
|
612
|
+
const { Follow, Undo } = await import("@fedify/fedify/vocab");
|
|
606
613
|
const handle = this.options.actor.handle;
|
|
607
614
|
const ctx = this._federation.createContext(
|
|
608
615
|
new URL(this._publicationUrl),
|
|
@@ -678,12 +685,16 @@ export default class ActivityPubEndpoint {
|
|
|
678
685
|
* Send an Update(Person) activity to all followers so remote servers
|
|
679
686
|
* re-fetch the actor object (picking up profile changes, new featured
|
|
680
687
|
* collections, attachments, etc.).
|
|
688
|
+
*
|
|
689
|
+
* Delivery is batched to avoid a thundering herd: hundreds of remote
|
|
690
|
+
* servers simultaneously re-fetching the actor, featured posts, and
|
|
691
|
+
* featured tags after receiving the Update all at once.
|
|
681
692
|
*/
|
|
682
693
|
async broadcastActorUpdate() {
|
|
683
694
|
if (!this._federation) return;
|
|
684
695
|
|
|
685
696
|
try {
|
|
686
|
-
const { Update } = await import("@fedify/fedify");
|
|
697
|
+
const { Update } = await import("@fedify/fedify/vocab");
|
|
687
698
|
const handle = this.options.actor.handle;
|
|
688
699
|
const ctx = this._federation.createContext(
|
|
689
700
|
new URL(this._publicationUrl),
|
|
@@ -709,21 +720,80 @@ export default class ActivityPubEndpoint {
|
|
|
709
720
|
object: actor,
|
|
710
721
|
});
|
|
711
722
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
{
|
|
723
|
+
// Fetch followers and deduplicate by shared inbox so each remote
|
|
724
|
+
// server only gets one delivery (same as preferSharedInbox but
|
|
725
|
+
// gives us control over batching).
|
|
726
|
+
const followers = await this._collections.ap_followers
|
|
727
|
+
.find({})
|
|
728
|
+
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
|
|
729
|
+
.toArray();
|
|
730
|
+
|
|
731
|
+
// Group by shared inbox (or direct inbox if none)
|
|
732
|
+
const inboxMap = new Map();
|
|
733
|
+
for (const f of followers) {
|
|
734
|
+
const key = f.sharedInbox || f.inbox;
|
|
735
|
+
if (key && !inboxMap.has(key)) {
|
|
736
|
+
inboxMap.set(key, f);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const uniqueRecipients = [...inboxMap.values()];
|
|
741
|
+
const BATCH_SIZE = 25;
|
|
742
|
+
const BATCH_DELAY_MS = 5000;
|
|
743
|
+
let delivered = 0;
|
|
744
|
+
let failed = 0;
|
|
745
|
+
|
|
746
|
+
console.info(
|
|
747
|
+
`[ActivityPub] Broadcasting Update(Person) to ${uniqueRecipients.length} ` +
|
|
748
|
+
`unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
|
|
717
749
|
);
|
|
718
750
|
|
|
719
|
-
|
|
751
|
+
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
|
|
752
|
+
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
|
|
753
|
+
|
|
754
|
+
// Build Fedify-compatible Recipient objects:
|
|
755
|
+
// extractInboxes() reads: recipient.id, recipient.inboxId,
|
|
756
|
+
// recipient.endpoints?.sharedInbox
|
|
757
|
+
const recipients = batch.map((f) => ({
|
|
758
|
+
id: new URL(f.actorUrl),
|
|
759
|
+
inboxId: new URL(f.inbox || f.sharedInbox),
|
|
760
|
+
endpoints: f.sharedInbox
|
|
761
|
+
? { sharedInbox: new URL(f.sharedInbox) }
|
|
762
|
+
: undefined,
|
|
763
|
+
}));
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
await ctx.sendActivity(
|
|
767
|
+
{ identifier: handle },
|
|
768
|
+
recipients,
|
|
769
|
+
update,
|
|
770
|
+
{ preferSharedInbox: true },
|
|
771
|
+
);
|
|
772
|
+
delivered += batch.length;
|
|
773
|
+
} catch (error) {
|
|
774
|
+
failed += batch.length;
|
|
775
|
+
console.warn(
|
|
776
|
+
`[ActivityPub] Batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Stagger batches so remote servers don't all re-fetch at once
|
|
781
|
+
if (i + BATCH_SIZE < uniqueRecipients.length) {
|
|
782
|
+
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
console.info(
|
|
787
|
+
`[ActivityPub] Update(Person) broadcast complete: ` +
|
|
788
|
+
`${delivered} delivered, ${failed} failed`,
|
|
789
|
+
);
|
|
720
790
|
|
|
721
791
|
await logActivity(this._collections.ap_activities, {
|
|
722
792
|
direction: "outbound",
|
|
723
793
|
type: "Update",
|
|
724
794
|
actorUrl: this._publicationUrl,
|
|
725
795
|
objectUrl: this._getActorUrl(),
|
|
726
|
-
summary:
|
|
796
|
+
summary: `Sent Update(Person) to ${delivered}/${uniqueRecipients.length} inboxes`,
|
|
727
797
|
}).catch(() => {});
|
|
728
798
|
} catch (error) {
|
|
729
799
|
console.error(
|
|
@@ -899,6 +969,8 @@ export default class ActivityPubEndpoint {
|
|
|
899
969
|
parallelWorkers: this.options.parallelWorkers,
|
|
900
970
|
actorType: this.options.actorType,
|
|
901
971
|
logLevel: this.options.logLevel,
|
|
972
|
+
debugDashboard: this.options.debugDashboard,
|
|
973
|
+
debugPassword: this.options.debugPassword,
|
|
902
974
|
});
|
|
903
975
|
|
|
904
976
|
this._federation = federation;
|
package/lib/batch-refollow.js
CHANGED
|
@@ -188,7 +188,7 @@ export function submitComposeController(mountPath, plugin) {
|
|
|
188
188
|
});
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
const { Create, Note } = await import("@fedify/fedify");
|
|
191
|
+
const { Create, Note } = await import("@fedify/fedify/vocab");
|
|
192
192
|
const handle = plugin.options.actor.handle;
|
|
193
193
|
const ctx = plugin._federation.createContext(
|
|
194
194
|
new URL(plugin._publicationUrl),
|
|
@@ -34,7 +34,7 @@ export function boostController(mountPath, plugin) {
|
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
const { Announce } = await import("@fedify/fedify");
|
|
37
|
+
const { Announce } = await import("@fedify/fedify/vocab");
|
|
38
38
|
const handle = plugin.options.actor.handle;
|
|
39
39
|
const ctx = plugin._federation.createContext(
|
|
40
40
|
new URL(plugin._publicationUrl),
|
|
@@ -168,7 +168,7 @@ export function unboostController(mountPath, plugin) {
|
|
|
168
168
|
});
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
const { Announce, Undo } = await import("@fedify/fedify");
|
|
171
|
+
const { Announce, Undo } = await import("@fedify/fedify/vocab");
|
|
172
172
|
const handle = plugin.options.actor.handle;
|
|
173
173
|
const ctx = plugin._federation.createContext(
|
|
174
174
|
new URL(plugin._publicationUrl),
|
|
@@ -36,7 +36,7 @@ export function likeController(mountPath, plugin) {
|
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const { Like } = await import("@fedify/fedify");
|
|
39
|
+
const { Like } = await import("@fedify/fedify/vocab");
|
|
40
40
|
const handle = plugin.options.actor.handle;
|
|
41
41
|
const ctx = plugin._federation.createContext(
|
|
42
42
|
new URL(plugin._publicationUrl),
|
|
@@ -191,7 +191,7 @@ export function unlikeController(mountPath, plugin) {
|
|
|
191
191
|
});
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
const { Like, Undo } = await import("@fedify/fedify");
|
|
194
|
+
const { Like, Undo } = await import("@fedify/fedify/vocab");
|
|
195
195
|
const handle = plugin.options.actor.handle;
|
|
196
196
|
const ctx = plugin._federation.createContext(
|
|
197
197
|
new URL(plugin._publicationUrl),
|
|
@@ -144,7 +144,7 @@ export function blockController(mountPath, plugin) {
|
|
|
144
144
|
// Send Block activity via federation
|
|
145
145
|
if (plugin._federation) {
|
|
146
146
|
try {
|
|
147
|
-
const { Block } = await import("@fedify/fedify");
|
|
147
|
+
const { Block } = await import("@fedify/fedify/vocab");
|
|
148
148
|
const handle = plugin.options.actor.handle;
|
|
149
149
|
const ctx = plugin._federation.createContext(
|
|
150
150
|
new URL(plugin._publicationUrl),
|
|
@@ -223,7 +223,7 @@ export function unblockController(mountPath, plugin) {
|
|
|
223
223
|
// Send Undo(Block) via federation
|
|
224
224
|
if (plugin._federation) {
|
|
225
225
|
try {
|
|
226
|
-
const { Block, Undo } = await import("@fedify/fedify");
|
|
226
|
+
const { Block, Undo } = await import("@fedify/fedify/vocab");
|
|
227
227
|
const handle = plugin.options.actor.handle;
|
|
228
228
|
const ctx = plugin._federation.createContext(
|
|
229
229
|
new URL(plugin._publicationUrl),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Post detail controller — view individual AP posts/notes/articles
|
|
2
|
-
import { Article, Note, Person, Service, Application } from "@fedify/fedify";
|
|
2
|
+
import { Article, Note, Person, Service, Application } from "@fedify/fedify/vocab";
|
|
3
3
|
import { getToken } from "../csrf.js";
|
|
4
4
|
import { extractObjectData } from "../timeline-store.js";
|
|
5
5
|
import { getCached, setCache } from "../lookup-cache.js";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public profile controller — renders a standalone HTML profile page
|
|
3
|
+
* for browsers visiting the actor URL (e.g. /activitypub/users/rick).
|
|
4
|
+
*
|
|
5
|
+
* Fedify handles ActivityPub clients via content negotiation; browsers
|
|
6
|
+
* that send Accept: text/html fall through to this controller.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function publicProfileController(plugin) {
|
|
10
|
+
return async (req, res, next) => {
|
|
11
|
+
const identifier = req.params.identifier;
|
|
12
|
+
|
|
13
|
+
// Only serve our own actor; unknown handles fall through to 404
|
|
14
|
+
if (identifier !== plugin.options.actor.handle) {
|
|
15
|
+
return next();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const { application } = req.app.locals;
|
|
20
|
+
const collections = application.collections;
|
|
21
|
+
|
|
22
|
+
const apProfile = collections.get("ap_profile");
|
|
23
|
+
const apFollowers = collections.get("ap_followers");
|
|
24
|
+
const apFollowing = collections.get("ap_following");
|
|
25
|
+
const apFeatured = collections.get("ap_featured");
|
|
26
|
+
const postsCollection = collections.get("posts");
|
|
27
|
+
|
|
28
|
+
// Parallel queries for all profile data
|
|
29
|
+
const [profile, followerCount, followingCount, postCount, featuredDocs, recentPosts] =
|
|
30
|
+
await Promise.all([
|
|
31
|
+
apProfile ? apProfile.findOne({}) : null,
|
|
32
|
+
apFollowers ? apFollowers.countDocuments() : 0,
|
|
33
|
+
apFollowing ? apFollowing.countDocuments() : 0,
|
|
34
|
+
postsCollection ? postsCollection.countDocuments() : 0,
|
|
35
|
+
apFeatured
|
|
36
|
+
? apFeatured.find().sort({ pinnedAt: -1 }).toArray()
|
|
37
|
+
: [],
|
|
38
|
+
postsCollection
|
|
39
|
+
? postsCollection
|
|
40
|
+
.find()
|
|
41
|
+
.sort({ "properties.published": -1 })
|
|
42
|
+
.limit(20)
|
|
43
|
+
.toArray()
|
|
44
|
+
: [],
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
// Enrich pinned posts with title/type from posts collection
|
|
48
|
+
const pinned = [];
|
|
49
|
+
for (const doc of featuredDocs) {
|
|
50
|
+
if (!postsCollection) break;
|
|
51
|
+
const post = await postsCollection.findOne({
|
|
52
|
+
"properties.url": doc.postUrl,
|
|
53
|
+
});
|
|
54
|
+
if (post?.properties) {
|
|
55
|
+
pinned.push({
|
|
56
|
+
url: doc.postUrl,
|
|
57
|
+
title:
|
|
58
|
+
post.properties.name ||
|
|
59
|
+
post.properties.content?.text?.slice(0, 120) ||
|
|
60
|
+
doc.postUrl,
|
|
61
|
+
type: post.properties["post-type"] || "note",
|
|
62
|
+
published: post.properties.published,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const domain = new URL(plugin._publicationUrl).hostname;
|
|
68
|
+
const handle = plugin.options.actor.handle;
|
|
69
|
+
|
|
70
|
+
res.render("activitypub-public-profile", {
|
|
71
|
+
profile: profile || {},
|
|
72
|
+
handle,
|
|
73
|
+
domain,
|
|
74
|
+
fullHandle: `@${handle}@${domain}`,
|
|
75
|
+
actorUrl: `${plugin._publicationUrl}activitypub/users/${handle}`,
|
|
76
|
+
siteUrl: plugin._publicationUrl,
|
|
77
|
+
followerCount,
|
|
78
|
+
followingCount,
|
|
79
|
+
postCount,
|
|
80
|
+
pinned,
|
|
81
|
+
recentPosts: recentPosts.map((p) => p.properties),
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
next(error);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
package/lib/federation-setup.js
CHANGED
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import { Temporal } from "@js-temporal/polyfill";
|
|
12
|
+
import {
|
|
13
|
+
createFederation,
|
|
14
|
+
InProcessMessageQueue,
|
|
15
|
+
ParallelMessageQueue,
|
|
16
|
+
} from "@fedify/fedify";
|
|
17
|
+
import {
|
|
18
|
+
exportJwk,
|
|
19
|
+
generateCryptoKeyPair,
|
|
20
|
+
importJwk,
|
|
21
|
+
} from "@fedify/fedify/sig";
|
|
12
22
|
import {
|
|
13
23
|
Application,
|
|
14
24
|
Article,
|
|
@@ -17,21 +27,15 @@ import {
|
|
|
17
27
|
Group,
|
|
18
28
|
Hashtag,
|
|
19
29
|
Image,
|
|
20
|
-
InProcessMessageQueue,
|
|
21
30
|
Note,
|
|
22
31
|
Organization,
|
|
23
|
-
ParallelMessageQueue,
|
|
24
32
|
Person,
|
|
25
33
|
PropertyValue,
|
|
26
34
|
Service,
|
|
27
|
-
|
|
28
|
-
exportJwk,
|
|
29
|
-
generateCryptoKeyPair,
|
|
30
|
-
importJwk,
|
|
31
|
-
importSpki,
|
|
32
|
-
} from "@fedify/fedify";
|
|
35
|
+
} from "@fedify/fedify/vocab";
|
|
33
36
|
import { configure, getConsoleSink } from "@logtape/logtape";
|
|
34
37
|
import { RedisMessageQueue } from "@fedify/redis";
|
|
38
|
+
import { createFederationDebugger } from "@fedify/debugger";
|
|
35
39
|
import Redis from "ioredis";
|
|
36
40
|
import { MongoKvStore } from "./kv-store.js";
|
|
37
41
|
import { registerInboxListeners } from "./inbox-listeners.js";
|
|
@@ -61,17 +65,21 @@ export function setupFederation(options) {
|
|
|
61
65
|
parallelWorkers = 5,
|
|
62
66
|
actorType = "Person",
|
|
63
67
|
logLevel = "warning",
|
|
68
|
+
debugDashboard = false,
|
|
69
|
+
debugPassword = "",
|
|
64
70
|
} = options;
|
|
65
71
|
|
|
66
72
|
// Map config string to Fedify actor class
|
|
67
73
|
const actorTypeMap = { Person, Service, Application, Organization, Group };
|
|
68
74
|
const ActorClass = actorTypeMap[actorType] || Person;
|
|
69
75
|
|
|
70
|
-
// Configure LogTape for Fedify delivery logging (once per process)
|
|
76
|
+
// Configure LogTape for Fedify delivery logging (once per process).
|
|
77
|
+
// When the debug dashboard is enabled, skip this — the debugger
|
|
78
|
+
// auto-configures LogTape with per-trace log collection + OpenTelemetry.
|
|
71
79
|
// Valid levels: "debug" | "info" | "warning" | "error" | "fatal"
|
|
72
80
|
const validLevels = ["debug", "info", "warning", "error", "fatal"];
|
|
73
81
|
const resolvedLevel = validLevels.includes(logLevel) ? logLevel : "warning";
|
|
74
|
-
if (!_logtapeConfigured) {
|
|
82
|
+
if (!debugDashboard && !_logtapeConfigured) {
|
|
75
83
|
_logtapeConfigured = true;
|
|
76
84
|
configure({
|
|
77
85
|
contextLocalStorage: new AsyncLocalStorage(),
|
|
@@ -186,7 +194,7 @@ export function setupFederation(options) {
|
|
|
186
194
|
|
|
187
195
|
if (rsaDoc?.publicKeyPem && rsaDoc?.privateKeyPem) {
|
|
188
196
|
try {
|
|
189
|
-
const publicKey = await
|
|
197
|
+
const publicKey = await importSpkiPem(rsaDoc.publicKeyPem);
|
|
190
198
|
const privateKey = await importPkcs8Pem(rsaDoc.privateKeyPem);
|
|
191
199
|
keyPairs.push({ publicKey, privateKey });
|
|
192
200
|
} catch {
|
|
@@ -284,13 +292,13 @@ export function setupFederation(options) {
|
|
|
284
292
|
setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl);
|
|
285
293
|
|
|
286
294
|
// --- NodeInfo ---
|
|
287
|
-
|
|
295
|
+
// Fedify 2.0: software.version is now a plain string (was SemVer object)
|
|
296
|
+
let softwareVersion = "1.0.0";
|
|
288
297
|
try {
|
|
289
298
|
const require = createRequire(import.meta.url);
|
|
290
299
|
const pkg = require("@indiekit/indiekit/package.json");
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
} catch { /* fallback to 1.0.0 */ }
|
|
300
|
+
if (pkg.version) softwareVersion = pkg.version;
|
|
301
|
+
} catch { /* fallback to "1.0.0" */ }
|
|
294
302
|
|
|
295
303
|
federation.setNodeInfoDispatcher("/nodeinfo/2.1", async () => {
|
|
296
304
|
const postsCount = collections.posts
|
|
@@ -311,15 +319,57 @@ export function setupFederation(options) {
|
|
|
311
319
|
};
|
|
312
320
|
});
|
|
313
321
|
|
|
322
|
+
// Handle permanent delivery failures (Fedify 2.0).
|
|
323
|
+
// Fires when a remote inbox returns 404/410 — the server is gone.
|
|
324
|
+
// Log it and let the admin see which followers are unreachable.
|
|
325
|
+
federation.setOutboxPermanentFailureHandler(async (_ctx, values) => {
|
|
326
|
+
const { inbox, error, actorIds } = values;
|
|
327
|
+
const inboxUrl = inbox?.href || String(inbox);
|
|
328
|
+
const actors = actorIds?.map((id) => id?.href || String(id)) || [];
|
|
329
|
+
console.warn(
|
|
330
|
+
`[ActivityPub] Permanent delivery failure to ${inboxUrl}: ${error?.message || "unknown"}` +
|
|
331
|
+
(actors.length ? ` (actors: ${actors.join(", ")})` : ""),
|
|
332
|
+
);
|
|
333
|
+
collections.ap_activities.insertOne({
|
|
334
|
+
direction: "outbound",
|
|
335
|
+
type: "DeliveryFailed",
|
|
336
|
+
actorUrl: publicationUrl,
|
|
337
|
+
objectUrl: inboxUrl,
|
|
338
|
+
summary: `Permanent delivery failure to ${inboxUrl}: ${error?.message || "unknown"}`,
|
|
339
|
+
affectedActors: actors,
|
|
340
|
+
receivedAt: new Date().toISOString(),
|
|
341
|
+
}).catch(() => {});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Wrap with debug dashboard if enabled. The debugger proxies the
|
|
345
|
+
// Federation object and intercepts requests at {mountPath}/__debug__/,
|
|
346
|
+
// serving a live dashboard showing traces, activities, signature
|
|
347
|
+
// verification, and correlated logs. It auto-configures OpenTelemetry
|
|
348
|
+
// tracing and LogTape per-trace log collection.
|
|
349
|
+
let activeFederation = federation;
|
|
350
|
+
if (debugDashboard) {
|
|
351
|
+
const debugOptions = {
|
|
352
|
+
path: `${mountPath}/__debug__`,
|
|
353
|
+
};
|
|
354
|
+
if (debugPassword) {
|
|
355
|
+
debugOptions.auth = { type: "password", password: debugPassword };
|
|
356
|
+
}
|
|
357
|
+
activeFederation = createFederationDebugger(federation, debugOptions);
|
|
358
|
+
console.info(
|
|
359
|
+
`[ActivityPub] Debug dashboard enabled at ${mountPath}/__debug__/` +
|
|
360
|
+
(debugPassword ? " (password-protected)" : " (WARNING: no password set)"),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
314
364
|
// Start the message queue for outbound activity delivery.
|
|
315
365
|
// Without this, ctx.sendActivity() enqueues delivery tasks but the
|
|
316
366
|
// InProcessMessageQueue never processes them — activities are never
|
|
317
367
|
// actually POSTed to follower inboxes.
|
|
318
|
-
|
|
368
|
+
activeFederation.startQueue().catch((error) => {
|
|
319
369
|
console.error("[ActivityPub] Failed to start delivery queue:", error.message);
|
|
320
370
|
});
|
|
321
371
|
|
|
322
|
-
return { federation };
|
|
372
|
+
return { federation: activeFederation };
|
|
323
373
|
}
|
|
324
374
|
|
|
325
375
|
// --- Collection setup helpers ---
|
|
@@ -695,6 +745,25 @@ export async function buildPersonActor(
|
|
|
695
745
|
return new ResolvedActorClass(personOptions);
|
|
696
746
|
}
|
|
697
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Import an SPKI PEM public key using Web Crypto API.
|
|
750
|
+
* Replaces Fedify 1.x's importSpki() which was removed in 2.0.
|
|
751
|
+
*/
|
|
752
|
+
async function importSpkiPem(pem) {
|
|
753
|
+
const lines = pem
|
|
754
|
+
.replace("-----BEGIN PUBLIC KEY-----", "")
|
|
755
|
+
.replace("-----END PUBLIC KEY-----", "")
|
|
756
|
+
.replace(/\s/g, "");
|
|
757
|
+
const der = Uint8Array.from(atob(lines), (c) => c.charCodeAt(0));
|
|
758
|
+
return crypto.subtle.importKey(
|
|
759
|
+
"spki",
|
|
760
|
+
der,
|
|
761
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
762
|
+
true,
|
|
763
|
+
["verify"],
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
698
767
|
/**
|
|
699
768
|
* Import a PKCS#8 PEM private key using Web Crypto API.
|
|
700
769
|
* Fedify's importPem only handles PKCS#1, but Node.js crypto generates PKCS#8.
|
package/lib/inbox-listeners.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
Remove,
|
|
22
22
|
Undo,
|
|
23
23
|
Update,
|
|
24
|
-
} from "@fedify/fedify";
|
|
24
|
+
} from "@fedify/fedify/vocab";
|
|
25
25
|
|
|
26
26
|
import { logActivity as logActivityShared } from "./activity-log.js";
|
|
27
27
|
import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
|
package/lib/jf2-to-as2.js
CHANGED
package/lib/kv-store.js
CHANGED
|
@@ -52,4 +52,25 @@ export class MongoKvStore {
|
|
|
52
52
|
async delete(key) {
|
|
53
53
|
await this.collection.deleteOne({ _id: this._serializeKey(key) });
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* List all entries whose key starts with the given prefix.
|
|
58
|
+
* Required by Fedify 2.0's KvStore interface.
|
|
59
|
+
*
|
|
60
|
+
* @param {string[]} [prefix=[]]
|
|
61
|
+
* @returns {AsyncIterable<{ key: string[], value: unknown }>}
|
|
62
|
+
*/
|
|
63
|
+
async *list(prefix = []) {
|
|
64
|
+
const prefixStr = this._serializeKey(prefix);
|
|
65
|
+
const filter = prefixStr
|
|
66
|
+
? { _id: { $regex: `^${prefixStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}` } }
|
|
67
|
+
: {};
|
|
68
|
+
const cursor = this.collection.find(filter);
|
|
69
|
+
for await (const doc of cursor) {
|
|
70
|
+
yield {
|
|
71
|
+
key: doc._id.split("/"),
|
|
72
|
+
value: doc.value,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
55
76
|
}
|
package/lib/timeline-store.js
CHANGED
package/locales/en.json
CHANGED
|
@@ -50,6 +50,18 @@
|
|
|
50
50
|
"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.",
|
|
51
51
|
"save": "Save profile",
|
|
52
52
|
"saved": "Profile saved. Changes are now visible to the fediverse.",
|
|
53
|
+
"public": {
|
|
54
|
+
"followPrompt": "Follow me on the fediverse",
|
|
55
|
+
"copyHandle": "Copy handle",
|
|
56
|
+
"copied": "Copied!",
|
|
57
|
+
"pinnedPosts": "Pinned posts",
|
|
58
|
+
"recentPosts": "Recent posts",
|
|
59
|
+
"joinedDate": "Joined",
|
|
60
|
+
"posts": "Posts",
|
|
61
|
+
"followers": "Followers",
|
|
62
|
+
"following": "Following",
|
|
63
|
+
"viewOnSite": "View on site"
|
|
64
|
+
},
|
|
53
65
|
"remote": {
|
|
54
66
|
"follow": "Follow",
|
|
55
67
|
"unfollow": "Unfollow",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@fedify/
|
|
41
|
-
"@fedify/fedify": "^
|
|
42
|
-
"@fedify/redis": "^
|
|
40
|
+
"@fedify/debugger": "^2.0.0",
|
|
41
|
+
"@fedify/fedify": "^2.0.0",
|
|
42
|
+
"@fedify/redis": "^2.0.0",
|
|
43
43
|
"@js-temporal/polyfill": "^0.5.0",
|
|
44
44
|
"express": "^5.0.0",
|
|
45
45
|
"ioredis": "^5.9.3",
|
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{ profile.name or handle }} (@{{ handle }}@{{ domain }})</title>
|
|
7
|
+
<meta name="description" content="{{ profile.summary | striptags | truncate(160) if profile.summary else fullHandle }}">
|
|
8
|
+
<meta property="og:title" content="{{ profile.name or handle }}">
|
|
9
|
+
<meta property="og:description" content="{{ profile.summary | striptags | truncate(160) if profile.summary else fullHandle }}">
|
|
10
|
+
{% if profile.icon %}
|
|
11
|
+
<meta property="og:image" content="{{ profile.icon }}">
|
|
12
|
+
{% endif %}
|
|
13
|
+
<meta property="og:type" content="profile">
|
|
14
|
+
<meta property="og:url" content="{{ actorUrl }}">
|
|
15
|
+
<link rel="me" href="{{ siteUrl }}">
|
|
16
|
+
<link rel="alternate" type="application/activity+json" href="{{ actorUrl }}">
|
|
17
|
+
<style>
|
|
18
|
+
/* ================================================================
|
|
19
|
+
CSS Custom Properties — light/dark mode
|
|
20
|
+
================================================================ */
|
|
21
|
+
:root {
|
|
22
|
+
--color-bg: #fff;
|
|
23
|
+
--color-surface: #f5f5f5;
|
|
24
|
+
--color-surface-raised: #fff;
|
|
25
|
+
--color-text: #1a1a1a;
|
|
26
|
+
--color-text-muted: #666;
|
|
27
|
+
--color-text-faint: #999;
|
|
28
|
+
--color-border: #e0e0e0;
|
|
29
|
+
--color-accent: #4f46e5;
|
|
30
|
+
--color-accent-text: #fff;
|
|
31
|
+
--color-purple: #7c3aed;
|
|
32
|
+
--color-green: #16a34a;
|
|
33
|
+
--color-yellow: #ca8a04;
|
|
34
|
+
--color-blue: #2563eb;
|
|
35
|
+
--radius-s: 6px;
|
|
36
|
+
--radius-m: 10px;
|
|
37
|
+
--radius-l: 16px;
|
|
38
|
+
--radius-full: 9999px;
|
|
39
|
+
--space-xs: 4px;
|
|
40
|
+
--space-s: 8px;
|
|
41
|
+
--space-m: 16px;
|
|
42
|
+
--space-l: 24px;
|
|
43
|
+
--space-xl: 32px;
|
|
44
|
+
--space-2xl: 48px;
|
|
45
|
+
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
46
|
+
--shadow-s: 0 1px 2px rgba(0,0,0,0.05);
|
|
47
|
+
--shadow-m: 0 2px 8px rgba(0,0,0,0.08);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@media (prefers-color-scheme: dark) {
|
|
51
|
+
:root {
|
|
52
|
+
--color-bg: #111;
|
|
53
|
+
--color-surface: #1a1a1a;
|
|
54
|
+
--color-surface-raised: #222;
|
|
55
|
+
--color-text: #e5e5e5;
|
|
56
|
+
--color-text-muted: #999;
|
|
57
|
+
--color-text-faint: #666;
|
|
58
|
+
--color-border: #333;
|
|
59
|
+
--color-accent: #818cf8;
|
|
60
|
+
--color-accent-text: #111;
|
|
61
|
+
--color-purple: #a78bfa;
|
|
62
|
+
--color-green: #4ade80;
|
|
63
|
+
--color-yellow: #facc15;
|
|
64
|
+
--color-blue: #60a5fa;
|
|
65
|
+
--shadow-s: 0 1px 2px rgba(0,0,0,0.2);
|
|
66
|
+
--shadow-m: 0 2px 8px rgba(0,0,0,0.3);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ================================================================
|
|
71
|
+
Base
|
|
72
|
+
================================================================ */
|
|
73
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
74
|
+
|
|
75
|
+
body {
|
|
76
|
+
background: var(--color-bg);
|
|
77
|
+
color: var(--color-text);
|
|
78
|
+
font-family: var(--font-sans);
|
|
79
|
+
line-height: 1.5;
|
|
80
|
+
margin: 0;
|
|
81
|
+
-webkit-font-smoothing: antialiased;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
a { color: var(--color-accent); text-decoration: none; }
|
|
85
|
+
a:hover { text-decoration: underline; }
|
|
86
|
+
|
|
87
|
+
.ap-pub {
|
|
88
|
+
margin: 0 auto;
|
|
89
|
+
max-width: 640px;
|
|
90
|
+
padding: 0 var(--space-m);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ================================================================
|
|
94
|
+
Header image
|
|
95
|
+
================================================================ */
|
|
96
|
+
.ap-pub__header {
|
|
97
|
+
background: var(--color-surface);
|
|
98
|
+
height: 220px;
|
|
99
|
+
overflow: hidden;
|
|
100
|
+
position: relative;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.ap-pub__header img {
|
|
104
|
+
display: block;
|
|
105
|
+
height: 100%;
|
|
106
|
+
object-fit: cover;
|
|
107
|
+
width: 100%;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.ap-pub__header--empty {
|
|
111
|
+
background: linear-gradient(135deg, var(--color-accent), var(--color-purple));
|
|
112
|
+
height: 160px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ================================================================
|
|
116
|
+
Identity — avatar, name, handle
|
|
117
|
+
================================================================ */
|
|
118
|
+
.ap-pub__identity {
|
|
119
|
+
padding: 0 var(--space-m);
|
|
120
|
+
position: relative;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.ap-pub__avatar-wrap {
|
|
124
|
+
margin-top: -48px;
|
|
125
|
+
position: relative;
|
|
126
|
+
width: 96px;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.ap-pub__avatar {
|
|
130
|
+
background: var(--color-surface);
|
|
131
|
+
border: 4px solid var(--color-bg);
|
|
132
|
+
border-radius: var(--radius-full);
|
|
133
|
+
display: block;
|
|
134
|
+
height: 96px;
|
|
135
|
+
object-fit: cover;
|
|
136
|
+
width: 96px;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.ap-pub__avatar--placeholder {
|
|
140
|
+
align-items: center;
|
|
141
|
+
background: var(--color-surface);
|
|
142
|
+
border: 4px solid var(--color-bg);
|
|
143
|
+
border-radius: var(--radius-full);
|
|
144
|
+
color: var(--color-text-muted);
|
|
145
|
+
display: flex;
|
|
146
|
+
font-size: 2.5em;
|
|
147
|
+
font-weight: 700;
|
|
148
|
+
height: 96px;
|
|
149
|
+
justify-content: center;
|
|
150
|
+
width: 96px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.ap-pub__name {
|
|
154
|
+
font-size: 1.5em;
|
|
155
|
+
font-weight: 700;
|
|
156
|
+
line-height: 1.2;
|
|
157
|
+
margin: var(--space-s) 0 var(--space-xs);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.ap-pub__handle {
|
|
161
|
+
color: var(--color-text-muted);
|
|
162
|
+
font-size: 0.95em;
|
|
163
|
+
margin-bottom: var(--space-m);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* ================================================================
|
|
167
|
+
Bio
|
|
168
|
+
================================================================ */
|
|
169
|
+
.ap-pub__bio {
|
|
170
|
+
line-height: 1.6;
|
|
171
|
+
margin-bottom: var(--space-l);
|
|
172
|
+
padding: 0 var(--space-m);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.ap-pub__bio a { color: var(--color-accent); }
|
|
176
|
+
|
|
177
|
+
.ap-pub__bio p { margin: 0 0 var(--space-s); }
|
|
178
|
+
.ap-pub__bio p:last-child { margin-bottom: 0; }
|
|
179
|
+
|
|
180
|
+
/* ================================================================
|
|
181
|
+
Profile fields
|
|
182
|
+
================================================================ */
|
|
183
|
+
.ap-pub__fields {
|
|
184
|
+
border: 1px solid var(--color-border);
|
|
185
|
+
border-radius: var(--radius-m);
|
|
186
|
+
margin: 0 var(--space-m) var(--space-l);
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.ap-pub__field {
|
|
191
|
+
border-bottom: 1px solid var(--color-border);
|
|
192
|
+
display: grid;
|
|
193
|
+
grid-template-columns: 140px 1fr;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.ap-pub__field:last-child { border-bottom: 0; }
|
|
197
|
+
|
|
198
|
+
.ap-pub__field-name {
|
|
199
|
+
background: var(--color-surface);
|
|
200
|
+
color: var(--color-text-muted);
|
|
201
|
+
font-size: 0.85em;
|
|
202
|
+
font-weight: 600;
|
|
203
|
+
padding: var(--space-s) var(--space-m);
|
|
204
|
+
text-transform: uppercase;
|
|
205
|
+
letter-spacing: 0.03em;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.ap-pub__field-value {
|
|
209
|
+
font-size: 0.95em;
|
|
210
|
+
overflow: hidden;
|
|
211
|
+
padding: var(--space-s) var(--space-m);
|
|
212
|
+
text-overflow: ellipsis;
|
|
213
|
+
white-space: nowrap;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.ap-pub__field-value a { color: var(--color-accent); }
|
|
217
|
+
|
|
218
|
+
/* ================================================================
|
|
219
|
+
Stats bar
|
|
220
|
+
================================================================ */
|
|
221
|
+
.ap-pub__stats {
|
|
222
|
+
border-bottom: 1px solid var(--color-border);
|
|
223
|
+
border-top: 1px solid var(--color-border);
|
|
224
|
+
display: flex;
|
|
225
|
+
margin: 0 var(--space-m) var(--space-l);
|
|
226
|
+
padding: var(--space-m) 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.ap-pub__stat {
|
|
230
|
+
flex: 1;
|
|
231
|
+
text-align: center;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.ap-pub__stat-value {
|
|
235
|
+
display: block;
|
|
236
|
+
font-size: 1.2em;
|
|
237
|
+
font-weight: 700;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.ap-pub__stat-label {
|
|
241
|
+
color: var(--color-text-muted);
|
|
242
|
+
display: block;
|
|
243
|
+
font-size: 0.8em;
|
|
244
|
+
text-transform: uppercase;
|
|
245
|
+
letter-spacing: 0.05em;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ================================================================
|
|
249
|
+
Follow prompt
|
|
250
|
+
================================================================ */
|
|
251
|
+
.ap-pub__follow {
|
|
252
|
+
background: var(--color-surface);
|
|
253
|
+
border-radius: var(--radius-m);
|
|
254
|
+
margin: 0 var(--space-m) var(--space-l);
|
|
255
|
+
padding: var(--space-l);
|
|
256
|
+
text-align: center;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.ap-pub__follow-title {
|
|
260
|
+
font-size: 1em;
|
|
261
|
+
font-weight: 600;
|
|
262
|
+
margin: 0 0 var(--space-s);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.ap-pub__follow-handle {
|
|
266
|
+
background: var(--color-surface-raised);
|
|
267
|
+
border: 1px solid var(--color-border);
|
|
268
|
+
border-radius: var(--radius-s);
|
|
269
|
+
display: inline-flex;
|
|
270
|
+
align-items: center;
|
|
271
|
+
gap: var(--space-s);
|
|
272
|
+
padding: var(--space-s) var(--space-m);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.ap-pub__follow-text {
|
|
276
|
+
color: var(--color-text);
|
|
277
|
+
font-family: monospace;
|
|
278
|
+
font-size: 0.95em;
|
|
279
|
+
user-select: all;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.ap-pub__copy-btn {
|
|
283
|
+
background: var(--color-accent);
|
|
284
|
+
border: 0;
|
|
285
|
+
border-radius: var(--radius-s);
|
|
286
|
+
color: var(--color-accent-text);
|
|
287
|
+
cursor: pointer;
|
|
288
|
+
font-size: 0.8em;
|
|
289
|
+
font-weight: 600;
|
|
290
|
+
padding: var(--space-xs) var(--space-s);
|
|
291
|
+
transition: opacity 0.2s;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.ap-pub__copy-btn:hover { opacity: 0.85; }
|
|
295
|
+
|
|
296
|
+
/* ================================================================
|
|
297
|
+
Section headings
|
|
298
|
+
================================================================ */
|
|
299
|
+
.ap-pub__section-title {
|
|
300
|
+
border-bottom: 1px solid var(--color-border);
|
|
301
|
+
font-size: 1.1em;
|
|
302
|
+
font-weight: 600;
|
|
303
|
+
margin: 0 var(--space-m) var(--space-m);
|
|
304
|
+
padding-bottom: var(--space-s);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* ================================================================
|
|
308
|
+
Post cards (pinned + recent)
|
|
309
|
+
================================================================ */
|
|
310
|
+
.ap-pub__posts {
|
|
311
|
+
display: flex;
|
|
312
|
+
flex-direction: column;
|
|
313
|
+
gap: var(--space-s);
|
|
314
|
+
margin: 0 var(--space-m) var(--space-l);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.ap-pub__post {
|
|
318
|
+
background: var(--color-surface-raised);
|
|
319
|
+
border: 1px solid var(--color-border);
|
|
320
|
+
border-left: 3px solid var(--color-border);
|
|
321
|
+
border-radius: var(--radius-s);
|
|
322
|
+
display: block;
|
|
323
|
+
padding: var(--space-m);
|
|
324
|
+
text-decoration: none;
|
|
325
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.ap-pub__post:hover {
|
|
329
|
+
border-color: var(--color-accent);
|
|
330
|
+
box-shadow: var(--shadow-s);
|
|
331
|
+
text-decoration: none;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.ap-pub__post--article { border-left-color: var(--color-green); }
|
|
335
|
+
.ap-pub__post--note { border-left-color: var(--color-purple); }
|
|
336
|
+
.ap-pub__post--photo { border-left-color: var(--color-yellow); }
|
|
337
|
+
.ap-pub__post--bookmark { border-left-color: var(--color-blue); }
|
|
338
|
+
|
|
339
|
+
.ap-pub__post-meta {
|
|
340
|
+
align-items: center;
|
|
341
|
+
color: var(--color-text-muted);
|
|
342
|
+
display: flex;
|
|
343
|
+
font-size: 0.8em;
|
|
344
|
+
gap: var(--space-s);
|
|
345
|
+
margin-bottom: var(--space-xs);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.ap-pub__post-type {
|
|
349
|
+
background: var(--color-surface);
|
|
350
|
+
border-radius: var(--radius-s);
|
|
351
|
+
font-size: 0.85em;
|
|
352
|
+
font-weight: 600;
|
|
353
|
+
padding: 1px 6px;
|
|
354
|
+
text-transform: capitalize;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.ap-pub__post-title {
|
|
358
|
+
color: var(--color-text);
|
|
359
|
+
font-weight: 600;
|
|
360
|
+
line-height: 1.4;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.ap-pub__post-excerpt {
|
|
364
|
+
color: var(--color-text-muted);
|
|
365
|
+
font-size: 0.9em;
|
|
366
|
+
line-height: 1.5;
|
|
367
|
+
margin-top: var(--space-xs);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.ap-pub__pinned-label {
|
|
371
|
+
color: var(--color-yellow);
|
|
372
|
+
font-size: 0.75em;
|
|
373
|
+
font-weight: 600;
|
|
374
|
+
text-transform: uppercase;
|
|
375
|
+
letter-spacing: 0.05em;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/* ================================================================
|
|
379
|
+
Footer
|
|
380
|
+
================================================================ */
|
|
381
|
+
.ap-pub__footer {
|
|
382
|
+
border-top: 1px solid var(--color-border);
|
|
383
|
+
color: var(--color-text-faint);
|
|
384
|
+
font-size: 0.85em;
|
|
385
|
+
margin: var(--space-xl) var(--space-m) 0;
|
|
386
|
+
padding: var(--space-l) 0;
|
|
387
|
+
text-align: center;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.ap-pub__footer a { color: var(--color-text-muted); }
|
|
391
|
+
|
|
392
|
+
/* ================================================================
|
|
393
|
+
Empty state
|
|
394
|
+
================================================================ */
|
|
395
|
+
.ap-pub__empty {
|
|
396
|
+
color: var(--color-text-muted);
|
|
397
|
+
font-style: italic;
|
|
398
|
+
padding: var(--space-m) 0;
|
|
399
|
+
text-align: center;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/* ================================================================
|
|
403
|
+
Responsive
|
|
404
|
+
================================================================ */
|
|
405
|
+
@media (max-width: 480px) {
|
|
406
|
+
.ap-pub__header { height: 160px; }
|
|
407
|
+
.ap-pub__header--empty { height: 120px; }
|
|
408
|
+
|
|
409
|
+
.ap-pub__field {
|
|
410
|
+
grid-template-columns: 1fr;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.ap-pub__field-name {
|
|
414
|
+
border-bottom: 0;
|
|
415
|
+
padding-bottom: var(--space-xs);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.ap-pub__field-value {
|
|
419
|
+
padding-top: 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.ap-pub__stats { flex-wrap: wrap; }
|
|
423
|
+
|
|
424
|
+
.ap-pub__stat {
|
|
425
|
+
flex: 0 0 50%;
|
|
426
|
+
margin-bottom: var(--space-s);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.ap-pub__follow-handle {
|
|
430
|
+
flex-direction: column;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
</style>
|
|
434
|
+
</head>
|
|
435
|
+
<body>
|
|
436
|
+
{# ---- Header image ---- #}
|
|
437
|
+
{% if profile.image %}
|
|
438
|
+
<div class="ap-pub__header">
|
|
439
|
+
<img src="{{ profile.image }}" alt="">
|
|
440
|
+
</div>
|
|
441
|
+
{% else %}
|
|
442
|
+
<div class="ap-pub__header ap-pub__header--empty"></div>
|
|
443
|
+
{% endif %}
|
|
444
|
+
|
|
445
|
+
<div class="ap-pub">
|
|
446
|
+
{# ---- Avatar + identity ---- #}
|
|
447
|
+
<div class="ap-pub__identity">
|
|
448
|
+
<div class="ap-pub__avatar-wrap">
|
|
449
|
+
{% if profile.icon %}
|
|
450
|
+
<img src="{{ profile.icon }}" alt="{{ profile.name or handle }}" class="ap-pub__avatar">
|
|
451
|
+
{% else %}
|
|
452
|
+
<div class="ap-pub__avatar--placeholder">{{ (profile.name or handle)[0] | upper }}</div>
|
|
453
|
+
{% endif %}
|
|
454
|
+
</div>
|
|
455
|
+
<h1 class="ap-pub__name">{{ profile.name or handle }}</h1>
|
|
456
|
+
<div class="ap-pub__handle">{{ fullHandle }}</div>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
{# ---- Bio ---- #}
|
|
460
|
+
{% if profile.summary %}
|
|
461
|
+
<div class="ap-pub__bio">{{ profile.summary | safe }}</div>
|
|
462
|
+
{% endif %}
|
|
463
|
+
|
|
464
|
+
{# ---- Profile fields (attachments) ---- #}
|
|
465
|
+
{% if profile.attachments and profile.attachments.length > 0 %}
|
|
466
|
+
<dl class="ap-pub__fields">
|
|
467
|
+
{% for field in profile.attachments %}
|
|
468
|
+
<div class="ap-pub__field">
|
|
469
|
+
<dt class="ap-pub__field-name">{{ field.name }}</dt>
|
|
470
|
+
<dd class="ap-pub__field-value">
|
|
471
|
+
{% if field.value and (field.value.startsWith("http://") or field.value.startsWith("https://")) %}
|
|
472
|
+
<a href="{{ field.value }}" rel="noopener nofollow" target="_blank">{{ field.value | replace("https://", "") | replace("http://", "") }}</a>
|
|
473
|
+
{% else %}
|
|
474
|
+
{{ field.value }}
|
|
475
|
+
{% endif %}
|
|
476
|
+
</dd>
|
|
477
|
+
</div>
|
|
478
|
+
{% endfor %}
|
|
479
|
+
</dl>
|
|
480
|
+
{% endif %}
|
|
481
|
+
|
|
482
|
+
{# ---- Stats bar ---- #}
|
|
483
|
+
<div class="ap-pub__stats">
|
|
484
|
+
<div class="ap-pub__stat">
|
|
485
|
+
<span class="ap-pub__stat-value">{{ postCount }}</span>
|
|
486
|
+
<span class="ap-pub__stat-label">Posts</span>
|
|
487
|
+
</div>
|
|
488
|
+
<div class="ap-pub__stat">
|
|
489
|
+
<span class="ap-pub__stat-value">{{ followingCount }}</span>
|
|
490
|
+
<span class="ap-pub__stat-label">Following</span>
|
|
491
|
+
</div>
|
|
492
|
+
<div class="ap-pub__stat">
|
|
493
|
+
<span class="ap-pub__stat-value">{{ followerCount }}</span>
|
|
494
|
+
<span class="ap-pub__stat-label">Followers</span>
|
|
495
|
+
</div>
|
|
496
|
+
{% if profile.createdAt %}
|
|
497
|
+
<div class="ap-pub__stat">
|
|
498
|
+
<span class="ap-pub__stat-value" id="joined-date">—</span>
|
|
499
|
+
<span class="ap-pub__stat-label">Joined</span>
|
|
500
|
+
</div>
|
|
501
|
+
{% endif %}
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
{# ---- Follow prompt ---- #}
|
|
505
|
+
<div class="ap-pub__follow">
|
|
506
|
+
<p class="ap-pub__follow-title">Follow me on the fediverse</p>
|
|
507
|
+
<div class="ap-pub__follow-handle">
|
|
508
|
+
<span class="ap-pub__follow-text" id="fedi-handle">{{ fullHandle }}</span>
|
|
509
|
+
<button class="ap-pub__copy-btn" id="copy-btn" type="button">Copy handle</button>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
|
|
513
|
+
{# ---- Pinned posts ---- #}
|
|
514
|
+
{% if pinned.length > 0 %}
|
|
515
|
+
<h2 class="ap-pub__section-title">Pinned posts</h2>
|
|
516
|
+
<div class="ap-pub__posts">
|
|
517
|
+
{% for post in pinned %}
|
|
518
|
+
<a href="{{ post.url }}" class="ap-pub__post ap-pub__post--{{ post.type }}">
|
|
519
|
+
<div class="ap-pub__post-meta">
|
|
520
|
+
<span class="ap-pub__pinned-label">Pinned</span>
|
|
521
|
+
<span class="ap-pub__post-type">{{ post.type }}</span>
|
|
522
|
+
{% if post.published %}
|
|
523
|
+
<time datetime="{{ post.published }}">{{ post.published | truncate(10, true, "") }}</time>
|
|
524
|
+
{% endif %}
|
|
525
|
+
</div>
|
|
526
|
+
<div class="ap-pub__post-title">{{ post.title }}</div>
|
|
527
|
+
</a>
|
|
528
|
+
{% endfor %}
|
|
529
|
+
</div>
|
|
530
|
+
{% endif %}
|
|
531
|
+
|
|
532
|
+
{# ---- Recent posts ---- #}
|
|
533
|
+
{% if recentPosts.length > 0 %}
|
|
534
|
+
<h2 class="ap-pub__section-title">Recent posts</h2>
|
|
535
|
+
<div class="ap-pub__posts">
|
|
536
|
+
{% for post in recentPosts %}
|
|
537
|
+
{% set postType = post["post-type"] or "note" %}
|
|
538
|
+
<a href="{{ post.url }}" class="ap-pub__post ap-pub__post--{{ postType }}">
|
|
539
|
+
<div class="ap-pub__post-meta">
|
|
540
|
+
<span class="ap-pub__post-type">{{ postType }}</span>
|
|
541
|
+
{% if post.published %}
|
|
542
|
+
<time datetime="{{ post.published }}">{{ post.published | truncate(10, true, "") }}</time>
|
|
543
|
+
{% endif %}
|
|
544
|
+
</div>
|
|
545
|
+
{% if post.name %}
|
|
546
|
+
<div class="ap-pub__post-title">{{ post.name }}</div>
|
|
547
|
+
{% endif %}
|
|
548
|
+
{% if post.content and post.content.text %}
|
|
549
|
+
<div class="ap-pub__post-excerpt">{{ post.content.text | truncate(150) }}</div>
|
|
550
|
+
{% endif %}
|
|
551
|
+
</a>
|
|
552
|
+
{% endfor %}
|
|
553
|
+
</div>
|
|
554
|
+
{% endif %}
|
|
555
|
+
|
|
556
|
+
{# ---- Empty state ---- #}
|
|
557
|
+
{% if pinned.length === 0 and recentPosts.length === 0 %}
|
|
558
|
+
<p class="ap-pub__empty">No posts yet.</p>
|
|
559
|
+
{% endif %}
|
|
560
|
+
|
|
561
|
+
{# ---- Footer ---- #}
|
|
562
|
+
<footer class="ap-pub__footer">
|
|
563
|
+
<a href="{{ siteUrl }}">{{ domain }}</a>
|
|
564
|
+
</footer>
|
|
565
|
+
</div>
|
|
566
|
+
|
|
567
|
+
<script>
|
|
568
|
+
// Copy handle to clipboard
|
|
569
|
+
document.getElementById("copy-btn").addEventListener("click", function() {
|
|
570
|
+
var handle = document.getElementById("fedi-handle").textContent;
|
|
571
|
+
navigator.clipboard.writeText(handle).then(function() {
|
|
572
|
+
var btn = document.getElementById("copy-btn");
|
|
573
|
+
btn.textContent = "Copied!";
|
|
574
|
+
setTimeout(function() { btn.textContent = "Copy handle"; }, 2000);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Format joined date
|
|
579
|
+
{% if profile.createdAt %}
|
|
580
|
+
(function() {
|
|
581
|
+
var el = document.getElementById("joined-date");
|
|
582
|
+
if (el) {
|
|
583
|
+
try {
|
|
584
|
+
var d = new Date("{{ profile.createdAt }}");
|
|
585
|
+
el.textContent = d.toLocaleDateString(undefined, { month: "short", year: "numeric" });
|
|
586
|
+
} catch(e) { el.textContent = "—"; }
|
|
587
|
+
}
|
|
588
|
+
})();
|
|
589
|
+
{% endif %}
|
|
590
|
+
</script>
|
|
591
|
+
</body>
|
|
592
|
+
</html>
|