WIP
commit
67e45697d2
@ -0,0 +1 @@
|
||||
node_modules
|
@ -0,0 +1,152 @@
|
||||
"use strict";
|
||||
|
||||
const Promise = require("bluebird");
|
||||
const express = require("express");
|
||||
const expressReactViews = require("@joepie91/express-react-views");
|
||||
const morgan = require("morgan");
|
||||
const path = require("path");
|
||||
const bodyParser = require("body-parser");
|
||||
const expressSession = require("express-session");
|
||||
const browserify = require("browserify");
|
||||
const watchifyMiddleware = require("watchify-middleware");
|
||||
const fs = require("fs");
|
||||
const defaultValue = require("default-value");
|
||||
const url = require("url");
|
||||
|
||||
const tinyLr = require("tiny-lr");
|
||||
const chokidar = require("chokidar");
|
||||
|
||||
const createUrlRewriter = require("./url-rewriter");
|
||||
const createSessionManager = require("./session-manager");
|
||||
const rewriteCssUrls = require("./rewrite-css-urls");
|
||||
const rewriteHtmlUrls = require("./rewrite-html-urls");
|
||||
const appendHtml = require("./append-html");
|
||||
|
||||
let injectorHtml = fs.readFileSync(path.join(__dirname, "./data/injector.html"), "utf8");
|
||||
|
||||
let sessionManager = createSessionManager();
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
let reloadServer = tinyLr();
|
||||
reloadServer.listen(35729);
|
||||
|
||||
let firstReloadDone = false;
|
||||
reloadServer.on("MSG /create", (id, url) => {
|
||||
if (firstReloadDone === false) {
|
||||
firstReloadDone = true;
|
||||
reloadPage();
|
||||
}
|
||||
});
|
||||
|
||||
function reloadPage(files) {
|
||||
reloadServer.changed({ body: { files: defaultValue(files, [ "*" ]) } });
|
||||
}
|
||||
|
||||
chokidar.watch(path.join(__dirname, "src/**/*.{js,jsx}")).on("all", () => {
|
||||
reloadPage();
|
||||
});
|
||||
|
||||
chokidar.watch(path.join(__dirname, "public/*.css")).on("all", (event, changedPath) => {
|
||||
let relativeChangedPath = path.relative(path.join(__dirname, "public"), changedPath);
|
||||
|
||||
reloadPage(relativeChangedPath);
|
||||
});
|
||||
}
|
||||
|
||||
let app = express();
|
||||
|
||||
app.engine("jsx", expressReactViews.createEngine({}));
|
||||
app.set("view engine", "jsx");
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
app.use("/scraping-tool-bundle.js", watchifyMiddleware(browserify("src/injector/index.jsx", {
|
||||
basedir: __dirname,
|
||||
debug: true,
|
||||
cache: {},
|
||||
extensions: [".jsx"],
|
||||
transform: [
|
||||
["babelify", {
|
||||
presets: ["@babel/preset-env", "@babel/preset-react"],
|
||||
}]
|
||||
]
|
||||
})));
|
||||
}
|
||||
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
|
||||
app.use(morgan("dev"));
|
||||
app.use(bodyParser.urlencoded({ extended: false }));
|
||||
|
||||
app.use(expressSession({
|
||||
secret: "foobar",
|
||||
resave: false,
|
||||
saveUninitialized: false
|
||||
}));
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.render("index");
|
||||
});
|
||||
|
||||
app.post("/browse", (req, res) => {
|
||||
res.redirect(`/scrape/${encodeURIComponent(req.body.url)}`);
|
||||
});
|
||||
|
||||
app.get(/^\/scrape\/(.+)/, (req, res) => {
|
||||
return Promise.try(() => {
|
||||
if (req.session.cookieJar == null) {
|
||||
/* NOTE: We don't store the session directly in req.session because it will not survive express-session's serialization and deserialization. We don't really care about cookie persistence across restarts right now. */
|
||||
req.session.bhttpSessionId = sessionManager.createSession();
|
||||
return Promise.promisify(req.session.save.bind(req.session))();
|
||||
}
|
||||
}).then(() => {
|
||||
let targetUrl = req.params[0];
|
||||
|
||||
let rewriteUrl = createUrlRewriter(targetUrl, `http://${req.get("host")}/scrape/`);
|
||||
|
||||
return Promise.try(() => {
|
||||
if (url.parse(targetUrl).hostname == null) {
|
||||
throw new Error(`Attempted to load a relative URL (${targetUrl}); this means that something was not correctly rewritten.`);
|
||||
} else {
|
||||
return sessionManager.getSession(req.session.bhttpSessionId).get(targetUrl, {
|
||||
headers: {
|
||||
"User-Agent": req.headers['user-agent']
|
||||
}
|
||||
});
|
||||
}
|
||||
}).then((response) => {
|
||||
let contentType = response.headers['content-type'];
|
||||
let contentLength = response.headers['content-length']
|
||||
|
||||
if (contentType != null) {
|
||||
res.setHeader("Content-Type", contentType);
|
||||
}
|
||||
|
||||
if (contentLength != null) {
|
||||
res.setHeader("Content-Length", contentLength);
|
||||
}
|
||||
|
||||
let rewrittenBody;
|
||||
|
||||
/* FIXME: Use an actual content-type parser */
|
||||
if (contentType == null) {
|
||||
rewrittenBody = response.body;
|
||||
} else if (contentType.includes("text/html")) {
|
||||
rewrittenBody = rewriteHtmlUrls(response.body, rewriteUrl);
|
||||
rewrittenBody = appendHtml(rewrittenBody, injectorHtml);
|
||||
} else if (contentType.includes("text/css")) {
|
||||
rewrittenBody = rewriteCssUrls(response.body.toString(), rewriteUrl);
|
||||
} else {
|
||||
rewrittenBody = response.body;
|
||||
}
|
||||
|
||||
res.send(rewrittenBody);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* TODO: Maybe make a 404 a hard error, as it is likely to occur when URLs are incorrectly rewritten? How to deal with crawlers trying nonsense URLs? */
|
||||
|
||||
app.listen(3000, () => {
|
||||
console.log("Listening on port 3000...");
|
||||
});
|
@ -0,0 +1,11 @@
|
||||
"use strict";
|
||||
|
||||
const cheerio = require("cheerio");
|
||||
|
||||
module.exports = function appendHtml(body, htmlToAppend) {
|
||||
let $ = cheerio.load(body);
|
||||
|
||||
$("body").append($(htmlToAppend));
|
||||
|
||||
return $.html();
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
<div class="___scraping___tool___">This is a box that isn't on the original website</div>
|
||||
<link rel="stylesheet" href="/scraping-tool-stylesheet.css">
|
||||
<script src="/scraping-tool-bundle.js"></script>
|
||||
|
||||
<div class="___scraping___tool___overlay active">
|
||||
|
||||
</div>
|
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "scraping-tool-poc",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"repository": "git@git.cryto.net:joepie91/scraping-tool-poc.git",
|
||||
"author": "Sven Slootweg <admin@cryto.net>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@joepie91/express-react-views": "^1.0.1",
|
||||
"bhttp": "^1.2.4",
|
||||
"bluebird": "^3.5.5",
|
||||
"body-parser": "^1.19.0",
|
||||
"cheerio": "^1.0.0-rc.3",
|
||||
"dataprog": "^0.1.0",
|
||||
"debounce": "^1.2.0",
|
||||
"default-value": "^1.0.0",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.16.2",
|
||||
"morgan": "^1.9.1",
|
||||
"nanoid": "^2.0.3",
|
||||
"rewrite-css-urls": "^1.0.4",
|
||||
"tough-cookie": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.5.5",
|
||||
"@babel/preset-env": "^7.5.5",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"babelify": "^10.0.0",
|
||||
"browserify": "^16.5.0",
|
||||
"chokidar": "^3.0.2",
|
||||
"document-ready-promise": "^3.0.1",
|
||||
"nodemon": "^1.19.1",
|
||||
"react": "^16.8.6",
|
||||
"react-dom": "^16.8.6",
|
||||
"tiny-lr": "^1.1.1",
|
||||
"watchify-middleware": "^1.8.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development yarn nodemon --ignore src/injector app.js"
|
||||
}
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
* {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.___scraping___tool___ {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
top: 32px;
|
||||
background-color: red;
|
||||
color: white;
|
||||
padding: 8px;
|
||||
z-index: 999999999;
|
||||
}
|
||||
|
||||
.___scraping___tool___overlay {
|
||||
pointer-events: none;
|
||||
/* opacity: 0.4; */
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 999999990;
|
||||
}
|
||||
|
||||
.___scraping___tool___overlay.active {
|
||||
/* background-color: rgba(240, 0, 0, 0.4); */
|
||||
font-family: sans-serif;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.___scraping___tool___hover, .___scraping___tool___selection, .___scraping___tool___secondarySelection, .___scraping___tool___tooltip {
|
||||
position: absolute;
|
||||
z-index: 999999991;
|
||||
}
|
||||
|
||||
.___scraping___tool___hover {
|
||||
background-color: rgba(216, 61, 255, 0.4);
|
||||
}
|
||||
|
||||
.___scraping___tool___selection {
|
||||
background-color: rgba(0, 255, 0, 0.4);
|
||||
}
|
||||
|
||||
.___scraping___tool___tooltip {
|
||||
background-color: black;
|
||||
color: white;
|
||||
padding: .2em .4em;
|
||||
font-family: sans-serif;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.___scraping___tool___secondarySelection {
|
||||
background-color: rgb(104, 241, 230);
|
||||
}
|
||||
|
||||
.___scraping___tool___candidatePicker {
|
||||
pointer-events: initial;
|
||||
z-index: 999999992;
|
||||
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 16px;
|
||||
|
||||
padding: .6em 1em;
|
||||
|
||||
background-color: rgba(43, 43, 43, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.___scraping___tool___candidate {
|
||||
padding: .2em .5em;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.___scraping___tool___candidate:hover {
|
||||
background-color: rgb(88, 88, 88);
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
"use strict";
|
||||
|
||||
const rewriteCssUrlsLib = require("rewrite-css-urls");
|
||||
|
||||
module.exports = function rewriteCssUrls(css, rewriteUrl) {
|
||||
let rewritten1 = rewriteCssUrlsLib.findAndReplace(css, { replaceUrl: (ref) => rewriteUrl(ref.url) });
|
||||
|
||||
let rewritten2 = rewritten1.replace(/sourceMappingURL=([^ ]+)/, (_match, url) => {
|
||||
return `sourceMappingURL=${rewriteUrl(url)}`;
|
||||
});
|
||||
|
||||
return rewritten2;
|
||||
};
|
@ -0,0 +1,55 @@
|
||||
"use strict";
|
||||
|
||||
const cheerio = require("cheerio");
|
||||
|
||||
const rewriteCssUrls = require("./rewrite-css-urls");
|
||||
|
||||
module.exports = function (body, rewriteUrl) {
|
||||
|
||||
function patchAttribute(elements, attribute) {
|
||||
elements.get().forEach((element) => {
|
||||
let $element = $(element);
|
||||
|
||||
let value = $element.attr(attribute);
|
||||
if (value != null) {
|
||||
$element.attr(attribute, rewriteUrl(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeAttribute(elements, attribute) {
|
||||
elements.get().forEach((element) => {
|
||||
let $element = $(element);
|
||||
|
||||
$element.removeAttr(attribute);
|
||||
});
|
||||
}
|
||||
|
||||
let $ = cheerio.load(body);
|
||||
|
||||
patchAttribute($("a"), "href");
|
||||
patchAttribute($("img"), "src"); /* FIXME: Responsive versions? */
|
||||
patchAttribute($("link"), "href");
|
||||
patchAttribute($("script"), "src");
|
||||
patchAttribute($("form"), "action");
|
||||
patchAttribute($("iframe"), "src");
|
||||
patchAttribute($("source"), "src");
|
||||
|
||||
/* NOTE: The below is necessary because we're rewriting the contents of CSS and potentially JS files, intentionally. TODO: In the future, just to be safe, we should actually verify the received content against the hashes first, before trying to forward the content to the user. */
|
||||
removeAttribute($("link"), "integrity");
|
||||
removeAttribute($("script"), "integrity");
|
||||
|
||||
$("style").get().forEach((element) => {
|
||||
let $element = $(element);
|
||||
|
||||
$element.text(rewriteCss($element.text()));
|
||||
});
|
||||
|
||||
$("*[style]").get().forEach((element) => {
|
||||
let $element = $(element);
|
||||
|
||||
$element.attr("style", rewriteCssUrls($element.attr("style"), rewriteUrl));
|
||||
});
|
||||
|
||||
return $.html();
|
||||
};
|
@ -0,0 +1,28 @@
|
||||
"use strict";
|
||||
|
||||
const bhttp = require("bhttp");
|
||||
const nanoid = require("nanoid");
|
||||
|
||||
module.exports = function createSessionManager() {
|
||||
let map = new Map();
|
||||
|
||||
return {
|
||||
createSession: function (options) {
|
||||
let id = nanoid();
|
||||
let session = bhttp.session(options);
|
||||
|
||||
map.set(id, session);
|
||||
|
||||
return id;
|
||||
},
|
||||
getSession: function (id) {
|
||||
let session = map.get(id);
|
||||
|
||||
if (session == null) {
|
||||
throw new Error("No such session exists; this should never happen");
|
||||
} else {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
@ -0,0 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = function elementsFromPoint(x, y) {
|
||||
if (typeof document.elementsFromPoint === "function") {
|
||||
return document.elementsFromPoint(x, y);
|
||||
} else if (typeof document.msElementsFromPoint === "function") {
|
||||
/* Fix for IE/Edge */
|
||||
let result = document.msElementsFromPoint(x, y);
|
||||
|
||||
if (result != null) {
|
||||
return Array.from(result);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
|
||||
function findIdRoot(element) {
|
||||
let current = element;
|
||||
|
||||
while (current != null) {
|
||||
if (current.id != null && current.id !== "") {
|
||||
return current;
|
||||
} else {
|
||||
current = current.parentElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = function generateSelector(element) {
|
||||
/* FIXME: check that element != null */
|
||||
let root = findIdRoot(element);
|
||||
|
||||
let segments = [
|
||||
(root != null) ? `#${root.id}` : null,
|
||||
(element.classList.length > 0)
|
||||
? Array.from(element.classList).map((className) => `.${className}`).join("")
|
||||
: element.tagName.toLowerCase()
|
||||
].filter((segment) => segment != null);
|
||||
|
||||
return segments.join(" ");
|
||||
|
||||
// console.log(root);
|
||||
|
||||
// let className = element.classList.item(0);
|
||||
|
||||
// if (root === element) {
|
||||
// return `#${root.id}`;
|
||||
// } else if (root != null) {
|
||||
// if (className != null) {
|
||||
// return `#${root.id} .${className}`;
|
||||
// } else {
|
||||
// return `#${root.id} ${element.tagName}`;
|
||||
// }
|
||||
// } else {
|
||||
// return `stuff`;
|
||||
// }
|
||||
};
|
@ -0,0 +1,152 @@
|
||||
"use strict";
|
||||
|
||||
const React = require("react");
|
||||
const ReactDOM = require("react-dom");
|
||||
const Promise = require("bluebird");
|
||||
const documentReadyPromise = require("document-ready-promise");
|
||||
const debounce = require("debounce");
|
||||
const { expression } = require("dataprog");
|
||||
|
||||
const generateSelector = require("./generate-selector");
|
||||
const useStateRef = require("./use-state-ref");
|
||||
const useMemoizedPosition = require("./use-memoized-position");
|
||||
const elementsFromPoint = require("./elements-from-point");
|
||||
const uniqueElementId = require("./unique-element-id");
|
||||
|
||||
function Overlay() {
|
||||
let [ scrollX, setScrollX ] = React.useState();
|
||||
let [ scrollY, setScrollY ] = React.useState();
|
||||
let [ hoveredElement, setHoveredElement ] = React.useState();
|
||||
let [ isHovering, setIsHovering, isHoveringRef ] = useStateRef(true);
|
||||
let [ isPicking, setIsPicking, isPickingRef ] = useStateRef(false);
|
||||
let [ elementList, setElementList ] = React.useState([]);
|
||||
let [ selectedElement, setSelectedElement ] = React.useState();
|
||||
let [ pickHoveredElement, setPickHoveredElement ] = React.useState();
|
||||
let [ pickedElement, setPickedElement ] = React.useState();
|
||||
|
||||
let enabledRef = React.useRef(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("scroll", debounce((event) => {
|
||||
setScrollX(window.scrollX);
|
||||
setScrollY(window.scrollY);
|
||||
}), 20);
|
||||
|
||||
let allElements = document.querySelectorAll("*");
|
||||
|
||||
for (let element of allElements) {
|
||||
/* TODO: Investigate whether switching to mousemove + elementFromPoint is more performant */
|
||||
element.addEventListener("mouseover", (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (isHoveringRef.current) {
|
||||
setHoveredElement(element);
|
||||
}
|
||||
});
|
||||
|
||||
function clickHandler(event) {
|
||||
if (enabledRef.current) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (isHoveringRef.current) {
|
||||
setIsHovering(false);
|
||||
setIsPicking(true);
|
||||
|
||||
let candidateElements = elementsFromPoint(event.clientX, event.clientY);
|
||||
|
||||
setElementList(candidateElements);
|
||||
setSelectedElement(candidateElements[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener("click", clickHandler);
|
||||
element.addEventListener("mousedown", clickHandler);
|
||||
element.addEventListener("mouseup", clickHandler);
|
||||
}
|
||||
}, []);
|
||||
|
||||
let hoveredPosition = useMemoizedPosition(hoveredElement, [ scrollX, scrollY ]);
|
||||
let selectedPosition = useMemoizedPosition(selectedElement, [ scrollX, scrollY ]);
|
||||
let pickedPosition = useMemoizedPosition(pickedElement, [ scrollX, scrollY ]);
|
||||
let pickHoveredPosition = useMemoizedPosition(pickHoveredElement, [ scrollX, scrollY ]);
|
||||
|
||||
return (
|
||||
<div className="___scraping___tool___overlay active">
|
||||
{(hoveredPosition != null && isHovering)
|
||||
? <HoverHighlight element={hoveredElement} {... hoveredPosition} />
|
||||
: null
|
||||
}
|
||||
{(selectedPosition != null)
|
||||
? <PrimarySelectionHighlight element={selectedElement} {... selectedPosition} />
|
||||
: null
|
||||
}
|
||||
{(pickedPosition != null && isPicking)
|
||||
? <HoverHighlight element={pickedElement} {... pickedPosition} />
|
||||
: null
|
||||
}
|
||||
{(pickHoveredPosition != null && isPicking)
|
||||
? <HoverHighlight element={pickHoveredElement} {... pickHoveredPosition} />
|
||||
: null
|
||||
}
|
||||
{(isPicking)
|
||||
? <Picker candidates={elementList} onHover={setPickHoveredElement} />
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Picker({ candidates, onHover }) {
|
||||
return (
|
||||
<div className="___scraping___tool___candidatePicker">
|
||||
{candidates.map((candidate) => {
|
||||
return <PickerCandidate
|
||||
key={uniqueElementId(candidate)}
|
||||
element={candidate}
|
||||
onEnter={() => onHover(candidate)}
|
||||
onLeave={() => onHover(null)}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerCandidate({ element, onEnter, onLeave }) {
|
||||
return (
|
||||
<div className="___scraping___tool___candidate" onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
||||
{generateSelector(element)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverHighlight({ x, y, width, height, element }) {
|
||||
let boxStyle = { left: x, top: y, width: width, height: height };
|
||||
let tooltipStyle = { left: x, top: y + height };
|
||||
|
||||
return (<>
|
||||
<div className="___scraping___tool___hover" style={boxStyle} />
|
||||
{(element != null)
|
||||
? <div className="___scraping___tool___tooltip" style={tooltipStyle}>{ generateSelector(element) }</div>
|
||||
: null
|
||||
}
|
||||
</>);
|
||||
}
|
||||
|
||||
function PrimarySelectionHighlight({ x, y, width, height, element }) {
|
||||
let boxStyle = { left: x, top: y, width: width, height: height };
|
||||
|
||||
return (<>
|
||||
<div className="___scraping___tool___selection" style={boxStyle} />
|
||||
</>);
|
||||
}
|
||||
|
||||
Promise.try(() => {
|
||||
return documentReadyPromise();
|
||||
}).then(() => {
|
||||
let $overlay = document.querySelector(".___scraping___tool___overlay");
|
||||
let $tool = document.querySelector(".___scraping___tool");
|
||||
|
||||
ReactDOM.render(<Overlay />, $overlay);
|
||||
});
|
@ -0,0 +1,11 @@
|
||||
"use strict";
|
||||
|
||||
let map = new WeakMap();
|
||||
|
||||
module.exports = function uniqueElementId(element) {
|
||||
if (!map.has(element)) {
|
||||
map.set(element, Math.random().toString());
|
||||
}
|
||||
|
||||
return map.get(element);
|
||||
};
|
@ -0,0 +1,20 @@
|
||||
"use strict";
|
||||
|
||||
const React = require("react");
|
||||
|
||||
module.exports = function useMemoizedPosition(element, extraDependencies = []) {
|
||||
return React.useMemo(() => {
|
||||
if (element != null) {
|
||||
let rect = element.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}, [ element, ... extraDependencies ]);
|
||||
};
|
@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
|
||||
const React = require("react");
|
||||
|
||||
module.exports = function useStateRef(initialState) {
|
||||
let [ state, setState ] = React.useState(initialState);
|
||||
let ref = React.useRef(initialState);
|
||||
|
||||
return [
|
||||
state,
|
||||
(newState) => {
|
||||
ref.current = newState;
|
||||
setState(newState);
|
||||
},
|
||||
ref
|
||||
];
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
const url = require("url");
|
||||
|
||||
module.exports = function createUrlRewriter(originUrl, prefix) {
|
||||
return function rewrite(path) {
|
||||
return `${prefix}${encodeURIComponent(url.resolve(originUrl, path))}`;
|
||||
};
|
||||
};
|
@ -0,0 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
const React = require("react");
|
||||
|
||||
module.exports = function Index() {
|
||||
return (
|
||||
<form action="/browse" method="post">
|
||||
<input type="text" name="url" />
|
||||
<button type="submit">Go!</button>
|
||||
</form>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue