@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 +15 -0
- package/COPYING +21 -0
- package/README.md +191 -3
- package/app.js +20 -0
- package/bwog.config.js +5 -0
- package/bwog.service +12 -0
- package/cli.js +23 -0
- package/cloudflare/Database.js +105 -0
- package/cloudflare/worker.js +104 -0
- package/config.js +13 -0
- package/controller.js +58 -0
- package/databases/DurableObjectDatabase.js +30 -0
- package/databases/JsonDatabase.js +38 -0
- package/databases/SqliteDatabase.js +99 -0
- package/generator.js +460 -0
- package/images/logo.png +0 -0
- package/images/model.blend +0 -0
- package/nginx.config +61 -0
- package/package.json +48 -6
- package/routes/auth.js +94 -0
- package/routes/editor.js +34 -0
- package/scripts/compile_templates.js +21 -0
- package/scripts/generate_site.js +58 -0
- package/scripts/json_to_sqlite.js +20 -0
- package/stores/NodeFsStore.js +14 -0
- package/stores/R2Store.js +11 -0
- package/templates.js +244 -0
- package/views/atom_feed.eta +23 -0
- package/views/basic_html.eta +13 -0
- package/views/default.eta +168 -0
- package/views/sitemap.eta +9 -0
- package/wrangler.jsonc +72 -0
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
package/bwog.service
ADDED
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;
|