Compare commits

...

2 commits

Author SHA1 Message Date
93fb60724c feat: add content encryption 2025-06-12 22:07:03 +08:00
d5d025e011 feat: add ruby helper shortcode 2025-06-12 20:35:32 +08:00
5 changed files with 211 additions and 0 deletions

View file

@ -0,0 +1,131 @@
---
import { encrypt } from '../../plugins/encrypt';
import {siteConfig} from "../../config";
interface Props {
password?: string;
pwEnv?: string;
}
const { password: propPassword, pwEnv } = Astro.props;
// Get password from props, environment variable, or site config
const password = (pwEnv ? import.meta.env[pwEnv] : propPassword) || siteConfig.contentPassword || import.meta.env.CONTENT_PASSWORD || Math.random().toString();
// Get the slot content
const content = await Astro.slots.render('default');
// Encrypt content at build time
const { encryptedData, iv } = encrypt(content, password);
---
<div class="encrypted-content" data-encrypted={encryptedData} data-iv={iv}>
<div class="password-form">
<p>This content is protected. Enter the password to view it:</p>
<form>
<input type="password" class="decrypt-password" title="password" />
<button class="decrypt-button">Decrypt</button>
</form>
</div>
<div class="content-container hidden"></div>
</div>
<style>
div.encrypted-content {
border: 1px solid var(--border-color);
}
div.password-form {
margin: 0.5rem auto;
}
input[type="password"] {
background: var(--accent-color);
border: none;
padding: 0.25rem;
}
button {
background-color: var(--accent-color);
border: none;
padding: 0.25rem;
}
div.hidden {
display: none;
}
</style>
<script>
// Client-side decryption logic
document.addEventListener('DOMContentLoaded', () => {
const containers = document.querySelectorAll('.encrypted-content');
containers.forEach(container => {
const encryptedData = container.getAttribute('data-encrypted');
const iv = container.getAttribute('data-iv');
const passwordInput = container.querySelector('.decrypt-password');
const decryptButton = container.querySelector('.decrypt-button');
const contentContainer = container.querySelector('.content-container');
const passwordForm = container.querySelector('.password-form');
decryptButton.addEventListener('click', async () => {
try {
const password = passwordInput.value;
if (!password) return;
const content = await decrypt(encryptedData, iv, password);
contentContainer.innerHTML = content;
contentContainer.classList.remove('hidden');
passwordForm.classList.add('hidden');
} catch (error) {
alert('Incorrect password');
console.error('Decryption failed:', error);
}
});
// Allow pressing Enter to decrypt
passwordInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
decryptButton.click();
}
});
});
async function decrypt(encryptedData, iv, password) {
// Convert base64 to array buffer
const encryptedBytes = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0));
const ivBytes = Uint8Array.from(atob(iv), c => c.charCodeAt(0));
// Derive key from password
const encoder = new TextEncoder();
const passwordBytes = encoder.encode(password);
const keyMaterial = await window.crypto.subtle.importKey(
'raw',
passwordBytes,
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
const key = await window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: ivBytes,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
// Decrypt
const decryptedBytes = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivBytes },
key,
encryptedBytes
);
// Convert array buffer to text
const decoder = new TextDecoder();
return decoder.decode(decryptedBytes);
}
});
</script>

View file

@ -0,0 +1,6 @@
---
const { text, ruby } = Astro.props;
---
<ruby>
{text} <rp>(</rp><rt>{ruby}</rt><rp>)</rp>
</ruby>

View file

