Merge pull request #37 from TxtDot/error-pages

Error pages
This commit is contained in:
Artemy Egorov 2023-08-23 17:24:41 +03:00 committed by GitHub
commit 6fc2098741
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 373 additions and 205 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "txtdot", "name": "txtdot",
"version": "1.1.1", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "txtdot", "name": "txtdot",
"version": "1.1.1", "version": "1.2.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/static": "^6.10.2", "@fastify/static": "^6.10.2",

View File

@ -1,6 +1,6 @@
{ {
"name": "txtdot", "name": "txtdot",
"version": "1.1.1", "version": "1.2.0",
"private": true, "private": true,
"description": "", "description": "",
"main": "dist/app.js", "main": "dist/app.js",

34
src/errors/api.ts Normal file
View File

@ -0,0 +1,34 @@
export interface IApiError {
code: number;
name: string;
message: string;
}
export const errorSchema = {
type: "object",
properties: {
code: {
type: "number",
description: "HTTP error code",
},
name: {
type: "string",
description: "Exception class name",
},
message: {
type: "string",
description: "Exception message",
},
},
};
export const errorResponseSchema = {
type: "object",
properties: {
data: {
type: "object",
nullable: true,
},
error: errorSchema,
},
};

View File

@ -1,14 +1,66 @@
import { FastifyReply, FastifyRequest } from "fastify"; import { FastifyReply, FastifyRequest } from "fastify";
import { NotHtmlMimetypeError } from "./main"; import { NotHtmlMimetypeError, TxtDotError } from "./main";
import { getFastifyError } from "./validation";
export default function errorHandler( export default function errorHandler(
error: Error, error: Error,
_: FastifyRequest, req: FastifyRequest,
reply: FastifyReply reply: FastifyReply
) { ) {
if (req.originalUrl.startsWith("/api/")) {
return apiErrorHandler(error, reply);
}
return htmlErrorHandler(error, reply);
}
function apiErrorHandler(error: Error, reply: FastifyReply) {
function generateResponse(code: number) {
return reply.code(code).send({
data: null,
error: {
code: code,
name: error.name,
message: error.message,
},
});
}
if (error instanceof NotHtmlMimetypeError) {
return generateResponse(501);
}
if (getFastifyError(error)?.statusCode === 400) {
return generateResponse(400);
}
if (error instanceof TxtDotError) {
return generateResponse(error.code);
}
return generateResponse(500);
}
function htmlErrorHandler(error: Error, reply: FastifyReply) {
if (error instanceof NotHtmlMimetypeError) { if (error instanceof NotHtmlMimetypeError) {
return reply.redirect(301, error.url); return reply.redirect(301, error.url);
} else {
return error;
} }
if (getFastifyError(error)?.statusCode === 400) {
return reply.code(400).view("/templates/error.ejs", {
code: 400,
description: `Invalid parameter specified: ${error.message}`,
})
}
if (error instanceof TxtDotError) {
return reply.code(error.code).view("/templates/error.ejs", {
code: error.code,
description: error.description,
});
}
return reply.code(500).view("/templates/error.ejs", {
code: 500,
description: `${error.name}: ${error.message}`,
});
} }

View File

@ -1,10 +1,34 @@
export class EngineParseError extends Error {} export abstract class TxtDotError extends Error {
export class InvalidParameterError extends Error {} code: number;
export class LocalResourceError extends Error {} name: string;
export class NotHtmlMimetypeError extends Error { description: string;
url: string;
constructor(params: { url: string }) { constructor(code: number, name: string, description: string) {
super(); super(description);
this.url = params?.url; this.code = code;
this.name = name;
this.description = description;
}
}
export class EngineParseError extends TxtDotError {
constructor(message: string) {
super(422, "EngineParseError", `Parse error: ${message}`);
}
}
export class LocalResourceError extends TxtDotError {
constructor() {
super(403, "LocalResourceError", "Proxying local resources is forbidden.");
}
}
export class NotHtmlMimetypeError extends Error {
name: string = "NotHtmlMimetypeError";
url: string;
constructor(url: string) {
super();
this.url = url;
} }
} }

9
src/errors/validation.ts Normal file
View File

@ -0,0 +1,9 @@
export interface IFastifyValidationError {
statusCode?: number;
code?: string;
validation?: any;
}
export function getFastifyError(error: Error) {
return error as unknown as IFastifyValidationError;
}

View File

@ -6,16 +6,15 @@ import { DOMWindow } from "jsdom";
import readability from "./readability"; import readability from "./readability";
import google from "./google"; import google from "./google";
import stackoverflow from "./stackoverflow/main";
import { generateProxyUrl } from "../utils/generate"; import { generateProxyUrl } from "../utils/generate";
import isLocalResource from "../utils/islocal"; import isLocalResource from "../utils/islocal";
import { import {
InvalidParameterError,
LocalResourceError, LocalResourceError,
NotHtmlMimetypeError, NotHtmlMimetypeError,
} from "../errors/main"; } from "../errors/main";
import stackoverflow from "./stackoverflow/main";
export default async function handlePage( export default async function handlePage(
url: string, // remote URL url: string, // remote URL
@ -28,21 +27,21 @@ export default async function handlePage(
throw new LocalResourceError(); throw new LocalResourceError();
} }
if (engine && engineList.indexOf(engine) === -1) {
throw new InvalidParameterError("Invalid engine");
}
const response = await axios.get(url); const response = await axios.get(url);
const mime: string | undefined = response.headers["content-type"]?.toString(); const mime: string | undefined = response.headers["content-type"]?.toString();
if (mime && mime.indexOf("text/html") === -1) { if (mime && mime.indexOf("text/html") === -1) {
throw new NotHtmlMimetypeError({ url }); throw new NotHtmlMimetypeError(url);
} }
const window = new JSDOM(response.data, { url }).window; const window = new JSDOM(response.data, { url }).window;
[...window.document.getElementsByTagName("a")].forEach((link) => { [...window.document.getElementsByTagName("a")].forEach((link) => {
link.href = generateProxyUrl(requestUrl, link.href, engine); try {
link.href = generateProxyUrl(requestUrl, link.href, engine);
} catch (_err) {
// ignore TypeError: Invalid URL
}
}); });
if (engine) { if (engine) {

View File

@ -1,5 +1,5 @@
export default { export default {
version: "1.1.0", version: "1.1.1",
description: description:
"HTTP proxy that parses only text, links and pictures from pages reducing internet traffic, removing ads and heavy scripts", "HTTP proxy that parses only text, links and pictures from pages reducing internet traffic, removing ads and heavy scripts",
}; };

View File

@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { GetSchema, IGetSchema } from "../types/requests"; import { GetSchema, IGetSchema } from "../types/requests/browser";
import handlePage from "../handlers/main"; import handlePage from "../handlers/main";
import { generateRequestUrl } from "../utils/generate"; import { generateRequestUrl } from "../utils/generate";

View File

@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { engineList } from "../handlers/main"; import { engineList } from "../handlers/main";
import { indexSchema } from "../types/requests"; import { indexSchema } from "../types/requests/browser";
export default async function indexRoute(fastify: FastifyInstance) { export default async function indexRoute(fastify: FastifyInstance) {
fastify.get("/", { schema: indexSchema }, async (_, reply) => { fastify.get("/", { schema: indexSchema }, async (_, reply) => {

View File

@ -1,5 +1,7 @@
import { EngineRequest, IParseSchema, parseSchema } from "../types/requests";
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { EngineRequest, IParseSchema, parseSchema } from "../types/requests/api";
import handlePage from "../handlers/main"; import handlePage from "../handlers/main";
import { generateRequestUrl } from "../utils/generate"; import { generateRequestUrl } from "../utils/generate";
@ -8,15 +10,18 @@ export default async function parseRoute(fastify: FastifyInstance) {
"/api/parse", "/api/parse",
{ schema: parseSchema }, { schema: parseSchema },
async (request: EngineRequest) => { async (request: EngineRequest) => {
return await handlePage( return {
request.query.url, data: await handlePage(
generateRequestUrl( request.query.url,
request.protocol, generateRequestUrl(
request.hostname, request.protocol,
request.originalUrl request.hostname,
request.originalUrl
),
request.query.engine
), ),
request.query.engine error: null,
); };
} }
); );
} }

View File

@ -1,6 +1,8 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { GetRequest, IParseSchema, rawHtmlSchema } from "../types/requests"; import { IParseSchema, rawHtmlSchema } from "../types/requests/api";
import { GetRequest } from "../types/requests/browser";
import handlePage from "../handlers/main"; import handlePage from "../handlers/main";
import { generateRequestUrl } from "../utils/generate"; import { generateRequestUrl } from "../utils/generate";

View File

@ -1,99 +0,0 @@
import { FastifyRequest, FastifySchema } from "fastify";
import { handlerSchema } from "../handlers/handler.interface";
import { engineList } from "../handlers/main";
export type GetRequest = FastifyRequest<{
Querystring: {
url: string;
format?: string;
engine?: string;
};
}>;
export interface IGetQuery {
url: string;
format?: string;
engine?: string;
}
export interface IParseQuery {
url: string;
engine?: string;
}
export interface IGetSchema {
Querystring: IGetQuery;
}
export interface IParseSchema {
Querystring: IParseQuery;
}
export const indexSchema = {
produces: ["text/html"],
hide: true
};
export const getQuerySchema = {
type: "object",
required: ["url"],
properties: {
url: {
type: "string",
description: "URL",
},
format: {
type: "string",
enum: ["text", "html", ""],
default: "html",
},
engine: {
type: "string",
enum: [...engineList, ""],
},
},
};
export const parseQuerySchema = {
type: "object",
required: ["url"],
properties: {
url: {
type: "string",
description: "URL",
},
engine: {
type: "string",
enum: [...engineList, ""],
},
},
};
export const GetSchema: FastifySchema = {
description: "Get page",
hide: true,
querystring: getQuerySchema,
produces: ["text/html", "text/plain"],
};
export const parseSchema: FastifySchema = {
description: "Parse page",
querystring: parseQuerySchema,
response: {
"2xx": handlerSchema,
},
produces: ["text/json"],
};
export const rawHtmlSchema: FastifySchema = {
description: "Get raw HTML",
querystring: parseQuerySchema,
produces: ["text/html"],
};
export type EngineRequest = FastifyRequest<{
Querystring: {
url: string;
engine?: string;
};
}>;

66
src/types/requests/api.ts Normal file
View File

@ -0,0 +1,66 @@
import { FastifySchema, FastifyRequest } from "fastify";
import { IApiError, errorResponseSchema } from "../../errors/api";
import { handlerSchema } from "../../handlers/handler.interface";
import { engineList } from "../../handlers/main";
export interface IApiResponse<T> {
data?: T;
error?: IApiError;
}
export interface IParseQuery {
url: string;
engine?: string;
}
export interface IParseSchema {
Querystring: IParseQuery;
}
export const parseQuerySchema = {
type: "object",
required: ["url"],
properties: {
url: {
type: "string",
description: "URL",
},
engine: {
type: "string",
enum: [...engineList, ""],
},
},
};
export const parseSchema: FastifySchema = {
description: "Parse the page and get all data from the engine",
querystring: parseQuerySchema,
response: {
"2xx": {
type: "object",
properties: {
data: handlerSchema,
error: {
type: "object",
nullable: true,
},
},
},
"4xx": errorResponseSchema,
"5xx": errorResponseSchema,
},
produces: ["text/json"],
};
export const rawHtmlSchema: FastifySchema = {
description: "Parse the page and get raw HTML from the engine",
querystring: parseQuerySchema,
produces: ["text/html"],
};
export type EngineRequest = FastifyRequest<{
Querystring: {
url: string;
engine?: string;
};
}>;

View File

@ -0,0 +1,48 @@
import { FastifyRequest, FastifySchema } from "fastify";
import { engineList } from "../../handlers/main";
export type GetRequest = FastifyRequest<{
Querystring: IGetQuery;
}>;
export interface IGetQuery {
url: string;
format?: string;
engine?: string;
}
export interface IGetSchema {
Querystring: IGetQuery;
}
export const indexSchema = {
produces: ["text/html"],
hide: true
};
export const getQuerySchema = {
type: "object",
required: ["url"],
properties: {
url: {
type: "string",
description: "URL",
},
format: {
type: "string",
enum: ["text", "html", ""],
default: "html",
},
engine: {
type: "string",
enum: [...engineList, ""],
},
},
};
export const GetSchema: FastifySchema = {
description: "Get page",
hide: true,
querystring: getQuerySchema,
produces: ["text/html", "text/plain"],
};

View File

@ -9,8 +9,10 @@
--bg2: #bbb; --bg2: #bbb;
--fg2: #333; --fg2: #333;
--accent: hsl(207, 100%, 40%); --accent: #0070cc; /* hsl(207, 100%, 40%) */
--accent-hl: hsl(207, 100%, 20%); --accent-hl: #003866; /* hsl(207, 100%, 20%) */
--error: #ff9400;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -21,8 +23,8 @@
--bg2: #444; --bg2: #444;
--fg2: #bbb; --fg2: #bbb;
--accent: hsl(207, 100%, 60%); --accent: #33a3ff; /* hsl(207, 100%, 60%) */
--accent-hl: hsl(207, 100%, 80%); --accent-hl: #99d1ff; /* hsl(207, 100%, 80%) */
} }
} }

60
static/form.css Normal file
View File

@ -0,0 +1,60 @@
.input-grid {
display: grid;
/* 2 columns: auto width, min-content width */
grid-template-columns: auto min-content;
/* gap: row column */
gap: 0.5rem 0.25rem;
width: fit-content;
}
.input-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.input {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
}
label {
font-size: 0.9rem;
}
#url {
width: 100%;
height: 100%; /* shrink to #submit height */
outline: none;
border: 0;
border-bottom: 0.125rem solid var(--fg2);
background: var(--bg);
color: var(--fg);
font-size: 1rem;
}
#url::placeholder {
color: var(--fg2);
opacity: 1;
}
#submit {
font-size: 1rem;
}
select {
border: 0;
border-bottom: 0.125rem solid var(--accent);
background: var(--bg);
color: var(--fg);
font-weight: 500;
font-size: 0.9rem;
}

View File

@ -5,9 +5,10 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.title { .title {
border-bottom: 0.125rem solid var(--bg2);
font-weight: 600; font-weight: 600;
margin: 1rem;
} }
a { a {

View File

@ -2,73 +2,17 @@ main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center;
} }
h1 { h1 {
width: fit-content; width: fit-content;
margin: auto; margin: auto;
} }
h1 > span {
h1 > .dot {
color: var(--accent); color: var(--accent);
} }
h1 > .dot-err {
.input-grid { color: var(--error);
display: grid;
/* 2 columns: auto width, min-content width */
grid-template-columns: auto min-content;
/* gap: row column */
gap: 0.5rem 0.25rem;
width: fit-content;
}
.input-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.input {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
}
label {
font-size: 0.9rem;
}
#url {
width: 100%;
height: 100%; /* shrink to #submit height */
outline: none;
border: 0;
border-bottom: 0.125rem solid var(--fg2);
background: var(--bg);
color: var(--fg);
font-size: 1rem;
}
#url::placeholder {
color: var(--fg2);
opacity: 1;
}
#submit {
font-size: 1rem;
}
select {
border: 0;
border-bottom: 0.125rem solid var(--accent);
background: var(--bg);
color: var(--fg);
font-weight: 600;
font-size: 0.9rem;
} }

