node.default to serve websites

Massimo Melina committed Dec 24, 2021 at 16:04 UTC d407f3a64b4562e7d8f674e83681509cf8c77411
11 files changed +57 -30
README.md
+3
@@ -16,6 +16,7 @@ You won't find all previous features here (yet), but still we got:
16 - search
17 - accounts
18 - resumable downloads
19 +- simple website serving
20
21 # Configuration
22
@@ -42,6 +43,8 @@ Valid keys in a node are:
43 - `perm`: specify who can see this.
44 Use this to limit access to this node.
45 Value is a dictionary, where the key is the username, and the value is `r`.
46 +- `mime`: specify what mime to use for this resource. Use "auto" for automatic detection.
47 +- `default`: to be used with a folder where you want to serve a default html. E.g.: "index.html". Using this will make `mime` default to "auto".
48
49 # Accounts
50
src/apis.ts
+19 -16
@@ -1,6 +1,5 @@
1 import Koa from 'koa'
2 import { vfs, VfsNode, walkNode } from './vfs'
3 -import { Stats } from 'fs'
3 import { stat } from 'fs/promises'
4 import _ from 'lodash'
5 import { getCurrentUser, verifyLogin } from './perm'
@@ -132,24 +131,28 @@ export const frontEndApis: ApiHandlers = {
131
132 async function nodeToDirEntry(node: VfsNode): Promise<DirEntry | null> {
133 try {
135 - return node.source?.includes('//') ? { n:node.name||'' }
136 - : node.source ? statToDirEntry(node.name, await stat(node.source))
137 - : node.name ? { n: node.name + '/' }
138 - : null
134 + let { name, source, default:def } = node
135 + if (source?.includes('//'))
136 + return { n: name || source }
137 + if (source) {
138 + if (!name)
139 + name = basename(source)
140 + if (def)
141 + return { n: name }
142 + const st = await stat(source)
143 + const folder = st.isDirectory()
144 + const { ctime, mtime } = st
145 + return {
146 + n: name + (folder ? '/' : ''),
147 + c: ctime,
148 + m: Math.abs(+mtime-+ctime) < 1000 ? undefined : mtime,
149 + s: folder ? undefined : st.size,
150 + }
151 + }
152 + return name ? { n: name + '/' } : null
153 }
154 catch (err:any) {
155 console.error(String(err))
156 return null
157 }
158 }
145 -
146 -function statToDirEntry(name: string | undefined, stat:Stats) {
147 - const folder = stat.isDirectory()
148 - const { ctime, mtime } = stat
149 - return {
150 - n: name + (folder ? '/' : ''),
151 - c: ctime,
152 - m: Math.abs(+mtime-+ctime) < 1000 ? undefined : mtime,
153 - s: folder ? undefined : stat.size,
154 - }
155 -}
src/index.ts
+11 -4
@@ -41,14 +41,21 @@ srv.use(async (ctx, next) => {
41 return await next()
42 if (path.startsWith(FRONTEND_URI))
43 return await serveFrontendPrefixed(ctx,next)
44 - const node = await vfs.urlToNode(decodeURI(path), ctx)
44 + const decoded = decodeURI(path)
45 + const node = await vfs.urlToNode(decoded, ctx)
46 if (!node)
47 return await next()
48 const { source } = node
48 - if (!source || await isDirectory(source)) { // this folder was requested without the trailing /
49 + if (!source || await isDirectory(source)) {
50 + if (!path.endsWith('/')) // this folder was requested without the trailing /
51 + return ctx.redirect(path + '/')
52 + if (node.default) {
53 + const def = await vfs.urlToNode(decoded + node.default, ctx)
54 + if (def)
55 + return serveFile(def)(ctx, next)
56 + }
57 ctx.set({ server:'HFS '+BUILD_TIMESTAMP })
50 - return path.endsWith('/') ? await serveFrontend(ctx, next)
51 - : ctx.redirect(path + '/')
58 + return await serveFrontend(ctx, next)
59 }
60 if (source)
61 return source.includes('//') ? mount(path,proxy(source,{}))(ctx,next)
src/misc.ts
+5
@@ -10,6 +10,11 @@ export async function isDirectory(path: string) {
10 catch(e) { return false }
11 }
12
13 +export async function isFile(path: string) {
14 + try { return (await fs.stat(path)).isFile() }
15 + catch(e) { return false }
16 +}
17 +
18 export function complySlashes(path: string) {
19 return path.replace(/\\/g,'/')
20 }
src/serveFile.ts
+4 -1
@@ -2,7 +2,8 @@ import Koa from 'koa'
2 import { createReadStream } from 'fs'
3 import fs from 'fs/promises'
4 import { METHOD_NOT_ALLOWED, NO_CONTENT } from './const'
5 -import { VfsNode } from './vfs'
5 +import { MIME_AUTO, VfsNode } from './vfs'
6 +import mimetypes from 'mime-types'
7
8 export function serveFile(node: VfsNode) : Koa.Middleware {
9 return async (ctx) => {
@@ -11,6 +12,8 @@ export function serveFile(node: VfsNode) : Koa.Middleware {
12 return
13 const { range } = ctx.request.header
14 ctx.set('Accept-Ranges', 'bytes')
15 + if (mime === MIME_AUTO)
16 + mime = mimetypes.lookup(source) || ''
17 if (mime)
18 ctx.type = mime
19 if (ctx.method === 'OPTIONS') {
src/vfs.ts
+4 -1
@@ -25,6 +25,7 @@ export interface VfsNode {
25 hidden?: boolean,
26 rename?: Record<string,string>,
27 perm?: Record<string, SinglePerm>,
28 + default?: string,
29 mime?: string
30 }
31
@@ -32,6 +33,8 @@ type SinglePerm = 'r' | 'w'
33
34 const EMPTY = { type: VfsNodeType.root }
35
36 +export const MIME_AUTO = 'auto'
37 +
38 export class Vfs {
39 root: VfsNode = EMPTY
40 watcher?: FSWatcher
@@ -98,7 +101,7 @@ export class Vfs {
101 const baseSource = run.source+ '/'
102 const source = baseSource + relativeSource
103 const removed = isMatch(source, [run.remove].flat().map(x => baseSource + x))
101 - return removed || !await fs.stat(source) ? undefined : { source }
104 + return removed || !await fs.stat(source) ? undefined : { source, mime: run.mime || (run.default && MIME_AUTO) }
105 }
106 return run
107
tests/index.html deleted
-1
@@ -1 +0,0 @@
1 -<h1>This is a test</h1>
tests/page/index.html new
+2
@@ -0,0 +1,2 @@
1 +<h1>This is a test</h1>
2 +<img src="gpl.png" alt="gpl logo"/>
tests/test.ts
+3 -2
@@ -10,16 +10,17 @@ const appStarted = new Promise(resolve =>
10 describe('basics', () => {
11 //before(async () => appStarted)
12 it('frontend', req('/', s => s.includes('<body>')))
13 - it('api.list', req('/~/api/file_list', res => inList(res, 'f2/') && inList(res, 'f3/'), {
13 + it('api.list', req('/~/api/file_list', res => inList(res, 'f2/') && inList(res, 'page'), {
14 data: { path:'/f1/' }
15 }))
16 - it('api.search', req('/~/api/file_list', res => inList(res, 'f2/') && !inList(res, 'f3/'), {
16 + it('api.search', req('/~/api/file_list', res => inList(res, 'f2/') && !inList(res, 'page'), {
17 data: { path:'f1', search:'2' }
18 }))
19 it('download', req('/f1/f2/alfa.txt', s => s.includes('abcd')))
20 it('partial download', req('/f1/f2/alfa.txt', s => s.includes('a') && !s.includes('d'), {
21 headers: { Range: 'bytes=0-2' }
22 }))
23 + it('website', req('/f1/page/', s => s.includes('This is a test')))
24 it('missing perm', req('/for-rejetto/', 404))
25 it('proxy', req('/proxy', s => s.includes('github')))
26 })
todo.md
-2
@@ -1,6 +1,4 @@
1 # To do
2 -- vfs: serve an html for a folder?
3 - - "default" property for vfsNode?
2 - log file
3 - throttle speed
4 - frontend: dialogs
vfs.yaml
+6 -3
@@ -4,9 +4,12 @@ children:
4 - name: f2
5 children:
6 - source: tests/alfa.txt
7 - - name: f3
8 - mime: html
9 - source: tests/index.html
7 + - name: pic
8 + mime: png
9 + source: tests/page/gpl.png
10 + - name: page
11 + default: index.html
12 + source: tests/page
13 - name: proxy
14 source: https://raw.githubusercontent.com/nodejs/node/master/README.md
15 - name: for-rejetto