魔改npm私有仓库 | Verdaccio教程

2020-07-23 11:42:59 浏览数 (1)

好久没分享前端技术了,今天推荐一个开源软件:Verdaccio,它是一个私有npm仓库。npm是一个基于http的协议,用来存放软件包并且维护版本和依赖,利用http提供的url路径、动词啥的来对软件包进行增删改查。所以Verdaccio这款软件的核心就是实现npm协议。

名词解释:

  • verdaccio:一个开源、私有npm服务器软件
  • npm:基于http的应用协议,用来存取JavaScript软件包,并提供周边服务
  • http:最流行的互联网应用协议,在此之上可以方便、快速地开发app
  • htpasswd:一套鉴权机制,通过文本文件存储用户名和密码

verdaccio有一个内置的数据库来存放所有的npm包,除此之外它还有一套默认的鉴权机制:htpasswd。htpasswd鉴权是通过htpasswd文件来存放所有的npm用户,鉴权、添加/删除的时候通过对文件的读写来实现。

很显然htpasswd鉴权机制有许多问题,文件的读写造成内存的浪费,最重要的是,公司内部通常有统一的鉴权服务器。

需要开发一套verdaccio插件来打通两者。除了插件,还需要一个统一的容器来整合verdaccio,插件,和零碎的静态组件,实现的目的是为了能够开箱即用(out of the box)。

登录成功后,用户名和密码通过加密的token(JWT)临时存放在客户端,存放的位置分为:

  • 浏览器:存放在localstorage中
  • CLI:存放在~/.npmrc下

verdaccio接收到npm请求后,解析出用户名和密码,有选择地向第三方进行认证,除了一些“只读”的操作不用认证,其余npm操作全部向第三方请求认证。认证的缓存时间是120秒,即120秒内重复请求可以免认证。这里我们使用Verdaccio提供的认证插件实现,加以简单的内存缓存即可实现:

代码语言:javascript复制
  constructor(config, options) {
    this.users = [];
    return this;
  }

  async auth(username, password) {
    // 寻找缓存
    if (this.users.includes(username)) {
      console.log("走的缓存");
      return;
    }

    if (global.$admin[username] === password) {
      console.log("走的白名单");
    } else {
      const resp = await fetch('path/to/authenticator', {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ username, password }),
      });
      if (!resp.ok) throw "登录失败";
      const resData = await resp.json();
      if (resData.status !== 200) throw resData.msg;
      console.log("走的第三方认证");
    }
    this.users.push(username);
    // 120秒后清空缓存
    setTimeout(() => this.users.remove(username), 120 * 1000);

    return;
  }

对了,以上用到了一个remove方法,它的作用是从列表中删除一个元素,列表长度-1,我们需要提前实现一下:

代码语言:javascript复制
// 非纯函数
Array.prototype.remove = function (item) {
  const i = this.indexOf(item);
  if (i < 0) return false;
  else return this.splice(i, 1);
};

Uplinks:上游仓库源

npm install时,上游的包会下载到下游的仓库中国,仓库源的优先级如下:

  • Verdaccio server
  • registry.npm.taobao.org
  • registry.npmjs.org

npm install流程图:

植入自定义前端脚本!

无奈Verdaccio没提供UI扩展机制,我们只能自己动手hack。当然不用阅读源码,利用verdaccio提供的中间件扩展,制作一个ExpressJS中间件插件,在插件中做手脚即可。

