@bwog/bwog 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,15 @@
1
+ NODE_ENV=production
2
+ GOOGLE_OAUTH_CLIENT_ID=12345ABCDE.apps.googleusercontent.com
3
+ GOOGLE_OAUTH_CLIENT_SECRET=12345ABCDE
4
+ GOOGLE_OAUTH_AUTHORIZED_USER_ID=12345ABCDE
5
+ SITE_DOMAIN=example.com
6
+ SESSION_SECRET=12345ABCDE
7
+ PUBLIC_PATH=/var/www/example
8
+ DATABASE_TYPE=JSON
9
+ DATABASE_FILE=/usr/share/bwog/blog.json
10
+ AUTHOR_NAME=Alice Smith
11
+ AUTHOR_EMAIL=author@example.com
12
+ ENABLE_AP=false
13
+ AP_HANDLE=author@example.com
14
+ AP_USERNAME=author
15
+ AP_PUBLIC_KEY_STRING="-----BEGIN PUBLIC KEY-----\nabc123\n-----END PUBLIC KEY-----"
package/COPYING ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Chase Patterson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,191 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ <p align="center"><img src="images/logo.png" alt="bwog" style="width: 20em;" /></p>
2
+ <p align="center">bwog is a toy static site generator for a tumblelog/microblog</p>
3
+
4
+ ## Architecture
5
+ The blog generated is by-and-large a static HTML site. The other component is a small nodejs app which facilitates adding posts and regenerating the site.
6
+
7
+ ### HTTP Server (NGINX)
8
+
9
+ This structure can be seen in the included example nginx site configuration (any static server and reverse proxy can be used).
10
+
11
+ The provided site config will need to be edited (for your domain, certificate locations, and ActivityPub `Actor` handle), or otherwise integrated into your server configuration.
12
+
13
+ This block attempts to find a file at the webroot matching the path in the requested URL:
14
+ ```
15
+ root /var/www/example;
16
+ index index.html;
17
+
18
+ location / {
19
+ try_files $uri $uri/ =404;
20
+ }
21
+ ```
22
+
23
+ This more-specific regex block is actually checked first. If the request matches any of these specific endpoints, it's forwarded to the express app running on port 3000.
24
+ ```
25
+ location ~ ^/(post|login|logout|oauth2/redirect/google) {
26
+ proxy_pass http://localhost:3000;
27
+ proxy_http_version 1.1;
28
+ proxy_set_header Upgrade $http_upgrade;
29
+ proxy_set_header Connection 'upgrade';
30
+ proxy_set_header Host $host;
31
+ proxy_cache_bypass $http_upgrade;
32
+ }
33
+ ```
34
+
35
+ #### ActivityPub
36
+
37
+ This block uses the related map and only returns a resource if the correct WebFinger `acct:` URI is requested.
38
+
39
+ If you already have a WebFinger system set up on the domain, the data from the generated `{PUBLIC_PATH}/ap/webfinger.json` should be served there.
40
+
41
+ ```
42
+ location /.well-known/webfinger {
43
+ default_type "application/jrd+json";
44
+ try_files $webfinger_resource =404;
45
+ }
46
+ ```
47
+ ```
48
+ map $arg_resource $webfinger_resource {
49
+ "acct:username@example.com" /ap/webfinger.json;
50
+ "acct%3Ausername%40example.com" /ap/webfinger.json;
51
+ }
52
+ ```
53
+
54
+ The `/ap/actor` resource referenced from the WebFinger is served via a matching file at the relative webroot path.
55
+ ```
56
+ location /ap {
57
+ types { } default_type "application/activity+json";
58
+ try_files $uri.json =404;
59
+ }
60
+ ```
61
+
62
+ The Outbox index and its pagination are served via the following directives:
63
+ ```
64
+ location /ap/outbox {
65
+ types { } default_type "application/activity+json";
66
+ try_files $outbox =404;
67
+ }
68
+ ```
69
+ ```
70
+ map $arg_page $outbox {
71
+ "" /ap/outbox.json;
72
+ "~^(?<i>[0-9]+-[0-9]+)?" /ap/outbox_pages/$i.json;
73
+ }
74
+ ```
75
+
76
+ And Notes and their respective Create activities are served via these:
77
+ ```
78
+ location /ap/notes {
79
+ types { } default_type "application/activity+json";
80
+ try_files $notes =404;
81
+ }
82
+ ```
83
+ ```
84
+ map $uri $note_id {
85
+ "~^/ap/notes/(?<id>.+)$" $id;
86
+ }
87
+
88
+ map $arg_activity $notes {
89
+ "true" /ap/creates/$note_id.json;
90
+ default /ap/notes/$note_id.json;
91
+ }
92
+ ```
93
+
94
+ ## Dependencies
95
+ Install dependencies with npm:
96
+ ```
97
+ npm install
98
+ ```
99
+
100
+ ## Daemon
101
+ The nodejs app can be run however you prefer to run daemons on your system. A systemd unit file is included at `bwog.service`.
102
+
103
+ ## Configuration
104
+ The included `.env.example` can be copied to `.env` and the values replaced.
105
+
106
+ The javascript oauth library Passport is used to authenticate via Google account. You must set up an oauth2 client on the Google Developer Console in the Google Auth Platform and copy the credentials here:
107
+ ```
108
+ GOOGLE_OAUTH_CLIENT_ID=12345ABCDE
109
+ GOOGLE_OAUTH_CLIENT_SECRET=12345ABCDE
110
+ ```
111
+ The id of the user to whom you wish to allow post access:
112
+ ```
113
+ GOOGLE_OAUTH_AUTHORIZED_USER_ID=12345ABCDE
114
+ ```
115
+ A long random string to encrypt the session:
116
+ ```
117
+ SESSION_SECRET=12345ABCDE
118
+ ```
119
+ The path to your static files (where they should be generated):
120
+ ```
121
+ PUBLIC_PATH=/var/www/example
122
+ ```
123
+ The type of database: `JSON` or `SQLITE`
124
+ ```
125
+ DATABASE_TYPE=JSON
126
+ ```
127
+ The path to your source post JSON or SQLite (corresponding to `DATABASE_TYPE`). This will be updated, and used to generate the site.
128
+ ```
129
+ DATABASE_FILE=/usr/share/bwog/blog.json
130
+ ```
131
+
132
+ ### Favicon
133
+ Place an image to be used as a favicon, ActivityPub icon, and Atom feed icon at the server webroot (`.env` `PUBLIC_PATH`). A 400px square png works well.
134
+
135
+ ## Post data
136
+ The source JSON has the following structure. Note the three types and following related properties. `id` is a base58 UUIDv4.
137
+ ```json
138
+ [
139
+ {
140
+ "id": "123456789aAbBcCdDeEfFg",
141
+ "author": "John Blog",
142
+ "createdAt": "2025-05-01T16:58:00Z",
143
+ "type": "text",
144
+ "text": "Hello, world."
145
+ },
146
+ {
147
+ "id": "GhHijJkKlmMnNopPqQrRsS",
148
+ "author": "John Blog",
149
+ "createdAt": "2025-05-02T10:58:00Z",
150
+ "type": "song",
151
+ "text": "Listen to this song https://youtu.be/vyut3GyQtn0",
152
+ "artist": "Télépopmusik",
153
+ "title": "Breathe"
154
+ },
155
+ {
156
+ "id": "tTuUvVwWxXyYzZ12345678",
157
+ "author": "John Blog",
158
+ "createdAt": "2025-05-03T20:38:00Z",
159
+ "text": "A couple slappin' tunes:\nhttps://www.youtube.com/watch?v=i-SpGB6DtHw\nhttps://www.youtube.com/watch?v=mMWCQa-Gwv0",
160
+ "type": "playlist",
161
+ "tracks": [
162
+ {
163
+ "artist": "The One AM Radio",
164
+ "title": "An Old Photo Of Your New Lover"
165
+ },
166
+ {
167
+ "artist": "Kings of Convenience",
168
+ "title": "Love Is No Big Truth"
169
+ }
170
+ ]
171
+ }
172
+ ]
173
+ ```
174
+
175
+ ## Usage
176
+ When the server is running, you can hit the `/login` endpoint to start the oauth flow. When authenticated, a form appears at the top of each page for posting.
177
+
178
+ Conversely `/logout` invalidates your session.
179
+
180
+ ## CLI utility
181
+ If you need to generate the entire site from the terminal, e.g. if you change a template, you can run it like this:
182
+ ```
183
+ node scripts/generate_site.js -u https://example.com -t JSON -i blog.json -o /var/www/example -n 'John Smith' -e 'jsmith@example.com' -l -a jsmith@example.com -m jsmith -k public.pem
184
+ ```
185
+
186
+ ## Interfaces & Protocols
187
+ A sitemap is generated at `/sitemap.xml`.
188
+
189
+ An Atom feed of the most recent 20 posts is generated at `/feed.atom`.
190
+
191
+ The ActivityPub protocol is implemented to the extent that an `Actor` is discoverable and all of the posts are accessible via the linked outbox.
package/app.js ADDED
@@ -0,0 +1,20 @@
1
+ import express from 'express';
2
+ import session from 'express-session';
3
+ import passport from 'passport';
4
+ import 'dotenv/config';
5
+
6
+ import authRouter from './routes/auth.js';
7
+ import editorRouter from './routes/editor.js';
8
+
9
+ const app = express();
10
+ app.use(session({
11
+ secret: process.env['SESSION_SECRET'],
12
+ resave: false,
13
+ saveUninitialized: false,
14
+ }));
15
+ app.use(passport.authenticate('session'));
16
+ app.use(express.urlencoded({type: 'application/x-www-form-urlencoded'}));
17
+ app.use('/', authRouter);
18
+ app.use('/', editorRouter);
19
+
20
+ export default app;
package/bwog.config.js ADDED
@@ -0,0 +1,5 @@
1
+ import resolve from 'node:path';
2
+
3
+ export default {
4
+ templates: resolve('./templates'),
5
+ }
package/bwog.service ADDED
@@ -0,0 +1,12 @@
1
+ [Unit]
2
+ Description=bwog editor
3
+ After=network.target
4
+ StartLimitIntervalSec=0
5
+
6
+ [Service]
7
+ Type=simple
8
+ Restart=always
9
+ ExecStart=/usr/bin/node --env-file=/var/www/bwog/.env /var/www/bwog/app.js
10
+
11
+ [Install]
12
+ WantedBy=multi-user.target
package/cli.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander';
4
+
5
+ import app from './app.js';
6
+
7
+ program
8
+ .description('bwog command line utility');
9
+
10
+ program.command('build')
11
+ .description('build required files for deployment')
12
+ .action(() => {
13
+ });
14
+
15
+ program.command('run')
16
+ .description('start web server listening for requests')
17
+ .option('-p, --port <number>', 'ActivityPub public key path, e.g. ./public.pem', '3000')
18
+ .action(({port}) => {
19
+ app.listen(port);
20
+ console.log('listening');
21
+ });
22
+
23
+ program.parse();
@@ -0,0 +1,105 @@
1
+ import { DurableObject } from 'cloudflare:workers'
2
+
3
+ class Database extends DurableObject {
4
+ constructor(ctx, env) {
5
+ super(ctx, env);
6
+
7
+ this.db = ctx.storage.sql;
8
+ }
9
+
10
+ initSchema(posts) {
11
+ this.db.exec(`CREATE TABLE posts (
12
+ id TEXT PRIMARY KEY,
13
+ author TEXT NOT NULL,
14
+ created_at TEXT NOT NULL,
15
+ content JSONB NOT NULL
16
+ );`);
17
+ }
18
+
19
+ importPosts(posts) {
20
+ posts.forEach(post => {
21
+ const content = {...post};
22
+ delete content.id;
23
+ delete content.author;
24
+ delete content.createdAt;
25
+ this.db.exec(
26
+ 'INSERT INTO posts (id, author, created_at, content) VALUES (?, ?, ?, jsonb(?));',
27
+ post.id,
28
+ post.author,
29
+ post.createdAt,
30
+ JSON.stringify(content)
31
+ );
32
+ });
33
+ }
34
+
35
+ getAll() {
36
+ const rawPosts = this.db.exec(`
37
+ SELECT
38
+ id,
39
+ author,
40
+ created_at,
41
+ json(content) as content
42
+ FROM posts
43
+ ORDER BY created_at DESC;
44
+ `).toArray();
45
+
46
+ return rawPosts.map(post => ({
47
+ id: post.id,
48
+ author: post.author,
49
+ createdAt: post.created_at,
50
+ ...JSON.parse(post.content)
51
+ }));
52
+ }
53
+
54
+ getLastTwoMonthsAtLeast20() {
55
+ const rawPosts = this.db.exec(`
56
+ WITH latest_two_months AS (
57
+ SELECT DISTINCT strftime('%Y-%m', created_at) AS month
58
+ FROM posts
59
+ ORDER BY month DESC
60
+ LIMIT 2
61
+ ),
62
+ suitability AS (
63
+ SELECT *,
64
+ CASE
65
+ WHEN created_at IN latest_two_months THEN 1
66
+ ELSE 0
67
+ END AS in_latest_two_months,
68
+ ROW_NUMBER() OVER (ORDER BY created_at DESC) AS recency
69
+ FROM posts
70
+ )
71
+ SELECT
72
+ id,
73
+ author,
74
+ created_at,
75
+ json(content) as content
76
+ FROM suitability
77
+ WHERE in_latest_two_months = 1
78
+ OR recency <= 20
79
+ ORDER BY created_at DESC;
80
+ `).toArray();
81
+
82
+ return rawPosts.map(post => ({
83
+ id: post.id,
84
+ author: post.author,
85
+ createdAt: post.created_at,
86
+ ...JSON.parse(post.content)
87
+ }));
88
+ }
89
+
90
+ createPost(post) {
91
+ const content = {...post};
92
+ delete content.id;
93
+ delete content.author;
94
+ delete content.createdAt;
95
+ this.db.exec(
96
+ 'INSERT INTO posts (id, author, created_at, content) VALUES (?, ?, ?, jsonb(?));',
97
+ post.id,
98
+ post.author,
99
+ post.createdAt,
100
+ JSON.stringify(content)
101
+ );
102
+ }
103
+ }
104
+
105
+ export default Database;
@@ -0,0 +1,104 @@
1
+ import { Hono } from 'hono';
2
+ import { jwt, sign } from 'hono/jwt';
3
+ import { csrf } from 'hono/csrf';
4
+ import { setCookie, deleteCookie } from 'hono/cookie';
5
+ import { googleAuth } from '@hono/oauth-providers/google';
6
+
7
+ import R2Store from '../stores/R2Store.js';
8
+ import DurableObjectDatabase from '../databases/DurableObjectDatabase.js';
9
+ import Controller from '../controller.js';
10
+ import Generator from '../generator.js';
11
+
12
+ const app = new Hono();
13
+
14
+ app.use(csrf());
15
+
16
+ app.use('/login', (c, next) => googleAuth({
17
+ client_id: c.env.GOOGLE_OAUTH_CLIENT_ID,
18
+ client_secret: c.env.GOOGLE_OAUTH_CLIENT_SECRET,
19
+ scope: ['email', 'profile'],
20
+ })(c, next));
21
+
22
+ app.get('/login', async c => {
23
+ const expirationSeconds = 60 * 60 * 24 * 7;
24
+ const user = c.get('user-google');
25
+
26
+ if (user.id !== c.env.GOOGLE_OAUTH_AUTHORIZED_USER_ID) {
27
+ deleteCookie(c, 'authToken');
28
+ deleteCookie(c, 'isAuthed');
29
+ return c.redirect('/');
30
+ }
31
+
32
+ const payload = {
33
+ sub: user,
34
+ exp: Math.floor(Date.now() / 1000) + expirationSeconds,
35
+ }
36
+
37
+ const jwtToken = await sign(payload, c.env.JWT_SECRET, 'HS256');
38
+
39
+ setCookie(c, 'authToken', jwtToken, {
40
+ path: '/',
41
+ secure: true,
42
+ httpOnly: true,
43
+ sameSite: 'Strict',
44
+ maxAge: expirationSeconds,
45
+ });
46
+ setCookie(c, 'isAuthed', true, {
47
+ path: '/',
48
+ secure: true,
49
+ httpOnly: false,
50
+ sameSite: 'Strict',
51
+ maxAge: expirationSeconds,
52
+ });
53
+
54
+ return c.redirect('/');
55
+ });
56
+
57
+ app.use('/post', (c, next) => jwt({
58
+ secret: c.env.JWT_SECRET,
59
+ alg: 'HS256',
60
+ cookie: 'authToken',
61
+ })(c, next));
62
+
63
+ app.post(['/post', '/generate'], async c => {
64
+ const user = c.get('jwtPayload').sub;
65
+ const stub = c.env.DATABASE.getByName('blog-posts');
66
+ const store = new R2Store(c.env.STORE);
67
+ const database = new DurableObjectDatabase(stub);
68
+ const controller = new Controller(store, database);
69
+
70
+ await controller.createPost(await c.req.parseBody(), user);
71
+
72
+ return c.redirect('/');
73
+ });
74
+
75
+ app.get('/setup', async c => {
76
+ const stub = c.env.DATABASE.getByName('blog-posts');
77
+ const store = new R2Store(c.env.STORE);
78
+ const database = new DurableObjectDatabase(stub);
79
+ const generator = new Generator(store, database);
80
+
81
+ const postsFile = await c.env.PRIVATE_BUCKET.get('posts.json');
82
+ const posts = await postsFile.json();
83
+
84
+ await database.initSchema();
85
+ await database.importPosts(posts);
86
+
87
+ await generator.generate(`https://${process.env['SITE_DOMAIN']}`, process.env['AUTHOR_NAME'], process.env['AUTHOR_EMAIL'], false);
88
+
89
+ return c.redirect('/');
90
+ });
91
+
92
+ app.get('/generate', async c => {
93
+ const stub = c.env.DATABASE.getByName('blog-posts');
94
+ const store = new R2Store(c.env.STORE);
95
+ const database = new DurableObjectDatabase(stub);
96
+ const generator = new Generator(store, database);
97
+
98
+ await generator.generate(`https://${process.env['SITE_DOMAIN']}`, process.env['AUTHOR_NAME'], process.env['AUTHOR_EMAIL'], false);
99
+
100
+ return c.redirect('/');
101
+ });
102
+
103
+ export { default as Database } from './Database.js';
104
+ export default app;
package/config.js ADDED
@@ -0,0 +1,13 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ let config = {};
4
+
5
+ config = import('bwog.config')
6
+ .catch(async () => {
7
+ config = await import(resolve(process.cwd(), './bwog.config.js'));
8
+ })
9
+ .catch(() => {
10
+ throw new Error('Could not locate bwog config file at alias or working directory');
11
+ })
12
+
13
+ export default config;
package/controller.js ADDED
@@ -0,0 +1,58 @@
1
+ import {generate} from 'short-uuid';
2
+
3
+ import Generator from './generator.js';
4
+
5
+ class Controller {
6
+ constructor(store, database) {
7
+ this.store = store;
8
+ this.database = database;
9
+ }
10
+
11
+ async createPost(formData, user) {
12
+ if (!formData.text && !formData.tracks) {
13
+ return null;
14
+ }
15
+ let post = {
16
+ id: generate(),
17
+ author: user.name,
18
+ createdAt: (new Date()).toISOString().split('.')[0] + 'Z',
19
+ text: formData.text.replaceAll('\r\n', '\n'),
20
+ };
21
+ const trackStrings = formData.tracks.replaceAll('\r\n', '\n').split('\n');
22
+ const trackStringsFiltered = trackStrings.filter(track => track !== '');
23
+ const tracks = trackStringsFiltered.map(track => {
24
+ const tuple = track.split(' - ');
25
+ return {
26
+ artist: tuple[0],
27
+ title: tuple[1],
28
+ };
29
+ });
30
+ post.type = tracks.length > 0
31
+ ? (tracks.length > 1 ? 'playlist' : 'song')
32
+ : 'text';
33
+
34
+ switch (post.type) {
35
+ case 'playlist':
36
+ post.tracks = tracks;
37
+ break;
38
+ case 'song':
39
+ post = {...post, ...tracks[0]};
40
+ break;
41
+ case 'text':
42
+ default:
43
+ break;
44
+ }
45
+
46
+ this.database.createPost(post);
47
+
48
+ const generator = new Generator(this.store, this.database);
49
+ await generator.generatePartial(`https://${process.env['SITE_DOMAIN']}`, process.env['AUTHOR_NAME'], process.env['AUTHOR_EMAIL'], process.env['ENABLE_AP'] === 'true', post);
50
+
51
+ console.log(`user posted: ${user.name} <${user.email}>`);
52
+ console.log(post);
53
+
54
+ return post;
55
+ }
56
+ }
57
+
58
+ export default Controller;
@@ -0,0 +1,30 @@
1
+ class DurableObjectDatabase {
2
+ constructor(file) {
3
+ this.file = file;
4
+ }
5
+
6
+ async connect() {
7
+ }
8
+
9
+ async initSchema() {
10
+ return this.file.initSchema();
11
+ }
12
+
13
+ async importPosts(posts) {
14
+ return this.file.importPosts(posts);
15
+ }
16
+
17
+ async getAll() {
18
+ return this.file.getAll();
19
+ }
20
+
21
+ async getLastTwoMonthsAtLeast20() {
22
+ return this.file.getLastTwoMonthsAtLeast20();
23
+ }
24
+
25
+ async createPost(post) {
26
+ return this.file.createPost(post);
27
+ }
28
+ }
29
+
30
+ export default DurableObjectDatabase;
@@ -0,0 +1,38 @@
1
+ import fs from 'fs';
2
+ import beautify from 'js-beautify';
3
+
4
+ class JsonDatabase {
5
+ constructor(file) {
6
+ this.file = file;
7
+ }
8
+
9
+ async connect() {
10
+ const { default: posts } = await import(this.file, {with: {type: 'json'}});
11
+ this.posts = posts;
12
+ this.posts.sort((a, b) => a.createdAt > b.createdAt ? -1 : (a.createdAt < b.createdAt ? 1 : 0));
13
+ }
14
+
15
+ async getAll() {
16
+ return this.posts;
17
+ }
18
+
19
+ async getLastTwoMonthsAtLeast20() {
20
+ return this.posts;
21
+ }
22
+
23
+ async createPost(post) {
24
+ this.posts.push(post);
25
+
26
+ try {
27
+ const json = beautify.js(
28
+ JSON.stringify(this.posts),
29
+ {end_with_newline: true},
30
+ );
31
+ fs.writeFileSync(this.file, json);
32
+ } catch (err) {
33
+ throw (`Failed to write file: ${this.file}`);
34
+ }
35
+ }
36
+ }
37
+
38
+ export default JsonDatabase;