21
templates/error.ejs Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="robots" content="noindex, nofollow">
<title>txt. <%= code %></title>
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/index.css">
</head>
<body>
<main>
<header>
<h1>txt<span class="dot-err">.</span></h1>
<p><%= description %></p>
</header>
<a href="/" class="button secondary">Home</a>
</main>
</body>
</html>

View File

@ -15,10 +15,9 @@
<a class="button secondary" href="/">Home</a> <a class="button secondary" href="/">Home</a>
<a class="button secondary" href="<%= remoteUrl %>">Original page</a> <a class="button secondary" href="<%= remoteUrl %>">Original page</a>
</div> </div>
<div class="title"> <p class="title">
<%= parsed.title %> <%= parsed.title %>
</div> </p>
<hr>
<%- parsed.content %> <%- parsed.content %>
</main> </main>
</body> </body>

View File

@ -9,11 +9,12 @@
<title>txt. main page</title> <title>txt. main page</title>
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/index.css"> <link rel="stylesheet" href="/static/index.css">
<link rel="stylesheet" href="/static/form.css">
</head> </head>
<body> <body>
<main> <main>
<header> <header>
<h1>txt<span>.</span></h1> <h1>txt<span class="dot">.</span></h1>
<p><%= desc %></p> <p><%= desc %></p>
</header> </header>
<form action="/get" method="get" class="input-grid"> <form action="/get" method="get" class="input-grid">