redirecter for ao3 that adds opengraph metadata

Merge branch 'panarchives'

Changed files
+113 -61
src
app
api
series
[seriesId]
works
[workId]
chapters
[chapterId]
generator
locked
series
[seriesId]
preview
works
[workId]
chapters
[chapterId]
preview
preview
lib
+5 -1
src/app/api/series/[seriesId]/route.js
···
import { getSeries } from "@fujocoded/ao3.js"
+
import { setArchiveBaseUrl, resetArchiveBaseUrl } from "@fujocoded/ao3.js/urls"
export const dynamic = 'force-static'
-
export async function GET(_req, ctx) {
+
export async function GET(req, ctx) {
const { seriesId } = await ctx.params
+
const { archive } = await req.nextUrl.searchParams
+
if (archive) setArchiveBaseUrl(archive)
const series = await getSeries({seriesId: seriesId})
+
if (archive) resetArchiveBaseUrl()
return Response.json(series)
}
+6 -1
src/app/api/works/[workId]/chapters/[chapterId]/route.js
···
import { getWork } from "@fujocoded/ao3.js"
+
import { setArchiveBaseUrl, resetArchiveBaseUrl } from "@fujocoded/ao3.js/urls"
export const dynamic = 'force-static'
-
export async function GET(_req, ctx) {
+
export async function GET(req, ctx) {
const { workId, chapterId } = await ctx.params
+
const params = await req.nextUrl.searchParams
+
const archive = params.get('archive')
+
if (archive) setArchiveBaseUrl(archive)
const work = await getWork({workId: workId, chapterId: chapterId})
+
if (archive) resetArchiveBaseUrl()
return Response.json(work)
}
+6 -3
src/app/api/works/[workId]/route.js
···
import { getWork } from "@fujocoded/ao3.js"
+
import { setArchiveBaseUrl, getArchiveBaseUrl, resetArchiveBaseUrl } from "@fujocoded/ao3.js/urls"
-
export const dynamic = 'force-static'
-
-
export async function GET(_req, ctx) {
+
export async function GET(req, ctx) {
const { workId } = await ctx.params
+
const params = await req.nextUrl.searchParams
+
const archive = params.get('archive')
+
if (archive) setArchiveBaseUrl(archive)
const work = await getWork({workId: workId})
+
if (archive) resetArchiveBaseUrl()
return Response.json(work)
}
+25 -3
src/app/generator/page.js
···
"use client"
import { useEffect, useState } from "react"
+
import { setArchiveBaseUrl, resetArchiveBaseUrl } from "@fujocoded/ao3.js/urls"
import themes from "@/lib/themes.js"
import baseFonts from "@/lib/baseFonts.js"
import titleFonts from "@/lib/titleFonts.js"
···
const [addr, setAddr] = useState('')
const [imgData, setImgData] = useState(null)
const [props, setProps] = useState(defaults)
+
const [domain, setDomain] = useState('')
const updateProp = (name, value) => {
const newProps = props
···
}
const updateData = async () => {
+
if (url === '') return
const workMatch = /\/works\/(?<workId>[0-9]+)(?:\/chapters\/(?<chapterId>[0-9]+))?$/
const seriesMatch = /\/series\/(?<seriesId>[0-9]+)$/
+
const baseurl = /(?<domain>https:\/\/[a-z0-9\-\.]+)\//
+
const domainMatch = url.match(baseurl)
+
if (!domainMatch) return
+
setDomain(domainMatch.groups.domain)
+
const domainParam = domain && !(["ao3.org", "archiveofourown.org", "archive.transformativeworks.org"].includes(domain)) ? `?archive=${domain}` : ''
if (workMatch.test(url)) {
const match = url.match(workMatch)
-
const resp = match.groups.chapterId ? await fetch(`/api/works/${match.groups.workId}/chapters/${match.groups.chapterId}`) : await fetch(`/api/works/${match.groups.workId}`)
+
const resp = match.groups.chapterId ? await fetch(`/api/works/${match.groups.workId}/chapters/${match.groups.chapterId}${domainParam}`) : await fetch(`/api/works/${match.groups.workId}${domainParam}`)
+
if (!resp) return
const data = await resp.json()
setAddr(match.groups.chapterId ? `works/${match.groups.workId}/chapters/${match.groups.chapterId}` : `works/${match.groups.workId}`)
setWorkData(data)
} else if (seriesMatch.test(url)) {
const match = url.match(seriesMatch)
-
const resp = await fetch(`/api/series/${match.groups.seriesId}`)
+
const resp = await fetch(`/api/series/${match.groups.seriesId}${domainParam}`)
+
if (!resp) return
const data = await resp.json()
setAddr(`series/${match.groups.seriesId}`)
setWorkData(data)
···
useEffect(() => {
const fn = async () => {
if (!addr) return;
+
if (workData.locked) {
+
const image = await fetch('/locked')
+
if (image.status !== 200) return;
+
const imageBlob = await image.blob()
+
const reader = new FileReader()
+
reader.onloadend = () => {
+
console.log(reader.result)
+
setImgData(reader.result)
+
}
+
return
+
}
const params = new URLSearchParams(props)
-
const image = await fetch(`/${addr}/preview?${params.toString()}`)
+
const image = await fetch(`/${addr}/preview?${params.toString()}&archive=${domain}`)
if (image.status !== 200) return;
const imageBlob = await image.blob()
const reader = new FileReader()
reader.onloadend = () => {
setImgData(reader.result)
+
console.log(imgData)
}
reader.readAsDataURL(imageBlob)
}
+13
src/app/locked/route.js
···
+
import OGImageLocked from "@/lib/ogimagelocked.js"
+
+
export const size = {
+
width: 1600,
+
height: 900,
+
}
+
+
export const contentType = 'image/png'
+
+
export async function GET(req, _ctx) {
+
const props = await req.nextUrl.searchParams
+
return OGImageLocked({theme: props.has('theme') ? props.get('theme') : process.env.DEFAULT_THEME})
+
}
+4 -1
src/app/series/[seriesId]/preview/route.js
···
import { getSeries } from "@fujocoded/ao3.js"
+
import { setArchiveBaseUrl, resetArchiveBaseUrl } from "@fujocoded/ao3.js/urls"
import querystring from 'node:querystring'
import sanitizeData from "@/lib/sanitizeData.js"
import OGImage from "@/lib/ogimage.js"
···
height: 900,
}
-
export const contentType = 'image/webp'
+
export const contentType = 'image/png'
export async function GET(req, ctx) {
const { seriesId } = await ctx.params
const p = await req.nextUrl.searchParams
const props = querystring.parse(p.toString())
const addr = `series/${seriesId}`
+
if (props.archive) setArchiveBaseUrl('https://'+props.archive)
const data = await getSeries({seriesId: seriesId})
+
if (props.archive) resetArchiveBaseUrl()
const imageParams = await sanitizeData({type: 'series', data: data, props: props})
const theme = imageParams.theme
const baseFont = baseFonts[imageParams.baseFont].displayName
+6 -6
src/app/works/[workId]/chapters/[chapterId]/preview/route.js
···
-
import { getWork } from "@fujocoded/ao3.js"
import querystring from 'node:querystring'
import sanitizeData from "@/lib/sanitizeData.js"
import OGImage from "@/lib/ogimage.js"
···
height: 900,
}
-
export const contentType = 'image/webp'
+
export const contentType = 'image/png'
export async function GET(req, ctx) {
const { workId, chapterId } = await ctx.params
-
const p = await req.nextUrl.searchParams
-
const props = querystring.parse(p.toString())
-
const addr = `works/${workId}/chapters/${chapterId}`
-
const data = await getWork({workId: workId, chapterId: chapterId})
+
const props = await req.nextUrl.searchParams
+
const work = await fetch(`http://${process.env.DOMAIN}/api/works/${workId}${domainParam}`)
+
const data = await data.json()
+
const addr = `${props.has('archive') ? props.get('archive').replace("https://", "")+"/" : 'archiveofourown.org/'}works/${workId}/chapters/${chapterId}`
+
const domainParam = props.has('archive') ? `?archive=${props.get('archive')}` : ''
const imageParams = await sanitizeData({type: 'work', data: data, props: props})
const theme = imageParams.theme
const baseFont = baseFonts[imageParams.baseFont].displayName
+6 -13
src/app/works/[workId]/preview/route.js
···
-
import { getWork } from "@fujocoded/ao3.js"
-
import { cache } from "react"
-
import querystring from 'node:querystring'
import sanitizeData from "@/lib/sanitizeData.js"
import OGImage from "@/lib/ogimage.js"
import baseFonts from "@/lib/baseFonts.js"
···
height: 900,
}
-
export const contentType = 'image/webp'
-
-
const getItem = cache(async (workId) => {
-
const work = await getWork({workId: workId})
-
return work
-
})
+
export const contentType = 'image/png'
export async function GET(req, ctx) {
const { workId } = await ctx.params
-
const p = await req.nextUrl.searchParams
-
const props = querystring.parse(p.toString())
-
const addr = `works/${workId}`
-
const data = await getItem(workId)
+
const props = await req.nextUrl.searchParams
+
const addr = `${props.has('archive') ? props.get('archive').replace("https://", "")+"/" : 'archiveofourown.org/'}works/${workId}`
+
const domainParam = props.has('archive') ? `?archive=${props.get('archive')}` : ''
+
const work = await fetch(`http://${process.env.DOMAIN}/api/works/${workId}${domainParam}`)
+
const data = await work.json()
const imageParams = await sanitizeData({type: 'work', data: data, props: props})
const theme = imageParams.theme
const baseFont = baseFonts[imageParams.baseFont].displayName
+19 -14
src/lib/ogimage.js
···
}}
>
<div
+
style={{
+
textTransform: "uppercase",
+
display: "flex",
+
justifyContent: "center",
+
color: theme.accent,
+
alignItems: "center",
+
display: "flex",
+
justifyContent: "center",
+
alignItems: "center",
+
textAlign: "center"
+
}}
+
>
+
{image.topLine}
+
</div>
+
<div
style={{
-
textTransform: "uppercase",
display: "flex",
justifyContent: "center",
-
gap: 10,
-
color: theme.accent,
-
alignItems: "center"
-
}}
+
alignItems: "center",
+
gap: 10
+
}}
>
-
<div
-
style={{
-
display: "flex"
-
}}
-
>
-
{image.topLine}
-
</div>
-
{image.props.rating && image.rating === 'E' && (<Explicit fg={theme.accentColor} bg={theme.accent} width={28} height={28} />)}
{image.props.rating && image.rating === 'M' && (<Mature fg={theme.accentColor} bg={theme.accent} width={28} height={28} />)}
{image.props.rating && image.rating === 'T' && (<Teen fg={theme.accentColor} bg={theme.accent} width={28} height={28} />)}
···
color: theme.accent2
}}
>
-
{image.props.wordcount && `${image.words} words • `}{(image.props.chapters && image.chapterCount !== null) && `${image.chapterCount} chapters • `}{image.props.postedAt && `posted on ${image.postedAt} • `}{image.props.updatedAt && `updated on ${image.updatedAt} • `}{process.env.ARCHIVE}/{addr}
+
{image.props.wordcount && `${image.words} words • `}{(image.props.chapters && image.chapterCount !== null) && `${image.chapterCount} chapters • `}{image.props.postedAt && `posted on ${image.postedAt} • `}{image.props.updatedAt && `updated on ${image.updatedAt} • `}{addr}
</div>
</div>
</div>
+7 -7
src/lib/ogimagelocked.js
···
import { ImageResponse } from "next/og"
-
import General from "@/icons/locked.js"
+
import themes from "@/lib/themes.js"
+
import Lock from "@/icons/lock.js"
export default async function OGImageLocked ({ theme }) {
+
const themeData = themes[theme]
return new ImageResponse(
(
<div
···
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
-
color: theme.color,
-
backgroundColor: theme.background,
-
fontFamily: baseFont,
+
color: themeData.color,
+
backgroundColor: themeData.background,
fontSize: 24,
padding: 20,
width: "100%",
height: "100%",
}}
>
-
<Locked bg={theme.background} fg={theme.color} width={480} height={480} />
+
<Lock bg={themeData.background} fg={themeData.color} width={480} height={480} />
</div>
-
),
-
opts
+
)
)
}
+16 -12
src/lib/sanitizeData.js
···
import DOM from "fauxdom"
import { readFile } from 'node:fs/promises'
+
import querystring from 'node:querystring'
import { join } from 'node:path'
import themes from '@/lib/themes.js'
import baseFonts from '@/lib/baseFonts.js'
import titleFonts from '@/lib/titleFonts.js'
-
const getWork = async (workId) => {
-
const x = await fetch('https://veryroundbird.house')
-
const data = await fetch(`http://${process.env.DOMAIN}/api/works/${workId}`)
+
const getWork = async (workId, archive = null) => {
+
const domainParam = archive ? `?archive=${archive}` : ''
+
const data = await fetch(`http://${process.env.DOMAIN}/api/works/${workId}${domainParam}`)
const work = await data.json()
return work
}
-
const getHighestRating = async (works) => {
+
const getHighestRating = async (works, archive = null) => {
+
console.log('get rating')
const ratings = await Promise.all(works.map(async (w) => {
-
const work = await getWork(w.id)
+
const work = await getWork(w.id, archive)
return work.rating
}))
if (ratings.includes("Not Rated")) {
···
return "G"
}
-
const getHighestWarning = async (works) => {
+
const getHighestWarning = async (works, archive = null) => {
+
console.log('get warning')
const warnings = await Promise.all(works.map(async (w) => {
const work = await getWork(w.id)
return work.tags.warnings
}))
-
console.log(warnings)
const warningsUnique = warnings.reduce((a, b) => { return a.concat(b) }).filter((w, i) => { return i === warnings.indexOf(w) })
if (warningsUnique.length === 1 && warningsUnique[0] === "Creator Chose Not To Use Archive Warnings") {
return "CNTW"
···
return "W"
}
-
const getCategory = async (works) => {
+
const getCategory = async (works, archive = null) => {
+
console.log('get category')
const categories = await Promise.all(works.map(async (w) => {
const work = await getWork(w.id)
return work.category
···
}
export default async function sanitizeData ({ type, data, props}) {
-
const propsParsed = sanitizeProps(props)
+
const propsParsed = sanitizeProps(querystring.parse(props.toString()))
+
const archive = props.has('archive') ? props.get('archive') : 'https://archiveofourown.org'
const baseFont = propsParsed.baseFont ? propsParsed.baseFont : process.env.DEFAULT_BASE_FONT
const baseFontData = baseFonts[baseFont]
const titleFont = propsParsed.titleFont ? propsParsed.titleFont : process.env.DEFAULT_TITLE_FONT
···
return a.username
})
: []
-
const rating = type === 'work' ? await getHighestRating([data]) : await getHighestRating(data.works)
-
const warning = type === 'work' ? await getHighestWarning([data]) : await getHighestWarning(data.works)
-
const category = type === 'work' ? await getCategory([data]) : await getCategory(data.works)
+
const rating = type === 'work' ? await getHighestRating([data], archive) : await getHighestRating(data.works, archive)
+
const warning = type === 'work' ? await getHighestWarning([data], archive) : await getHighestWarning(data.works, archive)
+
const category = type === 'work' ? await getCategory([data], archive) : await getCategory(data.works, archive)
const authorString = authorsFormatted.length > 1
? authorsFormatted.slice(0, -1).join(", ") + " & " +
authorsFormatted.slice(-1)[0]