├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── CHANGELOG.md ├── LICENSE ├── README.md ├── benchmark.js ├── benchmarks ├── http.js └── tenso.js ├── dist ├── tenso.cjs └── tenso.js ├── eslint.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── sample.js ├── src ├── core │ ├── config.js │ └── constants.js ├── middleware │ ├── asyncFlag.js │ ├── bypass.js │ ├── csrf.js │ ├── exit.js │ ├── guard.js │ ├── parse.js │ ├── payload.js │ ├── prometheus.js │ ├── rate.js │ ├── redirect.js │ └── zuul.js ├── parsers │ ├── json.js │ ├── jsonl.js │ └── xWwwFormURLEncoded.js ├── renderers │ ├── csv.js │ ├── html.js │ ├── javascript.js │ ├── json.js │ ├── jsonl.js │ ├── plain.js │ ├── xml.js │ └── yaml.js ├── serializers │ ├── custom.js │ └── plain.js ├── tenso.js └── utils │ ├── auth.js │ ├── capitalize.js │ ├── chunk.js │ ├── clone.js │ ├── delay.js │ ├── empty.js │ ├── explode.js │ ├── hasBody.js │ ├── hasRead.js │ ├── hypermedia.js │ ├── id.js │ ├── indent.js │ ├── isEmpty.js │ ├── marshal.js │ ├── parsers.js │ ├── random.js │ ├── regex.js │ ├── renderers.js │ ├── sanitize.js │ ├── scheme.js │ ├── serialize.js │ ├── serializers.js │ └── sort.js ├── test ├── auth.test.js ├── behavior.test.js ├── invalid.test.js ├── renderers.test.js ├── routes.js └── valid.test.js ├── types ├── core │ ├── config.d.ts │ └── constants.d.ts ├── middleware │ ├── asyncFlag.d.ts │ ├── bypass.d.ts │ ├── csrf.d.ts │ ├── guard.d.ts │ ├── keymaster.d.ts │ ├── parse.d.ts │ ├── payload.d.ts │ ├── prometheus.d.ts │ ├── rate.d.ts │ ├── redirect.d.ts │ └── zuul.d.ts ├── parsers │ ├── json.d.ts │ ├── jsonl.d.ts │ └── xWwwFormURLEncoded.d.ts ├── renderers │ ├── csv.d.ts │ ├── html.d.ts │ ├── javascript.d.ts │ ├── json.d.ts │ ├── jsonl.d.ts │ ├── plain.d.ts │ ├── xml.d.ts │ └── yaml.d.ts ├── serializers │ ├── custom.d.ts │ └── plain.d.ts ├── tenso.d.ts └── utils │ ├── auth.d.ts │ ├── chunk.d.ts │ ├── clone.d.ts │ ├── delay.d.ts │ ├── explode.d.ts │ ├── hasbody.d.ts │ ├── hypermedia.d.ts │ ├── id.d.ts │ ├── indent.d.ts │ ├── isEmpty.d.ts │ ├── marshal.d.ts │ ├── parsers.d.ts │ ├── random.d.ts │ ├── regex.d.ts │ ├── renderers.d.ts │ ├── sanitize.d.ts │ ├── scheme.d.ts │ ├── serialize.d.ts │ ├── serializers.d.ts │ └── sort.d.ts └── www ├── assets ├── css │ ├── bulma.css.map │ ├── bulma.min.css │ ├── style.css │ ├── style.css.map │ └── style.scss ├── img │ ├── avoidwork.svg │ └── favicon.png └── js │ ├── app.js │ ├── dom-router.min.js │ └── dom-router.min.js.map ├── sample └── index.html └── template.html /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [avoidwork] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: npm 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ci 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [20.x, 22.x] 16 | 17 | steps: 18 | - name: Checkout Repository 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 1 22 | 23 | - name: Setup Node ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | always-auth: false 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - name: Install dependencies 30 | run: npm install 31 | 32 | - name: Build & Run Tests 33 | run: npm run build & npm test 34 | 35 | automerge: 36 | needs: build 37 | runs-on: ubuntu-latest 38 | permissions: 39 | pull-requests: write 40 | contents: write 41 | steps: 42 | - uses: fastify/github-action-merge-dependabot@v3 43 | with: 44 | github-token: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /test/webpack/ 3 | .idea 4 | .nyc_output 5 | *.tgz 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm test 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024, Jason Mulligan 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of tenso nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tenso 2 | 3 | Tenso is an HTTP REST API framework, that will handle the serialization & creation of hypermedia links; all you have to do is give it `Arrays` or `Objects`. 4 | 5 | ## Example 6 | Creating an API with Tenso can be this simple: 7 | 8 | ```javascript 9 | import {tenso} from "tenso"; 10 | 11 | export const app = tenso(); 12 | 13 | app.get("/", "Hello, World!"); 14 | app.start(); 15 | ``` 16 | 17 | ### Creating Routes 18 | Routes are loaded as a module, with each HTTP method as an export, affording a very customizable API server. 19 | 20 | You can use `res` to `res.send(body[, status, headers])`, `res.redirect(url)`, or `res.error(status[, Error])`. 21 | 22 | The following example will create GET routes that will return an `Array` at `/`, an `Error` at `/reports/tps`, & a version 4 UUID at `/uuid`. 23 | 24 | As of 10.3.0 you can specify `always` as a method to run middleware before authorization middleware, which will skip `always` middleware registered after it (via instance methods). 25 | 26 | As of 17.2.0 you can have routes exit the middleware pipeline immediately by setting them in the `exit` Array. This differs from `unprotect` as there is no request body handling. 27 | 28 | #### Example 29 | 30 | ##### Routes 31 | 32 | ```javascript 33 | import {randomUUID as uuid} from "crypto"; 34 | 35 | export const initRoutes = { 36 | "get": { 37 | "/": ["reports", "uuid"], 38 | "/reports": ["tps"], 39 | "/reports/tps": (req, res) => res.error(785, Error("TPS Cover Sheet not attached")), 40 | "/uuid": (req, res) => res.send(uuid(), 200, {"cache-control": "no-cache"}) 41 | } 42 | }; 43 | ``` 44 | 45 | ##### Server 46 | 47 | ```javascript 48 | import {tenso} from "tenso"; 49 | import {initRoutes} from "./routes"; 50 | 51 | export const app = tenso({initRoutes}); 52 | 53 | app.start(); 54 | ``` 55 | 56 | #### Protected Routes 57 | Protected routes are routes that require authorization for access, and will redirect to authentication end points if needed. 58 | 59 | #### Unprotected Routes 60 | Unprotected routes are routes that do not require authorization for access, and will exit the authorization pipeline early to avoid rate limiting, csrf tokens, & other security measures. These routes are the DMZ of your API! _You_ **must** secure these end points with alternative methods if accepting input! 61 | 62 | #### Reserved Route 63 | The `/assets/*` route is reserved for the HTML browsable interface assets; please do not try to reuse this for data. 64 | 65 | ### Request Helpers 66 | Tenso decorates `req` with "helpers" such as `req.allow`, `req.csrf`, `req.ip`, `req.parsed`, & `req.private`. `PATCH`, `PUT`, & `POST` payloads are available as `req.body`. Sessions are available as `req.session` when using `local` authentication. 67 | 68 | Tenso decorates `res` with "helpers" such as `res.send()`, `res.status()`, & `res.json()`. 69 | 70 | ## Extensibility 71 | Tenso is extensible, and can be customized with custom parsers, renderers, & serializers. 72 | 73 | ### Parsers 74 | Custom parsers can be registered with `server.parser('mimetype', fn);` or directly on `server.parsers`. The parameters for a parser are `(arg)`. 75 | 76 | Tenso has parsers for: 77 | 78 | - `application/json` 79 | - `application/x-www-form-urlencoded` 80 | - `application/jsonl` 81 | - `application/json-lines` 82 | - `text/json-lines` 83 | 84 | ### Renderers 85 | Custom renderers can be registered with `server.renderer('mimetype', fn);`. The parameters for a renderer are `(req, res, arg)`. 86 | 87 | Tenso has renderers for: 88 | 89 | - `application/javascript` 90 | - `application/json` 91 | - `application/jsonl` 92 | - `application/json-lines` 93 | - `text/json-lines` 94 | - `application/yaml` 95 | - `application/xml` 96 | - `text/csv` 97 | - `text/html` 98 | 99 | ### Serializers 100 | Custom serializers can be registered with `server.serializer('mimetype', fn);`. The parameters for a serializer are `(arg, err, status = 200, stack = false)`. 101 | 102 | Tenso has two default serializers which can be overridden: 103 | 104 | - `plain` for plain text responses 105 | - `custom` for standard response shape 106 | 107 | ```json 108 | { 109 | "data": "`null` or ?", 110 | "error": "`null` or an `Error` stack trace / message", 111 | "links": [], 112 | "status": 200 113 | } 114 | ``` 115 | 116 | ## Responses 117 | Responses will have a standard shape, and will be utf-8 by default. The result will be in `data`. Hypermedia (pagination & links) will be in `links:[ {"uri": "...", "rel": "..."}, ...]`, & also in the `Link` HTTP header. 118 | 119 | Page size can be specified via the `page_size` parameter, e.g. `?page_size=25`. 120 | 121 | Sort order can be specified via then `order-by` which accepts `[field ]asc|desc` & can be combined like an SQL 'ORDER BY', e.g. `?order_by=desc` or `?order_by=lastName%20asc&order_by=firstName%20asc&order_by=age%20desc` 122 | 123 | ## REST / Hypermedia 124 | Hypermedia is a prerequisite of REST, and is best described by the [Richardson Maturity Model](http://martinfowler.com/articles/richardsonMaturityModel.html). Tenso will automagically paginate Arrays of results, or parse Entity representations for keys that imply 125 | relationships, and create the appropriate Objects in the `link` Array, as well as the `Link` HTTP header. Object keys that match this pattern: `/_(guid|uuid|id|uri|url)$/` will be considered 126 | hypermedia links. 127 | 128 | For example, if the key `user_id` was found, it would be mapped to `/users/:id` with a link `rel` of `related`. 129 | 130 | Tenso will bend the rules of REST when using authentication strategies provided by passport.js, or CSRF if is enabled, because they rely on a session. Session storage is in memory, or Redis. You have the option of a stateless or stateful API. 131 | 132 | Hypermedia processing of the response body can be disabled as of `10.2.0`, by setting `req.hypermedia = false` and/or `req.hypermediaHeader` via middleware. 133 | 134 | ## Configuration 135 | This is the default configuration for Tenso, without authentication or SSL. This would be ideal for development, but not production! Enabling SSL is as easy as providing file paths for the two keys. 136 | 137 | Everything is optional! You can provide as much, or as little configuration as you like. 138 | 139 | ``` 140 | { 141 | auth: { 142 | delay: 0, 143 | protect: [], 144 | unprotect: [], 145 | basic: { 146 | enabled: false, 147 | list: [] 148 | }, 149 | bearer: { 150 | enabled: false, 151 | tokens: [] 152 | }, 153 | jwt: { 154 | enabled: false, 155 | auth: null, 156 | audience: EMPTY, 157 | algorithms: [ 158 | "HS256", 159 | "HS384", 160 | "HS512" 161 | ], 162 | ignoreExpiration: false, 163 | issuer: "", 164 | scheme: "bearer", 165 | secretOrKey: "" 166 | }, 167 | msg: { 168 | login: "POST 'username' & 'password' to authenticate" 169 | }, 170 | oauth2: { 171 | enabled: false, 172 | auth: null, 173 | auth_url: "", 174 | token_url: "", 175 | client_id: "", 176 | client_secret: "" 177 | }, 178 | uri: { 179 | login: "/auth/login", 180 | logout: "/auth/logout", 181 | redirect: "/", 182 | root: "/auth" 183 | }, 184 | saml: { 185 | enabled: false, 186 | auth: null 187 | } 188 | }, 189 | autoindex: false, 190 | cacheSize: 1000, 191 | cacheTTL: 300000, 192 | catchAll: true, 193 | charset: "utf-8", 194 | corsExpose: "cache-control, content-language, content-type, expires, last-modified, pragma", 195 | defaultHeaders: { 196 | "content-type": "application/json; charset=utf-8", 197 | "vary": "accept, accept-encoding, accept-language, origin" 198 | }, 199 | digit: 3, 200 | etags: true, 201 | exit: [], 202 | host: "0.0.0.0", 203 | hypermedia: { 204 | enabled: true, 205 | header: true 206 | }, 207 | index: [], 208 | initRoutes: {}, 209 | jsonIndent: 0, 210 | logging: { 211 | enabled: true, 212 | format: "%h %l %u %t \"%r\" %>s %b", 213 | level: "debug", 214 | stack: true 215 | }, 216 | maxBytes: 0, 217 | mimeType: "application/json", 218 | origins: ["*"], 219 | pageSize: 5, 220 | port: 8000, 221 | prometheus: { 222 | enabled: false, 223 | metrics: { 224 | includeMethod: true, 225 | includePath: true, 226 | includeStatusCode: true, 227 | includeUp: true, 228 | buckets: [0.001, 0.01, 0.1, 1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 70, 100, 200], 229 | customLabels: {} 230 | } 231 | }, 232 | rate: { 233 | enabled: false, 234 | limit: 450, 235 | message: "Too many requests", 236 | override: null, 237 | reset: 900, 238 | status: 429 239 | }, 240 | renderHeaders: true, 241 | time: true, 242 | security: { 243 | key: "x-csrf-token", 244 | secret: "", 245 | csrf: true, 246 | csp: null, 247 | xframe: "", 248 | p3p: "", 249 | hsts: null, 250 | xssProtection: true, 251 | nosniff: true 252 | }, 253 | session: { 254 | cookie: { 255 | httpOnly: true, 256 | path: "/", 257 | sameSite: true, 258 | secure: "auto" 259 | }, 260 | name: "tenso.sid", 261 | proxy: true, 262 | redis: { 263 | host: "127.0.0.1", 264 | port: 6379 265 | }, 266 | rolling: true, 267 | resave: true, 268 | saveUninitialized: true, 269 | secret: "tensoABC", 270 | store: "memory" 271 | }, 272 | silent: false, 273 | ssl: { 274 | cert: null, 275 | key: null, 276 | pfx: null 277 | }, 278 | webroot: { 279 | root: "process.cwd()/www", 280 | static: "/assets", 281 | template: "template.html" 282 | } 283 | } 284 | ``` 285 | 286 | ## Authentication 287 | The `protect` Array is the endpoints that will require authentication. The `redirect` String is the end point users will be redirected to upon successfully authenticating, the default is `/`. 288 | 289 | Sessions are used for non `Basic` or `Bearer Token` authentication, and will have `/login`, `/logout`, & custom routes. Redis is supported for session storage. 290 | 291 | Multiple authentication strategies can be enabled at once. 292 | 293 | Authentication attempts have a random delay to deal with "timing attacks"; always rate limit in production environment! 294 | 295 | ### Basic Auth 296 | ``` 297 | { 298 | "auth": { 299 | "basic": { 300 | "enabled": true, 301 | "list": ["username:password", ...], 302 | }, 303 | "protect": ["/"] 304 | } 305 | } 306 | ``` 307 | 308 | ### JSON Web Token 309 | JSON Web Token (JWT) authentication is stateless and does not have an entry point. The `auth(token, callback)` function must verify `token.sub`, and must execute `callback(err, user)`. 310 | 311 | This authentication strategy relies on out-of-band information for the `secret`, and other optional token attributes. 312 | 313 | ``` 314 | { 315 | "auth": { 316 | "jwt": { 317 | "enabled": true, 318 | "auth": function (token, cb) { ... }, /* Authentication handler, to 'find' or 'create' a User */ 319 | "algorithms": [], /* Optional signing algorithms, defaults to ["HS256", "HS384", "HS512"] */ 320 | "audience": "", /* Optional, used to verify `aud` */ 321 | "issuer: "", /* Optional, used to verify `iss` */ 322 | "ignoreExpiration": false, /* Optional, set to `true` to ignore expired tokens */ 323 | "scheme": "Bearer", /* Optional, set to specify the `Authorization` scheme */ 324 | "secretOrKey": "" 325 | } 326 | "protect": ["/private"] 327 | } 328 | } 329 | ``` 330 | 331 | ### OAuth2 332 | OAuth2 authentication will create `/auth`, `/auth/oauth2`, & `/auth/oauth2/callback` routes. `auth(accessToken, refreshToken, profile, callback)` must execute `callback(err, user)`. 333 | 334 | ``` 335 | { 336 | "auth": { 337 | "oauth2": { 338 | "enabled": true, 339 | "auth": function ( ... ) { ... }, /* Authentication handler, to 'find' or 'create' a User */ 340 | "auth_url": "", /* Authorization URL */ 341 | "token_url": "", /* Token URL */ 342 | "client_id": "", /* Get this from authorization server */ 343 | "client_secret": "" /* Get this from authorization server */ 344 | }, 345 | "protect": ["/private"] 346 | } 347 | } 348 | ``` 349 | 350 | ### Oauth2 Bearer Token 351 | ``` 352 | { 353 | "auth": { 354 | "bearer": { 355 | "enabled": true, 356 | "tokens": ["abc", ...] 357 | }, 358 | "protect": ["/"] 359 | } 360 | } 361 | ``` 362 | 363 | ### SAML 364 | SAML authentication will create `/auth`, `/auth/saml`, & `/auth/saml/callback` routes. `auth(profile, callback)` must execute `callback(err, user)`. 365 | 366 | Tenso uses [passport-saml](https://github.com/bergie/passport-saml), for configuration options please visit it's homepage. 367 | 368 | ``` 369 | { 370 | "auth": { 371 | "saml": { 372 | "enabled": true, 373 | ... 374 | }, 375 | "protect": ["/private"] 376 | } 377 | } 378 | ``` 379 | 380 | ## Sessions 381 | Sessions can use a memory (default) or redis store. Memory will limit your sessions to a single server instance, while redis will allow you to share sessions across a cluster of processes, or machines. To use redis, set the `store` property to "redis". 382 | 383 | If the session `secret` is not provided, a version 4 `UUID` will be used. 384 | 385 | ``` 386 | { 387 | "session" : { 388 | cookie: { 389 | httpOnly: true, 390 | path: "/", 391 | sameSite: true, 392 | secure: false 393 | }, 394 | name: "tenso.sid", 395 | proxy: true, 396 | redis: { 397 | host: "127.0.0.1", 398 | port: 6379 399 | }, 400 | rolling: true, 401 | resave: true, 402 | saveUninitialized: true, 403 | secret: "tensoABC", 404 | store: "memory" 405 | } 406 | } 407 | ``` 408 | 409 | 410 | ## Security 411 | Tenso uses [lusca](https://github.com/krakenjs/lusca#api) for security as a middleware. Please see it's documentation for how to configure it; each method & argument is a key:value pair for `security`. 412 | 413 | ``` 414 | { 415 | "security": { ... } 416 | } 417 | ``` 418 | 419 | ## Rate Limiting 420 | Rate limiting is controlled by configuration, and is disabled by default. Rate limiting is based on `token`, `session`, or `ip`, depending upon authentication method. 421 | 422 | Rate limiting can be overridden by providing an `override` function that takes `req` & `rate`, and must return (a modified) `rate`. 423 | 424 | ``` 425 | { 426 | "rate": { 427 | "enabled": true, 428 | "limit": 450, /* Maximum requests allowed before `reset` */ 429 | "reset": 900, /* TTL in seconds */ 430 | "status": 429, /* Optional HTTP status */ 431 | "message": "Too many requests", /* Optional error message */ 432 | "override": function ( req, rate ) { ... } /* Override the default rate limiting */ 433 | } 434 | } 435 | ``` 436 | 437 | ## Limiting upload size 438 | A 'max byte' limit can be enforced on all routes that handle `PATCH`, `POST`, & `PUT` requests. The default limit is 20 KB (20480 B). 439 | 440 | ``` 441 | { 442 | "maxBytes": 5242880 443 | } 444 | ``` 445 | 446 | ## Logging 447 | Standard log levels are supported, and are emitted to `stdout` & `stderr`. Stack traces can be enabled. 448 | 449 | ``` 450 | { 451 | "logging": { 452 | "level": "warn", 453 | "enabled": true, 454 | "stack": true 455 | } 456 | } 457 | ``` 458 | 459 | ## HTML Renderer 460 | The HTML template can be overridden with a custom HTML document. 461 | 462 | Dark mode is supported! The `dark` class will be added to the `body` tag if the user's browser is in dark mode. 463 | 464 | ``` 465 | webroot: { 466 | root: "full path", 467 | static: "folder to serve static assets", 468 | template: "html template" 469 | } 470 | ``` 471 | 472 | ## Serving files 473 | Custom file routes can be created like this: 474 | 475 | ``` 476 | app.files("/folder", "/full/path/to/parent"); 477 | ``` 478 | 479 | ## EventSource streams 480 | Create & cache an `EventSource` stream to send messages to a Client. See [tiny-eventsource](https://github.com/avoidwork/tiny-eventsource) for configuration options: 481 | 482 | ``` 483 | const streams = new Map(); 484 | 485 | ... 486 | 487 | "/stream": (req, res) => { 488 | const id = req.user.userId; 489 | 490 | if (streams.has(id) === false) { 491 | streams.set(id, req.server.eventsource({ms: 3e4), "initialized"); 492 | } 493 | 494 | streams.get(id).init(req, res); 495 | } 496 | 497 | ... 498 | 499 | // Send data to Clients 500 | streams.get(id).send({...}); 501 | ``` 502 | 503 | ## Prometheus 504 | 505 | Prometheus metrics can be enabled by setting `{prometheus: {enabled: true}}`. The metrics will be available at `/metrics`. 506 | 507 | ## Testing 508 | 509 | Tenso has ~80% code coverage with its tests. Test coverage will be added in the future. 510 | 511 | ```console 512 | -----------|---------|----------|---------|---------|------------------------------------------------------------------------------------------------------------------------------- 513 | File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 514 | -----------|---------|----------|---------|---------|------------------------------------------------------------------------------------------------------------------------------- 515 | All files | 78.15 | 54.93 | 68.75 | 78.58 | 516 | tenso.cjs | 78.15 | 54.93 | 68.75 | 78.58 | ...85,1094,1102,1104,1115-1118,1139,1149-1175,1196-1200,1243-1251,1297-1298,1325-1365,1370,1398-1406,1412-1413,1425,1455-1456 517 | -----------|---------|----------|---------|---------|------------------------------------------------------------------------------------------------------------------------------- 518 | ``` 519 | 520 | ## Benchmark 521 | 522 | 1. Clone repository from [GitHub](https://github.com/avoidwork/tenso). 523 | 1. Install dependencies with `npm` or `yarn`. 524 | 1. Execute `benchmark` script with `npm` or `yarn`. 525 | 526 | ## License 527 | Copyright (c) 2024 Jason Mulligan 528 | 529 | Licensed under the BSD-3-Clause license. 530 | -------------------------------------------------------------------------------- /benchmark.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const {join} = require("path"), 4 | {platform} = require("os"), 5 | {readdir} = require("fs").promises, 6 | {spawn} = require("child_process"); 7 | 8 | function shell (arg = "") { 9 | return new Promise((resolve, reject) => { 10 | const args = arg.split(/\s/g), 11 | cmd = args.shift(), 12 | result = [], 13 | eresult = []; 14 | 15 | const ps = spawn(cmd, args, {detached: false, stdio: ["pipe", "pipe", "pipe"], shell: true}); 16 | 17 | ps.stdout.on("data", data => result.push(data.toString())); 18 | ps.stderr.on("data", data => eresult.push(data.toString())); 19 | ps.on("close", code => { 20 | if (code === 0) { 21 | resolve(result.join("\n").split("\n").filter(i => i.includes("[90m")).map(i => i.replace("[1] ", "")).join("\n")); 22 | } else { 23 | reject(new Error(eresult.join("\n"))); 24 | } 25 | }); 26 | }); 27 | } 28 | 29 | (async function () { 30 | const apath = join("node_modules", "autocannon", "autocannon.js"), 31 | cpath = join("node_modules", "concurrently", "bin", "concurrently.js"), 32 | fpath = join(__dirname, "benchmarks"), 33 | files = await readdir(fpath), 34 | sep = platform() === "win32" ? "\\" : "/", 35 | result = []; 36 | 37 | for (const file of files) { 38 | try { 39 | const stdout = await shell(`node ${cpath} -k --success first "node benchmarks${sep}${file}" "node ${apath} -c 100 -d 40 -p 10 localhost:8000"`); 40 | 41 | result.push({file: file.replace(".js", ""), stdout}); 42 | } catch (err) { 43 | console.error(err.stack); 44 | process.exit(1); 45 | } 46 | } 47 | 48 | console.log(result.map(i => `${i.file}\n${i.stdout}\n`).join("\n")); 49 | process.exit(0); 50 | }()); 51 | -------------------------------------------------------------------------------- /benchmarks/http.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const http = require("http"); 4 | 5 | http.createServer((req, res) => { 6 | res.writeHead(200, {"content-type": "application/json; char-set=utf-8"}); 7 | res.end(JSON.stringify("Hello World!")); 8 | }).listen(8000); 9 | -------------------------------------------------------------------------------- /benchmarks/tenso.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | require("../index.js")({ 4 | port: 8000, 5 | routes: { 6 | get: { 7 | "/": "Hello world!" 8 | } 9 | }, 10 | logging: { 11 | enabled: false 12 | }, 13 | security: { 14 | csrf: false, 15 | xssProtection: false, 16 | nosniff: false 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | 4 | export default [ 5 | { 6 | languageOptions: { 7 | globals: { 8 | ...globals.node, 9 | it: true, 10 | describe: true 11 | }, 12 | parserOptions: { 13 | ecmaVersion: 2020 14 | } 15 | }, 16 | rules: { 17 | "arrow-parens": [2, "as-needed"], 18 | "arrow-spacing": [2, {"before": true, "after": true}], 19 | "block-scoped-var": [0], 20 | "brace-style": [2, "1tbs", {"allowSingleLine": true}], 21 | "camelcase": [0], 22 | "comma-dangle": [2, "never"], 23 | "comma-spacing": [2], 24 | "comma-style": [2, "last"], 25 | "complexity": [0, 11], 26 | "consistent-return": [2], 27 | "consistent-this": [0, "that"], 28 | "curly": [2, "multi-line"], 29 | "default-case": [2], 30 | "dot-notation": [2, {"allowKeywords": true}], 31 | "eol-last": [2], 32 | "eqeqeq": [2], 33 | "func-names": [0], 34 | "func-style": [0, "declaration"], 35 | "generator-star-spacing": [2, "after"], 36 | "guard-for-in": [0], 37 | "handle-callback-err": [0], 38 | "indent": ["error", "tab", {"VariableDeclarator": {"var": 1, "let": 1, "const": 1}, "SwitchCase": 1}], 39 | "key-spacing": [2, {"beforeColon": false, "afterColon": true}], 40 | "quotes": [2, "double", "avoid-escape"], 41 | "max-depth": [0, 4], 42 | "max-len": [0, 80, 4], 43 | "max-nested-callbacks": [0, 2], 44 | "max-params": [0, 3], 45 | "max-statements": [0, 10], 46 | "new-parens": [2], 47 | "new-cap": [2, {"capIsNewExceptions": ["ToInteger", "ToObject", "ToPrimitive", "ToUint32"]}], 48 | "newline-after-var": [0], 49 | "newline-before-return": [2], 50 | "no-alert": [2], 51 | "no-array-constructor": [2], 52 | "no-bitwise": [0], 53 | "no-caller": [2], 54 | "no-catch-shadow": [2], 55 | "no-cond-assign": [2], 56 | "no-console": [0], 57 | "no-constant-condition": [1], 58 | "no-continue": [2], 59 | "no-control-regex": [2], 60 | "no-debugger": [2], 61 | "no-delete-var": [2], 62 | "no-div-regex": [0], 63 | "no-dupe-args": [2], 64 | "no-dupe-keys": [2], 65 | "no-duplicate-case": [2], 66 | "no-else-return": [0], 67 | "no-empty": [2], 68 | "no-eq-null": [0], 69 | "no-eval": [2], 70 | "no-ex-assign": [2], 71 | "no-extend-native": [1], 72 | "no-extra-bind": [2], 73 | "no-extra-boolean-cast": [2], 74 | "no-extra-semi": [1], 75 | "no-empty-character-class": [2], 76 | "no-fallthrough": [2], 77 | "no-floating-decimal": [2], 78 | "no-func-assign": [2], 79 | "no-implied-eval": [2], 80 | "no-inline-comments": [0], 81 | "no-inner-declarations": [2, "functions"], 82 | "no-invalid-regexp": [2], 83 | "no-irregular-whitespace": [2], 84 | "no-iterator": [2], 85 | "no-label-var": [2], 86 | "no-labels": [2], 87 | "no-lone-blocks": [2], 88 | "no-lonely-if": [2], 89 | "no-loop-func": [2], 90 | "no-mixed-requires": [0, false], 91 | "no-mixed-spaces-and-tabs": [2, false], 92 | "no-multi-spaces": [2], 93 | "no-multi-str": [2], 94 | "no-multiple-empty-lines": [2, {"max": 2}], 95 | "no-native-reassign": [0], 96 | "no-negated-in-lhs": [2], 97 | "no-nested-ternary": [0], 98 | "no-new": [2], 99 | "no-new-func": [0], 100 | "no-new-object": [2], 101 | "no-new-require": [0], 102 | "no-new-wrappers": [2], 103 | "no-obj-calls": [2], 104 | "no-octal": [2], 105 | "no-octal-escape": [2], 106 | "no-param-reassign": [0], 107 | "no-path-concat": [0], 108 | "no-plusplus": [0], 109 | "no-process-env": [0], 110 | "no-process-exit": [0], 111 | "no-proto": [2], 112 | "no-redeclare": [2], 113 | "no-regex-spaces": [2], 114 | "no-reserved-keys": [0], 115 | "no-reno-new-funced-modules": [0], 116 | "no-return-assign": [2], 117 | "no-script-url": [2], 118 | "no-self-compare": [0], 119 | "no-sequences": [2], 120 | "no-shadow": [2], 121 | "no-shadow-restricted-names": [2], 122 | "no-spaced-func": [2], 123 | "no-sparse-arrays": [2], 124 | "no-sync": [0], 125 | "no-ternary": [0], 126 | "no-throw-literal": [2], 127 | "no-trailing-spaces": [2], 128 | "no-undef": [2], 129 | "no-undef-init": [2], 130 | "no-undefined": [0], 131 | "no-underscore-dangle": [0], 132 | "no-unreachable": [2], 133 | "no-unused-expressions": [2], 134 | "no-unused-vars": [2, {"vars": "all", "args": "after-used"}], 135 | "no-use-before-define": [2], 136 | "no-void": [0], 137 | "no-warning-comments": [0, {"terms": ["todo", "fixme", "xxx"], "location": "start"}], 138 | "no-with": [2], 139 | "no-extra-parens": [2], 140 | "one-var": [0], 141 | "operator-assignment": [0, "always"], 142 | "operator-linebreak": [2, "after"], 143 | "padded-blocks": [0], 144 | "quote-props": [0], 145 | "radix": [0], 146 | "semi": [2], 147 | "semi-spacing": [2, {before: false, after: true}], 148 | "sort-vars": [0], 149 | "keyword-spacing": [2], 150 | "space-before-function-paren": [2, {anonymous: "always", named: "always"}], 151 | "space-before-blocks": [2, "always"], 152 | "space-in-brackets": [0, "never", { 153 | singleValue: true, 154 | arraysInArrays: false, 155 | arraysInObjects: false, 156 | objectsInArrays: true, 157 | objectsInObjects: true, 158 | propertyName: false 159 | }], 160 | "space-in-parens": [2, "never"], 161 | "space-infix-ops": [2], 162 | "space-unary-ops": [2, {words: true, nonwords: false}], 163 | "spaced-line-comment": [0, "always"], 164 | strict: [0], 165 | "use-isnan": [2], 166 | "valid-jsdoc": [0], 167 | "valid-typeof": [2], 168 | "vars-on-top": [0], 169 | "wrap-iife": [2], 170 | "wrap-regex": [2], 171 | yoda: [2, "never", {exceptRange: true}] 172 | } 173 | }, 174 | pluginJs.configs.recommended 175 | ]; 176 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tenso", 3 | "description": "Tenso is an HTTP REST API framework", 4 | "version": "17.2.4", 5 | "homepage": "https://github.com/avoidwork/tenso", 6 | "author": "Jason Mulligan ", 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/avoidwork/tenso.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/avoidwork/tenso/issues" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "engineStrict": true, 16 | "engines": { 17 | "node": ">=17.0.0" 18 | }, 19 | "scripts": { 20 | "benchmark": "node benchmark.js", 21 | "build": "npm run lint && npm run rollup", 22 | "changelog": "auto-changelog -p", 23 | "sample": "node sample.js", 24 | "lint": "eslint *.js src/**/*.js test/*.js", 25 | "fix": "eslint --fix *.js src/**/*.js test/*.js", 26 | "mocha": "nyc mocha test/*.test.js", 27 | "rollup": "rollup --config", 28 | "test": "npm run lint && npm run mocha", 29 | "prepare": "husky", 30 | "types": "npx -p typescript tsc src/tenso.js --declaration --allowJs --emitDeclarationOnly --outDir types" 31 | }, 32 | "source": "src/tenso.js", 33 | "main": "dist/tenso.cjs", 34 | "exports": { 35 | "types": "./types/tenso.d.ts", 36 | "import": "./dist/tenso.js", 37 | "require": "./dist/tenso.cjs" 38 | }, 39 | "type": "module", 40 | "types": "types/**/*.d.ts", 41 | "files": [ 42 | "dist/tenso.cjs", 43 | "dist/tenso.js", 44 | "types/tenso.d.ts", 45 | "www/template.html", 46 | "www/assets" 47 | ], 48 | "dependencies": { 49 | "connect-redis": "^7.1.1", 50 | "cookie-parser": "^1.4.6", 51 | "csv-stringify": "^6.5.1", 52 | "express-prom-bundle": "^8.0.0", 53 | "express-session": "^1.18.0", 54 | "fast-xml-parser": "^5.0.6", 55 | "ioredis": "^5.4.1", 56 | "jsonwebtoken": "^9.0.2", 57 | "keysort": "^3.0.1", 58 | "lusca": "^1.7.0", 59 | "mime-db": "^1.53.0", 60 | "passport": "^0.7.0", 61 | "passport-http": "^0.3.0", 62 | "passport-http-bearer": "^1.0.1", 63 | "passport-jwt": "^4.0.1", 64 | "passport-oauth2": "^1.8.0", 65 | "precise": "^4.0.3", 66 | "tiny-coerce": "^3.0.2", 67 | "tiny-etag": "^4.0.5", 68 | "tiny-eventsource": "^3.0.8", 69 | "tiny-jsonl": "^3.0.2", 70 | "tiny-merge": "^2.0.0", 71 | "woodland": "^20.1.2", 72 | "yamljs": "^0.3.0", 73 | "yargs-parser": "^22.0.0" 74 | }, 75 | "devDependencies": { 76 | "auto-changelog": "^2.5.0", 77 | "autocannon": "^8.0.0", 78 | "concurrently": "^9.0.1", 79 | "csv-parse": "^5.5.6", 80 | "eslint": "^9.12.0", 81 | "husky": "^9.1.6", 82 | "mocha": "^11.0.1", 83 | "nyc": "^17.1.0", 84 | "rollup": "^4.24.0", 85 | "tiny-httptest": "^4.0.13", 86 | "typescript": "^5.6.2" 87 | }, 88 | "keywords": [ 89 | "rest", 90 | "api", 91 | "cqrs", 92 | "gateway", 93 | "server", 94 | "hypermedia", 95 | "framework", 96 | "http", 97 | "https" 98 | ] 99 | } 100 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import {createRequire} from "node:module"; 2 | 3 | const require = createRequire(import.meta.url); 4 | const pkg = require("./package.json"); 5 | const year = new Date().getFullYear(); 6 | const bannerLong = `/** 7 | * ${pkg.name} 8 | * 9 | * @copyright ${year} ${pkg.author} 10 | * @license ${pkg.license} 11 | * @version ${pkg.version} 12 | */`; 13 | const defaultOutBase = {compact: true, banner: bannerLong, name: pkg.name}; 14 | const cjOutBase = {...defaultOutBase, compact: false, format: "cjs", exports: "named"}; 15 | const esmOutBase = {...defaultOutBase, format: "esm"}; 16 | 17 | export default [ 18 | { 19 | input: `./src/${pkg.name}.js`, 20 | output: [ 21 | { 22 | ...cjOutBase, 23 | file: `dist/${pkg.name}.cjs` 24 | }, 25 | { 26 | ...esmOutBase, 27 | file: `dist/${pkg.name}.js` 28 | } 29 | ], 30 | external: [ 31 | "node:crypto", 32 | "node:fs", 33 | "node:module", 34 | "node:path", 35 | "node:url", 36 | "woodland", 37 | "tiny-eventsource", 38 | "node:http", 39 | "node:https", 40 | "tiny-coerce", 41 | "yamljs", 42 | "url", 43 | "keysort", 44 | "cookie-parser", 45 | "redis", 46 | "express-session", 47 | "passport", 48 | "passport-http", 49 | "passport-http-bearer", 50 | "passport-local", 51 | "passport-oauth2", 52 | "passport-jwt", 53 | "ioredis", 54 | "csv-stringify/sync", 55 | "fast-xml-parser", 56 | "tiny-jsonl", 57 | "lusca", 58 | "connect-redis", 59 | "tiny-merge", 60 | "express-prom-bundle" 61 | ] 62 | } 63 | ]; 64 | -------------------------------------------------------------------------------- /sample.js: -------------------------------------------------------------------------------- 1 | import {tenso} from "./dist/tenso.js"; 2 | 3 | export const app = tenso(); 4 | 5 | app.get("/", "Hello, World!"); 6 | app.start(); 7 | -------------------------------------------------------------------------------- /src/core/config.js: -------------------------------------------------------------------------------- 1 | import { 2 | AUTO, 3 | BEARER, 4 | COOKIE_NAME, 5 | DEBUG, 6 | DEFAULT_CONTENT_TYPE, 7 | DEFAULT_VARY, 8 | EMPTY, 9 | EXPOSE_HEADERS, 10 | HEADER_APPLICATION_JSON, 11 | HEADER_CONTENT_TYPE, 12 | HEADER_VARY, 13 | HS256, 14 | HS384, 15 | HS512, 16 | INT_0, 17 | INT_1000, 18 | INT_3, 19 | INT_300000, 20 | INT_429, 21 | INT_450, 22 | INT_5, 23 | INT_6379, 24 | INT_8000, 25 | INT_900, 26 | IP_0000, 27 | IP_127001, 28 | LOG_FORMAT, 29 | MEMORY, 30 | MSG_LOGIN, 31 | MSG_TOO_MANY_REQUESTS, 32 | PATH_ASSETS, 33 | SAMEORIGIN, 34 | SESSION_SECRET, 35 | SLASH, 36 | TENSO, 37 | URL_AUTH_LOGIN, 38 | URL_AUTH_LOGOUT, 39 | URL_AUTH_ROOT, 40 | UTF_8, 41 | WILDCARD, 42 | X_CSRF_TOKEN 43 | } from "./constants.js"; 44 | 45 | export const config = { 46 | auth: { 47 | delay: INT_0, 48 | protect: [], 49 | unprotect: [], 50 | basic: { 51 | enabled: false, 52 | list: [] 53 | }, 54 | bearer: { 55 | enabled: false, 56 | tokens: [] 57 | }, 58 | jwt: { 59 | enabled: false, 60 | auth: null, 61 | audience: EMPTY, 62 | algorithms: [ 63 | HS256, 64 | HS384, 65 | HS512 66 | ], 67 | ignoreExpiration: false, 68 | issuer: EMPTY, 69 | scheme: BEARER, 70 | secretOrKey: EMPTY 71 | }, 72 | msg: { 73 | login: MSG_LOGIN 74 | }, 75 | oauth2: { 76 | enabled: false, 77 | auth: null, 78 | auth_url: EMPTY, 79 | token_url: EMPTY, 80 | client_id: EMPTY, 81 | client_secret: EMPTY 82 | }, 83 | uri: { 84 | login: URL_AUTH_LOGIN, 85 | logout: URL_AUTH_LOGOUT, 86 | redirect: SLASH, 87 | root: URL_AUTH_ROOT 88 | }, 89 | saml: { 90 | enabled: false, 91 | auth: null 92 | } 93 | }, 94 | autoindex: false, 95 | cacheSize: INT_1000, 96 | cacheTTL: INT_300000, 97 | catchAll: true, 98 | charset: UTF_8, 99 | corsExpose: EXPOSE_HEADERS, 100 | defaultHeaders: { 101 | [HEADER_CONTENT_TYPE]: DEFAULT_CONTENT_TYPE, 102 | [HEADER_VARY]: DEFAULT_VARY 103 | }, 104 | digit: INT_3, 105 | etags: true, 106 | exit: [], 107 | host: IP_0000, 108 | hypermedia: { 109 | enabled: true, 110 | header: true 111 | }, 112 | index: [], 113 | initRoutes: {}, 114 | jsonIndent: INT_0, 115 | logging: { 116 | enabled: true, 117 | format: LOG_FORMAT, 118 | level: DEBUG, 119 | stack: true 120 | }, 121 | maxBytes: INT_0, 122 | mimeType: HEADER_APPLICATION_JSON, 123 | origins: [WILDCARD], 124 | pageSize: INT_5, 125 | port: INT_8000, 126 | prometheus: { 127 | enabled: false, 128 | metrics: { 129 | includeMethod: true, 130 | includePath: true, 131 | includeStatusCode: true, 132 | includeUp: true, 133 | buckets: [0.001, 0.01, 0.1, 1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 70, 100, 200], 134 | customLabels: {} 135 | } 136 | }, 137 | rate: { 138 | enabled: false, 139 | limit: INT_450, 140 | message: MSG_TOO_MANY_REQUESTS, 141 | override: null, 142 | reset: INT_900, 143 | status: INT_429 144 | }, 145 | renderHeaders: true, 146 | time: true, 147 | security: { 148 | key: X_CSRF_TOKEN, 149 | secret: TENSO, 150 | csrf: true, 151 | csp: null, 152 | xframe: SAMEORIGIN, 153 | p3p: EMPTY, 154 | hsts: null, 155 | xssProtection: true, 156 | nosniff: true 157 | }, 158 | session: { 159 | cookie: { 160 | httpOnly: true, 161 | path: SLASH, 162 | sameSite: true, 163 | secure: AUTO 164 | }, 165 | name: COOKIE_NAME, 166 | proxy: true, 167 | redis: { 168 | host: IP_127001, 169 | port: INT_6379 170 | }, 171 | rolling: true, 172 | resave: true, 173 | saveUninitialized: true, 174 | secret: SESSION_SECRET, 175 | store: MEMORY 176 | }, 177 | silent: false, 178 | ssl: { 179 | cert: null, 180 | key: null, 181 | pfx: null 182 | }, 183 | webroot: { 184 | root: EMPTY, 185 | static: PATH_ASSETS, 186 | template: EMPTY 187 | } 188 | }; 189 | -------------------------------------------------------------------------------- /src/core/constants.js: -------------------------------------------------------------------------------- 1 | export const ACCESS_CONTROL = "access-control"; 2 | export const ALGORITHMS = "algorithms"; 3 | export const ALLOW = "allow"; 4 | export const AUDIENCE = "audience"; 5 | export const AUTH = "auth"; 6 | export const AUTO = "auto"; 7 | export const BASIC = "basic"; 8 | export const BEARER = "Bearer"; 9 | export const BOOLEAN = "boolean"; 10 | export const CACHE_CONTROL = "cache-control"; 11 | export const CALLBACK = "callback"; 12 | export const CHARSET_UTF8 = "; charset=utf-8"; 13 | export const COLLECTION = "collection"; 14 | export const COLON = ":"; 15 | export const COMMA = ","; 16 | export const COMMA_SPACE = ", "; 17 | export const CONNECT = "connect"; 18 | export const COOKIE_NAME = "tenso.sid"; 19 | export const DATA = "data"; 20 | export const DEBUG = "debug"; 21 | export const DEFAULT_CONTENT_TYPE = "application/json; charset=utf-8"; 22 | export const DEFAULT_VARY = "accept, accept-encoding, accept-language, origin"; 23 | export const DELETE = "DELETE"; 24 | export const DESC = "desc"; 25 | export const DOUBLE_SLASH = "//"; 26 | export const EMPTY = ""; 27 | export const ENCODED_SPACE = "%20"; 28 | export const END = "end"; 29 | export const EQ = "="; 30 | export const ERROR = "error"; 31 | export const EXPOSE = "expose"; 32 | export const EXPOSE_HEADERS = "cache-control, content-language, content-type, expires, last-modified, pragma"; 33 | export const FALSE = "false"; 34 | export const FIRST = "first"; 35 | export const FORMAT = "format"; 36 | export const FUNCTION = "function"; 37 | export const G = "g"; 38 | export const GET = "GET"; 39 | export const GT = ">"; 40 | export const HEAD = "HEAD"; 41 | export const HEADERS = "headers"; 42 | export const HEADER_ALLOW_GET = "GET, HEAD, OPTIONS"; 43 | export const HEADER_APPLICATION_JAVASCRIPT = "application/javascript"; 44 | export const HEADER_APPLICATION_JSON = "application/json"; 45 | export const HEADER_APPLICATION_JSONL = "application/jsonl"; 46 | export const HEADER_APPLICATION_JSON_LINES = "application/json-lines"; 47 | export const HEADER_APPLICATION_XML = "application/xml"; 48 | export const HEADER_APPLICATION_X_WWW_FORM_URLENCODED = "application/x-www-form-urlencoded"; 49 | export const HEADER_APPLICATION_YAML = "application/yaml"; 50 | export const HEADER_CONTENT_DISPOSITION = "content-disposition"; 51 | export const HEADER_CONTENT_DISPOSITION_VALUE = "attachment; filename=\"download.csv\""; 52 | export const HEADER_CONTENT_TYPE = "content-type"; 53 | export const HEADER_SPLIT = "\" <"; 54 | export const HEADER_TEXT_CSV = "text/csv"; 55 | export const HEADER_TEXT_HTML = "text/html"; 56 | export const HEADER_TEXT_JSON_LINES = "text/json-lines"; 57 | export const HEADER_TEXT_PLAIN = "text/plain"; 58 | export const HEADER_VARY = "vary"; 59 | export const HS256 = "HS256"; 60 | export const HS384 = "HS384"; 61 | export const HS512 = "HS512"; 62 | export const HTML = "html"; 63 | export const HYPHEN = "-"; 64 | export const I = "i"; 65 | export const ID = "id"; 66 | export const IDENT_VAR = "indent="; 67 | export const ID_2 = "_id"; 68 | export const IE = "ie"; 69 | export const INT_0 = 0; 70 | export const INT_1 = 1; 71 | export const INT_10 = 10; 72 | export const INT_100 = 1e2; 73 | export const INT_1000 = 1e3; 74 | export const INT_2 = 2; 75 | export const INT_200 = 2e2; 76 | export const INT_204 = 204; 77 | export const INT_206 = 206; 78 | export const INT_3 = 3; 79 | export const INT_300000 = 3e5; 80 | export const INT_304 = 304; 81 | export const INT_400 = 4e2; 82 | export const INT_401 = 401; 83 | export const INT_413 = 413; 84 | export const INT_429 = 429; 85 | export const INT_443 = 443; 86 | export const INT_450 = 450; 87 | export const INT_5 = 5; 88 | export const INT_500 = 5e2; 89 | export const INT_6379 = 6379; 90 | export const INT_80 = 80; 91 | export const INT_8000 = 8e3; 92 | export const INT_900 = 9e2; 93 | export const INT_NEG_1 = -1; 94 | export const INVALID_CONFIGURATION = "Invalid configuration"; 95 | export const IP_0000 = "0.0.0.0"; 96 | export const IP_127001 = "127.0.0.1"; 97 | export const ISSUER = "issuer"; 98 | export const ITEM = "item"; 99 | export const JWT = "jwt"; 100 | export const LAST = "last"; 101 | export const LT = "<"; 102 | export const LINK = "link"; 103 | export const LOG_FORMAT = "%h %l %u %t \"%r\" %>s %b"; 104 | export const MEMORY = "memory"; 105 | export const METRICS_PATH = "/metrics"; 106 | export const MSG_LOGIN = "POST 'username' & 'password' to authenticate"; 107 | export const MSG_PROMETHEUS_ENABLED = "Prometheus metrics enabled"; 108 | export const MSG_TOO_MANY_REQUESTS = "Too many requests"; 109 | export const MULTIPART = "multipart"; 110 | export const NEXT = "next"; 111 | export const NL = "\n"; 112 | export const NULL = "null"; 113 | export const NUMBER = "number"; 114 | export const OAUTH2 = "oauth2"; 115 | export const OPTIONS = "OPTIONS"; 116 | export const ORDER_BY = "order_by"; 117 | export const PAGE = "page"; 118 | export const PAGE_SIZE = "page_size"; 119 | export const PATCH = "PATCH"; 120 | export const PATH_ASSETS = "/assets"; 121 | export const PERIOD = "."; 122 | export const PIPE = "|"; 123 | export const POST = "POST"; 124 | export const PREV = "prev"; 125 | export const PREV_DIR = ".."; 126 | export const PRIVATE = "private"; 127 | export const PROTECT = "protect"; 128 | export const PUT = "PUT"; 129 | export const READ = "read"; 130 | export const REDIS = "redis"; 131 | export const REGEX_REPLACE = ")).*$"; 132 | export const RELATED = "related"; 133 | export const REL_URI = "rel, uri"; 134 | export const RETRY_AFTER = "retry-after"; 135 | export const S = "s"; 136 | export const SAMEORIGIN = "SAMEORIGIN"; 137 | export const SESSION_SECRET = "tensoABC"; 138 | export const SIGHUP = "SIGHUP"; 139 | export const SIGINT = "SIGINT"; 140 | export const SIGTERM = "SIGTERM"; 141 | export const SLASH = "/"; 142 | export const SPACE = " "; 143 | export const STRING = "string"; 144 | export const TEMPLATE_ALLOW = "{{allow}}"; 145 | export const TEMPLATE_BODY = "{{body}}"; 146 | export const TEMPLATE_CSRF = "{{csrf}}"; 147 | export const TEMPLATE_FILE = "template.html"; 148 | export const TEMPLATE_FORMATS = "{{formats}}"; 149 | export const TEMPLATE_HEADERS = "{{headers}}"; 150 | export const TEMPLATE_METHODS = "{{methods}}"; 151 | export const TEMPLATE_TITLE = "{{title}}"; 152 | export const TEMPLATE_URL = "{{url}}"; 153 | export const TEMPLATE_VERSION = "{{version}}"; 154 | export const TEMPLATE_YEAR = "{{year}}"; 155 | export const TENSO = "tenso"; 156 | export const TRUE = "true"; 157 | export const UNDEFINED = "undefined"; 158 | export const UNDERSCORE = "_"; 159 | export const UNPROTECT = "unprotect"; 160 | export const URI = "uri"; 161 | export const URI_SCHEME = "://"; 162 | export const URL_127001 = "http://127.0.0.1"; 163 | export const URL_AUTH_LOGIN = "/auth/login"; 164 | export const URL_AUTH_LOGOUT = "/auth/logout"; 165 | export const URL_AUTH_ROOT = "/auth"; 166 | export const UTF8 = "utf8"; 167 | export const UTF_8 = "utf-8"; 168 | export const WILDCARD = "*"; 169 | export const WWW = "www"; 170 | export const XML_ARRAY_NODE_NAME = "item"; 171 | export const XML_PROLOG = ""; 172 | export const X_CSRF_TOKEN = "x-csrf-token"; 173 | export const X_FORWARDED_PROTO = "x-forwarded-proto"; 174 | export const X_POWERED_BY = "x-powered-by"; 175 | export const X_RATELIMIT_LIMIT = "x-ratelimit-limit"; 176 | export const X_RATELIMIT_REMAINING = "x-ratelimit-remaining"; 177 | export const X_RATELIMIT_RESET = "x-ratelimit-reset"; 178 | -------------------------------------------------------------------------------- /src/middleware/asyncFlag.js: -------------------------------------------------------------------------------- 1 | export function asyncFlag (req, res, next) { 2 | req.protectAsync = true; 3 | next(); 4 | } 5 | -------------------------------------------------------------------------------- /src/middleware/bypass.js: -------------------------------------------------------------------------------- 1 | import {OPTIONS} from "../core/constants.js"; 2 | 3 | export function bypass (req, res, next) { 4 | req.unprotect = req.cors && req.method === OPTIONS || req.server.auth.unprotect.some(i => i.test(req.url)); 5 | next(); 6 | } 7 | -------------------------------------------------------------------------------- /src/middleware/csrf.js: -------------------------------------------------------------------------------- 1 | import lusca from "lusca"; 2 | 3 | let memoized = false, 4 | cachedFn, cachedKey; 5 | 6 | export function csrfWrapper (req, res, next) { 7 | if (memoized === false) { 8 | cachedKey = req.server.security.key; 9 | cachedFn = lusca.csrf({key: cachedKey, secret: req.server.security.secret}); 10 | memoized = true; 11 | } 12 | 13 | if (req.unprotect) { 14 | next(); 15 | } else { 16 | cachedFn(req, res, err => { 17 | if (err === void 0 && req.csrf && cachedKey in res.locals) { 18 | res.header(req.server.security.key, res.locals[cachedKey]); 19 | } 20 | 21 | next(err); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/middleware/exit.js: -------------------------------------------------------------------------------- 1 | export function exit (req, res, next) { 2 | if (req.server.exit.includes(req.url)) { 3 | req.exit(); 4 | } else { 5 | next(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/middleware/guard.js: -------------------------------------------------------------------------------- 1 | import {INT_401} from "../core/constants.js"; 2 | 3 | export function guard (req, res, next) { 4 | const login = req.server.auth.uri.login; 5 | 6 | if (req.url === login || req.isAuthenticated()) { 7 | next(); 8 | } else { 9 | res.error(INT_401); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/middleware/parse.js: -------------------------------------------------------------------------------- 1 | import {EMPTY, HEADER_CONTENT_TYPE, INT_0} from "../core/constants.js"; 2 | 3 | export function parse (req, res, next) { 4 | let valid = true, 5 | exception; 6 | 7 | if (req.body !== EMPTY) { 8 | const type = req.headers?.[HEADER_CONTENT_TYPE]?.replace(/\s.*$/, EMPTY) ?? EMPTY; 9 | const parsers = req.server.parsers; 10 | 11 | if (type.length > INT_0 && parsers.has(type)) { 12 | try { 13 | req.body = parsers.get(type)(req.body); 14 | } catch (err) { 15 | valid = false; 16 | exception = err; 17 | } 18 | } 19 | } 20 | 21 | next(valid === false ? exception : void 0); 22 | } 23 | -------------------------------------------------------------------------------- /src/middleware/payload.js: -------------------------------------------------------------------------------- 1 | import {DATA, EMPTY, END, HEADER_CONTENT_TYPE, INT_0, INT_413, MULTIPART, UTF8} from "../core/constants.js"; 2 | import {hasBody} from "../utils/hasBody.js"; 3 | 4 | export function payload (req, res, next) { 5 | if (hasBody(req.method) && req.headers?.[HEADER_CONTENT_TYPE]?.includes(MULTIPART) === false) { 6 | const max = req.server.maxBytes; 7 | let body = EMPTY, 8 | invalid = false; 9 | 10 | req.setEncoding(UTF8); 11 | 12 | req.on(DATA, data => { 13 | if (invalid === false) { 14 | body += data; 15 | 16 | if (max > INT_0 && Buffer.byteLength(body) > max) { 17 | invalid = true; 18 | res.error(INT_413); 19 | } 20 | } 21 | }); 22 | 23 | req.on(END, () => { 24 | if (invalid === false) { 25 | req.body = body; 26 | next(); 27 | } 28 | }); 29 | } else { 30 | next(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/middleware/prometheus.js: -------------------------------------------------------------------------------- 1 | import promBundle from "express-prom-bundle"; 2 | 3 | // Prometheus metrics 4 | export const prometheus = config => promBundle(config); 5 | -------------------------------------------------------------------------------- /src/middleware/rate.js: -------------------------------------------------------------------------------- 1 | import {INT_429, RETRY_AFTER, X_RATELIMIT_LIMIT, X_RATELIMIT_REMAINING, X_RATELIMIT_RESET} from "../core/constants.js"; 2 | 3 | const rateHeaders = [ 4 | X_RATELIMIT_LIMIT, 5 | X_RATELIMIT_REMAINING, 6 | X_RATELIMIT_RESET 7 | ]; 8 | 9 | export function rate (req, res, next) { 10 | const config = req.server.rate; 11 | 12 | if (config.enabled === false || req.unprotect) { 13 | next(); 14 | } else { 15 | const results = req.server.rateLimit(req, config.override), 16 | good = results.shift(); 17 | 18 | if (good) { 19 | for (const [idx, i] of rateHeaders.entries()) { 20 | res.header(i, results[idx]); 21 | } 22 | 23 | next(); 24 | } else { 25 | res.header(RETRY_AFTER, config.reset); 26 | res.error(config.status || INT_429); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/middleware/redirect.js: -------------------------------------------------------------------------------- 1 | export function redirect (req, res) { 2 | res.redirect(req.server.auth.uri.redirect, false); 3 | } 4 | -------------------------------------------------------------------------------- /src/middleware/zuul.js: -------------------------------------------------------------------------------- 1 | import {rate} from "./rate.js"; 2 | 3 | export function zuul (req, res, next) { 4 | const uri = req.url; 5 | let protect = false; 6 | 7 | if (req.unprotect === false) { 8 | for (const i of req.server.auth.protect) { 9 | if (i.test(uri)) { 10 | protect = true; 11 | break; 12 | } 13 | } 14 | } 15 | 16 | // Setting state so the connection can be terminated properly 17 | req.protect = protect; 18 | req.protectAsync = false; 19 | 20 | rate(req, res, e => { 21 | if (e !== void 0) { 22 | res.error(e); 23 | } else if (protect) { 24 | next(); 25 | } else { 26 | req.exit(); 27 | } 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /src/parsers/json.js: -------------------------------------------------------------------------------- 1 | import {EMPTY} from "../core/constants.js"; 2 | 3 | export function json (arg = EMPTY) { 4 | return JSON.parse(arg); 5 | } 6 | -------------------------------------------------------------------------------- /src/parsers/jsonl.js: -------------------------------------------------------------------------------- 1 | import {parse} from "tiny-jsonl"; 2 | import {EMPTY} from "../core/constants.js"; 3 | 4 | export function jsonl (arg = EMPTY) { 5 | return parse(arg); 6 | } 7 | -------------------------------------------------------------------------------- /src/parsers/xWwwFormURLEncoded.js: -------------------------------------------------------------------------------- 1 | import {coerce} from "tiny-coerce"; 2 | import {bodySplit} from "../utils/regex.js"; 3 | import {ENCODED_SPACE, INT_0, INT_1, INT_2} from "../core/constants.js"; 4 | import {chunk} from "../utils/chunk.js"; 5 | 6 | export function xWwwFormURLEncoded (arg) { 7 | const args = arg ? chunk(arg.split(bodySplit), INT_2) : [], 8 | result = {}; 9 | 10 | for (const i of args) { 11 | result[decodeURIComponent(i[INT_0].replace(/\+/g, ENCODED_SPACE))] = coerce(decodeURIComponent(i[INT_1].replace(/\+/g, ENCODED_SPACE))); 12 | } 13 | 14 | return result; 15 | } 16 | -------------------------------------------------------------------------------- /src/renderers/csv.js: -------------------------------------------------------------------------------- 1 | import {stringify} from "csv-stringify/sync"; 2 | import {COMMA, FALSE, HEADER_CONTENT_DISPOSITION, HEADER_CONTENT_DISPOSITION_VALUE, TRUE} from "../core/constants.js"; 3 | 4 | export function csv (req, res, arg) { 5 | const filename = req.url.split("/").pop().split(".")[0]; 6 | const input = res.statusCode < 400 ? Array.isArray(arg) ? arg : [arg] : [{Error: arg}]; 7 | 8 | res.header(HEADER_CONTENT_DISPOSITION, HEADER_CONTENT_DISPOSITION_VALUE.replace("download", filename)); 9 | 10 | return stringify(input, { 11 | cast: { 12 | boolean: value => value ? TRUE : FALSE, 13 | date: value => value.toISOString(), 14 | number: value => value.toString() 15 | }, 16 | delimiter: COMMA, 17 | header: true, 18 | quoted: false 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/renderers/html.js: -------------------------------------------------------------------------------- 1 | import { 2 | COLON, 3 | EMPTY, 4 | G, 5 | HEADER_ALLOW_GET, 6 | HTML, 7 | INT_0, 8 | INT_2, 9 | INT_NEG_1, 10 | NL, 11 | TEMPLATE_ALLOW, 12 | TEMPLATE_BODY, 13 | TEMPLATE_CSRF, 14 | TEMPLATE_FORMATS, 15 | TEMPLATE_HEADERS, 16 | TEMPLATE_METHODS, 17 | TEMPLATE_TITLE, 18 | TEMPLATE_URL, 19 | TEMPLATE_VERSION, 20 | TEMPLATE_YEAR, 21 | X_CSRF_TOKEN, 22 | X_FORWARDED_PROTO 23 | } from "../core/constants.js"; 24 | import {explode} from "../utils/explode.js"; 25 | import {sanitize} from "../utils/sanitize.js"; 26 | import {renderers} from "../utils/renderers.js"; 27 | 28 | export function html (req, res, arg, tpl = EMPTY) { 29 | const protocol = X_FORWARDED_PROTO in req.headers ? req.headers[X_FORWARDED_PROTO] + COLON : req.parsed.protocol, 30 | headers = res.getHeaders(); 31 | 32 | return tpl.length > INT_0 ? tpl.replace(new RegExp(TEMPLATE_TITLE, G), req.server.title) 33 | .replace(TEMPLATE_URL, req.parsed.href.replace(req.parsed.protocol, protocol)) 34 | .replace(TEMPLATE_HEADERS, Object.keys(headers).sort().map(i => `${i}${sanitize(headers[i])}`).join(NL)) 35 | .replace(TEMPLATE_FORMATS, `${Array.from(renderers.keys()).filter(i => i.indexOf(HTML) === INT_NEG_1).map(i => ``).join(NL)}`) 36 | .replace(TEMPLATE_BODY, sanitize(JSON.stringify(arg, null, INT_2))) 37 | .replace(TEMPLATE_YEAR, new Date().getFullYear()) 38 | .replace(TEMPLATE_VERSION, req.server.version) 39 | .replace(TEMPLATE_ALLOW, headers.allow) 40 | .replace(TEMPLATE_METHODS, explode((headers?.allow ?? EMPTY).replace(HEADER_ALLOW_GET, EMPTY)).filter(i => i !== EMPTY).map(i => ``).join(NL)) 41 | .replace(TEMPLATE_CSRF, headers?.[X_CSRF_TOKEN] ?? EMPTY) 42 | .replace("class=\"headers", req.server.renderHeaders === false ? "class=\"headers dr-hidden" : "class=\"headers") : EMPTY; 43 | } 44 | -------------------------------------------------------------------------------- /src/renderers/javascript.js: -------------------------------------------------------------------------------- 1 | import { 2 | CALLBACK, 3 | HEADER_APPLICATION_JAVASCRIPT, 4 | HEADER_CONTENT_TYPE, 5 | INT_0 6 | } from "../core/constants.js"; 7 | 8 | export function javascript (req, res, arg) { 9 | req.headers.accept = HEADER_APPLICATION_JAVASCRIPT; 10 | res.header(HEADER_CONTENT_TYPE, HEADER_APPLICATION_JAVASCRIPT); 11 | 12 | return `${req.parsed.searchParams.get(CALLBACK) ?? CALLBACK}(${JSON.stringify(arg, null, INT_0)});`; 13 | } 14 | -------------------------------------------------------------------------------- /src/renderers/json.js: -------------------------------------------------------------------------------- 1 | import {indent} from "../utils/indent.js"; 2 | 3 | export function json (req, res, arg) { 4 | return JSON.stringify(arg, null, indent(req.headers.accept, req.server.jsonIndent)); 5 | } 6 | -------------------------------------------------------------------------------- /src/renderers/jsonl.js: -------------------------------------------------------------------------------- 1 | import {stringify} from "tiny-jsonl"; 2 | 3 | export function jsonl (req, res, arg) { 4 | return stringify(arg); 5 | } 6 | -------------------------------------------------------------------------------- /src/renderers/plain.js: -------------------------------------------------------------------------------- 1 | import {indent} from "../utils/indent.js"; 2 | import {COMMA} from "../core/constants.js"; 3 | 4 | export function plain (req, res, arg) { 5 | return Array.isArray(arg) ? arg.map(i => plain(req, res, i)).join(COMMA) : arg instanceof Object ? JSON.stringify(arg, null, indent(req.headers.accept, req.server.json)) : arg.toString(); 6 | } 7 | -------------------------------------------------------------------------------- /src/renderers/xml.js: -------------------------------------------------------------------------------- 1 | import {XMLBuilder} from "fast-xml-parser"; 2 | import {XML_ARRAY_NODE_NAME, XML_PROLOG} from "../core/constants.js"; 3 | 4 | export function xml (req, res, arg) { 5 | const builder = new XMLBuilder({ 6 | processEntities: true, 7 | format: true, 8 | ignoreAttributes: false, 9 | arrayNodeName: Array.isArray(arg) ? XML_ARRAY_NODE_NAME : undefined 10 | }); 11 | 12 | return `${XML_PROLOG}\n${builder.build({output: arg})}`; 13 | } 14 | -------------------------------------------------------------------------------- /src/renderers/yaml.js: -------------------------------------------------------------------------------- 1 | import YAML from "yamljs"; 2 | 3 | export function yaml (req, res, arg) { 4 | return YAML.stringify(arg); 5 | } 6 | -------------------------------------------------------------------------------- /src/serializers/custom.js: -------------------------------------------------------------------------------- 1 | import {STATUS_CODES} from "node:http"; 2 | import {INT_200} from "../core/constants.js"; 3 | 4 | export function custom (arg, err, status = INT_200, stack = false) { 5 | return { 6 | data: arg, 7 | error: err !== null ? (stack ? err.stack : err.message) || err || STATUS_CODES[status] : null, 8 | links: [], 9 | status: status 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/serializers/plain.js: -------------------------------------------------------------------------------- 1 | import {STATUS_CODES} from "node:http"; 2 | import {INT_200} from "../core/constants.js"; 3 | 4 | export function plain (arg, err, status = INT_200, stack = false) { 5 | return err !== null ? (stack ? err.stack : err.message) || err || STATUS_CODES[status] : arg; 6 | } 7 | -------------------------------------------------------------------------------- /src/tenso.js: -------------------------------------------------------------------------------- 1 | import {readFileSync} from "node:fs"; 2 | import http from "node:http"; 3 | import https from "node:https"; 4 | import {createRequire} from "node:module"; 5 | import {join, resolve} from "node:path"; 6 | import {fileURLToPath, URL} from "node:url"; 7 | import {Woodland} from "woodland"; 8 | import {merge} from "tiny-merge"; 9 | import {eventsource} from "tiny-eventsource"; 10 | import {config as defaultConfig} from "./core/config.js"; 11 | import {parsers} from "./utils/parsers.js"; 12 | import {renderers} from "./utils/renderers.js"; 13 | import {serializers} from "./utils/serializers.js"; 14 | import {mimetype} from "./utils/regex.js"; 15 | import {hasBody} from "./utils/hasBody.js"; 16 | import { 17 | ACCESS_CONTROL, 18 | ALLOW, 19 | CACHE_CONTROL, 20 | COMMA, 21 | CONNECT, 22 | DELETE, 23 | EMPTY, 24 | ERROR, 25 | EXPOSE, 26 | EXPOSE_HEADERS, 27 | FORMAT, 28 | FUNCTION, 29 | HEADER_CONTENT_TYPE, 30 | HEADERS, 31 | HYPHEN, 32 | INT_0, 33 | INT_1, 34 | INT_1000, 35 | INT_200, 36 | INT_204, 37 | INT_304, 38 | INVALID_CONFIGURATION, 39 | METRICS_PATH, 40 | MSG_PROMETHEUS_ENABLED, 41 | NULL, 42 | OPTIONS, 43 | PREV_DIR, 44 | PRIVATE, 45 | SIGHUP, 46 | SIGINT, 47 | SIGTERM, 48 | TEMPLATE_FILE, 49 | UTF8, 50 | WWW, 51 | X_POWERED_BY 52 | } from "./core/constants.js"; 53 | import {serialize} from "./utils/serialize.js"; 54 | import {hypermedia} from "./utils/hypermedia.js"; 55 | import {exit} from "./middleware/exit.js"; 56 | import {payload} from "./middleware/payload.js"; 57 | import {parse} from "./middleware/parse.js"; 58 | import {prometheus} from "./middleware/prometheus.js"; 59 | import {auth} from "./utils/auth.js"; 60 | import {clone} from "./utils/clone.js"; 61 | 62 | const __dirname = fileURLToPath(new URL(".", import.meta.url)); 63 | const require = createRequire(import.meta.url); 64 | const {name, version} = require(join(__dirname, "..", "package.json")); 65 | 66 | class Tenso extends Woodland { 67 | constructor (config = defaultConfig) { 68 | super(config); 69 | 70 | for (const [key, value] of Object.entries(config)) { 71 | if (key in this === false) { 72 | this[key] = value; 73 | } 74 | } 75 | 76 | this.parsers = parsers; 77 | this.rates = new Map(); 78 | this.renderers = renderers; 79 | this.serializers = serializers; 80 | this.server = null; 81 | this.version = config.version; 82 | } 83 | 84 | canModify (arg) { 85 | return arg.includes(DELETE) || hasBody(arg); 86 | } 87 | 88 | connect (req, res) { 89 | req.csrf = this.canModify(req.method) === false && this.canModify(req.allow) && this.security.csrf === true; 90 | req.hypermedia = this.hypermedia.enabled; 91 | req.hypermediaHeader = this.hypermedia.header; 92 | req.private = false; 93 | req.protect = false; 94 | req.protectAsync = false; 95 | req.unprotect = false; 96 | req.url = req.parsed.pathname; 97 | req.server = this; 98 | 99 | if (req.cors) { 100 | const header = `${ACCESS_CONTROL}${HYPHEN}${req.method === OPTIONS ? ALLOW : EXPOSE}${HYPHEN}${HEADERS}`; 101 | 102 | res.removeHeader(header); 103 | res.header(header, `${EXPOSE_HEADERS}${req.csrf ? `, ${this.security.key}` : EMPTY}${this.corsExpose.length > INT_0 ? `, ${this.corsExpose}` : EMPTY}`); 104 | } 105 | } 106 | 107 | eventsource (...args) { 108 | return eventsource(...args); 109 | } 110 | 111 | final (req, res, arg) { 112 | return arg; 113 | } 114 | 115 | headers (req, res) { 116 | const key = CACHE_CONTROL, 117 | cache = res.getHeader(key) || EMPTY; 118 | 119 | if ((req.protect || req.csrf || req.private) && cache.includes(PRIVATE) === false) { 120 | const lcache = cache.replace(/(private|public)(,\s)?/g, EMPTY); 121 | 122 | res.removeHeader(key); 123 | res.header(key, `${PRIVATE}${lcache.length > INT_0 ? `${COMMA}${EMPTY}` : EMPTY}${lcache || EMPTY}`); 124 | } 125 | } 126 | 127 | init () { 128 | const authorization = Object.keys(this.auth).filter(i => this.auth?.[i]?.enabled === true).length > INT_0 || this.rate.enabled || this.security.csrf; 129 | 130 | this.decorate = this.decorate.bind(this); 131 | this.route = this.route.bind(this); 132 | this.render = this.render.bind(this); 133 | this.signals(); 134 | this.addListener(CONNECT, this.connect.bind(this)); 135 | this.onSend = (req, res, body = EMPTY, status = INT_200, headers) => { 136 | this.headers(req, res); 137 | res.statusCode = status; 138 | 139 | if (status !== INT_204 && status !== INT_304 && (body === null || typeof body.on !== FUNCTION)) { 140 | for (const fn of [serialize, hypermedia, this.final, this.render]) { 141 | body = fn(req, res, body); 142 | } 143 | } 144 | 145 | return [body, status, headers]; 146 | }; 147 | 148 | // Prometheus metrics 149 | if (this.prometheus.enabled) { 150 | const middleware = prometheus(this.prometheus.metrics); 151 | 152 | this.log(`type=init, message"${MSG_PROMETHEUS_ENABLED}"`); 153 | this.always(middleware).ignore(middleware); 154 | 155 | // Registering a route for middleware response to be served 156 | this.get(METRICS_PATH, EMPTY); 157 | 158 | // Hooking events that might bypass middleware 159 | this.on(ERROR, (req, res) => { 160 | if (req.valid === false) { 161 | middleware(req, res, () => void 0); 162 | } 163 | }); 164 | } 165 | 166 | // Early exit after prometheus metrics (for GETs only) 167 | this.always(exit).ignore(exit); 168 | 169 | // Payload handling 170 | this.always(payload).ignore(payload); 171 | this.always(parse).ignore(parse); 172 | 173 | // Setting 'always' routes before authorization runs 174 | for (const [key, value] of Object.entries(this.initRoutes.always ?? {})) { 175 | if (typeof value === FUNCTION) { 176 | this.always(key, value).ignore(value); 177 | } 178 | } 179 | 180 | delete this.initRoutes.always; 181 | 182 | if (authorization) { 183 | auth(this); 184 | } 185 | 186 | // Static assets on disk for browsable interface 187 | if (this.webroot.static !== EMPTY) { 188 | this.files(this.webroot.static, this.webroot.root); 189 | } 190 | 191 | // Setting routes 192 | for (const [method, routes] of Object.entries(this.initRoutes ?? {})) { 193 | for (const [route, target] of Object.entries(routes ?? {})) { 194 | this[method](route, target); 195 | } 196 | } 197 | 198 | delete this.initRoutes; 199 | 200 | return this; 201 | } 202 | 203 | parser (mediatype = EMPTY, fn = arg => arg) { 204 | this.parsers.set(mediatype, fn); 205 | 206 | return this; 207 | } 208 | 209 | rateLimit (req, fn) { 210 | const reqId = req.sessionID || req.ip; 211 | let valid = true, 212 | seconds = Math.floor(new Date().getTime() / INT_1000), 213 | limit, remaining, reset, state; 214 | 215 | if (this.rates.has(reqId) === false) { 216 | this.rates.set(reqId, { 217 | limit: this.rate.limit, 218 | remaining: this.rate.limit, 219 | reset: seconds + this.rate.reset, 220 | time_reset: this.rate.reset 221 | }); 222 | } 223 | 224 | if (typeof fn === FUNCTION) { 225 | this.rates.set(reqId, fn(req, this.rates.get(reqId))); 226 | } 227 | 228 | state = this.rates.get(reqId); 229 | limit = state.limit; 230 | remaining = state.remaining; 231 | reset = state.reset; 232 | 233 | if (seconds >= reset) { 234 | reset = state.reset = seconds + this.rate.reset; 235 | remaining = state.remaining = limit - INT_1; 236 | } else if (remaining > INT_0) { 237 | state.remaining--; 238 | remaining = state.remaining; 239 | } else { 240 | valid = false; 241 | } 242 | 243 | return [valid, limit, remaining, reset]; 244 | } 245 | 246 | render (req, res, arg) { 247 | if (arg === null) { 248 | arg = NULL; 249 | } 250 | 251 | const accepts = (req.parsed.searchParams.get(FORMAT) || req.headers.accept || res.getHeader(HEADER_CONTENT_TYPE)).split(COMMA); 252 | let format = EMPTY, 253 | renderer, result; 254 | 255 | for (const media of accepts) { 256 | const lmimetype = media.replace(mimetype, EMPTY); 257 | 258 | if (this.renderers.has(lmimetype)) { 259 | format = lmimetype; 260 | break; 261 | } 262 | } 263 | 264 | if (format.length === INT_0) { 265 | format = this.mimeType; 266 | } 267 | 268 | renderer = this.renderers.get(format); 269 | res.header(HEADER_CONTENT_TYPE, format); 270 | result = renderer(req, res, arg, this.webroot.template); 271 | 272 | return result; 273 | } 274 | 275 | renderer (mediatype, fn) { 276 | this.renderers.set(mediatype, fn); 277 | 278 | return this; 279 | } 280 | 281 | serializer (mediatype, fn) { 282 | this.serializers.set(mediatype, fn); 283 | 284 | return this; 285 | } 286 | 287 | signals () { 288 | for (const signal of [SIGHUP, SIGINT, SIGTERM]) { 289 | process.on(signal, () => { 290 | this.stop(); 291 | process.exit(0); 292 | }); 293 | } 294 | 295 | return this; 296 | } 297 | 298 | start () { 299 | if (this.server === null) { 300 | if (this.ssl.cert === null && this.ssl.pfx === null && this.ssl.key === null) { 301 | this.server = http.createServer(this.route).listen(this.port, this.host); 302 | } else { 303 | this.server = https.createServer({ 304 | cert: this.ssl.cert ? readFileSync(this.ssl.cert) : void INT_0, 305 | pfx: this.ssl.pfx ? readFileSync(this.ssl.pfx) : void INT_0, 306 | key: this.ssl.key ? readFileSync(this.ssl.key) : void INT_0, 307 | port: this.port, 308 | host: this.host 309 | }, this.route).listen(this.port, this.host); 310 | } 311 | 312 | this.log(`Started server on ${this.host}:${this.port}`); 313 | } 314 | 315 | return this; 316 | } 317 | 318 | stop () { 319 | if (this.server !== null) { 320 | this.server.close(); 321 | this.server = null; 322 | this.log(`Stopped server on ${this.host}:${this.port}`); 323 | } 324 | 325 | return this; 326 | } 327 | } 328 | 329 | export function tenso (userConfig = {}) { 330 | const config = merge(clone(defaultConfig), userConfig); 331 | 332 | if ((/^[^\d+]$/).test(config.port) && config.port < INT_1) { 333 | console.error(INVALID_CONFIGURATION); 334 | process.exit(INT_1); 335 | } 336 | 337 | config.title = config.title ?? name; 338 | config.version = version; 339 | config.webroot.root = resolve(config.webroot.root || join(__dirname, PREV_DIR, WWW)); 340 | config.webroot.template = readFileSync(config.webroot.template || join(config.webroot.root, TEMPLATE_FILE), {encoding: UTF8}); 341 | 342 | if (config.silent !== true) { 343 | config.defaultHeaders.server = `tenso/${config.version}`; 344 | config.defaultHeaders[X_POWERED_BY] = `nodejs/${process.version}, ${process.platform}/${process.arch}`; 345 | } 346 | 347 | const app = new Tenso(config); 348 | 349 | return app.init(); 350 | } 351 | -------------------------------------------------------------------------------- /src/utils/auth.js: -------------------------------------------------------------------------------- 1 | import redis from "ioredis"; 2 | import cookie from "cookie-parser"; 3 | import session from "express-session"; 4 | import passport from "passport"; 5 | import passportJWT from "passport-jwt"; 6 | import {BasicStrategy} from "passport-http"; 7 | import {Strategy as BearerStrategy} from "passport-http-bearer"; 8 | import {Strategy as OAuth2Strategy} from "passport-oauth2"; 9 | import {STATUS_CODES} from "node:http"; 10 | import {asyncFlag} from "../middleware/asyncFlag.js"; 11 | import {bypass} from "../middleware/bypass.js"; 12 | import {csrfWrapper} from "../middleware/csrf.js"; 13 | import {guard} from "../middleware/guard.js"; 14 | import {redirect} from "../middleware/redirect.js"; 15 | import {zuul} from "../middleware/zuul.js"; 16 | import {clone} from "./clone.js"; 17 | import {delay} from "./delay.js"; 18 | import {isEmpty} from "./isEmpty.js"; 19 | import {randomUUID as uuid} from "node:crypto"; 20 | import { 21 | ALGORITHMS, 22 | AUDIENCE, 23 | AUTH, 24 | BASIC, 25 | BEARER, 26 | CALLBACK, 27 | COLON, 28 | EMPTY, 29 | I, 30 | INT_0, 31 | INT_1, 32 | INT_401, 33 | INT_443, 34 | INT_80, 35 | ISSUER, 36 | JWT, 37 | OAUTH2, 38 | PERIOD, 39 | PIPE, 40 | PROTECT, 41 | READ, 42 | REDIS, 43 | REGEX_REPLACE, 44 | S, 45 | SLASH, 46 | UNDERSCORE, 47 | UNPROTECT, 48 | URI, 49 | WILDCARD 50 | } from "../core/constants.js"; 51 | import RedisStore from "connect-redis"; 52 | import lusca from "lusca"; 53 | 54 | const {Strategy: JWTStrategy, ExtractJwt} = passportJWT, 55 | groups = [PROTECT, UNPROTECT]; 56 | 57 | export function auth (obj) { 58 | const ssl = obj.ssl.cert && obj.ssl.key, 59 | realm = `http${ssl ? S : EMPTY}://${obj.host}${obj.port !== INT_80 && obj.port !== INT_443 ? COLON + obj.port : EMPTY}`, 60 | async = obj.auth.oauth2.enabled || obj.auth.saml.enabled, 61 | stateless = obj.rate.enabled === false && obj.security.csrf === false, 62 | authDelay = obj.auth.delay, 63 | authMap = {}, 64 | authUris = []; 65 | 66 | let sesh, fnCookie, fnSession, passportInit, passportSession; 67 | 68 | obj.ignore(asyncFlag); 69 | 70 | for (const k of groups) { 71 | obj.auth[k] = (obj.auth[k] || []).map(i => new RegExp(`^${i !== obj.auth.uri.login ? i.replace(/\.\*/g, WILDCARD).replace(/\*/g, `${PERIOD}${WILDCARD}`) : EMPTY}(/|$)`, I)); 72 | } 73 | 74 | for (const i of Object.keys(obj.auth)) { 75 | if (obj.auth[i].enabled) { 76 | const uri = `${SLASH}${AUTH}${SLASH}${i}`; 77 | 78 | authMap[`${i}${UNDERSCORE}${URI}`] = uri; 79 | authUris.push(uri); 80 | obj.auth.protect.push(new RegExp(`^/auth/${i}(/|$)`)); 81 | } 82 | } 83 | 84 | if (stateless === false) { 85 | const objSession = clone(obj.session); 86 | 87 | delete objSession.redis; 88 | delete objSession.store; 89 | 90 | sesh = Object.assign({secret: uuid()}, objSession); 91 | 92 | if (obj.session.store === REDIS) { 93 | const client = redis.createClient(clone(obj.session.redis)); 94 | 95 | sesh.store = new RedisStore({client}); 96 | } 97 | 98 | fnCookie = cookie(); 99 | fnSession = session(sesh); 100 | 101 | obj.always(fnCookie).ignore(fnCookie); 102 | obj.always(fnSession).ignore(fnSession); 103 | obj.always(bypass).ignore(bypass); 104 | 105 | if (obj.security.csrf) { 106 | obj.always(csrfWrapper).ignore(csrfWrapper); 107 | } 108 | } 109 | 110 | if (obj.security.csp instanceof Object) { 111 | const luscaCsp = lusca.csp(obj.security.csp); 112 | 113 | obj.always(luscaCsp).ignore(luscaCsp); 114 | } 115 | 116 | if (isEmpty(obj.security.xframe || EMPTY) === false) { 117 | const luscaXframe = lusca.xframe(obj.security.xframe); 118 | 119 | obj.always(luscaXframe).ignore(luscaXframe); 120 | } 121 | 122 | if (isEmpty(obj.security.p3p || EMPTY) === false) { 123 | const luscaP3p = lusca.p3p(obj.security.p3p); 124 | 125 | obj.always(luscaP3p).ignore(luscaP3p); 126 | } 127 | 128 | if (obj.security.hsts instanceof Object) { 129 | const luscaHsts = lusca.hsts(obj.security.hsts); 130 | 131 | obj.always(luscaHsts).ignore(luscaHsts); 132 | } 133 | 134 | if (obj.security.xssProtection) { 135 | const luscaXssProtection = lusca.xssProtection(obj.security.xssProtection); 136 | 137 | obj.always(luscaXssProtection).ignore(luscaXssProtection); 138 | } 139 | 140 | if (obj.security.nosniff) { 141 | const luscaNoSniff = lusca.nosniff(); 142 | 143 | obj.always(luscaNoSniff).ignore(luscaNoSniff); 144 | } 145 | 146 | // Can fork to `middleware.keymaster()` 147 | obj.always(zuul).ignore(zuul); 148 | passportInit = passport.initialize(); 149 | obj.always(passportInit).ignore(passportInit); 150 | 151 | if (stateless === false) { 152 | passportSession = passport.session(); 153 | obj.always(passportSession).ignore(passportSession); 154 | } 155 | 156 | passport.serializeUser((user, done) => done(null, user)); 157 | passport.deserializeUser((arg, done) => done(null, arg)); 158 | 159 | if (obj.auth.basic.enabled) { 160 | let x = {}; 161 | 162 | const validate = (arg, cb) => { 163 | if (x[arg] !== void 0) { 164 | cb(null, x[arg]); 165 | } else { 166 | cb(new Error(STATUS_CODES[INT_401]), null); 167 | } 168 | }; 169 | 170 | for (const i of obj.auth.basic.list || []) { 171 | let args = i.split(COLON); 172 | 173 | if (args.length > INT_0) { 174 | x[args[INT_0]] = {password: args[INT_1]}; 175 | } 176 | } 177 | 178 | passport.use(new BasicStrategy((username, password, done) => { 179 | delay(() => { 180 | validate(username, (err, user) => { 181 | if (err !== null) { 182 | return done(err); 183 | } 184 | 185 | if (user === void 0 || user.password !== password) { 186 | return done(null, false); 187 | } 188 | 189 | return done(null, user); 190 | }); 191 | }, authDelay); 192 | })); 193 | 194 | const passportAuth = passport.authenticate(BASIC, {session: stateless === false}); 195 | 196 | if (async) { 197 | const uri = `${SLASH}${AUTH}${SLASH}${BASIC}`; 198 | 199 | obj.get(uri, passportAuth).ignore(passportAuth); 200 | obj.get(uri, redirect); 201 | } else { 202 | obj.always(passportAuth).ignore(passportAuth); 203 | } 204 | } else if (obj.auth.bearer.enabled) { 205 | const validate = (arg, cb) => { 206 | if (obj.auth.bearer.tokens.includes(arg)) { 207 | cb(null, arg); 208 | } else { 209 | cb(new Error(STATUS_CODES[INT_401]), null); 210 | } 211 | }; 212 | 213 | passport.use(new BearerStrategy((token, done) => { 214 | delay(() => { 215 | validate(token, (err, user) => { 216 | if (err !== null) { 217 | done(err); 218 | } else if (user === void 0) { 219 | done(null, false); 220 | } else { 221 | done(null, user, {scope: READ}); 222 | } 223 | }); 224 | }, authDelay); 225 | })); 226 | 227 | const passportAuth = passport.authenticate(BEARER.toLowerCase(), {session: stateless === false}); 228 | 229 | if (async) { 230 | const uri = `${SLASH}${AUTH}${SLASH}${BEARER.toLowerCase()}`; 231 | 232 | obj.get(uri, passportAuth).ignore(passportAuth); 233 | obj.get(uri, redirect); 234 | } else { 235 | obj.always(passportAuth).ignore(passportAuth); 236 | } 237 | } else if (obj.auth.jwt.enabled) { 238 | const opts = { 239 | jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme(obj.auth.jwt.scheme), 240 | secretOrKey: obj.auth.jwt.secretOrKey, 241 | ignoreExpiration: obj.auth.jwt.ignoreExpiration === true 242 | }; 243 | 244 | for (const i of [ALGORITHMS, AUDIENCE, ISSUER]) { 245 | if (obj.auth.jwt[i] !== void 0) { 246 | opts[i] = obj.auth.jwt[i]; 247 | } 248 | } 249 | 250 | passport.use(new JWTStrategy(opts, (token, done) => { 251 | delay(() => { 252 | obj.auth.jwt.auth(token, (err, user) => { 253 | if (err !== null) { 254 | done(err); 255 | } else { 256 | done(null, user); 257 | } 258 | }); 259 | }, authDelay); 260 | })); 261 | 262 | const passportAuth = passport.authenticate(JWT, {session: false}); 263 | obj.always(passportAuth).ignore(passportAuth); 264 | } else if (obj.auth.oauth2.enabled) { 265 | const uri = `${SLASH}${AUTH}${SLASH}${OAUTH2}`; 266 | const uri_callback = `${uri}${SLASH}${CALLBACK}`; 267 | 268 | passport.use(new OAuth2Strategy({ 269 | authorizationURL: obj.auth.oauth2.auth_url, 270 | tokenURL: obj.auth.oauth2.token_url, 271 | clientID: obj.auth.oauth2.client_id, 272 | clientSecret: obj.auth.oauth2.client_secret, 273 | callbackURL: `${realm}${uri_callback}` 274 | }, (accessToken, refreshToken, profile, done) => { 275 | delay(() => { 276 | obj.auth.oauth2.auth(accessToken, refreshToken, profile, (err, user) => { 277 | if (err !== null) { 278 | done(err); 279 | } else { 280 | done(null, user); 281 | } 282 | }); 283 | }, authDelay); 284 | })); 285 | 286 | obj.get(uri, asyncFlag); 287 | obj.get(uri, passport.authenticate(OAUTH2)); 288 | obj.get(uri_callback, asyncFlag); 289 | obj.get(uri_callback, passport.authenticate(OAUTH2, {failureRedirect: obj.auth.uri.login})); 290 | obj.get(uri_callback, redirect); 291 | } 292 | 293 | if (authUris.length > INT_0) { 294 | if (Object.keys(authMap).length > INT_0) { 295 | obj.get(obj.auth.uri.root, authMap); 296 | } 297 | 298 | let r = `(?!${obj.auth.uri.root}/(`; 299 | 300 | for (const i of authUris) { 301 | r += i.replace(`${UNDERSCORE}${URI}`, EMPTY) + PIPE; 302 | } 303 | 304 | r = r.replace(/\|$/, EMPTY) + REGEX_REPLACE; 305 | obj.always(r, guard).ignore(guard); 306 | 307 | obj.get(obj.auth.uri.login, (req, res) => res.json({instruction: obj.auth.msg.login})); 308 | } 309 | 310 | obj.get(obj.auth.uri.logout, (req, res) => { 311 | if (req.session !== void 0) { 312 | req.session.destroy(); 313 | } 314 | 315 | redirect(req, res); 316 | }); 317 | 318 | return obj; 319 | } 320 | -------------------------------------------------------------------------------- /src/utils/capitalize.js: -------------------------------------------------------------------------------- 1 | import {explode} from "./explode.js"; 2 | import {INT_0, INT_1, SPACE} from "../core/constants.js"; 3 | 4 | export function capitalize (obj, e = false, delimiter = SPACE) { 5 | return e ? explode(obj, delimiter).map(capitalize).join(delimiter) : obj.charAt(INT_0).toUpperCase() + obj.slice(INT_1); 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/chunk.js: -------------------------------------------------------------------------------- 1 | import {INT_0, INT_2} from "../core/constants.js"; 2 | 3 | export function chunk (arg = [], size = INT_2) { 4 | const result = []; 5 | const nth = Math.ceil(arg.length / size); 6 | let i = INT_0; 7 | 8 | while (i < nth) { 9 | result.push(arg.slice(i * size, ++i * size)); 10 | } 11 | 12 | return result; 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/clone.js: -------------------------------------------------------------------------------- 1 | export const clone = arg => JSON.parse(JSON.stringify(arg)); 2 | -------------------------------------------------------------------------------- /src/utils/delay.js: -------------------------------------------------------------------------------- 1 | import {random} from "./random.js"; 2 | import {INT_0} from "../core/constants.js"; 3 | 4 | export function delay (fn = () => void 0, n = INT_0) { 5 | if (n === INT_0) { 6 | fn(); 7 | } else { 8 | setTimeout(fn, random(n)); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/empty.js: -------------------------------------------------------------------------------- 1 | import {INT_0} from "../core/constants.js"; 2 | 3 | export function empty (obj) { 4 | return obj.length === INT_0; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/explode.js: -------------------------------------------------------------------------------- 1 | import {COMMA, EMPTY} from "../core/constants.js"; 2 | 3 | export function explode (arg = EMPTY, delimiter = COMMA) { 4 | return arg.trim().split(new RegExp(`\\s*${delimiter}\\s*`)); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/hasBody.js: -------------------------------------------------------------------------------- 1 | import {PATCH, POST, PUT} from "../core/constants.js"; 2 | 3 | export function hasBody (arg) { 4 | return arg.includes(PATCH) || arg.includes(POST) || arg.includes(PUT); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/hasRead.js: -------------------------------------------------------------------------------- 1 | import {GET, HEAD, OPTIONS} from "../core/constants.js"; 2 | 3 | export function hasRead (arg) { 4 | return arg.includes(GET) || arg.includes(HEAD) || arg.includes(OPTIONS); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/hypermedia.js: -------------------------------------------------------------------------------- 1 | import {URL} from "url"; 2 | import {keysort} from "keysort"; 3 | import {collection as collectionPattern, trailingSlash} from "./regex"; 4 | import { 5 | COLLECTION, 6 | COMMA_SPACE, 7 | DOUBLE_SLASH, 8 | EMPTY, 9 | ENCODED_SPACE, 10 | FIRST, 11 | GET, 12 | HEADER_SPLIT, 13 | INT_0, 14 | INT_1, 15 | INT_200, 16 | INT_206, 17 | INT_5, 18 | ITEM, 19 | LAST, 20 | LINK, 21 | NEXT, 22 | PAGE, 23 | PAGE_SIZE, 24 | PREV, 25 | REL_URI, 26 | SLASH, 27 | URL_127001 28 | } from "../core/constants.js"; 29 | import {marshal} from "./marshal.js"; 30 | 31 | export function hypermedia (req, res, rep) { 32 | const server = req.server, 33 | headers = res.getHeaders(), 34 | collection = req.url, 35 | links = [], 36 | seen = new Set(), 37 | exists = rep !== null; 38 | let query, page, page_size, nth, root, parent; 39 | 40 | query = req.parsed.searchParams; 41 | page = Number(query.get(PAGE)) || INT_1; 42 | page_size = Number(query.get(PAGE_SIZE)) || server.pageSize || INT_5; 43 | 44 | if (page < INT_1) { 45 | page = INT_1; 46 | } 47 | 48 | if (page_size < INT_1) { 49 | page_size = server.pageSize || INT_5; 50 | } 51 | 52 | root = new URL(`${URL_127001}${req.url}${req.parsed.search}`); 53 | root.searchParams.delete(PAGE); 54 | root.searchParams.delete(PAGE_SIZE); 55 | 56 | if (root.pathname !== SLASH) { 57 | const proot = root.pathname.replace(trailingSlash, EMPTY).replace(collectionPattern, "$1") || SLASH; 58 | 59 | if (server.allowed(GET, proot)) { 60 | links.push({uri: proot, rel: COLLECTION}); 61 | seen.add(proot); 62 | } 63 | } 64 | 65 | if (exists) { 66 | if (Array.isArray(rep.data)) { 67 | if (req.method === GET && (rep.status >= INT_200 && rep.status <= INT_206)) { 68 | nth = Math.ceil(rep.data.length / page_size); 69 | 70 | if (nth > INT_1) { 71 | const start = (page - INT_1) * page_size, 72 | end = start + page_size; 73 | 74 | rep.data = rep.data.slice(start, end); 75 | root.searchParams.set(PAGE, INT_0); 76 | root.searchParams.set(PAGE_SIZE, page_size); 77 | 78 | if (page > INT_1) { 79 | root.searchParams.set(PAGE, INT_1); 80 | links.push({uri: `${root.pathname}${root.search}`, rel: FIRST}); 81 | } 82 | 83 | if (page - INT_1 > INT_1 && page <= nth) { 84 | root.searchParams.set(PAGE, page - INT_1); 85 | links.push({uri: `${root.pathname}${root.search}`, rel: PREV}); 86 | } 87 | 88 | if (page + INT_1 < nth) { 89 | root.searchParams.set(PAGE, page + INT_1); 90 | links.push({uri: `${root.pathname}${root.search}`, rel: NEXT}); 91 | } 92 | 93 | if (nth > INT_0 && page !== nth) { 94 | root.searchParams.set(PAGE, nth); 95 | links.push({uri: `${root.pathname}${root.search}`, rel: LAST}); 96 | } 97 | } 98 | } 99 | 100 | if (req.hypermedia) { 101 | for (const i of rep.data) { 102 | if (i instanceof Object) { 103 | marshal(i, ITEM, req.url.replace(trailingSlash, EMPTY), root, seen, links, server); 104 | } else { 105 | const li = i.toString(); 106 | 107 | if (li !== collection) { 108 | const uri = li.startsWith(SLASH) || li.indexOf(DOUBLE_SLASH) >= INT_0 ? li : `${collection.replace(/\s/g, ENCODED_SPACE)}/${li.replace(/\s/g, ENCODED_SPACE)}`.replace(/^\/\//, SLASH); 109 | 110 | if (uri !== collection && server.allowed(GET, uri)) { 111 | links.push({uri: uri, rel: ITEM}); 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } else if (rep.data instanceof Object && req.hypermedia) { 118 | parent = req.url.split(SLASH).filter(i => i !== EMPTY); 119 | 120 | if (parent.length > INT_1) { 121 | parent.pop(); 122 | } 123 | 124 | rep.data = marshal(rep.data, void 0, parent[parent.length - INT_1], root, seen, links, server); 125 | } 126 | } 127 | 128 | if (links.length > INT_0) { 129 | if (headers.link !== void 0) { 130 | for (const i of headers.link.split(HEADER_SPLIT)) { 131 | links.push({ 132 | uri: i.replace(/(^<|>.*$)/g, EMPTY), 133 | rel: i.replace(/(^.*rel="|"$)/g, EMPTY) 134 | }); 135 | } 136 | } 137 | 138 | if (req.hypermediaHeader) { 139 | res.header(LINK, keysort(links, REL_URI).map(i => `<${i.uri}>; rel="${i.rel}"`).join(COMMA_SPACE)); 140 | } 141 | 142 | if (exists && Array.isArray(rep?.links ?? EMPTY)) { 143 | rep.links = links; 144 | } 145 | } 146 | 147 | return rep; 148 | } 149 | -------------------------------------------------------------------------------- /src/utils/id.js: -------------------------------------------------------------------------------- 1 | import {EMPTY, I, ID, ID_2} from "../core/constants.js"; 2 | 3 | const pattern = new RegExp(`${ID}|${ID_2}$`, I); 4 | 5 | export function id (arg = EMPTY) { 6 | return pattern.test(arg); 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/indent.js: -------------------------------------------------------------------------------- 1 | import {EMPTY, IDENT_VAR, INT_0, INT_1, INT_10} from "../core/constants.js"; 2 | 3 | export function indent (arg = EMPTY, fallback = INT_0) { 4 | return arg.includes(IDENT_VAR) ? parseInt(arg.match(/indent=(\d+)/)[INT_1], INT_10) : fallback; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/isEmpty.js: -------------------------------------------------------------------------------- 1 | import {EMPTY} from "../core/constants.js"; 2 | 3 | export function isEmpty (arg = EMPTY) { 4 | return arg === EMPTY; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/marshal.js: -------------------------------------------------------------------------------- 1 | import {EMPTY, ENCODED_SPACE, GET, IE, INT_0, ITEM, RELATED, S, SLASH} from "../core/constants.js"; 2 | import {id} from "./id.js"; 3 | import {hypermedia as hypermediaPattern, trailing, trailingS, trailingY} from "./regex.js"; 4 | import {scheme} from "./scheme.js"; 5 | 6 | // Parsing the object for hypermedia properties 7 | export function marshal (obj, rel, item_collection, root, seen, links, server) { 8 | let keys = Object.keys(obj), 9 | lrel = rel || RELATED, 10 | result; 11 | 12 | if (keys.length === INT_0) { 13 | result = null; 14 | } else { 15 | for (const i of keys) { 16 | if (obj[i] !== void 0 && obj[i] !== null) { 17 | const lid = id(i); 18 | const isLinkable = hypermediaPattern.test(i); 19 | 20 | // If ID like keys are found, and are not URIs, they are assumed to be root collections 21 | if (lid || isLinkable) { 22 | const lkey = obj[i].toString(); 23 | let lcollection, uri; 24 | 25 | if (isLinkable) { 26 | lcollection = i.replace(trailing, EMPTY).replace(trailingS, EMPTY).replace(trailingY, IE) + S; 27 | lrel = RELATED; 28 | } else { 29 | lcollection = item_collection; 30 | lrel = ITEM; 31 | } 32 | 33 | if (scheme(lkey) === false) { 34 | uri = `${lcollection[0] === SLASH ? EMPTY : SLASH}${lcollection.replace(/\s/g, ENCODED_SPACE)}/${lkey.replace(/\s/g, ENCODED_SPACE)}`; 35 | 36 | if (uri !== root && seen.has(uri) === false) { 37 | seen.add(uri); 38 | 39 | if (server.allowed(GET, uri)) { 40 | links.push({uri: uri, rel: lrel}); 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | result = obj; 49 | } 50 | 51 | return result; 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/parsers.js: -------------------------------------------------------------------------------- 1 | import {json} from "../parsers/json.js"; 2 | import {jsonl} from "../parsers/jsonl.js"; 3 | import {xWwwFormURLEncoded} from "../parsers/xWwwFormURLEncoded.js"; 4 | 5 | import { 6 | HEADER_APPLICATION_JSON, 7 | HEADER_APPLICATION_JSON_LINES, 8 | HEADER_APPLICATION_JSONL, 9 | HEADER_APPLICATION_X_WWW_FORM_URLENCODED, 10 | HEADER_TEXT_JSON_LINES 11 | } from "../core/constants.js"; 12 | 13 | export const parsers = new Map([ 14 | [ 15 | HEADER_APPLICATION_X_WWW_FORM_URLENCODED, 16 | xWwwFormURLEncoded 17 | ], 18 | [ 19 | HEADER_APPLICATION_JSON, 20 | json 21 | ], 22 | [ 23 | HEADER_APPLICATION_JSON_LINES, 24 | jsonl 25 | ], 26 | [ 27 | HEADER_APPLICATION_JSONL, 28 | jsonl 29 | ], 30 | [ 31 | HEADER_TEXT_JSON_LINES, 32 | jsonl 33 | ] 34 | ]); 35 | -------------------------------------------------------------------------------- /src/utils/random.js: -------------------------------------------------------------------------------- 1 | import {randomInt} from "node:crypto"; 2 | import {INT_1, INT_100} from "../core/constants.js"; 3 | 4 | export function random (n = INT_100) { 5 | return randomInt(INT_1, n); 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/regex.js: -------------------------------------------------------------------------------- 1 | export const bodySplit = /&|=/; 2 | export const collection = /(.*)(\/.*)$/; 3 | export const hypermedia = /(([a-z]+(_)?)?id|url|uri)$/i; 4 | export const mimetype = /;.*/; 5 | export const trailing = /_.*$/; 6 | export const trailingS = /s$/; 7 | export const trailingSlash = /\/$/; 8 | export const trailingY = /y$/; 9 | -------------------------------------------------------------------------------- /src/utils/renderers.js: -------------------------------------------------------------------------------- 1 | import {json} from "../renderers/json.js"; 2 | import {yaml} from "../renderers/yaml.js"; 3 | import {xml} from "../renderers/xml.js"; 4 | import {plain} from "../renderers/plain.js"; 5 | import {javascript} from "../renderers/javascript.js"; 6 | import {csv} from "../renderers/csv.js"; 7 | import {html} from "../renderers/html.js"; 8 | import {jsonl} from "../renderers/jsonl.js"; 9 | import { 10 | HEADER_APPLICATION_JAVASCRIPT, 11 | HEADER_APPLICATION_JSON, 12 | HEADER_APPLICATION_JSON_LINES, 13 | HEADER_APPLICATION_JSONL, 14 | HEADER_APPLICATION_XML, 15 | HEADER_APPLICATION_YAML, 16 | HEADER_TEXT_CSV, 17 | HEADER_TEXT_HTML, 18 | HEADER_TEXT_JSON_LINES, 19 | HEADER_TEXT_PLAIN 20 | } from "../core/constants.js"; 21 | 22 | export const renderers = new Map([ 23 | [HEADER_APPLICATION_JSON, json], 24 | [HEADER_APPLICATION_YAML, yaml], 25 | [HEADER_APPLICATION_XML, xml], 26 | [HEADER_TEXT_PLAIN, plain], 27 | [HEADER_APPLICATION_JAVASCRIPT, javascript], 28 | [HEADER_TEXT_CSV, csv], 29 | [HEADER_TEXT_HTML, html], 30 | [HEADER_APPLICATION_JSON_LINES, jsonl], 31 | [HEADER_APPLICATION_JSONL, jsonl], 32 | [HEADER_TEXT_JSON_LINES, jsonl] 33 | ]); 34 | -------------------------------------------------------------------------------- /src/utils/sanitize.js: -------------------------------------------------------------------------------- 1 | import {GT, LT, STRING} from "../core/constants.js"; 2 | 3 | export function sanitize (arg) { 4 | return typeof arg === STRING ? arg.replace(//g, GT) : arg; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/scheme.js: -------------------------------------------------------------------------------- 1 | import {EMPTY, SLASH, URI_SCHEME} from "../core/constants.js"; 2 | 3 | export function scheme (arg = EMPTY) { 4 | return arg.includes(SLASH) || arg[0] === URI_SCHEME; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/serialize.js: -------------------------------------------------------------------------------- 1 | import {serializers} from "./serializers.js"; 2 | import {explode} from "./explode.js"; 3 | import {mimetype as regex} from "./regex.js"; 4 | import {sort} from "./sort.js"; 5 | import {CHARSET_UTF8, COMMA, EMPTY, FORMAT, HEADER_CONTENT_TYPE, INT_400, INT_500} from "../core/constants.js"; 6 | 7 | export function serialize (req, res, arg) { 8 | const status = res.statusCode; 9 | let format = req.server.mimeType, 10 | accepts = explode(req.parsed.searchParams.get(FORMAT) || req.headers.accept || res.getHeader(HEADER_CONTENT_TYPE) || format, COMMA), 11 | errz = arg instanceof Error || status >= INT_400, 12 | result, serializer; 13 | 14 | for (const i of accepts) { 15 | let mimetype = i.replace(regex, EMPTY); 16 | 17 | if (serializers.has(mimetype)) { 18 | format = mimetype; 19 | break; 20 | } 21 | } 22 | 23 | serializer = serializers.get(format); 24 | res.removeHeader(HEADER_CONTENT_TYPE); 25 | res.header(HEADER_CONTENT_TYPE, `${format}${CHARSET_UTF8}`); 26 | 27 | if (errz) { 28 | result = serializer(null, arg, status < INT_400 ? INT_500 : status, req.server.logging.stackWire); 29 | } else { 30 | result = serializer(sort(arg, req), null, status); 31 | } 32 | 33 | return result; 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/serializers.js: -------------------------------------------------------------------------------- 1 | import {custom} from "../serializers/custom.js"; 2 | import {plain} from "../serializers/plain.js"; 3 | import { 4 | HEADER_APPLICATION_JAVASCRIPT, 5 | HEADER_APPLICATION_JSON, 6 | HEADER_APPLICATION_JSON_LINES, 7 | HEADER_APPLICATION_JSONL, 8 | HEADER_APPLICATION_XML, 9 | HEADER_APPLICATION_YAML, 10 | HEADER_TEXT_CSV, 11 | HEADER_TEXT_HTML, 12 | HEADER_TEXT_JSON_LINES, 13 | HEADER_TEXT_PLAIN 14 | } from "../core/constants.js"; 15 | 16 | export const serializers = new Map([ 17 | [HEADER_APPLICATION_JSON, custom], 18 | [HEADER_APPLICATION_YAML, custom], 19 | [HEADER_APPLICATION_XML, custom], 20 | [HEADER_TEXT_PLAIN, plain], 21 | [HEADER_APPLICATION_JAVASCRIPT, custom], 22 | [HEADER_TEXT_CSV, plain], 23 | [HEADER_TEXT_HTML, custom], 24 | [HEADER_APPLICATION_JSON_LINES, plain], 25 | [HEADER_APPLICATION_JSONL, plain], 26 | [HEADER_TEXT_JSON_LINES, plain] 27 | ]); 28 | -------------------------------------------------------------------------------- /src/utils/sort.js: -------------------------------------------------------------------------------- 1 | import {keysort} from "keysort"; 2 | import {clone} from "./clone"; 3 | import {BOOLEAN, COMMA, DESC, EQ, INT_0, NUMBER, ORDER_BY, SPACE, STRING, UNDEFINED} from "../core/constants.js"; 4 | 5 | const ORDER_BY_EQ_DESC = `${ORDER_BY}${EQ}${DESC}`; 6 | const COMMA_SPACE = `${COMMA}${SPACE}`; 7 | 8 | export function sort (arg, req) { 9 | let output = clone(arg); 10 | 11 | if (typeof req.parsed.search === STRING && req.parsed.searchParams.has(ORDER_BY) && Array.isArray(arg)) { 12 | const type = typeof arg[INT_0]; 13 | 14 | if (type !== BOOLEAN && type !== NUMBER && type !== STRING && type !== UNDEFINED && arg[INT_0] !== null) { 15 | const args = req.parsed.searchParams.getAll(ORDER_BY).filter(i => i !== DESC).join(COMMA_SPACE); 16 | 17 | if (args.length > INT_0) { 18 | output = keysort(output, args); 19 | } 20 | } 21 | 22 | if (req.parsed.search.includes(ORDER_BY_EQ_DESC)) { 23 | output = output.reverse(); 24 | } 25 | } 26 | 27 | return output; 28 | } 29 | -------------------------------------------------------------------------------- /test/auth.test.js: -------------------------------------------------------------------------------- 1 | import {httptest} from "tiny-httptest"; 2 | import jwt from "jsonwebtoken"; 3 | import {tenso} from "../dist/tenso.cjs"; 4 | import {routes} from "./routes.js"; 5 | 6 | const timeout = 5000; 7 | const basePort = 3000; 8 | 9 | process.setMaxListeners(0); 10 | 11 | describe("Permissions (CSRF disabled)", function () { 12 | const port = basePort + 1; 13 | 14 | this.timeout(timeout); 15 | this.tenso = tenso({port: port, initRoutes: routes, logging: {enabled: false}, security: {csrf: false}}); 16 | 17 | const server = this.tenso.start(); 18 | 19 | it("GET / - returns an array of endpoints", function () { 20 | return httptest({url: "http://localhost:" + port}) 21 | .expectJson() 22 | .expectStatus(200) 23 | .expectHeader("allow", "GET, HEAD, OPTIONS") 24 | .expectValue("links", [{uri: "/empty", rel: "item"}, 25 | {uri: "/items", rel: "item"}, 26 | {uri: "/somethings", rel: "item"}, 27 | {uri: "/test", rel: "item"}, 28 | {uri: "/things", rel: "item"}, 29 | {uri: "/?page=2&page_size=5", rel: "last"}]) 30 | .expectValue("data", ["empty", "items", "somethings", "test", "things"]) 31 | .expectValue("error", null) 32 | .expectValue("status", 200) 33 | .end(); 34 | }); 35 | 36 | it("GET /invalid - returns a 'not found' error", function () { 37 | return httptest({url: "http://localhost:" + port + "/invalid"}) 38 | .expectJson() 39 | .expectStatus(404) 40 | .expectValue("data", null) 41 | .expectValue("error", "Not Found") 42 | .expectValue("status", 404) 43 | .end(); 44 | }); 45 | 46 | it("DELETE / - returns a 'method not allowed' error", function () { 47 | return httptest({url: "http://localhost:" + port, method: "delete"}) 48 | .expectJson() 49 | .expectStatus(405) 50 | .expectValue("data", null) 51 | .expectValue("error", "Method Not Allowed") 52 | .expectValue("status", 405) 53 | .end(); 54 | }); 55 | 56 | it("POST / - returns a 'method not allowed' error", function () { 57 | return httptest({url: "http://localhost:" + port, method: "post"}) 58 | .expectJson() 59 | .expectStatus(405) 60 | .expectValue("data", null) 61 | .expectValue("error", "Method Not Allowed") 62 | .expectValue("status", 405) 63 | .end(); 64 | }); 65 | 66 | it("PUT / - returns a 'method not allowed' error", function () { 67 | return httptest({url: "http://localhost:" + port, method: "put"}) 68 | .expectJson() 69 | .expectStatus(405) 70 | .expectValue("data", null) 71 | .expectValue("error", "Method Not Allowed") 72 | .expectValue("status", 405) 73 | .end(); 74 | }); 75 | 76 | it("PATCH / - returns a 'method not allowed' error", function () { 77 | return httptest({url: "http://localhost:" + port, method: "patch"}) 78 | .expectJson() 79 | .expectStatus(405) 80 | .expectValue("data", null) 81 | .expectValue("error", "Method Not Allowed") 82 | .expectValue("status", 405) 83 | .end().then(() => server.stop()); 84 | }); 85 | }); 86 | 87 | describe("Basic Auth", function () { 88 | const port = basePort + 2; 89 | 90 | this.timeout(timeout); 91 | this.tenso = tenso({ 92 | port: port, 93 | initRoutes: routes, 94 | logging: {enabled: false}, 95 | auth: {basic: {enabled: true, list: ["test:123"]}, protect: ["/uuid"]} 96 | }); 97 | 98 | const server = this.tenso.start(); 99 | 100 | it("GET / - returns links", function () { 101 | return httptest({url: "http://localhost:" + port}) 102 | .expectJson() 103 | .expectStatus(200) 104 | .expectValue("links", [{uri: "/empty", rel: "item"}, 105 | {uri: "/items", rel: "item"}, 106 | {uri: "/somethings", rel: "item"}, 107 | {uri: "/test", rel: "item"}, 108 | {uri: "/things", rel: "item"}, 109 | {uri: "/?page=2&page_size=5", rel: "last"}]) 110 | .expectValue("data", ["empty", "items", "somethings", "test", "things"]) 111 | .expectValue("error", null) 112 | .expectValue("status", 200) 113 | .end(); 114 | }); 115 | 116 | it("GET /uuid - returns a uuid (authorized)", function () { 117 | return httptest({url: "http://test:123@localhost:" + port + "/uuid"}) 118 | .expectJson() 119 | .expectStatus(200) 120 | .expectValue("links", [{uri: "/", rel: "collection"}]) 121 | .expectValue("error", null) 122 | .expectValue("status", 200) 123 | .end(); 124 | }); 125 | 126 | it("GET /uuid - returns an 'unauthorized' error", function () { 127 | return httptest({url: "http://localhost:" + port + "/uuid"}) 128 | .expectStatus(401) 129 | .end().then(() => server.stop()); 130 | }); 131 | }); 132 | 133 | describe("OAuth2 Token Bearer", function () { 134 | const port = basePort + 3; 135 | 136 | this.timeout(timeout); 137 | this.tenso = tenso({ 138 | port: port, 139 | initRoutes: routes, 140 | logging: {enabled: false}, 141 | auth: {bearer: {enabled: true, tokens: ["abc-123"]}, protect: ["/"]} 142 | }); 143 | 144 | const server = this.tenso.start(); 145 | 146 | it("GET / - returns an array of endpoints (authorized)", function () { 147 | return httptest({url: "http://localhost:" + port, headers: {authorization: "Bearer abc-123"}}) 148 | .expectJson() 149 | .expectStatus(200) 150 | .expectValue("links", [{uri: "/empty", rel: "item"}, 151 | {uri: "/items", rel: "item"}, 152 | {uri: "/somethings", rel: "item"}, 153 | {uri: "/test", rel: "item"}, 154 | {uri: "/things", rel: "item"}, 155 | {uri: "/?page=2&page_size=5", rel: "last"}]) 156 | .expectValue("data", ["empty", "items", "somethings", "test", "things"]) 157 | .expectValue("error", null) 158 | .expectValue("status", 200) 159 | .end(); 160 | }); 161 | 162 | it("GET / - returns an 'unauthorized' error", function () { 163 | return httptest({url: "http://localhost:" + port}) 164 | .expectStatus(401) 165 | .end().then(() => server.stop()); 166 | }); 167 | }); 168 | 169 | describe("JWT", function () { 170 | const port = basePort + 5, 171 | secret = "jennifer", 172 | token = jwt.sign({username: "jason@attack.io"}, secret); 173 | 174 | this.timeout(timeout); 175 | this.tenso = tenso({ 176 | port: port, initRoutes: routes, logging: {enabled: false}, auth: { 177 | jwt: { 178 | enabled: true, 179 | auth: function (arg, cb) { 180 | if (arg.username === "jason@attack.io") { 181 | cb(null, arg); 182 | } else { 183 | cb(new Error("Invalid token"), null); 184 | } 185 | }, 186 | secretOrKey: secret 187 | }, 188 | security: { 189 | csrf: false 190 | }, 191 | protect: ["/uuid"] 192 | } 193 | }); 194 | 195 | const server = this.tenso.start(); 196 | 197 | it("GET /uuid - returns a uuid (authorized)", function () { 198 | return httptest({url: "http://localhost:" + port + "/uuid", headers: {authorization: "Bearer " + token}}) 199 | .expectStatus(200) 200 | .expectJson() 201 | .expectValue("links", [{uri: "/", rel: "collection"}]) 202 | .expectValue("error", null) 203 | .expectValue("status", 200) 204 | .end(); 205 | }); 206 | 207 | it("GET /uuid - returns an 'unauthorized' error", function () { 208 | return httptest({url: "http://localhost:" + port + "/uuid"}) 209 | .expectStatus(401) 210 | .end().then(() => server.stop()); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /test/behavior.test.js: -------------------------------------------------------------------------------- 1 | import {httptest} from "tiny-httptest"; 2 | import {tenso} from "../dist/tenso.js"; 3 | import {routes} from "./routes.js"; 4 | 5 | const timeout = 5000; 6 | const basePort = 3100; 7 | 8 | describe("Pagination", function () { 9 | const port = basePort; 10 | 11 | this.timeout(timeout); 12 | this.tenso = tenso({port: port, initRoutes: routes, logging: {enabled: false}, security: {csrf: false}}); 13 | 14 | const server = this.tenso.start(); 15 | 16 | it("GET /empty - returns an empty array", function () { 17 | return httptest({url: "http://localhost:" + port + "/empty"}) 18 | .expectStatus(200) 19 | .expectValue("links", [{uri: "/", rel: "collection"}]) 20 | .expectValue("data", []) 21 | .expectValue("error", null) 22 | .expectValue("status", 200) 23 | .end(); 24 | }); 25 | 26 | it("GET /items/ - returns page 1/3 of an array of numbers", function () { 27 | return httptest({url: "http://localhost:" + port + "/items/"}) 28 | .expectStatus(200) 29 | .expectValue("links", [{"uri": "/", "rel": "collection"}, { 30 | "uri": "/items/?page=3&page_size=5", 31 | "rel": "last" 32 | }, {"uri": "/items/?page=2&page_size=5", "rel": "next"}]) 33 | .expectValue("data", [1, 2, 3, 4, 5]) 34 | .expectValue("error", null) 35 | .expectValue("status", 200) 36 | .end(); 37 | }); 38 | 39 | it("GET /items - returns page 1/3 of an array of numbers", function () { 40 | return httptest({url: "http://localhost:" + port + "/items"}) 41 | .expectStatus(200) 42 | .expectValue("links", [{"uri": "/", "rel": "collection"}, { 43 | "uri": "/items?page=3&page_size=5", 44 | "rel": "last" 45 | }, {"uri": "/items?page=2&page_size=5", "rel": "next"}]) 46 | .expectValue("data", [1, 2, 3, 4, 5]) 47 | .expectValue("error", null) 48 | .expectValue("status", 200) 49 | .end(); 50 | }); 51 | 52 | it("GET /items?page=a&page_size=b - returns page 1/3 of an array of numbers", function () { 53 | return httptest({url: "http://localhost:" + port + "/items?page=a&page_size=b"}) 54 | .expectStatus(200) 55 | .expectValue("links", [{"uri": "/", "rel": "collection"}, { 56 | "uri": "/items?page=3&page_size=5", 57 | "rel": "last" 58 | }, {"uri": "/items?page=2&page_size=5", "rel": "next"}]) 59 | .expectValue("data", [1, 2, 3, 4, 5]) 60 | .expectValue("error", null) 61 | .expectValue("status", 200) 62 | .end(); 63 | }); 64 | 65 | it("GET /items?page=0&page_size=5 - returns page 1/3 of an array of numbers", function () { 66 | return httptest({url: "http://localhost:" + port + "/items?page=0&page_size=5"}) 67 | .expectStatus(200) 68 | .expectValue("links", [{"uri": "/", "rel": "collection"}, { 69 | "uri": "/items?page=3&page_size=5", 70 | "rel": "last" 71 | }, {"uri": "/items?page=2&page_size=5", "rel": "next"}]) 72 | .expectValue("data", [1, 2, 3, 4, 5]) 73 | .expectValue("error", null) 74 | .expectValue("status", 200) 75 | .end(); 76 | }); 77 | 78 | it("GET /items?page=0&page_size=-1 - returns page 1/3 of an array of numbers", function () { 79 | return httptest({url: "http://localhost:" + port + "/items?page=0&page_size=-1"}) 80 | .expectStatus(200) 81 | .expectValue("links", [{"uri": "/", "rel": "collection"}, { 82 | "uri": "/items?page=3&page_size=5", 83 | "rel": "last" 84 | }, {"uri": "/items?page=2&page_size=5", "rel": "next"}]) 85 | .expectValue("data", [1, 2, 3, 4, 5]) 86 | .expectValue("error", null) 87 | .expectValue("status", 200) 88 | .end(); 89 | }); 90 | 91 | it("GET /items?page=2&page_size=5 - returns page 2/3 of an array of numbers", function () { 92 | return httptest({url: "http://localhost:" + port + "/items?page=2&page_size=5"}) 93 | .expectStatus(200) 94 | .expectValue("links", [{"uri": "/", "rel": "collection"}, { 95 | "uri": "/items?page=1&page_size=5", 96 | "rel": "first" 97 | }, {"uri": "/items?page=3&page_size=5", "rel": "last"}]) 98 | .expectValue("data", [6, 7, 8, 9, 10]) 99 | .expectValue("error", null) 100 | .expectValue("status", 200) 101 | .end(); 102 | }); 103 | 104 | it("GET /items?page=3&page_size=5 - returns page 3/3 of an array of numbers", function () { 105 | return httptest({url: "http://localhost:" + port + "/items?page=3&page_size=5"}) 106 | .expectStatus(200) 107 | .expectValue("links", [{"uri": "/", "rel": "collection"}, { 108 | "uri": "/items?page=1&page_size=5", 109 | "rel": "first" 110 | }, {"uri": "/items?page=2&page_size=5", "rel": "prev"}]) 111 | .expectValue("data", [11, 12, 13, 14, 15]) 112 | .expectValue("error", null) 113 | .expectValue("status", 200) 114 | .end(); 115 | }); 116 | 117 | it("GET /items?page=4&page_size=5 - returns page 4/3 of an array of numbers (empty)", function () { 118 | return httptest({url: "http://localhost:" + port + "/items?page=4&page_size=5"}) 119 | .expectStatus(200) 120 | .expectValue("links", [{"uri": "/", "rel": "collection"}, { 121 | "uri": "/items?page=1&page_size=5", 122 | "rel": "first" 123 | }, {"uri": "/items?page=3&page_size=5", "rel": "last"}]) 124 | .expectValue("data", []) 125 | .expectValue("error", null) 126 | .expectValue("status", 200) 127 | .end(); 128 | }); 129 | 130 | it("GET /items?email=user@domain.com - returns page 1/3 of an array of numbers, preserving the query string via encoding", function () { 131 | return httptest({url: "http://localhost:" + port + "/items?email=user@domain.com"}) 132 | .expectStatus(200) 133 | .expectValue("links", [{ 134 | uri: "/", 135 | rel: "collection" 136 | }, { 137 | uri: "/items?email=user%40domain.com&page=3&page_size=5", 138 | rel: "last" 139 | }, { 140 | uri: "/items?email=user%40domain.com&page=2&page_size=5", 141 | rel: "next" 142 | }]) 143 | .expectValue("data", [1, 2, 3, 4, 5]) 144 | .expectValue("error", null) 145 | .expectValue("status", 200) 146 | .end().then(() => server.stop()); 147 | }); 148 | }); 149 | 150 | describe("Hypermedia", function () { 151 | const port = basePort + 1; 152 | 153 | this.timeout(timeout); 154 | this.tenso = tenso({port: port, initRoutes: routes, logging: {enabled: false}, security: {csrf: false}}); 155 | 156 | const server = this.tenso.start(); 157 | 158 | it("GET /things - returns a collection of representations that has hypermedia properties", function () { 159 | return httptest({url: "http://localhost:" + port + "/things"}) 160 | .expectStatus(200) 161 | .expectValue("links", [{ 162 | uri: "/", 163 | rel: "collection" 164 | }]) 165 | .expectValue("data", [{id: 1, name: "thing 1", user_id: 1, welcome: "

