├── .dockerignore ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── db.ts ├── deps.ts ├── error_handler.ts ├── main.ts ├── resources ├── admin_index.ts ├── admin_login.ts ├── blog_admin_index.ts ├── blog_create.ts ├── blog_delete.ts ├── blog_edit.ts ├── blog_index.ts ├── blog_page.ts ├── blog_rescue.ts ├── files.ts ├── pages_index.ts └── protected_resource.ts ├── services └── auth_service.ts ├── static ├── createblog.js ├── editblog.js ├── xeact-html.js ├── xeact.js └── xess.css ├── tools └── hashpw.ts └── views ├── admin_index.html ├── admin_login.html ├── blog_create.html ├── blog_edit.html ├── blog_index.html ├── blog_index_admin.html ├── blog_page.html ├── error.html ├── index.html └── layout.html /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/bin 15 | **/charts 16 | **/docker-compose* 17 | **/compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | README.md 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | *.db 107 | 108 | fly.toml -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": true 5 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM denoland/deno:alpine 2 | 3 | # The port that your application listens to. 4 | EXPOSE 8080 5 | 6 | WORKDIR /app 7 | 8 | # Prefer not to run as root. 9 | USER deno 10 | 11 | # Cache the dependencies as a layer (the following two steps are re-run only when deps.ts is modified). 12 | # Ideally cache deps.ts will download and compile _all_ external files used in main.ts. 13 | COPY deps.ts . 14 | RUN deno cache deps.ts 15 | 16 | # These steps will be re-run upon each file change in your working directory: 17 | ADD . . 18 | # Compile the main app so that it doesn't need to be compiled each startup/entry. 19 | RUN deno cache main.ts 20 | 21 | CMD ["run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "main.ts"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Xe Iaso 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 😂 2 | 3 | 😂 is a blog engine powered by [Deno](https://deno.land). 😂 has no canonical pronunciation, and users are not encouraged to come up with one. 😂 is and always will be 😂 with no way to pronounce it. 4 | 5 | 😂 stores posts in SQLite and displays them as HTML. 😂 also has JSONFeed support so that external readers can catch up on new happenings. 6 | 7 | Xe writes 😂 live on Twitch: https://twitch.tv/princessxen. 8 | 9 | # Installation 10 | 11 | TODO(Xe): this -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - [x] Database models for posts 2 | - [x] Database models for static pages 3 | - [x] Database models for username/password 4 | - [x] HTML rendering of posts 5 | - [x] HTML rendering of post list 6 | - [ ] HTML rendering of static pages 7 | - [ ] JSONFeed 8 | - [x] move handlers into a different .ts file 9 | - [ ] Docker/Nix builds 10 | - [ ] Make blog index not show time of created_at 11 | - [ ] updated_at triggers for pages and posts 12 | - [ ] prerelease posts 13 | - [ ] autogen slugs 14 | - [ ] Admin UI 15 | - [ ] login page 16 | - [ ] session token (JWT?) 17 | - [x] edit posts 18 | - [x] delete posts 19 | - [x] create posts 20 | - [ ] use a better editing component 21 | - [ ] site information 22 | - [ ] thread through to layout.html 23 | -------------------------------------------------------------------------------- /db.ts: -------------------------------------------------------------------------------- 1 | import { DB, jose } from "./deps.ts"; 2 | 3 | const dbLoc = Deno.env.get("DATABASE_PATH"); 4 | const db = new DB(dbLoc !== undefined ? dbLoc : "./xn--g28h.db"); 5 | 6 | const migrations = [ 7 | ` 8 | CREATE TABLE IF NOT EXISTS posts 9 | ( id INTEGER NOT NULL PRIMARY KEY 10 | , title TEXT NOT NULL 11 | , slug TEXT NOT NULL 12 | , content TEXT NOT NULL 13 | , content_html TEXT NOT NULL 14 | , created_at TEXT NOT NULL 15 | , updated_at TEXT 16 | , deleted_at TEXT 17 | , metadata TEXT 18 | ); 19 | `, 20 | ` 21 | CREATE TABLE IF NOT EXISTS pages 22 | ( id INTEGER NOT NULL PRIMARY KEY 23 | , title TEXT NOT NULL 24 | , slug TEXT NOT NULL 25 | , content TEXT NOT NULL 26 | , content_html TEXT NOT NULL 27 | , created_at TEXT NOT NULL 28 | , updated_at TEXT 29 | , deleted_at TEXT 30 | ); 31 | `, 32 | ` 33 | CREATE TABLE IF NOT EXISTS users 34 | ( id INTEGER NOT NULL PRIMARY KEY 35 | , username TEXT NOT NULL 36 | , password TEXT NOT NULL 37 | ); 38 | `, 39 | ` 40 | CREATE UNIQUE INDEX IF NOT EXISTS posts_slugs 41 | ON posts(slug); 42 | `, 43 | ` 44 | ALTER TABLE posts ADD COLUMN public_at TEXT; 45 | `, 46 | ` 47 | CREATE TABLE IF NOT EXISTS jwt_secrets 48 | ( id INTEGER NOT NULL PRIMARY KEY 49 | , private TEXT NOT NULL 50 | , public TEXT NOT NULL 51 | ) 52 | `, 53 | ` 54 | ALTER TABLE posts ADD COLUMN draft BOOLEAN default FALSE; 55 | `, 56 | ]; 57 | 58 | const [dbVersion] = db.query("PRAGMA user_version")[0]; 59 | 60 | if (dbVersion as number < migrations.length) { 61 | const toRun = migrations.slice(dbVersion as number); 62 | for (const step of toRun) { 63 | db.query(step); 64 | } 65 | 66 | db.query(`PRAGMA user_version=${migrations.length}`); 67 | } 68 | 69 | // generate JWT keypair to save users a step. 70 | { 71 | const [len] = db.query("SELECT COUNT(*) FROM jwt_secrets")[0]; 72 | 73 | if (len === 0) { 74 | (async () => { 75 | const { publicKey, privateKey } = await jose.generateKeyPair("ES256", { 76 | extractable: true, 77 | }); 78 | 79 | const privateKeyPEM = await jose.exportJWK(privateKey); 80 | const publicKeyPEM = await jose.exportJWK(publicKey); 81 | 82 | console.log("🔒 generated new JWT keys"); 83 | db.query(`INSERT INTO jwt_secrets(private, public) VALUES (?, ?)`, [ 84 | JSON.stringify(privateKeyPEM), 85 | JSON.stringify(publicKeyPEM), 86 | ]); 87 | })(); 88 | } 89 | } 90 | 91 | async function getKeys(): Promise<{ 92 | publicKey: Uint8Array | jose.KeyLike; 93 | privateKey: Uint8Array | jose.KeyLike; 94 | }> { 95 | const [publicKey, privateKey] = 96 | db.query("SELECT public, private FROM jwt_secrets LIMIT 1")[0]; 97 | const [pubObj, privObj] = [ JSON.parse(publicKey as string), JSON.parse(privateKey as string) ]; 98 | privObj.alg = "ES256"; 99 | pubObj.alg = "ES256"; 100 | return { 101 | publicKey: await jose.importJWK(pubObj), 102 | privateKey: await jose.importJWK(privObj), 103 | }; 104 | } 105 | 106 | export { db, getKeys }; -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "https://deno.land/x/local_cache@1.0/mod.ts"; 2 | import * as Drash from "https://deno.land/x/drash@v2.5.4/mod.ts"; 3 | import { DB } from "https://deno.land/x/sqlite@v3.4.0/mod.ts"; 4 | import * as jose from "https://deno.land/x/jose@v4.8.1/index.ts"; 5 | import { Marked } from "https://deno.land/x/markdown@v2.0.0/mod.ts"; 6 | import { qrcode } from "https://deno.land/x/qrcode@v2.0.0/mod.ts"; 7 | 8 | // @deno-types="https://deno.land/x/otpauth@v7.1.3/dist/otpauth.d.ts" 9 | import * as OTPAuth from "https://deno.land/x/otpauth@v7.1.3/dist/otpauth.esm.js"; 10 | 11 | import { crypto } from "https://deno.land/std@0.140.0/crypto/mod.ts"; 12 | import { Buffer } from "https://deno.land/std@0.140.0/node/buffer.ts"; 13 | import * as iobuf from "https://deno.land/std@0.140.0/io/buffer.ts"; 14 | 15 | // templates 16 | import { TengineService } from "https://deno.land/x/drash@v2.5.4/src/services/tengine/tengine.ts"; 17 | 18 | const tengine = new TengineService({ 19 | views_path: "./views/", 20 | }); 21 | 22 | import { DexterService } from "https://deno.land/x/drash@v2.5.4/src/services/dexter/dexter.ts"; 23 | 24 | const dexter = new DexterService({ 25 | enabled: true, 26 | method: true, 27 | url: true, 28 | response_time: true, 29 | }); 30 | 31 | export { 32 | Buffer, 33 | Cache, 34 | crypto, 35 | DB, 36 | dexter, 37 | Drash, 38 | iobuf, 39 | jose, 40 | Marked, 41 | OTPAuth, 42 | qrcode, 43 | tengine, 44 | }; 45 | -------------------------------------------------------------------------------- /error_handler.ts: -------------------------------------------------------------------------------- 1 | import { Drash } from "./deps.ts"; 2 | 3 | export default class ErrorHandler extends Drash.ErrorHandler { 4 | public catch( 5 | error: Error, 6 | _request: Request, 7 | response: Drash.Response, 8 | ): void { 9 | // Handle all built-in Drash errors. This means any error that Drash 10 | // throws internally will be handled in this block. This also means any 11 | // resource that throws Drash.Errors.HttpError will be handled here. 12 | if (error instanceof Drash.Errors.HttpError) { 13 | response.status = error.code; 14 | 15 | const html = response.render("error.html", { 16 | title: error.message, 17 | error, 18 | }) as string; 19 | return response.html(html); 20 | } 21 | 22 | // If the error is not of type Drash.Errors.HttpError, then default to a 23 | // HTTP 500 error response. This is useful if you cannot ensure that 24 | // third-party dependencies (e.g., some database dependency) will throw 25 | // an error object that can be converted to an HTTP response. 26 | response.status = 500; 27 | return response.json({ 28 | message: "Server failed to process the request.", 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { dexter, Drash, tengine } from "./deps.ts"; 2 | 3 | // Resources 4 | import AdminIndex from "./resources/admin_index.ts"; 5 | import AdminLogin from "./resources/admin_login.ts"; 6 | import BlogDelete from "./resources/blog_delete.ts"; 7 | import BlogRescue from "./resources/blog_rescue.ts"; 8 | import BlogAdminIndex from "./resources/blog_admin_index.ts"; 9 | import BlogEdit from "./resources/blog_edit.ts"; 10 | import BlogIndex from "./resources/blog_index.ts"; 11 | import BlogPage from "./resources/blog_page.ts"; 12 | import BlogCreate from "./resources/blog_create.ts"; 13 | import Files from "./resources/files.ts"; 14 | import PagesIndex from "./resources/pages_index.ts"; 15 | 16 | import ErrorHandler from "./error_handler.ts"; 17 | 18 | const server = new Drash.Server({ 19 | error_handler: ErrorHandler, 20 | hostname: "", 21 | port: 8080, 22 | protocol: "http", 23 | resources: [ 24 | Files, 25 | PagesIndex, 26 | AdminLogin, 27 | AdminIndex, 28 | BlogDelete, 29 | BlogRescue, 30 | BlogAdminIndex, 31 | BlogEdit, 32 | BlogIndex, 33 | BlogPage, 34 | BlogCreate, 35 | ], 36 | services: [tengine, dexter], 37 | }); 38 | 39 | server.run(); 40 | 41 | console.log(`😂 running at ${server.address}.`); 42 | -------------------------------------------------------------------------------- /resources/admin_index.ts: -------------------------------------------------------------------------------- 1 | import { Drash } from "../deps.ts"; 2 | import { db } from "../db.ts"; 3 | import ProtectedResource from "./protected_resource.ts"; 4 | 5 | export default class AdminIndex extends ProtectedResource { 6 | public paths = ["/admin"]; 7 | 8 | public GET(_request: Drash.Request, response: Drash.Response) { 9 | const posts = db.query("SELECT COUNT(*) FROM posts")[0]; 10 | const pages = db.query("SELECT COUNT(*) FROM pages")[0]; 11 | const dbVersion = db.query("PRAGMA user_version")[0]; 12 | 13 | const html = response.render("admin_index.html", { 14 | posts, 15 | pages, 16 | dbVersion, 17 | title: "👷🏼‍♀️", 18 | }) as string; 19 | return response.html(html); 20 | } 21 | } -------------------------------------------------------------------------------- /resources/admin_login.ts: -------------------------------------------------------------------------------- 1 | import { Buffer, crypto, Drash, jose } from "../deps.ts"; 2 | import { db, getKeys } from "../db.ts"; 3 | 4 | export default class Login extends Drash.Resource { 5 | public paths = ["/admin/login"]; 6 | 7 | public async POST(request: Drash.Request, response: Drash.Response) { 8 | let username = request.bodyParam("username"); 9 | let password = request.bodyParam("password"); 10 | 11 | if (!username) { 12 | response.status = 400; 13 | return response.text("need auth"); 14 | } 15 | username = username as string; 16 | 17 | if (!password) { 18 | response.status = 400; 19 | return response.text("need auth"); 20 | } 21 | password = password as string; 22 | 23 | console.log({ username, password }); 24 | 25 | const userCount = db.query("SELECT COUNT(*) FROM users")[0]; 26 | 27 | const pwhash = Buffer.from( 28 | new Uint8Array( 29 | await crypto.subtle.digest( 30 | "BLAKE3", 31 | new TextEncoder().encode(password as string), 32 | ), 33 | ), 34 | ).toString("hex"); 35 | 36 | if (userCount[0] === 0) { 37 | db.query(`INSERT INTO users(username, password) VALUES (?, ?)`, [ 38 | username as string, 39 | pwhash, 40 | ]); 41 | } 42 | 43 | const [dbPwHash] = 44 | db.query(`SELECT password FROM users WHERE username = ?`, [ 45 | username as string, 46 | ])[0]; 47 | 48 | if (pwhash !== dbPwHash) { 49 | response.status = 400; 50 | return response.text("need auth"); 51 | } 52 | 53 | const { privateKey } = await getKeys(); 54 | 55 | console.log(privateKey); 56 | 57 | const jwt = await new jose.SignJWT({}) 58 | .setProtectedHeader({ alg: "ES256" }) 59 | .setIssuedAt() 60 | .setIssuer("xn--g28h.login") 61 | .setAudience("xn--g28h.blog") 62 | .setExpirationTime("2h") 63 | .sign(privateKey); 64 | 65 | console.log(jwt); 66 | 67 | response.setCookie({ name: "jwt", value: jwt }); 68 | 69 | response.headers.set("Location", "/admin"); 70 | response.status = 307; 71 | 72 | response.text("auth worked!"); 73 | } 74 | 75 | public GET(_request: Drash.Request, response: Drash.Response) { 76 | const html = response.render("admin_login.html", { title: "🔑❓" }) as string; 77 | return response.html(html); 78 | } 79 | } -------------------------------------------------------------------------------- /resources/blog_admin_index.ts: -------------------------------------------------------------------------------- 1 | import { Drash } from "../deps.ts"; 2 | import { db } from "../db.ts"; 3 | import ProtectedResource from "./protected_resource.ts"; 4 | 5 | export default class AdminIndex extends ProtectedResource { 6 | public paths = ["/admin/blog"]; 7 | 8 | public GET(_request: Drash.Request, response: Drash.Response) { 9 | const posts = []; 10 | 11 | for ( 12 | const [title, slug, created_at, updated_at, deleted_at, public_at, draft] of db.query( 13 | "SELECT title, slug, created_at, updated_at, deleted_at, public_at, draft FROM posts ORDER BY created_at DESC", 14 | ) 15 | ) { 16 | posts.push({ 17 | title, 18 | slug, 19 | created_at, 20 | updated_at, 21 | deleted_at, 22 | public_at, 23 | draft, 24 | }); 25 | } 26 | 27 | const html = response.render("blog_index_admin.html", { 28 | title: "👷🏼‍♀️📄", 29 | posts: posts, 30 | }) as string; 31 | 32 | return response.html(html); 33 | } 34 | } -------------------------------------------------------------------------------- /resources/blog_create.ts: -------------------------------------------------------------------------------- 1 | import { Drash, Marked } from "../deps.ts"; 2 | import { db } from "../db.ts"; 3 | import ProtectedResource from "./protected_resource.ts"; 4 | 5 | export default class Create extends ProtectedResource { 6 | public paths = ["/admin/blog/create"]; 7 | 8 | public POST(request: Drash.Request, response: Drash.Response) { 9 | const slug = request.bodyParam("slug"); 10 | const title = request.bodyParam("title"); 11 | const content = request.bodyParam("text"); 12 | 13 | if (!slug) { 14 | response.status = 400; 15 | return response.json({ "error": "missing slug" }); 16 | } 17 | 18 | if (!title) { 19 | response.status = 400; 20 | return response.json({ "error": "Missing title" }); 21 | } 22 | 23 | if (!content) { 24 | response.status = 400; 25 | return response.json({ "error": "Missing content" }); 26 | } 27 | 28 | const content_html = Marked.parse(content).content; 29 | 30 | db.query( 31 | "INSERT INTO posts(title, content, content_html, slug, created_at) VALUES(?, ?, ?, ?, DATETIME('now'))", 32 | [title as string, content as string, content_html, slug], 33 | ); 34 | 35 | response.json({ "message": "Post updated." }); 36 | } 37 | 38 | public GET(_request: Drash.Request, response: Drash.Response) { 39 | const html = response.render("blog_create.html", { title: "🆕📝" }) as string; 40 | return response.html(html); 41 | } 42 | } -------------------------------------------------------------------------------- /resources/blog_delete.ts: -------------------------------------------------------------------------------- 1 | import { Drash } from "../deps.ts"; 2 | import { db } from "../db.ts"; 3 | import ProtectedResource from "./protected_resource.ts"; 4 | 5 | export default class BlogDelete extends ProtectedResource { 6 | public paths = ["/admin/blog/:slug/delete"]; 7 | 8 | public GET(request: Drash.Request, response: Drash.Response) { 9 | const slug = request.pathParam("slug"); 10 | if (!slug) { 11 | response.status = 400; 12 | return response.text("need slug"); 13 | } 14 | 15 | db.query("UPDATE posts SET deleted_at=DATETIME('now') WHERE slug = ?", [slug]); 16 | 17 | response.status = 403; 18 | response.headers.set("Location", "/admin/blog"); 19 | return response.html(""); 20 | } 21 | } -------------------------------------------------------------------------------- /resources/blog_edit.ts: -------------------------------------------------------------------------------- 1 | import { Drash, Marked } from "../deps.ts"; 2 | import { db } from "../db.ts"; 3 | import ProtectedResource from "./protected_resource.ts"; 4 | 5 | export default class BlogEdit extends ProtectedResource { 6 | public paths = ["/admin/blog/:slug/edit"]; 7 | 8 | public POST(request: Drash.Request, response: Drash.Response) { 9 | const slug = request.pathParam("slug"); 10 | const title = request.bodyParam("title"); 11 | const content = request.bodyParam("text"); 12 | const is_draft = request.bodyParam("draft"); 13 | 14 | if (!title) { 15 | response.status = 400; 16 | return response.json({ "error": "Missing title" }); 17 | } 18 | if (!content) { 19 | response.status = 400; 20 | return response.json({ "error": "Missing content" }); 21 | } 22 | 23 | const content_html = Marked.parse(content).content; 24 | 25 | db.query( 26 | "UPDATE posts SET title = ?, content = ?, content_html = ?, updated_at = DATETIME('now'), draft = ? WHERE slug = ?", 27 | [title as string, content as string, content_html, is_draft || true, slug], 28 | ); 29 | 30 | response.json({ "message": "Post updated." }); 31 | } 32 | 33 | public GET(request: Drash.Request, response: Drash.Response) { 34 | const slug = request.pathParam("slug"); 35 | 36 | let post = undefined; 37 | for ( 38 | const [title, content, created_at, deleted_at, draft] of db.query( 39 | "SELECT title, content, created_at, deleted_at, draft FROM posts WHERE slug = ?", 40 | [slug], 41 | ) 42 | ) { 43 | post = { 44 | title, 45 | slug, 46 | content, 47 | created_at, 48 | deleted_at, 49 | draft, 50 | }; 51 | } 52 | 53 | if (post === undefined) { 54 | throw new Drash.Errors.HttpError(404, "Post not found: " + slug); 55 | } 56 | 57 | const html = response.render("blog_edit.html", post) as string; 58 | return response.html(html); 59 | } 60 | } -------------------------------------------------------------------------------- /resources/blog_index.ts: -------------------------------------------------------------------------------- 1 | import { Drash } from "../deps.ts"; 2 | import { db } from "../db.ts"; 3 | 4 | export default class Index extends Drash.Resource { 5 | public paths = ["/blog"]; 6 | 7 | public GET(_request: Drash.Request, response: Drash.Response) { 8 | const posts = []; 9 | 10 | for ( 11 | const [title, slug, created_at ] of db.query( 12 | "SELECT title, slug, created_at FROM posts WHERE draft = FALSE AND deleted_at IS NULL OR public_at < DATETIME('now') ORDER BY created_at DESC", 13 | ) 14 | ) { 15 | posts.push({ 16 | title, 17 | slug, 18 | created_at, 19 | }); 20 | } 21 | 22 | const html = response.render("blog_index.html", { 23 | title: "📄", 24 | posts: posts, 25 | }) as string; 26 | 27 | return response.html(html); 28 | } 29 | } -------------------------------------------------------------------------------- /resources/blog_page.ts: -------------------------------------------------------------------------------- 1 | import { Drash } from "../deps.ts"; 2 | import { db } from "../db.ts"; 3 | 4 | export default class Page extends Drash.Resource { 5 | public paths = ["/blog/:slug"]; 6 | 7 | public GET(request: Drash.Request, response: Drash.Response) { 8 | const slug = request.pathParam("slug"); 9 | 10 | let post = undefined; 11 | for ( 12 | const [title, content_html, created_at, updated_at, public_at, draft] of db.query( 13 | "SELECT title, content_html, created_at, updated_at, public_at, draft FROM posts WHERE slug = ? AND deleted_at IS NULL", 14 | [slug], 15 | ) 16 | ) { 17 | post = { 18 | title, 19 | content_html, 20 | created_at, 21 | updated_at, 22 | public_at, 23 | draft, 24 | }; 25 | } 26 | 27 | if (post === undefined) { 28 | throw new Drash.Errors.HttpError(404, "Post not found or was deleted: " + slug); 29 | } 30 | 31 | const html = response.render("blog_page.html", post) as string; 32 | return response.html(html); 33 | } 34 | } -------------------------------------------------------------------------------- /resources/blog_rescue.ts: -------------------------------------------------------------------------------- 1 | import { Drash } from "../deps.ts"; 2 | import { db } from "../db.ts"; 3 | import ProtectedResource from "./protected_resource.ts"; 4 | 5 | export default class BlogRescue extends ProtectedResource { 6 | public paths = ["/admin/blog/:slug/rescue"]; 7 | 8 | public GET(request: Drash.Request, response: Drash.Response) { 9 | let slug = request.pathParam("slug"); 10 | if (!slug) { 11 | response.status = 400; 12 | return response.text("need slug"); 13 | } 14 | slug = slug as string; 15 | 16 | db.query("UPDATE posts SET deleted_at=NULL WHERE slug = ?", [slug]); 17 | 18 | response.status = 403; 19 | response.headers.set("Location", "/admin/blog"); 20 | return response.html(""); 21 | } 22 | } -------------------------------------------------------------------------------- /resources/files.ts: -------------------------------------------------------------------------------- 1 | import { Drash } from "../deps.ts"; 2 | 3 | export default class Files extends Drash.Resource { 4 | paths = ["/static/.*"]; 5 | 6 | public GET(request: Drash.Request, response: Drash.Response) { 7 | const path = new URL(request.url).pathname; 8 | return response.file(`.${path}`); // response.file("./favicon.ico") 9 | } 10 | } -------------------------------------------------------------------------------- /resources/pages_index.ts: -------------------------------------------------------------------------------- 1 | import { Drash } from "../deps.ts"; 2 | 3 | export default class PagesIndex extends Drash.Resource { 4 | public paths = ["/"]; 5 | 6 | public GET(_request: Drash.Request, response: Drash.Response) { 7 | const opts = { 8 | title: "Home", 9 | content: "Welcome to my website! I haven't configured a home page with 😂 yet, but I'm sure you'll find something interesting if you dig around my blog.", 10 | }; 11 | 12 | const html = response.render("index.html", opts) as string; 13 | return response.html(html); 14 | } 15 | } -------------------------------------------------------------------------------- /resources/protected_resource.ts: -------------------------------------------------------------------------------- 1 | import {Drash} from "../deps.ts"; 2 | import AuthService from "../services/auth_service.ts"; 3 | 4 | export default class ProtectedResource extends Drash.Resource { 5 | public services = { 6 | ALL: [AuthService], 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /services/auth_service.ts: -------------------------------------------------------------------------------- 1 | import { Drash, jose } from "../deps.ts"; 2 | import { getKeys } from "../db.ts"; 3 | 4 | class AuthenticationService extends Drash.Service { 5 | /** 6 | * Run the following code before a resource is hit. 7 | * 8 | * @param request - The incoming request from the client. 9 | * @param response - The response to send back to the client (if needed). 10 | */ 11 | public async runBeforeResource( 12 | request: Drash.Request, 13 | response: Drash.Response, 14 | ) { 15 | const cookie = request.getCookie("jwt"); 16 | 17 | if (!cookie) { 18 | response.status = 307; 19 | response.headers.set("Location", "/admin/login"); 20 | return response.html(""); 21 | } 22 | 23 | const { publicKey } = await getKeys(); 24 | await jose.jwtVerify( 25 | cookie, 26 | publicKey, 27 | { 28 | issuer: "xn--g28h.login", 29 | audience: "xn--g28h.blog", 30 | algorithms: ["ES256"], 31 | }, 32 | ); 33 | } 34 | } 35 | 36 | export default new AuthenticationService(); 37 | -------------------------------------------------------------------------------- /static/createblog.js: -------------------------------------------------------------------------------- 1 | import { g, r } from "./xeact.js"; 2 | 3 | r(() => { 4 | const submit = g("submit"); 5 | submit.onclick = async () => { 6 | const editor = g("editor"); 7 | const text = editor.value; 8 | const title = g("title").value; 9 | const slug = g("slug").value; 10 | 11 | const resp = await fetch("/admin/blog/create", { 12 | method: "POST", 13 | body: JSON.stringify({ text, title, slug }), 14 | headers: { 15 | Accept: "application/json", 16 | "Content-Type": "application/json", 17 | }, 18 | }); 19 | if (resp.status != 200) { 20 | alert("Error: " + resp.status); 21 | } else { 22 | window.location.href = "/admin/blog/" + slug + "/edit"; 23 | } 24 | }; 25 | }); -------------------------------------------------------------------------------- /static/editblog.js: -------------------------------------------------------------------------------- 1 | import { g, r } from "./xeact.js"; 2 | 3 | r(() => { 4 | const submit = g("submit"); 5 | submit.onclick = async () => { 6 | const editor = g("editor"); 7 | const text = editor.value; 8 | const title = g("title").value; 9 | const slug = g("slug").value; 10 | const draft = g("draft").checked; 11 | 12 | const resp = await fetch("/admin/blog/" + slug + "/edit", { 13 | method: "POST", 14 | body: JSON.stringify({ text, title, draft }), 15 | headers: { 16 | Accept: "application/json", 17 | "Content-Type": "application/json", 18 | }, 19 | }); 20 | if (resp.status != 200) { 21 | alert("Error: " + resp.status); 22 | } else { 23 | //window.location.href = "/admin/blog/" + slug + "/edit"; 24 | } 25 | }; 26 | }); 27 | -------------------------------------------------------------------------------- /static/xeact-html.js: -------------------------------------------------------------------------------- 1 | import { h, t } from "./xeact.js"; 2 | const $tl = (kind) => (text, attrs = {}, children = []) => { 3 | children.unshift(t(text)); 4 | return h(kind, attrs, children); 5 | }; 6 | export const h1 = $tl("h1"); 7 | export const h2 = $tl("h2"); 8 | export const h3 = $tl("h3"); 9 | export const h4 = $tl("h4"); 10 | export const h5 = $tl("h5"); 11 | export const h6 = $tl("h6"); 12 | export const p = $tl("p"); 13 | export const b = $tl("b"); 14 | export const i = $tl("i"); 15 | export const u = $tl("u"); 16 | export const dd = $tl("dd"); 17 | export const dt = $tl("dt"); 18 | export const del = $tl("del"); 19 | export const sub = $tl("sub"); 20 | export const sup = $tl("sup"); 21 | export const strong = $tl("strong"); 22 | export const small = $tl("small"); 23 | export const hl = () => h("hl"); 24 | export const br = () => h("br"); 25 | export const img = (src, alt="") => h("img", {src, alt}); 26 | export const ahref = (href, text) => h("a", {href}, t(text)); 27 | const $dl = (kind) => (attrs = {}, children = []) => h(kind, attrs, children); 28 | export const span = $dl("span"); 29 | export const div = $dl("div"); 30 | export const ul = $dl("ul"); 31 | export const iframe = (src, attrs = {}) => { 32 | attrs["src"] = src; 33 | return h("iframe", attrs); 34 | }; 35 | -------------------------------------------------------------------------------- /static/xeact.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a DOM element, assigns the properties of `data` to it, and appends all `children`. 3 | * 4 | * @type{function(string, Object=, Node|Array.=)} 5 | */ 6 | const h = (name, data = {}, children = []) => { 7 | let result = Object.assign(document.createElement(name), data); 8 | if (!Array.isArray(children)) { 9 | children = [children]; 10 | } 11 | result.append(...children); 12 | return result; 13 | }; 14 | 15 | /** 16 | * Create a text node. 17 | * 18 | * Equivalent to `document.createTextNode(text)` 19 | * 20 | * @type{function(string): Text} 21 | */ 22 | const t = (text) => document.createTextNode(text); 23 | 24 | /** 25 | * Remove all child nodes from a DOM element. 26 | * 27 | * @type{function(Node)} 28 | */ 29 | const x = (elem) => { 30 | while (elem.lastChild) { 31 | elem.removeChild(elem.lastChild); 32 | } 33 | }; 34 | 35 | /** 36 | * Get all elements with the given ID. 37 | * 38 | * Equivalent to `document.getElementById(name)` 39 | * 40 | * @type{function(string): HTMLElement} 41 | */ 42 | const g = (name) => document.getElementById(name); 43 | 44 | /** 45 | * Get all elements with the given class name. 46 | * 47 | * Equivalent to `document.getElementsByClassName(name)` 48 | * 49 | * @type{function(string): HTMLCollectionOf.} 50 | */ 51 | const c = (name) => document.getElementsByClassName(name); 52 | 53 | /** @type{function(string): HTMLCollectionOf.} */ 54 | const n = (name) => document.getElementsByName(name); 55 | 56 | /** 57 | * Get all elements matching the given HTML selector. 58 | * 59 | * Matches selectors with `document.querySelectorAll(selector)` 60 | * 61 | * @type{function(string): Array.} 62 | */ 63 | const s = (selector) => Array.from(document.querySelectorAll(selector)); 64 | 65 | /** 66 | * Generate a relative URL from `url`, appending all key-value pairs from `params` as URL-encoded parameters. 67 | * 68 | * @type{function(string=, Object=): string} 69 | */ 70 | const u = (url = "", params = {}) => { 71 | let result = new URL(url, window.location.href); 72 | Object.entries(params).forEach((kv) => { 73 | let [k, v] = kv; 74 | result.searchParams.set(k, v); 75 | }); 76 | return result.toString(); 77 | }; 78 | 79 | /** 80 | * Takes a callback to run when all DOM content is loaded. 81 | * 82 | * Equivalent to `window.addEventListener('DOMContentLoaded', callback)` 83 | * 84 | * @type{function(function())} 85 | */ 86 | const r = (callback) => window.addEventListener('DOMContentLoaded', callback); 87 | 88 | export { h, t, x, g, c, n, u, s, r }; 89 | -------------------------------------------------------------------------------- /static/xess.css: -------------------------------------------------------------------------------- 1 | main { 2 | font-family: monospace, monospace; 3 | max-width: 38rem; 4 | padding: 2rem; 5 | margin: auto; 6 | } 7 | 8 | @media only screen and (max-device-width: 736px) { 9 | main { 10 | padding: 0rem; 11 | } 12 | } 13 | 14 | ::selection { 15 | background: #d3869b; 16 | } 17 | 18 | body { 19 | background: #282828; 20 | color: #ebdbb2; 21 | } 22 | 23 | pre { 24 | background-color: #3c3836; 25 | padding: 1em; 26 | border: 0; 27 | } 28 | 29 | a, a:active, a:visited { 30 | color: #b16286; 31 | background-color: #1d2021; 32 | } 33 | 34 | h1, h2, h3, h4, h5 { 35 | margin-bottom: .1rem; 36 | } 37 | 38 | blockquote { 39 | border-left: 1px solid #bdae93; 40 | margin: 0.5em 10px; 41 | padding: 0.5em 10px; 42 | } 43 | 44 | footer { 45 | align-content: center; 46 | } 47 | 48 | @media (prefers-color-scheme: light) { 49 | body { 50 | background: #fbf1c7; 51 | color: #3c3836; 52 | } 53 | 54 | pre { 55 | background-color: #ebdbb2; 56 | padding: 1em; 57 | border: 0; 58 | } 59 | 60 | a, a:active, a:visited { 61 | color: #b16286; 62 | background-color: #f9f5d7; 63 | } 64 | 65 | h1, h2, h3, h4, h5 { 66 | margin-bottom: .1rem; 67 | } 68 | 69 | blockquote { 70 | border-left: 1px solid #655c54; 71 | margin: 0.5em 10px; 72 | padding: 0.5em 10px; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tools/hashpw.ts: -------------------------------------------------------------------------------- 1 | import { Buffer, crypto, iobuf } from "../deps.ts"; 2 | 3 | async function promptString(question: string): Promise { 4 | console.log(question); 5 | 6 | for await (const line of iobuf.readLines(Deno.stdin)) { 7 | return line; 8 | } 9 | 10 | return undefined; 11 | } 12 | 13 | console.log( 14 | Buffer.from( 15 | new Uint8Array( 16 | await crypto.subtle.digest( 17 | "BLAKE3", 18 | new TextEncoder().encode(await promptString("enter password:")), 19 | ), 20 | ), 21 | ).toString("hex"), 22 | ); -------------------------------------------------------------------------------- /views/admin_index.html: -------------------------------------------------------------------------------- 1 | <% extends("layout.html") %> 2 | 3 |

<% title %>

4 | 5 |

Posts: <% posts %> - ✏️

6 |

Pages: <% pages %> - ✏️

7 | 8 |

Database Version: <% dbVersion %>

-------------------------------------------------------------------------------- /views/admin_login.html: -------------------------------------------------------------------------------- 1 | <% extends("layout.html") %> 2 | 3 |

<% title %>

4 | 5 |
6 |
7 |
8 | 9 |
-------------------------------------------------------------------------------- /views/blog_create.html: -------------------------------------------------------------------------------- 1 | <% extends("layout.html") %> 2 | 3 |

<% title %>

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /views/blog_edit.html: -------------------------------------------------------------------------------- 1 | <% extends("layout.html") %> 2 | 3 |

✏️: <% title %>

4 | 5 |

Created at: <% created_at %>

6 | 7 | <% if (deleted_at) { %> 8 |

Deleted at: <% deleted_at %>

9 | <% } %> 10 | 11 | 🌐 <% if (deleted_at === null) { %> <% } else { %>🛟<% } %>
12 | 13 | 14 | checked<% } %> /> 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /views/blog_index.html: -------------------------------------------------------------------------------- 1 | <% extends("layout.html") %> 2 | 3 |

