Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/core/http.ts
3557 views
1
/*
2
* http.ts
3
*
4
* Copyright (C) 2020-2023 Posit Software, PBC
5
*/
6
7
import { existsSync } from "../deno_ral/fs.ts";
8
import { basename, extname, join, normalize, posix } from "../deno_ral/path.ts";
9
import { error, info } from "../deno_ral/log.ts";
10
11
import * as colors from "fmt/colors";
12
13
import {
14
contentType,
15
isHtmlContent,
16
isPdfContent,
17
isTextContent,
18
kTextHtml,
19
} from "./mime.ts";
20
import { logError } from "./log.ts";
21
import { pathWithForwardSlashes } from "./path.ts";
22
import { FileResponse, HttpFileRequestOptions } from "./http-types.ts";
23
24
export function isAbsoluteRef(href: string) {
25
return /^(?:http|https)\:\/\/.+/.test(href);
26
}
27
28
export function isFileRef(href: string) {
29
return !/^\w+:/.test(href) && !href.startsWith("#");
30
}
31
32
export function httpFileRequestHandler(
33
options: HttpFileRequestOptions,
34
) {
35
async function serveFile(
36
filePath: string,
37
req: Request,
38
): Promise<Response> {
39
// read file (allow custom handler first shot at html files)
40
let fileResponse: FileResponse | undefined;
41
if (options.onFile) {
42
fileResponse = await options.onFile(filePath, req);
43
}
44
if (!fileResponse) {
45
fileResponse = {
46
contentType: contentType(filePath),
47
body: Deno.readFileSync(filePath),
48
};
49
}
50
51
return httpContentResponse(fileResponse.body, fileResponse.contentType);
52
}
53
54
function serveFallback(
55
req: Request,
56
e: Error,
57
fsPath?: string,
58
): Promise<Response> {
59
const encoder = new TextEncoder();
60
if (e instanceof URIError) {
61
return Promise.resolve(
62
new Response(encoder.encode("BadRequest"), { status: 400 }),
63
);
64
} else if (e instanceof Deno.errors.NotFound) {
65
const url = normalizeURL(req.url);
66
const handle404 = options.on404
67
? options.on404(url, req)
68
: { print: true, response: { body: encoder.encode("Not Found") } };
69
70
// Ignore 404s from specific files
71
const ignoreFileNames = [
72
"favicon.ico",
73
"listings.json",
74
/jupyter-.*.js/,
75
/apple-touch-icon-/,
76
];
77
78
handle404.print = handle404.print &&
79
!!options.printUrls &&
80
(!fsPath || (
81
!ignoreFileNames.find((name) => {
82
return basename(fsPath).match(name);
83
}) &&
84
extname(fsPath) !== ".map"
85
));
86
if (handle404.print) {
87
printUrl(url, false);
88
}
89
return Promise.resolve(
90
new Response(handle404.response.body, {
91
status: 404,
92
headers: {
93
"Content-Type": kTextHtml,
94
},
95
}),
96
);
97
} else {
98
error(`500 (Internal Error): ${(e as Error).message}`, { bold: true });
99
return Promise.resolve(
100
new Response(encoder.encode("Internal server error"), {
101
status: 500,
102
}),
103
);
104
}
105
}
106
107
return async (req: Request): Promise<Response> => {
108
// custom request handler
109
if (options.onRequest) {
110
const response = await options.onRequest(req);
111
if (response) {
112
return response;
113
}
114
}
115
116
// handle file requests
117
let response: Response | undefined;
118
let fsPath: string | undefined;
119
try {
120
// establish base dir and fsPath (w/ slashes normalized to /)
121
const baseDir = pathWithForwardSlashes(options.baseDir);
122
const normalizedUrl = normalizeURL(req.url);
123
fsPath = pathWithForwardSlashes(baseDir + normalizedUrl!);
124
125
// don't let the path escape the serveDir
126
if (fsPath.indexOf(baseDir) !== 0) {
127
fsPath = baseDir;
128
}
129
const fileInfo = existsSync(fsPath) ? Deno.statSync(fsPath) : undefined;
130
if (fileInfo && fileInfo.isDirectory) {
131
fsPath = join(fsPath, options.defaultFile || "index.html");
132
}
133
if (fileInfo?.isDirectory && !normalizedUrl.endsWith("/")) {
134
response = serveRedirect(normalizedUrl + "/");
135
} else {
136
response = await serveFile(fsPath, req);
137
138
// if we are serving the default file and its not html
139
// then provide content-disposition: attachment
140
if (
141
normalizedUrl === "/" && !isBrowserPreviewable(fsPath)
142
) {
143
response.headers.append(
144
"content-disposition",
145
'attachment; filename="' + options.defaultFile + '"',
146
);
147
}
148
if (options.printUrls === "all") {
149
printUrl(normalizedUrl);
150
}
151
}
152
} catch (e) {
153
if (!(e instanceof Error)) throw e;
154
// it's possible for an exception to occur before we've normalized the path
155
// so we need to renormalize it here
156
if (fsPath) {
157
fsPath = normalize(fsPath);
158
}
159
response = await serveFallback(
160
req,
161
e,
162
fsPath,
163
);
164
}
165
return response!;
166
};
167
}
168
169
export function httpContentResponse(
170
content: Uint8Array | string,
171
contentType?: string,
172
): Response {
173
if (typeof content === "string") {
174
content = new TextEncoder().encode(content);
175
}
176
// content headers
177
const headers = new Headers();
178
headers.set("Content-Length", content.byteLength.toString());
179
if (contentType) {
180
headers.set("Content-Type", contentType);
181
}
182
headers.set("Cache-Control", "no-store, max-age=0");
183
return new Response(content, {
184
status: 200,
185
headers,
186
});
187
}
188
189
export function normalizeURL(url: string): string {
190
let normalizedUrl = url;
191
try {
192
normalizedUrl = decodeURI(normalizedUrl);
193
} catch (e) {
194
if (!(e instanceof URIError)) {
195
throw e;
196
}
197
}
198
199
try {
200
//allowed per https://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html
201
const absoluteURI = new URL(normalizedUrl);
202
normalizedUrl = decodeURI(absoluteURI.pathname);
203
} catch (e) { //wasn't an absoluteURI
204
if (!(e instanceof TypeError)) {
205
throw e;
206
}
207
}
208
209
if (normalizedUrl[0] !== "/") {
210
throw new URIError("The request URI is malformed.");
211
}
212
213
normalizedUrl = posix.normalize(normalizedUrl);
214
const startOfParams = normalizedUrl.indexOf("?");
215
return startOfParams > -1
216
? normalizedUrl.slice(0, startOfParams)
217
: normalizedUrl;
218
}
219
220
export function isBrowserPreviewable(file?: string) {
221
return (
222
isHtmlContent(file) ||
223
isPdfContent(file) ||
224
isTextContent(file)
225
);
226
}
227
228
export function maybeDisplaySocketError(e: unknown) {
229
if (
230
!(e instanceof Deno.errors.NotFound) &&
231
!(e instanceof Deno.errors.BrokenPipe) &&
232
!(e instanceof Deno.errors.NotConnected) &&
233
!(e instanceof Deno.errors.ConnectionAborted) &&
234
!(e instanceof Deno.errors.ConnectionReset) &&
235
!(e instanceof Deno.errors.ConnectionRefused) &&
236
!(e instanceof DOMException)
237
) {
238
logError(e as Error);
239
}
240
}
241
242
export function serveRedirect(url: string): Response {
243
const headers = new Headers();
244
headers.set("Cache-Control", "no-store, max-age=0");
245
headers.set("Location", url);
246
return new Response(null, {
247
status: 301,
248
headers,
249
});
250
}
251
252
function printUrl(url: string, found = true) {
253
const format = !found ? colors.red : undefined;
254
const urlDisplay = url + (found ? "" : " (404: Not Found)");
255
if (
256
isHtmlContent(url) || url.endsWith("/") || extname(url) === ""
257
) {
258
info(`GET: ${urlDisplay}`, {
259
bold: false,
260
format: format || colors.green,
261
});
262
} else if (!found) {
263
info(urlDisplay, { dim: found, format, indent: 2 });
264
}
265
}
266
267