A simple AtProto app to read pet.mewsse.link records on my PDS.

Add basic static asset serve and template rendering

Mewsse 31fb06ac d6ee2bc3

assets/background.webp

This is a binary file and will not be displayed.

assets/favicon.ico

This is a binary file and will not be displayed.

assets/favicon.png

This is a binary file and will not be displayed.

+1
assets/missing-image.svg
···
···
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width='1em' height='1em'><path fill="currentColor" d="M21 5c0-1.1-.9-2-2-2H5.83L21 18.17zM2.81 2.81L1.39 4.22L3 5.83V19c0 1.1.9 2 2 2h13.17l1.61 1.61l1.41-1.41zM6 17l3-4l2.25 3l.82-1.1l2.1 2.1z"/></svg>
assets/network.webp

This is a binary file and will not be displayed.

+217
assets/style.css
···
···
+
html,
+
body {
+
background: #0a0022;
+
color: #75beeb;
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
+
padding: 0;
+
margin: 0;
+
min-height: 100%;
+
}
+
+
html {
+
height: 100%;
+
}
+
+
body {
+
padding: 2vw;
+
min-height: 100%;
+
box-sizing: border-box;
+
background: rgb(136,7,74);
+
background: linear-gradient(180deg, rgba(136,7,74,1) 0%, rgba(21,1,53,1) 100%);
+
background: url(./background.webp) no-repeat center center;
+
background-size: auto;
+
background-attachment: fixed;
+
}
+
+
body h1 {
+
font-size: 5em;
+
color: oklch(1 0 197);
+
margin: 0 0 0.1em;
+
}
+
+
body > * {
+
box-sizing: border-box;
+
position: relative;
+
z-index: 1;
+
max-width: 800px;
+
margin: 0 auto;
+
transform: translateZ(0);
+
}
+
+
h1+p {
+
font-size: 1.3em;
+
color: oklch(1 0 197);
+
margin: 0 0 .5em 0;
+
}
+
+
p {
+
margin: 0
+
}
+
+
small {
+
font-size: 1rem;
+
}
+
+
.atom {
+
display: inline-block;
+
width: 1rem;
+
height: 1rem;
+
}
+
+
.atom svg {
+
fill: currentColor;
+
}
+
+
.small {
+
margin: .3em 0 0;
+
color: #75beeb;
+
font-size: .8em;
+
}
+
+
.description {
+
color: #afd4ff;
+
}
+
+
a, a:visited {
+
color: oklch(1 0 197);
+
text-decoration: none;
+
}
+
a:hover {
+
color: #ff91e9;
+
text-decoration: none;
+
}
+
+
a span {
+
color: #ff91e9;
+
}
+
+
h1+p a, h1+p a:visited {
+
color: #ff91e9;
+
text-decoration: underline;
+
}
+
+
a:hover span {
+
color: oklch(1 0 197);
+
}
+
+
a, a span {
+
transition: color .3s;
+
}
+
+
header, .links, .pagination{
+
padding: 1em;
+
background: #241844;
+
border: 1px solid oklch(100% 0% 0 / 10%);
+
border-radius: 1em;
+
margin-bottom: 1em;
+
}
+
+
.item {
+
text-align: center;
+
display: flex;
+
margin-bottom: 1rem;
+
padding-bottom: 1rem;
+
border-bottom : 1px solid oklch(100% 0% 0 / 10%);
+
}
+
+
.item:last-child {
+
margin-bottom: 0;
+
padding-bottom: 0;
+
border-bottom : none;
+
}
+
+
.item section {
+
width: 100%;
+
padding: 0 1rem 0 0;
+
}
+
+
.item .aside {
+
display: block;
+
width: 300px;
+
background: white url(./missing-image.svg) no-repeat center center;
+
border-radius: .6rem;
+
overflow: hidden;
+
}
+
+
.item h2, .item .description{
+
text-align: left;
+
}
+
+
.item img {
+
display: block;
+
width: 100%;
+
height: 100%;
+
object-fit: cover;
+
transition: all .3s;
+
}
+
+
.item .aside input {
+
display: none;
+
}
+
+
.item.nsfw .aside {
+
background: transparent;
+
}
+
+
.item.nsfw img {
+
filter: blur(30px);
+
}
+
+
.item.big {
+
flex-direction: column;
+
}
+
+
.item.big .description {
+
margin-bottom: 1rem;
+
}
+
+
.item.big .aside {
+
width: 100%;
+
}
+
+
.item.nsfw .aside:hover img, .item.nsfw .aside input:checked + img {
+
cursor: pointer;
+
filter: blur(0rem);
+
}
+
+
.pagination ul {
+
text-align: center;
+
list-style: none;
+
margin: 0;
+
padding: 0;
+
}
+
+
.pagination ul li, .pagination ul li a {
+
display: inline-block;
+
}
+
+
.pagination ul li a, .pagination ul li.selected {
+
padding: .5rem 1rem;
+
}
+
+
header {
+
margin-bottom : 1em;
+
}
+
+
h2 {
+
margin-top: 0;
+
}
+
+
@media only screen and (max-width: 700px) {
+
body h1 {
+
font-size: 2rem;
+
}
+
+
.item {
+
flex-direction: column;
+
}
+
+
.item .aside {
+
width: 100%;
+
margin-top: 1rem;
+
}
+
+
.item .aside.empty {
+
display: none;
+
}
+
}
+30 -1
package-lock.json
···
"@skyware/jetstream": "^0.2.5",
"better-sqlite3": "^12.4.1",
"dotenv": "^17.2.3",
-
"kysely": "^0.28.7"
},
"devDependencies": {
"@atcute/lex-cli": "^2.2.2",
···
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
},
"node_modules/event-target-polyfill": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz",
···
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/mimic-response": {
···
"@skyware/jetstream": "^0.2.5",
"better-sqlite3": "^12.4.1",
"dotenv": "^17.2.3",
+
"eta": "^4.0.1",
+
"kysely": "^0.28.7",
+
"mime": "^4.1.0"
},
"devDependencies": {
"@atcute/lex-cli": "^2.2.2",
···
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
},
+
"node_modules/eta": {
+
"version": "4.0.1",
+
"resolved": "https://registry.npmjs.org/eta/-/eta-4.0.1.tgz",
+
"integrity": "sha512-0h0oBEsF6qAJU7eu9ztvJoTo8D2PAq/4FvXVIQA1fek3WOTe6KPsVJycekG1+g1N6mfpblkheoGwaUhMtnlH4A==",
+
"license": "MIT",
+
"engines": {
+
"node": ">=20"
+
},
+
"funding": {
+
"url": "https://github.com/bgub/eta?sponsor=1"
+
}
+
},
"node_modules/event-target-polyfill": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz",
···
"license": "MIT",
"engines": {
"node": ">=20.0.0"
+
}
+
},
+
"node_modules/mime": {
+
"version": "4.1.0",
+
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
+
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
+
"funding": [
+
"https://github.com/sponsors/broofa"
+
],
+
"license": "MIT",
+
"bin": {
+
"mime": "bin/cli.js"
+
},
+
"engines": {
+
"node": ">=16"
}
},
"node_modules/mimic-response": {
+3 -1
package.json
···
"@skyware/jetstream": "^0.2.5",
"better-sqlite3": "^12.4.1",
"dotenv": "^17.2.3",
-
"kysely": "^0.28.7"
},
"devDependencies": {
"@atcute/lex-cli": "^2.2.2",
···
"@skyware/jetstream": "^0.2.5",
"better-sqlite3": "^12.4.1",
"dotenv": "^17.2.3",
+
"eta": "^4.0.1",
+
"kysely": "^0.28.7",
+
"mime": "^4.1.0"
},
"devDependencies": {
"@atcute/lex-cli": "^2.2.2",
+1 -1
src/id-resolver.ts
···
import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver } from '@atcute/identity-resolver'
import { isDid} from '@atcute/lexicons/syntax'
-
import process from 'process'
import type { DidDocument, Service } from '@atcute/identity'
import type { Did } from '@atcute/lexicons/syntax'
···
import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver } from '@atcute/identity-resolver'
import { isDid} from '@atcute/lexicons/syntax'
+
import process from 'node:process'
import type { DidDocument, Service } from '@atcute/identity'
import type { Did } from '@atcute/lexicons/syntax'
+5 -5
src/index.ts
···
import { createDb, migrateToLatest } from './db.ts'
import { createIngester } from './ingester.ts'
-
import dotenv from 'dotenv'
-
import process from 'process'
-
-
import type { Database } from './db.ts'
import { Jetstream } from '@skyware/jetstream'
import { logger } from './lib/logger.ts'
import { Router } from './lib/router.ts'
-
import { createRoutes } from './routes.ts'
dotenv.config()
···
import { createDb, migrateToLatest } from './db.ts'
import { createIngester } from './ingester.ts'
import { Jetstream } from '@skyware/jetstream'
+
import { createRoutes } from './routes.ts'
import { logger } from './lib/logger.ts'
import { Router } from './lib/router.ts'
+
import process from 'node:process'
+
import dotenv from 'dotenv'
+
+
import type { Database } from './db.ts'
dotenv.config()
+1 -1
src/ingester.ts
···
import type { Database, Link } from './db.ts'
import { Client, simpleFetchHandler } from '@atcute/client'
-
import { Jetstream } from '@skyware/jetstream'
import { findUserPDS, getUserDID } from './id-resolver.ts'
import { RepoReader } from '@atcute/car/v4'
import { decode } from '@atcute/cbor'
import { logger } from "./lib/logger.ts"
···
import type { Database, Link } from './db.ts'
import { Client, simpleFetchHandler } from '@atcute/client'
import { findUserPDS, getUserDID } from './id-resolver.ts'
+
import { Jetstream } from '@skyware/jetstream'
import { RepoReader } from '@atcute/car/v4'
import { decode } from '@atcute/cbor'
import { logger } from "./lib/logger.ts"
+78 -6
src/lib/router.ts
···
-
import { createServer, Server, IncomingMessage, ServerResponse } from "node:http";
-
import { logger } from "./logger.ts";
interface Callback {
(req: IncomingMessage, res: ServerResponse, params?: {[key: string]: string}): void
···
string: "[a-z-_]+"
}
constructor() {
this.routes = []
this.server = null
···
}
handleHttpRequest() {
-
return (req:IncomingMessage, res:ServerResponse) => {
for (let route of this.routes) {
let match = req.url?.match(route.path)
-
console.log(route.path, req.url)
-
if (!match) continue;
route.callback(req, res, match.groups)
return
}
-
res.end('HTTP/1.1 400 Bad Request\r\n\r\n')
}
}
···
+
import { createServer, Server, IncomingMessage, ServerResponse } from "node:http"
+
import { logger } from "./logger.ts"
+
import process from 'node:process'
+
import path from "node:path"
+
import mime from "mime"
+
import fs from "node:fs"
interface Callback {
(req: IncomingMessage, res: ServerResponse, params?: {[key: string]: string}): void
···
string: "[a-z-_]+"
}
+
readonly NON_SAFE_CHARS = /[\x00-\x1F\x20\x7F-\uFFFF]+/g
+
readonly ASSETS_MATCHER = /^\/assets\/([a-z0-9.-_\/]+)|\/favicon.ico$/g
+
readonly IGNORE_FILES = [
+
"assets",
+
"."
+
]
+
constructor() {
this.routes = []
this.server = null
···
}
handleHttpRequest() {
+
return async (req:IncomingMessage, res:ServerResponse) => {
+
if (!req.method) {
+
res.end('HTTP/1.1 400 Bad Request\r\n\r\n')
+
logger.error(`400: ${req.url}`)
+
}
+
+
if (
+
req.method &&
+
req.url &&
+
req.url.match(this.ASSETS_MATCHER) &&
+
['GET', 'HEAD'].includes(req.method)
+
) {
+
const pathName = String(req.url)
+
.replace(this.NON_SAFE_CHARS, encodeURIComponent)
+
.split("/")
+
.filter((part) => part.length > 0 && !this.IGNORE_FILES.includes(part))
+
.join('/')
+
const tryFile = path.normalize(path.join(process.cwd(), 'assets', pathName))
+
+
let stat = null
+
+
try {
+
stat = await fs.promises.stat(tryFile)
+
+
if (!stat.isFile()) {
+
throw new Error("Not a file")
+
}
+
} catch (error) {
+
logger.error(error)
+
res.end('HTTP/1.1 404 Not Found\r\n\r\n')
+
return
+
}
+
+
const ext = path.extname(tryFile)
+
const type = mime.getType(ext) || 'application/octet-stream'
+
+
res.setHeader('Content-Type', type)
+
res.setHeader('Content-Length', stat.size)
+
res.setHeader('Etag', `${stat.size.toString(16)}-${stat.mtime.getTime().toString(16)}`)
+
res.setHeader('Last-Modified', stat.mtime.toUTCString())
+
logger.info(`${req.method} ${req.url}`)
+
+
if (req.method === "HEAD") {
+
res.end()
+
return
+
}
+
+
const stream = fs.createReadStream(tryFile)
+
stream.pipe(res)
+
+
stream.on('error', (err) => {
+
logger.error(err)
+
stream.destroy()
+
})
+
+
stream.on('end', () => {
+
res.end()
+
})
+
+
return
+
}
for (let route of this.routes) {
let match = req.url?.match(route.path)
+
if (route.method != req.method || !match) continue
+
logger.info(`${req.method} ${req.url}`)
route.callback(req, res, match.groups)
return
}
+
res.end('HTTP/1.1 404 Not Found\r\n\r\n')
+
logger.error(`404: ${req.method} ${req.url}`)
}
}
+8 -2
src/routes.ts
···
-
import { Router } from "./lib/router.ts";
export const createRoutes = (router:Router) => {
router.get('/', (req, res) => {
-
res.end()
})
router.get('/page/{page:number}', (req, res, params) => {
···
+
import { Router } from "./lib/router.ts"
+
import path from "node:path"
+
import { Eta } from "eta"
export const createRoutes = (router:Router) => {
+
const eta = new Eta({ views: path.join(import.meta.dirname, "views")})
router.get('/', (req, res) => {
+
const body = eta.render("./main", { items: [], pages: []})
+
+
res.writeHead(200, {'Content-Type': 'text/html'})
+
res.end(body)
})
router.get('/page/{page:number}', (req, res, params) => {
+31
src/views/feed.eta
···
···
+
<?xml version="1.0" encoding="UTF-8"?>
+
<feed xmlns="http://www.w3.org/2005/Atom">
+
<title>Mewsse's links</title>
+
<id>https://mewsse.gay/</id>
+
<link rel="alternate" href="https://mewsse.gay/"/>
+
<link rel="self" href="https://mewsse.gay/feed.atom"/>
+
<updated><%= it.date.toISOString() %></updated>
+
<author>
+
<name>Mewsse</name>
+
</author>
+
<% it.items.forEach(function (item) { %>
+
<entry>
+
<title><%= item.title %></title>
+
<link rel="alternate" type="text/html" href="<%= item.url %>"/>
+
<id><%= item.url %></id>
+
<published><%= new Date(item.date).toISOString() %></published>
+
<updated><%= new Date(item.date).toISOString() %></updated>
+
<content type="html">
+
<![CDATA[
+
<h2><a href="<%= item.url %>"><%= item.title %></a></h2>
+
<% if (item.description) { %>
+
<p><%~ item.description %></p>
+
<% } %>
+
<% if (item.image) { %>
+
<img src="<%= item.image %>" />
+
<% } %>
+
]]>
+
</content>
+
</entry>
+
<% }) %>
+
</feed>
+22
src/views/link.eta
···
···
+
<div class="item <% if (it.nsfw) { %>nsfw<% } %> <% if (it.big) { %>big<% } %>">
+
<section>
+
<h2><a href="<%= it.url %>"><%= it.title %></a></h2>
+
<% if (it.description) { %>
+
<div class="description"><%~ it.description %></div>
+
<% } %>
+
</section>
+
<% if (it.nsfw) { %>
+
<label class="aside <% if (!it.image) { %>empty<% } %>" target="<%= it.id %>">
+
<input type="checkbox" id="<%= it.id %>">
+
<% if (it.image) { %>
+
<img src="<%= it.image %>">
+
<% } %>
+
</label>
+
<% } else { %>
+
<a class="aside <% if (!it.image) { %>empty<% } %>" href="<%= it.url %>">
+
<% if (it.image) { %>
+
<img src="<%= it.image %>">
+
<% } %>
+
</a>
+
<% } %>
+
</div>
+45
src/views/main.eta
···
···
+
<!DOCTYPE html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8">
+
<meta name="robots" content="noindex">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Mewsse's links<% if (it.selected > 0) { %> - page <%= it.selected + 1 %><% } %></title>
+
<link rel="stylesheet" href="/assets/style.css">
+
<link rel="icon" type="image/png" href="/assets/favicon.png">
+
<link rel="alternate" title="All links" type="application/atom+xml" href="/feed.atom">
+
<meta property="og:title" content="Mewsse's links">
+
<meta property="og:description" content="A collection of links I like or want to save for later.">
+
<meta property="og:type" content="website">
+
<meta property="og:url" content="https://mewsse.gay/">
+
<meta property="og:image" content="https://mewsse.gay/network.webp">
+
</head>
+
<body>
+
<header>
+
<h1>🏳️‍🌈 Mewsse's links</h1>
+
<p>
+
A collection of links I like or want to save for later.
+
<a href="/feed.atom" class="atom">
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
+
<path d="M0 64C0 46.3 14.3 32 32 32c229.8 0 416 186.2 416 416c0 17.7-14.3 32-32 32s-32-14.3-32-32C384 253.6 226.4 96 32 96C14.3 96 0 81.7 0 64zM0 416a64 64 0 1 1 128 0A64 64 0 1 1 0 416zM32 160c159.1 0 288 128.9 288 288c0 17.7-14.3 32-32 32s-32-14.3-32-32c0-123.7-100.3-224-224-224c-17.7 0-32-14.3-32-32s14.3-32 32-32z"/>
+
</svg>
+
</a>
+
<br>
+
<small>You can find more infos about me on my <a href="https://mewsse.pet">website</a></small>
+
</p>
+
<p class="small">Yeah it can be NSFW, but it's blured.</p>
+
</header>
+
+
<div class="links">
+
<% it.items.forEach(function (item) { %>
+
<%~ include("link", item) %>
+
<% }) %>
+
</div>
+
+
<% if(it.pages.length > 1) { %>
+
<div class="pagination">
+
<%~ include("pagination", { pages: it.pages, selected: it.selected }) %>
+
</div>
+
<% } %>
+
</body>
+
</html>
+49
src/views/pagination.eta
···
···
+
<ul>
+
<% if (it.selected > 0) { %>
+
<% if (it.dir) {%>
+
<li><a href="/<%=it.dir%>/<% if (it.selected > 2) { %>/page-<%= it.selected %>.html<% } %>">Previous</a></li>
+
<% } else { %>
+
<li><a href="/<% if (it.selected > 2) { %>pages/page-<%= it.selected %>.html<% } %>">Previous</a></li>
+
<% } %>
+
<% } %>
+
+
<% if(it.pages.length > 4 && it.selected > 2) { %>
+
<% if (it.dir) {%>
+
<li><a href="/<%=it.dir%>">1</a></li>
+
<% } else { %>
+
<li><a href="/">1</a></li>
+
<% } %>
+
<li>...</li>
+
<% } %>
+
+
<% Array.from(Array(5).keys()).forEach(function(page){%>
+
<% if (page + it.selected - 2 < it.pages.length && page + it.selected - 2 > -1) {%>
+
<% if (page + it.selected - 2 == it.selected) { %>
+
<li class="selected"><%= it.selected - 2 + page + 1%></li>
+
<% } else { %>
+
<% if (it.dir) {%>
+
<li><a href="/<%=it.dir%>/<% if (page > 1) { %>page-<%= it.selected + page + 1 - 2%>.html<% } %>"><%= it.selected + page + 1 - 2%></a></li>
+
<% } else { %>
+
<li><a href="/<% if (page > 1) { %>pages/page-<%= it.selected + page + 1 - 2%>.html<% } %>"><%= it.selected + page + 1 - 2%></a></li>
+
<% } %>
+
<% } %>
+
<% } %>
+
<% }) %>
+
+
<% if(it.pages.length > 4 && it.selected < it.pages.length-3) { %>
+
<li>...</li>
+
<% if (it.dir) {%>
+
<li><a href="/<%=it.dir%>/page-<%= it.pages.length %>.html"><%= it.pages.length %></a></li>
+
<% } else { %>
+
<li><a href="/pages/page-<%= it.pages.length %>.html"><%= it.pages.length %></a></li>
+
<% } %>
+
<% } %>
+
+
<% if (it.selected+1 < it.pages.length) { %>
+
<% if (it.dir) {%>
+
<li><a href="/<%=it.dir%>/page-<%= it.selected + 2%>.html">Next</a></li>
+
<% } else { %>
+
<li><a href="/pages/page-<%= it.selected + 2%>.html">Next</a></li>
+
<% } %>
+
<% } %>
+
</ul>