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

Fix tabs + fix lexicon

fix

Mewsse 61fccc11 13d2e446

Changed files
+536 -504
lexicons
pet
mewsse
src
+61 -49
lexicons/pet/mewsse/link.json
···
{
-
"lexicon": 1,
-
"id": "pet.mewsse.link",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "Record containing a link.",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["title", "link", "createdAt"],
-
"properties": {
-
"link": {
-
"type": "string",
-
"maxLength": 3000,
-
"description": "The link to point to."
-
},
-
"title": {
-
"type": "string",
-
"maxLength": 3000,
-
"description": "Title of the given link."
-
},
-
"description": {
-
"type": "string",
-
"description": "Short description for the content of the link."
-
},
-
"tag": {
-
"type": "string",
-
"description": "A tag for classify the link."
-
},
-
"image": {
-
"type": "blob",
-
"description": "An image to illustrate the link",
-
"accept": ["image/*"],
-
"maxSize": 1000000
-
},
-
"alt": {
-
"type": "string",
-
"maxLength": 3000,
-
"description": "A alt text to describe the image."
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "Client-declared timestamp when this post was originally created."
-
}
-
}
-
}
-
}
-
}
+
"lexicon": 1,
+
"id": "pet.mewsse.link",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "Record containing a link.",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"title",
+
"link",
+
"createdAt"
+
],
+
"properties": {
+
"link": {
+
"type": "string",
+
"maxLength": 3000,
+
"description": "The link to point to."
+
},
+
"title": {
+
"type": "string",
+
"maxLength": 3000,
+
"description": "Title of the given link."
+
},
+
"description": {
+
"type": "string",
+
"description": "Short description for the content of the link."
+
},
+
"image": {
+
"type": "blob",
+
"description": "An image to illustrate the link",
+
"accept": [
+
"image/*"
+
],
+
"maxSize": 1000000
+
},
+
"alt": {
+
"type": "string",
+
"maxLength": 3000,
+
"description": "A alt text to describe the image."
+
},
+
"nsfw": {
+
"type": "boolean",
+
"default": false,
+
"description": "Is the link nsfw ?"
+
},
+
"big": {
+
"type": "boolean",
+
"default": false,
+
"description": "Do we need to display the link as large content ?"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Client-declared timestamp when this post was originally created."
+
}
+
}
+
}
+
}
+
}
}
+47 -45
src/db.ts
···
import type { Migration, MigrationProvider } from 'kysely'
export type DatabaseSchema = {
-
links: Link,
-
revs: Rev
+
links: Link,
+
revs: Rev
}
export type Link = {
-
rkey: string,
-
link: string,
-
title: string,
-
description: string | null,
-
tag: string | null,
-
image: string | null,
-
alt: string | null,
-
createdAt: string,
+
rkey: string,
+
link: string,
+
title: string,
+
description: string | null,
+
image: string | null,
+
alt: string | null,
+
nsfw: number,
+
big: number,
+
createdAt: string,
}
export type Rev = {
-
rkey: string,
-
createdAt: string,
+
rkey: string,
+
createdAt: string,
}
const migrations: Record<string, Migration> = {}
const migrationProvider: MigrationProvider = {
-
async getMigrations() {
-
return migrations
-
},
+
async getMigrations() {
+
return migrations
+
},
}
migrations['001'] = {
-
async up(db: Kysely<any>) {
-
await db.schema
-
.createTable('links')
-
.addColumn('rkey', 'varchar', (col) => col.primaryKey())
-
.addColumn('link', 'varchar', (col) => col.notNull())
-
.addColumn('title', 'varchar', (col) => col.notNull())
-
.addColumn('description', 'varchar')
-
.addColumn('tag', 'varchar')
-
.addColumn('image', 'varchar')
-
.addColumn('alt', 'varchar')
-
.addColumn('createdAt', 'varchar', (col) => col.notNull())
-
.execute()
+
async up(db: Kysely<any>) {
+
await db.schema
+
.createTable('links')
+
.addColumn('rkey', 'varchar', (col) => col.primaryKey())
+
.addColumn('link', 'varchar', (col) => col.notNull())
+
.addColumn('title', 'varchar', (col) => col.notNull())
+
.addColumn('description', 'varchar')
+
.addColumn('image', 'varchar')
+
.addColumn('alt', 'varchar')
+
.addColumn('nsfw', 'integer')
+
.addColumn('big', 'integer')
+
.addColumn('createdAt', 'varchar', (col) => col.notNull())
+
.execute()
-
await db.schema
-
.createTable('revs')
-
.addColumn('rkey', 'varchar', (col) => col.primaryKey())
-
.addColumn('createdAt', 'varchar', (col) => col.notNull())
-
.execute()
-
},
+
await db.schema
+
.createTable('revs')
+
.addColumn('rkey', 'varchar', (col) => col.primaryKey())
+
.addColumn('createdAt', 'varchar', (col) => col.notNull())
+
.execute()
+
},
-
async down(db: Kysely<any>) {
-
await db.schema.dropTable('links').execute()
-
await db.schema.dropTable('rev').execute()
-
},
+
async down(db: Kysely<any>) {
+
await db.schema.dropTable('links').execute()
+
await db.schema.dropTable('rev').execute()
+
},
}
export const createDb = (location: string): Database => {
-
return new Kysely<DatabaseSchema>({
-
dialect: new SqliteDialect({
-
database: new SqliteDb(location)
-
}),
-
})
+
return new Kysely<DatabaseSchema>({
+
dialect: new SqliteDialect({
+
database: new SqliteDb(location)
+
}),
+
})
}
export const migrateToLatest = async (db: Database) => {
-
const migrator = new Migrator({db, provider: migrationProvider})
-
const { error } = await migrator.migrateToLatest()
-
if(error) throw error
+
const migrator = new Migrator({ db, provider: migrationProvider })
+
const { error } = await migrator.migrateToLatest()
+
if (error) throw error
}
export type Database = Kysely<DatabaseSchema>
+45 -45
src/id-resolver.ts
···
import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver } from '@atcute/identity-resolver'
-
import { isDid} from '@atcute/lexicons/syntax'
+
import { isDid } from '@atcute/lexicons/syntax'
import process from 'node:process'
-
import type { DidDocument, Service } from '@atcute/identity'
+
import type { DidDocument } from '@atcute/identity'
import type { Did } from '@atcute/lexicons/syntax'
const docResolver = new CompositeDidDocumentResolver({
-
methods: {
-
plc: new PlcDidDocumentResolver(),
-
web: new WebDidDocumentResolver()
-
}
+
methods: {
+
plc: new PlcDidDocumentResolver(),
+
web: new WebDidDocumentResolver()
+
}
})
export class DIDError extends Error {
-
constructor(msg: string) {
-
super(msg)
+
constructor(msg: string) {
+
super(msg)
-
Object.setPrototypeOf(this, DIDError.prototype)
-
}
+
Object.setPrototypeOf(this, DIDError.prototype)
+
}
}
export class ServiceError extends Error {
-
constructor(msg: string) {
-
super(msg)
+
constructor(msg: string) {
+
super(msg)
-
Object.setPrototypeOf(this, ServiceError.prototype)
-
}
+
Object.setPrototypeOf(this, ServiceError.prototype)
+
}
}
-
export function getUserDID() : Did<"web"> | Did<"plc"> {
-
const did = process.env.DID
+
export function getUserDID(): Did<"web"> | Did<"plc"> {
+
const did = process.env.DID
-
if (!did || did == "") {
-
throw new DIDError("Missing DID to ingest")
-
}
+
if (!did || did == "") {
+
throw new DIDError("Missing DID to ingest")
+
}
-
if (!isDid(did)) {
-
throw new DIDError("DID is not in the correct format")
-
}
+
if (!isDid(did)) {
+
throw new DIDError("DID is not in the correct format")
+
}
-
return did as Did<"web"> | Did<"plc">
+
return did as Did<"web"> | Did<"plc">
}
-
export async function findUserDIDDoc() : Promise<DidDocument> {
+
export async function findUserDIDDoc(): Promise<DidDocument> {
-
try {
-
const did = getUserDID()
-
const doc = await docResolver.resolve(did)
-
return doc
-
} catch (err) {
-
throw err
-
}
+
try {
+
const did = getUserDID()
+
const doc = await docResolver.resolve(did)
+
return doc
+
} catch (err) {
+
throw err
+
}
}
export async function findUserPDS(): Promise<string> {
-
const didDoc = await findUserDIDDoc()
+
const didDoc = await findUserDIDDoc()
+
+
if (!didDoc.service) {
+
throw new ServiceError("No service found on user did doc")
+
}
-
if (!didDoc.service) {
-
throw new ServiceError("No service found on user did doc")
-
}
+
const pds = didDoc.service.filter(service => service.id == "#atproto_pds")
-
const pds = didDoc.service.filter(service => service.id == "#atproto_pds")
-
-
if (pds.length < 1) {
-
throw new ServiceError(`No valid service found for ${process.env.DID}`)
-
}
+
if (pds.length < 1) {
+
throw new ServiceError(`No valid service found for ${process.env.DID}`)
+
}
-
let serviceEndpoint = pds.shift()?.serviceEndpoint
+
let serviceEndpoint = pds.shift()?.serviceEndpoint
-
if (!serviceEndpoint || typeof serviceEndpoint != 'string' ) {
-
throw new ServiceError(`No valid service found for ${process.env.DID}`)
-
}
+
if (!serviceEndpoint || typeof serviceEndpoint != 'string') {
+
throw new ServiceError(`No valid service found for ${process.env.DID}`)
+
}
-
return serviceEndpoint
+
return serviceEndpoint
}
+49 -49
src/index.ts
···
dotenv.config()
export type Context = {
-
db: Database
-
jetstream: Jetstream,
-
httpServer: Router
+
db: Database
+
jetstream: Jetstream,
+
httpServer: Router
}
export class Server {
-
public ctx: Context
+
public ctx: Context
-
constructor(
-
ctx: Context
-
) {
-
this.ctx = ctx
-
}
+
constructor(
+
ctx: Context
+
) {
+
this.ctx = ctx
+
}
-
static async create() {
-
const port = process.env.PORT || "8080"
-
const dbPath = process.env.DB_PATH || ":memory"
+
static async create() {
+
const port = process.env.PORT || "8080"
+
const dbPath = process.env.DB_PATH || ":memory"
-
const db = createDb(dbPath)
-
await migrateToLatest(db)
-
logger.info('Migrating db to latest version')
+
const db = createDb(dbPath)
+
await migrateToLatest(db)
+
logger.info('Migrating db to latest version')
-
const ingester = createIngester(db)
-
await ingester.backfill()
-
const jetstream = await ingester.jetstream()
+
const ingester = createIngester(db)
+
await ingester.backfill()
+
const jetstream = await ingester.jetstream()
-
jetstream.start()
-
logger.info("Starting jetstream client")
+
jetstream.start()
+
logger.info("Starting jetstream client")
-
const httpServer = new Router()
-
createRoutes(httpServer)
+
const httpServer = new Router()
+
createRoutes(httpServer, db)
-
const ctx: Context = {
-
db,
-
jetstream,
-
httpServer
-
}
-
-
httpServer.start(port)
-
logger.info(`Starting http server on port ${port}`)
-
-
return new Server(ctx)
+
const ctx: Context = {
+
db,
+
jetstream,
+
httpServer
}
-
-
async close() {
-
logger.info("Stopping jetstream client")
-
await this.ctx.jetstream.close()
-
await this.ctx.httpServer.close()
+
+
httpServer.start(port)
+
logger.info(`Starting http server on port ${port}`)
+
+
return new Server(ctx)
+
}
+
+
async close() {
+
logger.info("Stopping jetstream client")
+
await this.ctx.jetstream.close()
+
await this.ctx.httpServer.close()
-
return new Promise<void>((resolve) => {
-
resolve()
-
})
-
}
+
return new Promise<void>((resolve) => {
+
resolve()
+
})
+
}
}
const run = async () => {
-
const server = await Server.create()
+
const server = await Server.create()
-
const onClose = async () => {
-
setTimeout(() => process.exit(1), 10000).unref()
-
// await server.close()
-
process.exit()
-
}
+
const onClose = async () => {
+
setTimeout(() => process.exit(1), 10000).unref()
+
// await server.close()
+
process.exit()
+
}
-
process.on('SIGINT', onClose)
-
process.on('SIGTERM', onClose)
+
process.on('SIGINT', onClose)
+
process.on('SIGTERM', onClose)
}
run()
+152 -148
src/ingester.ts
···
import { decode } from '@atcute/cbor'
import { logger } from "./lib/logger.ts"
+
import type { CommitCreate } from "@skyware/jetstream"
+
interface RepoParams {
-
did: Did<"web"> | Did<"plc">,
-
since?: string
+
did: Did<"web"> | Did<"plc">,
+
since?: string
}
export class IngestionError extends Error {
-
constructor(msg: string) {
-
super(msg)
+
constructor(msg: string) {
+
super(msg)
-
Object.setPrototypeOf(this, IngestionError.prototype)
-
}
+
Object.setPrototypeOf(this, IngestionError.prototype)
+
}
}
export function findImage(did: Did<"web"> | Did<"plc">, pds: string, record: any): string | null {
-
const imageCid = record.image ? record.image.ref.$link : null
-
if (!imageCid) return null
-
-
// let the user pull the blob with their browser directly fomr the pds
-
// decreasing space needed to run the service and prevent duplication
-
// if hosted at the same place as the pds (self host anyone ?)
-
return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${imageCid}`
+
const imageCid = record.image ? record.image.ref.$link : null
+
if (!imageCid) return null
+
+
// let the user pull the blob with their browser directly fomr the pds
+
// decreasing space needed to run the service and prevent duplication
+
// if hosted at the same place as the pds (self host anyone ?)
+
return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${imageCid}`
}
export function createIngester(db: Database) {
-
return {
-
async backfill() : Promise<any> {
-
const did = getUserDID()
-
const pds = await findUserPDS()
-
const handler = simpleFetchHandler({service: pds})
-
const rpc = new Client({ handler })
-
const now = new Date()
+
return {
+
async backfill(): Promise<any> {
+
const did = getUserDID()
+
const pds = await findUserPDS()
+
const handler = simpleFetchHandler({ service: pds })
+
const rpc = new Client({ handler })
+
const now = new Date()
-
logger.info(`Starting backfilling`)
+
logger.info(`Starting backfilling`)
-
const params: RepoParams = {
-
did
-
}
+
const params: RepoParams = {
+
did
+
}
-
const lastRev = await db
-
.selectFrom('revs')
-
.select('rkey')
-
.orderBy('createdAt', 'desc')
-
.executeTakeFirst()
+
const lastRev = await db
+
.selectFrom('revs')
+
.select('rkey')
+
.orderBy('createdAt', 'desc')
+
.executeTakeFirst()
-
if (lastRev) {
-
params.since = lastRev.rkey
-
}
+
if (lastRev) {
+
params.since = lastRev.rkey
+
}
-
const {ok, data} = await rpc.get(`com.atproto.sync.getRepo`, {
-
params,
-
as: 'stream'
-
})
+
const { ok, data } = await rpc.get(`com.atproto.sync.getRepo`, {
+
params,
+
as: 'stream'
+
})
-
if (!ok) {
-
throw new IngestionError(`Error while syncing repo for ${did} on ${pds}`)
-
}
+
if (!ok) {
+
throw new IngestionError(`Error while syncing repo for ${did} on ${pds}`)
+
}
-
await using repo = RepoReader.fromStream(data)
-
let links:Array<Link> = []
-
let repoRev = null;
+
await using repo = RepoReader.fromStream(data)
+
let links: Array<Link> = []
+
let repoRev = null;
-
for await (const entry of repo) {
-
if (!repoRev) repoRev = entry.rkey
-
if (entry.collection != "pet.mewsse.link") continue
-
const link : Link = decode(entry.bytes)
+
for await (const entry of repo) {
+
if (!repoRev) repoRev = entry.rkey
+
if (entry.collection != "pet.mewsse.link") continue
+
const link = decode(entry.bytes)
-
links.push({
-
rkey: entry.rkey,
-
link: link.link,
-
title: link.title,
-
description: link.description,
-
tag: link.tag ?? null,
-
image: findImage(did, pds, link),
-
alt: link.alt ?? null,
-
createdAt: link.createdAt
-
})
-
}
+
links.push({
+
rkey: entry.rkey,
+
link: link.link,
+
title: link.title,
+
description: link.description,
+
image: findImage(did, pds, link),
+
alt: link.alt ?? null,
+
nsfw: +(link.nsfw || 0),
+
big: +(link.big || 0),
+
createdAt: link.createdAt
+
})
+
}
-
links = links.sort((a, b) => a.createdAt > b.createdAt ? 1 : -1)
+
links = links.sort((a, b) => a.createdAt > b.createdAt ? 1 : -1)
-
for await (const link of links) {
-
await db
-
.insertInto('links')
-
.values(link)
-
.onConflict((conflict) =>
-
conflict.column('rkey').doUpdateSet({
-
link: link.link,
-
title: link.title,
-
description: link.description,
-
tag: link.tag,
-
image: link.image,
-
alt: link.alt,
-
})
-
)
-
.execute()
-
-
logger.info(`Inserting record ${link.rkey}`)
-
}
+
for await (const link of links) {
+
await db
+
.insertInto('links')
+
.values(link)
+
.onConflict((conflict) =>
+
conflict.column('rkey').doUpdateSet({
+
link: link.link,
+
title: link.title,
+
description: link.description,
+
image: link.image,
+
alt: link.alt,
+
nsfw: +(link.nsfw || 0),
+
big: +(link.big || 0),
+
})
+
)
+
.execute()
-
if (!repoRev || repoRev === null) {
-
logger.error('Backfilling error: no last pds revision found')
-
return;
-
}
+
logger.info(`Inserting record ${link.rkey}`)
+
}
-
if (repoRev != lastRev?.rkey) {
-
await db
-
.insertInto('revs')
-
.values({
-
rkey: repoRev,
-
createdAt: now.toISOString()
-
})
-
.execute()
-
}
+
if (!repoRev || repoRev === null) {
+
logger.error('Backfilling error: no last pds revision found')
+
return;
+
}
-
logger.info(`Backfilling ended`)
-
},
+
if (repoRev != lastRev?.rkey) {
+
await db
+
.insertInto('revs')
+
.values({
+
rkey: repoRev,
+
createdAt: now.toISOString()
+
})
+
.execute()
+
}
-
async jetstream() : Promise<Jetstream> {
-
const did = getUserDID()
-
const pds = await findUserPDS()
+
logger.info(`Backfilling ended`)
+
},
-
const jetstream = new Jetstream({
-
wantedCollections: ['pet.mewsse.link'],
-
wantedDids: [did]
-
})
+
async jetstream(): Promise<Jetstream> {
+
const did = getUserDID()
+
const pds = await findUserPDS()
-
jetstream.onCreate('pet.mewsse.link', async (event) => {
-
if (event.commit.record.$type != "pet.mewsse.link") return
+
const jetstream = new Jetstream({
+
wantedCollections: ['pet.mewsse.link'],
+
wantedDids: [did]
+
})
-
const rev = event.commit.rev
-
const record = event.commit.record
-
+
jetstream.onCreate('pet.mewsse.link', async (event) => {
+
if (event.commit.record.$type != "pet.mewsse.link") return
-
await db
-
.insertInto('links')
-
.values({
-
rkey: rev,
-
link: record.link,
-
title: record.title,
-
description: record.description ?? null,
-
tag: record.tag ?? null,
-
image: findImage(did, pds, record),
-
alt: record.alt ?? null,
-
createdAt: record.createdAt
-
})
-
.execute()
+
const rev = event.commit.rev
+
const record = event.commit.record
-
logger.info(`Record ${rev} created`)
-
})
-
jetstream.onUpdate('pet.mewsse.link', async (event) => {
-
if (event.commit.record.$type != "pet.mewsse.link") return
+
await db
+
.insertInto('links')
+
.values({
+
rkey: rev,
+
link: record.link,
+
title: record.title,
+
description: record.description ?? null,
+
image: findImage(did, pds, record),
+
alt: record.alt ?? null,
+
nsfw: +(record.nsfw || 0),
+
big: +(record.big || 0),
+
createdAt: record.createdAt
+
})
+
.execute()
-
const rev = event.commit.rev
-
const record = event.commit.record
+
logger.info(`Record ${rev} created`)
+
})
-
await db
-
.updateTable('links')
-
.set({
-
link: record.link,
-
title: record.title,
-
description: record.description ?? null,
-
tag: record.tag ?? null,
-
image: findImage(did, pds, record),
-
alt: record.alt ?? null,
-
createdAt: record.createdAt
-
})
-
.where('rkey', '=', rev)
-
.executeTakeFirstOrThrow()
+
jetstream.onUpdate('pet.mewsse.link', async (event) => {
+
if (event.commit.record.$type != "pet.mewsse.link") return
+
+
const rev = event.commit.rev
+
const record = event.commit.record
+
+
await db
+
.updateTable('links')
+
.set({
+
link: record.link,
+
title: record.title,
+
description: record.description ?? null,
+
image: findImage(did, pds, record),
+
alt: record.alt ?? null,
+
createdAt: record.createdAt
+
})
+
.where('rkey', '=', rev)
+
.executeTakeFirstOrThrow()
+
+
logger.info(`Record ${rev} updated`)
+
})
-
logger.info(`Record ${rev} updated`)
-
})
+
jetstream.onDelete('pet.mewsse.link', async (event) => {
+
if (event.commit.collection != "pet.mewsse.link") return
-
jetstream.onDelete('pet.mewsse.link', async (event) => {
-
if (event.commit.collection != "pet.mewsse.link") return
-
-
await db
-
.deleteFrom('links')
-
.where('rkey', '=', event.commit.rkey)
-
.executeTakeFirstOrThrow()
+
await db
+
.deleteFrom('links')
+
.where('rkey', '=', event.commit.rkey)
+
.executeTakeFirstOrThrow()
-
logger.info(`Record ${event.commit.rkey} deleted`)
-
})
+
logger.info(`Record ${event.commit.rkey} deleted`)
+
})
-
return jetstream
-
}
+
return jetstream
}
+
}
}
+1 -1
src/lexicons/index.ts
···
-
export * as PetMewsseLink from "./types/pet/mewsse/link.js"
+
export * as PetMewsseLink from "./types/pet/mewsse/link.js";
+15 -9
src/lexicons/types/pet/mewsse/link.ts
···
-
import type {} from "@atcute/lexicons"
-
import * as v from "@atcute/lexicons/validations"
-
import type {} from "@atcute/lexicons/ambient"
+
import type {} from "@atcute/lexicons";
+
import * as v from "@atcute/lexicons/validations";
+
import type {} from "@atcute/lexicons/ambient";
const _mainSchema = /*#__PURE__*/ v.record(
/*#__PURE__*/ v.tidString(),
···
/*#__PURE__*/ v.stringLength(0, 3000),
]),
),
+
/**
+
* Do we need to display the link as large content ?
+
* @default false
+
*/
+
big: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean(), false),
/**
* Client-declared timestamp when this post was originally created.
*/
···
/*#__PURE__*/ v.stringLength(0, 3000),
]),
/**
-
* A tag for classify the link.
+
* Is the link nsfw ?
+
* @default false
*/
-
tag: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
+
nsfw: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean(), false),
/**
* Title of the given link.
* @maxLength 3000
···
/*#__PURE__*/ v.stringLength(0, 3000),
]),
}),
-
)
+
);
-
type main$schematype = typeof _mainSchema
+
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
-
export const mainSchema = _mainSchema as mainSchema
+
export const mainSchema = _mainSchema as mainSchema;
export interface Main extends v.InferInput<typeof mainSchema> {}
declare module "@atcute/lexicons/ambient" {
interface Records {
-
"pet.mewsse.link": mainSchema
+
"pet.mewsse.link": mainSchema;
}
}
+111 -111
src/lib/router.ts
···
import fs from "node:fs"
interface Callback {
-
(req: IncomingMessage, res: ServerResponse, params?: {[key: string]: string}): void
+
(req: IncomingMessage, res: ServerResponse, params?: { [key: string]: string }): void
}
interface Route {
-
method: string,
-
path: RegExp,
-
callback: Callback
+
method: string,
+
path: RegExp,
+
callback: Callback
}
export class Router {
-
private routes:Array<Route>
-
private server:Server<typeof IncomingMessage, typeof ServerResponse> | null
+
private routes: Array<Route>
+
private server: Server<typeof IncomingMessage, typeof ServerResponse> | null
-
readonly ROUTE_MATCHER = /(?:{([a-z0-9-_]+):([a-z]+)})/ig
-
readonly ROUTE_SUBSTITUTION = "(?<$1>$2)"
-
readonly PARAM_TYPES: {[key:string]: string} = {
-
all: "[a-z0-9-_]+",
-
number: "[0-9]+",
-
string: "[a-z-_]+"
-
}
+
readonly ROUTE_MATCHER = /(?:{([a-z0-9-_]+):([a-z]+)})/ig
+
readonly ROUTE_SUBSTITUTION = "(?<$1>$2)"
+
readonly PARAM_TYPES: { [key: string]: string } = {
+
all: "[a-z0-9-_]+",
+
number: "[0-9]+",
+
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",
-
"."
-
]
+
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
-
}
+
constructor() {
+
this.routes = []
+
this.server = null
+
}
-
route(method:string, path:string, callback:Callback) {
-
let replacedPath = `^${path.replace(this.ROUTE_MATCHER, this.ROUTE_SUBSTITUTION)}$`
+
route(method: string, path: string, callback: Callback) {
+
let replacedPath = `^${path.replace(this.ROUTE_MATCHER, this.ROUTE_SUBSTITUTION)}$`
-
for (let type in this.PARAM_TYPES) {
-
if (replacedPath.includes(type)) {
-
replacedPath = replacedPath.replace(type, this.PARAM_TYPES[type])
-
}
-
}
-
-
this.routes.push({
-
method: method,
-
path: new RegExp(replacedPath, "i"),
-
callback
-
})
+
for (let type in this.PARAM_TYPES) {
+
if (replacedPath.includes(type)) {
+
replacedPath = replacedPath.replace(type, this.PARAM_TYPES[type])
+
}
}
-
get(path:string, callback:Callback) {
-
this.route('GET', path, callback)
-
}
+
this.routes.push({
+
method: method,
+
path: new RegExp(replacedPath, "i"),
+
callback
+
})
+
}
-
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}`)
-
}
+
get(path: string, callback: Callback) {
+
this.route('GET', path, callback)
+
}
-
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))
+
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}`)
+
}
-
let stat = null
+
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))
-
try {
-
stat = await fs.promises.stat(tryFile)
+
let stat = null
-
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
-
}
+
try {
+
stat = await fs.promises.stat(tryFile)
-
const ext = path.extname(tryFile)
-
const type = mime.getType(ext) || 'application/octet-stream'
+
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
+
}
-
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}`)
+
const ext = path.extname(tryFile)
+
const type = mime.getType(ext) || 'application/octet-stream'
-
if (req.method === "HEAD") {
-
res.end()
-
return
-
}
+
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}`)
-
const stream = fs.createReadStream(tryFile)
-
stream.pipe(res)
-
-
stream.on('error', (err) => {
-
logger.error(err)
-
stream.destroy()
-
})
+
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()
-
})
+
stream.on('end', () => {
+
res.end()
+
})
-
return
-
}
+
return
+
}
-
for (let route of this.routes) {
-
let match = req.url?.match(route.path)
+
for await (let route of this.routes) {
+
let match = req.url?.match(route.path)
-
if (route.method != req.method || !match) continue
+
if (route.method != req.method || !match) continue
-
logger.info(`${req.method} ${req.url}`)
-
route.callback(req, res, match.groups)
-
return
-
}
+
logger.info(`${req.method} ${req.url}`)
+
await 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}`)
-
}
+
res.end('HTTP/1.1 404 Not Found\r\n\r\n')
+
logger.error(`404: ${req.method} ${req.url}`)
}
+
}
-
start(port:string) {
-
this.server = createServer(this.handleHttpRequest())
+
start(port: string) {
+
this.server = createServer(this.handleHttpRequest())
-
this.server.on('clientError', (err, socket) => {
-
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
-
})
+
this.server.on('clientError', (err, socket) => {
+
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
+
})
-
this.server.listen(port)
-
}
+
this.server.listen(port)
+
}
-
async close() {
-
this.server?.close()
-
}
+
async close() {
+
this.server?.close()
+
}
}
+18 -10
src/routes.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")})
+
import type { Database } from './db.ts'
-
router.get('/', (req, res) => {
-
const body = eta.render("./main", { items: [], pages: []})
+
export const createRoutes = (router: Router, db: Database) => {
+
const eta = new Eta({ views: path.join(import.meta.dirname, "views") })
-
res.writeHead(200, {'Content-Type': 'text/html'})
-
res.end(body)
-
})
+
router.get('/', async (req, res) => {
+
const links = await db
+
.selectFrom("links")
+
.selectAll()
+
.orderBy("createdAt", "desc")
+
.limit(10)
+
.execute()
+
const body = eta.render("./main", { items: links, pages: [] })
-
router.get('/page/{page:number}', (req, res, params) => {
-
res.end()
-
})
+
res.writeHead(200, { 'Content-Type': 'text/html' })
+
res.end(body)
+
})
+
+
router.get('/page/{page:number}', async (req, res, params) => {
+
res.end()
+
})
}
+37 -37
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">
+
<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>
+
<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>
-
<div class="links">
-
<% it.items.forEach(function (item) { %>
-
<%~ include("link", item) %>
-
<% }) %>
+
<% if(it.pages.length > 1) { %>
+
<div class="pagination">
+
<%~ include("pagination", { pages: it.pages, selected: it.selected }) %>
</div>
-
-
<% if(it.pages.length > 1) { %>
-
<div class="pagination">
-
<%~ include("pagination", { pages: it.pages, selected: it.selected }) %>
-
</div>
-
<% } %>
-
</body>
+
<% } %>
+
</body>
</html>