blahblah

"}, { 166 | id: 2, 167 | name: "thing 2", 168 | user_id: 1 169 | }, {id: 3, name: "thing 3", user_id: 2}]) 170 | .expectValue("error", null) 171 | .expectValue("status", 200) 172 | .end(); 173 | }); 174 | 175 | it("GET /somethings/abc - returns an entity that has hypermedia properties, and data", function () { 176 | return httptest({url: "http://localhost:" + port + "/somethings/abc"}) 177 | .expectStatus(200) 178 | .expectValue("links", [{"uri": "/somethings", "rel": "collection"}, {"uri": "/users/123", "rel": "related"}]) 179 | .expectValue("data", { 180 | _id: "abc", 181 | user_id: 123, 182 | title: "This is a title", 183 | body: "Where is my body?", 184 | source_url: "http://source.tld" 185 | }) 186 | .expectValue("error", null) 187 | .expectValue("status", 200) 188 | .end(); 189 | }); 190 | 191 | it("GET /somethings/def - returns an entity that has hypermedia properties, and no data", function () { 192 | return httptest({url: "http://localhost:" + port + "/somethings/def"}) 193 | .expectStatus(200) 194 | .expectValue("links", [{"uri": "/somethings", "rel": "collection"}, {"uri": "/users/123", "rel": "related"}]) 195 | .expectValue("data", {_id: "def", user_id: 123, source_url: "http://source.tld"}) 196 | .expectValue("error", null) 197 | .expectValue("status", 200) 198 | .end().then(() => server.stop()); 199 | }); 200 | }); 201 | 202 | describe("Rate Limiting", function () { 203 | const port = basePort + 2; 204 | 205 | this.timeout(timeout); 206 | this.tenso = tenso({ 207 | port: port, 208 | initRoutes: routes, 209 | logging: {enabled: false}, 210 | security: {csrf: false}, 211 | rate: {enabled: true, limit: 2, reset: 900} 212 | }); 213 | 214 | const server = this.tenso.start(); 215 | 216 | it("GET / - returns an array of endpoints (1/2)", function () { 217 | return httptest({url: "http://localhost:" + port}) 218 | .cookies() 219 | .expectStatus(200) 220 | .expectHeader("x-ratelimit-limit", "2") 221 | .expectHeader("x-ratelimit-remaining", "1") 222 | .expectValue("links", [{uri: "/empty", rel: "item"}, 223 | {uri: "/items", rel: "item"}, 224 | {uri: "/somethings", rel: "item"}, 225 | {uri: "/test", rel: "item"}, 226 | {uri: "/things", rel: "item"}, 227 | {uri: "/?page=2&page_size=5", rel: "last"}]) 228 | .expectValue("data", ["empty", "items", "somethings", "test", "things"]) 229 | .expectValue("error", null) 230 | .expectValue("status", 200) 231 | .end(); 232 | }); 233 | 234 | it("GET / - returns an array of endpoints (2/2)", function () { 235 | return httptest({url: "http://localhost:" + port}) 236 | .cookies() 237 | .expectStatus(200) 238 | .expectHeader("x-ratelimit-limit", "2") 239 | .expectHeader("x-ratelimit-remaining", "0") 240 | .expectValue("links", [{uri: "/empty", rel: "item"}, 241 | {uri: "/items", rel: "item"}, 242 | {uri: "/somethings", rel: "item"}, 243 | {uri: "/test", rel: "item"}, 244 | {uri: "/things", rel: "item"}, 245 | {uri: "/?page=2&page_size=5", rel: "last"}]) 246 | .expectValue("data", ["empty", "items", "somethings", "test", "things"]) 247 | .expectValue("error", null) 248 | .expectValue("status", 200) 249 | .end(); 250 | }); 251 | 252 | it("GET / - returns a 'too many requests' error", function () { 253 | return httptest({url: "http://localhost:" + port}) 254 | .cookies() 255 | .expectStatus(429) 256 | .expectValue("data", null) 257 | .expectValue("error", "Too Many Requests") 258 | .expectValue("status", 429) 259 | .end().then(() => server.stop()); 260 | }); 261 | }); 262 | 263 | describe("Rate Limiting (Override)", function () { 264 | const port = basePort + 3; 265 | let i = 1; 266 | 267 | this.timeout(timeout); 268 | this.tenso = tenso({ 269 | port: port, initRoutes: routes, logging: {enabled: false}, security: {csrf: false}, rate: { 270 | enabled: true, 271 | limit: 2, 272 | reset: 900, 273 | override: function (req, rate) { 274 | if (++i > 1 && rate.limit < 100) { 275 | rate.limit += 100; 276 | rate.remaining += 100; 277 | } 278 | 279 | return rate; 280 | } 281 | } 282 | }); 283 | 284 | const server = this.tenso.start(); 285 | 286 | it("GET / - returns an array of endpoints (1/2)", function () { 287 | return httptest({url: "http://localhost:" + port}) 288 | .cookies() 289 | .expectStatus(200) 290 | .expectHeader("x-ratelimit-limit", "102") 291 | .expectHeader("x-ratelimit-remaining", "101") 292 | .expectValue("links", [{uri: "/empty", rel: "item"}, 293 | {uri: "/items", rel: "item"}, 294 | {uri: "/somethings", rel: "item"}, 295 | {uri: "/test", rel: "item"}, 296 | {uri: "/things", rel: "item"}, 297 | {uri: "/?page=2&page_size=5", rel: "last"}]) 298 | .expectValue("data", ["empty", "items", "somethings", "test", "things"]) 299 | .expectValue("error", null) 300 | .expectValue("status", 200) 301 | .end(); 302 | }); 303 | 304 | it("GET / - returns an array of endpoints (2/2)", function () { 305 | return httptest({url: "http://localhost:" + port}) 306 | .cookies() 307 | .expectStatus(200) 308 | .expectHeader("x-ratelimit-limit", "102") 309 | .expectHeader("x-ratelimit-remaining", "100") 310 | .expectValue("links", [{uri: "/empty", rel: "item"}, 311 | {uri: "/items", rel: "item"}, 312 | {uri: "/somethings", rel: "item"}, 313 | {uri: "/test", rel: "item"}, 314 | {uri: "/things", rel: "item"}, 315 | {uri: "/?page=2&page_size=5", rel: "last"}]) 316 | .expectValue("data", ["empty", "items", "somethings", "test", "things"]) 317 | .expectValue("error", null) 318 | .expectValue("status", 200) 319 | .end().then(() => server.stop()); 320 | }); 321 | }); 322 | 323 | describe("Request body max byte size", function () { 324 | const port = basePort + 4; 325 | 326 | this.timeout(timeout); 327 | this.tenso = tenso({ 328 | port: port, 329 | initRoutes: routes, 330 | logging: {enabled: false}, 331 | security: {csrf: false}, 332 | maxBytes: 10 333 | }); 334 | 335 | const server = this.tenso.start(); 336 | 337 | it("POST /test - returns an a result", function () { 338 | return httptest({url: "http://localhost:" + port + "/test", method: "post"}) 339 | .json({"x": 1}) 340 | .expectStatus(200) 341 | .expectValue("links", [{uri: "/", rel: "collection"}]) 342 | .expectValue("data", "OK!") 343 | .expectValue("error", null) 344 | .expectValue("status", 200) 345 | .end(); 346 | }); 347 | 348 | it("POST /test (invalid) - returns a 'request entity too large' error", function () { 349 | return httptest({url: "http://localhost:" + port + "/test", method: "post"}) 350 | .json({"abc": true}) 351 | .expectStatus(413) 352 | .expectValue("data", null) 353 | .expectValue("error", "Payload Too Large") 354 | .expectValue("status", 413) 355 | .end().then(() => server.stop()); 356 | }); 357 | }); 358 | 359 | describe("Route parameters", function () { 360 | const port = basePort + 5; 361 | 362 | this.timeout(timeout); 363 | this.tenso = tenso({port: port, initRoutes: routes, logging: {enabled: false}, security: {csrf: false}}); 364 | 365 | const server = this.tenso.start(); 366 | 367 | it("GET /test/hidden - returns an a 'hidden' result", function () { 368 | return httptest({url: "http://localhost:" + port + "/test/hidden"}) 369 | .expectStatus(200) 370 | .expectValue("links", [{uri: "/test", rel: "collection"}]) 371 | .expectValue("data", "hidden") 372 | .expectValue("error", null) 373 | .expectValue("status", 200) 374 | .end().then(() => server.stop()); 375 | }); 376 | }); 377 | 378 | describe("CORS", function () { 379 | const port = basePort + 6; 380 | 381 | this.timeout(timeout); 382 | this.tenso = tenso({port: port, initRoutes: routes, logging: {enabled: false}, security: {csrf: false}}); 383 | 384 | const server = this.tenso.start(); 385 | 386 | it("OPTIONS /empty - returns an empty array", function () { 387 | return httptest({url: "http://localhost:" + port + "/empty", method: "options"}) 388 | .cors("http://not.localhost") 389 | .expectStatus(200) 390 | .end(); 391 | }); 392 | 393 | it("GET /empty - returns an empty array", function () { 394 | return httptest({url: "http://localhost:" + port + "/empty"}) 395 | .cors("http://not.localhost") 396 | .expectStatus(200) 397 | .end().then(() => server.stop()); 398 | }); 399 | }); 400 | 401 | describe("CORS Headers", function () { 402 | const port = basePort + 7; 403 | 404 | this.timeout(timeout); 405 | this.tenso = tenso({port: port, initRoutes: routes, logging: {enabled: false}, security: {csrf: true}}); 406 | 407 | const server = this.tenso.start(); 408 | 409 | it("GET /test - exposes x-csrf-token header", function () { 410 | return httptest({url: "http://localhost:" + port + "/test"}) 411 | .cors("http://not.localhost") 412 | .expectHeader("access-control-expose-headers", /x-csrf-token/) 413 | .expectHeader("x-csrf-token", /\w/) 414 | .expectStatus(200) 415 | .end().then(() => server.stop()); 416 | }); 417 | }); 418 | 419 | describe("Sorting", function () { 420 | const port = basePort + 8; 421 | 422 | this.timeout(timeout); 423 | this.tenso = tenso({port: port, initRoutes: routes, logging: {enabled: false}, security: {csrf: false}}); 424 | 425 | const server = this.tenso.start(); 426 | 427 | it("GET /things?order_by=user_id%20asc&order_by=name%20desc - returns a sorted array of objects", function () { 428 | return httptest({url: "http://localhost:" + port + "/things?order_by=user_id%20asc&order_by=name%20desc"}) 429 | .expectStatus(200) 430 | .expectValue("data", [ 431 | { 432 | "id": 2, 433 | "name": "thing 2", 434 | "user_id": 1 435 | }, 436 | { 437 | "id": 1, 438 | "name": "thing 1", 439 | "user_id": 1, 440 | "welcome": "

