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",
"version": "1.1.1",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "txtdot",
"version": "1.1.1",
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"@fastify/static": "^6.10.2",

View File

@ -1,6 +1,6 @@
{
"name": "txtdot",
"version": "1.1.1",
"version": "1.2.0",
"private": true,
"description": "",
"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 { NotHtmlMimetypeError } from "./main";
import { NotHtmlMimetypeError, TxtDotError } from "./main";
import { getFastifyError } from "./validation";
export default function errorHandler(
error: Error,
_: FastifyRequest,
req: FastifyRequest,
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) {
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 class InvalidParameterError extends Error {}
export class LocalResourceError extends Error {}
export class NotHtmlMimetypeError extends Error {
url: string;
constructor(params: { url: string }) {
super();
this.url = params?.url;
export abstract class TxtDotError extends Error {
code: number;
name: string;
description: string;
constructor(code: number, name: string, description: string) {
super(description);
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 google from "./google";
import stackoverflow from "./stackoverflow/main";
import { generateProxyUrl } from "../utils/generate";
import isLocalResource from "../utils/islocal";
import {
InvalidParameterError,
LocalResourceError,
NotHtmlMimetypeError,
} from "../errors/main";
import stackoverflow from "./stackoverflow/main";
export default async function handlePage(
url: string, // remote URL
@ -28,21 +27,21 @@ export default async function handlePage(
throw new LocalResourceError();
}
if (engine && engineList.indexOf(engine) === -1) {
throw new InvalidParameterError("Invalid engine");
}
const response = await axios.get(url);
const mime: string | undefined = response.headers["content-type"]?.toString();
if (mime && mime.indexOf("text/html") === -1) {
throw new NotHtmlMimetypeError({ url });
throw new NotHtmlMimetypeError(url);
}
const window = new JSDOM(response.data, { url }).window;
[...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) {

View File

@ -1,5 +1,5 @@
export default {
version: "1.1.0",
version: "1.1.1",
description:
"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 { GetSchema, IGetSchema } from "../types/requests";
import { GetSchema, IGetSchema } from "../types/requests/browser";
import handlePage from "../handlers/main";
import { generateRequestUrl } from "../utils/generate";

View File

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

View File

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

View File

@ -1,6 +1,8 @@
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 { 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;
--fg2: #333;
--accent: hsl(207, 100%, 40%);
--accent-hl: hsl(207, 100%, 20%);
--accent: #0070cc; /* hsl(207, 100%, 40%) */
--accent-hl: #003866; /* hsl(207, 100%, 20%) */
--error: #ff9400;
}
@media (prefers-color-scheme: dark) {
@ -21,8 +23,8 @@
--bg2: #444;
--fg2: #bbb;
--accent: hsl(207, 100%, 60%);
--accent-hl: hsl(207, 100%, 80%);
--accent: #33a3ff; /* hsl(207, 100%, 60%) */
--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;
}
.title {
border-bottom: 0.125rem solid var(--bg2);
font-weight: 600;
margin: 1rem;
}
a {

View File

@ -2,73 +2,17 @@ main {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
h1 {
width: fit-content;
margin: auto;
}
h1 > span {
h1 > .dot {
color: var(--accent);
}
.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: 600;
font-size: 0.9rem;
h1 > .dot-err {
color: var(--error);
}

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="<%= remoteUrl %>">Original page</a>
</div>
<div class="title">
<p class="title">
<%= parsed.title %>
</div>
<hr>
</p>
<%- parsed.content %>
</main>
</body>

View File

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