<% title %>

4 | 5 |
    6 | <% for (let post of posts) { %> 7 |
  • <% post.created_at %> <% post.title %>
  • 8 | <% } %> 9 |
10 | 11 | <% if (posts.length === 0) { %> 12 |

😭⏰🔜🆕

13 | <% } %> -------------------------------------------------------------------------------- /views/blog_index_admin.html: -------------------------------------------------------------------------------- 1 | <% extends("layout.html") %> 2 | 3 |

<% title %>

4 | 5 | 🆕📝 6 | 7 |
    8 | <% for (let post of posts) { %> 9 |
  • <% if (!post.draft) { %>📄<% } else { %>📝<% } %> <% post.created_at %> <% post.title %> - <% if (post.deleted_at === null) { %> <% } else { %>🛟<% } %>
  • 10 | <% } %> 11 |
-------------------------------------------------------------------------------- /views/blog_page.html: -------------------------------------------------------------------------------- 1 | <% extends("layout.html") %> 2 | 3 |

<% if (!draft) { %>📄<% } else { %>📝<% } %> <% title %>

4 | 5 | Created at: <% created_at %> <% if (updated_at) { %>
Updated at: <% updated_at %> <% } %>
6 | 7 | <% content_html %> -------------------------------------------------------------------------------- /views/error.html: -------------------------------------------------------------------------------- 1 | <% extends("layout.html") %> 2 | 3 |

⛔😭

4 | 5 |

OOPSIE WOOPSIE!! Uwu We made a fucky wucky!! A wittle fucko boingo! The code monkeys at our headquarters are working VEWY HAWD to fix this!

6 | 7 |

<% error.message %>

-------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | <% extends("layout.html") %> 2 | 3 |

<% title %>

4 | 5 | <% content %> -------------------------------------------------------------------------------- /views/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <% title %> 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | <% yield %> 14 | 15 | 18 |
19 | 20 | --------------------------------------------------------------------------------