blahblah

" 441 | }, 442 | { 443 | "id": 3, 444 | "name": "thing 3", 445 | "user_id": 2 446 | } 447 | ]) 448 | .expectValue("error", null) 449 | .expectValue("status", 200) 450 | .end(); 451 | }); 452 | 453 | it("GET /items?order_by=asc - returns a sorted array of primitives", function () { 454 | return httptest({url: "http://localhost:" + port + "/items?order_by=asc"}) 455 | .expectStatus(200) 456 | .expectValue("data", [ 457 | 1, 458 | 2, 459 | 3, 460 | 4, 461 | 5 462 | ]) 463 | .expectValue("error", null) 464 | .expectValue("status", 200) 465 | .end(); 466 | }); 467 | 468 | it("GET /items?order_by=desc - returns a sorted array of primitives", function () { 469 | return httptest({url: "http://localhost:" + port + "/items?order_by=desc"}) 470 | .expectStatus(200) 471 | .expectValue("data", [ 472 | 15, 473 | 14, 474 | 13, 475 | 12, 476 | 11 477 | ]) 478 | .expectValue("error", null) 479 | .expectValue("status", 200) 480 | .end().then(() => server.stop()); 481 | }); 482 | }); 483 | -------------------------------------------------------------------------------- /test/invalid.test.js: -------------------------------------------------------------------------------- 1 | import http from "node:http"; 2 | import {httptest} from "tiny-httptest"; 3 | import {tenso} from "../dist/tenso.cjs"; 4 | import {routes} from "./routes.js"; 5 | 6 | const timeout = 5000; 7 | 8 | describe("Invalid", function () { 9 | const port = 8020; 10 | 11 | this.timeout(timeout); 12 | this.tenso = tenso({port: port, initRoutes: routes, logging: {enabled: false}, security: {csrf: false}}); 13 | 14 | const server = this.tenso.start(); 15 | 16 | it("GET / (416 / 'Partial response - invalid')", function () { 17 | return httptest({url: "http://localhost:" + port + "/", headers: {range: "a-b"}}) 18 | .expectStatus(416) 19 | .expectValue("error", http.STATUS_CODES[416]) 20 | .end(); 21 | }); 22 | 23 | it("GET / (416 / 'Partial response - invalid #2')", function () { 24 | return httptest({url: "http://localhost:" + port + "/", headers: {range: "5-0"}}) 25 | .expectStatus(416) 26 | .expectValue("error", http.STATUS_CODES[416]) 27 | .end(); 28 | }); 29 | 30 | it("POST / (405 / 'Method Not Allowed')", function () { 31 | return httptest({url: "http://localhost:" + port + "/", method: "post"}) 32 | .expectStatus(405) 33 | .expectHeader("allow", "GET, HEAD, OPTIONS") 34 | .expectValue("error", http.STATUS_CODES[405]) 35 | .end(); 36 | }); 37 | 38 | it("PUT / (405 / 'Method Not Allowed')", function () { 39 | return httptest({url: "http://localhost:" + port + "/", method: "put"}) 40 | .expectStatus(405) 41 | .expectHeader("allow", "GET, HEAD, OPTIONS") 42 | .expectValue("error", http.STATUS_CODES[405]) 43 | .end(); 44 | }); 45 | 46 | it("PATCH / (405 / 'Method Not Allowed')", function () { 47 | return httptest({url: "http://localhost:" + port + "/", method: "patch"}) 48 | .expectStatus(405) 49 | .expectHeader("allow", "GET, HEAD, OPTIONS") 50 | .expectValue("error", http.STATUS_CODES[405]) 51 | .end(); 52 | }); 53 | 54 | it("DELETE / (405 / 'Method Not Allowed')", function () { 55 | return httptest({url: "http://localhost:" + port + "/", method: "delete"}) 56 | .expectStatus(405) 57 | .expectHeader("allow", "GET, HEAD, OPTIONS") 58 | .expectValue("error", http.STATUS_CODES[405]) 59 | .end(); 60 | }); 61 | 62 | it("GET /nothere.html (404 / 'Not Found')", function () { 63 | return httptest({url: "http://localhost:" + port + "/nothere.html"}) 64 | .expectStatus(404) 65 | .expectHeader("allow", "") 66 | .expectValue("error", http.STATUS_CODES[404]) 67 | .end(); 68 | }); 69 | 70 | it("GET /nothere.html%3fa=b?=c (404 / 'Not Found')", function () { 71 | return httptest({url: "http://localhost:" + port + "/nothere.html%3fa=b?=c"}) 72 | .expectStatus(404) 73 | .expectHeader("allow", "") 74 | .expectValue("error", http.STATUS_CODES[404]) 75 | .end(); 76 | }); 77 | 78 | it("GET /nothere.x_%22%3E%3Cimg%20src=x%20onerror=prompt(1)%3E.html (404 / 'Not Found')", function () { 79 | return httptest({url: "http://localhost:" + port + "/nothere.x_%22%3E%3Cimg%20src=x%20onerror=prompt(1)%3E.html"}) 80 | .expectStatus(404) 81 | .expectHeader("allow", "") 82 | .expectValue("error", http.STATUS_CODES[404]) 83 | .end(); 84 | }); 85 | 86 | it("POST /nothere.html (404 / 'Not Found')", function () { 87 | return httptest({url: "http://localhost:" + port + "/nothere.html", method: "post"}) 88 | .expectStatus(404) 89 | .expectHeader("allow", "") 90 | .expectValue("error", http.STATUS_CODES[404]) 91 | .end(); 92 | }); 93 | 94 | it("PUT /nothere.html (404 / 'Not Found')", function () { 95 | return httptest({url: "http://localhost:" + port + "/nothere.html", method: "put"}) 96 | .expectStatus(404) 97 | .expectHeader("allow", "") 98 | .expectValue("error", http.STATUS_CODES[404]) 99 | .end(); 100 | }); 101 | 102 | it("PATCH /nothere.html (404 / 'Not Found')", function () { 103 | return httptest({url: "http://localhost:" + port + "/nothere.html", method: "patch"}) 104 | .expectStatus(404) 105 | .expectHeader("allow", "") 106 | .expectValue("error", http.STATUS_CODES[404]) 107 | .end(); 108 | }); 109 | 110 | it("DELETE /nothere.html (404 / 'Not Found')", function () { 111 | return httptest({url: "http://localhost:" + port + "/nothere.html", method: "delete"}) 112 | .expectStatus(404) 113 | .expectHeader("allow", "") 114 | .expectValue("error", http.STATUS_CODES[404]) 115 | .end(); 116 | }); 117 | 118 | it("GET /../README (404 / 'Not Found')", function () { 119 | return httptest({url: "http://localhost:" + port + "/../README"}) 120 | .expectStatus(404) 121 | .expectHeader("allow", "") 122 | .expectValue("error", http.STATUS_CODES[404]) 123 | .end(); 124 | }); 125 | 126 | it("GET /././../README (404 / 'Not Found')", function () { 127 | return httptest({url: "http://localhost:" + port + "/././../README"}) 128 | .expectStatus(404) 129 | .expectHeader("allow", "") 130 | .expectValue("error", http.STATUS_CODES[404]) 131 | .end().then(() => server.stop()); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /test/renderers.test.js: -------------------------------------------------------------------------------- 1 | import {httptest} from "tiny-httptest"; 2 | import {tenso} from "../dist/tenso.cjs"; 3 | import {routes} from "./routes.js"; 4 | import {parse} from "csv-parse/sync"; 5 | 6 | const timeout = 5000; 7 | 8 | process.setMaxListeners(0); 9 | 10 | describe("Renderers", function () { 11 | const port = 8011; 12 | 13 | this.timeout(timeout); 14 | this.tenso = tenso({port: port, initRoutes: routes, logging: {enabled: false}, security: {csrf: false}}); 15 | 16 | const server = this.tenso.start(); 17 | 18 | it("GET CSV (header)", function () { 19 | return httptest({url: "http://localhost:" + port + "/things", headers: {accept: "text/csv"}}) 20 | .expectStatus(200) 21 | .expectHeader("content-type", "text/csv") 22 | .expectHeader("content-disposition", "attachment; filename=\"things.csv\"") 23 | .expectBody(arg => parse(arg) instanceof Object) 24 | .end(); 25 | }); 26 | 27 | it("GET CSV (query string)", function () { 28 | return httptest({url: "http://localhost:" + port + "/things?format=text/csv"}) 29 | .expectStatus(200) 30 | .expectHeader("content-type", "text/csv") 31 | .expectHeader("content-disposition", "attachment; filename=\"things.csv\"") 32 | .expectBody(arg => parse(arg) instanceof Object) 33 | .end(); 34 | }); 35 | 36 | it("GET CSV (invalid)", function () { 37 | return httptest({url: "http://localhost:" + port + "/abc/?format=text/csv"}) 38 | .expectStatus(404) 39 | .end(); 40 | }); 41 | 42 | it("GET JSONP (header)", function () { 43 | return httptest({url: "http://localhost:" + port, headers: {accept: "application/javascript"}}) 44 | .expectStatus(200) 45 | .expectHeader("content-type", "application/javascript") 46 | .expectBody(/^callback\(/) 47 | .end(); 48 | }); 49 | 50 | it("GET JSONP (query string)", function () { 51 | return httptest({url: "http://localhost:" + port + "/?format=application/javascript"}) 52 | .expectStatus(200) 53 | .expectHeader("content-type", "application/javascript") 54 | .expectBody(/^callback\(/) 55 | .end(); 56 | }); 57 | 58 | it("GET JSONP (invalid)", function () { 59 | return httptest({url: "http://localhost:" + port + "/abc/?format=application/javascript"}) 60 | .expectStatus(404) 61 | .end(); 62 | }); 63 | 64 | it("GET JSONP (header - custom callback)", function () { 65 | return httptest({ 66 | url: "http://localhost:" + port + "/?callback=custom", 67 | headers: {accept: "application/javascript"} 68 | }) 69 | .expectStatus(200) 70 | .expectHeader("content-type", "application/javascript") 71 | .expectBody(/^custom\(/) 72 | .end(); 73 | }); 74 | 75 | it("GET JSONP (query string - custom callback)", function () { 76 | return httptest({url: "http://localhost:" + port + "/?format=application/javascript&callback=custom"}) 77 | .expectStatus(200) 78 | .expectHeader("content-type", "application/javascript") 79 | .expectBody(/^custom\(/) 80 | .end(); 81 | }); 82 | 83 | it("GET JSONP (invalid)", function () { 84 | return httptest({url: "http://localhost:" + port + "/abc/?format=application/javascript&callback=custom"}) 85 | .expectStatus(404) 86 | .end(); 87 | }); 88 | 89 | it("GET HTML (header)", function () { 90 | return httptest({ 91 | url: "http://localhost:" + port, 92 | headers: {accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"} 93 | }) 94 | .expectStatus(200) 95 | .expectHeader("content-type", /text\/html/) 96 | .expectBody(/ server.stop()); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /test/routes.js: -------------------------------------------------------------------------------- 1 | import {randomUUID as uuid} from "node:crypto"; 2 | 3 | export const get = { 4 | "/": [ 5 | "empty", 6 | "items", 7 | "somethings", 8 | "test", 9 | "things", 10 | "users", 11 | "uuid" 12 | ], 13 | "/null": null, 14 | "/empty": [], 15 | "/items(/?)": [ 16 | 1, 17 | 2, 18 | 3, 19 | 4, 20 | 5, 21 | 6, 22 | 7, 23 | 8, 24 | 9, 25 | 10, 26 | 11, 27 | 12, 28 | 13, 29 | 14, 30 | 15 31 | ], 32 | "/test": "Get to the chopper!", 33 | "/test/:hidden": function (req, res) { 34 | res.send(req.params.hidden); 35 | }, 36 | "/things": [ 37 | { 38 | id: 1, 39 | name: "thing 1", 40 | user_id: 1, 41 | welcome: "

