create-express-new 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/LICENSE +24 -0
- package/README.md +282 -2
- package/bin/express-cli.js +575 -0
- package/lib/app.js +607 -0
- package/lib/prompts.js +338 -0
- package/lib/wizard.js +398 -0
- package/package.json +65 -4
- package/templates/app/Dockerfile.ejs +64 -0
- package/templates/app/app.js.ejs +163 -0
- package/templates/app/csrf.js.ejs +21 -0
- package/templates/app/dockerignore +15 -0
- package/templates/app/env.example.ejs +40 -0
- package/templates/app/eslint.config.js.ejs +28 -0
- package/templates/app/gitignore +22 -0
- package/templates/app/npmrc +8 -0
- package/templates/app/pnpm-workspace.yaml +24 -0
- package/templates/app/routes/index.js.ejs +22 -0
- package/templates/app/routes/uploads.js.ejs +94 -0
- package/templates/app/routes/users.js.ejs +42 -0
- package/templates/app/test/app.test.js.ejs +199 -0
- package/templates/app/www.js.ejs +46 -0
- package/templates/public/index.html +13 -0
- package/templates/public/stylesheets/style.css +8 -0
- package/templates/versions.json +49 -0
- package/templates/views/error.ejs +12 -0
- package/templates/views/error.hbs +3 -0
- package/templates/views/error.pug +6 -0
- package/templates/views/error.twig +7 -0
- package/templates/views/index.ejs +11 -0
- package/templates/views/index.hbs +2 -0
- package/templates/views/index.pug +5 -0
- package/templates/views/index.twig +6 -0
- package/templates/views/layout.hbs +10 -0
- package/templates/views/layout.pug +7 -0
- package/templates/views/layout.twig +10 -0
- package/templates/views/new-user.ejs +18 -0
- package/templates/views/new-user.hbs +9 -0
- package/templates/views/new-user.pug +13 -0
- package/templates/views/new-user.twig +13 -0
- package/templates/views/uploads.ejs +16 -0
- package/templates/views/uploads.hbs +7 -0
- package/templates/views/uploads.pug +10 -0
- package/templates/views/uploads.twig +11 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<% if (esm) { -%>
|
|
2
|
+
import { csrfSync } from 'csrf-sync';
|
|
3
|
+
<% } else { -%>
|
|
4
|
+
const { csrfSync } = require('csrf-sync');
|
|
5
|
+
<% } -%>
|
|
6
|
+
|
|
7
|
+
// CSRF protection for requests that change data, which must send the token
|
|
8
|
+
// in a _csrf form field or an x-csrf-token header (GET is not checked)
|
|
9
|
+
const csrf = csrfSync({
|
|
10
|
+
getTokenFromRequest: (req) => req.body?._csrf ?? req.get('x-csrf-token')<%- uploads ? ',' : '' %>
|
|
11
|
+
<% if (uploads) { -%>
|
|
12
|
+
// uploads check the token themselves, as multer reads their form
|
|
13
|
+
skipCsrfProtection: (req) => req.path === '/uploads'
|
|
14
|
+
<% } -%>
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
<% if (esm) { -%>
|
|
18
|
+
export default csrf;
|
|
19
|
+
<% } else { -%>
|
|
20
|
+
module.exports = csrf;
|
|
21
|
+
<% } -%>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Environment variables for local development.
|
|
2
|
+
#
|
|
3
|
+
# Copy this file to .env, which `<%- start %>` and `<%- dev %>` load automatically.
|
|
4
|
+
# Variables already set in the environment take precedence over .env.
|
|
5
|
+
# Do not commit .env.
|
|
6
|
+
|
|
7
|
+
PORT=3000
|
|
8
|
+
|
|
9
|
+
# Set to production when you deploy
|
|
10
|
+
# NODE_ENV=production
|
|
11
|
+
<% if (cors) { -%>
|
|
12
|
+
|
|
13
|
+
# Comma-separated origins allowed to call the app from a browser
|
|
14
|
+
# (defaults to any origin)
|
|
15
|
+
# CORS_ORIGIN=https://example.com,https://admin.example.com
|
|
16
|
+
<% } -%>
|
|
17
|
+
<% if (trustProxy) { -%>
|
|
18
|
+
|
|
19
|
+
# Behind a load balancer or other proxy, the number of proxies (or their
|
|
20
|
+
# addresses), so the app sees each client's IP address and protocol
|
|
21
|
+
# TRUST_PROXY=1
|
|
22
|
+
<% } -%>
|
|
23
|
+
<% if (rateLimit) { -%>
|
|
24
|
+
|
|
25
|
+
# Requests each client may make in each window of milliseconds
|
|
26
|
+
# (defaults to 100 every 15 minutes)
|
|
27
|
+
# RATE_LIMIT_MAX=100
|
|
28
|
+
# RATE_LIMIT_WINDOW_MS=900000
|
|
29
|
+
<% } -%>
|
|
30
|
+
<% if (session) { -%>
|
|
31
|
+
|
|
32
|
+
# Long random secret that signs the session cookie, required in production,
|
|
33
|
+
# such as the output of: node -e "console.log(crypto.randomBytes(32).toString('hex'))"
|
|
34
|
+
# SESSION_SECRET=
|
|
35
|
+
<% } -%>
|
|
36
|
+
<% if (uploads) { -%>
|
|
37
|
+
|
|
38
|
+
# Largest file that can be uploaded, in bytes (defaults to 5 MB)
|
|
39
|
+
# UPLOAD_MAX_SIZE=5242880
|
|
40
|
+
<% } -%>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
import { defineConfig } from 'eslint/config';
|
|
3
|
+
import globals from 'globals';
|
|
4
|
+
<% if (ts) { -%>
|
|
5
|
+
import tseslint from 'typescript-eslint';
|
|
6
|
+
<% } -%>
|
|
7
|
+
|
|
8
|
+
export default defineConfig([
|
|
9
|
+
js.configs.recommended,
|
|
10
|
+
<% if (ts) { -%>
|
|
11
|
+
tseslint.configs.recommended,
|
|
12
|
+
<% } -%>
|
|
13
|
+
<% if (!esm) { -%>
|
|
14
|
+
{
|
|
15
|
+
files: ['**/*.js'],
|
|
16
|
+
languageOptions: { sourceType: 'commonjs' }
|
|
17
|
+
},
|
|
18
|
+
<% } -%>
|
|
19
|
+
{
|
|
20
|
+
languageOptions: {
|
|
21
|
+
globals: globals.node
|
|
22
|
+
},
|
|
23
|
+
rules: {
|
|
24
|
+
// Express error handlers need all four arguments, even when next is unused
|
|
25
|
+
'<%- ts ? '@typescript-eslint/' : '' %>no-unused-vars': ['error', { argsIgnorePattern: '^next$' }]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
]);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# Environment variables, except the example
|
|
5
|
+
.env
|
|
6
|
+
.env.*
|
|
7
|
+
!.env.example
|
|
8
|
+
|
|
9
|
+
# Logs
|
|
10
|
+
logs/
|
|
11
|
+
*.log
|
|
12
|
+
npm-debug.log*
|
|
13
|
+
|
|
14
|
+
# Test coverage
|
|
15
|
+
coverage/
|
|
16
|
+
|
|
17
|
+
# TypeScript
|
|
18
|
+
*.tsbuildinfo
|
|
19
|
+
|
|
20
|
+
# Operating system files
|
|
21
|
+
.DS_Store
|
|
22
|
+
Thumbs.db
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# npm settings that protect installs from compromised packages.
|
|
2
|
+
# npm 12 and newer, which this app requires in package.json, also refuses
|
|
3
|
+
# dependencies from git and skips the install scripts of dependencies unless
|
|
4
|
+
# they are approved with `npm approve-scripts`.
|
|
5
|
+
|
|
6
|
+
# Wait 3 days before installing a newly published version, so a compromised
|
|
7
|
+
# release can be found and removed from the registry first.
|
|
8
|
+
min-release-age=3
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# pnpm settings that protect installs from compromised packages.
|
|
2
|
+
# https://pnpm.io/supply-chain-security
|
|
3
|
+
|
|
4
|
+
# Wait 3 days (in minutes) before installing a newly published version, so a
|
|
5
|
+
# compromised release can be found and removed from the registry first.
|
|
6
|
+
minimumReleaseAge: 4320
|
|
7
|
+
|
|
8
|
+
# Fail when a dependency wants to run an install script, instead of skipping
|
|
9
|
+
# it. None of this app's dependencies need one; approve a package you trust
|
|
10
|
+
# with `pnpm approve-builds`.
|
|
11
|
+
strictDepBuilds: true
|
|
12
|
+
|
|
13
|
+
# Dependencies of dependencies must come from the registry, not git or a URL.
|
|
14
|
+
blockExoticSubdeps: true
|
|
15
|
+
|
|
16
|
+
# Fail when a version was published with weaker proof of where it came from
|
|
17
|
+
# than an earlier version of the package, a sign of a stolen account. Versions
|
|
18
|
+
# over a year old (in minutes) are not checked.
|
|
19
|
+
trustPolicy: no-downgrade
|
|
20
|
+
trustPolicyIgnoreAfter: 525600
|
|
21
|
+
|
|
22
|
+
# Run the pnpm version recorded in pnpm-lock.yaml, downloading it when another
|
|
23
|
+
# version is installed, so everyone installs with the same pnpm.
|
|
24
|
+
pmOnFail: download
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<% if (esm) { -%>
|
|
2
|
+
import express from 'express';
|
|
3
|
+
<% } else { -%>
|
|
4
|
+
const express = require('express');
|
|
5
|
+
<% } -%>
|
|
6
|
+
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
/* GET home page. */
|
|
10
|
+
router.get('/', (req, res) => {
|
|
11
|
+
<% if (api) { -%>
|
|
12
|
+
res.json({ message: 'Welcome to Express' });
|
|
13
|
+
<% } else { -%>
|
|
14
|
+
res.render('index', { title: 'Express' });
|
|
15
|
+
<% } -%>
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
<% if (esm) { -%>
|
|
19
|
+
export default router;
|
|
20
|
+
<% } else { -%>
|
|
21
|
+
module.exports = router;
|
|
22
|
+
<% } -%>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<% if (esm) { -%>
|
|
2
|
+
import express from 'express';
|
|
3
|
+
<% if (ts) { -%>
|
|
4
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
5
|
+
<% } -%>
|
|
6
|
+
import createError from 'http-errors';
|
|
7
|
+
import multer from 'multer';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
<% if (csrf) { -%>
|
|
10
|
+
import csrf from '../csrf.<%- ext %>';
|
|
11
|
+
<% } -%>
|
|
12
|
+
<% } else { -%>
|
|
13
|
+
const express = require('express');
|
|
14
|
+
const createError = require('http-errors');
|
|
15
|
+
const multer = require('multer');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
<% if (csrf) { -%>
|
|
18
|
+
const csrf = require('../csrf.js');
|
|
19
|
+
<% } -%>
|
|
20
|
+
<% } -%>
|
|
21
|
+
|
|
22
|
+
const router = express.Router();
|
|
23
|
+
|
|
24
|
+
// file types to accept, as sent by the client, so check a file's contents
|
|
25
|
+
// too before trusting it
|
|
26
|
+
const TYPES = ['application/pdf', 'image/gif', 'image/jpeg', 'image/png', 'image/webp', 'text/plain'];
|
|
27
|
+
|
|
28
|
+
// files are saved in uploads/ under random names, never the name the client
|
|
29
|
+
// sends, and are not served as static files, where they could run scripts
|
|
30
|
+
const upload = multer({
|
|
31
|
+
dest: path.join(<%- dirname %>, '..', 'uploads'),
|
|
32
|
+
limits: {
|
|
33
|
+
// bytes per file, 5 MB unless set
|
|
34
|
+
fileSize: Number(process.env.UPLOAD_MAX_SIZE) || 5 * 1024 * 1024,
|
|
35
|
+
files: 1
|
|
36
|
+
},
|
|
37
|
+
fileFilter: (req, file, cb) => {
|
|
38
|
+
<% if (csrf) { -%>
|
|
39
|
+
// check the CSRF token, from the _csrf field before the file, so
|
|
40
|
+
// nothing is saved without it
|
|
41
|
+
if (!csrf.isRequestValid(req)) return cb(csrf.invalidCsrfTokenError);
|
|
42
|
+
<% } -%>
|
|
43
|
+
if (!TYPES.includes(file.mimetype)) return cb(createError(415, 'Unsupported file type'));
|
|
44
|
+
cb(null, true);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
<% if (view) { -%>
|
|
49
|
+
/* GET the upload form. */
|
|
50
|
+
router.get('/', (req, res) => {
|
|
51
|
+
res.render('uploads', { title: 'Upload a file', uploaded: req.query.uploaded });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
<% } -%>
|
|
55
|
+
/* POST a file in the `file` field. */
|
|
56
|
+
router.post('/', upload.single('file'), (req, res, next) => {
|
|
57
|
+
<% if (csrf) { -%>
|
|
58
|
+
// without a file, the CSRF token is not checked yet
|
|
59
|
+
if (!csrf.isRequestValid(req)) return next(csrf.invalidCsrfTokenError);
|
|
60
|
+
<% } -%>
|
|
61
|
+
if (!req.file) return next(createError(400, 'Choose a file to upload'));
|
|
62
|
+
|
|
63
|
+
<% if (view) { -%>
|
|
64
|
+
// redirect, so refreshing the page does not upload again
|
|
65
|
+
res.redirect(303, `/uploads?uploaded=${encodeURIComponent(req.file.originalname)}`);
|
|
66
|
+
<% } else { -%>
|
|
67
|
+
res.status(201).json({
|
|
68
|
+
// the file's name in uploads/
|
|
69
|
+
id: req.file.filename,
|
|
70
|
+
name: req.file.originalname,
|
|
71
|
+
type: req.file.mimetype,
|
|
72
|
+
size: req.file.size
|
|
73
|
+
});
|
|
74
|
+
<% } -%>
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// multer's errors, such as a file over the size limit, are client errors
|
|
78
|
+
<% if (ts) { -%>
|
|
79
|
+
router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
|
80
|
+
<% } else { -%>
|
|
81
|
+
router.use((err, req, res, next) => {
|
|
82
|
+
<% } -%>
|
|
83
|
+
if (err instanceof multer.MulterError) {
|
|
84
|
+
next(createError(err.code === 'LIMIT_FILE_SIZE' ? 413 : 400, err.message));
|
|
85
|
+
} else {
|
|
86
|
+
next(err);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
<% if (esm) { -%>
|
|
91
|
+
export default router;
|
|
92
|
+
<% } else { -%>
|
|
93
|
+
module.exports = router;
|
|
94
|
+
<% } -%>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<% if (esm) { -%>
|
|
2
|
+
import express from 'express';
|
|
3
|
+
<% } else { -%>
|
|
4
|
+
const express = require('express');
|
|
5
|
+
<% } -%>
|
|
6
|
+
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
/* GET users listing. */
|
|
10
|
+
router.get('/', (req, res) => {
|
|
11
|
+
<% if (api) { -%>
|
|
12
|
+
res.json([]);
|
|
13
|
+
<% } else { -%>
|
|
14
|
+
res.send('respond with a resource');
|
|
15
|
+
<% } -%>
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
<% if (form) { -%>
|
|
19
|
+
/* GET the new user form. */
|
|
20
|
+
router.get('/new', (req, res) => {
|
|
21
|
+
res.render('new-user', { title: 'New user', added: req.query.added });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/* POST the new user form, after the app checks its CSRF token. */
|
|
25
|
+
router.post('/new', (req, res) => {
|
|
26
|
+
const name = String(req.body?.name ?? '').trim();
|
|
27
|
+
|
|
28
|
+
if (!name) {
|
|
29
|
+
res.status(400).render('new-user', { title: 'New user', error: 'Enter a name' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// save the user here, then redirect, so refreshing the page does not post again
|
|
34
|
+
res.redirect(303, `/users/new?added=${encodeURIComponent(name)}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
<% } -%>
|
|
38
|
+
<% if (esm) { -%>
|
|
39
|
+
export default router;
|
|
40
|
+
<% } else { -%>
|
|
41
|
+
module.exports = router;
|
|
42
|
+
<% } -%>
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<% if (esm) { -%>
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
<% if (uploads) { -%>
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
<% } -%>
|
|
7
|
+
<% if (ts) { -%>
|
|
8
|
+
import type { Server } from 'node:http';
|
|
9
|
+
import type { AddressInfo } from 'node:net';
|
|
10
|
+
<% } -%>
|
|
11
|
+
import { after, before, describe, it } from 'node:test';
|
|
12
|
+
<% if (uploads) { -%>
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
<% } -%>
|
|
15
|
+
import app from '../app.<%- ext %>';
|
|
16
|
+
<% } else { -%>
|
|
17
|
+
const assert = require('node:assert');
|
|
18
|
+
const { once } = require('node:events');
|
|
19
|
+
<% if (uploads) { -%>
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
<% } -%>
|
|
23
|
+
const { after, before, describe, it } = require('node:test');
|
|
24
|
+
const app = require('../app.js');
|
|
25
|
+
<% } -%>
|
|
26
|
+
|
|
27
|
+
// skip request logs while testing (see app.<%- ext %>)
|
|
28
|
+
process.env.NODE_ENV = 'test';
|
|
29
|
+
|
|
30
|
+
describe('app', () => {
|
|
31
|
+
<% if (ts) { -%>
|
|
32
|
+
let server: Server;
|
|
33
|
+
let baseUrl: string;
|
|
34
|
+
<% } else { -%>
|
|
35
|
+
let server;
|
|
36
|
+
let baseUrl;
|
|
37
|
+
<% } -%>
|
|
38
|
+
|
|
39
|
+
before(async () => {
|
|
40
|
+
// listen on a random free port
|
|
41
|
+
server = app.listen(0);
|
|
42
|
+
await once(server, 'listening');
|
|
43
|
+
<% if (ts) { -%>
|
|
44
|
+
baseUrl = `http://localhost:${(server.address() as AddressInfo).port}`;
|
|
45
|
+
<% } else { -%>
|
|
46
|
+
baseUrl = `http://localhost:${server.address().port}`;
|
|
47
|
+
<% } -%>
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
after(() => {
|
|
51
|
+
server.close();
|
|
52
|
+
});
|
|
53
|
+
<% if (csrf) { -%>
|
|
54
|
+
|
|
55
|
+
// a session cookie and its CSRF token, from a page with a form
|
|
56
|
+
<% if (ts) { -%>
|
|
57
|
+
async function csrfSession(url: string) {
|
|
58
|
+
<% } else { -%>
|
|
59
|
+
async function csrfSession(url) {
|
|
60
|
+
<% } -%>
|
|
61
|
+
const res = await fetch(url);
|
|
62
|
+
const cookie = res.headers.getSetCookie().map((header) => header.split(';')[0]).join('; ');
|
|
63
|
+
const token = /<meta name="csrf-token" content="([^"]+)">/.exec(await res.text())?.[1] ?? '';
|
|
64
|
+
return { cookie, token };
|
|
65
|
+
}
|
|
66
|
+
<% } -%>
|
|
67
|
+
|
|
68
|
+
it('responds to GET /health', async () => {
|
|
69
|
+
const res = await fetch(`${baseUrl}/health`);
|
|
70
|
+
assert.strictEqual(res.status, 200);
|
|
71
|
+
assert.deepStrictEqual(await res.json(), { status: 'ok' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
<% if (rateLimit) { -%>
|
|
75
|
+
it('sends rate limit headers', async () => {
|
|
76
|
+
const res = await fetch(`${baseUrl}/`);
|
|
77
|
+
assert.ok(res.headers.has('ratelimit-policy'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
<% } -%>
|
|
81
|
+
<% if (csrf) { -%>
|
|
82
|
+
it('rejects posts without a CSRF token', async () => {
|
|
83
|
+
const res = await fetch(`${baseUrl}/users/new`, { method: 'POST', body: new URLSearchParams({ name: 'Ada' }) });
|
|
84
|
+
assert.strictEqual(res.status, 403);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('adds a user with the form and its CSRF token', async () => {
|
|
88
|
+
const { cookie, token } = await csrfSession(`${baseUrl}/users/new`);
|
|
89
|
+
const res = await fetch(`${baseUrl}/users/new`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { cookie },
|
|
92
|
+
body: new URLSearchParams({ _csrf: token, name: 'Ada' }),
|
|
93
|
+
redirect: 'manual'
|
|
94
|
+
});
|
|
95
|
+
assert.strictEqual(res.status, 303);
|
|
96
|
+
assert.strictEqual(res.headers.get('location'), '/users/new?added=Ada');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('asks again for a user without a name', async () => {
|
|
100
|
+
const { cookie, token } = await csrfSession(`${baseUrl}/users/new`);
|
|
101
|
+
const res = await fetch(`${baseUrl}/users/new`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { cookie },
|
|
104
|
+
body: new URLSearchParams({ _csrf: token, name: ' ' })
|
|
105
|
+
});
|
|
106
|
+
assert.strictEqual(res.status, 400);
|
|
107
|
+
assert.match(await res.text(), /Enter a name/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
<% } -%>
|
|
111
|
+
<% if (uploads) { -%>
|
|
112
|
+
it('uploads a file', async () => {
|
|
113
|
+
const uploads = path.join(<%- esm ? 'import.meta.dirname' : '__dirname' %>, '..', 'uploads');
|
|
114
|
+
const existing = new Set(fs.readdirSync(uploads));
|
|
115
|
+
<% if (csrf) { -%>
|
|
116
|
+
const { cookie, token } = await csrfSession(`${baseUrl}/uploads`);
|
|
117
|
+
<% } -%>
|
|
118
|
+
const body = new FormData();
|
|
119
|
+
<% if (csrf) { -%>
|
|
120
|
+
// the token comes before the file, so it is checked before saving
|
|
121
|
+
body.append('_csrf', token);
|
|
122
|
+
<% } -%>
|
|
123
|
+
body.append('file', new Blob(['hello'], { type: 'text/plain' }), 'hello.txt');
|
|
124
|
+
|
|
125
|
+
<% if (csrf) { -%>
|
|
126
|
+
const res = await fetch(`${baseUrl}/uploads`, { method: 'POST', headers: { cookie }, body, redirect: 'manual' });
|
|
127
|
+
<% } else { -%>
|
|
128
|
+
const res = await fetch(`${baseUrl}/uploads`, { method: 'POST', body, redirect: 'manual' });
|
|
129
|
+
<% } -%>
|
|
130
|
+
<% if (view) { -%>
|
|
131
|
+
assert.strictEqual(res.status, 303);
|
|
132
|
+
assert.strictEqual(res.headers.get('location'), '/uploads?uploaded=hello.txt');
|
|
133
|
+
<% } else { -%>
|
|
134
|
+
assert.strictEqual(res.status, 201);
|
|
135
|
+
assert.strictEqual((await res.json()).name, 'hello.txt');
|
|
136
|
+
<% } -%>
|
|
137
|
+
|
|
138
|
+
// remove the uploaded file
|
|
139
|
+
const added = fs.readdirSync(uploads).filter((file) => !existing.has(file));
|
|
140
|
+
assert.strictEqual(added.length, 1);
|
|
141
|
+
for (const file of added) fs.rmSync(path.join(uploads, file));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('rejects unsupported file types', async () => {
|
|
145
|
+
<% if (csrf) { -%>
|
|
146
|
+
const { cookie, token } = await csrfSession(`${baseUrl}/uploads`);
|
|
147
|
+
<% } -%>
|
|
148
|
+
const body = new FormData();
|
|
149
|
+
<% if (csrf) { -%>
|
|
150
|
+
body.append('_csrf', token);
|
|
151
|
+
<% } -%>
|
|
152
|
+
body.append('file', new Blob(['<html>'], { type: 'text/html' }), 'page.html');
|
|
153
|
+
|
|
154
|
+
<% if (csrf) { -%>
|
|
155
|
+
const res = await fetch(`${baseUrl}/uploads`, { method: 'POST', headers: { cookie }, body });
|
|
156
|
+
<% } else { -%>
|
|
157
|
+
const res = await fetch(`${baseUrl}/uploads`, { method: 'POST', body });
|
|
158
|
+
<% } -%>
|
|
159
|
+
assert.strictEqual(res.status, 415);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
<% } -%><% if (api) { -%>
|
|
163
|
+
it('responds to GET /', async () => {
|
|
164
|
+
const res = await fetch(`${baseUrl}/`);
|
|
165
|
+
assert.strictEqual(res.status, 200);
|
|
166
|
+
assert.deepStrictEqual(await res.json(), { message: 'Welcome to Express' });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('responds to GET /users', async () => {
|
|
170
|
+
const res = await fetch(`${baseUrl}/users`);
|
|
171
|
+
assert.strictEqual(res.status, 200);
|
|
172
|
+
assert.deepStrictEqual(await res.json(), []);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('responds with a JSON 404 for unknown routes', async () => {
|
|
176
|
+
const res = await fetch(`${baseUrl}/does-not-exist`);
|
|
177
|
+
assert.strictEqual(res.status, 404);
|
|
178
|
+
assert.match(res.headers.get('content-type') ?? '', /application\/json/);
|
|
179
|
+
assert.strictEqual((await res.json()).error, 'Not Found');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
<% } else { -%>
|
|
183
|
+
it('responds to GET /', async () => {
|
|
184
|
+
const res = await fetch(`${baseUrl}/`);
|
|
185
|
+
assert.strictEqual(res.status, 200);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('responds to GET /users', async () => {
|
|
189
|
+
const res = await fetch(`${baseUrl}/users`);
|
|
190
|
+
assert.strictEqual(res.status, 200);
|
|
191
|
+
assert.strictEqual(await res.text(), 'respond with a resource');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('responds with 404 for unknown routes', async () => {
|
|
195
|
+
const res = await fetch(`${baseUrl}/does-not-exist`);
|
|
196
|
+
assert.strictEqual(res.status, 404);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
<% } -%>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
<% if (esm) { -%>
|
|
4
|
+
import app from '../app.<%- ext %>';
|
|
5
|
+
<% } else { -%>
|
|
6
|
+
const app = require('../app.js');
|
|
7
|
+
<% } -%>
|
|
8
|
+
|
|
9
|
+
const port = process.env.PORT || 3000;
|
|
10
|
+
|
|
11
|
+
const server = app.listen(port, (err) => {
|
|
12
|
+
if (err) {
|
|
13
|
+
// handle specific listen errors with friendly messages
|
|
14
|
+
<% if (ts) { -%>
|
|
15
|
+
switch ((err as NodeJS.ErrnoException).code) {
|
|
16
|
+
<% } else { -%>
|
|
17
|
+
switch (err.code) {
|
|
18
|
+
<% } -%>
|
|
19
|
+
case 'EACCES':
|
|
20
|
+
console.error(`Port ${port} requires elevated privileges`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
break;
|
|
23
|
+
case 'EADDRINUSE':
|
|
24
|
+
console.error(`Port ${port} is already in use`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
break;
|
|
27
|
+
default:
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(`Listening on port ${port}`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// close the server gracefully when asked to stop
|
|
36
|
+
<% if (ts) { -%>
|
|
37
|
+
function shutdown(signal: NodeJS.Signals) {
|
|
38
|
+
<% } else { -%>
|
|
39
|
+
function shutdown(signal) {
|
|
40
|
+
<% } -%>
|
|
41
|
+
console.log(`${signal} received, shutting down`);
|
|
42
|
+
server.close(() => process.exit(0));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
process.on('SIGINT', shutdown);
|
|
46
|
+
process.on('SIGTERM', shutdown);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"versions": {
|
|
3
|
+
"@eslint/js": "^10.0.1",
|
|
4
|
+
"@types/compression": "^1.8.1",
|
|
5
|
+
"@types/cookie-parser": "^1.4.10",
|
|
6
|
+
"@types/cors": "^2.8.19",
|
|
7
|
+
"@types/express": "^5.0.6",
|
|
8
|
+
"@types/express-session": "^1.19.0",
|
|
9
|
+
"@types/http-errors": "^2.0.5",
|
|
10
|
+
"@types/morgan": "^1.9.10",
|
|
11
|
+
"@types/multer": "^2.3.0",
|
|
12
|
+
"@types/node": "^22.20.5",
|
|
13
|
+
"compression": "^1.8.2",
|
|
14
|
+
"cookie-parser": "^1.4.7",
|
|
15
|
+
"cors": "^2.8.6",
|
|
16
|
+
"csrf-sync": "^4.2.1",
|
|
17
|
+
"ejs": "^6.0.1",
|
|
18
|
+
"eslint": "^10.12.0",
|
|
19
|
+
"express": "^5.2.1",
|
|
20
|
+
"express-rate-limit": "^8.7.0",
|
|
21
|
+
"express-session": "^1.19.0",
|
|
22
|
+
"globals": "^17.13.0",
|
|
23
|
+
"hbs": "^4.3.1",
|
|
24
|
+
"helmet": "^8.3.0",
|
|
25
|
+
"http-errors": "^2.0.1",
|
|
26
|
+
"morgan": "^1.12.1",
|
|
27
|
+
"multer": "^2.4.0",
|
|
28
|
+
"npm": "^12.2.0",
|
|
29
|
+
"pino-http": "^11.0.0",
|
|
30
|
+
"pino-pretty": "^13.1.3",
|
|
31
|
+
"pnpm": "^12.9.1",
|
|
32
|
+
"pug": "^3.0.4",
|
|
33
|
+
"twig": "^3.0.0",
|
|
34
|
+
"typescript": "^7.0.2",
|
|
35
|
+
"typescript-eslint": "^8.71.0",
|
|
36
|
+
"typescript@6": "^6.0.3"
|
|
37
|
+
},
|
|
38
|
+
"hold": {
|
|
39
|
+
"@types/node": "kept on the major of the minimum supported Node.js version (22)",
|
|
40
|
+
"typescript@6": {
|
|
41
|
+
"reason": "used with --ts --lint until typescript-eslint supports TypeScript 7",
|
|
42
|
+
"until": {
|
|
43
|
+
"package": "typescript-eslint",
|
|
44
|
+
"peer": "typescript",
|
|
45
|
+
"supports": "7.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|