思路是这样的:作为一个单页面应用,verdaccio总是会在一开始发送一份index.html给前端,只要在它发送index之后拦截下来,在其中插入一些“恶意代码”再返回给前端就行了。verdaccio中返回index的地方实际有两处,分别是列表页和详情页,对应的url路径分别是“/”和“/-/web/detail/*”。

列表页和详情页的概念真是无处不在

Verdacciol列表页示例

Verdaccio详情页示例

在这两个地方分别拦截2下:第一次是请求方向,匹配到对应的路径后在response对象上标记一下“index”,第二次是返回方向,匹配“index”标记并植入脚本。所以请求方向的中间件代码应该如下:

代码语言:javascript复制
    // register_middlewares
    app.get(["/", "/-/web/detail/*"], (req, res, next) => {
      res.locals["index.html"] = true;
      next();
    });

既然它用的是Express就没理由不使用response.send方法。我们重写这个方法就能监听到返回的任何数据,但只对index.html类型的数据做修改,返回方向植入代码如下:

代码语言:javascript复制
const { response } = require("express");
const { JSDOM } = require("jsdom");

// 扩展send方法,拦截response
const send = response.send;
response.send = function (...params) {
  if (this.locals["index.html"]) {
    const dom = new JSDOM(params[0]);

    dom.window.document.head
      .querySelectorAll("link[rel*='icon']")
      .forEach((e) => e.remove());
    dom.window.document.head.innerHTML  = `
      <link rel="stylesheet" type="text/css" href="path/to/css.css">  
      <link rel="shortcut icon" href="path/to/favicon.png"> `;
    dom.window.document.body.innerHTML  = `
      <script src="path/to/js.js"></script> `;
    params[0] = dom.serialize();
  }
  send.bind(this)(...params);
};

成功植入了JS代码就像黑客拿到了webshell,至少在前端可以为所欲为地魔改UI了。虽然共享同一个事件驱动引擎,但你的JS脚本和网页本身的JS脚本逻辑上处于2个不同的“线程”,比如想要寻找一个dom元素,但不知道元素是否健在,是否有延迟等等问题,不知何时去寻找。

对于2个线程之间的博弈,主流的做法是在以下3种突变情况之后,页面稳定的情况下,才可以采取必要行动:

  • URL路径变化:利用H5新特性history的pushState/replaceState解决问题
  • Dom元素发生变化:利用MutationObserver API来监听body的变化
  • 监听网络请求:利用ServiceWorker API来监听前端发送的HTTP请求

因为呢,通常发生以上三种情况的时候,UI才有可能发生变动,从一个稳定期过渡到另一个稳定期。我们可以在此契机下执行我们的回调,避免在稳定期周期执行。

向文件中写入一个浮点数

如果想让前端知道当前web系统的版本号或发行日期,比如package.json中的version字段,好像并没有直接的办法。最省力的做法是每次运行时写入一个前端可读的文本文件,其中记录着当前时间,也可以写入一个8字节的双精度浮点数。为啥不写入正整数?因为JavaScript实数类型默认就是64位浮点数,比较方便而已。代码没什么意思,大家过一下就行:

代码语言:javascript复制
// verdaccio运行时
const fsp = require("fs").promises;
fsp
  .writeFile(
    "path/to/timestamp",
    Buffer.from(new Float64Array([new Date().getTime()]).buffer)
  )
  .catch((err) => {
    console.log(err);
    process.exit(0);
  });
代码语言:javascript复制
// 前端脚本
fetch("path/to/timestamp")
  .then((res) => res.arrayBuffer())
  .then((buffer) =>
    console.log("发行时间", new Date(new Float64Array(buffer)[0]))
  );
代码语言:javascript复制
# .gitignore
path/to/timestamp

前端重构的可行性

我很少推荐前端框架啊,上一次不知道多久以前推荐过一次AgGrid这个表格框架,那倒是纯前端的框架,Verdaccio其实是全栈框架。但是如果你不喜欢Verdaccio默认的UI页面,也可以重写整个前端,然后调用后端接口即可。列表页的接口就是请求所有packages列表,详情页其实就做了两件事儿:一是把README.md展示成HTML,二是把package.json的内容罗列出来,这两点可以参考npmjs.com上面的做法。然后还有基于JWT的token鉴权机制也很简单。所以重写前端很简单,把Verdaccio当作一个后端框架比较舒适。

0 人点赞