blahblah

" 42 | }, { 43 | id: 2, 44 | name: "thing 2", 45 | user_id: 1 46 | }, { 47 | id: 3, 48 | name: "thing 3", 49 | user_id: 2 50 | } 51 | ], 52 | "/somethings": [ 53 | "abc", 54 | "def" 55 | ], 56 | "/somethings/abc": { 57 | "_id": "abc", 58 | "user_id": 123, 59 | "title": "This is a title", 60 | "body": "Where is my body?", 61 | "source_url": "http://source.tld" 62 | }, 63 | "/somethings/def": { 64 | "_id": "def", 65 | "user_id": 123, 66 | "source_url": "http://source.tld" 67 | }, 68 | "/stream": (req, res) => req.server.eventsource("initialized").init(req, res).send("Hello World!", "welcome"), 69 | "/users": [ 70 | 123 71 | ], 72 | "/users/123": { 73 | "_id": 123, 74 | firstName: "Jason", 75 | lastName: "Mulligan" 76 | }, 77 | "/uuid": function (req, res) { 78 | res.send(uuid(), 200, {"cache-control": "no-cache, must-revalidate"}); 79 | } 80 | }; 81 | 82 | export const post = { 83 | "/test": function (req, res) { 84 | res.send("OK!"); 85 | } 86 | }; 87 | 88 | export const routes = {get, post}; 89 | export default routes; 90 | -------------------------------------------------------------------------------- /test/valid.test.js: -------------------------------------------------------------------------------- 1 | import {httptest} from "tiny-httptest"; 2 | import {tenso} from "../dist/tenso.cjs"; 3 | import {routes} from "./routes.js"; 4 | 5 | const timeout = 5000; 6 | 7 | describe("Valid", function () { 8 | const port = 3021; 9 | 10 | this.timeout(timeout); 11 | this.tenso = tenso({ 12 | port: port, 13 | initRoutes: routes, 14 | etags: {enabled: true}, 15 | logging: {enabled: false}, 16 | security: {csrf: false} 17 | }); 18 | 19 | const server = this.tenso.start(); 20 | 21 | it("GET / (200 / 'Array')", function () { 22 | return httptest({url: "http://localhost:" + port + "/"}) 23 | .expectStatus(200) 24 | .end(); 25 | }); 26 | 27 | it("HEAD / (200 / empty)", function () { 28 | return httptest({url: "http://localhost:" + port + "/", method: "head"}) 29 | .expectStatus(200) 30 | .expectHeader("allow", "GET, HEAD, OPTIONS") 31 | .expectHeader("content-length", 320) 32 | .expectBody(/^$/) 33 | .end(); 34 | }); 35 | 36 | it("OPTIONS / (200 / empty)", function () { 37 | return httptest({url: "http://localhost:" + port + "/", method: "options"}) 38 | .expectStatus(200) 39 | .expectHeader("allow", "GET, HEAD, OPTIONS") 40 | .expectHeader("content-length", 320) 41 | .expectValue("data", /\w/) 42 | .expectValue("error", null) 43 | .expectValue("status", 200) 44 | .end(); 45 | }); 46 | 47 | it("GET / (206 / 'Partial response - bytes=0-5')", function () { 48 | return httptest({url: "http://localhost:" + port + "/", headers: {range: "bytes=0-5"}}) 49 | .expectStatus(206) 50 | .expectHeader("content-range", /^bytes 0-5\/290$/) 51 | .expectHeader("content-length", 5) 52 | .expectBody(/^{"dat$/) 53 | .end(); 54 | }); 55 | 56 | it("GET / (206 / 'Partial response - bytes=-5')", function () { 57 | return httptest({url: "http://localhost:" + port + "/", headers: {range: "bytes=-5"}}) 58 | .expectStatus(206) 59 | .expectHeader("content-range", /^bytes 285-290\/290$/) 60 | .expectHeader("content-length", 5) 61 | .expectBody(/^:200}$/) 62 | .end(); 63 | }); 64 | 65 | it("GET /null (200 / 'null')", function () { 66 | return httptest({url: "http://localhost:" + port + "/null"}) 67 | .expectStatus(200) 68 | .expectHeader("allow", "GET, HEAD, OPTIONS") 69 | .expectValue("data", null) 70 | .expectValue("error", null) 71 | .expectValue("status", 200) 72 | .end().then(() => server.stop()); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /types/core/config.d.ts: -------------------------------------------------------------------------------- 1 | export namespace config { 2 | export namespace auth { 3 | export { INT_0 as delay }; 4 | export let protect: any[]; 5 | export let unprotect: any[]; 6 | export namespace basic { 7 | let enabled: boolean; 8 | let list: any[]; 9 | } 10 | export namespace bearer { 11 | let enabled_1: boolean; 12 | export { enabled_1 as enabled }; 13 | export let tokens: any[]; 14 | } 15 | export namespace jwt { 16 | let enabled_2: boolean; 17 | export { enabled_2 as enabled }; 18 | let auth_1: any; 19 | export { auth_1 as auth }; 20 | export { EMPTY as audience }; 21 | export let algorithms: string[]; 22 | export let ignoreExpiration: boolean; 23 | export { EMPTY as issuer }; 24 | export { BEARER as scheme }; 25 | export { EMPTY as secretOrKey }; 26 | } 27 | export namespace msg { 28 | export { MSG_LOGIN as login }; 29 | } 30 | export namespace oauth2 { 31 | let enabled_3: boolean; 32 | export { enabled_3 as enabled }; 33 | let auth_2: any; 34 | export { auth_2 as auth }; 35 | export { EMPTY as auth_url }; 36 | export { EMPTY as token_url }; 37 | export { EMPTY as client_id }; 38 | export { EMPTY as client_secret }; 39 | } 40 | export namespace uri { 41 | export { URL_AUTH_LOGIN as login }; 42 | export { URL_AUTH_LOGOUT as logout }; 43 | export { SLASH as redirect }; 44 | export { URL_AUTH_ROOT as root }; 45 | } 46 | export namespace saml { 47 | let enabled_4: boolean; 48 | export { enabled_4 as enabled }; 49 | let auth_3: any; 50 | export { auth_3 as auth }; 51 | } 52 | } 53 | export let autoindex: boolean; 54 | export { INT_1000 as cacheSize }; 55 | export { INT_300000 as cacheTTL }; 56 | export let catchAll: boolean; 57 | export { UTF_8 as charset }; 58 | export { EXPOSE_HEADERS as corsExpose }; 59 | export let defaultHeaders: { 60 | "content-type": string; 61 | vary: string; 62 | }; 63 | export { INT_3 as digit }; 64 | export let etags: boolean; 65 | export let exit: any[]; 66 | export { IP_0000 as host }; 67 | export namespace hypermedia { 68 | let enabled_5: boolean; 69 | export { enabled_5 as enabled }; 70 | export let header: boolean; 71 | } 72 | export let index: any[]; 73 | export let initRoutes: {}; 74 | export { INT_0 as jsonIndent }; 75 | export namespace logging { 76 | let enabled_6: boolean; 77 | export { enabled_6 as enabled }; 78 | export { LOG_FORMAT as format }; 79 | export { DEBUG as level }; 80 | export let stack: boolean; 81 | } 82 | export { INT_0 as maxBytes }; 83 | export { HEADER_APPLICATION_JSON as mimeType }; 84 | export let origins: string[]; 85 | export { INT_5 as pageSize }; 86 | export { INT_8000 as port }; 87 | export namespace prometheus { 88 | let enabled_7: boolean; 89 | export { enabled_7 as enabled }; 90 | export namespace metrics { 91 | let includeMethod: boolean; 92 | let includePath: boolean; 93 | let includeStatusCode: boolean; 94 | let includeUp: boolean; 95 | let buckets: number[]; 96 | let customLabels: {}; 97 | } 98 | } 99 | export namespace rate { 100 | let enabled_8: boolean; 101 | export { enabled_8 as enabled }; 102 | export { INT_450 as limit }; 103 | export { MSG_TOO_MANY_REQUESTS as message }; 104 | export let override: any; 105 | export { INT_900 as reset }; 106 | export { INT_429 as status }; 107 | } 108 | export let renderHeaders: boolean; 109 | export let time: boolean; 110 | export namespace security { 111 | export { X_CSRF_TOKEN as key }; 112 | export { TENSO as secret }; 113 | export let csrf: boolean; 114 | export let csp: any; 115 | export { SAMEORIGIN as xframe }; 116 | export { EMPTY as p3p }; 117 | export let hsts: any; 118 | export let xssProtection: boolean; 119 | export let nosniff: boolean; 120 | } 121 | export namespace session { 122 | export namespace cookie { 123 | export let httpOnly: boolean; 124 | export { SLASH as path }; 125 | export let sameSite: boolean; 126 | export { AUTO as secure }; 127 | } 128 | export { COOKIE_NAME as name }; 129 | export let proxy: boolean; 130 | export namespace redis { 131 | export { IP_127001 as host }; 132 | export { INT_6379 as port }; 133 | } 134 | export let rolling: boolean; 135 | export let resave: boolean; 136 | export let saveUninitialized: boolean; 137 | export { SESSION_SECRET as secret }; 138 | export { MEMORY as store }; 139 | } 140 | export let silent: boolean; 141 | export namespace ssl { 142 | let cert: any; 143 | let key: any; 144 | let pfx: any; 145 | } 146 | export namespace webroot { 147 | export { EMPTY as root }; 148 | export { PATH_ASSETS as static }; 149 | export { EMPTY as template }; 150 | } 151 | } 152 | import { INT_0 } from "./constants.js"; 153 | import { EMPTY } from "./constants.js"; 154 | import { BEARER } from "./constants.js"; 155 | import { MSG_LOGIN } from "./constants.js"; 156 | import { URL_AUTH_LOGIN } from "./constants.js"; 157 | import { URL_AUTH_LOGOUT } from "./constants.js"; 158 | import { SLASH } from "./constants.js"; 159 | import { URL_AUTH_ROOT } from "./constants.js"; 160 | import { INT_1000 } from "./constants.js"; 161 | import { INT_300000 } from "./constants.js"; 162 | import { UTF_8 } from "./constants.js"; 163 | import { EXPOSE_HEADERS } from "./constants.js"; 164 | import { INT_3 } from "./constants.js"; 165 | import { IP_0000 } from "./constants.js"; 166 | import { LOG_FORMAT } from "./constants.js"; 167 | import { DEBUG } from "./constants.js"; 168 | import { HEADER_APPLICATION_JSON } from "./constants.js"; 169 | import { INT_5 } from "./constants.js"; 170 | import { INT_8000 } from "./constants.js"; 171 | import { INT_450 } from "./constants.js"; 172 | import { MSG_TOO_MANY_REQUESTS } from "./constants.js"; 173 | import { INT_900 } from "./constants.js"; 174 | import { INT_429 } from "./constants.js"; 175 | import { X_CSRF_TOKEN } from "./constants.js"; 176 | import { TENSO } from "./constants.js"; 177 | import { SAMEORIGIN } from "./constants.js"; 178 | import { AUTO } from "./constants.js"; 179 | import { COOKIE_NAME } from "./constants.js"; 180 | import { IP_127001 } from "./constants.js"; 181 | import { INT_6379 } from "./constants.js"; 182 | import { SESSION_SECRET } from "./constants.js"; 183 | import { MEMORY } from "./constants.js"; 184 | import { PATH_ASSETS } from "./constants.js"; 185 | -------------------------------------------------------------------------------- /types/core/constants.d.ts: -------------------------------------------------------------------------------- 1 | export const ACCESS_CONTROL: "access-control"; 2 | export const ALGORITHMS: "algorithms"; 3 | export const ALLOW: "allow"; 4 | export const AUDIENCE: "audience"; 5 | export const AUTH: "auth"; 6 | export const AUTO: "auto"; 7 | export const BASIC: "basic"; 8 | export const BEARER: "Bearer"; 9 | export const BOOLEAN: "boolean"; 10 | export const CACHE_CONTROL: "cache-control"; 11 | export const CALLBACK: "callback"; 12 | export const CHARSET_UTF8: "; charset=utf-8"; 13 | export const COLLECTION: "collection"; 14 | export const COLON: ":"; 15 | export const COMMA: ","; 16 | export const COMMA_SPACE: ", "; 17 | export const CONNECT: "connect"; 18 | export const COOKIE_NAME: "tenso.sid"; 19 | export const DATA: "data"; 20 | export const DEBUG: "debug"; 21 | export const DEFAULT_CONTENT_TYPE: "application/json; charset=utf-8"; 22 | export const DEFAULT_VARY: "accept, accept-encoding, accept-language, origin"; 23 | export const DELETE: "DELETE"; 24 | export const DESC: "desc"; 25 | export const DOUBLE_SLASH: "//"; 26 | export const EMPTY: ""; 27 | export const ENCODED_SPACE: "%20"; 28 | export const END: "end"; 29 | export const EQ: "="; 30 | export const ERROR: "error"; 31 | export const EXPOSE: "expose"; 32 | export const EXPOSE_HEADERS: "cache-control, content-language, content-type, expires, last-modified, pragma"; 33 | export const FALSE: "false"; 34 | export const FIRST: "first"; 35 | export const FORMAT: "format"; 36 | export const FUNCTION: "function"; 37 | export const G: "g"; 38 | export const GET: "GET"; 39 | export const GT: ">"; 40 | export const HEAD: "HEAD"; 41 | export const HEADERS: "headers"; 42 | export const HEADER_ALLOW_GET: "GET, HEAD, OPTIONS"; 43 | export const HEADER_APPLICATION_JAVASCRIPT: "application/javascript"; 44 | export const HEADER_APPLICATION_JSON: "application/json"; 45 | export const HEADER_APPLICATION_JSONL: "application/jsonl"; 46 | export const HEADER_APPLICATION_JSON_LINES: "application/json-lines"; 47 | export const HEADER_APPLICATION_XML: "application/xml"; 48 | export const HEADER_APPLICATION_X_WWW_FORM_URLENCODED: "application/x-www-form-urlencoded"; 49 | export const HEADER_APPLICATION_YAML: "application/yaml"; 50 | export const HEADER_CONTENT_DISPOSITION: "content-disposition"; 51 | export const HEADER_CONTENT_DISPOSITION_VALUE: "attachment; filename=\"download.csv\""; 52 | export const HEADER_CONTENT_TYPE: "content-type"; 53 | export const HEADER_SPLIT: "\" <"; 54 | export const HEADER_TEXT_CSV: "text/csv"; 55 | export const HEADER_TEXT_HTML: "text/html"; 56 | export const HEADER_TEXT_JSON_LINES: "text/json-lines"; 57 | export const HEADER_TEXT_PLAIN: "text/plain"; 58 | export const HEADER_VARY: "vary"; 59 | export const HS256: "HS256"; 60 | export const HS384: "HS384"; 61 | export const HS512: "HS512"; 62 | export const HTML: "html"; 63 | export const HYPHEN: "-"; 64 | export const I: "i"; 65 | export const ID: "id"; 66 | export const IDENT_VAR: "indent="; 67 | export const ID_2: "_id"; 68 | export const IE: "ie"; 69 | export const INT_0: 0; 70 | export const INT_1: 1; 71 | export const INT_10: 10; 72 | export const INT_100: 100; 73 | export const INT_1000: 1000; 74 | export const INT_2: 2; 75 | export const INT_200: 200; 76 | export const INT_204: 204; 77 | export const INT_206: 206; 78 | export const INT_3: 3; 79 | export const INT_300000: 300000; 80 | export const INT_304: 304; 81 | export const INT_400: 400; 82 | export const INT_401: 401; 83 | export const INT_413: 413; 84 | export const INT_429: 429; 85 | export const INT_443: 443; 86 | export const INT_450: 450; 87 | export const INT_5: 5; 88 | export const INT_500: 500; 89 | export const INT_6379: 6379; 90 | export const INT_80: 80; 91 | export const INT_8000: 8000; 92 | export const INT_900: 900; 93 | export const INT_NEG_1: -1; 94 | export const INVALID_CONFIGURATION: "Invalid configuration"; 95 | export const IP_0000: "0.0.0.0"; 96 | export const IP_127001: "127.0.0.1"; 97 | export const ISSUER: "issuer"; 98 | export const ITEM: "item"; 99 | export const JWT: "jwt"; 100 | export const LAST: "last"; 101 | export const LT: "<"; 102 | export const LINK: "link"; 103 | export const LOG_FORMAT: "%h %l %u %t \"%r\" %>s %b"; 104 | export const MEMORY: "memory"; 105 | export const METRICS_PATH: "/metrics"; 106 | export const MSG_LOGIN: "POST 'username' & 'password' to authenticate"; 107 | export const MSG_PROMETHEUS_ENABLED: "Prometheus metrics enabled"; 108 | export const MSG_TOO_MANY_REQUESTS: "Too many requests"; 109 | export const MULTIPART: "multipart"; 110 | export const NEXT: "next"; 111 | export const NL: "\n"; 112 | export const NULL: "null"; 113 | export const NUMBER: "number"; 114 | export const OAUTH2: "oauth2"; 115 | export const OPTIONS: "OPTIONS"; 116 | export const ORDER_BY: "order_by"; 117 | export const PAGE: "page"; 118 | export const PAGE_SIZE: "page_size"; 119 | export const PATCH: "PATCH"; 120 | export const PATH_ASSETS: "/assets"; 121 | export const PERIOD: "."; 122 | export const PIPE: "|"; 123 | export const POST: "POST"; 124 | export const PREV: "prev"; 125 | export const PREV_DIR: ".."; 126 | export const PRIVATE: "private"; 127 | export const PROTECT: "protect"; 128 | export const PUT: "PUT"; 129 | export const READ: "read"; 130 | export const REDIS: "redis"; 131 | export const REGEX_REPLACE: ")).*$"; 132 | export const RELATED: "related"; 133 | export const REL_URI: "rel, uri"; 134 | export const RETRY_AFTER: "retry-after"; 135 | export const S: "s"; 136 | export const SAMEORIGIN: "SAMEORIGIN"; 137 | export const SESSION_SECRET: "tensoABC"; 138 | export const SIGHUP: "SIGHUP"; 139 | export const SIGINT: "SIGINT"; 140 | export const SIGTERM: "SIGTERM"; 141 | export const SLASH: "/"; 142 | export const SPACE: " "; 143 | export const STRING: "string"; 144 | export const TEMPLATE_ALLOW: "{{allow}}"; 145 | export const TEMPLATE_BODY: "{{body}}"; 146 | export const TEMPLATE_CSRF: "{{csrf}}"; 147 | export const TEMPLATE_FILE: "template.html"; 148 | export const TEMPLATE_FORMATS: "{{formats}}"; 149 | export const TEMPLATE_HEADERS: "{{headers}}"; 150 | export const TEMPLATE_METHODS: "{{methods}}"; 151 | export const TEMPLATE_TITLE: "{{title}}"; 152 | export const TEMPLATE_URL: "{{url}}"; 153 | export const TEMPLATE_VERSION: "{{version}}"; 154 | export const TEMPLATE_YEAR: "{{year}}"; 155 | export const TENSO: "tenso"; 156 | export const TRUE: "true"; 157 | export const UNDEFINED: "undefined"; 158 | export const UNDERSCORE: "_"; 159 | export const UNPROTECT: "unprotect"; 160 | export const URI: "uri"; 161 | export const URI_SCHEME: "://"; 162 | export const URL_127001: "http://127.0.0.1"; 163 | export const URL_AUTH_LOGIN: "/auth/login"; 164 | export const URL_AUTH_LOGOUT: "/auth/logout"; 165 | export const URL_AUTH_ROOT: "/auth"; 166 | export const UTF8: "utf8"; 167 | export const UTF_8: "utf-8"; 168 | export const WILDCARD: "*"; 169 | export const WWW: "www"; 170 | export const XML_ARRAY_NODE_NAME: "item"; 171 | export const XML_PROLOG: ""; 172 | export const X_CSRF_TOKEN: "x-csrf-token"; 173 | export const X_FORWARDED_PROTO: "x-forwarded-proto"; 174 | export const X_POWERED_BY: "x-powered-by"; 175 | export const X_RATELIMIT_LIMIT: "x-ratelimit-limit"; 176 | export const X_RATELIMIT_REMAINING: "x-ratelimit-remaining"; 177 | export const X_RATELIMIT_RESET: "x-ratelimit-reset"; 178 | -------------------------------------------------------------------------------- /types/middleware/asyncFlag.d.ts: -------------------------------------------------------------------------------- 1 | export function asyncFlag(req: any, res: any, next: any): void; 2 | -------------------------------------------------------------------------------- /types/middleware/bypass.d.ts: -------------------------------------------------------------------------------- 1 | export function bypass(req: any, res: any, next: any): void; 2 | -------------------------------------------------------------------------------- /types/middleware/csrf.d.ts: -------------------------------------------------------------------------------- 1 | export function csrfWrapper(req: any, res: any, next: any): void; 2 | -------------------------------------------------------------------------------- /types/middleware/guard.d.ts: -------------------------------------------------------------------------------- 1 | export function guard(req: any, res: any, next: any): void; 2 | -------------------------------------------------------------------------------- /types/middleware/keymaster.d.ts: -------------------------------------------------------------------------------- 1 | export function keymaster(req: any, res: any): void; 2 | -------------------------------------------------------------------------------- /types/middleware/parse.d.ts: -------------------------------------------------------------------------------- 1 | export function parse(req: any, res: any, next: any): void; 2 | -------------------------------------------------------------------------------- /types/middleware/payload.d.ts: -------------------------------------------------------------------------------- 1 | export function payload(req: any, res: any, next: any): void; 2 | -------------------------------------------------------------------------------- /types/middleware/prometheus.d.ts: -------------------------------------------------------------------------------- 1 | export function prometheus(config: any): any; 2 | -------------------------------------------------------------------------------- /types/middleware/rate.d.ts: -------------------------------------------------------------------------------- 1 | export function rate(req: any, res: any, next: any): void; 2 | -------------------------------------------------------------------------------- /types/middleware/redirect.d.ts: -------------------------------------------------------------------------------- 1 | export function redirect(req: any, res: any): void; 2 | -------------------------------------------------------------------------------- /types/middleware/zuul.d.ts: -------------------------------------------------------------------------------- 1 | export function zuul(req: any, res: any, next: any): void; 2 | -------------------------------------------------------------------------------- /types/parsers/json.d.ts: -------------------------------------------------------------------------------- 1 | export function json(arg?: string): any; 2 | -------------------------------------------------------------------------------- /types/parsers/jsonl.d.ts: -------------------------------------------------------------------------------- 1 | export function jsonl(arg?: string): Record | Record[]; 2 | -------------------------------------------------------------------------------- /types/parsers/xWwwFormURLEncoded.d.ts: -------------------------------------------------------------------------------- 1 | export function xWwwFormURLEncoded(arg: any): {}; 2 | -------------------------------------------------------------------------------- /types/renderers/csv.d.ts: -------------------------------------------------------------------------------- 1 | export function csv(req: any, res: any, arg: any): string; 2 | -------------------------------------------------------------------------------- /types/renderers/html.d.ts: -------------------------------------------------------------------------------- 1 | export function html(req: any, res: any, arg: any, tpl?: string): string; 2 | -------------------------------------------------------------------------------- /types/renderers/javascript.d.ts: -------------------------------------------------------------------------------- 1 | export function javascript(req: any, res: any, arg: any): string; 2 | -------------------------------------------------------------------------------- /types/renderers/json.d.ts: -------------------------------------------------------------------------------- 1 | export function json(req: any, res: any, arg: any): string; 2 | -------------------------------------------------------------------------------- /types/renderers/jsonl.d.ts: -------------------------------------------------------------------------------- 1 | export function jsonl(req: any, res: any, arg: any): string; 2 | -------------------------------------------------------------------------------- /types/renderers/plain.d.ts: -------------------------------------------------------------------------------- 1 | export function plain(req: any, res: any, arg: any): any; 2 | -------------------------------------------------------------------------------- /types/renderers/xml.d.ts: -------------------------------------------------------------------------------- 1 | export function xml(req: any, res: any, arg: any): string; 2 | -------------------------------------------------------------------------------- /types/renderers/yaml.d.ts: -------------------------------------------------------------------------------- 1 | export function yaml(req: any, res: any, arg: any): any; 2 | -------------------------------------------------------------------------------- /types/serializers/custom.d.ts: -------------------------------------------------------------------------------- 1 | export function custom(arg: any, err: any, status?: number, stack?: boolean): { 2 | data: any; 3 | error: any; 4 | links: any[]; 5 | status: number; 6 | }; 7 | -------------------------------------------------------------------------------- /types/serializers/plain.d.ts: -------------------------------------------------------------------------------- 1 | export function plain(arg: any, err: any, status?: number, stack?: boolean): any; 2 | -------------------------------------------------------------------------------- /types/tenso.d.ts: -------------------------------------------------------------------------------- 1 | export function tenso(userConfig?: {}): Tenso; 2 | declare class Tenso extends Woodland { 3 | constructor(config?: { 4 | auth: { 5 | delay: number; 6 | protect: any[]; 7 | unprotect: any[]; 8 | basic: { 9 | enabled: boolean; 10 | list: any[]; 11 | }; 12 | bearer: { 13 | enabled: boolean; 14 | tokens: any[]; 15 | }; 16 | jwt: { 17 | enabled: boolean; 18 | auth: any; 19 | audience: string; 20 | algorithms: string[]; 21 | ignoreExpiration: boolean; 22 | issuer: string; 23 | scheme: string; 24 | secretOrKey: string; 25 | }; 26 | msg: { 27 | login: string; 28 | }; 29 | oauth2: { 30 | enabled: boolean; 31 | auth: any; 32 | auth_url: string; 33 | token_url: string; 34 | client_id: string; 35 | client_secret: string; 36 | }; 37 | uri: { 38 | login: string; 39 | logout: string; 40 | redirect: string; 41 | root: string; 42 | }; 43 | saml: { 44 | enabled: boolean; 45 | auth: any; 46 | }; 47 | }; 48 | autoindex: boolean; 49 | cacheSize: number; 50 | cacheTTL: number; 51 | catchAll: boolean; 52 | charset: string; 53 | corsExpose: string; 54 | defaultHeaders: { 55 | "content-type": string; 56 | vary: string; 57 | }; 58 | digit: number; 59 | etags: boolean; 60 | exit: any[]; 61 | host: string; 62 | hypermedia: { 63 | enabled: boolean; 64 | header: boolean; 65 | }; 66 | index: any[]; 67 | initRoutes: {}; 68 | jsonIndent: number; 69 | logging: { 70 | enabled: boolean; 71 | format: string; 72 | level: string; 73 | stack: boolean; 74 | }; 75 | maxBytes: number; 76 | mimeType: string; 77 | origins: string[]; 78 | pageSize: number; 79 | port: number; 80 | prometheus: { 81 | enabled: boolean; 82 | metrics: { 83 | includeMethod: boolean; 84 | includePath: boolean; 85 | includeStatusCode: boolean; 86 | includeUp: boolean; 87 | buckets: number[]; 88 | customLabels: {}; 89 | }; 90 | }; 91 | rate: { 92 | enabled: boolean; 93 | limit: number; 94 | message: string; 95 | override: any; 96 | reset: number; 97 | status: number; 98 | }; 99 | renderHeaders: boolean; 100 | time: boolean; 101 | security: { 102 | key: string; 103 | secret: string; 104 | csrf: boolean; 105 | csp: any; 106 | xframe: string; 107 | p3p: string; 108 | hsts: any; 109 | xssProtection: boolean; 110 | nosniff: boolean; 111 | }; 112 | session: { 113 | cookie: { 114 | httpOnly: boolean; 115 | path: string; 116 | sameSite: boolean; 117 | secure: string; 118 | }; 119 | name: string; 120 | proxy: boolean; 121 | redis: { 122 | host: string; 123 | port: number; 124 | }; 125 | rolling: boolean; 126 | resave: boolean; 127 | saveUninitialized: boolean; 128 | secret: string; 129 | store: string; 130 | }; 131 | silent: boolean; 132 | ssl: { 133 | cert: any; 134 | key: any; 135 | pfx: any; 136 | }; 137 | webroot: { 138 | root: string; 139 | static: string; 140 | template: string; 141 | }; 142 | }); 143 | parsers: Map; 144 | rates: Map; 145 | renderers: Map; 146 | serializers: Map; 147 | server: any; 148 | version: any; 149 | canModify(arg: any): any; 150 | connect(req: any, res: any): void; 151 | eventsource(...args: any[]): import("tiny-eventsource").EventSource; 152 | final(req: any, res: any, arg: any): any; 153 | headers(req: any, res: any): void; 154 | init(): this; 155 | render(req: any, res: any, arg: any): string; 156 | parser(mediatype?: string, fn?: (arg: any) => any): this; 157 | rateLimit(req: any, fn: any): any[]; 158 | renderer(mediatype: any, fn: any): this; 159 | serializer(mediatype: any, fn: any): this; 160 | signals(): this; 161 | start(): this; 162 | stop(): this; 163 | } 164 | import { Woodland } from "woodland"; 165 | export {}; 166 | -------------------------------------------------------------------------------- /types/utils/auth.d.ts: -------------------------------------------------------------------------------- 1 | export function auth(obj: any): any; 2 | -------------------------------------------------------------------------------- /types/utils/chunk.d.ts: -------------------------------------------------------------------------------- 1 | export function chunk(arg?: any[], size?: number): any[][]; 2 | -------------------------------------------------------------------------------- /types/utils/clone.d.ts: -------------------------------------------------------------------------------- 1 | export function clone(arg: any): any; 2 | -------------------------------------------------------------------------------- /types/utils/delay.d.ts: -------------------------------------------------------------------------------- 1 | export function delay(fn?: () => any, n?: number): void; 2 | -------------------------------------------------------------------------------- /types/utils/explode.d.ts: -------------------------------------------------------------------------------- 1 | export function explode(arg?: string, delimiter?: string): string[]; 2 | -------------------------------------------------------------------------------- /types/utils/hasbody.d.ts: -------------------------------------------------------------------------------- 1 | export function hasBody(arg: any): any; 2 | -------------------------------------------------------------------------------- /types/utils/hypermedia.d.ts: -------------------------------------------------------------------------------- 1 | export function hypermedia(req: any, res: any, rep: any): any; 2 | -------------------------------------------------------------------------------- /types/utils/id.d.ts: -------------------------------------------------------------------------------- 1 | export function id(arg?: string): boolean; 2 | -------------------------------------------------------------------------------- /types/utils/indent.d.ts: -------------------------------------------------------------------------------- 1 | export function indent(arg?: string, fallback?: number): number; 2 | -------------------------------------------------------------------------------- /types/utils/isEmpty.d.ts: -------------------------------------------------------------------------------- 1 | export function isEmpty(arg?: string): arg is ""; 2 | -------------------------------------------------------------------------------- /types/utils/marshal.d.ts: -------------------------------------------------------------------------------- 1 | export function marshal(obj: any, rel: any, item_collection: any, root: any, seen: any, links: any, server: any): any; 2 | -------------------------------------------------------------------------------- /types/utils/parsers.d.ts: -------------------------------------------------------------------------------- 1 | export const parsers: Map; 2 | import { json } from "../parsers/json.js"; 3 | -------------------------------------------------------------------------------- /types/utils/random.d.ts: -------------------------------------------------------------------------------- 1 | export function random(n?: number): number; 2 | -------------------------------------------------------------------------------- /types/utils/regex.d.ts: -------------------------------------------------------------------------------- 1 | export const bodySplit: RegExp; 2 | export const collection: RegExp; 3 | export const hypermedia: RegExp; 4 | export const mimetype: RegExp; 5 | export const trailing: RegExp; 6 | export const trailingS: RegExp; 7 | export const trailingSlash: RegExp; 8 | export const trailingY: RegExp; 9 | -------------------------------------------------------------------------------- /types/utils/renderers.d.ts: -------------------------------------------------------------------------------- 1 | export const renderers: Map; 2 | import { html } from "../renderers/html.js"; 3 | -------------------------------------------------------------------------------- /types/utils/sanitize.d.ts: -------------------------------------------------------------------------------- 1 | export function sanitize(arg: any): any; 2 | -------------------------------------------------------------------------------- /types/utils/scheme.d.ts: -------------------------------------------------------------------------------- 1 | export function scheme(arg?: string): boolean; 2 | -------------------------------------------------------------------------------- /types/utils/serialize.d.ts: -------------------------------------------------------------------------------- 1 | export function serialize(req: any, res: any, arg: any): any; 2 | -------------------------------------------------------------------------------- /types/utils/serializers.d.ts: -------------------------------------------------------------------------------- 1 | export const serializers: Map; 2 | import { plain } from "../serializers/plain.js"; 3 | -------------------------------------------------------------------------------- /types/utils/sort.d.ts: -------------------------------------------------------------------------------- 1 | export function sort(arg: any, req: any): any; 2 | -------------------------------------------------------------------------------- /www/assets/css/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | min-height: 100vh; 4 | overflow-y: auto; 5 | overflow-x: hidden; 6 | } 7 | 8 | .table { 9 | background: none; 10 | } 11 | 12 | body { 13 | background: whitesmoke; 14 | color: #292929; 15 | } 16 | body.dark { 17 | background: #292929 !important; 18 | color: #dbdbdb !important; 19 | } 20 | body.dark h1 { 21 | color: #dbdbdb !important; 22 | } 23 | body.dark a:focus, body.dark a:hover, body.dark a:active, body.dark a.is-active { 24 | color: #dbdbdb !important; 25 | } 26 | body.dark .label, body.dark .table, body.dark .table th, body.dark .box { 27 | color: #dbdbdb !important; 28 | } 29 | body.dark .box { 30 | background: #363636 !important; 31 | } 32 | body.dark .item a { 33 | color: #3298dc !important; 34 | } 35 | body.dark .input:after, body.dark .input:focus, body.dark .input:hover, body.dark .input:active, body.dark .input.is-active, 36 | body.dark .textarea:after, 37 | body.dark .textarea:focus, 38 | body.dark .textarea:hover, 39 | body.dark .textarea:active, 40 | body.dark .textarea.is-active, 41 | body.dark .select:after, 42 | body.dark .select:focus, 43 | body.dark .select:hover, 44 | body.dark .select:active, 45 | body.dark .select.is-active, 46 | body.dark .select select:after, 47 | body.dark .select select:focus, 48 | body.dark .select select:hover, 49 | body.dark .select select:active, 50 | body.dark .select select.is-active { 51 | border-color: #3298dc !important; 52 | } 53 | body.dark button.is-primary { 54 | background-color: #3298dc !important; 55 | } 56 | body.dark .tabs li.is-active a { 57 | border-bottom-color: #3298dc !important; 58 | color: #3298dc !important; 59 | } 60 | body.dark code { 61 | background: #4a4a4a !important; 62 | color: #dbdbdb !important; 63 | } 64 | body.dark footer { 65 | background: #272727 !important; 66 | } 67 | body.dark footer a { 68 | color: #3298dc !important; 69 | } 70 | 71 | .app { 72 | width: 100% !important; 73 | max-width: 100% !important; 74 | min-height: 100vh; 75 | } 76 | .app > * { 77 | width: 100% !important; 78 | max-width: 100%; 79 | } 80 | 81 | .dr-hidden { 82 | display: none !important; 83 | } 84 | 85 | a:focus, a:hover, a:active, a.is-active { 86 | color: #00527D !important; 87 | } 88 | 89 | .item a { 90 | color: #00527D !important; 91 | } 92 | 93 | .input:after, .input:focus, .input:hover, .input:active, .input.is-active, 94 | .textarea:after, 95 | .textarea:focus, 96 | .textarea:hover, 97 | .textarea:active, 98 | .textarea.is-active, 99 | .select:after, 100 | .select:focus, 101 | .select:hover, 102 | .select:active, 103 | .select.is-active, 104 | .select select:after, 105 | .select select:focus, 106 | .select select:hover, 107 | .select select:active, 108 | .select select.is-active { 109 | border-color: #00527D; 110 | } 111 | 112 | button.is-primary { 113 | background-color: #00527D !important; 114 | } 115 | 116 | .tabs li.is-active a { 117 | border-bottom-color: #00527D !important; 118 | color: #00527D !important; 119 | } 120 | 121 | pre { 122 | padding: 10px; 123 | font-size: 1em; 124 | } 125 | 126 | code { 127 | background: whitesmoke; 128 | color: #dbdbdb; 129 | } 130 | 131 | .key { 132 | font-weight: bold; 133 | } 134 | .key.title, .key.subtitle { 135 | font-size: unset; 136 | line-height: unset; 137 | } 138 | 139 | .spaces { 140 | display: inline-block; 141 | width: 1.1em; 142 | } 143 | 144 | .spaced { 145 | margin-bottom: 25px; 146 | } 147 | 148 | .textarea { 149 | height: 300px; 150 | } 151 | 152 | .footer .logo { 153 | width: 160px; 154 | } 155 | .footer a { 156 | color: #00527D !important; 157 | } 158 | 159 | /*# sourceMappingURL=style.css.map */ 160 | -------------------------------------------------------------------------------- /www/assets/css/style.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":["style.scss"],"names":[],"mappings":"AASA;AAAA;EAEE;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE,YAlBoB;EAmBpB,OAjBe;;AAmBf;EACE;EACA;;AAEA;EACE;;AAIA;EAIE;;AAIJ;EACE;;AAGF;EACE;;AAIA;EACE;;AAQF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EAKE;;AAIJ;EACE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;;AAEA;EACE;;;AAMR;EACE;EACA;EACA;;AAEA;EACE;EACA;;;AAIJ;EACE;;;AAIA;EAIE;;;AAKF;EACE;;;AAQF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EAKE,cAlIG;;;AAsIP;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE,SAjJI;EAkJJ;;;AAGF;EACE,YAnJoB;EAoJpB,OAnJe;;;AAsJjB;EACE;;AAEA;EAEE;EACA;;;AAIJ;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAIA;EACE;;AAGF;EACE","file":"style.css"} -------------------------------------------------------------------------------- /www/assets/css/style.scss: -------------------------------------------------------------------------------- 1 | $ten: 10px; 2 | $blue: #00527D; 3 | $darkBlue: #3298dc; 4 | $lightCodeBackground: whitesmoke; 5 | $lightCodeColor: #dbdbdb; 6 | $darkBackground: #292929; 7 | $darkCode: #4a4a4a; 8 | $darkFooter: #272727; 9 | 10 | html, 11 | body { 12 | min-height: 100vh; 13 | overflow-y: auto; 14 | overflow-x: hidden; 15 | } 16 | 17 | .table { 18 | background: none; 19 | } 20 | 21 | body { 22 | background: $lightCodeBackground; 23 | color: $darkBackground; 24 | 25 | &.dark { 26 | background: $darkBackground !important; 27 | color: $lightCodeColor !important; 28 | 29 | h1 { 30 | color: $lightCodeColor !important; 31 | } 32 | 33 | a { 34 | &:focus, 35 | &:hover, 36 | &:active, 37 | &.is-active { 38 | color: $lightCodeColor !important; 39 | } 40 | } 41 | 42 | .label, .table, .table th, .box { 43 | color: $lightCodeColor !important; 44 | } 45 | 46 | .box { 47 | background: lighten($darkBackground, 5%) !important; 48 | } 49 | 50 | .item { 51 | a { 52 | color: $darkBlue !important; 53 | } 54 | } 55 | 56 | .input, 57 | .textarea, 58 | .select, 59 | .select select { 60 | &:after, 61 | &:focus, 62 | &:hover, 63 | &:active, 64 | &.is-active { 65 | border-color: $darkBlue !important; 66 | } 67 | } 68 | 69 | button.is-primary { 70 | background-color: $darkBlue !important; 71 | } 72 | 73 | .tabs li.is-active a { 74 | border-bottom-color: $darkBlue !important; 75 | color: $darkBlue !important; 76 | } 77 | 78 | code { 79 | background: $darkCode !important; 80 | color: $lightCodeColor !important; 81 | } 82 | 83 | footer { 84 | background: $darkFooter !important; 85 | 86 | a { 87 | color: $darkBlue !important; 88 | } 89 | } 90 | } 91 | } 92 | 93 | .app { 94 | width: 100% !important; 95 | max-width: 100% !important; 96 | min-height: 100vh; 97 | 98 | & > * { 99 | width: 100% !important; 100 | max-width: 100%; 101 | } 102 | } 103 | 104 | .dr-hidden { 105 | display: none !important; 106 | } 107 | 108 | a { 109 | &:focus, 110 | &:hover, 111 | &:active, 112 | &.is-active { 113 | color: $blue !important; 114 | } 115 | } 116 | 117 | .item { 118 | a { 119 | color: $blue !important; 120 | } 121 | } 122 | 123 | .input, 124 | .textarea, 125 | .select, 126 | .select select { 127 | &:after, 128 | &:focus, 129 | &:hover, 130 | &:active, 131 | &.is-active { 132 | border-color: $blue; 133 | } 134 | } 135 | 136 | button.is-primary { 137 | background-color: $blue !important; 138 | } 139 | 140 | .tabs li.is-active a { 141 | border-bottom-color: $blue !important; 142 | color: $blue !important; 143 | } 144 | 145 | pre { 146 | padding: $ten; 147 | font-size: 1em; 148 | } 149 | 150 | code { 151 | background: $lightCodeBackground; 152 | color: $lightCodeColor; 153 | } 154 | 155 | .key { 156 | font-weight: bold; 157 | 158 | &.title, 159 | &.subtitle { 160 | font-size: unset; 161 | line-height: unset; 162 | } 163 | } 164 | 165 | .spaces { 166 | display: inline-block; 167 | width: 1.1em; 168 | } 169 | 170 | .spaced { 171 | margin-bottom: $ten * 2.5; 172 | } 173 | 174 | .textarea { 175 | height: $ten * 30; 176 | } 177 | 178 | .footer { 179 | .logo { 180 | width: $ten * 16; 181 | } 182 | 183 | a { 184 | color: $blue !important; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /www/assets/img/avoidwork.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /www/assets/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avoidwork/tenso/791d1b020bfac28238caf4ead190fba75c4c6b05/www/assets/img/favicon.png -------------------------------------------------------------------------------- /www/assets/js/app.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | (function (document, window, location, fetch, router, localStorage) { 4 | // Wiring up the request tab 5 | const button = document.querySelector("button"), 6 | close = document.querySelector("#close"), 7 | form = document.querySelector("form"), 8 | formats = document.querySelector("#formats"), 9 | methods = document.querySelector("#methods"), 10 | modal = document.querySelector(".modal"), 11 | loading = modal.querySelector(".loading"), 12 | textarea = document.querySelector("textarea"), 13 | resBody = modal.querySelector(".body"), 14 | toggle = document.querySelector("#viewModeToggle"), 15 | body = document.querySelector("body"), 16 | json = /^[[{"]/, 17 | isJson = /application\/json/; 18 | 19 | if (methods.childElementCount > 0) { 20 | form.setAttribute("method", methods.options[methods.selectedIndex].value); 21 | } 22 | 23 | function escape (arg) { 24 | return arg.replace(/[-[\]{}()*+?.,\\/^$|#\s]/g, "\\$&"); 25 | } 26 | 27 | // Stage 1 of prettifying a 28 | function prepare (html) { 29 | const keys = Array.from(html.match(/".*":/g)), 30 | matches = Array.from(keys.concat(html.match(/:\s(".*"|\d{3,3}|null)/g))), 31 | replaces = matches.map(i => keys.includes(i) ? i.replace(/("(.*)")/, "$1") : i.replace(/(".*"|\d{3,3}|null)/, "$1")); 32 | 33 | let output = html; 34 | 35 | matches.forEach((i, idx) => { 36 | output = output.replace(new RegExp(escape(i), "g"), replaces[idx]); 37 | }); 38 | 39 | return output; 40 | } 41 | 42 | // Prettifies a 43 | function prettify (arg) { 44 | // Changing
 into selectable Elements
 45 | 		arg.parentNode.parentNode.innerHTML = prepare(arg.innerHTML)
 46 | 			.replace(/\n/g, "
\n") 47 | .replace(/(\s{2,2})/g, ""); 48 | 49 | // Changing URIs into anchors 50 | Array.from(document.querySelectorAll(".item")).forEach(i => { 51 | let html = i.innerHTML, 52 | val = html.replace(/(^"|"$)/g, ""); 53 | 54 | if (val.indexOf("/") === 0 || val.indexOf("//") > -1) { 55 | html = html.replace(val, `${val}`); 56 | } 57 | 58 | i.innerHTML = html; 59 | }); 60 | } 61 | 62 | function maybeJson (arg) { 63 | let output = false; 64 | 65 | if (json.test(arg)) { 66 | try { 67 | JSON.parse(JSON.stringify(arg)); 68 | output = true; 69 | } catch (err) { 70 | console.warn(err.message); 71 | } 72 | } 73 | 74 | return output; 75 | } 76 | 77 | function sanitize (arg = "") { 78 | let tmp = typeof arg !== "string" ? JSON.stringify(arg, null, 2) : arg; 79 | 80 | return tmp.replace(//g, ">"); 81 | } 82 | 83 | // Intercepting the submission 84 | form.onsubmit = ev => { 85 | ev.preventDefault(); 86 | ev.stopPropagation(); 87 | 88 | window.requestAnimationFrame(() => { 89 | resBody.innerText = ""; 90 | resBody.classList.add("dr-hidden"); 91 | loading.classList.remove("dr-hidden"); 92 | button.classList.add("is-loading"); 93 | modal.classList.add("is-active"); 94 | }); 95 | 96 | fetch(location.protocol + "//" + location.host + location.pathname, {method: methods.options[methods.selectedIndex].value, body: textarea.value, credentials: "include", headers: {"content-type": maybeJson(textarea.value) ? "application/json" : "application/x-www-form-urlencoded", "x-csrf-token": document.querySelector("#csrf").innerText}}).then(res => { 97 | if (!res.ok) { 98 | throw res; 99 | } 100 | 101 | return isJson.test(res.headers.get("content-type") || "") ? res.json() : res.text(); 102 | }).then(arg => { 103 | window.requestAnimationFrame(() => { 104 | resBody.innerHTML = arg.data !== undefined ? Array.isArray(arg.data) ? arg.data.map(i => sanitize(i)).join("
\n") : sanitize(arg.data) : sanitize(arg.data) || sanitize(arg); 105 | resBody.parentNode.classList.remove("has-text-centered"); 106 | resBody.classList.remove("dr-hidden"); 107 | loading.classList.add("dr-hidden"); 108 | button.classList.remove("is-loading"); 109 | }); 110 | }).catch(res => { 111 | window.requestAnimationFrame(() => { 112 | resBody.innerHTML = "

" + res.status + " - " + res.statusText + "

"; 113 | resBody.parentNode.classList.add("has-text-centered"); 114 | resBody.classList.remove("dr-hidden"); 115 | loading.classList.add("dr-hidden"); 116 | button.classList.remove("is-loading"); 117 | }); 118 | 119 | console.warn(res.status + " - " + res.statusText); 120 | }); 121 | }; 122 | 123 | methods.onchange = () => form.setAttribute("method", methods.options[methods.selectedIndex].value); 124 | 125 | // Creating a DOM router 126 | router({css: {current: "is-active", hidden: "dr-hidden"}, callback: ev => { 127 | window.requestAnimationFrame(() => { 128 | Array.from(document.querySelectorAll("li.is-active")).forEach(i => i.classList.remove("is-active")); 129 | ev.trigger?.[0]?.parentNode?.classList?.add("is-active"); 130 | }); 131 | }}); 132 | 133 | // Wiring up format selection 134 | close.onclick = ev => { 135 | ev.preventDefault(); 136 | ev.stopPropagation(); 137 | button.classList.remove("is-loading"); 138 | modal.classList.remove("is-active"); 139 | }; 140 | 141 | // Wiring up format selection 142 | formats.onchange = ev => { 143 | window.location = `${window.location.pathname}?format=${ev.target.options[ev.target.selectedIndex].value}${window.location.search.replace(/^\?/, "&")}`; 144 | }; 145 | 146 | // Dark mode toggle 147 | toggle.onclick = ev => { 148 | ev.preventDefault(); 149 | ev.stopPropagation(); 150 | window.requestAnimationFrame(() => { 151 | body.classList.toggle("dark"); 152 | const isDark = body.classList.contains("dark"); 153 | toggle.innerText = isDark ? "Light" : "Dark"; 154 | localStorage.setItem("tensoDark", isDark); 155 | }); 156 | }; 157 | 158 | // Setting up the UI 159 | window.requestAnimationFrame(() => { 160 | // Hiding the request tab if read-only 161 | if (!(/(PATCH|PUT|POST)/).test(document.querySelector("#allow").innerText)) { 162 | document.querySelector("li.request").classList.add("dr-hidden"); 163 | } 164 | 165 | // Resetting format selection (back button) 166 | formats.selectedIndex = 0; 167 | 168 | // Prettifying the response 169 | prettify(document.querySelector("#body")); 170 | 171 | // Setting up dark mode 172 | if (localStorage.getItem("tensoDark") === "true") { 173 | toggle.click(); 174 | console.log("Starting in dark mode"); 175 | } 176 | }); 177 | 178 | console.log([ 179 | " ,----,", 180 | " ,/ .`|", 181 | " ,` .' :", 182 | " ; ; /", 183 | ".'___,/ ,' ,---, ,---.", 184 | "| : | ,-+-. / | .--.--. ' ,'\\", 185 | "; |.'; ; ,---. ,--.'|' |/ / ' / / |", 186 | "`----' | | / \\| | ,\"' | : /`./ . ; ,. :", 187 | " ' : ; / / | | / | | : ;_ ' | |: :", 188 | " | | '. ' / | | | | |\\ \\ `.' | .; :", 189 | " ' : |' ; /| | | |/ `----. \\ : |", 190 | " ; |.' ' | / | | |--' / /`--' /\\ \\ /", 191 | " '---' | : | |/ '--'. / `----'", 192 | " \\ \\ /'---' `--'---'", 193 | " `----'" 194 | ].join("\n")); 195 | }(document, window, location, fetch, domRouter.router, localStorage)); 196 | -------------------------------------------------------------------------------- /www/assets/js/dom-router.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | 2022 Jason Mulligan 3 | @version 5.1.3 4 | */ 5 | !function(t,s){"object"==typeof exports&&"undefined"!=typeof module?s(exports):"function"==typeof define&&define.amd?define(["exports"],s):s((t="undefined"!=typeof globalThis?globalThis:t||self).domRouter={})}(this,(function(t){"use strict";const s="",e=/.*#/,i=window.requestAnimationFrame;class r{constructor(t){this.hash=t.hash,this.element=t.element,this.trigger=t.trigger,this.timestamp=(new Date).toISOString()}}class o{constructor({active:t=!0,callback:s=function(){},css:e={current:"dr-current",hidden:"dr-hidden"},ctx:i=document.body,start:r=null,delimiter:o="/",logging:h=!1,stickyPos:n=!0,stickyRoute:c=!0,stickySearchParams:a=!1,stop:l=!0,storage:d="session",storageKey:u="lastRoute"}={}){this.active=t,this.callback=s,this.css=e,this.ctx=i,this.delimiter=o,this.history=[],this.logging=h,this.routes=[],this.stickyPos=n,this.stickyRoute=c,this.stickySearchParams=a,this.storage="session"===d?sessionStorage:localStorage,this.storageKey=u,this.stop=l,this.start=this.stickyRoute&&this.storage.getItem(this.storageKey)||r}current(){return this.history[this.history.length-1]}handler(){const t=this.history.length>0&&(this.current().hash||s).replace(e,s)||null,o=location.hash.includes("#")?location.hash.replace(e,s):null;if(this.active&&this.valid(o))if(this.routes.includes(o)){const e=document.body.scrollTop,h=t?t.split(this.delimiter):[],n=o.split(this.delimiter),c=[];let a=s;for(const t of h)a+=`${a.length>0?`${this.delimiter}`:s}${t}`,c.push(...this.select(`a[href="#${a}"]`));i((()=>{let t,s;for(const t of c)t.classList.remove(this.css.current);for(const e of n.keys()){const i=e+1,r=h.length>=i,o=r?this.select(`#${h.slice(0,i).join(" #")}`):void 0,c=r?this.select(`a[href='#${h.slice(0,i).join(this.delimiter)}']`):void 0;t=this.select(`#${n.slice(0,i).join(" #")}`),s=this.select(`a[href='#${n.slice(0,i).join(this.delimiter)}']`),this.load(c,o,s,t)}this.stickyRoute&&this.storage.setItem(this.storageKey,o),this.stickyPos&&(document.body.scrollTop=e);const i=function(t={element:null,hash:"",trigger:null}){return new r(t)}({element:t,hash:o,trigger:s});if(!this.stickySearchParams){const t=new URL(location.href);for(const s of t.searchParams.keys())t.searchParams.delete(s);history.replaceState({},"",t.href)}this.log(i),this.callback(i)}))}else this.route(this.routes.filter((t=>t.includes(o)))[0]||this.start);return this}load(t=[],s=[],e=[],i=[]){for(const s of t)s.classList.remove(this.css.current);for(const[t,e]of s.entries())e.id!==i[t]?.id&&e.classList.add(this.css.hidden);for(const t of e)t.classList.add(this.css.current);for(const t of i)this.sweep(t,this.css.hidden);return this}log(t){return this.history.push(this.logging?t:{hash:t.hash}),this}popstate(t){return this.handler(t),this}process(){const t=document.location.hash.replace("#",s);this.scan(this.start),this.ctx.classList.contains(this.css.hidden)||(t.length>0&&this.routes.includes(t)?this.handler():this.route(this.start))}route(t=""){const s=new URL(location.href);return s.hash.replace("#","")!==t&&(s.hash=t,history.pushState({},"",s.href),this.handler()),this}select(t){return Array.from(this.ctx.querySelectorAll.call(this.ctx,t)).filter((t=>null!==t))}scan(t=""){const i=null===t?s:t;return this.routes=Array.from(new Set(this.select("a[href^='#']").map((t=>t.href.replace(e,s))).filter((t=>t!==s)))),i.length>0&&!this.routes.includes(i)&&this.routes.push(i),this.start=i||this.routes[0]||null,this}sweep(t,s){return i((()=>{Array.from(t.parentNode.childNodes).filter((s=>1===s.nodeType&&s.id&&s.id!==t.id)).forEach((t=>t.classList.add(s))),t.classList.remove(s)})),this}valid(t=""){return t===s||!1===/=/.test(t)}}t.router=function(t){const s=new o(t);return s.popstate=s.popstate.bind(s),"addEventListener"in window?window.addEventListener("popstate",s.popstate,!1):window.onpopstate=s.popstate,s.active&&s.process(),s},Object.defineProperty(t,"__esModule",{value:!0})}));//# sourceMappingURL=dom-router.min.js.map 6 | -------------------------------------------------------------------------------- /www/assets/js/dom-router.min.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"dom-router.min.js","sources":["../src/constants.js","../src/route.js","../src/router.js"],"sourcesContent":["export const cssCurrent = \"dr-current\";\nexport const cssHidden = \"dr-hidden\";\nexport const delimiter = \"/\";\nexport const empty = \"\";\nexport const hash = \"#\";\nexport const notHash = /.*#/;\nexport const render = window.requestAnimationFrame;\nexport const selectorHasHash = \"a[href^='#']\";\n","class Route {\n\tconstructor (cfg) {\n\t\tthis.hash = cfg.hash;\n\t\tthis.element = cfg.element;\n\t\tthis.trigger = cfg.trigger;\n\t\tthis.timestamp = new Date().toISOString();\n\t}\n}\n\nexport function route (cfg = {element: null, hash: \"\", trigger: null}) {\n\treturn new Route(cfg);\n}\n","import {cssCurrent, cssHidden, delimiter as slash, empty, hash, notHash, render, selectorHasHash} from \"./constants.js\";\nimport {route} from \"./route.js\";\n\nclass Router {\n\tconstructor ({active = true, callback = function () {}, css = {current: cssCurrent, hidden: cssHidden}, ctx = document.body, start = null, delimiter = slash, logging = false, stickyPos = true, stickyRoute = true, stickySearchParams = false, stop = true, storage = \"session\", storageKey = \"lastRoute\"} = {}) {\n\t\tthis.active = active;\n\t\tthis.callback = callback;\n\t\tthis.css = css;\n\t\tthis.ctx = ctx;\n\t\tthis.delimiter = delimiter;\n\t\tthis.history = [];\n\t\tthis.logging = logging;\n\t\tthis.routes = [];\n\t\tthis.stickyPos = stickyPos;\n\t\tthis.stickyRoute = stickyRoute;\n\t\tthis.stickySearchParams = stickySearchParams;\n\t\tthis.storage = storage === \"session\" ? sessionStorage : localStorage;\n\t\tthis.storageKey = storageKey;\n\t\tthis.stop = stop;\n\t\tthis.start = this.stickyRoute ? this.storage.getItem(this.storageKey) || start : start;\n\t}\n\n\tcurrent () {\n\t\treturn this.history[this.history.length - 1];\n\t}\n\n\thandler () {\n\t\tconst oldHash = this.history.length > 0 ? (this.current().hash || empty).replace(notHash, empty) || null : null,\n\t\t\tnewHash = location.hash.includes(hash) ? location.hash.replace(notHash, empty) : null;\n\n\t\tif (this.active && this.valid(newHash)) {\n\t\t\tif (!this.routes.includes(newHash)) {\n\t\t\t\tthis.route(this.routes.filter(i => i.includes(newHash))[0] || this.start);\n\t\t\t} else {\n\t\t\t\tconst y = document.body.scrollTop,\n\t\t\t\t\toldHashes = oldHash ? oldHash.split(this.delimiter) : [],\n\t\t\t\t\tnewHashes = newHash.split(this.delimiter),\n\t\t\t\t\tremove = [];\n\t\t\t\tlet oldRoute = empty;\n\n\t\t\t\tfor (const loldHash of oldHashes) {\n\t\t\t\t\toldRoute += `${oldRoute.length > 0 ? `${this.delimiter}` : empty}${loldHash}`;\n\t\t\t\t\tremove.push(...this.select(`a[href=\"#${oldRoute}\"]`));\n\t\t\t\t}\n\n\t\t\t\trender(() => {\n\t\t\t\t\tlet newEl, newTrigger;\n\n\t\t\t\t\tfor (const i of remove) {\n\t\t\t\t\t\ti.classList.remove(this.css.current);\n\t\t\t\t\t}\n\n\t\t\t\t\tfor (const idx of newHashes.keys()) {\n\t\t\t\t\t\tconst nth = idx + 1,\n\t\t\t\t\t\t\tvalid = oldHashes.length >= nth,\n\t\t\t\t\t\t\toldEl = valid ? this.select(`#${oldHashes.slice(0, nth).join(\" #\")}`) : void 0,\n\t\t\t\t\t\t\toldTrigger = valid ? this.select(`a[href='#${oldHashes.slice(0, nth).join(this.delimiter)}']`) : void 0;\n\n\t\t\t\t\t\tnewEl = this.select(`#${newHashes.slice(0, nth).join(\" #\")}`);\n\t\t\t\t\t\tnewTrigger = this.select(`a[href='#${newHashes.slice(0, nth).join(this.delimiter)}']`);\n\t\t\t\t\t\tthis.load(oldTrigger, oldEl, newTrigger, newEl);\n\t\t\t\t\t}\n\n\t\t\t\t\tif (this.stickyRoute) {\n\t\t\t\t\t\tthis.storage.setItem(this.storageKey, newHash);\n\t\t\t\t\t}\n\n\t\t\t\t\tif (this.stickyPos) {\n\t\t\t\t\t\tdocument.body.scrollTop = y;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst r = route({\n\t\t\t\t\t\telement: newEl,\n\t\t\t\t\t\thash: newHash,\n\t\t\t\t\t\ttrigger: newTrigger\n\t\t\t\t\t});\n\n\t\t\t\t\tif (!this.stickySearchParams) {\n\t\t\t\t\t\tconst url = new URL(location.href);\n\n\t\t\t\t\t\tfor (const key of url.searchParams.keys()) {\n\t\t\t\t\t\t\turl.searchParams.delete(key);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\thistory.replaceState({}, \"\", url.href);\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.log(r);\n\t\t\t\t\tthis.callback(r);\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\treturn this;\n\t}\n\n\tload (oldTrigger = [], oldEl = [], newTrigger = [], newEl = []) {\n\t\tfor (const i of oldTrigger) {\n\t\t\ti.classList.remove(this.css.current);\n\t\t}\n\n\t\tfor (const [idx, i] of oldEl.entries()) {\n\t\t\tif (i.id !== newEl[idx]?.id) {\n\t\t\t\ti.classList.add(this.css.hidden);\n\t\t\t}\n\t\t}\n\n\t\tfor (const i of newTrigger) {\n\t\t\ti.classList.add(this.css.current);\n\t\t}\n\n\t\tfor (const i of newEl) {\n\t\t\tthis.sweep(i, this.css.hidden);\n\t\t}\n\n\t\treturn this;\n\t}\n\n\tlog (arg) {\n\t\tthis.history.push(this.logging ? arg : {hash: arg.hash});\n\n\t\treturn this;\n\t}\n\n\tpopstate (ev) {\n\t\tthis.handler(ev);\n\n\t\treturn this;\n\t}\n\n\tprocess () {\n\t\tconst lhash = document.location.hash.replace(hash, empty);\n\n\t\tthis.scan(this.start);\n\n\t\tif (!this.ctx.classList.contains(this.css.hidden)) {\n\t\t\tif (lhash.length > 0 && this.routes.includes(lhash)) {\n\t\t\t\tthis.handler();\n\t\t\t} else {\n\t\t\t\tthis.route(this.start);\n\t\t\t}\n\t\t}\n\t}\n\n\troute (arg = empty) {\n\t\tconst url = new URL(location.href);\n\n\t\tif (url.hash.replace(\"#\", \"\") !== arg) {\n\t\t\turl.hash = arg;\n\t\t\thistory.pushState({}, \"\", url.href);\n\t\t\tthis.handler();\n\t\t}\n\n\t\treturn this;\n\t}\n\n\tselect (arg) {\n\t\treturn Array.from(this.ctx.querySelectorAll.call(this.ctx, arg)).filter(i => i !== null);\n\t}\n\n\tscan (input = empty) {\n\t\tconst arg = input === null ? empty : input;\n\n\t\tthis.routes = Array.from(new Set(this.select(selectorHasHash).map(i => i.href.replace(notHash, empty)).filter(i => i !== empty)));\n\n\t\tif (arg.length > 0 && !this.routes.includes(arg)) {\n\t\t\tthis.routes.push(arg);\n\t\t}\n\n\t\tthis.start = arg || this.routes[0] || null;\n\n\t\treturn this;\n\t}\n\n\tsweep (obj, klass) {\n\t\trender(() => {\n\t\t\tArray.from(obj.parentNode.childNodes).filter(i => i.nodeType === 1 && i.id && i.id !== obj.id).forEach(i => i.classList.add(klass));\n\t\t\tobj.classList.remove(klass);\n\t\t});\n\n\t\treturn this;\n\t}\n\n\tvalid (arg = empty) {\n\t\treturn arg === empty || (/=/).test(arg) === false;\n\t}\n}\n\nexport function router (arg) {\n\tconst obj = new Router(arg);\n\n\tobj.popstate = obj.popstate.bind(obj);\n\n\tif (\"addEventListener\" in window) {\n\t\twindow.addEventListener(\"popstate\", obj.popstate, false);\n\t} else {\n\t\twindow.onpopstate = obj.popstate;\n\t}\n\n\tif (obj.active) {\n\t\tobj.process();\n\t}\n\n\treturn obj;\n}\n"],"names":["empty","notHash","render","window","requestAnimationFrame","Route","constructor","cfg","this","hash","element","trigger","timestamp","Date","toISOString","Router","active","callback","css","current","hidden","ctx","document","body","start","delimiter","logging","stickyPos","stickyRoute","stickySearchParams","stop","storage","storageKey","history","routes","sessionStorage","localStorage","getItem","length","handler","oldHash","replace","newHash","location","includes","valid","y","scrollTop","oldHashes","split","newHashes","remove","oldRoute","loldHash","push","select","newEl","newTrigger","i","classList","idx","keys","nth","oldEl","slice","join","oldTrigger","load","setItem","r","route","url","URL","href","key","searchParams","delete","replaceState","log","filter","entries","id","add","sweep","arg","popstate","ev","process","lhash","scan","contains","pushState","Array","from","querySelectorAll","call","input","Set","map","obj","klass","parentNode","childNodes","nodeType","forEach","test","exports","router","bind","addEventListener","onpopstate","Object","defineProperty","value"],"mappings":";;;;iPAAO,MAGMA,EAAQ,GAERC,EAAU,MACVC,EAASC,OAAOC,sBCN7B,MAAMC,EACLC,YAAaC,GACZC,KAAKC,KAAOF,EAAIE,KAChBD,KAAKE,QAAUH,EAAIG,QACnBF,KAAKG,QAAUJ,EAAII,QACnBH,KAAKI,WAAY,IAAIC,MAAOC,aAC5B,ECHF,MAAMC,EACLT,aAAaU,OAACA,GAAS,EAAIC,SAAEA,EAAW,WAAc,EAAAC,IAAEA,EAAM,CAACC,QFJtC,aEI2DC,OFH5D,aEG8EC,IAAEA,EAAMC,SAASC,KAAIC,MAAEA,EAAQ,KAAMC,UAAAA,EFFnH,IEEoIC,QAAEA,GAAU,EAAKC,UAAEA,GAAY,EAAIC,YAAEA,GAAc,EAAIC,mBAAEA,GAAqB,EAAKC,KAAEA,GAAO,EAAIC,QAAEA,EAAU,UAASC,WAAEA,EAAa,aAAe,IAC9SxB,KAAKQ,OAASA,EACdR,KAAKS,SAAWA,EAChBT,KAAKU,IAAMA,EACXV,KAAKa,IAAMA,EACXb,KAAKiB,UAAYA,EACjBjB,KAAKyB,QAAU,GACfzB,KAAKkB,QAAUA,EACflB,KAAK0B,OAAS,GACd1B,KAAKmB,UAAYA,EACjBnB,KAAKoB,YAAcA,EACnBpB,KAAKqB,mBAAqBA,EAC1BrB,KAAKuB,QAAsB,YAAZA,EAAwBI,eAAiBC,aACxD5B,KAAKwB,WAAaA,EAClBxB,KAAKsB,KAAOA,EACZtB,KAAKgB,MAAQhB,KAAKoB,aAAcpB,KAAKuB,QAAQM,QAAQ7B,KAAKwB,aAAuBR,CACjF,CAEDL,UACC,OAAOX,KAAKyB,QAAQzB,KAAKyB,QAAQK,OAAS,EAC1C,CAEDC,UACC,MAAMC,EAAUhC,KAAKyB,QAAQK,OAAS,IAAK9B,KAAKW,UAAUV,MAAQT,GAAOyC,QAAQxC,EAASD,IAAiB,KAC1G0C,EAAUC,SAASlC,KAAKmC,SFxBP,KEwBwBD,SAASlC,KAAKgC,QAAQxC,EAASD,GAAS,KAElF,GAAIQ,KAAKQ,QAAUR,KAAKqC,MAAMH,GAC7B,GAAKlC,KAAK0B,OAAOU,SAASF,GAEnB,CACN,MAAMI,EAAIxB,SAASC,KAAKwB,UACvBC,EAAYR,EAAUA,EAAQS,MAAMzC,KAAKiB,WAAa,GACtDyB,EAAYR,EAAQO,MAAMzC,KAAKiB,WAC/B0B,EAAS,GACV,IAAIC,EAAWpD,EAEf,IAAK,MAAMqD,KAAYL,EACtBI,GAAY,GAAGA,EAASd,OAAS,EAAI,GAAG9B,KAAKiB,YAAczB,IAAQqD,IACnEF,EAAOG,QAAQ9C,KAAK+C,OAAO,YAAYH,QAGxClD,GAAO,KACN,IAAIsD,EAAOC,EAEX,IAAK,MAAMC,KAAKP,EACfO,EAAEC,UAAUR,OAAO3C,KAAKU,IAAIC,SAG7B,IAAK,MAAMyC,KAAOV,EAAUW,OAAQ,CACnC,MAAMC,EAAMF,EAAM,EACjBf,EAAQG,EAAUV,QAAUwB,EAC5BC,EAAQlB,EAAQrC,KAAK+C,OAAO,IAAIP,EAAUgB,MAAM,EAAGF,GAAKG,KAAK,cAAW,EACxEC,EAAarB,EAAQrC,KAAK+C,OAAO,YAAYP,EAAUgB,MAAM,EAAGF,GAAKG,KAAKzD,KAAKiB,qBAAkB,EAElG+B,EAAQhD,KAAK+C,OAAO,IAAIL,EAAUc,MAAM,EAAGF,GAAKG,KAAK,SACrDR,EAAajD,KAAK+C,OAAO,YAAYL,EAAUc,MAAM,EAAGF,GAAKG,KAAKzD,KAAKiB,gBACvEjB,KAAK2D,KAAKD,EAAYH,EAAON,EAAYD,EACzC,CAEGhD,KAAKoB,aACRpB,KAAKuB,QAAQqC,QAAQ5D,KAAKwB,WAAYU,GAGnClC,KAAKmB,YACRL,SAASC,KAAKwB,UAAYD,GAG3B,MAAMuB,ED9DJ,SAAgB9D,EAAM,CAACG,QAAS,KAAMD,KAAM,GAAIE,QAAS,OAC/D,OAAO,IAAIN,EAAME,EAClB,CC4De+D,CAAM,CACf5D,QAAS8C,EACT/C,KAAMiC,EACN/B,QAAS8C,IAGV,IAAKjD,KAAKqB,mBAAoB,CAC7B,MAAM0C,EAAM,IAAIC,IAAI7B,SAAS8B,MAE7B,IAAK,MAAMC,KAAOH,EAAII,aAAad,OAClCU,EAAII,aAAaC,OAAOF,GAGzBzC,QAAQ4C,aAAa,CAAE,EAAE,GAAIN,EAAIE,KACjC,CAEDjE,KAAKsE,IAAIT,GACT7D,KAAKS,SAASoD,EAAE,GAEjB,MA1DA7D,KAAK8D,MAAM9D,KAAK0B,OAAO6C,QAAOrB,GAAKA,EAAEd,SAASF,KAAU,IAAMlC,KAAKgB,OA6DrE,OAAOhB,IACP,CAED2D,KAAMD,EAAa,GAAIH,EAAQ,GAAIN,EAAa,GAAID,EAAQ,IAC3D,IAAK,MAAME,KAAKQ,EACfR,EAAEC,UAAUR,OAAO3C,KAAKU,IAAIC,SAG7B,IAAK,MAAOyC,EAAKF,KAAMK,EAAMiB,UACxBtB,EAAEuB,KAAOzB,EAAMI,IAAMqB,IACxBvB,EAAEC,UAAUuB,IAAI1E,KAAKU,IAAIE,QAI3B,IAAK,MAAMsC,KAAKD,EACfC,EAAEC,UAAUuB,IAAI1E,KAAKU,IAAIC,SAG1B,IAAK,MAAMuC,KAAKF,EACfhD,KAAK2E,MAAMzB,EAAGlD,KAAKU,IAAIE,QAGxB,OAAOZ,IACP,CAEDsE,IAAKM,GAGJ,OAFA5E,KAAKyB,QAAQqB,KAAK9C,KAAKkB,QAAU0D,EAAM,CAAC3E,KAAM2E,EAAI3E,OAE3CD,IACP,CAED6E,SAAUC,GAGT,OAFA9E,KAAK+B,QAAQ+C,GAEN9E,IACP,CAED+E,UACC,MAAMC,EAAQlE,SAASqB,SAASlC,KAAKgC,QF/HnB,IE+HiCzC,GAEnDQ,KAAKiF,KAAKjF,KAAKgB,OAEVhB,KAAKa,IAAIsC,UAAU+B,SAASlF,KAAKU,IAAIE,UACrCoE,EAAMlD,OAAS,GAAK9B,KAAK0B,OAAOU,SAAS4C,GAC5ChF,KAAK+B,UAEL/B,KAAK8D,MAAM9D,KAAKgB,OAGlB,CAED8C,MAAOc,EAAMpF,IACZ,MAAMuE,EAAM,IAAIC,IAAI7B,SAAS8B,MAQ7B,OANIF,EAAI9D,KAAKgC,QAAQ,IAAK,MAAQ2C,IACjCb,EAAI9D,KAAO2E,EACXnD,QAAQ0D,UAAU,CAAE,EAAE,GAAIpB,EAAIE,MAC9BjE,KAAK+B,WAGC/B,IACP,CAED+C,OAAQ6B,GACP,OAAOQ,MAAMC,KAAKrF,KAAKa,IAAIyE,iBAAiBC,KAAKvF,KAAKa,IAAK+D,IAAML,QAAOrB,GAAW,OAANA,GAC7E,CAED+B,KAAMO,EAAQhG,IACb,MAAMoF,EAAgB,OAAVY,EAAiBhG,EAAQgG,EAUrC,OARAxF,KAAK0B,OAAS0D,MAAMC,KAAK,IAAII,IAAIzF,KAAK+C,OF5JT,gBE4JiC2C,KAAIxC,GAAKA,EAAEe,KAAKhC,QAAQxC,EAASD,KAAQ+E,QAAOrB,GAAKA,IAAM1D,MAErHoF,EAAI9C,OAAS,IAAM9B,KAAK0B,OAAOU,SAASwC,IAC3C5E,KAAK0B,OAAOoB,KAAK8B,GAGlB5E,KAAKgB,MAAQ4D,GAAO5E,KAAK0B,OAAO,IAAM,KAE/B1B,IACP,CAED2E,MAAOgB,EAAKC,GAMX,OALAlG,GAAO,KACN0F,MAAMC,KAAKM,EAAIE,WAAWC,YAAYvB,QAAOrB,GAAoB,IAAfA,EAAE6C,UAAkB7C,EAAEuB,IAAMvB,EAAEuB,KAAOkB,EAAIlB,KAAIuB,SAAQ9C,GAAKA,EAAEC,UAAUuB,IAAIkB,KAC5HD,EAAIxC,UAAUR,OAAOiD,EAAM,IAGrB5F,IACP,CAEDqC,MAAOuC,EAAMpF,IACZ,OAAOoF,IAAQpF,IAA6B,IAApB,IAAMyG,KAAKrB,EACnC,EAmBFsB,EAAAC,OAhBO,SAAiBvB,GACvB,MAAMe,EAAM,IAAIpF,EAAOqE,GAcvB,OAZAe,EAAId,SAAWc,EAAId,SAASuB,KAAKT,GAE7B,qBAAsBhG,OACzBA,OAAO0G,iBAAiB,WAAYV,EAAId,UAAU,GAElDlF,OAAO2G,WAAaX,EAAId,SAGrBc,EAAInF,QACPmF,EAAIZ,UAGEY,CACR,EAAAY,OAAAC,eAAAN,EAAA,aAAA,CAAAO,OAAA,GAAA"} -------------------------------------------------------------------------------- /www/sample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tenso Assets 6 | 7 | 8 | css, img, js 9 | 10 | -------------------------------------------------------------------------------- /www/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{title}} 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 |

17 | {{title}} 18 |

19 |
20 |
21 |
22 |
23 |
24 |
25 | 33 |
34 |
35 |
36 |
37 |
38 |
39 | 40 |

41 | 42 | 45 | 46 |

47 |
48 |
49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | {{headers}} 60 | 61 |
HeaderValue
62 |
63 |
64 |
65 |
66 | 67 |
{{body}}
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | 78 |

79 | 80 | 83 | 84 |

85 |
86 |
87 | 88 |

89 | 90 |

91 |
92 | 93 |
94 |

95 | 96 |

97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |


110 | Tenso {{version}}
111 | Dark

112 |
113 |
114 |
115 |
116 |
117 | 133 |
{{allow}}
134 |
{{csrf}}
135 | 136 | 137 | 138 | 139 | --------------------------------------------------------------------------------