This commit is contained in:
Niccolo Borgioli 2023-08-26 02:24:59 +02:00
parent f77ddf3c35
commit 5d7cbebceb
No known key found for this signature in database
GPG Key ID: D93C615F75EE4F0B
22 changed files with 1714 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
*
!src
!templates
!package.json
!pnpm-lock.yaml
!tsconfig.json

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env*
dist
node_modules

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v20.5.1

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"files.associations": {
"*.eta": "html"
}
}

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM node:20-slim AS base
ENV CI=true
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install
COPY . /app
RUN pnpm run build
FROM base
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
COPY --from=build /app/templates /app/templates
EXPOSE 8000
CMD [ "pnpm", "start" ]

8
docker-compose.yaml Normal file
View File

@ -0,0 +1,8 @@
version: '3.9'
services:
app:
build: .
env_file: .env
ports:
- 80:8000

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "blaze-this-page",
"version": "1.1.0",
"description": "The service to navigate the web also with bad connectivity",
"type": "module",
"main": "./dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsc -w",
"start": "node ."
},
"dependencies": {
"@fastify/compress": "^6.4.0",
"@fastify/env": "^4.2.0",
"@fastify/etag": "^4.2.0",
"@fastify/static": "^6.10.2",
"@fastify/view": "^8.0.0",
"eta": "^3.1.1",
"fastify": "^4.21.0",
"fastify-minify": "^1.2.0",
"node-html-parser": "^6.1.6",
"zod": "^3.22.2"
},
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@types/node": "^20.5.6",
"pino-pretty": "^10.2.0",
"typescript": "^5.2.2"
},
"packageManager": "pnpm@8.6.12"
}

1113
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

21
src/error.ts Normal file
View File

@ -0,0 +1,21 @@
import type { FastifyInstance } from 'fastify'
export class BlazeError extends Error {
constructor(public code: number, message: string) {
super(message)
}
}
export function init(app: FastifyInstance) {
app.setErrorHandler((err, req, reply) => {
let code = 500
let message = 'Internal server error'
if (err instanceof BlazeError) {
code = err.code
message = err.message
} else {
req.log.error(err)
}
return reply.code(code).view('error', { title: 'Error', code, message })
})
}

30
src/html.ts Normal file
View File

@ -0,0 +1,30 @@
import { parse } from 'node-html-parser'
export function replaceLinks(raw: string): string {
const html = parse(raw)
html.querySelectorAll('a').forEach((el) => {
if (el.hasAttribute('data-preserve-link')) return
const href = el.getAttribute('href')
if (href) {
el.setAttribute('href', `/browse?${new URLSearchParams({ url: href }).toString()}`)
}
})
return html.toString()
}
export function cleanContent(raw: string, base: string): string {
const html = parse(raw)
const tagsToRemove = ['script', 'style', 'img', 'picture', 'video', 'iframe', 'svg']
for (const tag of tagsToRemove) {
html.querySelectorAll(tag).forEach((el) => el.remove())
}
html.querySelectorAll('a').forEach((el) => {
const href = el.getAttribute('href')
if (href) {
el.setAttribute('href', new URL(href, base).toString())
}
})
return html.querySelector('body')?.innerHTML ?? ''
}

74
src/index.ts Normal file
View File

