Initial commit

master
Sven Slootweg 5 years ago
commit 19dcc18a45

2
.gitignore vendored

@ -0,0 +1,2 @@
node_modules
config.jsx

@ -0,0 +1,57 @@
# mobile-proxy
A simple service that proxies and transforms content from one or more external sites, meant to be used for generating versions of websites that are friendly to low-powered/limited mobile devices.
Supports multiple external sites; the correct site to proxy will be picked depending on the hostname of the incoming request. So you need one 'subdomain' (DNS record) per site you want to proxy.
Keep in mind that this is *not* a general-purpose reverse proxy. While its behaviour is reasonably configurable, it's ultimately meant to generate stripped-down versions of sites, and to that end it may indiscriminately strip out rich content that a shoddily-designed or low-powered mobile device may not be able to deal with.
Rewrites URLs on-the-fly as needed to keep site-internal links pointing at the proxy, even when those links are expressed as absolute URLs. Currently no CSS URL rewriting; you probably won't be proxying any CSS anyway.
## Configuration
Create a `config.jsx` (yes, JSX) that looks something like this:
```jsx
"use strict";
const React = require("react");
const url = require("url");
module.exports = {
port: 3001,
hosts: {
"iomfats.cryto.net": {
prefix: () => (
<div style={{ color: "#801700" }}>
This is a mobile proxy for the IOMfAtS Story Shelf.
</div>
),
filters: [{
matchPath: "/",
mapUrl: "http://iomfats.org/storyshelf/",
mapContent: ($) => $("div#homelinks").html()
}, {
mapUrl: ({path}) => url.resolve("http://iomfats.org/", path),
mapContent: ($) => $("div#content").html()
}]
}
}
};
```
You can use JSX like you normally would, but you *cannot* currently `require` external JSX files. You *can* require other JS modules, so long as you've installed them into the project first. The `$` argument in a `mapContent` callback is a [`cheerio`](https://github.com/cheeriojs/cheerio) instance, the object passed to `mapUrl` comes from [`url.parse`](https://nodejs.org/api/url.html#url_url_parse_urlstring_parsequerystring_slashesdenotehost).
I might write more documentation later if somebody other than me actually ends up using this thing. If you get stuck on something, just file an issue for now with your question!
## Running it
```sh
node server.js
```
Or if your configuration file is in a different location:
```sh
node server.js /path/to/config.jsx
```

@ -0,0 +1,19 @@
"use strict";
const path = require("path");
module.exports = function (configPath) {
let absoluteConfigPath = path.join(__dirname, configPath);
require('@babel/register')({
only: [
(item) => item === absoluteConfigPath,
],
presets: [
"@babel/preset-react",
["@babel/preset-env", { targets: { node: "current" } }]
]
});
return require(configPath);
}

@ -0,0 +1,30 @@
{
"name": "mobile-proxy",
"version": "1.0.0",
"main": "index.js",
"repository": "git@git.cryto.net:joepie91/mobile-proxy.git",
"author": "Sven Slootweg <admin@cryto.net>",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0",
"@babel/register": "^7.5.5",
"bhttp": "^1.2.4",
"cheerio": "^1.0.0-rc.3",
"create-error": "^0.3.1",
"dataprog": "^0.1.0",
"default-value": "^1.0.0",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"object.pick": "^1.3.0",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"report-errors": "^1.0.0",
"rewrite-css-urls": "^1.0.4",
"whatwg-mimetype": "^2.3.0"
},
"devDependencies": {
"nodemon": "^1.19.1"
}
}

