Compare commits
No commits in common. "51ff8ae5eae26b1f76f757436876acb32fc533a6" and "298fa4f42d7f94591ef0714a85f6394889d078d1" have entirely different histories.
51ff8ae5ea
...
298fa4f42d
|
@ -1,5 +0,0 @@
|
||||||
README.md
|
|
||||||
db.sqlite*
|
|
||||||
_data
|
|
||||||
.vscode
|
|
||||||
node_modules
|
|
95
.eleventy.js
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
const ACTOR = require("./actor").default
|
||||||
|
|
||||||
|
module.exports = function(eleventyConfig) {
|
||||||
|
// I'm .gitignoring my content for now, so 11ty should not ignore that
|
||||||
|
eleventyConfig.setUseGitIgnore(false)
|
||||||
|
|
||||||
|
// Filters are in a separate function to try and keep this config less cluttered
|
||||||
|
addFilters(eleventyConfig)
|
||||||
|
// same with shortcodes
|
||||||
|
addShortcodes(eleventyConfig)
|
||||||
|
// and collections
|
||||||
|
addCollections(eleventyConfig)
|
||||||
|
|
||||||
|
// add the actor data, accessible globally
|
||||||
|
eleventyConfig.addGlobalData("ACTOR", ACTOR);
|
||||||
|
|
||||||
|
// TODO: assets?
|
||||||
|
// files to passthrough copy
|
||||||
|
eleventyConfig.addPassthroughCopy({"img":"assets/img"})
|
||||||
|
eleventyConfig.addPassthroughCopy({"js":"assets/js"})
|
||||||
|
eleventyConfig.addPassthroughCopy("css")
|
||||||
|
eleventyConfig.addPassthroughCopy("CNAME")
|
||||||
|
|
||||||
|
// plugins
|
||||||
|
eleventyConfig.addPlugin(require("@11ty/eleventy-plugin-rss"))
|
||||||
|
const { EleventyHtmlBasePlugin } = require("@11ty/eleventy")
|
||||||
|
eleventyConfig.addPlugin(EleventyHtmlBasePlugin)
|
||||||
|
|
||||||
|
// Return your Object options:
|
||||||
|
return {
|
||||||
|
dir: {
|
||||||
|
input: "_content",
|
||||||
|
output: "_site"
|
||||||
|
},
|
||||||
|
htmlTemplateEngine: "njk",
|
||||||
|
templateFormats: ["md","html","njk"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCollections(eleventyConfig) {
|
||||||
|
eleventyConfig.addCollection("feed", function(collectionApi) {
|
||||||
|
return collectionApi.getAllSorted().reverse().filter(item => {
|
||||||
|
if(!item.data.published) return false
|
||||||
|
return item.filePathStem.startsWith('/posts/')
|
||||||
|
}).map(item => {
|
||||||
|
item.data.author = ACTOR
|
||||||
|
return item
|
||||||
|
}).sort((a, b) => new Date(b.published).getTime() - new Date(a.published).getTime())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addShortcodes(eleventyConfig) {
|
||||||
|
eleventyConfig.addNunjucksShortcode("getVar", function(varString){ return this.ctx[varString] })
|
||||||
|
eleventyConfig.addShortcode('renderlayoutblock', function(name){ return (this.page.layoutblock || {})[name] || '' })
|
||||||
|
eleventyConfig.addPairedShortcode('layoutblock', function(content, name) {
|
||||||
|
if (!this.page.layoutblock) this.page.layoutblock = {}
|
||||||
|
this.page.layoutblock[name] = content
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFilters(eleventyConfig) {
|
||||||
|
eleventyConfig.addFilter("formatDate", formatDateFilter)
|
||||||
|
eleventyConfig.addFilter("dateISOString", dateISOStringFilter)
|
||||||
|
eleventyConfig.addFilter("dateObj", (value) => new Date(value))
|
||||||
|
eleventyConfig.addFilter("log", (value) => { console.log(`[11ty] 📄LOG: `, value); return value })
|
||||||
|
eleventyConfig.addFilter("concat", (value, other) => value + '' + other)
|
||||||
|
eleventyConfig.addNunjucksAsyncFilter("await", (promise) => promise.then(res => callback(null, res)).catch(err => callback(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// default date formatting
|
||||||
|
function formatDateFilter(value) {
|
||||||
|
try{
|
||||||
|
const date = new Date(value)
|
||||||
|
if(date) return date.toISOString().replace('T', ' ').slice(0, -5)
|
||||||
|
else throw 'Unrecognized data format'
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
console.error(`Could not convert "${value}"`, e)
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dates as iso string
|
||||||
|
function dateISOStringFilter(value) {
|
||||||
|
try{
|
||||||
|
const date = new Date(value)
|
||||||
|
if(date) return date.toISOString()
|
||||||
|
else throw 'Unrecognized data format'
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
console.error(`Could not convert "${value}"`, e)
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
2
.gitignore
vendored
|
@ -173,5 +173,3 @@ _content/_data/_inbox
|
||||||
_content/_data/_outbox
|
_content/_data/_outbox
|
||||||
_content/posts
|
_content/posts
|
||||||
_site
|
_site
|
||||||
_data
|
|
||||||
db.sqlite*
|
|
||||||
|
|
38
.vscode/tasks.json
vendored
|
@ -1,38 +0,0 @@
|
||||||
{
|
|
||||||
"version": "2.0.0",
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"type": "shell",
|
|
||||||
"command": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000",
|
|
||||||
"windows":{
|
|
||||||
"command": "C:\\Users\\death\\AppData\\Local\\Microsoft\\WinGet\\Links\\ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000"
|
|
||||||
},
|
|
||||||
"label": "ngrok tunnel",
|
|
||||||
"detail": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000",
|
|
||||||
"presentation": {
|
|
||||||
"reveal": "always",
|
|
||||||
"panel": "dedicated"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "shell",
|
|
||||||
"command": "bun run --watch src/index.ts",
|
|
||||||
"label": "bun run",
|
|
||||||
"detail": "bun run --watch src/index.ts",
|
|
||||||
"presentation": {
|
|
||||||
"reveal": "always",
|
|
||||||
"panel": "dedicated"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "shell",
|
|
||||||
"command": "docker compose up",
|
|
||||||
"label": "docker compose up",
|
|
||||||
"detail": "docker compose up",
|
|
||||||
"presentation": {
|
|
||||||
"reveal": "always",
|
|
||||||
"panel": "dedicated"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
1
CNAME
Normal file
|
@ -0,0 +1 @@
|
||||||
|
death.id.au
|
11
Dockerfile
|
@ -1,11 +0,0 @@
|
||||||
FROM oven/bun:latest
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY ./package.json /app
|
|
||||||
COPY ./bun.lockb /app
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
ADD . /app
|
|
||||||
|
|
||||||
CMD [ "bun", "run", "/app/src/index.ts"]
|
|
0
_content/_data/_inbox/.gitkeep
Normal file
0
_content/_data/_outbox/.gitkeep
Normal file
1
_content/_data/disliked.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
[]
|
1
_content/_data/followers.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
[]
|
1
_content/_data/following.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
[]
|
1
_content/_data/layout.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
module.exports = "layout-default.njk"
|
1
_content/_data/liked.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
[]
|
1
_content/_data/shared.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
[]
|
13
_content/_includes/layout-default.njk
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
---js
|
||||||
|
{
|
||||||
|
layout: "layout-main.njk",
|
||||||
|
ctx: function() { return this.ctx }
|
||||||
|
}
|
||||||
|
---
|
||||||
|
{% from "macro-entry.njk" import entryMacro %}
|
||||||
|
|
||||||
|
{{ entryMacro(ctx(), author, url, content) }}
|
||||||
|
|
||||||
|
{% layoutblock 'foot' %}
|
||||||
|
<script src="/assets/js/relative-time.js"></script>
|
||||||
|
{% endlayoutblock %}
|
15
_content/_includes/layout-feed.njk
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
title: Feed
|
||||||
|
layout: layout-main.njk
|
||||||
|
scripts_foot: '<script src="/assets/js/relative-time.js"></script>'
|
||||||
|
---
|
||||||
|
<div class="h-feed">
|
||||||
|
<h2 class="p-name">{{ title }}</h2>
|
||||||
|
{{ content | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'partial-pagination.njk' %}
|
||||||
|
|
||||||
|
{% layoutblock 'foot' %}
|
||||||
|
<script src="/assets/js/relative-time.js"></script>
|
||||||
|
{% endlayoutblock %}
|
38
_content/_includes/layout-main.njk
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
title: Mon Repos (Death's Domain)
|
||||||
|
---
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/img/avatar-tt.svg">
|
||||||
|
<link rel="icon" type="image/png" href="/assets/img/avatar-tiny.png">
|
||||||
|
<link rel="authorization_endpoint" href="https://deathau-cellar-door.glitch.me/">
|
||||||
|
<link rel="token_endpoint" href="https://deathau-cellar-door.glitch.me/token">
|
||||||
|
{# <link rel="webmention" href="https://webmention.io/death.id.au/webmention" />
|
||||||
|
<link rel="pingback" href="https://webmention.io/death.id.au/xmlrpc" />
|
||||||
|
<link rel="microsub" href="https://aperture.p3k.io/microsub/807">
|
||||||
|
<link rel="micropub" href="https://micropub.death.id.au/.netlify/functions/micropub">
|
||||||
|
<link rel="micropub_media" href="https://micropub.death.id.au/.netlify/functions/media"> #}
|
||||||
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="RSS Feed for {{ ACTOR.hostname }}" href="/rss.xml" />
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Atom Feed for {{ ACTOR.hostname}}" href="/atom.xml" />
|
||||||
|
<link rel="alternate" type="application/json" title="JSON Feed for {{ ACTOR.hostname }}" href="/feed.json" />
|
||||||
|
<script src="https://kit.fontawesome.com/ebe14e90c3.js" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/luxon/build/global/luxon.min.js" crossorigin="anonymous"></script>
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
{% renderlayoutblock 'head' %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{% renderlayoutblock 'header' %}
|
||||||
|
<main>
|
||||||
|
{{ content | safe }}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
{% renderlayoutblock 'footer' %}
|
||||||
|
{% renderlayoutblock 'foot' %}
|
||||||
|
</html>
|
10
_content/_includes/macro-author.njk
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{% macro authorMacro(author) %}
|
||||||
|
{# {{ author | getAuthorData | log }} #}
|
||||||
|
<a class="p-author h-card u-url" href="{{ author.canonical_uri if author.canonical_uri else author.url }}">
|
||||||
|
<img class="u-photo small-avatar" alt="{{ author.icon.name if author.icon.name else "Avatar for " + author.preferredUsername }}" src="{{ author.icon.url if author.icon.url else author.avatar }}"/>
|
||||||
|
<span class="display-name">
|
||||||
|
<span class="p-name">{{ author.name }}</span>
|
||||||
|
<span class="p-nickname">{{ author.preferredUsername }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endmacro %}
|
9
_content/_includes/macro-card-head.njk
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{% from "macro-author.njk" import authorMacro %}
|
||||||
|
{% macro cardHeadMacro(author, date, url) %}
|
||||||
|
<div class="card-head">
|
||||||
|
<a class="u-url permalink" rel="bookmark" href="{{ url }}" >
|
||||||
|
<time class="dt-published" datetime="{{ date | dateISOString }}">{{ date | formatDate }}</time>
|
||||||
|
</a>
|
||||||
|
{{ authorMacro(author) }}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
14
_content/_includes/macro-entry.njk
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{% from "macro-card-head.njk" import cardHeadMacro %}
|
||||||
|
{% from "macro-summary.njk" import summaryMacro %}
|
||||||
|
{% macro entryMacro(item, author, url, content, summaryOnly=false) %}
|
||||||
|
<div class="h-entry type-{{ item.postType }}">
|
||||||
|
{{ cardHeadMacro(author, item.published, url) }}
|
||||||
|
<div class="card-body">
|
||||||
|
{{ summaryMacro(item, url) }}
|
||||||
|
{% if item.type == 'article' and summaryOnly %}
|
||||||
|
{% elseif content %}
|
||||||
|
<div class="e-content">{{ content | safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
83
_content/_includes/macro-summary.njk
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
{% macro summaryMacro(item, url) %}
|
||||||
|
{% switch item.type %}
|
||||||
|
{% case "article" %} {# article summary: #}
|
||||||
|
<h2 class="p-name"><a class="u-url" rel="bookmark" title="{{ item.name if item.name else item.title }}" href="{{ url }}">
|
||||||
|
{{ item.name if item.name else item.title }}
|
||||||
|
</a></h2>
|
||||||
|
{% if item.summary %}
|
||||||
|
<p class="p-summary">{{ item.summary | safe }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% case "reply" %} {# reply summary: #}
|
||||||
|
<p class="p-summary"><i class="fa-solid fa-reply"></i> Reply to <a class="u-in-reply-to" href="{{ item["in-reply-to"] }}">{{ item["in-reply-to"] }}</a></p>
|
||||||
|
|
||||||
|
{% case "like" %} {# like summary: #}
|
||||||
|
<p class="p-summary">Favourited <i class="fa-solid fa-star"></i> <a class="u-like-of" href="{{ item['like-of'] }}">{{ item['like-of'] }}</a></p>
|
||||||
|
|
||||||
|
{% case "boost" %} {# boost summary: #}
|
||||||
|
<p class="p-summary"></p>Boosted <i class="fa-solid fa-retweet"></i> <a class="u-repost-of" href="{{ item["repost-of"] }}">{{ item["repost-of"] }}</a></p>
|
||||||
|
|
||||||
|
{% case "bookmark" %} {# bookmark summary: #}
|
||||||
|
<p class="p-summary">Bookmarked <i class="fa-solid fa-bookmark"></i> <a class="u-bookmark-of" href="{{ item["bookmark-of"] }}">{{ item["bookmark-of"] }}</a></p>
|
||||||
|
|
||||||
|
{% case "read" %} {# read summary: #}
|
||||||
|
<p class="p-summary">
|
||||||
|
{% if item["read-status"].toLowerCase() == "to-read" %}
|
||||||
|
<data class="p-x-read-status p-read-status" value="to-read">To Read: </data>
|
||||||
|
<i class="fa-solid fa-book"></i>
|
||||||
|
{% elseif item["read-status"].toLowerCase() == "reading" %}
|
||||||
|
<data class="p-x-read-status p-read-status" value="reading">Currently Reading: </data>
|
||||||
|
<i class="fa-solid fa-book-open"></i>
|
||||||
|
{% elseif item["read-status"].toLowerCase() == "finished" %}
|
||||||
|
<data class="p-x-read-status p-read-status" value="finished">Finished Reading: </data>
|
||||||
|
<i class="fa-solid fa-book-bookmark"></i>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item["read-of"].startsWith("http") %}
|
||||||
|
<a class="u-read-of" href="{{ item["read-of"] }}">{{ item["read-of"] }}</a>
|
||||||
|
{% else %}
|
||||||
|
<strong class="p-read-of">{{ item["read-of"] }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% case "watch" %} {# watch summary: #}
|
||||||
|
<p class="p-summary">
|
||||||
|
{% if item["watch-status"].toLowerCase() == "to-watch" %}
|
||||||
|
<data class="p-x-watch-status p-watch-status" value="to-watch">To Watch: </data>
|
||||||
|
{% elseif item["watch-status"].toLowerCase() == "watching" %}
|
||||||
|
<data class="p-x-watch-status p-watch-status" value="watching">Currently Watching: </data>
|
||||||
|
{% elseif item["watch-status"].toLowerCase() == "watched" or item["watch-status"].toLowerCase() == "finished" %}
|
||||||
|
<data class="p-x-watch-status p-watch-status" value="finished">Finished watching: </data>
|
||||||
|
{% else %}
|
||||||
|
<data class="p-x-watch-status p-watch-status" value="finished">Watched: </data>
|
||||||
|
{% endif %}
|
||||||
|
<i class="fa-solid fa-clapperboard"></i>
|
||||||
|
|
||||||
|
{% if item["watch-of"].startsWith("http") %}
|
||||||
|
<a class="u-watch-of" href="{{ item["watch-of"] }}">{{ item["watch-of"] }}</a>
|
||||||
|
{% else %}
|
||||||
|
<strong class="p-watch-of">{{ item["watch-of"] }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% case "rsvp" %} {# rsvp summary: #}
|
||||||
|
<p class="p-summary">
|
||||||
|
|
||||||
|
{% if item.rsvp.toLowerCase() == "yes" %}
|
||||||
|
<i class="fa-regular fa-calendar-check"></i>
|
||||||
|
<data class="p-rsvp" value="yes">Will attend</data>
|
||||||
|
{% elseif item.rsvp.toLowerCase() == "maybe" %}
|
||||||
|
<i class="fa-regular fa-calendar-minus"></i>
|
||||||
|
<data class="p-rsvp" value="maybe">Might attend</data>
|
||||||
|
{% elseif item.rsvp.toLowerCase() == "no" %}
|
||||||
|
<i class="fa-regular fa-calendar-xmark"></i>
|
||||||
|
<data class="p-rsvp" value="no">Won't attend</data>
|
||||||
|
{% elseif item.rsvp.toLowerCase() == "interested" %}
|
||||||
|
<i class="fa-regular fa-calendar-plus"></i>
|
||||||
|
<data class="p-rsvp" value="interested">Interested in</data>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="u-in-reply-to" href="{{ item["in-reply-to"] }}">{{ item["in-reply-to"] }}</a>
|
||||||
|
</p>
|
||||||
|
{% endswitch %}
|
||||||
|
{% endmacro %}
|
15
_content/_includes/partial-pagination.njk
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{% if pagination %}
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination" role="list">
|
||||||
|
<li><a href="{{ pagination.href.first }}" rel="first" title="First Page">«</a></li>
|
||||||
|
<li><a href="{{ pagination.href.previous if pagination.href.previous else "#" }}" class="{{ '' if pagination.href.previous else 'disabled' }}" rel="prev" title="Previous Page">‹</a></li>
|
||||||
|
|
||||||
|
{%- for pageEntry in pagination.pages %}
|
||||||
|
<li><a href="{{ pagination.hrefs[ loop.index0 ] }}"{% if page.url == pagination.hrefs[ loop.index0 ] %} aria-current="page" class="active" title="Current Page" {% else %} title="Jump to Page {{ loop.index }}" {% endif %}>{{ loop.index }}</a></li>
|
||||||
|
{%- endfor %}
|
||||||
|
|
||||||
|
<li><a href="{{ pagination.href.next if pagination.href.next else "#" }}" class="{{ '' if pagination.href.next else 'disabled' }}" rel="next" title="Next Page">›</a></li>
|
||||||
|
<li><a href="{{ pagination.href.last }}" rel="last" title="Last Page">»</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
6
_content/_includes/summary-article.njk
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<h2 class="p-name"><a class="u-url" rel="bookmark" title="{{ item.name if item.name else item.title }}" href="{{ url }}">
|
||||||
|
{{ item.name if item.name else item.title }}
|
||||||
|
</a></h2>
|
||||||
|
{% if item.summary %}
|
||||||
|
<p class="p-summary">{{ item.summary | safe }}</p>
|
||||||
|
{% endif %}
|
0
_content/_includes/summary-reply.njk
Normal file
36
_content/atom.njk
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
---json
|
||||||
|
{
|
||||||
|
"layout": null,
|
||||||
|
"permalink": "atom.xml",
|
||||||
|
"eleventyExcludeFromCollections": true,
|
||||||
|
"metadata": {
|
||||||
|
"subtitle": "A feed of all my posts on the fediverse",
|
||||||
|
"language": "en"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
{% from "macro-summary.njk" import summaryMacro %}
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom" xml:base="{{ ACTOR.url }}">
|
||||||
|
<title>{{ ACTOR.name }}'s feed</title>
|
||||||
|
<subtitle>{{ metadata.subtitle }}</subtitle>
|
||||||
|
<link href="{{ permalink | absoluteUrl(ACTOR.url) }}" rel="self"/>
|
||||||
|
<updated>{{ collections.feed[0].date | dateToRfc3339 }}</updated>
|
||||||
|
<id>{{ ACTOR.id | absoluteUrl(ACTOR.url) }}</id>
|
||||||
|
<author>
|
||||||
|
<name>{{ ACTOR.name }}</name>
|
||||||
|
</author>
|
||||||
|
{%- for post in collections.feed %}
|
||||||
|
{%- set absolutePostUrl = post.url | absoluteUrl(ACTOR.url) %}
|
||||||
|
<entry>
|
||||||
|
<title>{{ post.data.title }}</title>
|
||||||
|
<link href="{{ absolutePostUrl }}"/>
|
||||||
|
<updated>{{ post.data.published | dateObj | dateToRfc3339 }}</updated>
|
||||||
|
<id>{{ absolutePostUrl }}</id>
|
||||||
|
<content xml:lang="{{ metadata.language }}" type="html">
|
||||||
|
{{ summaryMacro(post.data, post.url) | htmlToAbsoluteUrls(absolutePostUrl) }}
|
||||||
|
{{ post.templateContent | htmlToAbsoluteUrls(absolutePostUrl) }}
|
||||||
|
</content>
|
||||||
|
</entry>
|
||||||
|
{%- endfor %}
|
||||||
|
</feed>
|
76
_content/index.html
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
---
|
||||||
|
layout: layout-main.njk
|
||||||
|
eleventyExcludeFromCollections: true
|
||||||
|
---
|
||||||
|
<!-- Reference for representative h-card properties: https://microformats.org/wiki/h-card -->
|
||||||
|
<div class="h-card" rel="author">
|
||||||
|
<img class="u-featured" src="{{ ACTOR.image.url }}" alt="{{ ACTOR.image.name}}" />
|
||||||
|
<img class="u-photo" alt="{{ ACTOR.icon.name}}" src="{{ ACTOR.icon.url }}" />
|
||||||
|
<h1>
|
||||||
|
I'm <span class="p-name">{{ ACTOR.name }}</span>
|
||||||
|
{% if ACTOR.name != ACTOR.preferredUsername %}
|
||||||
|
(a.k.a <span class="p-nickname">{{ ACTOR.preferredUsername }}</span>)
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
||||||
|
<p class="p-note">
|
||||||
|
{% if ACTOR.summary %}
|
||||||
|
{{ ACTOR.summary }}
|
||||||
|
{% else %}
|
||||||
|
...and I am a human on the Internet.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Go check out some <a href="/posts">stuff I wrote</a><br>
|
||||||
|
Or stay up-to-date by following me
|
||||||
|
<ul>
|
||||||
|
<li><button class="icon-button button-input"
|
||||||
|
data-instruction="Please enter your fediverse / mastodon handle (e.g. '@user@domain.social')"
|
||||||
|
data-placeholder="@user@domain.social"
|
||||||
|
data-success="Thanks for the follow!"
|
||||||
|
onsubmit="handleFollow(event.value)">
|
||||||
|
<img src="/assets/img/Fediverse_logo_proposal.svg" alt="Fediverse logo">
|
||||||
|
Follow @{{ ACTOR.preferredUsername }}@{{ ACTOR.hostname }}
|
||||||
|
</button></li>
|
||||||
|
<li><i class="fa fa-rss"></i> <a class="u-url" href="/rss.xml">RSS Feed</a></li>
|
||||||
|
<li><i class="fa fa-feed"></i> <a class="u-url" href="/atom.xml">Atom Feed</a></li>
|
||||||
|
<li><i class="fa fa-feed"></i> <a class="u-url" href="/feed.json">JSON Feed</a></li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
...or elsewhere on the Internet:
|
||||||
|
<ul>
|
||||||
|
<li><i><img class="tiny-avatar" src="/assets/img/avatar-tt-trans.svg"></i> <a class="u-uid u-url" href="https://death.id.au">Mon Repos (you are here)</a><a class="u-url" href="acct:death.au@death.id.au"></a></li>
|
||||||
|
<li><i><img class="tiny-avatar" src="/assets/img/Obsidian.svg"></i> <a class="u-url" href="https://notes.death.id.au" rel="me">My published notes</a></li>
|
||||||
|
<li><i class="fa-brands fa-mastodon"></i> <a class="u-url" href="https://pkm.social/@death_au" rel="me">@death_au@pkm.social</a></li>
|
||||||
|
<li><i class="fa-brands fa-github"></i> <a class="u-url" href="https://github.com/deathau" rel="me">@deathau</a></li>
|
||||||
|
<li><i class="fa-brands fa-twitter"></i> <a class="u-url" href="https://twitter.com/death_au" rel="me">@death_au</a></li>
|
||||||
|
<li><i class="fa-brands fa-linkedin"></i> <a class="u-url" href="https://www.linkedin.com/in/gordon-pedersen/">Gordon Pedersen</a></li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% layoutblock 'foot' %}
|
||||||
|
<script src="/assets/js/follow.js"></script>
|
||||||
|
<script src="/assets/js/button-input.js"></script>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('unhandledrejection', function(event) {
|
||||||
|
alert(event.reason)
|
||||||
|
})
|
||||||
|
function handleFollow(handle) {
|
||||||
|
try{
|
||||||
|
follow(`@${ACTOR.preferredUsername}@${ACTOR.hostname}`, handle)
|
||||||
|
}
|
||||||
|
catch(e){
|
||||||
|
alert(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function tryFollow(user) {
|
||||||
|
try{
|
||||||
|
follow(user)
|
||||||
|
}
|
||||||
|
catch(e){
|
||||||
|
alert(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endlayoutblock %}
|
36
_content/json.njk
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
---json
|
||||||
|
{
|
||||||
|
"layout": null,
|
||||||
|
"permalink": "feed.json",
|
||||||
|
"eleventyExcludeFromCollections": true,
|
||||||
|
"metadata": {
|
||||||
|
"subtitle": "A feed of all my posts on the fediverse",
|
||||||
|
"language": "en"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
---
|
||||||
|
{ {% from "macro-summary.njk" import summaryMacro %}
|
||||||
|
"version": "https://jsonfeed.org/version/1.1",
|
||||||
|
"title": "{{ ACTOR.name }}'s feed",
|
||||||
|
"language": "{{ metadata.language }}",
|
||||||
|
"home_page_url": "{{ ACTOR.url }}/",
|
||||||
|
"feed_url": "{{ permalink | absoluteUrl(ACTOR.url) }}",
|
||||||
|
"description": "{{ metadata.subtitle }}",
|
||||||
|
"author": {
|
||||||
|
"name": "{{ ACTOR.name }}",
|
||||||
|
"url": "{{ ACTOR.url }}/"
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{%- for post in collections.feed %}
|
||||||
|
{%- set absolutePostUrl = post.url | absoluteUrl(ACTOR.url) %}
|
||||||
|
{
|
||||||
|
"id": "{{ absolutePostUrl }}",
|
||||||
|
"url": "{{ absolutePostUrl }}",
|
||||||
|
"title": "{{ post.data.title }}",
|
||||||
|
"content_html": {{ summaryMacro(post.data, post.url) | concat(post.templateContent) | htmlToAbsoluteUrls(absolutePostUrl) | dump | safe }},
|
||||||
|
"date_published": "{{ post.data.published | dateObj | dateToRfc3339 }}"
|
||||||
|
}
|
||||||
|
{% if not loop.last %},{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
]
|
||||||
|
}
|
0
_content/posts/.gitkeep
Normal file
18
_content/posts/index.njk
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
title: My Feed
|
||||||
|
layout: layout-feed.njk
|
||||||
|
pagination:
|
||||||
|
data: collections.feed
|
||||||
|
size: 20
|
||||||
|
eleventyExcludeFromCollections: true
|
||||||
|
---
|
||||||
|
{% from "macro-entry.njk" import entryMacro %}
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{% for item in pagination.items %}
|
||||||
|
<li>
|
||||||
|
{{ entryMacro(item.data, item.data.author, item.url, item.templateContent, true) }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
36
_content/rss.njk
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
---js
|
||||||
|
{
|
||||||
|
"layout": null,
|
||||||
|
"permalink": "rss.xml",
|
||||||
|
"eleventyExcludeFromCollections": true,
|
||||||
|
"metadata": {
|
||||||
|
"subtitle": "A feed of all my posts on the fediverse",
|
||||||
|
"language": "en"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
{% from "macro-summary.njk" import summaryMacro %}
|
||||||
|
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xml:base="{{ ACTOR.url }}" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title>{{ ACTOR.name }}'s feed</title>
|
||||||
|
<link>{{ ACTOR.url }}</link>
|
||||||
|
<atom:link href="{{ permalink | absoluteUrl(ACTOR.url) }}" rel="self" type="application/rss+xml" />
|
||||||
|
<description>{{ metadata.subtitle }}</description>
|
||||||
|
<language>{{ metadata.language }}</language>
|
||||||
|
{%- for post in collections.feed | reverse %}
|
||||||
|
{%- set absolutePostUrl = post.url | absoluteUrl(ACTOR.url) %}
|
||||||
|
<item>
|
||||||
|
<title>{{ post.data.title }}</title>
|
||||||
|
<link>{{ absolutePostUrl }}</link>
|
||||||
|
<description>
|
||||||
|
{{ summaryMacro(post.data, post.url) | htmlToAbsoluteUrls(absolutePostUrl) }}
|
||||||
|
{{ post.templateContent | htmlToAbsoluteUrls(absolutePostUrl) }}
|
||||||
|
</description>
|
||||||
|
<pubDate>{{ post.data.published | dateObj | dateToRfc822 }}</pubDate>
|
||||||
|
<dc:creator>{{ ACTOR.name }}</dc:creator>
|
||||||
|
<guid>{{ absolutePostUrl }}</guid>
|
||||||
|
</item>
|
||||||
|
{%- endfor %}
|
||||||
|
</channel>
|
||||||
|
</rss>
|
4
actor.ts
|
@ -8,8 +8,8 @@ export const summary = ""
|
||||||
// avatar image
|
// avatar image
|
||||||
const icon:{ type:"Image", mediaType:string, url:string, name:string } = {
|
const icon:{ type:"Image", mediaType:string, url:string, name:string } = {
|
||||||
type: "Image",
|
type: "Image",
|
||||||
mediaType: "image/png",
|
mediaType: "image/svg+xml",
|
||||||
url: "https://s.gravatar.com/avatar/bc0f8c2d2ecc533cb236ce1f858365a25dbbc80ce42df49b9a95e88f07f91720?s=800",
|
url: BASE_URL + "/assets/img/avatar-tt.svg",
|
||||||
name: "My profile photo — a pixelated version of me"
|
name: "My profile photo — a pixelated version of me"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
BIN
bun.lockb
211
css/styles.css
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
:root {
|
||||||
|
--bg-rgb: 238, 238, 238;
|
||||||
|
--on-bg-rgb: 4, 4, 4;
|
||||||
|
--primary-rgb: 160, 116, 196;
|
||||||
|
--on-primary-rgb: 238, 238, 238;
|
||||||
|
--bg: rgb(var(--bg-rgb));
|
||||||
|
--on-bg: rgb(var(--on-bg-rgb));
|
||||||
|
--primary: rgb(var(--primary-rgb));
|
||||||
|
--on-primary: rgb(var(--on-primary-rgb));
|
||||||
|
|
||||||
|
--fade-alpha: 0.06;
|
||||||
|
--mute-alpha: 0.3;
|
||||||
|
|
||||||
|
--bg-fade: rgba(var(--bg-rgb), var(--fade-alpha));
|
||||||
|
--on-bg-fade: rgba(var(--on-bg-rgb), var(--fade-alpha));
|
||||||
|
--primary-fade: rgba(var(--primary-rgb), var(--fade-alpha));
|
||||||
|
--on-primary-fade: rgba(var(--on-primary-rgb), var(--fade-alpha));
|
||||||
|
|
||||||
|
--bg-muted: rgba(var(--bg-rgb), var(--mute-alpha));
|
||||||
|
--on-bg-muted: rgba(var(--on-bg-rgb), var(--mute-alpha));
|
||||||
|
--primary-muted: rgba(var(--primary-rgb), var(--mute-alpha));
|
||||||
|
--on-primary-muted: rgba(var(--on-primary-rgb), var(--mute-alpha));
|
||||||
|
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root{
|
||||||
|
--bg-rgb: 17, 17, 17;
|
||||||
|
--on-bg-rgb: 251, 251, 251;
|
||||||
|
--on-primary-rgb: 251, 251, 251;
|
||||||
|
|
||||||
|
--fade-alpha: 0.16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--on-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:last-child {
|
||||||
|
margin-block-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
img.u-photo { border-radius: 50%; max-height:200px; }
|
||||||
|
/* .p-author img.u-photo{
|
||||||
|
max-height: 48px;
|
||||||
|
} */
|
||||||
|
ul { padding: 0; list-style: none; }
|
||||||
|
img.u-featured {
|
||||||
|
display:block;
|
||||||
|
z-index:0;
|
||||||
|
margin-bottom:-125px
|
||||||
|
}
|
||||||
|
|
||||||
|
button.icon-button {
|
||||||
|
line-height: 24px;
|
||||||
|
background-color: #666289;
|
||||||
|
color:#fff;
|
||||||
|
border:none;
|
||||||
|
border-radius:4px;
|
||||||
|
}
|
||||||
|
button.icon-button>img {
|
||||||
|
max-height: 24px;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-input-container>label{
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
|
.button-input-container>input{
|
||||||
|
background-color: #666289;
|
||||||
|
color:#fff;
|
||||||
|
border:none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 66px 4px 6px;
|
||||||
|
margin-right: -60px;
|
||||||
|
}
|
||||||
|
.button-input-container>input:focus{
|
||||||
|
border:none;
|
||||||
|
}
|
||||||
|
.button-input-container>button{
|
||||||
|
background-color: #666289;
|
||||||
|
color:#fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
i>img{
|
||||||
|
max-height: 1em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
img.tiny-avatar {
|
||||||
|
max-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.small-avatar {
|
||||||
|
max-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
margin: 50px auto;
|
||||||
|
}
|
||||||
|
.pagination li {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.pagination a {
|
||||||
|
padding: 8px 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--on-bg-fade);
|
||||||
|
}
|
||||||
|
.pagination a.active {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a:hover:not(.active) { background-color: var(--on-bg-fade); }
|
||||||
|
|
||||||
|
.pagination a.disabled {
|
||||||
|
color: var(--primary-fade);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* End Pagination */
|
||||||
|
|
||||||
|
/* Feed Entries */
|
||||||
|
|
||||||
|
.h-entry {
|
||||||
|
max-width: 500px;
|
||||||
|
min-width: 320px;
|
||||||
|
background-color: var(--primary-fade);
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--on-bg-fade);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-entry:not(:last-child) {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-entry .p-author {
|
||||||
|
max-width: calc(100% - 80px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-entry .u-photo {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-entry .display-name {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-entry .display-name .p-name {
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-entry .display-name .p-nickname {
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--on-primary-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-entry .permalink {
|
||||||
|
float:right;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--on-primary-muted);
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width:80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* End Feed Entries */
|
|
@ -1,19 +0,0 @@
|
||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
build: .
|
|
||||||
container_name: bun-activitypub
|
|
||||||
command: bun run --watch /app/src/index.ts
|
|
||||||
environment:
|
|
||||||
- ACCOUNT=death.au
|
|
||||||
- REAL_NAME=Gordon Pedersen
|
|
||||||
- HOSTNAME=death.id.au
|
|
||||||
- TOKEN_ENDPOINT=https://deathau-cellar-door.glitch.me/token
|
|
||||||
- WEB_SITE_HOSTNAME=www.death.id.au
|
|
||||||
- DATA_PATH=/data
|
|
||||||
ports:
|
|
||||||
- 3000:3000
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- ./_data:/data
|
|
170
img/Fediverse_logo_proposal.svg
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="196.52mm"
|
||||||
|
height="196.52mm"
|
||||||
|
viewBox="0 0 196.52 196.52"
|
||||||
|
version="1.1"
|
||||||
|
id="svg8"
|
||||||
|
inkscape:version="0.92.2 2405546, 2018-03-11"
|
||||||
|
sodipodi:docname="Logo_penta_connectat-imbrincat_retallats-color.svg"
|
||||||
|
inkscape:export-filename="/home/nestor/Pictures/Fediversal/Logo_penta_connectat-imbrincat_retallats-color-512x.png"
|
||||||
|
inkscape:export-xdpi="66.175453"
|
||||||
|
inkscape:export-ydpi="66.175453">
|
||||||
|
<defs
|
||||||
|
id="defs2" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="0.50411932"
|
||||||
|
inkscape:cx="-209.83484"
|
||||||
|
inkscape:cy="399.15332"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:current-layer="layer2"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:snap-smooth-nodes="true"
|
||||||
|
inkscape:snap-midpoints="true"
|
||||||
|
inkscape:snap-global="false"
|
||||||
|
inkscape:window-width="1366"
|
||||||
|
inkscape:window-height="736"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="32"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
fit-margin-top="5"
|
||||||
|
fit-margin-left="5"
|
||||||
|
fit-margin-right="5"
|
||||||
|
fit-margin-bottom="5" />
|
||||||
|
<metadata
|
||||||
|
id="metadata5">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title />
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer2"
|
||||||
|
inkscape:label="Linies"
|
||||||
|
style="display:inline"
|
||||||
|
transform="translate(6.6789703,-32.495842)">
|
||||||
|
<path
|
||||||
|
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#a730b8;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:41.5748024;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
||||||
|
d="m 181.13086,275.13672 a 68.892408,68.892408 0 0 1 -29.46484,29.32812 l 161.75781,162.38868 38.99805,-19.76368 z m 213.36328,214.1875 -38.99805,19.76367 81.96289,82.2832 a 68.892409,68.892409 0 0 1 29.47071,-29.33203 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,-6.6789703,32.495842)"
|
||||||
|
id="path9722"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
<path
|
||||||
|
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#5496be;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:41.5748024;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
||||||
|
d="m 581.64648,339.39062 -91.57617,46.41016 6.75196,43.18945 103.61523,-52.51367 A 68.892409,68.892409 0 0 1 581.64648,339.39062 Z M 436.9082,412.74219 220.38281,522.47656 a 68.892408,68.892408 0 0 1 18.79492,37.08985 L 443.66016,455.93359 Z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,-6.6789703,32.495842)"
|
||||||
|
id="path9729"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
<path
|
||||||
|
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#ce3d1a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:41.5748024;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
||||||
|
d="M 367.27539,142.4375 262.79492,346.4082 293.64258,377.375 404.26562,161.41797 A 68.892408,68.892408 0 0 1 367.27539,142.4375 Z m -131.6543,257.02148 -52.92187,103.31446 a 68.892409,68.892409 0 0 1 36.98633,18.97851 l 46.78125,-91.32812 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,-6.6789703,32.495842)"
|
||||||
|
id="path9713"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
<path
|
||||||
|
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#d0188f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:41.5748024;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
||||||
|
d="m 150.76758,304.91797 a 68.892408,68.892408 0 0 1 -34.41602,7.19531 68.892408,68.892408 0 0 1 -6.65039,-0.69531 l 30.90235,197.66211 a 68.892409,68.892409 0 0 1 34.41601,-7.19531 68.892409,68.892409 0 0 1 6.64649,0.69531 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,-6.6789703,32.495842)"
|
||||||
|
id="path1015"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
<path
|
||||||
|
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#5b36e9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:41.5748024;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
||||||
|
d="m 239.3418,560.54492 a 68.892408,68.892408 0 0 1 0.7207,13.87696 68.892408,68.892408 0 0 1 -7.26758,27.17968 l 197.62891,31.71289 a 68.892409,68.892409 0 0 1 -0.72266,-13.8789 68.892409,68.892409 0 0 1 7.26953,-27.17774 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,-6.6789703,32.495842)"
|
||||||
|
id="path1674"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
<path
|
||||||
|
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#30b873;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:41.5748024;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
||||||
|
d="m 601.13281,377.19922 -91.21875,178.08203 a 68.892408,68.892408 0 0 1 36.99414,18.98242 L 638.125,396.18359 a 68.892409,68.892409 0 0 1 -36.99219,-18.98437 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,-6.6789703,32.495842)"
|
||||||
|
id="path1676"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
<path
|
||||||
|
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#ebe305;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:41.5748024;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
||||||
|
d="m 476.72266,125.33008 a 68.892408,68.892408 0 0 1 -29.47071,29.33203 l 141.26563,141.81055 a 68.892409,68.892409 0 0 1 29.46875,-29.33204 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,-6.6789703,32.495842)"
|
||||||
|
id="path1678"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
<path
|
||||||
|
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#f47601;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:41.5748024;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
||||||
|
d="m 347.78711,104.63086 -178.57617,90.49805 a 68.892409,68.892409 0 0 1 18.79297,37.08593 l 178.57421,-90.50195 a 68.892408,68.892408 0 0 1 -18.79101,-37.08203 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,-6.6789703,32.495842)"
|
||||||
|
id="path1680"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
<path
|
||||||
|
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#57c115;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:41.5748024;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
||||||
|
d="m 446.92578,154.82617 a 68.892408,68.892408 0 0 1 -34.98242,7.48242 68.892408,68.892408 0 0 1 -6.0293,-0.63281 l 15.81836,101.29102 43.16211,6.92578 z m -16,167.02735 37.40039,239.48242 a 68.892409,68.892409 0 0 1 33.91406,-6.94336 68.892409,68.892409 0 0 1 7.20704,0.79101 L 474.08984,328.77734 Z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,-6.6789703,32.495842)"
|
||||||
|
id="path9758"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
<path
|
||||||
|
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#dbb210;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:41.5748024;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
||||||
|
d="m 188.13086,232.97461 a 68.892408,68.892408 0 0 1 0.75781,14.0957 68.892408,68.892408 0 0 1 -7.16015,26.98242 l 101.36914,16.28125 19.92382,-38.9082 z m 173.73633,27.90039 -19.92578,38.91211 239.51367,38.4668 a 68.892409,68.892409 0 0 1 -0.69531,-13.71875 68.892409,68.892409 0 0 1 7.34961,-27.32422 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,-6.6789703,32.495842)"
|
||||||
|
id="path9760"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer3"
|
||||||
|
inkscape:label="Nodes"
|
||||||
|
style="display:inline;opacity:1"
|
||||||
|
transform="translate(6.6789703,-32.495842)">
|
||||||
|
<circle
|
||||||
|
style="fill:#ffca00;fill-opacity:0.99596773;stroke:none;stroke-width:0.26458332;stroke-opacity:0.96078431"
|
||||||
|
id="path817"
|
||||||
|
cx="106.26596"
|
||||||
|
cy="51.535553"
|
||||||
|
r="16.570711"
|
||||||
|
transform="rotate(3.1178174)" />
|
||||||
|
<circle
|
||||||
|
id="path819"
|
||||||
|
style="fill:#64ff00;fill-opacity:0.99596773;stroke:none;stroke-width:0.26458332;stroke-opacity:0.96078431"
|
||||||
|
cx="171.42836"
|
||||||
|
cy="110.19328"
|
||||||
|
r="16.570711"
|
||||||
|
transform="rotate(3.1178174)" />
|
||||||
|
<circle
|
||||||
|
id="path823"
|
||||||
|
style="fill:#00a3ff;fill-opacity:0.99596773;stroke:none;stroke-width:0.26458332;stroke-opacity:0.96078431"
|
||||||
|
cx="135.76379"
|
||||||
|
cy="190.27704"
|
||||||
|
r="16.570711"
|
||||||
|
transform="rotate(3.1178174)" />
|
||||||
|
<circle
|
||||||
|
style="fill:#9500ff;fill-opacity:0.99596773;stroke:none;stroke-width:0.26458332;stroke-opacity:0.96078431"
|
||||||
|
id="path825"
|
||||||
|
cx="48.559471"
|
||||||
|
cy="181.1138"
|
||||||
|
r="16.570711"
|
||||||
|
transform="rotate(3.1178174)" />
|
||||||
|
<circle
|
||||||
|
id="path827"
|
||||||
|
style="fill:#ff0000;fill-opacity:0.99596773;stroke:none;stroke-width:0.26458332;stroke-opacity:0.96078431"
|
||||||
|
cx="30.328812"
|
||||||
|
cy="95.366837"
|
||||||
|
r="16.570711"
|
||||||
|
transform="rotate(3.1178174)" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 19 KiB |
6
img/Obsidian.svg
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<svg width="140" height="180" viewBox="0 0 140 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M49.775 105.089C54.3828 103.715 61.8069 101.605 70.3434 101.082C65.2222 88.1449 63.986 76.8242 64.9803 66.7442C66.1285 55.1033 70.23 45.3874 74.228 37.1201C75.0811 35.3561 75.9012 33.7135 76.6879 32.1378C77.8017 29.9069 78.8486 27.81 79.8278 25.6916C81.4579 22.1652 82.6674 19.051 83.2791 16.1576C83.8806 13.3125 83.8838 10.7715 83.1727 8.3342C82.4607 5.89352 80.9475 3.26635 78.0704 0.386692C74.3134 -0.587559 70.1448 0.267767 67.0197 3.08162L29.9298 36.4772C27.861 38.34 26.503 40.8642 26.0879 43.6182L22.8899 64.8384C27.9185 69.2873 40.33 82.2201 47.8789 100.165C48.5525 101.766 49.1875 103.408 49.775 105.089Z" fill="white"/>
|
||||||
|
<path d="M21.3902 74.5293C21.2153 75.2761 20.9692 76.0051 20.6549 76.7063L1.05225 120.436C-0.961131 124.928 -0.0336421 130.194 3.39276 133.726L34.2418 165.523C49.9952 142.262 47.6984 120.379 40.5026 103.274C35.0465 90.3037 26.777 80.1526 21.3902 74.5293Z" fill="white"/>
|
||||||
|
<path d="M41.3687 169.269C41.9093 169.355 42.4575 169.407 43.0096 169.424C48.864 169.6 58.7098 170.109 66.6947 171.582C73.2088 172.783 86.1213 176.397 96.747 179.505C104.855 181.877 113.211 175.396 114.387 167.024C115.245 160.917 116.855 154.009 119.821 147.677L119.753 147.702C114.73 133.682 108.34 124.629 101.641 118.849C94.9619 113.086 87.7708 110.397 80.8276 109.42C69.2835 107.795 58.7071 110.832 52.0453 112.791C56.0353 129.428 54.8074 149.004 41.3687 169.269Z" fill="white"/>
|
||||||
|
<path d="M124.96 139.034C131.626 128.965 136.375 121.134 138.881 116.888C140.135 114.764 139.907 112.102 138.423 110.133C134.554 105.002 127.152 94.5755 123.12 84.9218C118.973 74.9962 118.355 59.5866 118.319 52.081C118.306 49.2279 117.402 46.4413 115.639 44.1994L91.6762 13.73C91.5918 15.1034 91.3946 16.4659 91.1093 17.8158C90.3118 21.5882 88.8073 25.3437 87.0916 29.0552C86.086 31.2306 84.9238 33.5612 83.7497 35.9157C82.9682 37.4827 82.1814 39.0607 81.432 40.6102C77.5579 48.6212 73.9528 57.3151 72.9451 67.5313C72.011 77.0006 73.2894 88.014 79.0482 101.162C80.0074 101.243 80.9727 101.351 81.9422 101.487C90.2067 102.651 98.8807 105.891 106.866 112.781C113.73 118.704 119.932 127.19 124.96 139.034Z" fill="white"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.2 KiB |
BIN
img/avatar-tiny.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
37
img/avatar-tt-trans.svg
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -0.5 13 17" shape-rendering="crispEdges">
|
||||||
|
<metadata>Made with Pixels to Svg https://codepen.io/shshaw/pen/XbxvNj</metadata>
|
||||||
|
<path stroke="#522301" d="M3 0h1M2 1h1M1 2h2M2 4h1M1 7h1M2 9h1M4 9h1" />
|
||||||
|
<path stroke="#512201" d="M4 0h1M3 1h1M1 3h2M1 4h1M1 5h1M1 6h1M1 8h2M0 9h1M3 9h1M0 10h2M0 11h3M1 12h1" />
|
||||||
|
<path stroke="#805130" d="M5 0h1M11 2h1" />
|
||||||
|
<path stroke="#815231" d="M6 0h2M3 2h1" />
|
||||||
|
<path stroke="#8d5e3d" d="M8 0h1M6 1h1M11 1h1" />
|
||||||
|
<path stroke="#8c5d3c" d="M9 0h1M12 2h1" />
|
||||||
|
<path stroke="#8d5e3c" d="M10 0h1M5 1h1M7 1h1M9 1h2" />
|
||||||
|
<path stroke="#7f5231" d="M4 1h1" />
|
||||||
|
<path stroke="#8c5e3c" d="M8 1h1M4 2h1" />
|
||||||
|
<path stroke="#fecbcb" d="M5 2h3M9 2h1M5 3h1M4 5h1M9 5h1M4 6h2M12 6h1M7 7h1" />
|
||||||
|
<path stroke="#ffcbcb" d="M8 2h1M10 2h1M10 7h1" />
|
||||||
|
<path stroke="#e9b6b6" d="M3 3h1M3 5h1M4 7h1M10 13h1" />
|
||||||
|
<path stroke="#ffcccc" d="M4 3h1M8 3h3M4 4h1M7 4h4M12 4h1M6 5h2M11 5h2M6 6h1M11 6h1M6 7h1M8 7h2M3 13h1" />
|
||||||
|
<path stroke="#fecccc" d="M6 3h2M11 3h2M6 4h1M8 5h1M10 5h1" />
|
||||||
|
<path stroke="#e9b5b5" d="M3 4h1M3 6h1M3 7h1" />
|
||||||
|
<path stroke="#000000" d="M5 4h1M11 4h1M4 10h1M6 10h1M3 11h1M7 11h1M4 12h1M10 12h1" />
|
||||||
|
<path stroke="#a97777" d="M2 5h1" />
|
||||||
|
<path stroke="#fbc8c8" d="M5 5h1M12 7h1" />
|
||||||
|
<path stroke="#aa7676" d="M2 6h1M2 7h1" />
|
||||||
|
<path stroke="#ce9a9a" d="M7 6h1" />
|
||||||
|
<path stroke="#ce9b9b" d="M8 6h2" />
|
||||||
|
<path stroke="#fcc8c8" d="M10 6h1" />
|
||||||
|
<path stroke="#b55916" d="M5 7h1M5 8h1M6 9h1M7 10h1" />
|
||||||
|
<path stroke="#e47726" d="M11 7h1M7 8h4M8 9h3" />
|
||||||
|
<path stroke="#aa7777" d="M3 8h2M5 9h1" />
|
||||||
|
<path stroke="#d06d25" d="M6 8h1M11 8h1M7 9h1M8 10h2M8 11h1" />
|
||||||
|
<path stroke="#4d3ca6" d="M1 9h1M2 10h1" />
|
||||||
|
<path stroke="#2a2a2a" d="M5 10h1M4 11h2M7 12h1M5 13h4M5 14h1M8 14h1" />
|
||||||
|
<path stroke="#2b2b2b" d="M6 11h1M5 12h2M8 12h1M6 14h2" />
|
||||||
|
<path stroke="#323232" d="M9 11h1M3 12h1M4 16h2M8 16h2" />
|
||||||
|
<path stroke="#333333" d="M9 12h1M9 13h1M9 14h1" />
|
||||||
|
<path stroke="#232323" d="M4 13h1M4 14h1" />
|
||||||
|
<path stroke="#003298" d="M4 15h2M9 15h1" />
|
||||||
|
<path stroke="#003399" d="M6 15h3" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2 KiB |
38
img/avatar-tt.svg
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -0.5 20 20" shape-rendering="crispEdges">
|
||||||
|
<metadata>Made with Pixels to Svg https://codepen.io/shshaw/pen/XbxvNj</metadata>
|
||||||
|
<path stroke="#666289" d="M0 0h20M0 1h20M0 2h20M0 3h6M14 3h6M0 4h5M15 4h5M0 5h4M16 5h4M0 6h4M16 6h4M0 7h4M16 7h4M0 8h4M16 8h4M0 9h4M16 9h4M0 10h4M16 10h4M0 11h4M15 11h5M0 12h3M14 12h6M0 13h3M6 13h1M13 13h7M0 14h3M13 14h7M0 15h4M5 15h1M14 15h6M0 16h6M14 16h6M0 17h7M13 17h7M0 18h7M13 18h7M0 19h7M9 19h2M13 19h7" />
|
||||||
|
<path stroke="#522301" d="M6 3h1M5 4h1M4 5h2M5 7h1M4 10h1M5 12h1M7 12h1" />
|
||||||
|
<path stroke="#512201" d="M7 3h1M6 4h1M4 6h2M4 7h1M4 8h1M4 9h1M4 11h2M3 12h1M6 12h1M3 13h2M3 14h3M4 15h1" />
|
||||||
|
<path stroke="#805130" d="M8 3h1M14 5h1" />
|
||||||
|
<path stroke="#815231" d="M9 3h2M6 5h1" />
|
||||||
|
<path stroke="#8d5e3d" d="M11 3h1M9 4h1M14 4h1" />
|
||||||
|
<path stroke="#8c5d3c" d="M12 3h1M15 5h1" />
|
||||||
|
<path stroke="#8d5e3c" d="M13 3h1M8 4h1M10 4h1M12 4h2" />
|
||||||
|
<path stroke="#7f5231" d="M7 4h1" />
|
||||||
|
<path stroke="#8c5e3c" d="M11 4h1M7 5h1" />
|
||||||
|
<path stroke="#fecbcb" d="M8 5h3M12 5h1M8 6h1M7 8h1M12 8h1M7 9h2M15 9h1M10 10h1" />
|
||||||
|
<path stroke="#ffcbcb" d="M11 5h1M13 5h1M13 10h1" />
|
||||||
|
<path stroke="#e9b6b6" d="M6 6h1M6 8h1M7 10h1M13 16h1" />
|
||||||
|
<path stroke="#ffcccc" d="M7 6h1M11 6h3M7 7h1M10 7h4M15 7h1M9 8h2M14 8h2M9 9h1M14 9h1M9 10h1M11 10h2M6 16h1" />
|
||||||
|
<path stroke="#fecccc" d="M9 6h2M14 6h2M9 7h1M11 8h1M13 8h1" />
|
||||||
|
<path stroke="#e9b5b5" d="M6 7h1M6 9h1M6 10h1" />
|
||||||
|
<path stroke="#000000" d="M8 7h1M14 7h1M7 13h1M9 13h1M6 14h1M10 14h1M7 15h1M13 15h1" />
|
||||||
|
<path stroke="#a97777" d="M5 8h1" />
|
||||||
|
<path stroke="#fbc8c8" d="M8 8h1M15 10h1" />
|
||||||
|
<path stroke="#aa7676" d="M5 9h1M5 10h1" />
|
||||||
|
<path stroke="#ce9a9a" d="M10 9h1" />
|
||||||
|
<path stroke="#ce9b9b" d="M11 9h2" />
|
||||||
|
<path stroke="#fcc8c8" d="M13 9h1" />
|
||||||
|
<path stroke="#b55916" d="M8 10h1M8 11h1M9 12h1M10 13h1" />
|
||||||
|
<path stroke="#e47726" d="M14 10h1M10 11h4M11 12h3" />
|
||||||
|
<path stroke="#aa7777" d="M6 11h2M8 12h1" />
|
||||||
|
<path stroke="#d06d25" d="M9 11h1M14 11h1M10 12h1M11 13h2M11 14h1" />
|
||||||
|
<path stroke="#4d3ca6" d="M4 12h1M5 13h1" />
|
||||||
|
<path stroke="#2a2a2a" d="M8 13h1M7 14h2M10 15h1M8 16h4M8 17h1M11 17h1" />
|
||||||
|
<path stroke="#2b2b2b" d="M9 14h1M8 15h2M11 15h1M9 17h2" />
|
||||||
|
<path stroke="#323232" d="M12 14h1M6 15h1M7 19h2M11 19h2" />
|
||||||
|
<path stroke="#333333" d="M12 15h1M12 16h1M12 17h1" />
|
||||||
|
<path stroke="#232323" d="M7 16h1M7 17h1" />
|
||||||
|
<path stroke="#003298" d="M7 18h2M12 18h1" />
|
||||||
|
<path stroke="#003399" d="M9 18h3" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
BIN
img/avatar-tt@800.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
img/banner-1500x500.jpg
Normal file
After Width: | Height: | Size: 76 KiB |
61
js/button-input.js
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
function buttonInputClick() {
|
||||||
|
this.style.display = 'none'
|
||||||
|
|
||||||
|
const buttonInput = document.createElement('div')
|
||||||
|
buttonInput.classList.add('button-input-container')
|
||||||
|
|
||||||
|
if(this.dataset.instruction){
|
||||||
|
const label = document.createElement('label')
|
||||||
|
buttonInput.appendChild(label)
|
||||||
|
label.innerText = this.dataset.instruction
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.createElement('input')
|
||||||
|
buttonInput.appendChild(input)
|
||||||
|
input.type = 'text'
|
||||||
|
if(this.dataset.placeholder){
|
||||||
|
input.placeholder = this.dataset.placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = document.createElement('button')
|
||||||
|
buttonInput.appendChild(button)
|
||||||
|
button.innerText = "Submit"
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
if(this.onsubmit){
|
||||||
|
const event = new Event('button-input-value')
|
||||||
|
event.value = input.value
|
||||||
|
this.onsubmit(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonInput.parentNode.removeChild(buttonInput)
|
||||||
|
if(this.dataset.success){
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.classList.add('success')
|
||||||
|
span.innerText = this.dataset.success
|
||||||
|
setTimeout(() => {
|
||||||
|
span.parentNode.removeChild(span)
|
||||||
|
this.style.display = null
|
||||||
|
}, 5000);
|
||||||
|
this.parentNode.insertBefore(span, this.nextSibling)
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
this.style.display = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
input.addEventListener("keypress", function(event) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
button.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.parentNode.insertBefore(buttonInput, this.nextSibling)
|
||||||
|
input.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.button-input').forEach(button => {
|
||||||
|
button.addEventListener('click', buttonInputClick)
|
||||||
|
});
|
||||||
|
}, false);
|
35
js/follow.js
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
const SUBSCRIBE_LINK_REL = 'http://ostatus.org/schema/1.0/subscribe'
|
||||||
|
function follow(username, handle) {
|
||||||
|
if(!handle){
|
||||||
|
handle = prompt("Please enter your fediverse / mastodon handle (e.g. '@user@domain.social')", "@")
|
||||||
|
}
|
||||||
|
|
||||||
|
if(handle) {
|
||||||
|
const input = handle
|
||||||
|
handle = handle.trim().replace(/^@/,'')
|
||||||
|
const split = handle.split('@')
|
||||||
|
if(split.length == 2) {
|
||||||
|
const resource = `acct:${handle}`
|
||||||
|
const domain = split[1]
|
||||||
|
|
||||||
|
// look up remote user via webfinger
|
||||||
|
const url = `https://${domain}/.well-known/webfinger?resource=${resource}`
|
||||||
|
fetch(url, {headers: {
|
||||||
|
'Content-Type': 'application/activity+json'
|
||||||
|
}}).then(async result => {
|
||||||
|
const json = await result.json()
|
||||||
|
const subscribe = json.links.find(link => link.rel && link.rel == SUBSCRIBE_LINK_REL)
|
||||||
|
let template = subscribe.template
|
||||||
|
window.open(template.replace("{uri}", username), '_blank').focus()
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.error(e)
|
||||||
|
throw `Sorry, we couldn't find a subscribe uri for ${input}.\n\nTry searching for "${username}" on ${domain} (or in your fediverse client of choice)`
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw 'Please enter your fediverse address in @user@domain.social format'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
6
js/relative-time.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('time').forEach(time => {
|
||||||
|
const datetime = luxon.DateTime.fromISO(time.getAttribute('datetime'))
|
||||||
|
time.innerText = datetime.toRelative()
|
||||||
|
});
|
||||||
|
}, false);
|
11
package.json
|
@ -3,17 +3,20 @@
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run --watch src/index.ts"
|
"start": "bun run --watch src/index.ts",
|
||||||
|
"ngrok": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node-forge": "^1.3.5",
|
"@types/node-forge": "^1.3.5",
|
||||||
"bun-types": "^1.0.5"
|
"bun-types": "^1.0.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-forge": "^1.3.1",
|
"@11ty/eleventy": "^2.0.1",
|
||||||
"using-statement": "^0.4.2"
|
"@11ty/eleventy-plugin-rss": "^1.2.0",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"node-forge": "^1.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,14 +1,11 @@
|
||||||
import { authorized, defaultHeaders, verify } from "./request"
|
import * as db from "./db"
|
||||||
|
import { verify } from "./request"
|
||||||
import outbox from "./outbox"
|
import outbox from "./outbox"
|
||||||
import inbox from "./inbox"
|
import inbox from "./inbox"
|
||||||
import ACTOR from "../actor"
|
import ACTOR from "../actor"
|
||||||
import { activityPubTypes } from "./env"
|
import { activityPubTypes } from "./env"
|
||||||
import ActivityPubDB from "./db"
|
|
||||||
|
|
||||||
let db:ActivityPubDB
|
export default (req: Request): Response | Promise<Response> | undefined => {
|
||||||
|
|
||||||
export default (req: Request, database:ActivityPubDB): Response | Promise<Response> | undefined => {
|
|
||||||
db = database
|
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
let match
|
let match
|
||||||
|
|
||||||
|
@ -27,22 +24,30 @@ export default (req: Request, database:ActivityPubDB): Response | Promise<Respon
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reqIsActivityPub(req:Request) {
|
export function reqIsActivityPub(req:Request) {
|
||||||
const contentType = req.headers.get("Accept") + ',' + req.headers.get('Content-Type')
|
const contentType = req.headers.get("Accept")
|
||||||
return activityPubTypes.some(t => contentType?.includes(t))
|
return activityPubTypes.some(t => contentType?.includes(t))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function idsFromValue(value:any):string[] {
|
||||||
|
if (!value) return []
|
||||||
|
else if (typeof value === 'string') return [value]
|
||||||
|
else if (value.id) return [value.id]
|
||||||
|
else if (Array.isArray(value)) return value.map(v => idsFromValue(v)).flat(Infinity) as string[]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const postOutbox = async (req:Request):Promise<Response> => {
|
const postOutbox = async (req:Request):Promise<Response> => {
|
||||||
console.log("PostOutbox")
|
console.log("PostOutbox")
|
||||||
|
|
||||||
if(!authorized(req)) return new Response('', { status: 401, headers: defaultHeaders(req) })
|
|
||||||
|
|
||||||
const bodyText = await req.text()
|
const bodyText = await req.text()
|
||||||
|
|
||||||
|
// TODO: verify calls to the outbox, whether that be by basic authentication, bearer, or otherwise.
|
||||||
|
|
||||||
const body = JSON.parse(bodyText)
|
const body = JSON.parse(bodyText)
|
||||||
// ensure that the verified actor matches the actor in the request body
|
// ensure that the verified actor matches the actor in the request body
|
||||||
if (ACTOR.id !== body.actor) return new Response("", { status: 401, headers: defaultHeaders(req) })
|
if (ACTOR.id !== body.actor) return new Response("", { status: 401 })
|
||||||
|
|
||||||
return await outbox(body, req, db)
|
return await outbox(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
const postInbox = async (req:Request):Promise<Response> => {
|
const postInbox = async (req:Request):Promise<Response> => {
|
||||||
|
@ -56,33 +61,33 @@ const postInbox = async (req:Request):Promise<Response> => {
|
||||||
// verify the signed HTTP request
|
// verify the signed HTTP request
|
||||||
from = await verify(req, bodyText);
|
from = await verify(req, bodyText);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return new Response("", { status: 401, headers: defaultHeaders(req) })
|
return new Response("", { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = JSON.parse(bodyText)
|
const body = JSON.parse(bodyText)
|
||||||
|
|
||||||
// ensure that the verified actor matches the actor in the request body
|
// ensure that the verified actor matches the actor in the request body
|
||||||
if (from !== body.actor) return new Response("", { status: 401, headers: defaultHeaders(req) })
|
if (from !== body.actor) return new Response("", { status: 401 })
|
||||||
|
|
||||||
return await inbox(body, req, db)
|
return await inbox(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOutbox = async (req:Request):Promise<Response> => {
|
const getOutbox = async (req:Request):Promise<Response> => {
|
||||||
console.log("GetOutbox")
|
console.log("GetOutbox")
|
||||||
|
|
||||||
// TODO: Paging?
|
// TODO: Paging?
|
||||||
const posts = db.listOutboxActivities()
|
const posts = await db.listOutboxActivities()
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
id: ACTOR.outbox,
|
id: ACTOR.outbox,
|
||||||
type: "OrderedCollection",
|
type: "OrderedCollection",
|
||||||
totalItems: posts?.length,
|
totalItems: posts.length,
|
||||||
orderedItems: posts?.map((post) => ({
|
orderedItems: posts.map((post) => ({
|
||||||
...post,
|
...post,
|
||||||
actor: ACTOR.id
|
actor: ACTOR.id
|
||||||
})).sort( (a,b) => new Date(b.published).getTime() - new Date(a.published).getTime())
|
})).sort( (a,b) => new Date(b.published).getTime() - new Date(a.published).getTime())
|
||||||
}, { status:200, headers: defaultHeaders(req) })
|
}, { headers: { "Content-Type": "application/activity+json"} })
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFollowers = async (req:Request):Promise<Response> => {
|
const getFollowers = async (req:Request):Promise<Response> => {
|
||||||
|
@ -90,23 +95,23 @@ const getFollowers = async (req:Request):Promise<Response> => {
|
||||||
|
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
const page = url.searchParams.get("page")
|
const page = url.searchParams.get("page")
|
||||||
const followers = db.listFollowers()
|
const followers = await db.listFollowers()
|
||||||
|
|
||||||
if(!page) return Response.json({
|
if(!page) return Response.json({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
id: ACTOR.followers,
|
id: ACTOR.followers,
|
||||||
type: "OrderedCollection",
|
type: "OrderedCollection",
|
||||||
totalItems: followers?.length,
|
totalItems: followers.length,
|
||||||
first: `${ACTOR.followers}?page=1`,
|
first: `${ACTOR.followers}?page=1`,
|
||||||
}, { status:200, headers: defaultHeaders(req) })
|
})
|
||||||
else return Response.json({
|
else return Response.json({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
id: `${ACTOR.followers}?page=${page}`,
|
id: `${ACTOR.followers}?page=${page}`,
|
||||||
type: "OrderedCollectionPage",
|
type: "OrderedCollectionPage",
|
||||||
partOf: ACTOR.followers,
|
partOf: ACTOR.followers,
|
||||||
totalItems: followers?.length,
|
totalItems: followers.length,
|
||||||
orderedItems: followers?.map(follower => follower?.id)
|
orderedItems: followers.map(follower => follower.actor)
|
||||||
}, { status:200, headers: defaultHeaders(req) })
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFollowing = async (req:Request):Promise<Response> => {
|
const getFollowing = async (req:Request):Promise<Response> => {
|
||||||
|
@ -114,41 +119,41 @@ const getFollowing = async (req:Request):Promise<Response> => {
|
||||||
|
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
const page = url.searchParams.get("page")
|
const page = url.searchParams.get("page")
|
||||||
const following = db.listFollowing()
|
const following = await db.listFollowing()
|
||||||
|
|
||||||
if(!page) return Response.json({
|
if(!page) return Response.json({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
id: ACTOR.following,
|
id: ACTOR.following,
|
||||||
type: "OrderedCollection",
|
type: "OrderedCollection",
|
||||||
totalItems: following?.length,
|
totalItems: following.length,
|
||||||
first: `${ACTOR.following}?page=1`,
|
first: `${ACTOR.following}?page=1`,
|
||||||
}, { status:200, headers: defaultHeaders(req) })
|
})
|
||||||
else return Response.json({
|
else return Response.json({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
id: `${ACTOR.following}?page=${page}`,
|
id: `${ACTOR.following}?page=${page}`,
|
||||||
type: "OrderedCollectionPage",
|
type: "OrderedCollectionPage",
|
||||||
partOf: ACTOR.following,
|
partOf: ACTOR.following,
|
||||||
totalItems: following?.length,
|
totalItems: following.length,
|
||||||
orderedItems: following?.map(follow => follow.id)
|
orderedItems: following.map(follow => follow.actor)
|
||||||
}, { status:200, headers: defaultHeaders(req) })
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getActor = async (req:Request):Promise<Response> => {
|
const getActor = async (req:Request):Promise<Response> => {
|
||||||
console.log("GetActor")
|
console.log("GetActor")
|
||||||
|
|
||||||
if(reqIsActivityPub(req)) return Response.json(ACTOR, { status:200, headers: defaultHeaders(req) })
|
if(reqIsActivityPub(req)) return Response.json(ACTOR, { headers: { "Content-Type": "application/activity+json"}})
|
||||||
else return Response.json(db.listPosts(), { status:200, headers: defaultHeaders(req) })
|
else return Response.json(await db.listPosts())
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPost = async (req:Request, id:string):Promise<Response> => {
|
const getPost = async (req:Request, id:string):Promise<Response> => {
|
||||||
console.log("GetPost", id)
|
console.log("GetPost", id)
|
||||||
|
|
||||||
if(reqIsActivityPub(req)) return Response.json((db.getOutboxActivity(id))?.object, { status:200, headers: defaultHeaders(req) })
|
if(reqIsActivityPub(req)) return Response.json((await db.getOutboxActivity(id)).object, { headers: { "Content-Type": "application/activity+json"}})
|
||||||
else return Response.json(db.getPost(id), { status:200, headers: defaultHeaders(req) })
|
else return Response.json(await db.getPost(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOutboxActivity = async (req:Request, id:string):Promise<Response> => {
|
const getOutboxActivity = async (req:Request, id:string):Promise<Response> => {
|
||||||
console.log("GetOutboxActivity", id)
|
console.log("GetOutboxActivity", id)
|
||||||
|
|
||||||
return Response.json((db.getOutboxActivity(id)), { status:200, headers: defaultHeaders(req) })
|
return Response.json((await db.getOutboxActivity(id)), { headers: { "Content-Type": "application/activity+json"}})
|
||||||
}
|
}
|
114
src/admin.ts
|
@ -1,23 +1,21 @@
|
||||||
import { activityPubTypes } from "./env"
|
import { idsFromValue } from "./activitypub"
|
||||||
|
import * as db from "./db"
|
||||||
|
import { ADMIN_PASSWORD, ADMIN_USERNAME, activityPubTypes } from "./env"
|
||||||
import outbox from "./outbox"
|
import outbox from "./outbox"
|
||||||
import { authorized, fetchObject, idsFromValue } from "./request"
|
import { fetchObject } from "./request"
|
||||||
import ACTOR from "../actor"
|
import ACTOR from "../actor"
|
||||||
import ActivityPubDB, { Activity } from "./db"
|
|
||||||
|
|
||||||
let db:ActivityPubDB
|
export default (req: Request): Response | Promise<Response> | undefined => {
|
||||||
|
|
||||||
export default async (req: Request, database: ActivityPubDB): Promise<Response | Promise<Response> | undefined> => {
|
|
||||||
db = database
|
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
|
|
||||||
if(!url.pathname.startsWith('/admin')) return undefined
|
if(!url.pathname.startsWith('/admin')) return undefined
|
||||||
url.pathname = url.pathname.substring(6)
|
url.pathname = url.pathname.substring(6)
|
||||||
|
|
||||||
if(!(await authorized(req))) return new Response("", { status: 401 })
|
if(ADMIN_USERNAME && ADMIN_PASSWORD && !checkAuth(req.headers)) return new Response("", { status: 401 })
|
||||||
|
|
||||||
let match
|
let match
|
||||||
if(req.method === "GET" && (match = url.pathname.match(/^\/test\/?$/i))) return new Response("", { status: 204 })
|
if(req.method === "GET" && (match = url.pathname.match(/^\/test\/?$/i))) return new Response("", { status: 204 })
|
||||||
// else if(req.method == "POST" && (match = url.pathname.match(/^\/rebuild\/?$/i))) return rebuild(req)
|
else if(req.method == "POST" && (match = url.pathname.match(/^\/rebuild\/?$/i))) return rebuild(req)
|
||||||
else if(req.method == "POST" && (match = url.pathname.match(/^\/create\/?$/i))) return create(req)
|
else if(req.method == "POST" && (match = url.pathname.match(/^\/create\/?$/i))) return create(req)
|
||||||
else if(req.method == "POST" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return follow(req, match[1])
|
else if(req.method == "POST" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return follow(req, match[1])
|
||||||
else if(req.method == "DELETE" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return unfollow(req, match[1])
|
else if(req.method == "DELETE" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return unfollow(req, match[1])
|
||||||
|
@ -30,14 +28,30 @@ export default async (req: Request, database: ActivityPubDB): Promise<Response |
|
||||||
else if(req.method == "POST" && (match = url.pathname.match(/^\/reply\/(.+)\/?$/i))) return create(req, match[1])
|
else if(req.method == "POST" && (match = url.pathname.match(/^\/reply\/(.+)\/?$/i))) return create(req, match[1])
|
||||||
else if(req.method == "DELETE" && (match = url.pathname.match(/^\/delete\/(.+)\/?$/i))) return deletePost(req, match[1])
|
else if(req.method == "DELETE" && (match = url.pathname.match(/^\/delete\/(.+)\/?$/i))) return deletePost(req, match[1])
|
||||||
|
|
||||||
console.info(`Couldn't match admin path ${req.method} "${url.pathname}"`)
|
console.log(`Couldn't match admin path ${req.method} "${url.pathname}"`)
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checkAuth = (headers: Headers): Boolean => {
|
||||||
|
// check the basic auth header
|
||||||
|
const auth = headers.get("Authorization")
|
||||||
|
const split = auth?.split("")
|
||||||
|
if(!split || split.length != 2 || split[0] !== "Basic") return false
|
||||||
|
const decoded = atob(split[1])
|
||||||
|
const [username, password] = decoded.split(":")
|
||||||
|
return username === ADMIN_USERNAME && password === ADMIN_PASSWORD
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuild the 11ty static pages
|
||||||
|
export const rebuild = async(req:Request):Promise<Response> => {
|
||||||
|
await db.rebuild()
|
||||||
|
return new Response("", { status: 201 })
|
||||||
|
}
|
||||||
|
|
||||||
// create an activity
|
// create an activity
|
||||||
const create = async (req:Request, inReplyTo:string|null = null):Promise<Response> => {
|
const create = async (req:Request, inReplyTo:string|null = null):Promise<Response> => {
|
||||||
const body = await req.json() as any
|
const body = await req.json()
|
||||||
|
|
||||||
if(!inReplyTo && body.object.inReplyTo) inReplyTo = body.object.inReplyTo
|
if(!inReplyTo && body.object.inReplyTo) inReplyTo = body.object.inReplyTo
|
||||||
|
|
||||||
|
@ -68,10 +82,10 @@ const create = async (req:Request, inReplyTo:string|null = null):Promise<Respons
|
||||||
object: { ...object }
|
object: { ...object }
|
||||||
}
|
}
|
||||||
|
|
||||||
return await outbox(activity, db)
|
return await outbox(activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
const idFromHandle = async(handle:string) => {
|
const follow = async (req:Request, handle:string):Promise<Response> => {
|
||||||
let url
|
let url
|
||||||
if(handle.startsWith('@')) handle = handle.substring(1)
|
if(handle.startsWith('@')) handle = handle.substring(1)
|
||||||
try {
|
try {
|
||||||
|
@ -83,7 +97,7 @@ const idFromHandle = async(handle:string) => {
|
||||||
if(!host) return new Response('account not url or name@domain.tld', { status: 400 })
|
if(!host) return new Response('account not url or name@domain.tld', { status: 400 })
|
||||||
|
|
||||||
const res = await fetch(`https://${host}/.well-known/webfinger/?resource=acct:${handle}`, { headers: { 'accept': 'application/jrd+json'}})
|
const res = await fetch(`https://${host}/.well-known/webfinger/?resource=acct:${handle}`, { headers: { 'accept': 'application/jrd+json'}})
|
||||||
const webfinger = await res.json() as any
|
const webfinger = await res.json()
|
||||||
if(!webfinger.links) return new Response("", { status: 404 })
|
if(!webfinger.links) return new Response("", { status: 404 })
|
||||||
|
|
||||||
const links:any[] = webfinger.links
|
const links:any[] = webfinger.links
|
||||||
|
@ -92,12 +106,7 @@ const idFromHandle = async(handle:string) => {
|
||||||
|
|
||||||
url = actorLink.href
|
url = actorLink.href
|
||||||
}
|
}
|
||||||
return url
|
console.log(`Following ${url}`)
|
||||||
}
|
|
||||||
|
|
||||||
const follow = async (req:Request, handle:string):Promise<Response> => {
|
|
||||||
const url = await idFromHandle(handle)
|
|
||||||
console.info(`Following ${url}`)
|
|
||||||
|
|
||||||
// send the follow request to the supplied actor
|
// send the follow request to the supplied actor
|
||||||
return await outbox({
|
return await outbox({
|
||||||
|
@ -106,27 +115,26 @@ const follow = async (req:Request, handle:string):Promise<Response> => {
|
||||||
actor: ACTOR.id,
|
actor: ACTOR.id,
|
||||||
object: url,
|
object: url,
|
||||||
to: [url, "https://www.w3.org/ns/activitystreams#Public"]
|
to: [url, "https://www.w3.org/ns/activitystreams#Public"]
|
||||||
}, db)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unfollow = async (req:Request, handle:string):Promise<Response> => {
|
const unfollow = async (req:Request, handle:string):Promise<Response> => {
|
||||||
const url = await idFromHandle(handle)
|
|
||||||
// check to see if we are already following. If not, just return success
|
// check to see if we are already following. If not, just return success
|
||||||
const existing = db.getFollowing(url)
|
const existing = await db.getFollowing(handle)
|
||||||
if (!existing) return new Response("", { status: 204 })
|
if (!existing) return new Response("", { status: 204 })
|
||||||
const activity = db.getOutboxActivity(existing.activity_id)
|
const activity = await db.getOutboxActivity(existing.id)
|
||||||
// outbox will also take care of the deletion
|
// outbox will also take care of the deletion
|
||||||
return await outbox({
|
return await outbox({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
type: "Undo",
|
type: "Undo",
|
||||||
actor: ACTOR.id,
|
actor: ACTOR.id,
|
||||||
object: activity,
|
object: activity,
|
||||||
to: activity?.to
|
to: activity.to
|
||||||
}, db)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const like = async (req:Request, object_url:string):Promise<Response> => {
|
const like = async (req:Request, object_url:string):Promise<Response> => {
|
||||||
const object = await (await fetchObject(object_url)).json() as any
|
const object = await (await fetchObject(object_url)).json()
|
||||||
|
|
||||||
return await outbox({
|
return await outbox({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
@ -135,27 +143,27 @@ const like = async (req:Request, object_url:string):Promise<Response> => {
|
||||||
object: object,
|
object: object,
|
||||||
to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"],
|
to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"],
|
||||||
cc: [ACTOR.followers]
|
cc: [ACTOR.followers]
|
||||||
}, db)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unlike = async (req:Request, activity_id:string):Promise<Response> => {
|
const unlike = async (req:Request, object_id:string):Promise<Response> => {
|
||||||
// check to see if we are already following. If not, just return success
|
// check to see if we are already following. If not, just return success
|
||||||
const liked = db.listLiked()
|
const liked = await db.listLiked()
|
||||||
let existing = liked?.find(o => o?.activity_id === activity_id)
|
let existing = liked.find(o => o.object_id === object_id)
|
||||||
if (!existing){
|
if (!existing){
|
||||||
const object = await (await fetchObject(activity_id)).json() as any
|
const object = await (await fetchObject(object_id)).json()
|
||||||
idsFromValue(object).forEach(id => {
|
idsFromValue(object).forEach(id => {
|
||||||
const e = liked?.find(o => o.activity_id === id)
|
const e = liked.find(o => o.object_id === id)
|
||||||
if(e) existing = e
|
if(e) existing = e
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!existing) return new Response("No like found to delete", { status: 204 })
|
if (!existing) return new Response("No like found to delete", { status: 204 })
|
||||||
const activity = db.getOutboxActivity(existing.activity_id)
|
const activity = await db.getOutboxActivity(existing.id)
|
||||||
return undo(activity)
|
return undo(activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dislike = async (req:Request, object_url:string):Promise<Response> => {
|
const dislike = async (req:Request, object_url:string):Promise<Response> => {
|
||||||
const object = await (await fetchObject(object_url)).json() as any
|
const object = await (await fetchObject(object_url)).json()
|
||||||
|
|
||||||
return await outbox({
|
return await outbox({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
@ -164,27 +172,27 @@ const dislike = async (req:Request, object_url:string):Promise<Response> => {
|
||||||
object: object,
|
object: object,
|
||||||
to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"],
|
to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"],
|
||||||
cc: [ACTOR.followers]
|
cc: [ACTOR.followers]
|
||||||
}, db)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const undislike = async (req:Request, object_id:string):Promise<Response> => {
|
const undislike = async (req:Request, object_id:string):Promise<Response> => {
|
||||||
// check to see if we are already following. If not, just return success
|
// check to see if we are already following. If not, just return success
|
||||||
const disliked = db.listDisliked()
|
const disliked = await db.listDisliked()
|
||||||
let existing = disliked?.find(o => o.activity_id === object_id)
|
let existing = disliked.find(o => o.object_id === object_id)
|
||||||
if (!existing){
|
if (!existing){
|
||||||
const object = await (await fetchObject(object_id)).json()
|
const object = await (await fetchObject(object_id)).json()
|
||||||
idsFromValue(object).forEach(id => {
|
idsFromValue(object).forEach(id => {
|
||||||
const e = disliked?.find(o => o.activity_id === id)
|
const e = disliked.find(o => o.object_id === id)
|
||||||
if(e) existing = e
|
if(e) existing = e
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!existing) return new Response("No dislike found to delete", { status: 204 })
|
if (!existing) return new Response("No dislike found to delete", { status: 204 })
|
||||||
const activity = db.getOutboxActivity(existing.activity_id)
|
const activity = await db.getOutboxActivity(existing.id)
|
||||||
return undo(activity)
|
return undo(activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
const share = async (req:Request, object_url:string):Promise<Response> => {
|
const share = async (req:Request, object_url:string):Promise<Response> => {
|
||||||
const object = await (await fetchObject(object_url)).json() as any
|
const object = await (await fetchObject(object_url)).json()
|
||||||
|
|
||||||
return await outbox({
|
return await outbox({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
@ -193,22 +201,22 @@ const share = async (req:Request, object_url:string):Promise<Response> => {
|
||||||
object: object,
|
object: object,
|
||||||
to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"],
|
to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"],
|
||||||
cc: [ACTOR.followers]
|
cc: [ACTOR.followers]
|
||||||
}, db)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unshare = async (req:Request, object_id:string):Promise<Response> => {
|
const unshare = async (req:Request, object_id:string):Promise<Response> => {
|
||||||
// check to see if we are already following. If not, just return success
|
// check to see if we are already following. If not, just return success
|
||||||
const shared = db.listShared()
|
const shared = await db.listShared()
|
||||||
let existing = shared?.find(o => o.activity_id === object_id)
|
let existing = shared.find(o => o.object_id === object_id)
|
||||||
if (!existing){
|
if (!existing){
|
||||||
const object = await (await fetchObject(object_id)).json()
|
const object = await (await fetchObject(object_id)).json()
|
||||||
idsFromValue(object).forEach(id => {
|
idsFromValue(object).forEach(id => {
|
||||||
const e = shared?.find(o => o.activity_id === id)
|
const e = shared.find(o => o.object_id === id)
|
||||||
if(e) existing = e
|
if(e) existing = e
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!existing) return new Response("No share found to delete", { status: 204 })
|
if (!existing) return new Response("No share found to delete", { status: 204 })
|
||||||
const activity = db.getOutboxActivity(existing.activity_id)
|
const activity = await db.getOutboxActivity(existing.id)
|
||||||
return undo(activity)
|
return undo(activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -221,23 +229,23 @@ const undo = async(activity:any):Promise<Response> => {
|
||||||
object: activity,
|
object: activity,
|
||||||
to: activity.to,
|
to: activity.to,
|
||||||
cc: activity.cc
|
cc: activity.cc
|
||||||
}, db)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletePost = async (req:Request, id:string):Promise<Response> => {
|
const deletePost = async (req:Request, id:string):Promise<Response> => {
|
||||||
const post = db.getPostByURL(id)
|
const post = await db.getPostByURL(id)
|
||||||
if(!post) return new Response("", { status: 404 })
|
if(!post) return new Response("", { status: 404 })
|
||||||
const activity = db.getOutboxActivity(post.activity_id)
|
const activity = await db.getOutboxActivity(post.local_id)
|
||||||
return await outbox({
|
return await outbox({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
type: "Delete",
|
type: "Delete",
|
||||||
actor: ACTOR.id,
|
actor: ACTOR.id,
|
||||||
to: activity?.to,
|
to: activity.to,
|
||||||
cc: activity?.cc,
|
cc: activity.cc,
|
||||||
// audience: activity?.audience,
|
audience: activity.audience,
|
||||||
object: {
|
object: {
|
||||||
id,
|
id,
|
||||||
type: "Tombstone"
|
type: "Tombstone"
|
||||||
}
|
}
|
||||||
}, db)
|
})
|
||||||
}
|
}
|
618
src/db.ts
|
@ -1,427 +1,229 @@
|
||||||
import { Database, Statement } from "bun:sqlite"
|
import { ACTIVITY_INBOX_PATH, ACTIVITY_OUTBOX_PATH, CONTENT_PATH, DATA_PATH, POSTS_PATH, STATIC_PATH } from "./env"
|
||||||
import { existsSync, mkdirSync } from "fs"
|
import path from "path"
|
||||||
import { dirname } from "path"
|
import { readdir } from "fs/promises"
|
||||||
import { DB_PATH } from "./env"
|
import { unlinkSync } from "node:fs"
|
||||||
|
import { fetchObject } from "./request"
|
||||||
|
import { idsFromValue } from "./activitypub"
|
||||||
|
import ACTOR from "../actor"
|
||||||
|
const matter = require('gray-matter')
|
||||||
|
const Eleventy = require("@11ty/eleventy")
|
||||||
|
|
||||||
export type ActivityObject = {
|
// rebuild the 11ty static pages
|
||||||
activity_id: string
|
export async function rebuild() {
|
||||||
id: string
|
console.info(`Building 11ty from ${CONTENT_PATH}, to ${STATIC_PATH}`)
|
||||||
published: string
|
await new Eleventy(CONTENT_PATH, STATIC_PATH, { configPath: '.eleventy.js' }).write()
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Activity = ActivityObject & {
|
export async function createInboxActivity(activity:any, object_id:any) {
|
||||||
type: string
|
const activityFile = Bun.file(path.join(ACTIVITY_INBOX_PATH, `${object_id}.activity.json`))
|
||||||
actor: string
|
await Bun.write(activityFile, JSON.stringify(activity))
|
||||||
published: string
|
|
||||||
to: string[]
|
|
||||||
cc?: string[]
|
|
||||||
object?: any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Following = ActivityObject & {
|
export async function createOutboxActivity(activity:any, object_id:any) {
|
||||||
accepted?: boolean
|
const activityFile = Bun.file(path.join(ACTIVITY_OUTBOX_PATH, `${object_id}.activity.json`))
|
||||||
|
await Bun.write(activityFile, JSON.stringify(activity))
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Post = ActivityObject & {
|
export async function getInboxActivity(id:string) {
|
||||||
attributedTo: string
|
const file = Bun.file(path.join(ACTIVITY_INBOX_PATH, `${id}.activity.json`))
|
||||||
type: string
|
return await file.json()
|
||||||
content?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ActivityPubDB {
|
export async function getOutboxActivity(id:string) {
|
||||||
// The Database
|
const file = Bun.file(path.join(ACTIVITY_OUTBOX_PATH, `${id}.activity.json`))
|
||||||
db: Database
|
return await file.json()
|
||||||
|
}
|
||||||
|
|
||||||
// Cached Statements
|
export async function listInboxActivities() {
|
||||||
|
return await Promise.all(
|
||||||
|
(await readdir(ACTIVITY_INBOX_PATH)).filter(v => v.endsWith('.activity.json'))
|
||||||
|
.map(async filename => await Bun.file(path.join(ACTIVITY_INBOX_PATH, filename)).json())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
export async function listOutboxActivities() {
|
||||||
const dir = dirname(DB_PATH)
|
return await Promise.all(
|
||||||
if(!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
(await readdir(ACTIVITY_OUTBOX_PATH)).filter(v => v.endsWith('.activity.json'))
|
||||||
this.db = new Database(DB_PATH, {create:true, readwrite:true})
|
.map(async filename => await Bun.file(path.join(ACTIVITY_OUTBOX_PATH, filename)).json())
|
||||||
this.migrate()
|
)
|
||||||
this.cacheQueries()
|
}
|
||||||
|
|
||||||
|
export async function createPost(post_object:any, object_id:string) {
|
||||||
|
const file = Bun.file(path.join(POSTS_PATH, `${object_id}.md`))
|
||||||
|
let {type, object, inReplyTo} = post_object
|
||||||
|
if(inReplyTo && typeof inReplyTo === 'string') inReplyTo = await fetchObject(inReplyTo)
|
||||||
|
|
||||||
|
if(object){
|
||||||
|
let { content, published, id, attributedTo } = object
|
||||||
|
if(content as string) content = '> ' + content.replace('\n', '\n> ') + '\n'
|
||||||
|
else content = ""
|
||||||
|
|
||||||
|
content += post_object.content || ""
|
||||||
|
//TODO: add appropriate content for different types (e.g. like, etc)
|
||||||
|
const data:any = { id, published, attributedTo, type }
|
||||||
|
if(inReplyTo) data.inReplyTo = idsFromValue(inReplyTo).at(0)
|
||||||
|
await Bun.write(file, matter.stringify(content, data))
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
const { content, published, id, attributedTo } = post_object
|
||||||
|
|
||||||
close() {
|
let reply_content = ""
|
||||||
this.db.close()
|
if(!object && inReplyTo) {
|
||||||
}
|
reply_content = inReplyTo.content
|
||||||
|
if(reply_content as string) reply_content = '> ' + reply_content.replace('\n', '\n> ') + '\n'
|
||||||
migrate() {
|
else reply_content = ""
|
||||||
let version = (this.db.query("PRAGMA user_version;").get() as {user_version:number})?.user_version
|
|
||||||
|
|
||||||
const statements:Statement[] = []
|
|
||||||
|
|
||||||
switch(version) {
|
|
||||||
case 0:
|
|
||||||
this.db.exec("PRAGMA journal_mode = WAL;")
|
|
||||||
|
|
||||||
// Create the inbox table
|
|
||||||
statements.push(this.db.prepare(`CREATE TABLE [inbox] (
|
|
||||||
[activity_id] TEXT NOT NULL PRIMARY KEY,
|
|
||||||
[id] TEXT NOT NULL,
|
|
||||||
[type] TEXT NOT NULL,
|
|
||||||
[actor] TEXT NOT NULL,
|
|
||||||
[published] TEXT NOT NULL,
|
|
||||||
[to] TEXT,
|
|
||||||
[cc] TEXT,
|
|
||||||
[activity] TEXT NOT NULL
|
|
||||||
)`))
|
|
||||||
|
|
||||||
// Create the outbox table
|
|
||||||
statements.push(this.db.prepare(`CREATE TABLE [outbox] (
|
|
||||||
[activity_id] TEXT NOT NULL PRIMARY KEY,
|
|
||||||
[id] TEXT NOT NULL,
|
|
||||||
[type] TEXT NOT NULL,
|
|
||||||
[actor] TEXT NOT NULL,
|
|
||||||
[published] TEXT NOT NULL,
|
|
||||||
[to] TEXT,
|
|
||||||
[cc] TEXT,
|
|
||||||
[activity] TEXT NOT NULL
|
|
||||||
)`))
|
|
||||||
|
|
||||||
// Create the following table
|
|
||||||
statements.push(this.db.prepare(`CREATE TABLE [following] (
|
|
||||||
[activity_id] TEXT NOT NULL PRIMARY KEY,
|
|
||||||
[id] TEXT NOT NULL,
|
|
||||||
[published] TEXT NOT NULL,
|
|
||||||
[accepted] INTEGER
|
|
||||||
)`))
|
|
||||||
|
|
||||||
// Create the followers table
|
|
||||||
statements.push(this.db.prepare(`CREATE TABLE [followers] (
|
|
||||||
[activity_id] TEXT NOT NULL PRIMARY KEY,
|
|
||||||
[id] TEXT NOT NULL,
|
|
||||||
[published] TEXT NOT NULL
|
|
||||||
)`))
|
|
||||||
|
|
||||||
// Create the liked table
|
|
||||||
statements.push(this.db.prepare(`CREATE TABLE [liked] (
|
|
||||||
[activity_id] TEXT NOT NULL PRIMARY KEY,
|
|
||||||
[id] TEXT NOT NULL,
|
|
||||||
[published] TEXT NOT NULL
|
|
||||||
)`))
|
|
||||||
|
|
||||||
// Create the disliked table
|
|
||||||
statements.push(this.db.prepare(`CREATE TABLE [disliked] (
|
|
||||||
[activity_id] TEXT NOT NULL PRIMARY KEY,
|
|
||||||
[id] TEXT NOT NULL,
|
|
||||||
[published] TEXT NOT NULL
|
|
||||||
)`))
|
|
||||||
|
|
||||||
// Create the shared table
|
|
||||||
statements.push(this.db.prepare(`CREATE TABLE [shared] (
|
|
||||||
[activity_id] TEXT NOT NULL PRIMARY KEY,
|
|
||||||
[id] TEXT NOT NULL,
|
|
||||||
[published] TEXT NOT NULL
|
|
||||||
)`))
|
|
||||||
|
|
||||||
version = 1
|
|
||||||
case 1:
|
|
||||||
// Create the posts table
|
|
||||||
statements.push(this.db.prepare(`CREATE TABLE [posts] (
|
|
||||||
[activity_id] TEXT NOT NULL PRIMARY KEY,
|
|
||||||
[id] TEXT NOT NULL,
|
|
||||||
[published] TEXT NOT NULL,
|
|
||||||
[attributedTo] TEXT,
|
|
||||||
[type] TEXT NOT NULL,
|
|
||||||
[content] TEXT,
|
|
||||||
[object] TEXT
|
|
||||||
)`))
|
|
||||||
version = 2
|
|
||||||
case 2: break;
|
|
||||||
default: break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const migration = this.db.transaction((statements:Statement[]) => {
|
const data:any = { id, published, attributedTo, type }
|
||||||
statements.forEach(s => {
|
if(inReplyTo) data.inReplyTo = idsFromValue(inReplyTo).at(0)
|
||||||
s.run();
|
await Bun.write(file, matter.stringify((reply_content || "") + (content || ""), data))
|
||||||
s.finalize()
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
if(migration(statements)) this.db.run(`PRAGMA user_version=${version}`)
|
|
||||||
}
|
}
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPost(id:string) {
|
||||||
queries:{
|
const file = Bun.file(path.join(POSTS_PATH, `${id}.md`))
|
||||||
createInboxActivity?:Statement,
|
const { data, content } = matter(await file.text())
|
||||||
getInboxActivity?:Statement,
|
return {
|
||||||
listInboxActivities?:Statement,
|
...data,
|
||||||
createOutboxActivity?:Statement,
|
content: content.trim(),
|
||||||
getOutboxActivity?:Statement,
|
local_id: id
|
||||||
listOutboxActivities?:Statement,
|
|
||||||
createFollowing?:Statement,
|
|
||||||
getFollowing?:Statement,
|
|
||||||
deleteFollowing?:Statement,
|
|
||||||
listFollowing?:Statement,
|
|
||||||
acceptFollowing?:Statement,
|
|
||||||
createFollower?:Statement,
|
|
||||||
getFollower?:Statement,
|
|
||||||
deleteFollower?:Statement,
|
|
||||||
listFollowers?:Statement,
|
|
||||||
createLiked?:Statement,
|
|
||||||
getLiked?:Statement,
|
|
||||||
deleteLiked?:Statement,
|
|
||||||
listLiked?:Statement,
|
|
||||||
createDisliked?:Statement,
|
|
||||||
getDisliked?:Statement,
|
|
||||||
deleteDisliked?:Statement,
|
|
||||||
listDisliked?:Statement,
|
|
||||||
createShared?:Statement,
|
|
||||||
getShared?:Statement,
|
|
||||||
deleteShared?:Statement,
|
|
||||||
listShared?:Statement,
|
|
||||||
createPost?:Statement,
|
|
||||||
getPost?:Statement,
|
|
||||||
getPostByURL?:Statement,
|
|
||||||
deletePost?:Statement,
|
|
||||||
listPosts?:Statement
|
|
||||||
} = {}
|
|
||||||
|
|
||||||
cacheQueries() {
|
|
||||||
// inbox queries
|
|
||||||
this.queries.createInboxActivity = this.db.query(`INSERT INTO [inbox]
|
|
||||||
([activity_id], [id], [type], [actor], [published], [to], [cc], [activity])
|
|
||||||
VALUES ($activity_id, $id, $type, $actor, $published, $to, $cc, $activity)`)
|
|
||||||
this.queries.getInboxActivity = this.db.query(`SELECT * FROM [inbox] WHERE [activity_id]=$activity_id`)
|
|
||||||
this.queries.listInboxActivities = this.db.query(`SELECT * FROM [inbox] ORDER BY [published] DESC`)
|
|
||||||
|
|
||||||
// outbox queries
|
|
||||||
this.queries.createOutboxActivity = this.db.query(`INSERT INTO [outbox]
|
|
||||||
([activity_id], [id], [type], [actor], [published], [to], [cc], [activity])
|
|
||||||
VALUES ($activity_id, $id, $type, $actor, $published, $to, $cc, $activity)`)
|
|
||||||
this.queries.getOutboxActivity = this.db.query(`SELECT * FROM [outbox] WHERE [activity_id]=$activity_id`)
|
|
||||||
this.queries.listOutboxActivities = this.db.query(`SELECT * FROM [outbox] ORDER BY [published] DESC`)
|
|
||||||
|
|
||||||
// following queries
|
|
||||||
this.queries.createFollowing = this.db.query(`INSERT INTO [following]
|
|
||||||
([activity_id], [id], [published], [accepted])
|
|
||||||
VALUES ($activity_id, $id, $published, NULL)`)
|
|
||||||
this.queries.getFollowing = this.db.query('SELECT * FROM [following] WHERE [id]=$id')
|
|
||||||
this.queries.deleteFollowing = this.db.query('DELETE FROM [following] WHERE [id]=$id')
|
|
||||||
this.queries.listFollowing = this.db.query('SELECT * FROM [following] ORDER BY [published] DESC')
|
|
||||||
this.queries.acceptFollowing = this.db.query('UPDATE [following] SET [accepted]=TRUE WHERE [id]=$id')
|
|
||||||
|
|
||||||
// follower queries
|
|
||||||
this.queries.createFollower = this.db.query(`INSERT INTO [followers]
|
|
||||||
([activity_id], [id], [published])
|
|
||||||
VALUES ($activity_id, $id, $published)`)
|
|
||||||
this.queries.getFollower = this.db.query('SELECT * FROM [followers] WHERE [id]=$id')
|
|
||||||
this.queries.deleteFollower = this.db.query('DELETE FROM [followers] WHERE [id]=$id')
|
|
||||||
this.queries.listFollowers = this.db.query('SELECT * FROM [followers] ORDER BY [published] DESC')
|
|
||||||
|
|
||||||
// liked queries
|
|
||||||
this.queries.createLiked = this.db.query(`INSERT INTO [liked]
|
|
||||||
([activity_id], [id], [published])
|
|
||||||
VALUES ($activity_id, $id, $published)`)
|
|
||||||
this.queries.getLiked = this.db.query('SELECT * FROM [liked] WHERE [id]=$id')
|
|
||||||
this.queries.deleteLiked = this.db.query('DELETE FROM [liked] WHERE [id]=$id')
|
|
||||||
this.queries.listLiked = this.db.query('SELECT * FROM [liked] ORDER BY [published] DESC')
|
|
||||||
|
|
||||||
// disliked queries
|
|
||||||
this.queries.createDisliked = this.db.query(`INSERT INTO [disliked]
|
|
||||||
([activity_id], [id], [published])
|
|
||||||
VALUES ($activity_id, $id, $published)`)
|
|
||||||
this.queries.getDisliked = this.db.query('SELECT * FROM [disliked] WHERE [id]=$id')
|
|
||||||
this.queries.deleteDisliked = this.db.query('DELETE FROM [disliked] WHERE [id]=$id')
|
|
||||||
this.queries.listDisliked = this.db.query('SELECT * FROM [disliked] ORDER BY [published] DESC')
|
|
||||||
|
|
||||||
// shared queries
|
|
||||||
this.queries.createShared = this.db.query(`INSERT INTO [shared]
|
|
||||||
([activity_id], [id], [published])
|
|
||||||
VALUES ($activity_id, $id, $published)`)
|
|
||||||
this.queries.getShared = this.db.query('SELECT * FROM [shared] WHERE [id]=$id')
|
|
||||||
this.queries.deleteShared = this.db.query('DELETE FROM [shared] WHERE [id]=$id')
|
|
||||||
this.queries.listShared = this.db.query('SELECT * FROM [shared] ORDER BY [published] DESC')
|
|
||||||
|
|
||||||
// post queries
|
|
||||||
this.queries.createPost = this.db.query(`INSERT INTO [posts]
|
|
||||||
([activity_id], [id], [published], [attributedTo], [type], [content], [object])
|
|
||||||
VALUES ($activity_id, $id, $published, $attributedTo, $type, $content, $object)`)
|
|
||||||
this.queries.getPost = this.db.query('SELECT * FROM [posts] WHERE [activity_id]=$activity_id')
|
|
||||||
this.queries.getPostByURL = this.db.query('SELECT * FROM [posts] WHERE [id]=$id')
|
|
||||||
this.queries.deletePost = this.db.query('DELETE FROM [posts] WHERE [id]=$id')
|
|
||||||
this.queries.listPosts = this.db.query('SELECT * FROM [posts] ORDER BY [published] DESC')
|
|
||||||
}
|
|
||||||
|
|
||||||
createInboxActivity(activity_id:string, activity:any) {
|
|
||||||
//new Date(activity.published).getTime().toString(36)
|
|
||||||
return this.queries.createInboxActivity?.run({
|
|
||||||
$activity_id: activity_id,
|
|
||||||
$id: activity.id,
|
|
||||||
$type: activity.type,
|
|
||||||
$actor: activity.actor,
|
|
||||||
$published: activity.published || new Date().toISOString(),
|
|
||||||
$to: JSON.stringify(activity.to),
|
|
||||||
$cc: JSON.stringify(activity.cc),
|
|
||||||
$activity: JSON.stringify(activity)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getInboxActivity(activity_id:string):Activity | undefined {
|
|
||||||
return JSON.parse((this.queries.getInboxActivity?.get(activity_id) as any).activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
listInboxActivities():Activity[] | undefined {
|
|
||||||
return this.queries.listInboxActivities?.all().map(r => JSON.parse((r as any).activity))
|
|
||||||
}
|
|
||||||
|
|
||||||
createOutboxActivity(activity_id:string, activity:any) {
|
|
||||||
//new Date(activity.published).getTime().toString(36)
|
|
||||||
return this.queries.createOutboxActivity?.run({
|
|
||||||
$activity_id: activity_id,
|
|
||||||
$id: activity.id,
|
|
||||||
$type: activity.type,
|
|
||||||
$actor: activity.actor,
|
|
||||||
$published: activity.published || new Date().toISOString(),
|
|
||||||
$to: JSON.stringify(activity.to),
|
|
||||||
$cc: JSON.stringify(activity.cc),
|
|
||||||
$activity: JSON.stringify(activity)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getOutboxActivity(activity_id:string): Activity | undefined {
|
|
||||||
return JSON.parse((this.queries.getOutboxActivity?.get(activity_id) as any).activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
listOutboxActivities(): Activity[] | undefined {
|
|
||||||
return this.queries.listOutboxActivities?.all().map(r => JSON.parse((r as any).activity))
|
|
||||||
}
|
|
||||||
|
|
||||||
createFollowing(activity_id:string, id:string, published:string | Date) {
|
|
||||||
if(published instanceof Date) published = published.toISOString()
|
|
||||||
return this.queries.createFollowing?.run({
|
|
||||||
$activity_id: activity_id,
|
|
||||||
$id: id,
|
|
||||||
$published: published
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getFollowing(id:string): Following | undefined {
|
|
||||||
return this.queries.getFollowing?.get(id) as any
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteFollowing(id:string) {
|
|
||||||
return this.queries.deleteFollowing?.run(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
listFollowing(): Following[] | undefined {
|
|
||||||
return this.queries.listFollowing?.all() as Following[]
|
|
||||||
}
|
|
||||||
|
|
||||||
acceptFollowing(id:string) {
|
|
||||||
return this.queries.acceptFollowing?.run(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
createFollower(activity_id:string, id:string, published:string | Date) {
|
|
||||||
if(published instanceof Date) published = published.toISOString()
|
|
||||||
return this.queries.createFollower?.run({
|
|
||||||
$activity_id: activity_id,
|
|
||||||
$id: id,
|
|
||||||
$published: published
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getFollower(id:string): ActivityObject | undefined {
|
|
||||||
return this.queries.getFollower?.get(id) as ActivityObject
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteFollower(id:string) {
|
|
||||||
return this.queries.deleteFollower?.run(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
listFollowers(): ActivityObject[] | undefined {
|
|
||||||
return this.queries.listFollowers?.all().map(x => x as ActivityObject)
|
|
||||||
}
|
|
||||||
|
|
||||||
createLiked(activity_id:string, id:string, published:string | Date) {
|
|
||||||
if(published instanceof Date) published = published.toISOString()
|
|
||||||
return this.queries.createLiked?.run({
|
|
||||||
$activity_id: activity_id,
|
|
||||||
$id: id,
|
|
||||||
$published: published
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getLiked(id:string): ActivityObject | undefined {
|
|
||||||
return this.queries.getLiked?.get(id) as ActivityObject
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteLiked(id:string) {
|
|
||||||
return this.queries.deleteLiked?.run(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
listLiked(): ActivityObject[] | undefined {
|
|
||||||
return this.queries.listLiked?.all().map(x => x as ActivityObject)
|
|
||||||
}
|
|
||||||
|
|
||||||
createDisliked(activity_id:string, id:string, published:string | Date) {
|
|
||||||
if(published instanceof Date) published = published.toISOString()
|
|
||||||
return this.queries.createDisliked?.run({
|
|
||||||
$activity_id: activity_id,
|
|
||||||
$id: id,
|
|
||||||
$published: published
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getDisliked(id:string): ActivityObject | undefined {
|
|
||||||
return this.queries.getDisliked?.get(id) as ActivityObject
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteDisliked(id:string) {
|
|
||||||
return this.queries.deleteDisliked?.run(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
listDisliked(): ActivityObject[] | undefined {
|
|
||||||
return this.queries.listDisliked?.all().map(x => x as ActivityObject)
|
|
||||||
}
|
|
||||||
|
|
||||||
createShared(activity_id:string, id:string, published:string | Date) {
|
|
||||||
if(published instanceof Date) published = published.toISOString()
|
|
||||||
return this.queries.createShared?.run({
|
|
||||||
$activity_id: activity_id,
|
|
||||||
$id: id,
|
|
||||||
$published: published
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getShared(id:string): ActivityObject | undefined {
|
|
||||||
return this.queries.getShared?.get(id) as ActivityObject
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteShared(id:string) {
|
|
||||||
return this.queries.deleteShared?.run(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
listShared(): ActivityObject[] | undefined {
|
|
||||||
return this.queries.listShared?.all().map(x => x as ActivityObject)
|
|
||||||
}
|
|
||||||
|
|
||||||
createPost(activity_id:string, object:any) {
|
|
||||||
return this.queries.createPost?.run({
|
|
||||||
$activity_id: activity_id,
|
|
||||||
$id: object.id,
|
|
||||||
$published: object.published,
|
|
||||||
$attributedTo: object.attributedTo,
|
|
||||||
$type: object.type,
|
|
||||||
$content: object.content,
|
|
||||||
$object: JSON.stringify(object)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getPost(activity_id:string): Post | undefined {
|
|
||||||
return JSON.parse((this.queries.getPost?.get(activity_id) as any).object)
|
|
||||||
}
|
|
||||||
|
|
||||||
getPostByURL(id:string): Post | undefined {
|
|
||||||
return JSON.parse((this.queries.getPostByURL?.get(id) as any).object)
|
|
||||||
}
|
|
||||||
|
|
||||||
deletePost(activity_id:string) {
|
|
||||||
return this.queries.deletePost?.run(activity_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
listPosts(): Post[] | undefined {
|
|
||||||
return this.queries.listPosts?.all().map(p => JSON.parse((p as any).object))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPostByURL(url_id:string) {
|
||||||
|
if(!url_id || !url_id.startsWith(ACTOR.url + '/posts/')) return null
|
||||||
|
const match = url_id.match(/\/([0-9a-f]+)\/?$/)
|
||||||
|
const local_id = match ? match[1] : url_id
|
||||||
|
return await getPost(local_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePost(id:string) {
|
||||||
|
unlinkSync(path.join(POSTS_PATH, id + '.md'))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPosts() {
|
||||||
|
return await Promise.all((await readdir(POSTS_PATH)).filter(v => v.endsWith('.md')).map(async filename => await getPost(filename.slice(0, -3))))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFollowing(handle:string, id:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `following.json`))
|
||||||
|
const following_list = await file.json() as Array<any>
|
||||||
|
if(!following_list.find(v => v.id === id || v.handle === handle)) following_list.push({id, handle, createdAt: new Date().toISOString()})
|
||||||
|
await Bun.write(file, JSON.stringify(following_list))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFollowing(handle:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `following.json`))
|
||||||
|
const following_list = await file.json() as Array<any>
|
||||||
|
await Bun.write(file, JSON.stringify(following_list.filter(v => v.handle !== handle)))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFollowing(handle:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `following.json`))
|
||||||
|
const following_list = await file.json() as Array<any>
|
||||||
|
return following_list.find(v => v.handle === handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFollowing(onlyAccepted = true) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `following.json`))
|
||||||
|
return ((await file.json()) as Array<any>).filter(f => !onlyAccepted || f.accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptFollowing(handle:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `following.json`))
|
||||||
|
const following_list = await file.json() as Array<any>
|
||||||
|
const following = following_list.find(v => v.handle === handle)
|
||||||
|
if(following) following.accepted = new Date().toISOString()
|
||||||
|
await Bun.write(file, JSON.stringify(following_list))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFollower(actor:string, id:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
|
||||||
|
const followers_list = await file.json() as Array<any>
|
||||||
|
if(!followers_list.find(v => v.id === id || v.actor === actor)) followers_list.push({id, actor, createdAt: new Date().toISOString()})
|
||||||
|
await Bun.write(file, JSON.stringify(followers_list))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFollower(actor:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
|
||||||
|
const followers_list = await file.json() as Array<any>
|
||||||
|
await Bun.write(file, JSON.stringify(followers_list.filter(v => v.actor !== actor)))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFollower(actor:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
|
||||||
|
const followers_list = await file.json() as Array<any>
|
||||||
|
return followers_list.find(v => v.actor === actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFollowers() {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
|
||||||
|
return await file.json() as Array<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLiked(object_id:string, id:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `liked.json`))
|
||||||
|
const liked_list = await file.json() as Array<any>
|
||||||
|
if(!liked_list.find(v => v.object_id === object_id)) liked_list.push({id, object_id, createdAt: new Date().toISOString()})
|
||||||
|
await Bun.write(file, JSON.stringify(liked_list))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLiked(object_id:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `liked.json`))
|
||||||
|
const liked_list = await file.json() as Array<any>
|
||||||
|
await Bun.write(file, JSON.stringify(liked_list.filter(v => v.object_id !== object_id)))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLiked() {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `liked.json`))
|
||||||
|
return await file.json() as Array<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDisliked(object_id:string, id:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `disliked.json`))
|
||||||
|
const disliked_list = await file.json() as Array<any>
|
||||||
|
if(!disliked_list.find(v => v.object_id === object_id)) disliked_list.push({id, object_id, createdAt: new Date().toISOString()})
|
||||||
|
await Bun.write(file, JSON.stringify(disliked_list))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDisliked(object_id:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `disliked.json`))
|
||||||
|
const disliked_list = await file.json() as Array<any>
|
||||||
|
await Bun.write(file, JSON.stringify(disliked_list.filter(v => v.object_id !== object_id)))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDisliked() {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `disliked.json`))
|
||||||
|
return await file.json() as Array<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createShared(object_id:string, id:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `shared.json`))
|
||||||
|
const shared_list = await file.json() as Array<any>
|
||||||
|
if(!shared_list.find(v => v.object_id === object_id)) shared_list.push({id, object_id, createdAt: new Date().toISOString()})
|
||||||
|
await Bun.write(file, JSON.stringify(shared_list))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteShared(object_id:string) {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `shared.json`))
|
||||||
|
const shared_list = await file.json() as Array<any>
|
||||||
|
await Bun.write(file, JSON.stringify(shared_list.filter(v => v.object_id !== object_id)))
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listShared() {
|
||||||
|
const file = Bun.file(path.join(DATA_PATH, `shared.json`))
|
||||||
|
return await file.json() as Array<any>
|
||||||
|
}
|
43
src/env.ts
|
@ -1,37 +1,52 @@
|
||||||
import forge from "node-forge" // Bun does not implement the required node:crypto functions
|
import forge from "node-forge" // import crypto from "node:crypto"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
// the endpoint for auth token validation
|
// set up username and password for admin actions
|
||||||
export const TOKEN_ENDPOINT = process.env.TOKEN_ENDPOINT
|
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "";
|
||||||
export const WEB_SITE_HOSTNAME = process.env.WEB_SITE_HOSTNAME
|
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "";
|
||||||
|
|
||||||
export const HOSTNAME = process.env.HOSTNAME || "localhost"
|
// get the hostname (`PROJECT_DOMAIN` is set via glitch, but since we're using Bun now, this won't matter)
|
||||||
|
export const HOSTNAME = /*(process.env.PROJECT_DOMAIN && `${process.env.PROJECT_DOMAIN}.glitch.me`) ||*/ process.env.HOSTNAME || "localhost"
|
||||||
|
export const NODE_ENV = process.env.NODE_ENV || "development"
|
||||||
export const PORT = process.env.PORT || "3000"
|
export const PORT = process.env.PORT || "3000"
|
||||||
export const BASE_URL = (HOSTNAME === "localhost" ? "http://" : "https://") + HOSTNAME
|
|
||||||
|
|
||||||
export const NODE_ENV = process.env.NODE_ENV || "production"
|
export const BASE_URL = (HOSTNAME === "localhost" ? "http://" : "https://") + HOSTNAME
|
||||||
|
|
||||||
// in development, generate a key pair to make it easier to get started
|
// in development, generate a key pair to make it easier to get started
|
||||||
const keypair =
|
const keypair =
|
||||||
NODE_ENV === "development"
|
NODE_ENV === "development"
|
||||||
? forge.pki.rsa.generateKeyPair({bits: 4096})
|
? forge.pki.rsa.generateKeyPair({bits: 4096}) //crypto.generateKeyPairSync("rsa", { modulusLength: 4096 })
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
export const PUBLIC_KEY =
|
export const PUBLIC_KEY =
|
||||||
process.env.PUBLIC_KEY ||
|
process.env.PUBLIC_KEY ||
|
||||||
(keypair && forge.pki.publicKeyToPem(keypair.publicKey)) ||
|
(keypair && forge.pki.publicKeyToPem(keypair.publicKey)) || //keypair?.publicKey.export({ type: "spki", format: "pem" }) ||
|
||||||
""
|
""
|
||||||
export const PRIVATE_KEY =
|
export const PRIVATE_KEY =
|
||||||
process.env.PRIVATE_KEY ||
|
process.env.PRIVATE_KEY ||
|
||||||
(keypair && forge.pki.privateKeyToPem(keypair.privateKey)) ||
|
(keypair && forge.pki.privateKeyToPem(keypair.privateKey)) || //keypair?.privateKey.export({ type: "pkcs8", format: "pem" }) ||
|
||||||
""
|
""
|
||||||
|
|
||||||
export const DATA_PATH = process.env.DATA_PATH || "/data"
|
export const STATIC_PATH = path.join('.', '_site')
|
||||||
export const DB_PATH = process.env.DB_PATH || path.join(DATA_PATH, "db.sqlite")
|
export const CONTENT_PATH = path.join('.', '_content')
|
||||||
|
export const POSTS_PATH = path.join(CONTENT_PATH, "posts")
|
||||||
|
export const DATA_PATH = path.join(CONTENT_PATH, "_data")
|
||||||
|
export const ACTIVITY_INBOX_PATH = path.join(DATA_PATH, "_inbox")
|
||||||
|
export const ACTIVITY_OUTBOX_PATH = path.join(DATA_PATH, "_outbox")
|
||||||
|
|
||||||
|
export const DEFAULT_DOCUMENTS = process.env.DEFAULT_DOCUMENTS || [
|
||||||
|
'index.html',
|
||||||
|
'index.shtml',
|
||||||
|
'index.htm',
|
||||||
|
'Index.html',
|
||||||
|
'Index.shtml',
|
||||||
|
'Index.htm',
|
||||||
|
'default.html',
|
||||||
|
'default.htm'
|
||||||
|
]
|
||||||
|
|
||||||
export const activityPubTypes = [
|
export const activityPubTypes = [
|
||||||
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||||
'application/ld+json; profile="http://www.w3.org/ns/activitystreams"',
|
|
||||||
'application/activity+json'
|
'application/activity+json'
|
||||||
]
|
]
|
||||||
export const contentTypeHeader = { 'Content-Type': activityPubTypes[0] }
|
export const contentTypeHeader = { 'Content-Type': activityPubTypes[0]}
|
32
src/inbox.ts
|
@ -1,40 +1,38 @@
|
||||||
|
import { idsFromValue } from "./activitypub"
|
||||||
|
import * as db from "./db"
|
||||||
import outbox from "./outbox"
|
import outbox from "./outbox"
|
||||||
import { defaultHeaders, idsFromValue, send } from "./request"
|
import { send } from "./request"
|
||||||
import ACTOR from "../actor"
|
import ACTOR from "../actor"
|
||||||
import ActivityPubDB from "./db"
|
|
||||||
|
|
||||||
let db:ActivityPubDB
|
export default async function inbox(activity:any) {
|
||||||
export default async function inbox(activity:any, req:Request, database:ActivityPubDB) {
|
|
||||||
db = database
|
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
// get the main recipients ([...new Set()] is to dedupe)
|
// get the main recipients ([...new Set()] is to dedupe)
|
||||||
const recipientList = [...new Set([...idsFromValue(activity.to), ...idsFromValue(activity.cc), ...idsFromValue(activity.audience)])]
|
const recipientList = [...new Set([...idsFromValue(activity.to), ...idsFromValue(activity.cc), ...idsFromValue(activity.audience)])]
|
||||||
|
|
||||||
// if my list of followers in the list of recipients, then forward to them as well
|
// if my list of followers in the list of recipients, then forward to them as well
|
||||||
if(recipientList.includes(ACTOR.url + "/followers")) {
|
if(recipientList.includes(ACTOR.url + "/followers")) {
|
||||||
(db.listFollowers())?.forEach(f => send(f.id, activity, activity.attributedTo))
|
(await db.listFollowers()).forEach(f => send(f, activity, activity.attributedTo))
|
||||||
}
|
}
|
||||||
|
|
||||||
// save this activity to my inbox
|
// save this activity to my inbox
|
||||||
const activity_id = `${date.getTime().toString(32)}`
|
const id = `${date.getTime().toString(16)}`
|
||||||
console.info(`New inbox activity ${activity_id} (${activity.type})`, activity)
|
db.createInboxActivity(activity, id)
|
||||||
db.createInboxActivity(activity_id, activity)
|
|
||||||
|
|
||||||
// TODO: process the activity and update local data
|
// TODO: process the activity and update local data
|
||||||
switch(activity.type) {
|
switch(activity.type) {
|
||||||
case "Follow": follow(activity, activity_id, req); break;
|
case "Follow": follow(activity, id); break;
|
||||||
case "Accept": accept(activity); break;
|
case "Accept": accept(activity); break;
|
||||||
case "Reject": reject(activity); break;
|
case "Reject": reject(activity); break;
|
||||||
case "Undo": undo(activity); break;
|
case "Undo": undo(activity); break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response("", { status:204, headers: defaultHeaders(req) })
|
return new Response("", { status: 204 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const follow = async (activity:any, activity_id:string, req:Request) => {
|
const follow = async (activity:any, id:string) => {
|
||||||
// someone is following me
|
// someone is following me
|
||||||
// save this follower locally
|
// save this follower locally
|
||||||
db.createFollower(activity_id, activity.actor, activity.published || new Date().toISOString())
|
db.createFollower(activity.actor, id)
|
||||||
// send an accept message to the outbox
|
// send an accept message to the outbox
|
||||||
await outbox({
|
await outbox({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
@ -42,26 +40,26 @@ const follow = async (activity:any, activity_id:string, req:Request) => {
|
||||||
actor: ACTOR.id,
|
actor: ACTOR.id,
|
||||||
to: [activity.actor],
|
to: [activity.actor],
|
||||||
object: activity,
|
object: activity,
|
||||||
}, req, db);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const undo = async (activity:any) => {
|
const undo = async (activity:any) => {
|
||||||
switch (activity.object.type) {
|
switch (activity.object.type) {
|
||||||
// someone is undoing their follow of me
|
// someone is undoing their follow of me
|
||||||
case "Follow": db.deleteFollower(activity.actor); break
|
case "Follow": await db.deleteFollower(activity.actor); break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const accept = async (activity:any) => {
|
const accept = async (activity:any) => {
|
||||||
switch (activity.object.type) {
|
switch (activity.object.type) {
|
||||||
// someone accepted my follow of them
|
// someone accepted my follow of them
|
||||||
case "Follow": db.acceptFollowing(activity.actor); break
|
case "Follow": await db.acceptFollowing(activity.actor); break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reject = async (activity:any) => {
|
const reject = async (activity:any) => {
|
||||||
switch (activity.object.type) {
|
switch (activity.object.type) {
|
||||||
// someone rejected my follow of them
|
// someone rejected my follow of them
|
||||||
case "Follow": db.deleteFollowing(activity.actor); break
|
case "Follow": await db.deleteFollowing(activity.actor); break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
72
src/index.ts
|
@ -1,28 +1,28 @@
|
||||||
|
import { DEFAULT_DOCUMENTS, STATIC_PATH } from "./env"
|
||||||
import admin from './admin'
|
import admin from './admin'
|
||||||
import activitypub, { reqIsActivityPub } from "./activitypub"
|
import activitypub from "./activitypub"
|
||||||
import { fetchObject } from "./request"
|
import { fetchObject } from "./request"
|
||||||
|
import path from "path"
|
||||||
|
import { BunFile } from "bun"
|
||||||
|
import { rebuild } from "./db"
|
||||||
import { handle, webfinger } from "../actor"
|
import { handle, webfinger } from "../actor"
|
||||||
import ActivityPubDB from './db'
|
|
||||||
import { HOSTNAME, PORT, WEB_SITE_HOSTNAME } from './env'
|
|
||||||
|
|
||||||
const db = new ActivityPubDB()
|
rebuild()
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: PORT,
|
port: 3000,
|
||||||
// hostname: HOSTNAME,
|
fetch(req: Request): Response | Promise<Response> {
|
||||||
async fetch(req: Request): Promise<Response> {
|
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
|
|
||||||
// log the incoming request info
|
// log the incoming request info
|
||||||
console.info(`${new Date().toISOString()} 📥 ${req.method} ${reqIsActivityPub(req)?'[AP] ':''}${req.url}`)
|
console.info(`${new Date().toISOString()} 📥 ${req.method} ${req.url}`)
|
||||||
|
|
||||||
// CORS route (for now, any domain has access)
|
// CORS route (for now, any domain has access)
|
||||||
if(req.method === "OPTIONS") {
|
if(req.method === "OPTIONS") {
|
||||||
const headers:any = {
|
const headers:any = {
|
||||||
'Access-Control-Allow-Origin': req.headers.get('Origin') || '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, DELETE',
|
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, DELETE',
|
||||||
'Access-Control-Max-Age': '86400',
|
'Access-Control-Max-Age': '86400',
|
||||||
'Access-Control-Allow-Credentials': true
|
|
||||||
}
|
}
|
||||||
let h
|
let h
|
||||||
if (h = req.headers.get('Access-Control-Request-Headers'))
|
if (h = req.headers.get('Access-Control-Request-Headers'))
|
||||||
|
@ -36,7 +36,6 @@ const server = Bun.serve({
|
||||||
// return the webfinger
|
// return the webfinger
|
||||||
return Response.json(webfinger, { headers: { 'content-type': 'application/jrd+json' }})
|
return Response.json(webfinger, { headers: { 'content-type': 'application/jrd+json' }})
|
||||||
}
|
}
|
||||||
// Debugging route for fetching ActivityPub documents
|
|
||||||
else if(req.method === "GET" && url.pathname.startsWith("/fetch/")) {
|
else if(req.method === "GET" && url.pathname.startsWith("/fetch/")) {
|
||||||
const object_url = url.pathname.substring(7)
|
const object_url = url.pathname.substring(7)
|
||||||
if(!object_url) return new Response("No url supplied", { status: 400})
|
if(!object_url) return new Response("No url supplied", { status: 400})
|
||||||
|
@ -44,30 +43,33 @@ const server = Bun.serve({
|
||||||
return fetchObject(object_url)
|
return fetchObject(object_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// admin and activitypub routes, plus a fallback
|
return admin(req) || activitypub(req) || staticFile(req)
|
||||||
return await admin(req, db) || activitypub(req, db) || fallback(req)
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
const getDefaultDocument = async(base_path: string) => {
|
||||||
const fallback = async (req: Request) => {
|
for(const d of DEFAULT_DOCUMENTS){
|
||||||
// if we haven't a fallback hostname defined, just return 404
|
const filePath = path.join(base_path, d)
|
||||||
if(!WEB_SITE_HOSTNAME) return new Response('', { status: 404 })
|
const file = Bun.file(filePath)
|
||||||
|
if(await file.exists()) return file
|
||||||
// swap the hostname in the current request
|
}
|
||||||
const url = new URL(req.url)
|
|
||||||
url.hostname = WEB_SITE_HOSTNAME
|
|
||||||
console.info(`➡️ Forwarding ${req.method} request to ${url}`)
|
|
||||||
// override the host header, because this can cause problems if wrong
|
|
||||||
if(req.headers.get("host")) req.headers.set('host', WEB_SITE_HOSTNAME)
|
|
||||||
|
|
||||||
// const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await req.blob()
|
|
||||||
// // just use *all the things* from the request object (body is handled separately)
|
|
||||||
|
|
||||||
// TODO: possibly not forwarding the body correctly.
|
|
||||||
// My current website is static, but probably want to test and/or fix this in the future.
|
|
||||||
// @ts-ignore typescript doesn't think Request is compatible with RequestInit
|
|
||||||
const newRequest = new Request(url, req)
|
|
||||||
return await fetch(newRequest)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info(`📡 Listening on http://${server.hostname}:${server.port} ...`)
|
const staticFile = async (req:Request): Promise<Response> => {
|
||||||
|
try{
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const filePath = path.join(STATIC_PATH, url.pathname)
|
||||||
|
let file:BunFile|undefined = Bun.file(filePath)
|
||||||
|
// if the file doesn't exist, attempt to get the default document for the path
|
||||||
|
if(!(await file.exists())) file = await getDefaultDocument(filePath)
|
||||||
|
|
||||||
|
if(file && await file.exists()) return new Response(file)
|
||||||
|
// if the file still doesn't exist, just return a 404
|
||||||
|
else return new Response("", { status: 404 })
|
||||||
|
}
|
||||||
|
catch(err) {
|
||||||
|
console.error(err)
|
||||||
|
return new Response("", { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Listening on http://localhost:${server.port} ...`);
|
120
src/outbox.ts
|
@ -1,14 +1,12 @@
|
||||||
import { defaultHeaders, fetchObject, idsFromValue, send } from "./request"
|
import { idsFromValue } from "./activitypub"
|
||||||
|
import * as db from "./db"
|
||||||
|
import { fetchObject, send } from "./request"
|
||||||
import ACTOR from "../actor"
|
import ACTOR from "../actor"
|
||||||
import ActivityPubDB, { Activity } from "./db"
|
|
||||||
|
|
||||||
let db:ActivityPubDB
|
export default async function outbox(activity:any):Promise<Response> {
|
||||||
|
|
||||||
export default async function outbox(activity:any, req:Request, database:ActivityPubDB):Promise<Response> {
|
|
||||||
db = database
|
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
const activity_id = `${date.getTime().toString(32)}`
|
const id = `${date.getTime().toString(16)}`
|
||||||
console.info('outbox', activity_id, activity)
|
console.log('outbox', id, activity)
|
||||||
|
|
||||||
// https://www.w3.org/TR/activitypub/#object-without-create
|
// https://www.w3.org/TR/activitypub/#object-without-create
|
||||||
if(!activity.actor && !(activity.object || activity.target || activity.result || activity.origin || activity.instrument)) {
|
if(!activity.actor && !(activity.object || activity.target || activity.result || activity.origin || activity.instrument)) {
|
||||||
|
@ -27,7 +25,7 @@ export default async function outbox(activity:any, req:Request, database:Activit
|
||||||
if(audience) activity.audience = audience
|
if(audience) activity.audience = audience
|
||||||
}
|
}
|
||||||
|
|
||||||
activity.id = `${ACTOR.url}/outbox/${activity_id}`
|
activity.id = `${ACTOR.url}/outbox/${id}`
|
||||||
if(!activity.published) activity.published = date.toISOString()
|
if(!activity.published) activity.published = date.toISOString()
|
||||||
|
|
||||||
if(activity.type === 'Create' && activity.object && Object(activity.object) === activity.object) {
|
if(activity.type === 'Create' && activity.object && Object(activity.object) === activity.object) {
|
||||||
|
@ -45,91 +43,81 @@ export default async function outbox(activity:any, req:Request, database:Activit
|
||||||
delete activity.bcc
|
delete activity.bcc
|
||||||
|
|
||||||
// now that has been taken care of, it's time to update our local data, depending on the contents of the activity
|
// now that has been taken care of, it's time to update our local data, depending on the contents of the activity
|
||||||
// aka "side effects"
|
|
||||||
// Note: I do this *before* adding the activity to the database so I can cache specific objects
|
|
||||||
// for example, for likes / dislikes / shares, the object probably only exists as the url,
|
|
||||||
// but I want to fetch it and store it so I can use that data later.
|
|
||||||
await outboxSideEffect(activity_id, activity)
|
|
||||||
// save the activity data for the outbox
|
|
||||||
db.createOutboxActivity(activity_id, activity)
|
|
||||||
|
|
||||||
// send to the appropriate recipients
|
|
||||||
finalRecipientList.forEach((to) => {
|
|
||||||
if (to.startsWith(ACTOR.url + "/followers")) db.listFollowers()?.forEach(f => send(f.id, activity))
|
|
||||||
else if (to === "https://www.w3.org/ns/activitystreams#Public") return // there's nothing to "send" to here
|
|
||||||
else if (to) send(to, activity)
|
|
||||||
})
|
|
||||||
|
|
||||||
return new Response("", { status: 201, headers: { ...defaultHeaders(req), location: activity.id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function outboxSideEffect(activity_id:string, activity:Activity) {
|
|
||||||
switch(activity.type) {
|
switch(activity.type) {
|
||||||
case "Accept": await accept(activity, activity_id); break;
|
case "Accept": await accept(activity, id); break;
|
||||||
case "Follow": await follow(activity, activity_id); break;
|
case "Follow": await follow(activity, id); break;
|
||||||
case "Like": await like(activity, activity_id); break;
|
case "Like": await like(activity, id); break;
|
||||||
case "Dislike": await dislike(activity, activity_id); break;
|
case "Dislike": await dislike(activity, id); break;
|
||||||
case "Annouce": await announce(activity, activity_id); break;
|
case "Annouce": await announce(activity, id); break;
|
||||||
case "Create": await create(activity, activity_id); break;
|
case "Create": await create(activity, id); break;
|
||||||
case "Undo": await undo(activity); break;
|
case "Undo": await undo(activity); break;
|
||||||
case "Delete": await deletePost(activity); break;
|
case "Delete": await deletePost(activity); break;
|
||||||
// TODO: case "Anncounce": return await share(activity)
|
// TODO: case "Anncounce": return await share(activity)
|
||||||
}
|
}
|
||||||
|
// save the activity data for the outbox
|
||||||
|
await db.createOutboxActivity(activity, id)
|
||||||
|
|
||||||
|
// send to the appropriate recipients
|
||||||
|
finalRecipientList.forEach((to) => {
|
||||||
|
if (to.startsWith(ACTOR.url + "/followers")) db.listFollowers().then(followers => followers.forEach(f => send(f.actor, activity)))
|
||||||
|
else if (to === "https://www.w3.org/ns/activitystreams#Public") return // there's nothing to "send" to here
|
||||||
|
else if (to) send(to, activity)
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response("", { status: 201, headers: { location: activity.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create(activity:any, activity_id:string) {
|
async function create(activity:any, id:string) {
|
||||||
activity.object.id = activity.object.url = `${ACTOR.url}/posts/${activity_id}`
|
activity.object.id = activity.object.url = `${ACTOR.url}/posts/${id}`
|
||||||
db.createPost(activity_id, activity.object)
|
await db.createPost(activity.object, id)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function accept(activity:any, activity_id:string) {
|
async function accept(activity:any, id:string) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function follow(activity:any, activity_id:string) {
|
async function follow(activity:any, id:string) {
|
||||||
idsFromValue(activity.object).forEach(id => {
|
await db.createFollowing(activity.object , id)
|
||||||
db.createFollowing(activity_id, id, activity.published)
|
|
||||||
});
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function like(activity:any, activity_id:string) {
|
async function like(activity:any, id:string) {
|
||||||
if(typeof activity.object === 'string'){
|
if(typeof activity.object === 'string'){
|
||||||
db.createLiked(activity_id, activity.object, activity.published || new Date())
|
await db.createLiked(activity.object, id)
|
||||||
activity.object = fetchObject(activity.object)
|
activity.object = await fetchObject(activity.object)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const liked = idsFromValue(activity.object)
|
const liked = await idsFromValue(activity.object)
|
||||||
liked.forEach(l => db.createLiked(activity_id, l, activity.published || new Date()))
|
liked.forEach(l => db.createLiked(l, id))
|
||||||
}
|
}
|
||||||
db.createPost(activity_id, activity)
|
await db.createPost(activity, id)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dislike(activity:any, activity_id:string) {
|
async function dislike(activity:any, id:string) {
|
||||||
if(typeof activity.object === 'string'){
|
if(typeof activity.object === 'string'){
|
||||||
db.createDisliked(activity_id, activity.object, activity.published || new Date())
|
await db.createDisliked(activity.object, id)
|
||||||
activity.object = await fetchObject(activity.object)
|
activity.object = await fetchObject(activity.object)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const disliked = await idsFromValue(activity.object)
|
const disliked = await idsFromValue(activity.object)
|
||||||
disliked.forEach(l => db.createDisliked(activity_id, l, activity.published || new Date()))
|
disliked.forEach(l => db.createDisliked(l, id))
|
||||||
}
|
}
|
||||||
db.createPost(activity_id, activity)
|
await db.createPost(activity, id)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function announce(activity:any, activity_id:string) {
|
async function announce(activity:any, id:string) {
|
||||||
if(typeof activity.object === 'string'){
|
if(typeof activity.object === 'string'){
|
||||||
db.createShared(activity_id, activity.object, activity.published || new Date())
|
await db.createShared(activity.object, id)
|
||||||
activity.object = await fetchObject(activity.object)
|
activity.object = await fetchObject(activity.object)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const shared = await idsFromValue(activity.object)
|
const shared = await idsFromValue(activity.object)
|
||||||
shared.forEach(l => db.createShared(activity_id, l, activity.published || new Date()))
|
shared.forEach(l => db.createShared(l, id))
|
||||||
}
|
}
|
||||||
db.createPost(activity_id, activity)
|
await db.createPost(activity, id)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,17 +136,17 @@ async function announce(activity:any, activity_id:string) {
|
||||||
async function undo(activity:any) {
|
async function undo(activity:any) {
|
||||||
const id = await idsFromValue(activity.object).at(0)
|
const id = await idsFromValue(activity.object).at(0)
|
||||||
if (!id) return true
|
if (!id) return true
|
||||||
const match = id.match(/\/([0-9a-z]+)\/?$/)
|
const match = id.match(/\/([0-9a-f]+)\/?$/)
|
||||||
const activity_id = match ? match[1] : id
|
const local_id = match ? match[1] : id
|
||||||
console.info('undo', activity_id)
|
console.log('undo', local_id)
|
||||||
try{
|
try{
|
||||||
const existing = db.getOutboxActivity(activity_id)
|
const existing = await db.getOutboxActivity(local_id)
|
||||||
|
|
||||||
switch(activity.object.type) {
|
switch(activity.object.type) {
|
||||||
case "Follow": idsFromValue(existing?.object).forEach(async id => db.deleteFollowing(id)); break;
|
case "Follow": await db.deleteFollowing(existing.object); break;
|
||||||
case "Like": idsFromValue(existing?.object).forEach(async id => db.deleteLiked(id)); db.deletePost(activity_id); break;
|
case "Like": idsFromValue(existing.object).forEach(async id => await db.deleteLiked(id)); await db.deletePost(local_id); break;
|
||||||
case "Dislike": idsFromValue(existing?.object).forEach(async id => db.deleteDisliked(id)); db.deletePost(activity_id); break;
|
case "Dislike": idsFromValue(existing.object).forEach(async id => await db.deleteDisliked(id)); await db.deletePost(local_id); break;
|
||||||
case "Announce": idsFromValue(existing?.object).forEach(async id => db.deleteShared(id)); db.deletePost(activity_id); break;
|
case "Announce": idsFromValue(existing.object).forEach(async id => await db.deleteShared(id)); await db.deletePost(local_id); break;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -172,8 +160,8 @@ async function undo(activity:any) {
|
||||||
async function deletePost(activity:any) {
|
async function deletePost(activity:any) {
|
||||||
const id = await idsFromValue(activity.object).at(0)
|
const id = await idsFromValue(activity.object).at(0)
|
||||||
if(!id) return false
|
if(!id) return false
|
||||||
const post = db.getPostByURL(id)
|
const post = await db.getPostByURL(id)
|
||||||
if(!post) return false
|
if(!post) return false
|
||||||
db.deletePost(post.activity_id)
|
await db.deletePost(post.local_id)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
|
@ -1,30 +1,7 @@
|
||||||
import forge from "node-forge" // import crypto from "node:crypto"
|
import forge from "node-forge" // import crypto from "node:crypto"
|
||||||
import { PRIVATE_KEY, TOKEN_ENDPOINT } from "./env";
|
import { PRIVATE_KEY } from "./env";
|
||||||
import ACTOR from "../actor"
|
import ACTOR from "../actor"
|
||||||
|
|
||||||
export function idsFromValue(value:any):string[] {
|
|
||||||
if (!value) return []
|
|
||||||
else if (typeof value === 'string') return [value]
|
|
||||||
else if (value.id) return [value.id]
|
|
||||||
else if (Array.isArray(value)) return value.map(v => idsFromValue(v)).flat(Infinity) as string[]
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultHeaders(req:Request) {
|
|
||||||
const headers:any = {
|
|
||||||
'Access-Control-Allow-Origin': req.headers.get('Origin') || '*',
|
|
||||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, DELETE',
|
|
||||||
'Access-Control-Max-Age': '86400',
|
|
||||||
'Access-Control-Allow-Credentials': true,
|
|
||||||
'Content-Type': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
|
|
||||||
}
|
|
||||||
let h
|
|
||||||
if (h = req.headers.get('Access-Control-Request-Headers'))
|
|
||||||
headers['Access-Control-Allow-Headers'] = 'Accept, Content-Type, Authorization, Signature, Digest, Date, Host'
|
|
||||||
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
// this function adds / modifies the appropriate headers for signing a request, then calls fetch
|
// this function adds / modifies the appropriate headers for signing a request, then calls fetch
|
||||||
export function signedFetch(url: string | URL | Request, init?: FetchRequestInit): Promise<Response>
|
export function signedFetch(url: string | URL | Request, init?: FetchRequestInit): Promise<Response>
|
||||||
{
|
{
|
||||||
|
@ -75,6 +52,7 @@ async function fetchActor(url:string) {
|
||||||
|
|
||||||
/** Fetches and returns an object at a URL. */
|
/** Fetches and returns an object at a URL. */
|
||||||
export async function fetchObject(object_url:string) {
|
export async function fetchObject(object_url:string) {
|
||||||
|
console.log(`fetch ${object_url}`)
|
||||||
|
|
||||||
const res = await signedFetch(object_url);
|
const res = await signedFetch(object_url);
|
||||||
|
|
||||||
|
@ -91,9 +69,9 @@ export async function fetchObject(object_url:string) {
|
||||||
* @param message the body of the request to send.
|
* @param message the body of the request to send.
|
||||||
*/
|
*/
|
||||||
export async function send(recipient:string, message:any, from:string=ACTOR.id) {
|
export async function send(recipient:string, message:any, from:string=ACTOR.id) {
|
||||||
console.info(`Sending to ${recipient}`, message)
|
console.log(`Sending to ${recipient}`, message)
|
||||||
// TODO: revisit fetch actor to use webfinger to get the inbox maybe?
|
// TODO: revisit fetch actor to use webfinger to get the inbox maybe?
|
||||||
const actor = await fetchActor(recipient) as any
|
const actor = await fetchActor(recipient)
|
||||||
|
|
||||||
const body = JSON.stringify(message)
|
const body = JSON.stringify(message)
|
||||||
|
|
||||||
|
@ -144,7 +122,7 @@ export async function verify(req:Request, body:string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the actor's public key
|
// get the actor's public key
|
||||||
const actor = await fetchActor(keyId) as any
|
const actor = await fetchActor(keyId);
|
||||||
if (!actor.publicKey) throw new Error("No public key found.")
|
if (!actor.publicKey) throw new Error("No public key found.")
|
||||||
const key = forge.pki.publicKeyFromPem(actor.publicKey.publicKeyPem)
|
const key = forge.pki.publicKeyFromPem(actor.publicKey.publicKeyPem)
|
||||||
|
|
||||||
|
@ -172,16 +150,3 @@ export async function verify(req:Request, body:string) {
|
||||||
|
|
||||||
return actor.id;
|
return actor.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* verifies that the request is authorized
|
|
||||||
* (i.e. has a valid auth token) */
|
|
||||||
export async function authorized(req:Request) {
|
|
||||||
if(!TOKEN_ENDPOINT) return false
|
|
||||||
const response = await fetch(TOKEN_ENDPOINT, { headers: {"authorization": req.headers.get("authorization") || ''} })
|
|
||||||
if(!response.ok) return false
|
|
||||||
|
|
||||||
const json = await response.json() as any
|
|
||||||
if(new URL(json.me).hostname !== new URL(ACTOR.id).hostname) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|