Published on

Open links to local files in your browser

Authors

I like to take notes with a browser-based application. Oftentimes it is useful to include links to local files in my notes, such as to a PDF of a paper or of an ebook. However, browsers these days have stringent security measures that prevent linking to local files directly. In this post I am going to share how I circumvented this. Be warned that it is a 'hands-on' approach that will require some hacking on your own to adapt it to your own environment.

First, we need to write a little server that will run locally in the background. It will do the actual opening of files. And secondly, we need a browser extension that intercepts file:// links and calls out to the local server.

The Server

I elected to use Deno for the server. It is a V8-based Javascript runtime (just like Node.js). It does not seem to be mature enough for anything serious, but it poised to become more relevant sooner or later; especially for scripts, since unlike Node.js, with Deno scripts are run in a sandbox by default, with the option of precisely defining laxer permissions.

The server simply listens on port 9090 and upon receiving a POST request, forwards the body to a platform-agnostic library for opening files (it uses xdg-open internally on Linux).

lfl-server.ts

import { createApp } from "https://deno.land/x/servest@v1.3.1/mod.ts";
import { open } from "https://deno.land/x/open/index.ts";

const app = createApp();

app.post("/", async (req) => {
  const body = await req.text();
  open(body);
  await req.respond({
    status: 200,
  });
});

app.listen({
  hostname: "localhost",
  port: 9090,
});

We can install this script with

$ deno install -f --allow-net=localhost --allow-run --allow-read lfl-server.ts

Note the --allow-net=localhost, if we for example forgot to set the host, the library I am using would use 0.0.0.0 as a default hostname. Everyone on in the local network would be able to open a file on our computer. However, since we specify this permission, the server would not start since it doesn't have the right permissions.

Now that the server is installed to ~/.deno/bin, we want to automatically start it. For Linux I did that by creating the systemd user unit below.

~/.config/systemd/user/lfl-server.service

[Unit]
Description=Local File Links Server

[Service]
ExecStart=%h/.deno/bin/lfl-server

[Install]
WantedBy=default.target

And by enabling (and starting) it with:

$ systemctl enable --now --user lfl-server

The Browser Extension

Browser extensions are essentially just a scripts that run on some pages of interest.

We want to detect file:// links on a page, and change the click handler to send out a request to the local server with the file path following file://. We can do that with the following:

extension.js

const fileLinks = document.querySelectorAll('a[href^="file:"]');

for (const fileLink of fileLinks) {
  fileLink.addEventListener(
    "click",
    (e) => {
      e.preventDefault();
      const file = fileLink.attributes.href.value.substring("file://".length);
      fetch("http://localhost:9999", {
        method: "post",
        body: file,
      }).catch(console.error);
    },
    false,
  );
}

One more file is needed to give the browser information about the extension: a manifest.json. A bare-bones version can be found below. I only want to intercept file links on my self-hosted Dokuwiki instance, where I do my note-taking, so I specify to only run the script when the domain matches that of my personal wiki. I also have to specify that I need permissions to make requests to localhost, since otherwise the request will be blocked thanks to CORS restrictions.

manifest.json

{
  "manifest_version": 2,
  "name": "Local File Links",
  "version": "1.0",
  "description": "Allows opening links to local files.",
  "content_scripts": [
    {
      "matches": ["*://wiki.bartlouwers.nl/*"],
      "js": ["extension.js"]
    }
  ],
  "permissions": ["http://localhost/"]
}

After creating a zip-file containing those two files, the extension must be changed to .xpi in the case of Firefox, or to .crx in the case of Chromium-based browsers. Then, it can be installed. However, because of all the havoc browser extensions have caused in the past, you need to have it signed first. You can circumvent this if you install Firefox Developer Edition and temporarily disable xpinstall.signatures.required on the about:config page, or on Chromium-based browsers by enabling developer mode on chrome://extensions.

And just like that, you can open local files with links in your browser!