@ -0,0 +1,43 @@
"use strict";
/* TODO: Separate out into module (that can take either a Cheerio instance or a HTML string) */
const rewriteCssUrls = require("rewrite-css-urls");
module.exports = function ($, rewriteUrl) {
function rewriteCss(css) {
return rewriteCssUrls.findAndReplace(css, { replaceUrl: (ref) => rewriteUrl(ref.url) });
}
function patchAttribute(elements, attribute) {
elements.get().forEach((element) => {
let $element = $(element);
let value = $element.attr(attribute);
if (value != null) {
$element.attr(attribute, rewriteUrl(value));
}
});
}
patchAttribute($("a"), "href");
patchAttribute($("img"), "src");
patchAttribute($("link"), "href");
patchAttribute($("script"), "src");
patchAttribute($("form"), "action");
patchAttribute($("iframe"), "src");
$("style").get().forEach((element) => {
let $element = $(element);
$element.text(rewriteCss($element.text()));
});
$("*[style]").get().forEach((element) => {
let $element = $(element);
$element.attr("style", rewriteCss($element.attr("style")));
});
return $;
};

@ -0,0 +1,116 @@
"use strict";
const Promise = require("bluebird");
const React = require("react");
const ReactDOMServer = require("react-dom/server");
const bhttp = require("bhttp");
const express = require("express");
const expressPromiseRouter = require("express-promise-router");
const { expression } = require("dataprog");
const url = require("url");
const createError = require("create-error");
const objectPick = require("object.pick");
const cheerio = require("cheerio");
const defaultValue = require("default-value");
const whatwgMimeType = require("whatwg-mimetype");
const rewriteHtmlUrls = require("./rewrite-html-urls");
const config = require("./load-config")(defaultValue(process.argv[2], "./config.jsx"));
let ConfigError = createError("ConfigError");
function stripWWW(hostname) {
return hostname.replace(/^www\./, "");
}
let app = express();
let router = expressPromiseRouter();
router.use((req, res) => {
let siteConfiguration = config.hosts[req.hostname];
if (siteConfiguration == null) {
res.status(404).send(`No configuration exists for host '${req.hostname}'`);
} else {
let filter = siteConfiguration.filters.find((filter) => {
if (filter.matchPath != null) {
if (typeof filter.matchPath === "string") {
return (filter.matchPath === req.path);
} else if (filter.matchPath instanceof RegExp) {
return filter.matchPath.test(req.path);
} else {
console.warn(`Unrecognized URL filter type while handling path '${req.path}'`);
return false;
}
} else {
return true;
}
});
let newUrl = expression(() => {
if (filter.mapUrl == null) {
throw new ConfigError(`No URL mapper specified for path '${req.path}'`);
} else if (typeof filter.mapUrl === "function") {
return filter.mapUrl(url.parse(req.originalUrl));
} else if (typeof filter.mapUrl === "string") {
return filter.mapUrl;
} else {
throw new ConfigError(`Unrecognized URL filter type while handling path '${req.path}'`);
}
});
return Promise.try(() => {
return bhttp.get(newUrl, { headers: { "user-agent": "mobile proxy (questions/comments: admin@cryto.net)" } });
}).then((response) => {
res.set(objectPick(response.headers, [ "content-type" ]));
let mimeType = new whatwgMimeType(response.headers["content-type"]);
if (mimeType.isHTML()) {
let $ = cheerio.load(response.body);
rewriteHtmlUrls($, (path) => {
let parsedOrigin = url.parse(newUrl);
let parsedPath = url.parse(path);
if (!["http:", "https:"].includes(parsedPath.protocol)) {
return path;
} else if (parsedPath.host == null) {
return url.resolve(req.hostname, path);
} else if (stripWWW(parsedPath.host) === stripWWW(parsedOrigin.host)) {
/* Internal absolute URL */
return url.resolve(req.hostname, parsedPath.path);
} else {
/* External absolute URL */
return path;
}
});
/* Strips any target="_blank" attributes, which mess up some mobile readers */
$("a").attr("target", null);
let prefix = (siteConfiguration.prefix != null)
? ReactDOMServer.renderToStaticMarkup(React.createElement(siteConfiguration.prefix))
: "";
res.send(prefix + filter.mapContent($));
} else {
res.send(response.body);
}
});
}
});
router.use((err, req, res, next) => {
if (err instanceof ConfigError) {
console.warn("Configuration error:", err.message);
res.status(500).send("Could not handle your request");
} else {
throw err;
}
});
app.use(router);
app.listen(config.port, () => {
console.log(`Listening on port ${config.port}`);
});

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save