@ -28,6 +28,10 @@ export const siteConfig = {
searchEngine: 'bing', // 'google', 'duckduckgo', 'bing'(broken until M1cr0$0ft get support for it), defaults to 'google'
// content
displayAvatar: true, // display author avatar in the article list and info line of article page
// encryption
// the global password to encrypt/decrypt the content, if set, all <ProtectedContent/> without specifying a password will be encrypted with this password
// you can use a different environment variable to set the password.
contentPassword: import.meta.env.CONTENT_PASSWORD,
// footer
// yes you can write html safely here
customFooter: '<i>I have no mouth, and I must SCREAM</i>',

View file

@ -7,6 +7,8 @@ tags: ["markdown", "css", "html", "sample"]
import Callout from '/src/components/shortcodes/Callout.astro';
import LinkCard from '/src/components/shortcodes/LinkCard.astro';
import Spoiler from '/src/components/shortcodes/Spoiler.astro';
import Ruby from '/src/components/shortcodes/Ruby.astro';
import ProtectedContent from '/src/components/shortcodes/ProtectedContent.astro';
This article offers a sample of basic and extended Markdown formatting that can be used, also it shows how some basic HTML elements are decorated.
## Markdown in Astro
@ -52,6 +54,17 @@ You can also display a 'tip' for the reader when hovered:
</Spoiler>
```
### Ruby
The `Ruby` component allows you to quickly add ruby annotations to your text, which is useful for providing pronunciation or additional information about certain words, especially in East Asian languages.
<Ruby text="汉" ruby="han" />
<Ruby text="字" ruby="zi" />
```mdx
<Ruby text="汉" ruby="han" />
<Ruby text="字" ruby="zi" />
```
### Callouts
You can use callouts to highlight important information or warnings in your content. Callouts are styled boxes that draw attention to specific parts of the text.
<Callout icon='💁' type="info">
@ -88,6 +101,35 @@ You can use the `LinkCard` component to create cards that link to external resou
Or to customize the card further with a title and description:
<LinkCard url="https://www.bilibili.com/video/BV1PC4y1L7mq/" title="Don't check the description" description="Don't click on the link" />
### Protected Content
You can use the `ProtectedContent` component to protect certain parts of your content with a password. This is useful for sharing exclusive content or information that should only be accessible to certain users.
<Callout type="warning">
About security:
Although the encrypt process is happened at build and encrypted content is not visible in the rendered HTML. But it's still not 100% secure, as the password can be stored in the source code of a document (which could be public). Use it only for not really sensitive content.
Using a environment variable to store the password is recommended.
</Callout>
<ProtectedContent password="42">
Yes, what you input is the ultimate answer to *life*, *the universe*, and **everything**.
</ProtectedContent>
```mdx
<ProtectedContent password="42">
Yes, what you input is the ultimate answer to *life*, *the universe*, and **everything**.
</ProtectedContent>
```
<ProtectedContent>
And this will be encrypted with a global password, which is set in the environment variable `CONTENT_PASSWORD` or defined by `config.ts`. (config.ts takes precedence over the environment variable)
</ProtectedContent>
```mdx
<ProtectedContent>
And this will be encrypted with a global password, which is set in the environment variable `CONTENT_PASSWORD` or defined by `config.ts`. (config.ts takes precedence over the environment variable)
</ProtectedContent>
```
## Headings
The following HTML `<h1>`—`<h6>` elements represent six levels of section headings. `<h1>` is the highest section level while `<h6>` is the lowest.

28
src/plugins/encrypt.ts Normal file
View file

@ -0,0 +1,28 @@
import crypto from 'crypto';
export function encrypt(content: string, password: string) {
// Generate a random initialization vector
const iv = crypto.randomBytes(16);
// Derive key from password using PBKDF2
const key = crypto.pbkdf2Sync(password, iv, 100000, 32, 'sha256');
// Create cipher
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
// Encrypt content
let encryptedData = cipher.update(content, 'utf8', 'base64');
encryptedData += cipher.final('base64');
// Get auth tag and append to encrypted data
const authTag = cipher.getAuthTag();
const encryptedBuffer = Buffer.concat([
Buffer.from(encryptedData, 'base64'),
authTag
]);
return {
encryptedData: encryptedBuffer.toString('base64'),
iv: iv.toString('base64')
};
}