@ -0,0 +1,74 @@
import { Eta } from 'eta'
import Fastify from 'fastify'
import path from 'node:path'
import { replaceLinks } from './html.js'
import { init as initRoutes } from './routes.js'
import { init as initError } from './error.js'
const app = Fastify({
logger:
process.env.NODE_ENV === 'production'
? true
: {
level: 'debug',
transport: {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname,remoteAddress',
},
},
},
})
declare module 'fastify' {
interface FastifyInstance {
config: {
BRAVE_KEY: string
}
}
}
await app
.register(import('@fastify/compress'))
.register(import('@fastify/etag'))
.register(import('fastify-minify'), { global: true })
.register(import('@fastify/view'), {
engine: {
eta: new Eta({ tags: ['{{', '}}'] }),
},
layout: 'layout',
templates: path.resolve('templates'),
})
.register(import('@fastify/env'), {
dotenv: true,
schema: {
type: 'object',
required: ['BRAVE_KEY'],
properties: {
BRAVE_KEY: {
type: 'string',
},
},
} as any,
})
// Redirect to blaze
app.addHook('onSend', async (req, reply, payload) => {
if (reply.getHeader('content-type')?.toString().startsWith('text/html')) {
return replaceLinks(payload as string)
}
return payload
})
initError(app)
initRoutes(app)
// START
try {
await app.listen({ port: 8000, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

38
src/routes.ts Normal file
View File

@ -0,0 +1,38 @@
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { BlazeError } from './error.js'
import { cleanContent } from './html.js'
import { Brave, SearchEngine } from './search/index.js'
export function init(app: FastifyInstance) {
const engine: SearchEngine = new Brave(app.config.BRAVE_KEY)
const SearchSchema = z.strictObject({ q: z.string().min(1) })
app.get('/search', async (req, reply) => {
const query = SearchSchema.safeParse(req.query)
if (query.success) {
const { q } = query.data
const results = await engine.search(q)
return reply.view('results', { results, title: `Blaze - ${q}`, needle: q })
} else {
throw new BlazeError(401, query.error.message)
}
})
const BrowseSchema = z.strictObject({ url: z.string().url() })
app.get('/browse', async (req, reply) => {
const query = BrowseSchema.safeParse(req.query)
if (query.success) {
const { url } = query.data
const raw = await fetch(url).then((r) => r.text())
return reply.view('browse', { title: 'Blaze', content: cleanContent(raw, url), needle: '' })
} else {
throw new BlazeError(401, query.error.message)
}
})
app.get('/', (req, reply) => {
return reply.view('home', { title: 'Blaze', needle: '' })
})
}

92
src/search/brave.ts Normal file
View File

@ -0,0 +1,92 @@
import { SearchEngine } from './index.js'
// https://transform.tools/json-to-typescript
export interface Root {
query: Query
mixed: Mixed
type: string
web: Web
}
export interface Query {
original: string
show_strict_warning: boolean
is_navigational: boolean
is_news_breaking: boolean
spellcheck_off: boolean
country: string
bad_results: boolean
should_fallback: boolean
postal_code: string
city: string
header_country: string
more_results_available: boolean
state: string
}
export interface Mixed {
type: string
main: Main[]
top: any[]
side: any[]
}
export interface Main {
type: string
index: number
all: boolean
}
export interface Web {
type: string
results: Result[]
family_friendly: boolean
}
export interface Result {
title: string
url: string
is_source_local: boolean
is_source_both: boolean
description: string
language: string
family_friendly: boolean
type: string
subtype: string
meta_url: MetaUrl
age?: string
}
export interface MetaUrl {
scheme: string
netloc: string
hostname: string
favicon: string
path: string
}
export class Brave implements SearchEngine {
private static URL = 'https://api.search.brave.com/res/v1/web/search'
constructor(private key: string) {}
async search(query: string) {
const url = `${Brave.URL}?${new URLSearchParams({
q: query,
safesearch: 'moderate',
result_filter: 'web',
}).toString()}`
const data: Root = await fetch(url, {
headers: { 'X-Subscription-Token': this.key },
}).then((response) => response.json())
return data.web.results.map((result) => {
return {
title: result.title,
description: result.description,
url: result.url,
}
})
}
}

11
src/search/index.ts Normal file
View File

@ -0,0 +1,11 @@
export type Result = {
title: string
url: string
description: string
}
export abstract class SearchEngine {
abstract search(query: string): Promise<Result[]>
}
export * from './brave.js'

12
src/search/mock.ts Normal file
View File

@ -0,0 +1,12 @@
import { Result, SearchEngine } from './index.js'
import { faker } from '@faker-js/faker'
export class Mock implements SearchEngine {
async search(query: string): Promise<Result[]> {
return [...new Array(20)].map((_) => ({
title: faker.internet.displayName(),
url: faker.internet.url(),
description: faker.lorem.sentences(),
}))
}
}

2
templates/browse.eta Normal file
View File

@ -0,0 +1,2 @@
{{~ include("./search.eta", it) }}
<section>{{~ it.content }}</section>

2
templates/error.eta Normal file
View File

@ -0,0 +1,2 @@
<h1>Error: {{= it.code }}</h1>
<p>{{= it.message }}</p>

3
templates/home.eta Normal file
View File

@ -0,0 +1,3 @@
<div style="margin: 10rem auto">
{{~ include("./search", it) }}
</div>

136
templates/layout.eta Normal file
View File

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{= it.title }}</title>
<style>
/*! modern-normalize v2.0.0 | MIT License | https://github.com/sindresorhus/modern-normalize */
*,
::after,
::before {
box-sizing: border-box;
}
html {
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
line-height: 1.15;
-webkit-text-size-adjust: 100%;
-moz-tab-size: 4;
tab-size: 4;
}
body {
margin: 0;
}
hr {
height: 0;
color: inherit;
}
abbr[title] {
text-decoration: underline dotted;
}
b,
strong {
font-weight: bolder;
}
code,
kbd,
pre,
samp {
font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 1em;
}
small {
font-size: 80%;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
select {
text-transform: none;
}
[type='button'],
[type='reset'],
[type='submit'],
button {
-webkit-appearance: button;
}
::-moz-focus-inner {
border-style: none;
padding: 0;
}
:-moz-focusring {
outline: 1px dotted ButtonText;
}
:-moz-ui-invalid {
box-shadow: none;
}
legend {
padding: 0;
}
progress {
vertical-align: baseline;
}
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
[type='search'] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
summary {
display: list-item;
}
</style>
<style>
:root {
--blaze-primary: #ff9400;
--blaze-bg: #fff8ee;
--blaze-text: #120c02;
}
main {
color: var(--blaze-text);
max-width: 50rem;
width: 100%;
padding: 1rem;
margin: 0 auto;
}
</style>
</head>
<body>
<main>{{~ it.body }}</main>
</body>
</html>

42
templates/results.eta Normal file
View File

@ -0,0 +1,42 @@
{{~ include("./search.eta", it) }}
<ol class="blaze_results">
{{ it.results.forEach(function(result){ }}
<li>
<a href="{{= result.url }}">
<article>
<pre>{{= result.url }}</pre>
<h2>{{= result.title }}</h2>
<p>{{~ result.description }}</p>
</article>
</a>
</li>
{{ }) }}
</ol>
<style>
.blaze_results li {
margin-bottom: 2rem;
}
.blaze_results a {
color: inherit;
text-decoration: inherit;
}
.blaze_results pre {
margin: 0;
overflow: auto;
font-size: 0.75em;
}
.blaze_results p {
margin: 0;
}
.blaze_results h2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
ol.blaze_results {
list-style: none;
padding: 0;
}
</style>

48
templates/search.eta Normal file
View File

@ -0,0 +1,48 @@
<form class="blaze_form" action="/search" method="get">
<a style="font-size: 2rem; font-weight: bold" href="/" data-preserve-link>Bla⚡e</a>
<input type="search" name="q" placeholder="search something..." value="{{= it.needle }}" />
<button type="submit">Search 🔎</button>
</form>
<style>
form.blaze_form {
display: flex;
gap: 0.5rem;
align-items: flex-end;
padding: 0.5rem;
border: 2px dashed black;
}
.blaze_form a {
text-decoration: inherit;
color: inherit;
margin-right: 1rem;
}
.blaze_form input,
.blaze_form button {
border-radius: 0;
background-color: var(--blaze-bg);
color: var(--blaze-text);
border: 2px solid var(--blaze-primary);
}
.blaze_form input {
padding: 0.25rem 0.25rem;
min-width: 10rem;
flex: 1;
}
.blaze_form button {
padding: 0.25rem 0.5rem;
cursor: pointer;
}
@media screen and (max-width: 30rem) {
form.blaze_form {
flex-direction: column;
align-items: center;
}
.blaze_form input,
.blaze_form button {
width: 100%;
}
}
</style>

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"outDir": "./dist",
"removeComments": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}