├── .gitignore ├── CHANGES.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README-lock.md ├── README-map.md ├── README-tag.md ├── README-vary.md ├── README.md ├── bin └── upcache.js ├── lib ├── common.js ├── index.js ├── lock.js ├── map.js ├── spawner.js └── tag.js ├── nginx ├── README.md ├── conf.d │ ├── gzip.conf │ ├── memcached.conf │ ├── proxy.conf │ └── upcache.conf ├── location.d │ ├── upcache-directives.conf │ └── upcache.conf ├── nginx.conf ├── server.d │ ├── upcache-memcached.conf │ └── upcache-redis.conf └── sites │ └── sample.conf ├── package.json ├── package.rockspec ├── test ├── Makefile ├── cache.js ├── common.js ├── handshake.js ├── lock.js ├── map-lock.js ├── map.js ├── tag-lock.js ├── tag.js └── vary.js ├── upcache.lua └── upcache ├── common.lua ├── lock.lua ├── map.lua ├── tag.lua └── vary.lua /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | test/runner/.temp.conf 36 | test/fixtures/*.pem 37 | 38 | rocks/* 39 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | 0.5.0 5 | ----- 6 | 7 | - introduced scope.sign(user, opts) to get a jwt without reference to express 8 | - refactor how variants are stored - use the same shared dictionnary 9 | - update nginx config to allow more memory for the shared dictionnaries 10 | - pass HttpError from http-errors module for throwing forbidden/unauthorized errors. 11 | The corresponding scope() options are no longer in use. 12 | 13 | 0.6.0 14 | ----- 15 | 16 | - factored restrict and allowed, renamed allowed to `test` 17 | - parametrized scopes now correctly return wildcard headers 18 | 19 | 0.7.0 20 | ----- 21 | 22 | - issuer is the hostname and cannot be configured 23 | 24 | 0.8.0 25 | ----- 26 | 27 | - scope.serializeBearer(req, user, opts) 28 | - `make luarocks` installs lua modules in a local tree 29 | 30 | 0.9.0 31 | ----- 32 | 33 | - nothing is cached unless tagged 34 | - no particular peremption is set (used to be 1 day by default if nothing was set) 35 | 36 | 1.0.0 37 | ----- 38 | 39 | - major breaking changes: scopes are now locks and has been simplified a lot. 40 | 41 | 2.2.0 42 | ----- 43 | 44 | - upcache.disabled (the lua module) let lua-ngx code disable upcache 45 | 46 | 2.6.0 47 | ----- 48 | 49 | - more es6 javascript 50 | - fix Vary handling when no response header is set 51 | - test Vary with Map 52 | 53 | 2.7.0 54 | ----- 55 | 56 | - tag.for cannot override tag.disable 57 | - better support for headers lists 58 | 59 | 2.8.0 60 | ----- 61 | 62 | - lock: req.user.grants is always set 63 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # How to 2 | # build `docker build -t kapouer/upcache .` 3 | # run `docker run -p 3001:3001 --net="host" -t kapouer/upcache` 4 | # ps `docker ps` 5 | # stop `docker stop containerName` 6 | # images `docker images` 7 | # remove `docker rmi -f imageId` 8 | # shell `docker run --rm -it kapouer/upcache bash -il` 9 | 10 | # debian stretch 11 | FROM debian:stretch-slim 12 | 13 | LABEL name="upcache" version="0.6.1" 14 | 15 | ENV DEBIAN_FRONTEND=noninteractive 16 | 17 | RUN mkdir -p /usr/share/man/man1 /usr/share/man/man7 /tmp 18 | RUN apt-get update && apt-get install -y --no-install-recommends wget gnupg ca-certificates apt-transport-https 19 | RUN echo "deb https://people.debian.org/~kapouer/apt/ stretch contrib" >> /etc/apt/sources.list 20 | RUN wget https://people.debian.org/~kapouer/apt/kapouer.gpg.key && apt-key add kapouer.gpg.key 21 | RUN apt-get update && apt-get install -y --no-install-recommends \ 22 | nginx \ 23 | libnginx-mod-http-lua \ 24 | libnginx-mod-http-set-misc \ 25 | libnginx-mod-http-srcache-filter \ 26 | libnginx-mod-http-memc \ 27 | memcached \ 28 | luarocks unzip \ 29 | lua-cjson \ 30 | nodejs nodejs-legacy npm 31 | 32 | RUN apt-get clean 33 | 34 | RUN luarocks install upcache 35 | 36 | # machine-id 37 | RUN echo "12e11ceb84fefe777a02ef52000007db" > /etc/machine-id 38 | 39 | # create user 40 | RUN useradd -m user 41 | 42 | WORKDIR /home/user 43 | 44 | COPY . . 45 | 46 | USER user 47 | RUN npm install 48 | 49 | USER root 50 | RUN apt-get purge -y luarocks unzip wget gnupg apt-transport-https npm 51 | RUN rm -rf /var/lib/apt/* 52 | 53 | # expose app port 54 | EXPOSE 3001 55 | 56 | RUN chown -R user:user /home/user/nginx && chown -R user:user /var/lib/nginx 57 | 58 | USER user 59 | CMD ./bin/upcache.js 60 | 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jérémy Lal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | luarocks: 3 | luarocks --tree=rocks install lua-resty-jwt 0.2.3 4 | curl -L https://github.com/openresty/lua-resty-string/archive/v0.15.tar.gz | \ 5 | tar -C ./rocks/share/lua/5.1/ -x -v -z -f - \ 6 | --wildcards '*/lib/resty/*' --strip-components 2 7 | curl -L https://github.com/openresty/lua-resty-lock/archive/v0.09.tar.gz | \ 8 | tar -C ./rocks/share/lua/5.1/ -x -v -z -f - \ 9 | --wildcards '*/lib/resty/*' --strip-components 2 10 | curl -L https://github.com/openresty/lua-resty-redis/archive/v0.29.tar.gz | \ 11 | tar -C ./rocks/share/lua/5.1/ -x -v -z -f - \ 12 | --wildcards '*/lib/resty/*' --strip-components 2 13 | 14 | 15 | nginx/mime.types: 16 | cd nginx && ln -sf /etc/nginx/mime.types . 17 | 18 | -------------------------------------------------------------------------------- /README-lock.md: -------------------------------------------------------------------------------- 1 | Upcache Lock 2 | ============ 3 | 4 | Simple access restrictions for the application and the proxy. 5 | 6 | Introduction 7 | ------------ 8 | 9 | Access restrictions often comes with a heavy price to pay regarding the ability 10 | to cache resources. 11 | 12 | Upcache Locks let the application dynamically setup the caching proxy (nginx with 13 | srcache here) so resources cache keys can vary on user grants 14 | based on how resources locks are set. 15 | 16 | How it works 17 | ------------ 18 | 19 | Client authenticates using a Json Web Token (jwt) signed with a RSA asymmetric key. 20 | 21 | The payload of the jwt must have a `grants` array. 22 | 23 | The application writes HTTP response headers so the proxy gets: 24 | 25 | - the RSA public key (only if the proxy requested it) 26 | - the list of locks the resource varies upon. 27 | 28 | When a client requests a resource to the proxy: 29 | 30 | - the proxy checks if the client has a valid jwt bearer cookie 31 | - and checks the list of known locks that resource varies upon 32 | - all the client jwt grants that are listed in the list of locks are used 33 | to build a resource cache key 34 | - if the resource is not already cached or if there were no known locks, 35 | the request is handed over to the application. 36 | 37 | Note that it's up to the application to make the access control checks, 38 | and return in the HTTP response headers (using upcache node module) 39 | the complete list of potential locks for a given resource: 40 | **that list must not vary on user grants** 41 | 42 | Usage 43 | ----- 44 | 45 | ```js 46 | const locker = require('upcache').lock({ 47 | publicKey: , 48 | privateKey: , 49 | algorithm: 'RS256', // default value, optional 50 | maxAge: age in seconds, must be an integer, 51 | userProperty: "user", // default value, optional, sets req[userProperty] 52 | varname: "cookie_bearer" // default value, optional, tells where jwt is 53 | }); 54 | 55 | app.use(locker.init); 56 | 57 | app.post("/login", async (req, res, next) => { 58 | const user = await dblogin(req.body.login, req.body.password); 59 | user.grants = ['subscriber', 'editor']; 60 | locker.login(res, user); 61 | }); 62 | 63 | app.get("/logout", (req, res, next) => { 64 | locker.logout(res); 65 | res.sendStatus(204); 66 | }); 67 | 68 | app.get('/api/user', locker.vary("id-:id", "webmaster"), (req, res, next) => { 69 | const user = await User.get(req.user.id); 70 | if (!req.user.grants.includes('webmaster')) delete user.privateData; 71 | return user; 72 | }); 73 | 74 | ``` 75 | 76 | Grants 77 | ------ 78 | 79 | A jwt must carry `grants`: an array of alphanumeric strings. 80 | 81 | Access is considered granted (or unlocked) if one grant unlocks one of the locks. 82 | 83 | Locks 84 | ----- 85 | 86 | The application returns lists of locks to the proxy. 87 | 88 | A lock can be any alphanumeric constant naming a grant - a *litteral* lock. 89 | 90 | Otherwise a lock is a *template* lock: 91 | 92 | - it can contain a wildcard `str*`, in which case all user grants matching 93 | that lock will be used to build a cache key; 94 | - it can even be `*` in which case all user grants make the cache key vary; 95 | - it can contain a named parameter `str:key` in which case the `:key` is 96 | replaced by a value in the jwt payload[key]. 97 | 98 | Middlewares and methods 99 | ----------------------- 100 | 101 | user is an object expected to have a `grants` array of strings. 102 | 103 | For defining locks: 104 | 105 | - locker.init(req, res, next) 106 | middleware setting up handshake and cookie name headers, and req[userProperty] 107 | - locker.vary(locks) 108 | returns a middleware that calls locker.headers 109 | - locker.headers(res, locks) 110 | sets response headers 111 | 112 | Helpers for jwt and cookie handling: 113 | 114 | - locker.sign(user, opts) 115 | sign user with opts.hostname as issuer, opts.maxAge, returns a jwt 116 | - locker.login(res, user, opts) 117 | calls sign and sets bearer 118 | - locker.logout(res) 119 | unsets cookie 120 | 121 | This library propose a general implementation for access restrictions: 122 | 123 | - locker.restrict(lockA, lockB, ...) 124 | returns a middleware that sends 401/403 or let through. 125 | Mind that `restrict('*')` will vary on all grants while not locking the resource; 126 | also `restrict('xxx-:id')` will only lock jwt that do not have an `id` property. 127 | To actually restrict by id, see examples in test/lock.js. 128 | It calls locker.headers() with the list of locks. 129 | 130 | http response headers 131 | --------------------- 132 | 133 | - X-Upcache-Lock 134 | list of locks for the current url 135 | 136 | - X-Upcache-Lock-Var (optional, defaults to cookie_bearer) 137 | The name of the ngx var that contains the json web token, 138 | can be `cookie_xxx` or `http_xxx` where xxx is lowercased, and dashes 139 | converted to underscores. 140 | 141 | - X-Upcache-Lock-Key (upon request) 142 | when the proxy sets X-Upcache-Lock-Key=1 in a request header, 143 | the application must return the rsa public key in this response header. 144 | -------------------------------------------------------------------------------- /README-map.md: -------------------------------------------------------------------------------- 1 | Upcache Map 2 | =========== 3 | 4 | Maps a request path to another request path. 5 | 6 | Introduction 7 | ------------ 8 | 9 | This is useful for catch-all pages. 10 | 11 | The mapping applies after other causes of variations. 12 | 13 | Usage 14 | ----- 15 | 16 | ```js 17 | const map = require('upcache').map; 18 | const tag = require('upcache').tag; 19 | 20 | app.get('*', tag('app'), async (req, res, next) => { 21 | try { 22 | const html = await decideContent(req.path); 23 | res.send(html); 24 | } catch(err) { 25 | map(res, '/.well-known/404'); 26 | res.send(htmlNotFound); 27 | } 28 | }); 29 | ``` 30 | 31 | http response headers 32 | --------------------- 33 | 34 | * X-Upcache-Map 35 | contains the path to map the request path to. 36 | -------------------------------------------------------------------------------- /README-tag.md: -------------------------------------------------------------------------------- 1 | Tag protocol 2 | ============ 3 | 4 | Build proxy cache keys using tags set by application, and let application 5 | invalidate all url for a given tag at once. 6 | 7 | Usage 8 | ----- 9 | 10 | ```js 11 | var tag = require('tag'); 12 | 13 | // setup a global app tag, 14 | 15 | app.use(tag('app')); 16 | 17 | app.post('/protected/purge', tag('app'), function(req, res, next) { 18 | // see "scope" for setting up permissions 19 | res.sendStatus(200); 20 | }); 21 | 22 | // per-resource tagging 23 | 24 | app.get('/api/collection', tag('zone'), appMw); 25 | app.post('/api/collection', tag('zone'), appMw); 26 | 27 | // multiple tags can be set 28 | app.get('/api/other', tag('zone', 'all'), ...); 29 | 30 | // a route can invalidate tags set on other routes 31 | app.put('/api/sample', tag('all'), ...); 32 | 33 | // a tag can depend on the route using req.locals or req.params replacement 34 | // no replacement is made if no param is defined. 35 | app.get('/:domain/list', tag(':domain'), ...); 36 | 37 | 38 | app.use(function(req, res, next) { 39 | res.locals.test = "test"; 40 | next(); 41 | }, tag('has-:test')); 42 | 43 | // if the replaced parameter is null, the tag is not added 44 | 45 | ``` 46 | 47 | `tag(...)(req, res, next)` can also be called directly, next being optional. 48 | 49 | The last argument of tag() can be a function replacing the default deciding 50 | when tags must be incremented: 51 | 52 | ```js 53 | function incFn(req) { 54 | return req.method != "GET"; 55 | } 56 | ``` 57 | 58 | Simplified access to cache-control directives is made available through 59 | `tag.for(...)` or `tag(...).for(...)` method, 60 | which accepts one argument: 61 | 62 | - string or number: a ttl in string format or in seconds 63 | - object: options for [express-cache-response-directive](https://github.com/kapouer/express-cache-response-directive). 64 | 65 | A `for()` call has no effect if the previous `tag()` call actually did not insert a tag. 66 | 67 | A middleware for disabling cache is also available with `tag.disable()`. 68 | If set, other calls to `tag.for()` on the same route will be ignored. 69 | 70 | ```js 71 | app.get('/api/stats', tag.for('1d'), appMw); 72 | app.get('/api/user', tag('user-*').for('10min'), appMw); 73 | app.get('/api/user', tag('user-*').for(3600), appMw); 74 | 75 | app.get('/trigger', tag.disable(), ...); // disable cache 76 | ``` 77 | 78 | Cache protocol 79 | -------------- 80 | 81 | Application tags resources by replying `X-Upcache-Tag: mytag` response header 82 | to set resource to latest known value for that tag, or `X-Upcache-Tag: +mytag` 83 | to increment the value known by the proxy for that tag. 84 | 85 | Proxy stores `mytag` as a sub-variant tag key for that url, and stores that 86 | value (or zero if initial) for that tag. 87 | This is like a `Vary: mytag` where `mytag` actual value is stored internally. 88 | 89 | The cache key formula is `mytag=curval`. Thus all tagged resources can be 90 | invalidated at once without any performance impact: the variants cache and the 91 | cache storage backend are both LRU caches, so they don't actually need to be 92 | purged - requests keys just need to be changed. 93 | 94 | For now only the proxy knows the tags values. 95 | 96 | Golden rule 97 | ----------- 98 | 99 | Never set a max-age on a mutable resource (unless you know it's okay to serve it perempted), 100 | only set a tag. 101 | 102 | Sample setup 103 | ------------ 104 | 105 | ```js 106 | // application-level tag, changes when application version changes 107 | app.get('*', tag('app')); 108 | // static files tag, changes upon application restart 109 | app.get('*.*', tag('static'), express.static(...)); 110 | // dynamic tag, changes upon non-GET calls 111 | app.use('/api/*', tag('dynamic')); 112 | ``` 113 | 114 | and invalidation of that tag can take place upon application restart: 115 | 116 | ```js 117 | app.post('/.upcache', function(req, res, next) { 118 | if (config.version != config.previousVersion) { 119 | console.info(`app tag changes because version changes from ${config.previousVersion} to ${config.version}`); 120 | config.previousVersion = config.version; 121 | tag('app')(req, res, next); 122 | } else { 123 | next(); 124 | } 125 | }, function(req, res, next) { 126 | if (!config.invalidated) { 127 | console.info(`static tag changes after restart`); 128 | config.invalidated = true; 129 | tag('static')(req, res, next); 130 | } else { 131 | next(); 132 | } 133 | }, function(req, res) { 134 | res.sendStatus(204); 135 | }); 136 | ``` 137 | -------------------------------------------------------------------------------- /README-vary.md: -------------------------------------------------------------------------------- 1 | Upcache Vary 2 | ============ 3 | 4 | `Vary` response header can configure cache key by mapping a request header value 5 | to a response header value. 6 | 7 | There are two cases: 8 | 9 | - legacy Vary, request value is mapped to cache key value with a different 10 | response header than the request header, seen with Accept* content negotiation. 11 | - modern Vary, request value is mapped with the same response header as the 12 | request header, seen with Client Hints. 13 | 14 | Vary on Accept 15 | -------------- 16 | 17 | ```text 18 | Vary: Accept 19 | Content-Type: 20 | ``` 21 | 22 | The request Accept value is mapped to the response Content-Type value to build 23 | the cache key. 24 | 25 | Vary on Accept-`Name` 26 | --------------------- 27 | 28 | ```text 29 | Vary: Accept- 30 | Content-: 31 | ``` 32 | 33 | The request Accept-X header value is mapped to the response Content-X header 34 | value to build the cache key. 35 | 36 | Vary on Cookie name 37 | ------------------- 38 | 39 | ```test 40 | Vary: X-Cookie- 41 | X-Cookie-: 42 | ``` 43 | 44 | This is like Accept-`Name` but varies on a virtual X-Cookie-`Name` header, 45 | which corresponds to the parsed cookie name. 46 | If a value is defined in the response, the request cookie value is mapped to it. 47 | 48 | Vary on `HeaderName` 49 | -------------------- 50 | 51 | ```text 52 | Vary: 53 | : 54 | ``` 55 | 56 | If there is no `Name` response header, the request `Name` header value is used 57 | directly to build the cache key. 58 | 59 | However this behavior is really not optimal, especially when dealing with 60 | User-Agent or other very variable request headers. 61 | 62 | Similar to content negotiation, or Client Hints, the response can tell 63 | how to map the request header value to another value. 64 | 65 | This not only improves cache storage, but cache hits, since the mapping 66 | itself is kept indefinitely (unless overwritten by another resource). 67 | 68 | request: 69 | 70 | ```text 71 | User-Agent: Mozilla/5.0 AppleWebKit/537.36 Chrome/73.0.3683.75 Safari/537.36 72 | ``` 73 | 74 | response: 75 | 76 | ```text 77 | Vary: User-Agent 78 | User-Agent: chrome/73.0.0 79 | ``` 80 | 81 | This induces a `User-Agent` mapping, from 82 | `Mozilla/5.0 AppleWebKit/537.36 Chrome/73.0.3683.75 Safari/537.36` to `chrome/73.0.0`. 83 | 84 | Usage 85 | ----- 86 | 87 | There is no js module since it's only a matter of setting standard headers. 88 | 89 | ```js 90 | const tag = require('upcache').tag; 91 | const polyfills = require('polyfill-library'); 92 | 93 | app.get('/polyfill.js', tag('app'), async (req, res, next) => { 94 | const opts = { 95 | uaString: req.get('user-agent'), 96 | minify: true, 97 | features: { 98 | 'es6': { flags: ['gated'] } 99 | } 100 | }; 101 | // let's assume polyfills already caches bundles by targetedFeatures 102 | const { targetedFeatures, bundle } = await polyfills.getPolyfills(opts); 103 | const hashKey = objectHash(targetedFeatures); 104 | res.vary('User-Agent'); 105 | res.set('User-Agent', hashkey); 106 | res.send(bundle); 107 | }); 108 | ``` 109 | 110 | http response headers 111 | --------------------- 112 | 113 | All standard headers discussed above. 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | upcache 2 | ======= 3 | 4 | Caching proxy having cache keys configured by the upstream application, 5 | by setting http response headers. 6 | 7 | Upcache has several ways of changing the cache keys: 8 | 9 | - [tag](./README-tag.md), version resources by zones 10 | - [lock](./README-lock.md), vary on client json web token grants 11 | - [vary](./README-vary.md), vary by grouping selected request headers 12 | - [map](./README-map.md), maps a request uri to another request uri 13 | 14 | **Breaking change**: server.d/upcache.conf is now server.d/upcache-memcached.conf 15 | 16 | Requirements 17 | ------------ 18 | 19 | In debian/12 these packages are easy to install: 20 | 21 | - nginx 22 | - libnginx-mod-http-srcache-filter 23 | - libnginx-mod-http-set-misc 24 | - libnginx-mod-http-memc (if using memcached, or for running the test suite 25 | - memcached or redis 26 | - lua-resty-core 27 | - lua-resty-lrucache 28 | 29 | - a Node.js express app 30 | 31 | Install 32 | ------- 33 | 34 | The Node.js app need the module 35 | 36 | ```sh 37 | npm install upcache 38 | ``` 39 | 40 | The nginx configuration need the module 41 | 42 | ```sh 43 | luarocks install upcache 44 | ``` 45 | 46 | nginx is easily configured with the set of files described in (depending on 47 | where npm installs the module) `./node_modules/upcache/nginx/README.md`. 48 | 49 | Usage 50 | ----- 51 | 52 | Once installed, load appropriate helpers with 53 | 54 | ```js 55 | const app = express(); 56 | const { tag, lock } = require('upcache'); 57 | const mlock = lock(config); 58 | 59 | app.get('/route', tag('ugc', 'global'), mlock.restrict('logged'), ...); 60 | app.post('/route', tag(), mlock.restrict('logged'), ...); 61 | 62 | ``` 63 | 64 | See README-tag.md and README-lock.md for documentation, 65 | and test/ for more examples. 66 | 67 | Mind that `srcache` module honours cache control headers - if the application 68 | sends responses with `Cache-Control: max-age=0`, the resource is not cached, 69 | and `tag().for()` is a facility for doing cache control. 70 | 71 | To cache something, resources must be tagged, so lock/vary won't work without tag. 72 | 73 | Detection by upstream 74 | --------------------- 75 | 76 | Upcache adds a `X-Upcache: ` header to requests, so upstream application 77 | can detect it is enabled, and which features are available. 78 | 79 | Testing 80 | ------- 81 | 82 | A pre-configured nginx environment is available for testing a Node.js application 83 | that listens on port 3000, with nginx on port 3001 and memcached on port 3002, 84 | simply by launching (depending on ./node_modules/.bin being on PATH or not) 85 | 86 | ```sh 87 | npm run upcache 88 | ``` 89 | 90 | which also has an option for filtering output `-g `. 91 | 92 | `mocha` relies on it for integration tests. No root permissions are needed. 93 | 94 | License 95 | ------- 96 | 97 | See LICENSE file. 98 | -------------------------------------------------------------------------------- /bin/upcache.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/node 2 | 3 | const dash = require('dashdash'); 4 | 5 | const spawner = require('../lib/spawner'); 6 | 7 | const parser = dash.createParser({options: [ 8 | { 9 | names: ['help', 'h'], 10 | type: 'bool', 11 | help: 'Print this help and exit.' 12 | }, 13 | { 14 | names: ['ngx'], 15 | type: 'number', 16 | default: 3001, 17 | help: 'nginx port number' 18 | }, 19 | { 20 | names: ['memc'], 21 | type: 'number', 22 | default: 3002, 23 | help: 'memcached port number' 24 | }, 25 | { 26 | names: ['app'], 27 | type: 'number', 28 | default: 3000, 29 | help: 'app port number (for nginx upstream config)' 30 | }, 31 | { 32 | names: ['grep', 'g'], 33 | type: 'string', 34 | help: 'filter output by pattern' 35 | } 36 | ]}); 37 | 38 | let opts; 39 | try { 40 | opts = parser.parse(process.argv); 41 | } catch(e) { 42 | console.error(e.toString()); 43 | opts = {help: true}; 44 | } 45 | 46 | if (opts.help) { 47 | const help = parser.help({includeEnv: true}).trimEnd(); 48 | console.info('usage: node foo.js [OPTIONS]\n' + 'options:\n' + help); 49 | process.exit(0); 50 | } 51 | 52 | if (opts.ngx != 3001) console.warn("Only nginx on port 3001 is supported"); 53 | opts.ngx = 3001; 54 | if (opts.app != 3000) console.warn("Only app on port 3000 is supported"); 55 | opts.app = 3000; 56 | 57 | spawner(opts).then(servers => { 58 | if (servers.memcached) console.info("Started memcached on port", opts.memc); 59 | if (servers.nginx) console.info("Started nginx on port", opts.ngx); 60 | console.info("Upstream app expected on port", opts.app); 61 | }).catch(err => { 62 | console.error(err); 63 | }); 64 | 65 | -------------------------------------------------------------------------------- /lib/common.js: -------------------------------------------------------------------------------- 1 | exports.prefixHeader = 'X-Upcache'; 2 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | Object.defineProperty(exports, 'lock', { 2 | get() { 3 | return require('./lock'); 4 | } 5 | }); 6 | 7 | Object.defineProperty(exports, 'tag', { 8 | get() { 9 | return require('./tag'); 10 | } 11 | }); 12 | 13 | Object.defineProperty(exports, 'map', { 14 | get() { 15 | return require('./map'); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /lib/lock.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('upcache:lock'); 2 | 3 | const jwt = require('jsonwebtoken'); 4 | const cookie = require('cookie'); 5 | const HttpError = require('http-errors'); 6 | 7 | const common = require('./common'); 8 | 9 | class Lock { 10 | static headerKey = common.prefixHeader + '-Lock-Key'; 11 | static headerVar = common.prefixHeader + '-Lock-Var'; 12 | static headerLock = common.prefixHeader + '-Lock'; 13 | 14 | constructor(obj) { 15 | this.publicKeySent = false; 16 | this.config = Object.assign({ 17 | algorithm: 'RS256', 18 | varname: 'cookie_bearer', 19 | userProperty: 'user', 20 | issuerProperty: 'hostname' 21 | }, obj); 22 | this.init = this.init.bind(this); 23 | this.handshake = this.handshake.bind(this); 24 | this.vary = this.vary.bind(this); 25 | 26 | const varname = this.config.varname; 27 | if (varname.startsWith('cookie_')) { 28 | this.cookieName = varname.substring(7); 29 | } else if (varname.startsWith('http_')) { 30 | this.headerName = varname.substring(5).replace(/_/g, '-'); 31 | } 32 | } 33 | 34 | vary() { 35 | let list = Array.from(arguments); 36 | if (list.length == 1 && Array.isArray(list[0])) list = list[0]; 37 | return function (req, res, next) { 38 | this.handshake(req, res); 39 | this.parse(req); 40 | this.headers(res, list); 41 | next(); 42 | }.bind(this); 43 | } 44 | headers(res, list) { 45 | if (list == null) { 46 | list = []; 47 | } else if (typeof list == "string") { 48 | list = [list]; 49 | } else if (typeof list == "object" && !Array.isArray(list)) { 50 | list = Object.keys(list); 51 | } 52 | let cur = res.get(Lock.headerLock); 53 | if (cur) { 54 | cur = cur.split(/,\s?/); 55 | } else { 56 | cur = []; 57 | } 58 | for (const str of list) { 59 | if (cur.includes(str) == false) cur.push(str); 60 | } 61 | if (cur.length > 0) { 62 | res.set(Lock.headerLock, cur.join(', ')); 63 | debug("send header", Lock.headerLock, cur); 64 | } 65 | } 66 | 67 | handshake(req, res, next) { 68 | if (req.get(Lock.headerKey) == '1' || !this.handshaked) { 69 | debug("sending public key to proxy"); 70 | this.handshaked = true; 71 | if (this.config.bearer) res.set(Lock.headerVar, this.config.bearer); 72 | res.set(Lock.headerKey, encodeURIComponent(this.config.publicKey)); 73 | } 74 | if (next) next(); 75 | } 76 | 77 | restrict() { 78 | const locks = Array.from(arguments); 79 | return (req, res, next) => { 80 | this.handshake(req, res); 81 | this.headers(res, locks); 82 | const user = this.parse(req); 83 | 84 | const locked = !locks.some(lock => { 85 | if (lock.includes('*')) { 86 | const reg = new RegExp('^' + lock.replace(/\*/g, '.*') + '$'); 87 | return user.grants.some((grant) => { 88 | return reg.test(grant); 89 | }); 90 | } else if (lock.includes(':')) { 91 | let found = false; 92 | lock.replace(/:(\w+)/, (m, p) => { 93 | if (user[p] !== undefined) { 94 | found = true; 95 | } 96 | }); 97 | return found; 98 | } else { 99 | return user.grants.includes(lock); 100 | } 101 | }); 102 | let err; 103 | if (!locked) { 104 | debug("unlocked"); 105 | } else if (user.grants.length == 0) { 106 | err = new HttpError.Unauthorized("No user grants"); 107 | } else { 108 | err = new HttpError.Forbidden("No allowed user grants"); 109 | } 110 | next(err); 111 | }; 112 | } 113 | 114 | sign(user, opts) { 115 | opts = Object.assign({}, this.config, opts); 116 | if (opts.maxAge && typeof opts.maxAge != 'number') { 117 | console.warn("upcache/scope.login: maxAge must be a number in seconds"); 118 | } 119 | if (!opts.issuer) throw new Error("Missing issuer"); 120 | return jwt.sign(user, opts.privateKey, { 121 | expiresIn: opts.maxAge, 122 | algorithm: opts.algorithm, 123 | issuer: opts.issuer 124 | }); 125 | } 126 | 127 | login(res, user, opts) { 128 | opts = Object.assign({}, this.config, opts); 129 | const { userProperty, issuerProperty } = opts; 130 | const { req } = res; 131 | opts.issuer = req[issuerProperty]; 132 | const bearer = this.sign(user, opts); 133 | if (userProperty && req[userProperty]) { 134 | req[userProperty].grants = user.grants || []; 135 | } 136 | if (this.cookieName) { 137 | res.cookie(this.cookieName, bearer, { 138 | maxAge: opts.maxAge * 1000, 139 | httpOnly: true, 140 | secure: res.req.secure, 141 | path: '/' 142 | }); 143 | } else if (this.headerName) { 144 | res.set(this.headerName, bearer); 145 | } 146 | return bearer; 147 | } 148 | 149 | logout(res) { 150 | if (this.cookieName) res.clearCookie(this.cookieName, { 151 | httpOnly: true, 152 | path: '/' 153 | }); 154 | } 155 | 156 | init(req, res, next) { 157 | this.handshake(req, res); 158 | this.parse(req); 159 | next(); 160 | } 161 | 162 | parse(req) { 163 | const { config } = this; 164 | const { userProperty, issuerProperty } = config; 165 | if (userProperty && req[userProperty]) return req[userProperty]; 166 | let bearer; 167 | let obj; 168 | if (this.cookieName) { 169 | if (!req.cookies) req.cookies = cookie.parse(req.headers.cookie || "") || {}; 170 | bearer = req.cookies[this.cookieName]; 171 | } else if (this.headerName) { 172 | bearer = req.get(this.headerName); 173 | } 174 | 175 | if (bearer) { 176 | try { 177 | obj = jwt.verify(bearer, config.publicKey, { 178 | algorithm: config.algorithm, 179 | issuer: req[issuerProperty] 180 | }); 181 | } catch (ex) { 182 | debug(ex, bearer); 183 | } 184 | } 185 | if (!obj) obj = {}; 186 | if (!obj.grants) obj.grants = []; 187 | debug(`set req.${userProperty}`, obj); 188 | req[userProperty] = obj; 189 | return obj; 190 | } 191 | } 192 | 193 | module.exports = function (obj) { 194 | return new Lock(obj); 195 | }; 196 | 197 | -------------------------------------------------------------------------------- /lib/map.js: -------------------------------------------------------------------------------- 1 | const common = require('./common'); 2 | 3 | const headerTag = common.prefixHeader + '-Map'; 4 | 5 | module.exports = function map(res, uri) { 6 | res.set(headerTag, uri); 7 | }; 8 | 9 | -------------------------------------------------------------------------------- /lib/spawner.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/node 2 | const { Deferred } = require('class-deferred'); 3 | const spawn = require('child_process').spawn; 4 | const Path = require('path'); 5 | const Transform = require('stream').Transform; 6 | 7 | const rootDir = Path.resolve(__dirname, '..', 'nginx'); 8 | 9 | process.chdir(rootDir); 10 | 11 | class FilterPipe extends Transform { 12 | constructor(matcher, grep) { 13 | super(); 14 | this.matcher = matcher; 15 | this.grep = grep ? new RegExp(grep, 'i') : null; 16 | } 17 | _transform(chunk, enc, cb) { 18 | const lines = []; 19 | chunk.toString().split('\n').forEach(str => { 20 | str = this.matcher(str); 21 | if (this.grep) { 22 | if (this.grep.test(str)) lines.push(str); 23 | } else if (str) { 24 | lines.push(str); 25 | } 26 | }); 27 | if (lines.length) lines.push(''); 28 | this.push(lines.join('\n')); 29 | cb(); 30 | } 31 | } 32 | 33 | module.exports = async function(opts) { 34 | const obj = {}; 35 | obj.close = close.bind(obj); 36 | const defer = new Deferred(); 37 | process.on('exit', obj.close); 38 | if (opts.memc) { 39 | obj.memcached = spawn('memcached', ['-vv', '-p', opts.memc, '-I', '10m']); 40 | obj.memcached.stdout.pipe(process.stdout); 41 | obj.memcached.stderr.pipe(new FilterPipe(((str) => { 42 | if (/^<\d+\s[sg]et\s.*$/mig.test(str)) return "[memc] " + str.substring(4); 43 | }), opts.grep)).pipe(process.stderr); 44 | obj.memcached.on('error', obj.close); 45 | } 46 | if (opts.ngx) { 47 | obj.nginx = spawn('/usr/sbin/nginx', [ 48 | '-p', rootDir, 49 | '-c', 'nginx.conf' 50 | ]); 51 | obj.nginx.stdout.pipe(process.stdout); 52 | obj.nginx.stderr.pipe(new FilterPipe(((str) => { 53 | if (/start worker process /.test(str) && !obj.nginx.started) { 54 | obj.nginx.started = true; 55 | defer.resolve(obj); 56 | } 57 | str = str.replace(/^nginx: \[alert\] could not open error log file: open.*/, ""); 58 | str = str.replace(/^.*(\[\w+\]).*?:(.*)$/, (str, p1, p2) => { 59 | if (p1 == "[notice]") return ""; 60 | return p1 + p2; 61 | }); 62 | str = str.replace(/^\[lua\][\d):]*\s/, "[lua] "); 63 | return str; 64 | }), opts.grep)).pipe(process.stderr); 65 | obj.nginx.on('error', obj.close); 66 | } else defer.resolve(obj); 67 | return defer; 68 | }; 69 | 70 | function close() { 71 | let count = 0; 72 | const defer = new Deferred(); 73 | if (this.nginx) { 74 | count++; 75 | this.nginx.on('exit', done); 76 | this.nginx.kill('SIGTERM'); 77 | delete this.nginx; 78 | } 79 | if (this.memcached) { 80 | count++; 81 | this.memcached.on('exit', done); 82 | this.memcached.kill('SIGKILL'); 83 | delete this.memcached; 84 | } 85 | function done() { 86 | if (--count) defer.resolve(); 87 | } 88 | return defer; 89 | } 90 | 91 | 92 | -------------------------------------------------------------------------------- /lib/tag.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('upcache:tag'); 2 | 3 | const ctrl = {}; 4 | require('@kapouer/express-cache-response-directive')()(null, ctrl, () => {}); 5 | 6 | const common = require('./common'); 7 | 8 | const headerTag = common.prefixHeader + '-Tag'; 9 | 10 | module.exports = tagFn; 11 | 12 | function forFn(opts) { 13 | if (typeof opts == "string") opts = { 14 | maxAge: opts 15 | }; 16 | return function (req, res, next) { 17 | const header = res.getHeader('cache-control') ?? []; 18 | const list = Array.isArray(header) ? header : header.split(', '); 19 | if (!list.includes('no-cache')) { 20 | ctrl.cacheControl.call(res, opts); 21 | } 22 | next(); 23 | }; 24 | } 25 | 26 | tagFn.for = forFn; 27 | 28 | tagFn.disable = function () { 29 | function disableMw(req, res, next) { 30 | ctrl.cacheControl.call(res, { 31 | noCache: true, 32 | mustRevalidate: true, 33 | proxyRevalidate: true 34 | }); 35 | next(); 36 | } 37 | disableMw.for = function () { 38 | return function (req, res, next) { 39 | next(); 40 | }; 41 | }; 42 | return disableMw; 43 | }; 44 | 45 | class TagMw { 46 | constructor(tags) { 47 | this.tags = tags; 48 | const len = tags.length; 49 | if (len && typeof tags[len - 1] == "function") { 50 | this.inc = tags.pop(); 51 | } else { 52 | this.inc = function(req) { 53 | return req.method != "GET"; 54 | }; 55 | } 56 | this.mw = this.mw.bind(this); 57 | this.mw.for = this.for.bind(this); 58 | } 59 | 60 | mw(req, res, next) { 61 | // prevent conditional requests if proxy is caching 62 | // it would have been done in the proxy, after a cache miss, if 63 | // current proxy allowed that easily 64 | if (req.get(common.prefixHeader)) { 65 | // TODO deal with If-Match, In-Unmodified-Since, If-Range 66 | delete req.headers["if-none-match"]; 67 | delete req.headers["if-modified-since"]; 68 | } 69 | const inc = this.inc(req); 70 | let list = res.get(headerTag); 71 | if (list) { 72 | list = list.split(',').map(str => str.trim()); 73 | } else { 74 | list = []; 75 | } 76 | this.hasTags = false; 77 | for (let tag of this.tags) { 78 | tag = replacements(tag, req); 79 | if (tag == null) continue; 80 | let incTag = inc; 81 | let itag; 82 | if (tag.startsWith('+')) { 83 | incTag = true; 84 | itag = tag; 85 | tag = tag.slice(1); 86 | } else { 87 | itag = '+' + tag; 88 | } 89 | let cur = list.indexOf(tag); 90 | if (cur < 0) { 91 | cur = list.indexOf(itag); 92 | } else if (incTag) { 93 | list[cur] = itag; 94 | continue; 95 | } 96 | if (cur < 0) { 97 | list.push(incTag ? itag : tag); 98 | this.hasTags = true; 99 | } 100 | } 101 | if (this.hasTags) { // else it did not change 102 | res.set(headerTag, list.join(', ')); 103 | } 104 | 105 | debug("response tags", list); 106 | if (next) next(); 107 | } 108 | 109 | for(ttl) { 110 | return (req, res, next) => { 111 | this.mw(req, res, (err) => { 112 | if (err) return next(err); 113 | if (this.hasTags) forFn(ttl)(req, res, next); 114 | else next(); 115 | }); 116 | }; 117 | } 118 | } 119 | 120 | function tagFn() { 121 | return new TagMw(Array.from(arguments)).mw; 122 | } 123 | 124 | 125 | function replacements(tag, req) { 126 | let someNull = false; 127 | const str = tag.replace(/:(\w+)/g, (str, name) => { 128 | const val = name in req.params ? req.params[name] : req.res.locals[name]; 129 | if (val == null) { 130 | someNull = true; 131 | } else { 132 | return val; 133 | } 134 | }); 135 | if (!someNull) return str; 136 | else return null; 137 | } 138 | -------------------------------------------------------------------------------- /nginx/README.md: -------------------------------------------------------------------------------- 1 | nginx.conf 2 | ---------- 3 | 4 | A sample nginx config for running tests. 5 | Do not use in production. 6 | 7 | 8 | snippets/cache-proxy.conf 9 | ------------------------- 10 | 11 | Goes unmodified in /etc/nginx/snippets/ 12 | 13 | ``` 14 | npm install -g upcache 15 | cd /etc/nginx/snippets 16 | ln -s /usr/local/lib/node_modules/upcache/nginx/snippets/cache-location.conf 17 | ln -s /usr/local/lib/node_modules/upcache/nginx/snippets/cache-memc.conf 18 | ``` 19 | 20 | 21 | conf.d/cache-server.conf 22 | ------------------------ 23 | 24 | Lua package path might need to be configured - or simply removed, depending 25 | on how is it installed (on debian, luarocks packages are on lua path by default). 26 | 27 | memcached port needs to be configured - on debian, system-installed is listening 28 | on port 11211. 29 | 30 | 31 | sites-enabled/upstream.conf 32 | --------------------------- 33 | 34 | A bare Node.js upstream sample config, rename and configure at will. 35 | 36 | -------------------------------------------------------------------------------- /nginx/conf.d/gzip.conf: -------------------------------------------------------------------------------- 1 | ## 2 | # Gzip Settings 3 | ## 4 | gzip on; 5 | gzip_disable "msie6"; 6 | 7 | gzip_vary on; 8 | gzip_proxied any; 9 | gzip_comp_level 6; 10 | gzip_buffers 16 8k; 11 | gzip_http_version 1.1; 12 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 13 | 14 | -------------------------------------------------------------------------------- /nginx/conf.d/memcached.conf: -------------------------------------------------------------------------------- 1 | upstream memcached-service { 2 | server 127.0.0.1:3002; 3 | keepalive 10; 4 | } 5 | 6 | -------------------------------------------------------------------------------- /nginx/conf.d/proxy.conf: -------------------------------------------------------------------------------- 1 | lua_package_path '../?.lua;../rocks/share/lua/5.1/?.lua;/usr/share/lua/5.1/?.lua;'; 2 | lua_package_cpath '../rocks/lib/lua/5.1/?.so;/usr/lib/x86_64-linux-gnu/lua/5.1/?.so;'; 3 | 4 | -------------------------------------------------------------------------------- /nginx/conf.d/upcache.conf: -------------------------------------------------------------------------------- 1 | lua_shared_dict upcacheLocks 100k; 2 | lua_shared_dict upcacheTags 1m; 3 | lua_shared_dict upcacheVariants 10m; 4 | 5 | -------------------------------------------------------------------------------- /nginx/location.d/upcache-directives.conf: -------------------------------------------------------------------------------- 1 | rewrite_by_lua_block { 2 | upcache.request() 3 | } 4 | 5 | header_filter_by_lua_block { 6 | upcache.response() 7 | } 8 | 9 | -------------------------------------------------------------------------------- /nginx/location.d/upcache.conf: -------------------------------------------------------------------------------- 1 | srcache_methods GET HEAD; 2 | 3 | set $fetchKey ''; 4 | set $fetchSkip 0; 5 | srcache_fetch GET /.well-known/fetch $fetchKey; 6 | srcache_fetch_skip $fetchSkip; 7 | 8 | 9 | set $storeKey ''; 10 | set $storeSkip 0; 11 | srcache_store PUT /.well-known/store $storeKey; 12 | srcache_store_skip $storeSkip; 13 | srcache_store_statuses 200 204 300 301 302 303 304 307 308 400 401 403 404 405 406 410 501; 14 | 15 | srcache_store_hide_header X-Upcache-Lock-Key; 16 | srcache_store_hide_header X-Upcache-Lock-Var; 17 | 18 | # srcache_response_cache_control off; # srcache honours Cache-Control headers by default 19 | # cache until peremption/eviction 20 | # this is possible because only tagged responses are cached 21 | srcache_default_expire 0; 22 | 23 | include ./location.d/upcache-directives.conf; 24 | -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | error_log stderr info; # error, info, debug 2 | pid nginx.pid; 3 | daemon off; 4 | worker_processes 2; # auto is good in production 5 | 6 | load_module /usr/share/nginx/modules/ndk_http_module.so; 7 | load_module /usr/share/nginx/modules/ngx_http_lua_module.so; 8 | load_module /usr/share/nginx/modules/ngx_http_memc_module.so; 9 | load_module /usr/share/nginx/modules/ngx_http_set_misc_module.so; 10 | load_module /usr/share/nginx/modules/ngx_http_srcache_filter_module.so; 11 | 12 | events { 13 | worker_connections 50; 14 | } 15 | 16 | http { 17 | access_log off; 18 | client_body_temp_path temp; 19 | include conf.d/*.conf; 20 | include sites/*.conf; 21 | } 22 | 23 | -------------------------------------------------------------------------------- /nginx/server.d/upcache-memcached.conf: -------------------------------------------------------------------------------- 1 | location ~ ^/.well-known/(store|fetch)$ { 2 | internal; 3 | client_max_body_size 20M; 4 | memc_connect_timeout 400ms; 5 | memc_send_timeout 400ms; 6 | memc_read_timeout 400ms; 7 | memc_ignore_client_abort on; 8 | 9 | set $memc_exptime 0; # let items expire by LRU 10 | set $memc_key $query_string; 11 | 12 | memc_pass memcached-service; 13 | } 14 | 15 | -------------------------------------------------------------------------------- /nginx/server.d/upcache-redis.conf: -------------------------------------------------------------------------------- 1 | location /.well-known/fetch { 2 | internal; 3 | 4 | content_by_lua_block { 5 | local redis = require "resty.redis" 6 | local red = redis:new() 7 | red:set_timeouts(1000, 1000, 1000) 8 | 9 | local ok, err = red:connect("127.0.0.1", 6379) 10 | if not ok then 11 | ngx.log(ngx.ERR, "Failed to connect to redis", err) 12 | return 13 | end 14 | 15 | local res = red:get(ngx.var.query_string) 16 | red:set_keepalive(10000, 100) 17 | if res == ngx.null then 18 | return ngx.exit(404) 19 | end 20 | ngx.print(res) 21 | } 22 | } 23 | 24 | location /.well-known/store { 25 | internal; 26 | client_max_body_size 20M; 27 | client_body_buffer_size 20M; 28 | 29 | content_by_lua_block { 30 | local redis = require "resty.redis" 31 | local red = redis:new() 32 | red:set_timeouts(1000, 1000, 1000) 33 | 34 | local ok, err = red:connect("127.0.0.1", 6379) 35 | if not ok then 36 | ngx.log(ngx.ERR, "Failed to connect to redis", err) 37 | return 38 | end 39 | ngx.req.read_body() 40 | ok, err = red:set(ngx.var.query_string, ngx.req.get_body_data()) 41 | red:set_keepalive(10000, 100) 42 | if not ok then 43 | ngx.log(ngx.ERR, "Failed to store", err) 44 | return ngx.exit(404) 45 | end 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /nginx/sites/sample.conf: -------------------------------------------------------------------------------- 1 | upstream express-service { 2 | server 127.0.0.1:3000; 3 | } 4 | 5 | init_by_lua_block { 6 | upcache = require "upcache" 7 | } 8 | 9 | server { 10 | listen 3001; 11 | listen [::1]:3001; 12 | include server.d/upcache-memcached.conf; 13 | location / { 14 | include location.d/upcache.conf; 15 | include /etc/nginx/proxy_params; 16 | proxy_set_header Accept-Encoding ""; 17 | proxy_pass http://express-service; 18 | } 19 | location /socket.io/ { 20 | proxy_set_header Upgrade $http_upgrade; 21 | proxy_set_header Connection "upgrade"; 22 | include /etc/nginx/proxy_params; 23 | proxy_pass http://express-service; 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "upcache", 3 | "version": "2.8.2", 4 | "description": "nginx proxy cache key protocols implementations", 5 | "scripts": { 6 | "test": "mocha", 7 | "rock": "version=$(node -p \"require('./package').version\")-1 && name=$(node -p \"require('./package').name\") && echo \"version = '${version}'\" | cat - package.rockspec > ${name}-${version}.rockspec" 8 | }, 9 | "mocha": { 10 | "exit": true 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git@github.com:kapouer/upcache.git" 15 | }, 16 | "keywords": [ 17 | "cache", 18 | "protocols", 19 | "nginx", 20 | "proxy", 21 | "purge", 22 | "lua", 23 | "upstream" 24 | ], 25 | "bin": { 26 | "upcache": "bin/upcache.js" 27 | }, 28 | "main": "./lib/index.js", 29 | "author": "Jérémy Lal ", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/kapouer/upcache/issues" 33 | }, 34 | "dependencies": { 35 | "@kapouer/express-cache-response-directive": "^2.1.0", 36 | "class-deferred": "^1.0.1", 37 | "cookie": "^0.6.0", 38 | "dashdash": "^2.0.0", 39 | "debug": "^4.3.4", 40 | "jsonwebtoken": "^9.0.2" 41 | }, 42 | "devDependencies": { 43 | "@kapouer/eslint-config": "^2.0.0", 44 | "express": "^4.19.2", 45 | "mocha": "^10.4.0" 46 | }, 47 | "eslintConfig": { 48 | "extends": "@kapouer/eslint-config", 49 | "overrides": [ 50 | { 51 | "files": [ 52 | "lib/*.js" 53 | ] 54 | }, 55 | { 56 | "files": [ 57 | "test/*.js" 58 | ], 59 | "env": { 60 | "mocha": true 61 | } 62 | } 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /package.rockspec: -------------------------------------------------------------------------------- 1 | package = "upcache" 2 | source = { 3 | url = "git://github.com/kapouer/upcache.git" 4 | } 5 | description = { 6 | summary = "Scope and Tag cache protocols for application - proxy cache keys management.", 7 | detailed = "This is the lua module to be used with proper nginx config and Node.js application module", 8 | homepage = "https://github.com/kapouer/upcache", 9 | license = "MIT" 10 | } 11 | dependencies = { 12 | "lua >= 5.1", 13 | "lua-resty-jwt = 0.2.3", 14 | "lua-resty-string >= 0.09", 15 | "lua-messagepack >= 0.5.3" 16 | } 17 | build = { 18 | type = "builtin", 19 | modules = { 20 | ['upcache'] = "upcache.lua", 21 | ['upcache.lock'] = "upcache/lock.lua", 22 | ['upcache.tag'] = "upcache/tag.lua", 23 | ['upcache.map'] = "upcache/map.lua", 24 | ['upcache.vary'] = "upcache/vary.lua", 25 | ['upcache.common'] = "upcache/common.lua" 26 | }, 27 | copy_directories = { "nginx" } 28 | } 29 | -------------------------------------------------------------------------------- /test/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: keys 2 | keys: 3 | mkdir -p fixtures && cd fixtures && rm *.pem; \ 4 | openssl genrsa -out private.pem 2048; \ 5 | openssl rsa -in private.pem -pubout -out public.pem 6 | 7 | -------------------------------------------------------------------------------- /test/cache.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { strict: assert } = require('assert'); 3 | 4 | const runner = require('../lib/spawner'); 5 | const common = require('./common'); 6 | 7 | const tag = require('../lib').tag; 8 | 9 | const ports = { 10 | app: 3000, 11 | ngx: 3001, 12 | memc: 3002 13 | }; 14 | 15 | describe("Cache", () => { 16 | let servers, app; 17 | const testPath = '/map-host'; 18 | const cachePath = '/nocache' 19 | 20 | let good = 0; 21 | let bad = 0; 22 | 23 | before(async () => { 24 | servers = await runner(ports); 25 | 26 | app = express(); 27 | app.server = app.listen(ports.app); 28 | app.fakeHost = 'example.com'; 29 | 30 | app.get(testPath, tag('test').for('1d'), (req, res, next) => { 31 | if (req.get('Host') == app.fakeHost) good++; 32 | else bad++; 33 | res.send({ 34 | value: (req.path || '/').substring(1), 35 | date: new Date() 36 | }); 37 | }); 38 | 39 | app.get(cachePath, tag.disable(), tag.for('1d'), (req, res, next) => { 40 | res.send({ 41 | value: (req.path || '/').substring(1), 42 | date: new Date() 43 | }); 44 | }); 45 | 46 | }); 47 | 48 | after(async () => { 49 | app.server.close(); 50 | await servers.close(); 51 | }); 52 | 53 | beforeEach(() => { 54 | good = 0; 55 | bad = 0; 56 | }); 57 | 58 | it("cache using Host http header, not using server_name", async () => { 59 | const req = { 60 | port: ports.ngx, 61 | path: testPath, 62 | headers: { 63 | Host: app.fakeHost 64 | } 65 | }; 66 | 67 | let res = await common.get(req); 68 | 69 | const result = res.body.toString(); 70 | 71 | res = await common.get(req); 72 | assert.equal(res.body.toString(), result); 73 | res = await common.get({ 74 | ...req, headers: {} 75 | }); 76 | assert.equal(good, 1); 77 | assert.equal(bad, 1); 78 | }); 79 | 80 | it("nocache even if another middleware tries to", async () => { 81 | const req = { 82 | port: ports.ngx, 83 | path: cachePath 84 | }; 85 | 86 | let res = await common.get(req); 87 | 88 | const result = res.body.toString(); 89 | 90 | res = await common.get(req); 91 | assert.equal(res.body.toString(), result); 92 | res = await common.get({ 93 | ...req, headers: {} 94 | }); 95 | assert.equal(res.headers['cache-control'], 'no-cache, must-revalidate, proxy-revalidate'); 96 | }); 97 | 98 | }); 99 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const URL = require('url'); 3 | const { Deferred } = require('class-deferred'); 4 | 5 | exports.get = function (uri) { 6 | const defer = new Deferred(); 7 | if (typeof uri == "string") uri = URL.parse(uri); 8 | uri = Object.assign({}, uri); 9 | http.get(uri, (res) => { 10 | let body = ""; 11 | res.setEncoding('utf8'); 12 | res.on('data', (chunk) => { 13 | body += chunk; 14 | }); 15 | res.on('end', () => { 16 | try { 17 | res.body = JSON.parse(body); 18 | } catch(ex) { 19 | res.body = body; 20 | } 21 | defer.resolve(res); 22 | }); 23 | }).once('error', (err) => { 24 | defer.reject(err); 25 | }); 26 | return defer; 27 | }; 28 | 29 | exports.post = function(uri, data) { 30 | const defer = new Deferred(); 31 | if (typeof uri == "string") uri = URL.parse(uri); 32 | uri = Object.assign({}, uri); 33 | uri.method = 'POST'; 34 | const req = http.request(uri, (res) => { 35 | let body = ""; 36 | res.setEncoding('utf8'); 37 | res.on('data', (chunk) => { 38 | body += chunk; 39 | }); 40 | res.on('end', () => { 41 | try { 42 | res.body = JSON.parse(body); 43 | } catch(ex) { 44 | res.body = body; 45 | } 46 | defer.resolve(res); 47 | }); 48 | }); 49 | req.once('error', (err) => { 50 | defer.reject(err); 51 | }); 52 | if (data) req.write(data); 53 | req.end(); 54 | return defer; 55 | }; 56 | 57 | exports.errorHandler = function(err, req, res, next) { 58 | if (err.statusCode == 401 || err.statusCode == 403) return res.sendStatus(err.statusCode); 59 | else return next(err); 60 | }; 61 | -------------------------------------------------------------------------------- /test/handshake.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const Path = require('path'); 3 | const URL = require('url'); 4 | const { strict: assert } = require('assert'); 5 | const cookie = require('cookie'); 6 | const express = require('express'); 7 | 8 | const runner = require('../lib/spawner'); 9 | const common = require('./common'); 10 | 11 | const locker = require('..').lock({ 12 | privateKey: fs.readFileSync(Path.join(__dirname, 'fixtures/private.pem')).toString(), 13 | publicKey: fs.readFileSync(Path.join(__dirname, 'fixtures/public.pem')).toString(), 14 | maxAge: 3600, 15 | issuer: "test" 16 | }); 17 | 18 | const ports = { 19 | app: 3000, 20 | ngx: 3001, 21 | memc: 3002 22 | }; 23 | 24 | describe("Handshake", () => { 25 | let servers, app; 26 | const testPathWildcard = '/wildcard'; 27 | const testPathWildcardMultiple = '/partialmatches'; 28 | let counters = {}; 29 | 30 | function count(uri, inc) { 31 | if (typeof uri != "string") { 32 | if (uri.get) uri = uri.protocol + '://' + uri.get('Host') + uri.path; 33 | else uri = URL.format(Object.assign({ 34 | protocol: 'http', 35 | hostname: 'localhost', 36 | pathname: uri.path 37 | }, uri)); 38 | } 39 | let counter = counters[uri]; 40 | if (counter == null) counter = counters[uri] = 0; 41 | if (inc) counters[uri] += inc; 42 | return counters[uri]; 43 | } 44 | 45 | beforeEach(async () => { 46 | counters = {}; 47 | servers = await runner(ports); 48 | 49 | app = express(); 50 | app.server = app.listen(ports.app); 51 | 52 | app.post('/login', (req, res, next) => { 53 | let requestedScopes = req.query.scope || []; 54 | if (!Array.isArray(requestedScopes)) requestedScopes = [requestedScopes]; 55 | const bearer = locker.login(res, { 56 | id: 44, 57 | grants: requestedScopes 58 | }); 59 | if (req.query.redirect !== undefined) { 60 | res.redirect(req.query.redirect); 61 | } else res.send({ 62 | bearer: bearer // used in the test 63 | }); 64 | }); 65 | 66 | app.get(testPathWildcardMultiple, locker.restrict('book*'), (req, res, next) => { 67 | count(req, 1); 68 | res.send({ 69 | value: (req.path || '/').substring(1), 70 | date: new Date() 71 | }); 72 | }); 73 | app.post(testPathWildcardMultiple, locker.restrict('auth'), (req, res, next) => { 74 | res.sendStatus(204); 75 | }); 76 | 77 | app.get(testPathWildcard, locker.vary('*'), (req, res, next) => { 78 | count(req, 1); 79 | res.send({ 80 | value: (req.path || '/').substring(1), 81 | date: new Date() 82 | }); 83 | }); 84 | 85 | app.use(common.errorHandler); 86 | }); 87 | 88 | afterEach(async () => { 89 | app.server.close(); 90 | await servers.close(); 91 | }); 92 | 93 | 94 | it("cache a wildcard-restricted resource without grant then fetch the same with a grant with proxy", async () => { 95 | const headers = {}; 96 | const req = { 97 | headers: headers, 98 | port: ports.ngx, 99 | path: testPathWildcard 100 | }; 101 | 102 | const fakeRes = { 103 | req: { 104 | hostname: "locahost" 105 | }, 106 | cookie: function() {} 107 | }; 108 | let res; 109 | 110 | res = await common.post({ 111 | port: ports.ngx, 112 | path: testPathWildcardMultiple, 113 | headers: { 114 | Cookie: cookie.serialize("bearer", locker.login(fakeRes, { 115 | scopes: { 116 | auth: true 117 | } 118 | })) 119 | } 120 | }); 121 | assert.equal('x-upcache-key-handshake' in res.headers, false); 122 | 123 | res = await common.get(req); 124 | assert.equal(res.statusCode, 200); 125 | 126 | const firstDate = res.body.date; 127 | res = await common.post({ 128 | port: ports.ngx, 129 | path: '/login?scope=test' 130 | }); 131 | assert.equal('set-cookie' in res.headers, true); 132 | 133 | const cookies = cookie.parse(res.headers['set-cookie'][0]); 134 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 135 | res = await common.get(req); 136 | 137 | assert.equal(res.statusCode, 200); 138 | assert.notEqual(res.body.date, firstDate); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /test/lock.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const Path = require('path'); 3 | const URL = require('url'); 4 | const cookie = require('cookie'); 5 | const express = require('express'); 6 | const assert = require('assert').strict; 7 | 8 | const runner = require('../lib/spawner'); 9 | const common = require('./common'); 10 | 11 | const scope = require('..').lock({ 12 | privateKey: fs.readFileSync(Path.join(__dirname, 'fixtures/private.pem')).toString(), 13 | publicKey: fs.readFileSync(Path.join(__dirname, 'fixtures/public.pem')).toString(), 14 | maxAge: 3600, 15 | issuer: "test", 16 | userProperty: 'user' 17 | }); 18 | 19 | const ports = { 20 | app: 3000, 21 | ngx: 3001, 22 | memc: 3002 23 | }; 24 | 25 | describe("Lock", () => { 26 | let servers, app; 27 | const testPath = '/scope-test'; 28 | const testPathNotGranted = '/scope-not-granted-test'; 29 | const testPathWildcardMultiple = '/wildcardmul'; 30 | const testPathWildcard = '/wildcard'; 31 | const testPathHeadersSetting = '/headers'; 32 | const testPathHeadersWithReplacement = '/replacement'; 33 | let counters = {}; 34 | 35 | function count(uri, inc) { 36 | if (typeof uri != "string") { 37 | if (uri.get) uri = uri.protocol + '://' + uri.get('Host') + uri.path; 38 | else uri = URL.format(Object.assign({ 39 | protocol: 'http', 40 | hostname: 'localhost', 41 | pathname: uri.path 42 | }, uri)); 43 | } 44 | let counter = counters[uri]; 45 | if (counter == null) counter = counters[uri] = 0; 46 | if (inc) counters[uri] += inc; 47 | return counters[uri]; 48 | } 49 | 50 | before(async () => { 51 | servers = await runner(ports); 52 | 53 | app = express(); 54 | app.server = app.listen(ports.app); 55 | 56 | app.post('/login', (req, res, next) => { 57 | let givemeScope = req.query.scope; 58 | if (givemeScope && !Array.isArray(givemeScope)) givemeScope = [givemeScope]; 59 | const bearer = scope.login(res, { 60 | id: req.query.id || 44, 61 | grants: givemeScope || ['bookWriter', 'bookReader'] 62 | }); 63 | if (req.query.redirect !== undefined) { 64 | res.redirect(req.query.redirect); 65 | } else res.send({ 66 | bearer: bearer // used in the test 67 | }); 68 | }); 69 | 70 | app.post('/logout', (req, res, next) => { 71 | scope.logout(res); 72 | res.sendStatus(204); 73 | }); 74 | 75 | app.get(testPathHeadersSetting, scope.init, (req, res, next) => { 76 | count(req, 1); 77 | assert.ok(req.user); 78 | 79 | scope.headers(res, 'dynA'); 80 | scope.headers(res, ['dynB']); 81 | scope.headers(res, ['dynC', 'dynD']); 82 | scope.headers(res, ['dynD', 'dynE', 'dynA']); 83 | scope.headers(res, 'dynD'); 84 | 85 | res.send({ 86 | value: (req.path || '/').substring(1), 87 | date: new Date() 88 | }); 89 | }); 90 | 91 | app.get(testPathHeadersWithReplacement, scope.vary('id-:id'), (req, res, next) => { 92 | count(req, 1); 93 | assert.ok(req.user); 94 | res.send({ 95 | value: (req.path || '/').substring(1), 96 | date: new Date() 97 | }); 98 | }); 99 | 100 | app.get(testPath, scope.restrict('bookReader', 'bookSecond'), (req, res, next) => { 101 | assert.equal('user' in req, true); 102 | count(req, 1); 103 | res.send({ 104 | value: (req.path || '/').substring(1), 105 | date: new Date() 106 | }); 107 | }); 108 | 109 | app.get(testPathNotGranted, scope.restrict('bookReaderWhat'), (req, res, next) => { 110 | count(req, 1); 111 | res.send({ 112 | value: (req.path || '/').substring(1), 113 | date: new Date() 114 | }); 115 | }); 116 | 117 | app.get(testPathWildcardMultiple, scope.vary('book*'), (req, res, next) => { 118 | count(req, 1); 119 | res.send({ 120 | value: (req.path || '/').substring(1), 121 | date: new Date() 122 | }); 123 | }); 124 | app.post(testPathWildcardMultiple, (req, res, next) => { 125 | res.sendStatus(204); 126 | }); 127 | 128 | app.get(testPathWildcard, scope.vary('*'), (req, res, next) => { 129 | count(req, 1); 130 | res.send({ 131 | value: (req.path || '/').substring(1), 132 | date: new Date() 133 | }); 134 | }); 135 | 136 | app.get("/user/:id", scope.vary('user-:id'), (req, res, next) => { 137 | if (req.user.id == req.params.id) res.send({id: parseInt(req.params.id)}); 138 | else res.sendStatus(403); 139 | }); 140 | 141 | app.use(common.errorHandler); 142 | }); 143 | 144 | after(async () => { 145 | app.server.close(); 146 | await servers.close(); 147 | }); 148 | 149 | beforeEach(() => { 150 | counters = {}; 151 | }); 152 | 153 | it("get 401 when accessing a protected url without proxy", async () => { 154 | const req = { 155 | port: ports.app, 156 | path: testPath 157 | }; 158 | const res = await common.get(req); 159 | assert.equal(res.statusCode, 401); 160 | assert.equal(count(req), 0); 161 | }); 162 | 163 | it("log in and get read access to a url without proxy", async () => { 164 | const headers = {}; 165 | const req = { 166 | headers: headers, 167 | port: ports.app, 168 | path: testPath 169 | }; 170 | let res = await common.post({ 171 | port: ports.app, 172 | path: '/login' 173 | }); 174 | assert.equal('set-cookie' in res.headers, true); 175 | 176 | const cookies = cookie.parse(res.headers['set-cookie'][0]); 177 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 178 | res = await common.get(req); 179 | assert.equal(res.headers['x-upcache-lock'], 'bookReader, bookSecond'); 180 | assert.equal(count(req), 1); 181 | }); 182 | 183 | it("log in and not get read access to another url without proxy", async () => { 184 | const headers = {}; 185 | const req = { 186 | headers: headers, 187 | port: ports.app, 188 | path: testPathNotGranted 189 | }; 190 | let res = await common.post({ 191 | port: ports.app, 192 | path: '/login' 193 | }); 194 | assert.ok(res.headers['set-cookie']); 195 | const cookies = cookie.parse(res.headers['set-cookie'][0]); 196 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 197 | res = await common.get(req); 198 | 199 | assert.equal(res.statusCode, 403); 200 | assert.equal(count(req), 0); 201 | }); 202 | 203 | it("log in, access, then log out, and be denied access without proxy", async () => { 204 | const headers = {}; 205 | let res = await common.post({ 206 | port: ports.app, 207 | path: '/login' 208 | }); 209 | assert.ok(res.headers['set-cookie']); 210 | let cookies = cookie.parse(res.headers['set-cookie'][0]); 211 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 212 | res = await common.get({ 213 | headers: headers, 214 | port: ports.app, 215 | path: testPath 216 | }); 217 | 218 | assert.equal(res.statusCode, 200); 219 | res = await common.post({ 220 | headers: headers, 221 | port: ports.app, 222 | path: "/logout" 223 | }); 224 | 225 | assert.ok(res.headers['set-cookie']); 226 | cookies = cookie.parse(res.headers['set-cookie'][0]); 227 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 228 | res = await common.get({ 229 | headers: headers, 230 | port: ports.app, 231 | path: testPath 232 | }); 233 | 234 | assert.equal(res.statusCode, 401); 235 | }); 236 | 237 | it("get 401 when accessing a protected url with proxy", async () => { 238 | const req = { 239 | port: ports.ngx, 240 | path: testPath 241 | }; 242 | const res = await common.get(req); 243 | assert.equal(res.statusCode, 401); 244 | assert.equal(count(req), 0); 245 | }); 246 | 247 | it("log in and get read access to a url and not hit the cache with proxy", async () => { 248 | const headers = {}; 249 | const req = { 250 | headers: headers, 251 | port: ports.ngx, 252 | path: testPath 253 | }; 254 | let res = await common.post({ 255 | port: ports.ngx, 256 | path: '/login' 257 | }); 258 | assert.ok(res.headers['set-cookie']); 259 | const cookies = cookie.parse(res.headers['set-cookie'][0]); 260 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 261 | res = await common.get(req); 262 | 263 | assert.equal('x-upcache-lock-key' in res.headers, false); 264 | assert.equal(res.headers['x-upcache-lock'], 'bookReader, bookSecond'); 265 | 266 | assert.equal(res.statusCode, 200); 267 | assert.equal(count(req), 1); 268 | res = await common.get(req); 269 | assert.equal(res.statusCode, 200); 270 | // because it should be a cache hit 271 | assert.equal(count(req), 2); 272 | }); 273 | 274 | it("get headers right with proxy", async () => { 275 | const headers = {}; 276 | const req = { 277 | headers: headers, 278 | port: ports.ngx, 279 | path: testPathHeadersSetting 280 | }; 281 | const res = await common.get(req); 282 | assert.equal(res.headers['x-upcache-lock'], 'dynA, dynB, dynC, dynD, dynE'); 283 | assert.equal(res.statusCode, 200); 284 | assert.equal(count(req), 1); 285 | }); 286 | 287 | it("log in and not get read access to another url with proxy", async () => { 288 | const headers = {}; 289 | const req = { 290 | headers: headers, 291 | port: ports.ngx, 292 | path: testPathNotGranted 293 | }; 294 | let res = await common.post({ 295 | port: ports.ngx, 296 | path: '/login' 297 | }); 298 | assert.ok(res.headers['set-cookie']); 299 | const cookies = cookie.parse(res.headers['set-cookie'][0]); 300 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 301 | res = await common.get(req); 302 | assert.equal(res.statusCode, 403); 303 | assert.equal(count(req), 0); 304 | }); 305 | 306 | it("log in, access, then log out, and be denied access with proxy", async () => { 307 | const headers = {}; 308 | let res = await common.post({ 309 | port: ports.ngx, 310 | path: '/login' 311 | }); 312 | assert.ok(res.headers['set-cookie']); 313 | let cookies = cookie.parse(res.headers['set-cookie'][0]); 314 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 315 | res = await common.get({ 316 | headers: headers, 317 | port: ports.ngx, 318 | path: testPath 319 | }); 320 | 321 | assert.equal(res.statusCode, 200); 322 | res = await common.post({ 323 | headers: headers, 324 | port: ports.ngx, 325 | path: "/logout" 326 | }); 327 | assert.ok(res.headers['set-cookie']); 328 | cookies = cookie.parse(res.headers['set-cookie'][0]); 329 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 330 | res = await common.get({ 331 | headers: headers, 332 | port: ports.ngx, 333 | path: testPath 334 | }); 335 | 336 | assert.equal(res.statusCode, 401); 337 | }); 338 | 339 | it("log in with different scopes and cache each variant with proxy", async () => { 340 | const headers = {}; 341 | const req = { 342 | headers: headers, 343 | port: ports.ngx, 344 | path: testPath 345 | }; 346 | let res = await common.post({ 347 | port: ports.ngx, 348 | path: '/login?scope=bookReader' 349 | }); 350 | assert.ok(res.headers['set-cookie']); 351 | let cookies = cookie.parse(res.headers['set-cookie'][0]); 352 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 353 | res = await common.get(req); 354 | 355 | assert.equal(res.statusCode, 200); 356 | const firstDate = res.body.date; 357 | res = await common.post({ 358 | port: ports.ngx, 359 | path: '/login?scope=bookSecond' 360 | }); 361 | 362 | assert.ok(res.headers['set-cookie']); 363 | cookies = cookie.parse(res.headers['set-cookie'][0]); 364 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 365 | res = await common.get(req); 366 | assert.equal(res.statusCode, 200); 367 | assert.notEqual(res.body.date, firstDate); 368 | }); 369 | 370 | it("log in with different scopes on a wildcard restriction and cache each variant with proxy", async () => { 371 | const headers = {}; 372 | const req = { 373 | headers: headers, 374 | port: ports.ngx, 375 | path: testPathWildcardMultiple 376 | }; 377 | let res = await common.post({ 378 | port: ports.ngx, 379 | path: '/login?scope=book1&scope=book2' 380 | }); 381 | assert.ok(res.headers['set-cookie']); 382 | let cookies = cookie.parse(res.headers['set-cookie'][0]); 383 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 384 | res = await common.get(req); 385 | 386 | assert.equal(res.statusCode, 200); 387 | const firstDate = res.body.date; 388 | res = await common.post({ 389 | port: ports.ngx, 390 | path: '/login?scope=book3&scope=book2' 391 | }); 392 | 393 | assert.ok(res.headers['set-cookie']); 394 | cookies = cookie.parse(res.headers['set-cookie'][0]); 395 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 396 | res = await common.get(req); 397 | assert.equal(res.statusCode, 200); 398 | assert.notEqual(res.body.date, firstDate); 399 | }); 400 | 401 | it("cache a wildcard-restricted resource without grant then fetch the same with a grant with proxy", async () => { 402 | const headers = {}; 403 | const req = { 404 | headers: headers, 405 | port: ports.ngx, 406 | path: testPathWildcard 407 | }; 408 | 409 | let res = await common.get(req); 410 | assert.equal(res.statusCode, 200); 411 | const firstDate = res.body.date; 412 | res = await common.post({ 413 | port: ports.ngx, 414 | path: '/login?redirect=' + encodeURIComponent(testPathWildcard), 415 | }); 416 | 417 | assert.ok(res.headers['set-cookie']); 418 | const cookies = cookie.parse(res.headers['set-cookie'][0]); 419 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 420 | res = await common.get(req); 421 | 422 | assert.equal(res.statusCode, 200); 423 | assert.notEqual(res.body.date, firstDate); 424 | }); 425 | 426 | it("log in as user and be authorized to read user, then be unauthorized to read another user (without proxy)", async () => { 427 | const headers = {}; 428 | const req = { 429 | headers: headers, 430 | port: ports.app, 431 | path: '/user/45' 432 | }; 433 | let res = await common.post({ 434 | port: ports.app, 435 | path: '/login?id=45' 436 | }); 437 | assert.ok(res.headers['set-cookie']); 438 | const cookies = cookie.parse(res.headers['set-cookie'][0]); 439 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 440 | res = await common.get(req); 441 | 442 | assert.equal(res.headers['x-upcache-lock'], 'user-:id'); 443 | assert.equal(res.statusCode, 200); 444 | assert.equal(res.body.id, 45); 445 | req.path += '1'; 446 | res = await common.get(req); 447 | 448 | assert.equal(res.statusCode, 403); 449 | }); 450 | 451 | it("log in as user and be authorized to read user, then be unauthorized to read another user (with proxy)", async () => { 452 | const headers = {}; 453 | const req = { 454 | headers: headers, 455 | port: ports.ngx, 456 | path: '/user/45' 457 | }; 458 | let res = await common.post({ 459 | port: ports.ngx, 460 | path: '/login?id=45' 461 | }); 462 | assert.ok(res.headers['set-cookie']); 463 | const cookies = cookie.parse(res.headers['set-cookie'][0]); 464 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 465 | res = await common.get(req); 466 | 467 | assert.equal(res.headers['x-upcache-lock'], 'user-:id'); 468 | assert.equal(res.statusCode, 200); 469 | assert.equal(res.body.id, 45); 470 | req.path += '1'; 471 | res = await common.get(req); 472 | 473 | assert.equal(res.statusCode, 403); 474 | }); 475 | 476 | }); 477 | -------------------------------------------------------------------------------- /test/map-lock.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const Path = require('path'); 3 | const URL = require('url'); 4 | const cookie = require('cookie'); 5 | const express = require('express'); 6 | const assert = require('assert').strict; 7 | 8 | const runner = require('../lib/spawner'); 9 | const common = require('./common'); 10 | 11 | const upcache = require('..'); 12 | const locker = upcache.lock({ 13 | privateKey: fs.readFileSync(Path.join(__dirname, 'fixtures/private.pem')).toString(), 14 | publicKey: fs.readFileSync(Path.join(__dirname, 'fixtures/public.pem')).toString(), 15 | maxAge: 3600, 16 | issuer: "test", 17 | userProperty: "user" 18 | }); 19 | const map = upcache.map; 20 | 21 | const ports = { 22 | app: 3000, 23 | ngx: 3001, 24 | memc: 3002 25 | }; 26 | 27 | describe("Map and Lock", () => { 28 | let servers, app; 29 | const testPath = '/dynamic'; 30 | const testPathMapped = '/mapped'; 31 | let counters = {}; 32 | 33 | function count(uri, inc) { 34 | if (typeof uri != "string") { 35 | if (uri.get) uri = uri.protocol + '://' + uri.get('Host') + uri.path; 36 | else uri = URL.format(Object.assign({ 37 | protocol: 'http', 38 | hostname: 'localhost', 39 | pathname: uri.path 40 | }, uri)); 41 | } 42 | let counter = counters[uri]; 43 | if (counter == null) counter = counters[uri] = 0; 44 | if (inc) counters[uri] += inc; 45 | return counters[uri]; 46 | } 47 | 48 | before(async () => { 49 | servers = await runner(ports); 50 | 51 | app = express(); 52 | app.server = app.listen(ports.app); 53 | 54 | app.post('/login', (req, res, next) => { 55 | let givemeScope = req.query.scope; 56 | if (givemeScope && !Array.isArray(givemeScope)) givemeScope = [givemeScope]; 57 | const user = { 58 | id: req.query && req.query.id || 44, 59 | grants: givemeScope || ['bookWriter', 'bookReader'] 60 | }; 61 | const bearer = locker.login(res, user); 62 | res.send({ 63 | user: user, 64 | bearer: bearer // convenient but not technically needed 65 | }); 66 | }); 67 | 68 | app.post('/logout', (req, res, next) => { 69 | locker.logout(res); 70 | res.sendStatus(204); 71 | }); 72 | 73 | app.get(testPath, upcache.tag('app'), locker.init, (req, res, next) => { 74 | count(req, 1); 75 | 76 | const grants = req.user.grants; 77 | const locks = ['dynA', 'dynB']; 78 | locker.headers(res, locks); 79 | 80 | if (!locks.some((lock) => { 81 | return grants.includes(lock); 82 | })) { 83 | map(res, testPathMapped); 84 | res.status(403); 85 | } else { 86 | res.status(200); // useless but indicates it's on purpose 87 | } 88 | res.send({ 89 | value: (req.path || '/').substring(1), 90 | date: Date.now(), 91 | usergrants: grants 92 | }); 93 | }); 94 | 95 | app.use(common.errorHandler); 96 | }); 97 | 98 | after(async () => { 99 | app.server.close(); 100 | await servers.close(); 101 | }); 102 | 103 | beforeEach(() => { 104 | counters = {}; 105 | }); 106 | 107 | it("map several unauthorized users to the same cache key with proxy", async () => { 108 | const headers = {}; 109 | const req = { 110 | headers: headers, 111 | port: ports.ngx, 112 | path: testPath 113 | }; 114 | 115 | let res = await common.get(req); 116 | assert.equal(res.statusCode, 403); 117 | assert.equal(res.headers['x-upcache-map'], testPathMapped); 118 | const result = res.body; 119 | res = await common.post({ 120 | port: ports.ngx, 121 | path: '/login', 122 | query: { 123 | scope: 'what' 124 | } 125 | }); 126 | assert.ok('set-cookie' in res.headers); 127 | let cookies = cookie.parse(res.headers['set-cookie'][0]); 128 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 129 | res = await common.get(req); 130 | delete req.headers.Cookie; 131 | assert.equal(res.statusCode, 403); 132 | assert.deepEqual(result, res.body); 133 | assert.equal(count(req), 1); 134 | res = await common.get(req); 135 | 136 | assert.equal(res.statusCode, 403); 137 | assert.deepEqual(result, res.body); 138 | assert.equal(res.headers['x-upcache-map'], testPathMapped); 139 | assert.equal(count(req), 1); 140 | res = await common.post({ 141 | port: ports.ngx, 142 | path: '/login?scope=dynA' 143 | }); 144 | 145 | assert.ok('set-cookie' in res.headers); 146 | cookies = cookie.parse(res.headers['set-cookie'][0]); 147 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 148 | res = await common.get(req); 149 | 150 | assert.equal(count(req), 2); 151 | assert.equal(res.statusCode, 200); 152 | assert.deepEqual(res.body.usergrants, ['dynA']); 153 | assert.equal('x-upcache-map' in res.headers, false); 154 | res = await common.get(req); 155 | 156 | assert.equal(count(req), 2); 157 | assert.equal(res.statusCode, 200); 158 | assert.deepEqual(res.body.usergrants, ['dynA']); 159 | assert.equal('x-upcache-map' in res.headers, false); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /test/map.js: -------------------------------------------------------------------------------- 1 | const URL = require('url'); 2 | const express = require('express'); 3 | const { strict: assert } = require('assert'); 4 | 5 | const runner = require('../lib/spawner'); 6 | const common = require('./common'); 7 | 8 | const map = require('..').map; 9 | const tag = require('..').tag; 10 | 11 | const ports = { 12 | app: 3000, 13 | ngx: 3001, 14 | memc: 3002 15 | }; 16 | 17 | describe("Map", () => { 18 | let servers, app; 19 | const testPath = '/map-test'; 20 | const mappedTestPath = `${testPath}-mapped`; 21 | const testVary = "/map-vary"; 22 | const mappedTestVary = `${testVary}-mapped`; 23 | 24 | let counters = {}; 25 | 26 | function count(uri, inc) { 27 | if (typeof uri != "string") { 28 | if (uri.get) uri = uri.protocol + '://' + uri.get('Host') + uri.path; 29 | else uri = URL.format(Object.assign({ 30 | protocol: 'http', 31 | hostname: 'localhost', 32 | pathname: uri.path 33 | }, uri)); 34 | } 35 | let counter = counters[uri]; 36 | if (counter == null) counter = counters[uri] = 0; 37 | if (inc) counters[uri] += inc; 38 | return counters[uri]; 39 | } 40 | 41 | before(async () => { 42 | servers = await runner(ports); 43 | 44 | app = express(); 45 | app.server = app.listen(ports.app); 46 | 47 | app.get(testPath, tag('app'), (req, res, next) => { 48 | map(res, mappedTestPath); 49 | count(req, 1); 50 | res.send({ 51 | value: (req.path || '/').substring(1), 52 | date: new Date() 53 | }); 54 | }); 55 | 56 | app.get(testVary, tag('app'), (req, res, next) => { 57 | map(res, mappedTestVary); 58 | res.vary('Accept-Language'); 59 | res.append('Vary', 'Sec-Purpose'); 60 | count(req, 1); 61 | const langs = ['en', 'fr']; 62 | const suffix = req.get('Sec-Purpose') ?? ''; 63 | if (req.acceptsLanguages(langs) == 'en') { 64 | res.set('Content-Language', 'en'); 65 | res.send('Good !' + suffix); 66 | } else if (req.acceptsLanguages(langs) == 'fr') { 67 | res.set('Content-Language', 'fr'); 68 | res.send('Bien !' + suffix); 69 | } else { 70 | res.set('Content-Language', 'pt'); 71 | res.send('Bem !' + suffix); 72 | } 73 | }); 74 | }); 75 | 76 | after(async () => { 77 | app.server.close(); 78 | await servers.close(); 79 | }); 80 | 81 | beforeEach(() => { 82 | counters = {}; 83 | }); 84 | 85 | it("map testPath to mappedTestPath", async () => { 86 | const req = { 87 | port: ports.ngx, 88 | path: testPath 89 | }; 90 | const reqm = { 91 | port: ports.ngx, 92 | path: mappedTestPath 93 | }; 94 | let res = await common.get(req); 95 | const result = res.body.toString(); 96 | 97 | res = await common.get(req); 98 | assert.equal(res.body.toString(), result); 99 | assert.equal(count(req), 1); 100 | assert.equal(res.headers['x-upcache-map'], mappedTestPath); 101 | 102 | res = await common.get(reqm); 103 | assert.equal(res.body.toString(), result); 104 | assert.equal(count(req), 1); 105 | assert.equal(res.headers['x-upcache-map'], mappedTestPath); 106 | }); 107 | 108 | it("and Vary should map to two different keys", async () => { 109 | const headers = {}; 110 | const req = { 111 | headers: headers, 112 | port: ports.ngx, 113 | path: testVary 114 | }; 115 | let res; 116 | const english = "fr;q=0.8, en, pt"; 117 | const french = "fr;q=0.8, en;q=0.7, pt;q=0.5"; 118 | 119 | req.headers['Accept-Language'] = english; 120 | res = await common.get(req); 121 | assert.equal(res.headers.vary, 'Accept-Language, Sec-Purpose'); 122 | assert.equal(res.headers['content-language'], 'en'); 123 | assert.equal(res.headers['x-upcache-map'], mappedTestVary); 124 | assert.equal(count(req), 1); 125 | 126 | req.headers['Accept-Language'] = french; 127 | res = await common.get(req); 128 | assert.equal(res.headers.vary, 'Accept-Language, Sec-Purpose'); 129 | assert.equal(res.headers['content-language'], 'fr'); 130 | assert.equal(res.headers['x-upcache-map'], mappedTestVary); 131 | assert.equal(count(req), 2); 132 | 133 | req.headers['Sec-Purpose'] = 'prerender'; 134 | res = await common.get(req); 135 | assert.equal(res.body.toString(), 'Bien !prerender'); 136 | assert.equal(res.headers.vary, 'Accept-Language, Sec-Purpose'); 137 | assert.equal(res.headers['content-language'], 'fr'); 138 | assert.equal(res.headers['x-upcache-map'], mappedTestVary); 139 | assert.equal(count(req), 3); 140 | delete req.headers['Sec-Purpose']; 141 | 142 | req.headers['Accept-Language'] = english; 143 | res = await common.get(req); 144 | assert.equal(res.headers.vary, 'Accept-Language, Sec-Purpose'); 145 | assert.equal(res.headers['content-language'], 'en'); 146 | assert.equal(res.headers['x-upcache-map'], mappedTestVary); 147 | assert.equal(count(req), 3); 148 | 149 | req.headers['Accept-Language'] = french; 150 | res = await common.get(req); 151 | assert.equal(res.body.toString(), 'Bien !'); 152 | assert.equal(res.headers.vary, 'Accept-Language, Sec-Purpose'); 153 | assert.equal(res.headers['content-language'], 'fr'); 154 | assert.equal(res.headers['x-upcache-map'], mappedTestVary); 155 | assert.equal(count(req), 3); 156 | 157 | req.headers['Sec-Purpose'] = 'prerender'; 158 | res = await common.get(req); 159 | assert.equal(res.body.toString(), 'Bien !prerender'); 160 | assert.equal(res.headers.vary, 'Accept-Language, Sec-Purpose'); 161 | assert.equal(res.headers['content-language'], 'fr'); 162 | assert.equal(res.headers['x-upcache-map'], mappedTestVary); 163 | assert.equal(count(req), 3); 164 | delete req.headers['Sec-Purpose']; 165 | 166 | req.headers['Accept-Language'] = "fr;q=0.8, en;q=0.9"; // another english 167 | res = await common.get(req); 168 | assert.equal(res.headers.vary, 'Accept-Language, Sec-Purpose'); 169 | assert.equal(res.headers['content-language'], 'en'); 170 | assert.equal(res.headers['x-upcache-map'], mappedTestVary); 171 | assert.equal(count(req), 4); 172 | 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /test/tag-lock.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const Path = require('path'); 3 | const URL = require('url'); 4 | const cookie = require('cookie'); 5 | const express = require('express'); 6 | const assert = require('assert').strict; 7 | 8 | const runner = require('../lib/spawner'); 9 | const common = require('./common'); 10 | 11 | const upcache = require('..'); 12 | const locker = upcache.lock({ 13 | privateKey: fs.readFileSync(Path.join(__dirname, 'fixtures/private.pem')).toString(), 14 | publicKey: fs.readFileSync(Path.join(__dirname, 'fixtures/public.pem')).toString(), 15 | maxAge: 3600, 16 | issuer: "test", 17 | userProperty: "user" 18 | }); 19 | const tag = upcache.tag; 20 | 21 | const ports = { 22 | app: 3000, 23 | ngx: 3001, 24 | memc: 3002 25 | }; 26 | 27 | describe("Tag and Lock", () => { 28 | let servers, app; 29 | const testPath = '/full-scope-test'; 30 | const testPathTag = '/full-scope-tag-test'; 31 | const testPathNotGranted = '/full-scope-not-granted-test'; 32 | const scopeDependentTag = '/scope-dependent-tag'; 33 | const testPathDynamic = '/dynamic'; 34 | let counters = {}; 35 | 36 | function count(uri, inc) { 37 | if (typeof uri != "string") { 38 | if (uri.get) uri = uri.protocol + '://' + uri.get('Host') + uri.path; 39 | else uri = URL.format(Object.assign({ 40 | protocol: 'http', 41 | hostname: 'localhost', 42 | pathname: uri.path 43 | }, uri)); 44 | } 45 | let counter = counters[uri]; 46 | if (counter == null) counter = counters[uri] = 0; 47 | if (inc) counters[uri] += inc; 48 | return counters[uri]; 49 | } 50 | 51 | before(async () => { 52 | servers = await runner(ports); 53 | 54 | app = express(); 55 | app.server = app.listen(ports.app); 56 | 57 | app.post('/login', (req, res, next) => { 58 | let givemeScope = req.query.scope; 59 | if (givemeScope && !Array.isArray(givemeScope)) givemeScope = [givemeScope]; 60 | const bearer = locker.login(res, { 61 | id: req.query && req.query.id || 44, 62 | grants: givemeScope || ['bookWriter', 'bookReader'] 63 | }); 64 | res.send({ 65 | bearer: bearer // convenient but not technically needed 66 | }); 67 | }); 68 | 69 | app.post('/logout', (req, res, next) => { 70 | locker.logout(res); 71 | res.sendStatus(204); 72 | }); 73 | 74 | app.get('/', (req, res, next) => { 75 | res.send(req.get('x-upcache') ? "ok" : "not ok"); 76 | }); 77 | 78 | app.get(testPath, tag('test'), locker.restrict('bookReader', 'bookSecond'), (req, res, next) => { 79 | count(req, 1); 80 | res.send({ 81 | value: (req.path || '/').substring(1), 82 | date: new Date() 83 | }); 84 | }); 85 | 86 | app.post(testPath, (req, res, next) => { 87 | res.sendStatus(204); 88 | }); 89 | 90 | app.get(testPathTag, tag("full"), locker.restrict('bookReader', 'bookSecond'), (req, res, next) => { 91 | count(req, 1); 92 | res.send({ 93 | value: (req.path || '/').substring(1), 94 | date: new Date() 95 | }); 96 | }); 97 | 98 | app.post(testPathTag, tag("full"), (req, res, next) => { 99 | res.sendStatus(204); 100 | }); 101 | 102 | app.get(testPathNotGranted, tag("apart"), locker.restrict('bookReaderWhat'), (req, res, next) => { 103 | count(req, 1); 104 | res.send({ 105 | value: (req.path || '/').substring(1), 106 | date: new Date() 107 | }); 108 | }); 109 | 110 | app.get(scopeDependentTag, locker.restrict('user-:id'), (req, res, next) => { 111 | tag('usertag' + req.user.id)(req, res, next); 112 | }, (req, res, next) => { 113 | count(req, 1); 114 | res.send({ 115 | value: (req.path || '/').substring(1), 116 | date: new Date() 117 | }); 118 | }); 119 | app.post(scopeDependentTag, tag("usertag18"), (req, res, next) => { 120 | res.sendStatus(200); 121 | }); 122 | 123 | app.get(testPathDynamic, locker.init, tag('test'), (req, res, next) => { 124 | count(req, 1); 125 | 126 | const grants = req.user.grants; 127 | const locks = ['dynA', 'dynB']; 128 | locker.headers(res, locks); 129 | 130 | res.send({ 131 | value: (req.path || '/').substring(1), 132 | date: new Date(), 133 | usergrants: grants 134 | }); 135 | }); 136 | 137 | app.use(common.errorHandler); 138 | }); 139 | 140 | after(async () => { 141 | app.server.close(); 142 | await servers.close(); 143 | }); 144 | 145 | beforeEach(() => { 146 | counters = {}; 147 | }); 148 | 149 | it("set X-Upcache version in request header", async () => { 150 | const res = await common.get({ 151 | port: ports.ngx, 152 | path: '/' 153 | }); 154 | assert.equal(res.body, 'ok'); 155 | }); 156 | 157 | it("get 401 when accessing a protected url with proxy", async () => { 158 | const req = { 159 | port: ports.ngx, 160 | path: testPath 161 | }; 162 | const res = await common.get(req); 163 | assert.equal(res.statusCode, 401); 164 | assert.equal(count(req), 0); 165 | }); 166 | 167 | it("log in and get read access to a url and hit the cache with proxy", async () => { 168 | const headers = {}; 169 | const req = { 170 | headers: headers, 171 | port: ports.ngx, 172 | path: testPath 173 | }; 174 | let res = await common.get(req); 175 | assert.equal(res.headers['x-upcache-lock'], 'bookReader, bookSecond'); 176 | assert.equal(res.statusCode, 401); 177 | assert.equal(count(req), 0); 178 | 179 | res = await common.post({ 180 | port: ports.ngx, 181 | path: '/login' 182 | }); 183 | 184 | assert.ok(res.headers['set-cookie']); 185 | const cookies = cookie.parse(res.headers['set-cookie'][0]); 186 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 187 | res = await common.get(req); 188 | 189 | assert.equal(res.headers['x-upcache-lock'], 'bookReader, bookSecond'); 190 | assert.equal(res.statusCode, 200); 191 | assert.equal(count(req), 1); 192 | res = await common.get(req); 193 | 194 | assert.equal(res.statusCode, 200); 195 | // because it should be a cache hit 196 | assert.equal(count(req), 1); 197 | }); 198 | 199 | it("log in and not get read access to another url with proxy", async () => { 200 | const headers = {}; 201 | const req = { 202 | headers: headers, 203 | port: ports.ngx, 204 | path: testPathNotGranted 205 | }; 206 | let res = await common.post({ 207 | port: ports.ngx, 208 | path: '/login' 209 | }); 210 | assert.ok(res.headers['set-cookie']); 211 | const cookies = cookie.parse(res.headers['set-cookie'][0]); 212 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 213 | res = await common.get(req); 214 | 215 | assert.equal(res.statusCode, 403); 216 | assert.equal(count(req), 0); 217 | }); 218 | 219 | it("log in, access, then log out, and be denied access with proxy", async () => { 220 | const headers = {}; 221 | let res = await common.post({ 222 | port: ports.ngx, 223 | path: '/login' 224 | }); 225 | assert.ok(res.headers['set-cookie']); 226 | let cookies = cookie.parse(res.headers['set-cookie'][0]); 227 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 228 | res = await common.get({ 229 | headers: headers, 230 | port: ports.ngx, 231 | path: testPath 232 | }); 233 | 234 | assert.equal(res.statusCode, 200); 235 | res = await common.post({ 236 | headers: headers, 237 | port: ports.ngx, 238 | path: "/logout" 239 | }); 240 | 241 | assert.ok(res.headers['set-cookie']); 242 | cookies = cookie.parse(res.headers['set-cookie'][0]); 243 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 244 | res = await common.get({ 245 | headers: headers, 246 | port: ports.ngx, 247 | path: testPath 248 | }); 249 | 250 | assert.equal(res.statusCode, 401); 251 | }); 252 | 253 | it("log in with different scopes and cache each variant with proxy", async () => { 254 | const headers = {}; 255 | const req = { 256 | headers: headers, 257 | port: ports.ngx, 258 | path: testPath 259 | }; 260 | 261 | let res = await common.post({ 262 | port: ports.ngx, 263 | path: '/login?scope=bookReader' 264 | }); 265 | assert.ok(res.headers['set-cookie']); 266 | let cookies = cookie.parse(res.headers['set-cookie'][0]); 267 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 268 | res = await common.get(req); 269 | 270 | assert.equal(res.statusCode, 200); 271 | const firstDate = res.body.date; 272 | res = await common.post({ 273 | port: ports.ngx, 274 | path: '/login?scope=bookSecond' 275 | }); 276 | 277 | assert.ok(res.headers['set-cookie']); 278 | cookies = cookie.parse(res.headers['set-cookie'][0]); 279 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 280 | res = await common.get(req); 281 | 282 | assert.equal(res.statusCode, 200); 283 | assert.notEqual(res.body.date, firstDate); 284 | }); 285 | 286 | it("log in, access url, hit the cache, invalidate the cache with proxy", async () => { 287 | const headers = {}; 288 | const req = { 289 | headers: headers, 290 | port: ports.ngx, 291 | path: testPathTag 292 | }; 293 | let res = await common.post({ 294 | port: ports.ngx, 295 | path: '/login' 296 | }); 297 | assert.ok(res.headers['set-cookie']); 298 | const cookies = cookie.parse(res.headers['set-cookie'][0]); 299 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 300 | res = await common.get(req); 301 | 302 | assert.equal(res.headers['x-upcache-lock'], 'bookReader, bookSecond'); 303 | assert.equal(res.statusCode, 200); 304 | assert.equal(count(req), 1); 305 | res = await common.get(req); 306 | 307 | assert.equal(res.statusCode, 200); 308 | // because it should be a cache hit 309 | assert.equal(count(req), 1); 310 | res = await common.post(req); 311 | res = await common.get(req); 312 | assert.equal(count(req), 2); 313 | }); 314 | 315 | it("cache with scope-dependent tags", async () => { 316 | const headers = {}; 317 | const req = { 318 | headers: headers, 319 | port: ports.ngx, 320 | path: scopeDependentTag 321 | }; 322 | 323 | let res = await common.post({ 324 | port: ports.ngx, 325 | path: '/login?id=17' 326 | }); 327 | assert.ok(res.headers['set-cookie']); 328 | let cookies = cookie.parse(res.headers['set-cookie'][0]); 329 | const firstCookie = headers.Cookie = cookie.serialize("bearer", cookies.bearer); 330 | res = await common.get(req); 331 | 332 | assert.equal(res.statusCode, 200); 333 | const firstDate = res.body.date; 334 | res = await common.get(req); 335 | 336 | 337 | assert.equal(res.statusCode, 200); 338 | assert.equal(res.body.date, firstDate); 339 | assert.equal(count(req), 1); 340 | res = await common.post({ 341 | port: ports.ngx, 342 | path: '/login?id=18' 343 | }); 344 | 345 | assert.ok(res.headers['set-cookie']); 346 | cookies = cookie.parse(res.headers['set-cookie'][0]); 347 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 348 | res = await common.get(req); 349 | 350 | assert.equal(res.statusCode, 200); 351 | assert.notEqual(res.body.date, firstDate); 352 | assert.equal(count(req), 2); 353 | res = await common.post(req); 354 | 355 | assert.equal(res.statusCode, 200); 356 | res = await common.get(req); 357 | 358 | assert.equal(count(req), 3); 359 | assert.equal(res.statusCode, 200); 360 | headers.Cookie = firstCookie; 361 | res = await common.get(req); 362 | 363 | assert.equal(res.statusCode, 200); 364 | assert.equal(count(req), 3); 365 | }); 366 | 367 | it("log in and get read access to a url then read that url again without scopes with proxy", async () => { 368 | const headers = {}; 369 | const req = { 370 | headers: headers, 371 | port: ports.ngx, 372 | path: testPathDynamic 373 | }; 374 | let res = await common.get(req); 375 | assert.equal(res.headers['x-upcache-lock'], 'dynA, dynB'); 376 | assert.equal('x-upcache-key-handshake' in res.headers, false); 377 | assert.equal(res.statusCode, 200); 378 | res = await common.post({ 379 | port: ports.ngx, 380 | path: '/login?scope=dynA&scope=dynB' 381 | }); 382 | 383 | assert.ok(res.headers['set-cookie']); 384 | let cookies = cookie.parse(res.headers['set-cookie'][0]); 385 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 386 | res = await common.get(req); 387 | 388 | assert.equal('x-upcache-key-handshake' in res.headers, false); 389 | assert.equal(res.headers['x-upcache-lock'], 'dynA, dynB'); 390 | assert.equal(res.statusCode, 200); 391 | assert.equal(count(req), 2); 392 | delete headers.Cookie; 393 | res = await common.get(req); 394 | 395 | assert.equal(res.statusCode, 200); 396 | // because it should be a cache hit 397 | assert.equal(count(req), 2); 398 | 399 | res = await common.post({ 400 | port: ports.ngx, 401 | path: '/login?scope=dynA&scope=dynC' 402 | }); 403 | 404 | assert.ok(res.headers['set-cookie']); 405 | cookies = cookie.parse(res.headers['set-cookie'][0]); 406 | headers.Cookie = cookie.serialize("bearer", cookies.bearer); 407 | res = await common.get(req); 408 | 409 | assert.equal('x-upcache-key-handshake' in res.headers, false); 410 | assert.equal(res.headers['x-upcache-lock'], 'dynA, dynB'); 411 | assert.equal(res.statusCode, 200); 412 | assert.equal(count(req), 3); 413 | delete headers.Cookie; 414 | res = await common.get(req); 415 | 416 | assert.equal(res.headers['x-upcache-lock'], 'dynA, dynB'); 417 | assert.equal(res.statusCode, 200); 418 | // because it should be a cache hit 419 | assert.equal(count(req), 3); 420 | }); 421 | }); 422 | -------------------------------------------------------------------------------- /test/tag.js: -------------------------------------------------------------------------------- 1 | const URL = require('url'); 2 | const express = require('express'); 3 | const assert = require('assert').strict; 4 | 5 | const runner = require('../lib/spawner'); 6 | const common = require('./common'); 7 | 8 | const tag = require('..').tag; 9 | 10 | const ports = { 11 | app: 3000, 12 | ngx: 3001, 13 | memc: 3002 14 | }; 15 | 16 | describe("Tag", () => { 17 | let servers, app; 18 | const testPath = '/tag-test'; 19 | const conditionalPath = "/conditional"; 20 | const conditionalPathNot = "/conditionalnot"; 21 | const untaggedPath = '/untagged'; 22 | const counters = {}; 23 | 24 | function count(uri, inc) { 25 | if (typeof uri != "string") { 26 | if (uri.get) uri = uri.protocol + '://' + uri.get('Host') + uri.path; 27 | else uri = URL.format(Object.assign({ 28 | protocol: 'http', 29 | hostname: 'localhost', 30 | pathname: uri.path 31 | }, uri)); 32 | } 33 | let counter = counters[uri]; 34 | if (counter == null) counter = counters[uri] = 0; 35 | if (inc) counters[uri] += inc; 36 | return counters[uri]; 37 | } 38 | 39 | before(async () => { 40 | servers = await runner(ports); 41 | 42 | app = express(); 43 | app.server = app.listen(ports.app); 44 | 45 | app.get('/a', tag('global'), (req, res, next) => { 46 | count(req, 1); 47 | res.send({ 48 | value: (req.path || '/').substring(1), 49 | date: new Date() 50 | }); 51 | }); 52 | 53 | app.get(testPath, tag('test'), (req, res, next) => { 54 | count(req, 1); 55 | res.send({ 56 | value: (req.path || '/').substring(1), 57 | date: new Date() 58 | }); 59 | }); 60 | 61 | app.post(testPath, tag('test'), (req, res, next) => { 62 | res.send('OK'); 63 | }); 64 | 65 | app.post("/a", tag('test'), (req, res, next) => { 66 | res.send('OK'); 67 | }); 68 | 69 | app.get('/multiple', tag('one'), tag('two'), (req, res, next) => { 70 | count(req, 1); 71 | res.send({ 72 | value: (req.path || '/').substring(1), 73 | date: new Date() 74 | }); 75 | }); 76 | 77 | app.post("/multiple", tag('two'), (req, res, next) => { 78 | res.send('OK'); 79 | }); 80 | 81 | app.get(conditionalPath, tag('conditional'), (req, res, next) => { 82 | count(req, 1); 83 | res.set('ETag', 'W/"myetag"'); 84 | res.send({ 85 | value: (req.path || '/').substring(1), 86 | date: new Date() 87 | }); 88 | }); 89 | 90 | app.get(conditionalPathNot, tag('conditionalnot'), (req, res, next) => { 91 | count(req, 1); 92 | res.set('ETag', 'W/"myetagnot"'); 93 | res.send({ 94 | value: (req.path || '/').substring(1), 95 | date: new Date() 96 | }); 97 | }); 98 | 99 | app.get('/multiplesame', tag('one'), tag('one', 'two'), tag('+one', 'two', 'three'), (req, res, next) => { 100 | count(req, 1); 101 | res.send({ 102 | value: (req.path || '/').substring(1), 103 | date: new Date() 104 | }); 105 | }); 106 | 107 | app.get(untaggedPath, (req, res, next) => { 108 | count(req, 1); 109 | res.send("ok"); 110 | }); 111 | 112 | app.get("/params/:test", (req, res, next) => { 113 | if (req.params.test == "none") req.params.test = null; 114 | next(); 115 | }, tag('site-:test').for('1min'), (req, res, next) => { 116 | res.send({ 117 | value: (req.path || '/').substring(1), 118 | date: new Date() 119 | }); 120 | }); 121 | 122 | app.get("/params2/:test", tag('prev'), (req, res, next) => { 123 | if (req.params.test == "none") req.params.test = null; 124 | next(); 125 | }, tag('site-:test').for('1min'), (req, res, next) => { 126 | res.send({ 127 | value: (req.path || '/').substring(1), 128 | date: new Date() 129 | }); 130 | }); 131 | 132 | app.use(common.errorHandler); 133 | }); 134 | 135 | after(async () => { 136 | app.server.close(); 137 | await servers.close(); 138 | }); 139 | 140 | it("cache a url", async () => { 141 | const req = { 142 | port: ports.ngx, 143 | path: testPath 144 | }; 145 | let res = await common.get(req); 146 | assert.equal(res.headers['x-upcache-tag'], 'test'); 147 | res = await common.get(req); 148 | assert.equal(res.headers['x-upcache-tag'], 'test'); 149 | assert.equal(count(req), 1); 150 | }); 151 | 152 | it("honor req.params tag replacement", async () => { 153 | const req = { 154 | port: ports.ngx, 155 | path: "/params/me" 156 | }; 157 | let res = await common.get(req); 158 | 159 | assert.equal(res.headers['x-upcache-tag'], 'site-me'); 160 | assert.equal(res.headers['cache-control'], 'public, max-age=60'); 161 | req.path = "/params/none"; 162 | res = await common.get(req); 163 | 164 | assert.equal('x-upcache-tag' in res.headers, false); 165 | assert.equal('cache-control' in res.headers, false); 166 | }); 167 | 168 | it("honor req.params tag replacement with a previous tag set", async () => { 169 | const req = { 170 | port: ports.ngx, 171 | path: "/params2/me" 172 | }; 173 | let res = await common.get(req); 174 | assert.equal(res.headers['x-upcache-tag'], 'prev, site-me'); 175 | assert.equal(res.headers['cache-control'], 'public, max-age=60'); 176 | req.path = "/params2/none"; 177 | res = await common.get(req); 178 | 179 | assert.equal(res.headers['x-upcache-tag'], 'prev'); 180 | assert.equal('cache-control' in res.headers, false); 181 | }).timeout(0); 182 | 183 | it("invalidate a tag using a post", async () => { 184 | const req = { 185 | port: ports.ngx, 186 | path: testPath 187 | }; 188 | let res = await common.get(req); 189 | 190 | const firstDate = Date.parse(res.body.date); 191 | assert.equal(res.headers['x-upcache-tag'], 'test'); 192 | res = await common.post(req, 'postbody'); 193 | assert.equal(res.headers['x-upcache-tag'], '+test'); 194 | res = await common.get(req); 195 | assert.ok(Date.parse(res.body.date) > firstDate); 196 | }); 197 | 198 | it("invalidate one tag on a route with multiple tags using a post", async () => { 199 | const req = { 200 | port: ports.ngx, 201 | path: "/multiple" 202 | }; 203 | let res = await common.get(req); 204 | 205 | const firstDate = Date.parse(res.body.date); 206 | assert.equal(res.headers['x-upcache-tag'], 'one, two'); 207 | res = await common.post(req, 'postbody'); 208 | 209 | assert.equal(res.headers['x-upcache-tag'], '+two'); 210 | res = await common.get(req); 211 | 212 | assert.ok(Date.parse(res.body.date) > firstDate); 213 | }); 214 | 215 | it("invalidate a tag using a post to a different path", async () => { 216 | const req = { 217 | port: ports.ngx, 218 | path: testPath 219 | }; 220 | let res = await common.get(req); 221 | const firstDate = Date.parse(res.body.date); 222 | assert.equal(res.headers['x-upcache-tag'], 'test'); 223 | res = await common.post({ 224 | port: ports.ngx, 225 | path: "/a" 226 | }, 'postbody'); 227 | 228 | assert.equal(res.headers['x-upcache-tag'], '+test'); 229 | res = await common.get(req); 230 | assert.ok(Date.parse(res.body.date) > firstDate); 231 | }); 232 | 233 | it("handle conditional requests from upstream ETag once cached", async () => { 234 | const headers = {}; 235 | const req = { 236 | headers: headers, 237 | port: ports.ngx, 238 | path: conditionalPath 239 | }; 240 | let res = await common.get(req); 241 | assert.ok('etag' in res.headers); 242 | headers['If-None-Match'] = res.headers.etag; 243 | res = await common.get(req); 244 | 245 | assert.equal(res.statusCode, 304); 246 | assert.equal(count(req), 1); 247 | }); 248 | 249 | it("not let conditional requests go to upstream", async () => { 250 | const headers = {}; 251 | const req = { 252 | headers: headers, 253 | port: ports.ngx, 254 | path: conditionalPathNot 255 | }; 256 | headers['If-None-Match'] = 'W/"myetagnot"'; 257 | let res = await common.get(req); 258 | assert.equal(res.statusCode, 200); 259 | assert.equal(count(req), 1); 260 | res = await common.get(req); 261 | 262 | assert.equal(res.statusCode, 304); 263 | assert.equal(count(req), 1); 264 | }); 265 | 266 | it("not cache responses if not tagged by upstream", async () => { 267 | const headers = {}; 268 | const req = { 269 | headers: headers, 270 | port: ports.ngx, 271 | path: untaggedPath 272 | }; 273 | let res = await common.get(req); 274 | assert.equal(res.statusCode, 200); 275 | assert.equal(count(req), 1); 276 | res = await common.get(req); 277 | assert.equal(res.statusCode, 200); 278 | assert.equal(count(req), 2); 279 | }); 280 | 281 | it("not return multiple identical tags", async () => { 282 | const req = { 283 | port: ports.ngx, 284 | path: '/multiplesame' 285 | }; 286 | let res = await common.get(req); 287 | assert.equal(res.headers['x-upcache-tag'], '+one, two, three'); 288 | res = await common.get(req); 289 | assert.equal(res.headers['x-upcache-tag'], '+one, two, three'); 290 | assert.equal(count(req), 1); 291 | }); 292 | }); 293 | -------------------------------------------------------------------------------- /test/vary.js: -------------------------------------------------------------------------------- 1 | const URL = require('url'); 2 | const express = require('express'); 3 | const cookie = require('cookie'); 4 | const { strict: assert } = require('assert'); 5 | 6 | const runner = require('../lib/spawner'); 7 | const common = require('./common'); 8 | 9 | const tag = require('..').tag; 10 | 11 | const ports = { 12 | app: 3000, 13 | ngx: 3001, 14 | memc: 3002 15 | }; 16 | 17 | describe("Vary", () => { 18 | let servers, app; 19 | const testSimple = '/vary-simple'; 20 | const xAny = 'X-Arbitrary'; 21 | const testPath = '/vary-test'; 22 | const testCookie = '/vary-test-cookie'; 23 | const testNegotiation = '/nego'; 24 | const testLanguage = "/lang"; 25 | const testMulti = "/multi"; 26 | const testDup = "/dup"; 27 | let counters = {}; 28 | 29 | function count(uri, inc) { 30 | if (typeof uri != "string") { 31 | if (uri.get) uri = uri.protocol + '://' + uri.get('Host') + uri.path; 32 | else uri = URL.format(Object.assign({ 33 | protocol: 'http', 34 | hostname: 'localhost', 35 | pathname: uri.path 36 | }, uri)); 37 | } 38 | let counter = counters[uri]; 39 | if (counter == null) counter = counters[uri] = 0; 40 | if (inc) counters[uri] += inc; 41 | return counters[uri]; 42 | } 43 | 44 | before(async () => { 45 | servers = await runner(ports); 46 | 47 | app = express(); 48 | app.server = app.listen(ports.app); 49 | 50 | app.get(testSimple, tag('app'), (req, res, next) => { 51 | res.vary(xAny); 52 | count(req, 1); 53 | res.send({ 54 | received: req.get(xAny) ?? null 55 | }); 56 | }); 57 | 58 | app.get(testPath, tag('app'), (req, res, next) => { 59 | res.vary('User-Agent'); 60 | res.set('User-Agent', req.get('user-agent').includes('Firefox') ? 1 : 2); 61 | count(req, 1); 62 | res.send({ 63 | value: (req.path || '/').substring(1), 64 | date: new Date() 65 | }); 66 | }); 67 | 68 | app.get(testCookie, tag('app'), (req, res, next) => { 69 | const { Prerender } = cookie.parse(req.headers.cookie || "") || {}; 70 | res.vary('X-Cookie-Prerender'); 71 | if (Prerender != null) { 72 | res.set('X-Cookie-Prerender', Prerender == "on" ? 'true' : 'false'); 73 | } 74 | count(req, 1); 75 | res.send({ 76 | value: Prerender 77 | }); 78 | }); 79 | 80 | app.get(testNegotiation, tag('app'), (req, res, next) => { 81 | res.vary('Accept'); 82 | count(req, 1); 83 | if (req.accepts(['xml', 'html']) == 'xml') { 84 | res.type('application/xml'); 85 | res.send(''); 86 | } else if (req.accepts('json')) { 87 | res.json({xml: true}); 88 | } else { 89 | res.sendStatus(406); 90 | } 91 | }); 92 | 93 | app.get(testLanguage, tag('app'), (req, res, next) => { 94 | res.vary('Accept-Language'); 95 | if (req.query.nolang) { 96 | res.send('nolang'); 97 | return; 98 | } 99 | count(req, 1); 100 | const langs = ['en', 'fr']; 101 | if (req.acceptsLanguages(langs) == 'en') { 102 | res.set('Content-Language', 'en'); 103 | res.send('Good !'); 104 | } else if (req.acceptsLanguages(langs) == 'fr') { 105 | res.set('Content-Language', 'fr'); 106 | res.send('Bien !'); 107 | } else { 108 | res.set('Content-Language', 'fr'); 109 | res.send('Default'); 110 | } 111 | }); 112 | 113 | app.get(testMulti, tag('app'), (req, res, next) => { 114 | res.vary('Accept-Language'); 115 | res.append('Vary', 'Accept'); 116 | count(req, 1); 117 | const langs = ['en', 'fr']; 118 | let str = "Bad"; 119 | if (req.acceptsLanguages(langs) == 'en') { 120 | res.set('Content-Language', 'en'); 121 | str = "Good"; 122 | } else if (req.acceptsLanguages(langs) == 'fr') { 123 | res.set('Content-Language', 'fr'); 124 | str = "Bien"; 125 | } else { 126 | res.set('Content-Language', 'en'); 127 | str = 'default'; 128 | } 129 | if (req.accepts('html')) { 130 | res.type('html'); 131 | res.send(`${str}`); 132 | } else if (req.accepts('xml')) { 133 | res.type('application/xml'); 134 | res.send(`${str}`); 135 | } else if (req.accepts('json')) { 136 | res.json({xml: str}); 137 | } 138 | }); 139 | 140 | app.get(testDup, tag('app'), (req, res, next) => { 141 | res.vary('Accept-Language'); 142 | count(req, 1); 143 | res.set('Content-Language', 'en'); 144 | res.send('Good !'); 145 | }); 146 | }); 147 | 148 | after(async () => { 149 | app.server.close(); 150 | await servers.close(); 151 | }); 152 | 153 | beforeEach(() => { 154 | counters = {}; 155 | }); 156 | 157 | it("arbitrary request header", async () => { 158 | const headers = {}; 159 | const req = { 160 | headers: headers, 161 | port: ports.ngx, 162 | path: testSimple 163 | }; 164 | 165 | let res = await common.get(req); 166 | assert.deepEqual(res.body, { received: null }); 167 | 168 | headers[xAny] = 'one'; 169 | res = await common.get(req); 170 | assert.equal(res.headers.vary, xAny); 171 | assert.deepEqual(res.body, { received: 'one' }); 172 | assert.equal(count(req), 2); 173 | 174 | headers[xAny] = 'two'; 175 | res = await common.get(req); 176 | assert.equal(res.headers.vary, xAny); 177 | assert.deepEqual(res.body, { received: 'two' }); 178 | assert.equal(count(req), 3); 179 | 180 | delete headers[xAny]; 181 | res = await common.get(req); 182 | assert.equal(res.headers.vary, xAny); 183 | assert.deepEqual(res.body, { received: null }); 184 | assert.equal(count(req), 3); 185 | 186 | headers[xAny] = 'one'; 187 | res = await common.get(req); 188 | assert.equal(res.headers.vary, xAny); 189 | assert.deepEqual(res.body, { received: 'one' }); 190 | assert.equal(count(req), 3); 191 | 192 | headers[xAny] = 'two'; 193 | res = await common.get(req); 194 | assert.equal(res.headers.vary, xAny); 195 | assert.deepEqual(res.body, { received: 'two' }); 196 | assert.equal(count(req), 3); 197 | }); 198 | 199 | it("two groups of user-agent", async () => { 200 | const agent1 = 'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0'; 201 | const agent2 = 'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/42.0'; 202 | const agent3 = 'Mozilla/5.0'; 203 | const headers = { 204 | 'User-Agent': agent1 205 | }; 206 | const req = { 207 | headers: headers, 208 | port: ports.ngx, 209 | path: testPath 210 | }; 211 | let res = await common.get(req); 212 | headers['User-Agent'] = agent2; 213 | res = await common.get(req); 214 | 215 | assert.equal(count(req), 2); 216 | assert.equal(res.headers['vary'], 'Accept-Encoding, User-Agent'); 217 | assert.equal(res.headers['user-agent'], '1'); 218 | headers['User-Agent'] = agent1; 219 | res = await common.get(req); 220 | 221 | assert.equal(count(req), 2); 222 | assert.equal(res.headers['vary'], 'Accept-Encoding, User-Agent'); 223 | assert.equal(res.headers['user-agent'], '1'); 224 | headers['User-Agent'] = agent2; 225 | res = await common.get(req); 226 | 227 | assert.equal(res.headers['vary'], 'Accept-Encoding, User-Agent'); 228 | assert.equal(res.headers['user-agent'], '1'); 229 | assert.equal(count(req), 2); 230 | headers['User-Agent'] = agent3; 231 | res = await common.get(req); 232 | 233 | assert.equal(res.headers['vary'], 'Accept-Encoding, User-Agent'); 234 | assert.equal(res.headers['user-agent'], '2'); 235 | assert.equal(count(req), 3); 236 | }); 237 | 238 | it("Accept, Content-Type", async () => { 239 | const headers = {}; 240 | const req = { 241 | headers: headers, 242 | port: ports.ngx, 243 | path: testNegotiation 244 | }; 245 | let res = await common.get(req); 246 | assert.equal(count(req), 1); 247 | res = await common.get(req); 248 | 249 | assert.equal(count(req), 1); 250 | req.headers.Accept = "application/json"; 251 | res = await common.get(req); 252 | 253 | assert.equal(res.headers['vary'], 'Accept'); 254 | assert.equal(res.headers['content-type'], 'application/json; charset=utf-8'); 255 | assert.equal(count(req), 2); 256 | req.headers.Accept = "text/plain"; 257 | res = await common.get(req); 258 | 259 | assert.equal(res.statusCode, 406); 260 | assert.equal(count(req), 3); 261 | req.headers.Accept = "application/xml"; 262 | res = await common.get(req); 263 | 264 | assert.equal(res.headers['vary'], 'Accept'); 265 | assert.equal(res.headers['content-type'], 'application/xml; charset=utf-8'); 266 | assert.equal(count(req), 4); 267 | res = await common.get(req); 268 | 269 | assert.equal(res.headers['vary'], 'Accept'); 270 | assert.equal(res.headers['content-type'], 'application/xml; charset=utf-8'); 271 | assert.equal(count(req), 4); 272 | req.headers.Accept = "application/json"; 273 | res = await common.get(req); 274 | 275 | assert.equal(res.headers['vary'], 'Accept'); 276 | assert.equal(res.headers['content-type'], 'application/json; charset=utf-8'); 277 | assert.equal(count(req), 4); 278 | }); 279 | 280 | it("Accept-Language, Content-Language", async () => { 281 | const headers = {}; 282 | const req = { 283 | headers: headers, 284 | port: ports.ngx, 285 | path: testLanguage 286 | }; 287 | const english = "en-GB,en;q=0.9"; 288 | const french = "fr;q=0.8, en;q=0.7, pt;q=0.5"; 289 | 290 | await common.get({ 291 | headers: {'Accept-Language': english}, 292 | port: ports.ngx, 293 | path: testLanguage + '?nolang=1' 294 | }); 295 | 296 | req.headers['Accept-Language'] = english; 297 | let res = await common.get(req); 298 | assert.equal(res.headers['vary'], 'Accept-Language'); 299 | assert.equal(res.headers['content-language'], 'en'); 300 | 301 | req.headers['Accept-Language'] = french; 302 | res = await common.get(req); 303 | assert.equal(count(req), 2); 304 | assert.equal(res.headers['vary'], 'Accept-Language'); 305 | assert.equal(res.headers['content-language'], 'fr'); 306 | 307 | req.headers['Accept-Language'] = english; 308 | res = await common.get(req); 309 | assert.equal(count(req), 2); 310 | assert.equal(res.headers['vary'], 'Accept-Language'); 311 | assert.equal(res.headers['content-language'], 'en'); 312 | 313 | req.headers['Accept-Language'] = french; 314 | res = await common.get(req); 315 | assert.equal(res.headers['vary'], 'Accept-Language'); 316 | assert.equal(res.headers['content-language'], 'fr'); 317 | assert.equal(count(req), 2); 318 | 319 | req.headers['Accept-Language'] = "fr;q=0.8, en;q=0.9"; // another english 320 | res = await common.get(req); 321 | assert.equal(res.headers['vary'], 'Accept-Language'); 322 | assert.equal(res.headers['content-language'], 'en'); 323 | assert.equal(count(req), 3); 324 | 325 | req.headers['Accept-Language'] = "fr;q=0.8, en;q=0.9"; // another english 326 | res = await common.get(req); 327 | assert.equal(res.headers['vary'], 'Accept-Language'); 328 | assert.equal(res.headers['content-language'], 'en'); 329 | assert.equal(count(req), 3); 330 | }); 331 | 332 | it("Accept-Language and Accept", async () => { 333 | const headers = {}; 334 | const req = { 335 | headers: headers, 336 | port: ports.ngx, 337 | path: testMulti 338 | }; 339 | let counter = 0; 340 | const english = "en-GB,en;q=0.9"; 341 | const french = "fr;q=0.8, en;q=0.7, pt;q=0.5"; 342 | const browserAccept = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"; 343 | 344 | req.headers['Accept-Language'] = english; 345 | let res = await common.get({ 346 | headers: headers, 347 | port: ports.ngx, 348 | path: testLanguage 349 | }); 350 | assert.equal(res.headers['vary'], 'Accept-Language'); 351 | assert.equal(res.headers['content-language'], 'en'); 352 | 353 | req.headers['Accept-Language'] = null; 354 | req.headers.Accept = browserAccept; 355 | res = await common.get(req); 356 | counter++; 357 | assert.equal(res.headers['vary'], 'Accept-Encoding, Accept-Language, Accept'); 358 | assert.equal(res.headers['content-language'], 'en'); 359 | assert.equal(res.headers['content-type'], 'text/html; charset=utf-8'); 360 | 361 | req.headers['Accept-Language'] = english; 362 | req.headers.Accept = browserAccept; 363 | res = await common.get(req); 364 | counter++; 365 | assert.equal(res.headers['vary'], 'Accept-Encoding, Accept-Language, Accept'); 366 | assert.equal(res.headers['content-language'], 'en'); 367 | assert.equal(res.headers['content-type'], 'text/html; charset=utf-8'); 368 | 369 | req.headers['Accept-Language'] = french; 370 | req.headers.Accept = browserAccept; 371 | res = await common.get(req); 372 | counter++; 373 | assert.equal(count(req), counter); 374 | assert.equal(res.headers['vary'], 'Accept-Encoding, Accept-Language, Accept'); 375 | assert.equal(res.headers['content-language'], 'fr'); 376 | assert.equal(res.headers['content-type'], 'text/html; charset=utf-8'); 377 | 378 | req.headers['Accept-Language'] = english; 379 | req.headers.Accept = "application/json"; 380 | res = await common.get(req); 381 | counter++; 382 | assert.equal(count(req), counter); 383 | assert.equal(res.headers['vary'], 'Accept-Language, Accept'); 384 | assert.equal(res.headers['content-language'], 'en'); 385 | assert.equal(res.headers['content-type'], 'application/json; charset=utf-8'); 386 | 387 | req.headers['Accept-Language'] = french; 388 | req.headers.Accept = "application/json"; 389 | res = await common.get(req); 390 | counter++; 391 | assert.equal(count(req), counter); 392 | assert.equal(res.headers['vary'], 'Accept-Language, Accept'); 393 | assert.equal(res.headers['content-language'], 'fr'); 394 | assert.equal(res.headers['content-type'], 'application/json; charset=utf-8'); 395 | 396 | req.headers['Accept-Language'] = english; 397 | req.headers.Accept = "application/json"; 398 | res = await common.get(req); 399 | assert.equal(count(req), counter); 400 | assert.equal(res.headers['vary'], 'Accept-Language, Accept'); 401 | assert.equal(res.headers['content-language'], 'en'); 402 | assert.equal(res.headers['content-type'], 'application/json; charset=utf-8'); 403 | 404 | req.headers['Accept-Language'] = english; 405 | req.headers.Accept = "application/xml"; 406 | res = await common.get(req); 407 | counter++; 408 | assert.equal(count(req), counter); 409 | assert.equal(res.headers['vary'], 'Accept-Language, Accept'); 410 | assert.equal(res.headers['content-language'], 'en'); 411 | assert.equal(res.headers['content-type'], 'application/xml; charset=utf-8'); 412 | 413 | req.headers['Accept-Language'] = french; 414 | req.headers.Accept = "application/json"; 415 | res = await common.get(req); 416 | assert.equal(count(req), counter); 417 | assert.equal(res.headers['vary'], 'Accept-Language, Accept'); 418 | assert.equal(res.headers['content-language'], 'fr'); 419 | assert.equal(res.headers['content-type'], 'application/json; charset=utf-8'); 420 | }); 421 | 422 | 423 | it("Cookie Name", async () => { 424 | const headers = {}; 425 | const req = { 426 | headers: headers, 427 | port: ports.ngx, 428 | path: testCookie 429 | }; 430 | 431 | req.headers['Cookie'] = 'IgnoreMe=1; DNT=1; Prerender=on'; 432 | let res = await common.get(req); 433 | assert.equal(res.headers['vary'], 'X-Cookie-Prerender'); 434 | assert.equal(res.headers['x-cookie-prerender'], 'true'); 435 | req.headers['Cookie'] = 'IgnoreMe=1; DNT=1; Prerender=off'; 436 | res = await common.get(req); 437 | 438 | assert.equal(count(req), 2); 439 | assert.equal(res.headers['vary'], 'X-Cookie-Prerender'); 440 | assert.equal(res.headers['x-cookie-prerender'], 'false'); 441 | req.headers['Cookie'] = 'IgnoreMe=1; DNT=1; Prerender=on'; 442 | res = await common.get(req); 443 | 444 | assert.equal(count(req), 2); 445 | assert.equal(res.headers['vary'], 'X-Cookie-Prerender'); 446 | assert.equal(res.headers['x-cookie-prerender'], 'true'); 447 | req.headers['Cookie'] = 'IgnoreMe=1; DNT=1; Prerender=off'; 448 | res = await common.get(req); 449 | 450 | assert.equal(count(req), 2); 451 | assert.equal(res.headers['vary'], 'X-Cookie-Prerender'); 452 | assert.equal(res.headers['x-cookie-prerender'], 'false'); 453 | req.headers['Cookie'] = 'IgnoreMe=1; DNT=1'; 454 | res = await common.get(req); 455 | 456 | assert.equal(res.headers['vary'], 'X-Cookie-Prerender'); 457 | assert.equal('x-cookie-prerender' in res.headers, false); 458 | assert.equal(count(req), 3); 459 | }); 460 | 461 | it("duplicated headers", async () => { 462 | const req = { 463 | headers: {}, 464 | port: ports.ngx, 465 | path: testDup 466 | }; 467 | req.headers['Accept-Language'] = "fr;q=0.8, en;q=0.9"; 468 | res = await common.get(req); 469 | assert.equal(res.headers['vary'], 'Accept-Language'); 470 | assert.equal(res.headers['content-language'], 'en'); 471 | assert.equal(count(req), 1); 472 | 473 | // express req.headers sees the same but not nginx/lua 474 | req.headers['Accept-Language'] = ["fr;q=0.8", "en;q=0.9"]; 475 | res = await common.get(req); 476 | assert.equal(res.headers['vary'], 'Accept-Language'); 477 | assert.equal(res.headers['content-language'], 'en'); 478 | assert.equal(count(req), 1); 479 | 480 | }); 481 | }); 482 | -------------------------------------------------------------------------------- /upcache.lua: -------------------------------------------------------------------------------- 1 | local module = {} 2 | local Lock = require "upcache.lock" 3 | local Tag = require "upcache.tag" 4 | local Vary = require "upcache.vary" 5 | local Map = require "upcache.map" 6 | local common = require "upcache.common" 7 | local console = common.console 8 | 9 | module._VERSION = "1" 10 | 11 | module.jwt = Lock.jwt 12 | 13 | local function upkey(vars) 14 | return (vars.https == "on" and "https" or "http") .. "://" .. vars.host .. vars.request_uri 15 | end 16 | 17 | function module.request() 18 | if module.disabled then 19 | return 20 | end 21 | ngx.req.set_header(common.prefixHeader, module._VERSION) 22 | local vars = ngx.var 23 | local method = ngx.req.get_method() 24 | if method == "GET" or method == "HEAD" then 25 | local key = upkey(vars) 26 | local nkey = Lock.get(key, vars, ngx) 27 | nkey = Vary.get(nkey, vars, ngx) 28 | nkey = Map.get(nkey) 29 | nkey = Tag.get(nkey) 30 | if nkey ~= key then 31 | console.info("Req key changed: ", key, " >> ", nkey) 32 | else 33 | console.info("Req key: ", nkey) 34 | end 35 | vars.fetchKey = ngx.md5(nkey) 36 | else 37 | Lock.request(vars) 38 | end 39 | end 40 | 41 | function module.response() 42 | local vars = ngx.var 43 | if vars.srcache_fetch_status == "HIT" then 44 | return 45 | end 46 | local method = ngx.req.get_method() 47 | local key = upkey(vars) 48 | local nkey = key 49 | if method == "GET" or method == "HEAD" then 50 | nkey = Lock.set(nkey, vars, ngx) 51 | nkey = Vary.set(nkey, vars, ngx) 52 | nkey = Map.set(nkey, vars, ngx) 53 | nkey = Tag.set(nkey, vars, ngx) 54 | if vars.storeSkip == '1' then 55 | -- do nothing 56 | elseif nkey ~= key then 57 | console.info("New key: ", key, " >> ", nkey) 58 | vars.storeKey = ngx.md5(nkey) 59 | else 60 | console.info("Same key: ", key) 61 | vars.storeKey = vars.fetchKey 62 | end 63 | else 64 | Lock.response(vars, ngx) 65 | Tag.response(vars, ngx) 66 | end 67 | end 68 | 69 | 70 | 71 | return module 72 | -------------------------------------------------------------------------------- /upcache/common.lua: -------------------------------------------------------------------------------- 1 | local module = {} 2 | 3 | local mp = require 'MessagePack' 4 | 5 | local log = ngx.log 6 | local ERR = ngx.ERR 7 | local INFO = ngx.INFO 8 | local json = require 'cjson.safe' 9 | 10 | module.console = {} 11 | 12 | function module.console.info(...) 13 | return log(INFO, ...) 14 | end 15 | 16 | function module.console.error(...) 17 | return log(ERR, ...) 18 | end 19 | 20 | function module.console.encode(obj) 21 | if obj == nil then 22 | return "null" 23 | end 24 | return json.encode(obj) 25 | end 26 | 27 | module.prefixHeader = "X-Upcache" 28 | module.variants = "upcacheVariants" 29 | 30 | function module.headerList(obj) 31 | if obj == nil then 32 | return nil 33 | end 34 | if type(obj) == "string" then 35 | obj = {obj} 36 | end 37 | local list = {} 38 | for i, label in ipairs(obj) do 39 | for str in string.gmatch(label, "[^,%s]+") do 40 | table.insert(list, str) 41 | end 42 | end 43 | return list 44 | end 45 | 46 | function module.headerString(obj) 47 | if obj == nil then 48 | return nil 49 | elseif type(obj) == "string" then 50 | return obj 51 | else 52 | return table.concat(obj, ', ') 53 | end 54 | end 55 | 56 | function module.get(dict, key) 57 | local pac = ngx.shared[dict]:get(key) 58 | if pac == nil then 59 | return {} 60 | end 61 | return mp.unpack(pac) 62 | end 63 | 64 | function module.set(dict, key, data, what) 65 | local vars 66 | if what ~= nil then 67 | vars = module.get(dict, key) 68 | vars[what] = data 69 | else 70 | vars = data 71 | end 72 | ngx.shared[dict]:set(key, mp.pack(vars)) 73 | return vars 74 | end 75 | 76 | return module 77 | 78 | -------------------------------------------------------------------------------- /upcache/lock.lua: -------------------------------------------------------------------------------- 1 | local jwt = require 'resty.jwt' 2 | local validators = require "resty.jwt-validators" 3 | 4 | local common = require 'upcache.common' 5 | local console = common.console 6 | 7 | local module = {} 8 | 9 | local headerLock = common.prefixHeader .. "-Lock" 10 | local headerKey = common.prefixHeader .. "-Lock-Key" 11 | local headerVar = common.prefixHeader .. "-Lock-Var" 12 | local upcacheLocks = 'upcacheLocks' 13 | 14 | -- star is voluntarily removed from that pattern 15 | local quotepattern = '(['..("%^$().[]+-?"):gsub("(.)", "%%%1")..'])' 16 | 17 | local function quoteReg(str) 18 | return str:gsub(quotepattern, "%%%1") 19 | end 20 | 21 | local function logKey(from, what, key, data) 22 | if string.find(key, quoteReg(what) .. "$") == nil then return end 23 | console.info(from, " ", key, console.encode(data)) 24 | end 25 | 26 | local function authorize(locks, token) 27 | -- array of used grants 28 | local grants = {} 29 | if locks == nil then 30 | return grants 31 | end 32 | if token == nil then 33 | return grants 34 | end 35 | local grant, found 36 | 37 | for i, lock in ipairs(locks) do 38 | found = false 39 | if lock:find("%*") and token.grants ~= nil then 40 | local regstr = "^" .. quoteReg(lock):gsub('*', '.*') .. "$" 41 | for i, grant in ipairs(token.grants) do 42 | if grant:find(regstr) ~= nil then 43 | table.insert(grants, grant) 44 | break 45 | end 46 | end 47 | elseif lock:find(":") then 48 | grant = string.gsub(lock, "%w*:(%w+)", function(key) 49 | local val = token[key] 50 | if val ~= nil then 51 | found = true 52 | return token[key] 53 | end 54 | end) 55 | if found == true then 56 | table.insert(grants, grant) 57 | end 58 | elseif token.grants ~= nil then 59 | for i, grant in ipairs(token.grants) do 60 | if grant == lock then 61 | table.insert(grants, grant) 62 | break 63 | end 64 | end 65 | end 66 | end 67 | if #grants > 0 then 68 | table.sort(grants) 69 | end 70 | return grants 71 | end 72 | 73 | local function build_key(key, locks, token) 74 | local grants = authorize(locks, token) 75 | local str = table.concat(grants, ',') 76 | if str:len() > 0 then key = str .. ' ' .. key end 77 | return key 78 | end 79 | 80 | local function get_locks(key) 81 | return common.get(common.variants, key)['locks'] 82 | end 83 | 84 | local function update_locks(key, data) 85 | common.set(common.variants, key, data, 'locks') 86 | end 87 | 88 | local function get_jwt(conf, vars) 89 | if conf.key == nil then 90 | return nil 91 | end 92 | local varname = conf.varname 93 | if varname == nil then 94 | varname = "cookie_bearer" 95 | end 96 | local bearer = vars[varname] 97 | if bearer == nil then 98 | return nil 99 | end 100 | local jwt_obj = jwt:load_jwt(bearer) 101 | local verified = jwt:verify_jwt_obj(conf.key, jwt_obj, { 102 | iss = validators.equals(vars.host) 103 | }) 104 | if jwt_obj == nil or verified == false then 105 | return nil 106 | end 107 | return jwt_obj.payload 108 | end 109 | 110 | local function request(vars) 111 | local conf = common.get(upcacheLocks, vars.host) 112 | if conf.key == nil then 113 | ngx.req.set_header(headerKey, "1") 114 | end 115 | return conf 116 | end 117 | module.request = request 118 | 119 | local function response(vars, ngx) 120 | local host = vars.host 121 | local headers = ngx.header 122 | local varname = headers[headerVar] 123 | local key = headers[headerKey] 124 | local conf = common.get(upcacheLocks, host) 125 | local update = false 126 | 127 | if varname ~= nil then 128 | console.info("response sets var on '", host, "': ", varname) 129 | conf.varname = varname 130 | headers[headerVar] = nil 131 | update = true 132 | end 133 | if key ~= nil then 134 | console.info("response sets key on '", host, "' with ", key:len(), " bytes") 135 | key = ngx.unescape_uri(key) 136 | conf.key = key 137 | headers[headerKey] = nil 138 | update = true 139 | end 140 | if update then 141 | common.set(upcacheLocks, host, conf) 142 | end 143 | return conf 144 | end 145 | module.response = response 146 | 147 | function module.jwt(vars, ngx) 148 | return console.encode(get_jwt(request(vars), vars)) 149 | end 150 | 151 | function module.get(key, vars, ngx) 152 | local conf = request(vars) 153 | return build_key(key, get_locks(key), get_jwt(conf, vars)) 154 | end 155 | 156 | function module.set(key, vars, ngx) 157 | local headers = ngx.header 158 | local conf = response(vars, ngx) 159 | local locks = common.headerList(headers[headerLock]) 160 | if locks == nil then 161 | return key 162 | end 163 | update_locks(key, locks) 164 | return build_key(key, locks, get_jwt(conf, vars)) 165 | end 166 | 167 | return module; 168 | 169 | -------------------------------------------------------------------------------- /upcache/map.lua: -------------------------------------------------------------------------------- 1 | local common = require "upcache.common" 2 | local console = common.console 3 | 4 | local module = {} 5 | 6 | local mapHeader = common.prefixHeader .. "-Map" 7 | 8 | local function build_key(key, mapped_uri, uri) 9 | return key:sub(1, key:len() - uri:len()) .. mapped_uri 10 | end 11 | 12 | function module.get(key, vars, ngx) 13 | local nkey = common.get(common.variants, key)['map'] 14 | if nkey == nil then 15 | return key 16 | else 17 | return nkey 18 | end 19 | end 20 | 21 | function module.set(key, vars, ngx) 22 | local mapped_uri = ngx.header[mapHeader] 23 | if mapped_uri == nil then 24 | return key 25 | end 26 | local nkey = build_key(key, mapped_uri, vars.request_uri) 27 | common.set(common.variants, key, nkey, 'map') 28 | return nkey 29 | end 30 | 31 | return module; 32 | -------------------------------------------------------------------------------- /upcache/tag.lua: -------------------------------------------------------------------------------- 1 | local common = require "upcache.common" 2 | local console = common.console 3 | 4 | local module = {} 5 | 6 | local tagHeader = common.prefixHeader .. "-Tag" 7 | -- monotonous version prefix - prevents key conflicts between nginx reboots 8 | local MVP = ngx.time() 9 | 10 | local function build_key(key, tags) 11 | if tags == nil then return key end 12 | local nkey = key 13 | local mtags = ngx.shared.upcacheTags 14 | local tagval 15 | for i, tag in ipairs(tags) do 16 | tagval = mtags:get(tag) 17 | if tagval == nil then tagval = MVP end 18 | nkey = tag .. '=' .. tagval .. ' ' .. nkey 19 | end 20 | return nkey 21 | end 22 | 23 | local function response(vars, ngx) 24 | local tags = common.headerList(ngx.header[tagHeader]) 25 | if tags == nil 26 | then return nil 27 | end 28 | local mtags 29 | local tagval 30 | for i, tag in ipairs(tags) do 31 | if (tag:sub(1,1) == '+') then 32 | if mtags == nil then 33 | mtags = ngx.shared.upcacheTags 34 | end 35 | tag = tag:sub(2) 36 | tags[i] = tag 37 | tagval = mtags:get(tag) 38 | if tagval == nil then 39 | tagval = MVP 40 | end 41 | mtags:set(tag, tagval + 1) 42 | end 43 | end 44 | return tags 45 | end 46 | module.response = response 47 | 48 | function module.get(key) 49 | return build_key(key, common.get(common.variants, key)['tags']) 50 | end 51 | 52 | function module.set(key, vars, ngx) 53 | local tags = response(vars, ngx) 54 | if tags == nil then 55 | vars.storeSkip = '1' 56 | return key 57 | end 58 | table.sort(tags) 59 | common.set(common.variants, key, tags, 'tags') 60 | return build_key(key, tags) 61 | end 62 | 63 | return module; 64 | -------------------------------------------------------------------------------- /upcache/vary.lua: -------------------------------------------------------------------------------- 1 | local common = require "upcache.common" 2 | local console = common.console 3 | 4 | local module = {} 5 | 6 | local varyHeader = "Vary" 7 | 8 | local function sortedIterator(t, f) 9 | local a = {} 10 | for n in pairs(t) do table.insert(a, n) end 11 | table.sort(a, f) 12 | local i = 0 13 | local iter = function () 14 | i = i + 1 15 | if a[i] == nil then return nil 16 | else return a[i], t[a[i]] 17 | end 18 | end 19 | return iter 20 | end 21 | 22 | local function build_key(key, headers, list, vars) 23 | local resVal 24 | local reqVal 25 | for reqName, map in sortedIterator(list) do 26 | if reqName:sub(1, 9) == "X-Cookie-" then 27 | reqVal = vars['cookie_' .. reqName:sub(10)] 28 | else 29 | reqVal = headers[reqName] 30 | end 31 | resVal = map[common.headerString(reqVal) or '*'] 32 | if resVal ~= nil then 33 | key = reqName .. ':' .. resVal .. ' ' .. key 34 | end 35 | end 36 | return key 37 | end 38 | 39 | function module.get(key, vars, ngx) 40 | local list = common.get(common.variants, key)['vary'] 41 | if list == nil then 42 | return key 43 | end 44 | return build_key(key, ngx.req.get_headers(), list, vars) 45 | end 46 | 47 | function module.set(key, vars, ngx) 48 | local resHeaders = ngx.header 49 | local varies = common.headerList(resHeaders[varyHeader]) 50 | if varies == nil then 51 | return key 52 | end 53 | local list = common.get(common.variants, key)['vary'] or {} 54 | local ok = false 55 | local reqHeaders = ngx.req.get_headers() 56 | local resName, resVal, reqVal 57 | for i, reqName in ipairs(varies) do 58 | if reqName == "Accept" then 59 | reqVal = reqHeaders[reqName] 60 | resName = "Content-Type" 61 | elseif reqName:sub(1, 7) == "Accept-" then 62 | reqVal = reqHeaders[reqName] 63 | resName = "Content-" .. reqName:sub(8) 64 | elseif reqName:sub(1, 9) == "X-Cookie-" then 65 | reqVal = vars['cookie_' .. reqName:sub(10)] 66 | resName = reqName 67 | else 68 | reqVal = reqHeaders[reqName] 69 | resName = reqName 70 | end 71 | if reqVal == nil then 72 | reqVal = '*' 73 | end 74 | resVal = common.headerString(resHeaders[resName] or reqVal) 75 | 76 | local map = list[reqName] 77 | if map == nil then 78 | map = {} 79 | list[reqName] = map 80 | end 81 | map[reqVal] = resVal 82 | ok = true 83 | end 84 | if ok == false then 85 | return key 86 | end 87 | common.set(common.variants, key, list, 'vary') 88 | return build_key(key, reqHeaders, list, vars) 89 | end 90 | 91 | return module; 92 | 93 | --------------------------------------------------------------------------------