hexo支持私有文章加密隐藏

想要把个人笔记和博客整合在一起,需要把个人笔记隐藏起来。但由于hexo是静态网站生成器,因此无法实现校验身份再输出内容。只剩下一个方法,用加密算法把私有文章内容加密起来,而解密的key不包含在生成的静态网站中。目前已有优秀的插件可以使用,例如hexo-blog-encrypt。但是这个插件无法满足我的一些需求,因此重新开发,并梳理了一下流程。

主要方法

  1. 在网站的_config.yml文件夹中设置密码
  2. 文章根据tag标记(包含private),识别私有文章后,使用设置的密码,用AES算法加密文章内容和标题
  3. 输出文章列表时,对加密文章做特殊标记。前端展示时,隐藏加密文章,避免出现很多“乱码”文章
  4. 前端通过按钮或快捷键输入密码后,对文章内容和目录进行解密,并恢复加密文章的展示

具体实现

配置文件_config.yml

encrypt: # blog-encrypt
  password: somepassword

1. 文章加密aes256

这里不通过插件实现,而是通过主题目录下的scripts。这个文件夹下的文件是在编译时候使用的,区别于前端js文件。因此这里可以直接调用node模块。完整的使用方法可以参考官方文档

这里新建一个encrypt.js文件,使用hexo.extend.filter.register('after_post_render'方法,给所有文章做一次映射处理。其中加密使用crypto-js库,它隐藏了很多AES加密的细节,例如IV是自动生成并且包含在密文中的、密码自动填充到合适的位数。

const CryptoJS = require('crypto-js');

hexo.extend.filter.register('after_post_render', (data) => {
    // 如果没有设置密钥则跳过
    if (!hexo.config.encrypt || !hexo.config.encrypt.password || !data.tags) {
        return data;
    }
    // 跳过没有包含private这个tag的文章
    if (!data.tags.map(tag => tag.name).find(tag => tag === 'private')) {
        return data;
    }
    data.origin = data.content;
    data.encrypt = true;
    const password = hexo.config.encrypt.password;
    const ciphertext = CryptoJS.AES.encrypt(data.content, password).toString();
    // 替换content为加密后的内容,同时为了方便前端识别,包裹一个特殊的div
    data.content = `<div class="encrypted">${ciphertext}</div>`;
    return data;
});

2. 文章标题加密

经过上一步加密,执行hexo clean && hexo build && hexo start预览,可以发现文章内容变成了一堆加密后的内容,但是标题还是原来的,如果有需要,可以对标题进行加密。修改上一步代码:

// 替换content为加密后的内容,同时为了方便前端识别,包裹一个特殊的div
data.content = `<div class="encrypted">${ciphertext}</div>`;
data.title = CryptoJS.AES.encrypt(data.title, password).toString(); // 增加标题加密
return data;

3. 文章列表隐藏

到这里已经全部实现了加密,但是文章列表处依然会出现这些文章。这个需要根据具体使用的主题来实现隐藏。上面加密的步骤中,我们已经给已加密文章增加了一个encrypt标记。先在文章列表渲染模板里面做前端标记,如果使用的是ejs:

<a
    data-encrypted="<%= post.encrypt %>"
>
    <div class="post-title" title="<%=post.title %>">
        <% if (post.encrypt) { %>
            <div class="encrypted"><%= post.title %></div>
        <% } else { %>
            <%= post.title %>
        <% } %>
    </div>
</a>
a[data-encrypted='true'] {
    display: none;
}

以上代码给加密文章增加了一些标记,并且默认隐藏

4. RSS

如果开启了RSS,需要过滤此类文章。这里不是很好操作,需要直接修改RSS插件的代码。以hexo-generator-feed为例,需要增加一个已加密的filter,可以fork这个项目后做修改发一个自己的包,可以参考这个commit

如果嫌麻烦,可以直接替换为我制作的版本:@wangsijie/hexo-generator-feed

5. 输入解密密钥

进入解密环节,解密的前提是拿到密钥,在前端网站可以做一个隐藏的入口用于输入密钥,并且保存在localStorage中。

例如,设置为按下ctrl + u的时候弹出输入框:

document.onkeyup = function (e) {
    if (e.ctrlKey && e.which === 85) { // ctrl + u
        const password = window.prompt('输入密码');
        localStorage.setItem('decrypt-key', password);
        // decryptPost();
    }
};

上面这个代码放在主题的source目录下的任意地方并且被html引入即可

6. 文章解密

注意到上面代码里有一个decryptPost()函数被注释掉了,这里开始实现这个函数。首先是文章内容,通过上面添加的class="encrypted"识别

const items = document.querySelectorAll('.encrypted');
for (const item of items) {
    const decrypted = CryptoJS.AES.decrypt(item.textContent, password).toString(CryptoJS.enc.Utf8);
    item.parentNode.innerHTML = decrypted;
}

7. 标题解密

这里的标题指的是浏览器的标题。打开一篇文章内容后,发现浏览器标题也是被加密过的。

解密之前先要知道是否经过了加密,一个很土的方法是不管是否加密,强行进行解密,解密成功的话就证明是已加密的。这个方法过于暴力和不稳定,这里采用另一种方法,即文章渲染的时候就通过变量标记自己是否被加密。

在文章渲染模板,例如article.ejs中增加

<% if (post.encrypt) { %>
    <script>
        currentPostEncrypted = true;
    </script>
<% } %>

然后前端的decryptPost函数增加:

if (typeof currentPostEncrypted !== 'undefined' && currentPostEncrypted) {
    // 注意这里需要修改,这个正则表达式假设的标题格式为:文章标题 | 网站标题
    const titleSearch = /([^\s]*)\s/.exec(document.title);
    if (titleSearch) {
        const text = titleSearch[1];
        const decrypted = CryptoJS.AES.decrypt(text, password).toString(CryptoJS.enc.Utf8);
        document.title = document.title.replace(text, decrypted);
    }
}

8. 目录解密

解密目录和文章内容类似,多了一个显示隐藏内容的步骤,上面的函数做下面修改

const items = document.querySelectorAll('.encrypted');
for (const item of items) {
    const decrypted = CryptoJS.AES.decrypt(item.textContent, password).toString(CryptoJS.enc.Utf8);
    const navTitle = item.parentNode && item.parentNode.parentNode && item.parentNode.parentNode.parentNode;
    item.parentNode.innerHTML = decrypted;
    // 如果是文章列表,则把data-encrypted设置为false,显示内容
    if (navTitle && navTitle.getAttribute('data-encrypted')) {
        navTitle.setAttribute('data-encrypted', false);
    }
}

9. 密钥测试(可选)

在前端输入密钥后,没有判断密钥是否正确,直接进行解密,可能会在前端的体验上不是很好。密钥的检测,可以通过预置一个随机字符串,以及其加密后的内容来实现。

encrypt.js文件中注入这两个变量

hexo.extend.filter.register('template_locals', function(locals){
    if (!hexo.config.encrypt || !hexo.config.encrypt.password) {
        return locals;
    }
    const password = hexo.config.encrypt.password;
    const random = String(Math.random());
    const encrypted = CryptoJS.AES.encrypt(random, password).toString();
    // 增加两个全局变量
    locals.encryptTestString = random;
    locals.encryptTestStringEncrypted = encrypted;
    return locals;
});

footer.ejs中把这两个变量写到前端js中

<% if (typeof encryptTestString === 'string') { %>
    <script>
        var encryptTestString = '<%- encryptTestString %>';
        var encryptTestStringEncrypted = '<%- encryptTestStringEncrypted %>';
    </script>
<% } %>

然后测试密钥的方法就很简单了,使用用户输入的密钥,对encryptTestStringEncrypted进行解密,并比较解密后得到的是否和encryptTestString一致。

decrypt.js中增加:

if (CryptoJS.AES.decrypt(encryptTestStringEncrypted, password).toString(CryptoJS.enc.Utf8) !== encryptTestString) {
    // 密钥错误,删除,并跳过剩余解密步骤
    localStorage.removeItem('decrypt-key');
    return;
}

10. 总的decrytPost函数

function decryptPost() {
    const password = localStorage.getItem('decrypt-key');
    if (!password) {
        return;
    }
    // 测试密钥是否正确
    if (CryptoJS.AES.decrypt(encryptTestStringEncrypted, password).toString(CryptoJS.enc.Utf8) !== encryptTestString) {
        localStorage.removeItem('decrypt-key');
        return;
    }
    // 解密文章内容和文章标题(.encrypt标记过的内容)
    const items = document.querySelectorAll('.encrypted');
    for (const item of items) {
        const decrypted = CryptoJS.AES.decrypt(item.textContent, password).toString(CryptoJS.enc.Utf8);
        const navTitle = item.parentNode && item.parentNode.parentNode && item.parentNode.parentNode.parentNode;
        item.parentNode.innerHTML = decrypted;
        if (navTitle && navTitle.getAttribute('data-encrypted')) {
            navTitle.setAttribute('data-encrypted', false);
            navTitle.setAttribute('href', navTitle.getAttribute('data-href'));
        }
    }
    // 当前文章如果被加密,则解密浏览器标题
    if (typeof currentPostEncrypted !== 'undefined' && currentPostEncrypted) {
        const titleSearch = /([^\s]*)\s/.exec(document.title);
        if (titleSearch) {
            const text = titleSearch[1];
            const decrypted = CryptoJS.AES.decrypt(text, password).toString(CryptoJS.enc.Utf8);
            document.title = document.title.replace(text, decrypted);
        }
    }
}

注意事项

如果使用了ajax加载文章

不少主题为了提升体验,使用ajax加载文章,即点击文章标题后,页面只有局部刷新。这时候需要在获得文章内容后,判断是否需要解密。最简单的方式是,ajax成功加载文章并替换页面内容后,立即执行一次decryptPost(),同时记得处理浏览器标题(document.title)

如果有文章计数

如果主题中开启了分类文章计数,则会把加密文章也计算进去,导致不准确。需要自行修改相应的渲染文件,忽略掉post.encrypttrue的文章

可以修改加密tag标记

上面的例子中,用是否包含private这个tag来判断一篇文章是否需要加密。具体实现的时候可以自行调整

防止私有文章被搜索引擎读取

文章列表中,我们仅对加密文章做了前端隐藏,这时候浏览器有可能会抓取这些文章(加密后的内容),避免方法是,加密文章不使用直接的href标签,而是把链接放入data-href中,这样浏览器就不会认为这是一个链接。而在解密后,把data-href的内容设置为href即可恢复操作。

总结

以上实现了私有文章的加密和隐藏。达到的效果是,游客进入只能看到公开文章,并且不会察觉的私有文章,浏览体验上不被破坏。而管理员进入博客后,输入密钥,即可查看私有文章,方便的查询自己的笔记等。

文章原始链接:https://sijie.wang/posts/hexo-encrypt/

本站文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请保留原始链接