From 1539f7bdf1f5178338ee0c80edea75ce740cccfd Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Fri, 26 Jan 2024 13:21:04 +0800 Subject: [PATCH 01/14] feat: i18n --- lib/config.mjs | 1 + lib/feed.mjs | 2 +- lib/ssg.mjs | 10 ++++++++-- next.config.js | 9 +++++---- pages/blog/[id].js | 11 ++++++++--- pages/blog/example.js | 1 + pages/index.js | 2 ++ pnpm-lock.yaml | 15 ++++----------- ...p-defaults-2023.md => app-defaults-2023.en.md} | 0 ...rt-binary-tree.md => invert-binary-tree.en.md} | 0 posts/{long-live-rss.md => long-live-rss.en.md} | 0 posts/pre-rendering.en.md | 13 +++++++++++++ posts/pre-rendering.md | 10 +++++----- 13 files changed, 48 insertions(+), 26 deletions(-) rename posts/{app-defaults-2023.md => app-defaults-2023.en.md} (100%) rename posts/{invert-binary-tree.md => invert-binary-tree.en.md} (100%) rename posts/{long-live-rss.md => long-live-rss.en.md} (100%) create mode 100644 posts/pre-rendering.en.md diff --git a/lib/config.mjs b/lib/config.mjs index b8b3c37e..7fe6b15c 100644 --- a/lib/config.mjs +++ b/lib/config.mjs @@ -6,6 +6,7 @@ const config = { authorEmail: "hi@lawrenceli.me", baseURL: "https://lawrenceli.me", feedPath: "/atom.xml", + defaultLocale: "zh", feedItemsCount: 10, siteDescription: "Blog", activityPubUser: "lawrence", diff --git a/lib/feed.mjs b/lib/feed.mjs index 39692b41..f8494da9 100644 --- a/lib/feed.mjs +++ b/lib/feed.mjs @@ -19,7 +19,7 @@ const buildFeed = async () => { const markdownData = getMdPostsData(); const postContents = await Promise.all( markdownData.map(async item => { - return await getMdContentById(item.id, defaultMarkdownDirectory); + return await getMdContentById(item.fileName, defaultMarkdownDirectory); }), ); // only output the recent blog posts diff --git a/lib/ssg.mjs b/lib/ssg.mjs index 79c8c54a..ad0fc714 100644 --- a/lib/ssg.mjs +++ b/lib/ssg.mjs @@ -39,11 +39,15 @@ export const getMdPostsData = mdDirectory => { .filter(fileName => fileName.includes(".md")) .map(mdPostName => { const id = mdPostName.replace(/\.md$/, ""); + const locale = id.includes(".") ? id.split(".")[1] : config.defaultLocale; + const idWithoutLocale = id.replace(`.${locale}`, ""); const fullPath = path.join(mdDirectory, mdPostName); const mdContent = fs.readFileSync(fullPath, "utf8"); const matterResult = matter(mdContent); return { - id, + id: idWithoutLocale, + fileName: id, + locale, ...matterResult.data, content: matterResult.content, }; @@ -59,7 +63,9 @@ export const getMdContentById = (id, mdDirectory, withHTMLString = true) => { const isBlog = mdDirectory === defaultMarkdownDirectory; // if the markdown is blog article const mdPostNames = fs.readdirSync(mdDirectory); const mdPostsData = mdPostNames - .filter(name => name === id + ".md") + .filter(name => { + return name === id.replace(`.${config.defaultLocale}`, "") + ".md"; + }) .map(async mdPostName => { const fullPath = path.join(mdDirectory, mdPostName); const mdContent = fs.readFileSync(fullPath, "utf8"); diff --git a/next.config.js b/next.config.js index a449121a..e1e3ef7d 100644 --- a/next.config.js +++ b/next.config.js @@ -3,10 +3,11 @@ module.exports = { // experimental: { // appDir: true, // }, - // i18n: { - // locales: ["zh"], - // defaultLocale: "zh", - // }, + i18n: { + locales: ["zh", "en"], + defaultLocale: "zh", + localeDetection: false, + }, transpilePackages: ["react-tweet"], async headers() { return [ diff --git a/pages/blog/[id].js b/pages/blog/[id].js index b161e083..b74a76d5 100644 --- a/pages/blog/[id].js +++ b/pages/blog/[id].js @@ -40,7 +40,11 @@ export default PathId; export const getStaticProps = async context => { const { id } = context.params; - const mdData = await getMdContentById(id, defaultMarkdownDirectory, false); + const mdData = await getMdContentById( + `${id}.${context.locale}`, + defaultMarkdownDirectory, + false, + ); return { props: mdData, }; @@ -50,7 +54,8 @@ export const getStaticPaths = async () => { const mdPostsData = getMdPostsData(path.join(process.cwd(), "posts")); const paths = mdPostsData.map(data => { return { - params: data, + params: { id: data.id }, + locale: data.locale, }; }); // const paths = [ @@ -58,6 +63,6 @@ export const getStaticPaths = async () => { // ]; return { paths, - fallback: false, + fallback: true, }; }; diff --git a/pages/blog/example.js b/pages/blog/example.js index e392cd60..962aed3e 100644 --- a/pages/blog/example.js +++ b/pages/blog/example.js @@ -12,6 +12,7 @@ export const blogProps = { date: "2022-08-26", someKey: "someValeInJSXProps", tags: "example, test, jsx", + locale: "en", visible: true, }; diff --git a/pages/index.js b/pages/index.js index ab73b507..6a65d4eb 100644 --- a/pages/index.js +++ b/pages/index.js @@ -26,6 +26,7 @@ const Index = function index({ allPostsData }) {
{post.title} @@ -50,6 +51,7 @@ export const getStaticProps = async () => { id: post.id, title: post.title, date: post.date, + locale: post.locale, })); return { props: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9dc1a94..ab2effc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: "6.0" +lockfileVersion: "6.1" settings: autoInstallPeers: true @@ -46,7 +46,7 @@ dependencies: version: 14.1.0(react-dom@18.2.0)(react@18.2.0) next-sitemap: specifier: ^4.0.0 - version: 4.0.1(@next/env@14.0.4)(next@14.1.0) + version: 4.0.1(@next/env@14.1.0)(next@14.1.0) next-themes: specifier: ^0.2.1 version: 0.2.1(next@14.1.0)(react-dom@18.2.0)(react@18.2.0) @@ -314,13 +314,6 @@ packages: sparse-bitfield: 3.0.3 dev: false - /@next/env@14.0.4: - resolution: - { - integrity: sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==, - } - dev: false - /@next/env@14.1.0: resolution: { @@ -4331,7 +4324,7 @@ packages: } dev: true - /next-sitemap@4.0.1(@next/env@14.0.4)(next@14.1.0): + /next-sitemap@4.0.1(@next/env@14.1.0)(next@14.1.0): resolution: { integrity: sha512-dY6vKE/uayNYSfhXA64U9NZYSJtFzK01oBouY+XqgQieksKBG9BWNmq8yGSJUermyux4wCnNa9gLHwSk38emhA==, @@ -4343,7 +4336,7 @@ packages: next: "*" dependencies: "@corex/deepmerge": 4.0.43 - "@next/env": 14.0.4 + "@next/env": 14.1.0 minimist: 1.2.8 next: 14.1.0(react-dom@18.2.0)(react@18.2.0) dev: false diff --git a/posts/app-defaults-2023.md b/posts/app-defaults-2023.en.md similarity index 100% rename from posts/app-defaults-2023.md rename to posts/app-defaults-2023.en.md diff --git a/posts/invert-binary-tree.md b/posts/invert-binary-tree.en.md similarity index 100% rename from posts/invert-binary-tree.md rename to posts/invert-binary-tree.en.md diff --git a/posts/long-live-rss.md b/posts/long-live-rss.en.md similarity index 100% rename from posts/long-live-rss.md rename to posts/long-live-rss.en.md diff --git a/posts/pre-rendering.en.md b/posts/pre-rendering.en.md new file mode 100644 index 00000000..a243bfd6 --- /dev/null +++ b/posts/pre-rendering.en.md @@ -0,0 +1,13 @@ +--- +title: "Two Forms of Pre-rendering" +date: "1970-01-01" +description: "SSG & SSR" +tags: ssg, ssr +--- + +Next.js has two forms of pre-rendering: **Static Generation** and **Server-side Rendering**. The difference is in **when** it generates the HTML for a page. + +- **Static Generation** is the pre-rendering method that generates the HTML at **build time**. The pre-rendered HTML is then _reused_ on each request. +- **Server-side Rendering** is the pre-rendering method that generates the HTML on **each request**. + +Importantly, Next.js lets you **choose** which pre-rendering form to use for each page. You can create a "hybrid" Next.js app by using Static Generation for most pages and using Server-side Rendering for others. diff --git a/posts/pre-rendering.md b/posts/pre-rendering.md index a243bfd6..0c8e3ad9 100644 --- a/posts/pre-rendering.md +++ b/posts/pre-rendering.md @@ -1,13 +1,13 @@ --- -title: "Two Forms of Pre-rendering" +title: "两种预渲染形式" date: "1970-01-01" description: "SSG & SSR" tags: ssg, ssr --- -Next.js has two forms of pre-rendering: **Static Generation** and **Server-side Rendering**. The difference is in **when** it generates the HTML for a page. +Next.js 有两种形式的预渲染:**静态生成**和**服务器端渲染**。 区别在于**何时**生成页面的 HTML。 -- **Static Generation** is the pre-rendering method that generates the HTML at **build time**. The pre-rendered HTML is then _reused_ on each request. -- **Server-side Rendering** is the pre-rendering method that generates the HTML on **each request**. +- **静态生成**是在**构建时**生成 HTML 的预渲染方法。 然后,每个请求都会*重用*预渲染的 HTML。 +- **服务器端渲染**是在**每个请求**上生成 HTML 的预渲染方法。 -Importantly, Next.js lets you **choose** which pre-rendering form to use for each page. You can create a "hybrid" Next.js app by using Static Generation for most pages and using Server-side Rendering for others. +重要的是,Next.js 允许您**选择**每个页面使用哪种预渲染表单。 您可以通过对大多数页面使用静态生成并对其他页面使用服务器端渲染来创建“混合”Next.js 应用程序。 From 4d849c16fe8629fbfc6532ed367ebd5eb52f4d77 Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Fri, 26 Jan 2024 18:11:50 +0800 Subject: [PATCH 02/14] fix: fallback --- pages/blog/[id].js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pages/blog/[id].js b/pages/blog/[id].js index b74a76d5..08b2a8b6 100644 --- a/pages/blog/[id].js +++ b/pages/blog/[id].js @@ -59,10 +59,10 @@ export const getStaticPaths = async () => { }; }); // const paths = [ - // { params: { id: "hi" } }, + // { params: { id: "hi" }, locale: "zh" }, // ]; return { paths, - fallback: true, + fallback: false, }; }; From 8f84e636bfc60470b49edcae4ff24d54d7c55d46 Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Fri, 26 Jan 2024 18:25:23 +0800 Subject: [PATCH 03/14] fix: link locale --- components/a.js | 2 ++ components/tag.js | 2 ++ pages/tag/[tag].js | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/components/a.js b/components/a.js index 101f0cd2..8ab4a32b 100644 --- a/components/a.js +++ b/components/a.js @@ -1,4 +1,5 @@ import Link from "next/link"; +import config from "../lib/config.mjs"; const A = props => { return ( @@ -6,6 +7,7 @@ const A = props => { {...props} className="no-underline hover:underline text-black dark:text-white font-normal cursor-pointer" target={props.self ? "_self" : "_blank"} + locale={config.defaultLocale} /> ); }; diff --git a/components/tag.js b/components/tag.js index ba1fd140..1ae3e292 100644 --- a/components/tag.js +++ b/components/tag.js @@ -1,4 +1,5 @@ import Link from "next/link"; +import config from "../lib/config.mjs"; export default function Tag(props) { const highlight = props.highlight @@ -8,6 +9,7 @@ export default function Tag(props) { {post.title} From e4f85667cecb1ef3fd428c1a45efd6ab26e303a5 Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Sun, 28 Jan 2024 18:21:29 +0800 Subject: [PATCH 04/14] feat: i18n switcher --- components/blog.js | 30 ++++++++++++- lib/ssg.mjs | 30 ++++++++++++- posts/app-defaults-2023.zh.md | 79 +++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 posts/app-defaults-2023.zh.md diff --git a/components/blog.js b/components/blog.js index 16340b56..8e5cf0d1 100644 --- a/components/blog.js +++ b/components/blog.js @@ -13,8 +13,19 @@ import Disqus from "./disqus"; import Comments from "./comments"; export default withView(props => { - const { children, title, date, author, view, id, tags, pageURL, image } = - props; + const { + children, + title, + date, + author, + view, + id, + tags, + pageURL, + image, + i18n, + postLocale, + } = props; const [replies, setReplies] = useState([]); const [likes, setLikes] = useState([]); @@ -75,6 +86,21 @@ export default withView(props => {
+ {i18n?.length > 1 && + i18n + .filter(language => language !== postLocale) + .map(language => { + return ( + + 🌐 {language.toUpperCase()} + + ); + })}
{view > 0 && {view} views}
diff --git a/lib/ssg.mjs b/lib/ssg.mjs index ad0fc714..79080be7 100644 --- a/lib/ssg.mjs +++ b/lib/ssg.mjs @@ -55,6 +55,20 @@ export const getMdPostsData = mdDirectory => { return mdPostsData.sort(sortByDate).filter(filterVisibility); }; +const getI18nLanguagesByTitle = (i18nTitle, mdPostNames) => { + const languages = new Set(); + mdPostNames.forEach(fileName => { + if (fileName.startsWith(i18nTitle)) { + if (fileName.split(".")[1] === "md") { + languages.add(config.defaultLocale); + } else { + languages.add(fileName.split(".")[1]); + } + } + }); + return [...languages]; +}; + export const getMdContentById = (id, mdDirectory, withHTMLString = true) => { // default using the `./posts` for markdown directory if (!mdDirectory) { @@ -63,14 +77,24 @@ export const getMdContentById = (id, mdDirectory, withHTMLString = true) => { const isBlog = mdDirectory === defaultMarkdownDirectory; // if the markdown is blog article const mdPostNames = fs.readdirSync(mdDirectory); const mdPostsData = mdPostNames - .filter(name => { - return name === id.replace(`.${config.defaultLocale}`, "") + ".md"; + .filter(fileName => { + if (fileName.includes(`.${config.defaultLocale}`)) { + return fileName === id + ".md"; + } + return fileName === id.replace(`.${config.defaultLocale}`, "") + ".md"; }) .map(async mdPostName => { const fullPath = path.join(mdDirectory, mdPostName); const mdContent = fs.readFileSync(fullPath, "utf8"); const fileStat = fs.statSync(fullPath); const matterResult = matter(mdContent); + const postLocale = id.includes(".") + ? id.split(".")[1] + : config.defaultLocale; + const languages = getI18nLanguagesByTitle( + mdPostName.split(".")[0], + mdPostNames, + ); const htmlResult = await renderHTMLfromMarkdownString( matterResult.content, isBlog, @@ -81,6 +105,8 @@ export const getMdContentById = (id, mdDirectory, withHTMLString = true) => { // modifiedTime: fileStat.mtime.toISOString(), author: config.authorName, // dafult authorName // content: matterResult.content, // original markdown string + i18n: languages, + postLocale, htmlStringContent: withHTMLString ? htmlResult.value : "", // rendered html string htmlAst: isBlog ? fromHtml(htmlResult.value, { diff --git a/posts/app-defaults-2023.zh.md b/posts/app-defaults-2023.zh.md new file mode 100644 index 00000000..dcd00e38 --- /dev/null +++ b/posts/app-defaults-2023.zh.md @@ -0,0 +1,79 @@ +--- +title: "我的默认应用" +date: "2023-12-03" +description: "My default apps in 2023. https://defaults.rknight.me/" +modified: "2023-12-17" +tags: app, life, 2023 +--- + +最近看到很多博主在[App Defaults](https://defaults.rknight.me/)中分享了他们的默认应用程序。以下是我自己的: + +- 📨 邮件客户端 + - Apple 邮件 + - [Skiff 邮件](https://go.lawrenceli.me/skiff) `hi@lawrenceli.me`。 +- 📮 邮件服务器 + - Outlook(中国大陆可用) + - [Skiff 邮件](https://go.lawrenceli.me/skiff)。 很遗憾该服务尚不支持 POP3/IMAP。 +- 📝 注释 + - Apple Notes(使用 [montaigne.io](https://montaigne.io),可以将 Apple Notes 成为一个静态网站) +- ✅ 待办事项 + - Apple 提醒 +- 📷 iPhone 照片拍摄 + - Apple 相机 +- 🟦 照片管理 + - Apple 照片与 iCloud 同步 +- 📆 日历 + - 服务:iCloud 日历 + - 客户端:Apple 日历 +- 📁 云文件存储 + - iCloud 云盘 (iCloud+) + - [阿里云盘](https://www.aliyundrive.com/)(适用于 Apple TV) +- 📖 RSS + - 客户端:iOS/macOS 上的 [Reeder](https://reederapp.com/) + - 服务端:自托管 [FreshRSS](https://freshrss.org/index.html),出色的 PWA 和 Web 推送。 +- 🙍🏻‍♂️ 通讯录 + - N/A,没有联系人。 +- 🌐 浏览器 + - Mac 上的 Chrome + - iOS 上的 Safari +- 💬 聊天 + - [微信](https://www.wechat.com/)(中国必备应用) + - [Microsoft Teams](https://apps.apple.com/ph/app/microsoft-teams/id1113153706)(上班用) +- 🔖 书签 + - [Instapaper](https://www.instapaper.com/) +- 📑 稍后阅读 + - Reeder + Instapaper +- 📜 文字处理 + - [ONLYOFFICE](https://www.onlyoffice.com/)(开源 Office,与微软完全兼容) + - [Obsidian](https://obsidian.md/)(流行的 Markdown 编辑器,可通过 iCloud 同步) +- 📈 电子表格 + - ONLYOFFICE(开源 Office) +- 📊 演示 + - [Slidev](https://sli.dev/)(通过 Markdown 生成幻灯片) +- 🛒 购物清单 + - 在 Apple 提醒共享提醒列表 +- 🍴膳食计划 + - 无 +- 💰 预算和个人理财 + - 以前用过 [iOS 的 Finances 2](https://hochgatterer.me/finances/ios/),目前正在尝试 [BeanWise](https://apps.apple.com/us/app/beanwise/id6446314789?ref=https://lawrenceli.me) +- 📰 新闻 + - [Solidot](https://solidot.org) (中文版 [slashdot](https://slashdot.org) ) + - [路透社](https://www.reuters.com/) + - [Hacker News](https://news.ycombinator.com/) +- 🎵 音乐 + - [Apple Music](https://music.apple.com/)(学生订阅只需每月 6 元) + - [网易云音乐](https://music.163.com/)(作为 Apple Music 的补充) +- 🎤 播客 + - [Apple Podcast](https://www.apple.com/apple-podcasts/) 泛用型播客客户端,无审查 +- 🔐密码管理 + - iCloud KeyChain + - [Google 身份验证器](https://apps.apple.com/us/app/google-authenticator/id388497605) 多因素身份验证 + +## 额外默认设置 + +- 🚀 自托管 + - [Cloudflare](https://lawrenceli.me/blog/cloudflare)(用于 CDN 和 DNS)、[Vercel](https://vercel.com)、[Cloudflare Workers](https://developers.cloudflare.com/workers/), [fly.io](https://fly.io) +- 🤖 自动化 + - [IFTTT](https://ifttt.com),Apple 快捷指令(前 Workflow) +- 🛜 网络工具 + - [Tailscale](https://tailscale.com/)、[Cloudflare WARP](https://1.1.1.1)、[MerlinClash](https://mcreadme.gitbook.io/mc/) From 72c84e25ca0600ef50e09074c68276e2157cc8f1 Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Mon, 29 Jan 2024 14:39:31 +0800 Subject: [PATCH 05/14] i18n: add ja version of a post --- components/blog.js | 35 +++++++++++++++++++---------------- next.config.js | 2 +- posts/pre-rendering.ja.md | 13 +++++++++++++ 3 files changed, 33 insertions(+), 17 deletions(-) create mode 100644 posts/pre-rendering.ja.md diff --git a/components/blog.js b/components/blog.js index 8e5cf0d1..5054cc4b 100644 --- a/components/blog.js +++ b/components/blog.js @@ -86,23 +86,26 @@ export default withView(props => {
- {i18n?.length > 1 && - i18n - .filter(language => language !== postLocale) - .map(language => { - return ( - - 🌐 {language.toUpperCase()} - - ); - })} + {i18n?.length > 1 && ( + <> + {i18n + .filter(language => language !== postLocale) + .map(language => { + return ( + + 🌐 {language.toUpperCase()} + + ); + })} + + )}
- {view > 0 && {view} views} + {view > 10 && {view} views}
)} diff --git a/next.config.js b/next.config.js index e1e3ef7d..3fb01b74 100644 --- a/next.config.js +++ b/next.config.js @@ -4,7 +4,7 @@ module.exports = { // appDir: true, // }, i18n: { - locales: ["zh", "en"], + locales: ["zh", "en", "ja"], defaultLocale: "zh", localeDetection: false, }, diff --git a/posts/pre-rendering.ja.md b/posts/pre-rendering.ja.md new file mode 100644 index 00000000..0e3ccbb2 --- /dev/null +++ b/posts/pre-rendering.ja.md @@ -0,0 +1,13 @@ +--- +title: "2 つの形式のプリレンダリング" +date: "1970-01-01" +description: "SSG & SSR" +tags: ssg, ssr +--- + +Next.js には、**静的生成** と **サーバーサイド レンダリング** という 2 つの形式のプリレンダリングがあります。 違いは、ページの HTML を**いつ生成するか**にあります。 + +- **静的生成**は、**ビルド時**に HTML を生成するプリレンダリング方法です。 事前にレンダリングされた HTML は、リクエストごとに*再利用*されます。 +- **サーバーサイド レンダリング**は、**各リクエスト**で HTML を生成する事前レンダリング方法です。 + +重要なのは、Next.js では、各ページに使用するプリレンダリング フォームを **選択** できることです。 ほとんどのページには静的生成を使用し、その他のページにはサーバー側レンダリングを使用することで、「ハイブリッド」Next.js アプリを作成できます。 From 9af634f5badefdab391dcd8d50927df3f16f2aff Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Mon, 29 Jan 2024 14:57:54 +0800 Subject: [PATCH 06/14] i18n: index page --- pages/index.js | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/pages/index.js b/pages/index.js index 6a65d4eb..67bb0bc7 100644 --- a/pages/index.js +++ b/pages/index.js @@ -1,6 +1,7 @@ import Link from "next/link"; import withView from "../components/withView"; import Layout from "./../components/layout"; +import { useRouter } from "next/router"; import { getAllPostData } from "../lib/ssg"; const getYear = date => new Date(date).getFullYear(); @@ -11,6 +12,9 @@ const Index = function index({ allPostsData }) { result[getYear(currentPost.date)] || []).push(currentPost); return result; }, {}); + console.log(aggregatedPosts); + // eslint-disable-next-line react-hooks/rules-of-hooks + const router = useRouter(); return (
@@ -19,23 +23,29 @@ const Index = function index({ allPostsData }) { .map(year => { return (
-
- {year} -
- {aggregatedPosts[year].map(post => ( -
- - {post.title} - - {post.date} - - + {aggregatedPosts[year].some( + post => post.locale === router.locale, + ) && ( +
+ {year}
- ))} + )} + {aggregatedPosts[year] + .filter(post => post.locale === router.locale) + .map(post => ( +
+ + {post.title} + + {post.date} + + +
+ ))}
); })} From 62d57b460090ec40a72bf50b6cea2c7dbbce42fa Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Mon, 29 Jan 2024 16:28:45 +0800 Subject: [PATCH 07/14] i18n: rss feed --- lib/config.mjs | 5 +++-- lib/feed.mjs | 30 ++++++++++++++++++++++-------- next.config.js | 11 ++++++----- package.json | 2 +- pages/index.js | 1 - 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/lib/config.mjs b/lib/config.mjs index 7fe6b15c..42447832 100644 --- a/lib/config.mjs +++ b/lib/config.mjs @@ -1,4 +1,5 @@ -// eslint-disable-next-line import/no-anonymous-default-export +import nextConfig from "../next.config.js"; + const config = { siteTitle: "Lawrence Li", authorName: "Lawrence", @@ -6,7 +7,7 @@ const config = { authorEmail: "hi@lawrenceli.me", baseURL: "https://lawrenceli.me", feedPath: "/atom.xml", - defaultLocale: "zh", + defaultLocale: nextConfig.i18n.defaultLocale, feedItemsCount: 10, siteDescription: "Blog", activityPubUser: "lawrence", diff --git a/lib/feed.mjs b/lib/feed.mjs index f8494da9..71a529a4 100644 --- a/lib/feed.mjs +++ b/lib/feed.mjs @@ -4,6 +4,8 @@ import { defaultMarkdownDirectory, } from "./ssg.mjs"; import config from "./config.mjs"; +import nextConfig from "../next.config.js"; +import fs from "node:fs"; const { siteTitle, @@ -12,6 +14,7 @@ const { baseURL, websubHub, feedItemsCount, + defaultLocale, } = config; // https://datatracker.ietf.org/doc/html/rfc4287 @@ -22,11 +25,18 @@ const buildFeed = async () => { return await getMdContentById(item.fileName, defaultMarkdownDirectory); }), ); - // only output the recent blog posts - const feed = createRSS( - postContents.slice(0, Math.min(feedItemsCount, postContents.length)), - ); - console.log(feed); + const locales = nextConfig.i18n.locales; + locales.forEach(locale => { + console.log("Building feed for", locale); + const feed = createRSS(postContents, locale); + const fileName = `./public/atom.${locale}.xml`; + fs.openSync(fileName, "w"); + fs.writeFileSync(fileName, feed); + if (locale === defaultLocale) { + // just an alternate for default locale feed (to keep old link working) + fs.writeFileSync("./public/atom.xml", feed); + } + }); }; function mapToAtomEntry(post) { @@ -63,13 +73,17 @@ function decode(string) { .replace(/'/g, "'"); } -function createRSS(blogPosts = []) { - const postsString = blogPosts.map(mapToAtomEntry).reduce((a, b) => a + b, ""); +function createRSS(blogPosts = [], locale = defaultLocale) { + const postsString = blogPosts + .filter(post => post.postLocale === locale) + .map(mapToAtomEntry) + .slice(0, Math.min(feedItemsCount, blogPosts.length)) + .reduce((a, b) => a + b, ""); return ` ${siteTitle} Blog - + ${baseURL}/ diff --git a/next.config.js b/next.config.js index 3fb01b74..7692050d 100644 --- a/next.config.js +++ b/next.config.js @@ -1,11 +1,12 @@ // next.config.js + module.exports = { // experimental: { // appDir: true, // }, i18n: { - locales: ["zh", "en", "ja"], - defaultLocale: "zh", + locales: ["zh", "en", "ja"], // post.en.md, post.ja.md + defaultLocale: "zh", // post.zh.md or post.md localeDetection: false, }, transpilePackages: ["react-tweet"], @@ -29,10 +30,10 @@ module.exports = { destination: "/api/.well-known/:param", }, { - source: "/feed", - destination: "/atom.xml", + source: "/:locale/atom.xml", + destination: "/atom.:locale.xml", + locale: false, }, - // { // source: "/blog/:path", // has: [ diff --git a/package.json b/package.json index 470a6eed..59e4a3a4 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "markdown": "github", "scripts": { "dev": "pnpm feed && next dev -H 0.0.0.0", - "feed": "node ./lib/feed.mjs > ./public/atom.xml", + "feed": "node ./lib/feed.mjs", "websub": "node ./lib/websub.mjs", "start": "next start", "build": "NEXT_PUBLIC_BUILDTIME=$(date '+%s') next build && pnpm feed && next-sitemap", diff --git a/pages/index.js b/pages/index.js index 67bb0bc7..a1d98106 100644 --- a/pages/index.js +++ b/pages/index.js @@ -12,7 +12,6 @@ const Index = function index({ allPostsData }) { result[getYear(currentPost.date)] || []).push(currentPost); return result; }, {}); - console.log(aggregatedPosts); // eslint-disable-next-line react-hooks/rules-of-hooks const router = useRouter(); return ( From c6dbbd04b5de48d1fefb48d805dc6eb902397458 Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Mon, 29 Jan 2024 17:04:15 +0800 Subject: [PATCH 08/14] i18n: rewrite in middleware for rss --- components/header.js | 15 +++++++++------ lib/config.mjs | 3 ++- lib/feed.mjs | 2 +- lib/websub.mjs | 4 ++-- middleware.js | 4 ++++ next.config.js | 10 +++++----- 6 files changed, 23 insertions(+), 15 deletions(-) diff --git a/components/header.js b/components/header.js index 2b1476ab..3cc2777d 100644 --- a/components/header.js +++ b/components/header.js @@ -86,12 +86,15 @@ export default function Header({ type="application/ld+json" dangerouslySetInnerHTML={{ __html: structuredData }} /> - + {config.locales.map(locale => ( + + ))} {enableAdsense && } {image && ( diff --git a/lib/config.mjs b/lib/config.mjs index 42447832..acab7f77 100644 --- a/lib/config.mjs +++ b/lib/config.mjs @@ -6,8 +6,9 @@ const config = { domain: "lawrenceli.me", authorEmail: "hi@lawrenceli.me", baseURL: "https://lawrenceli.me", - feedPath: "/atom.xml", defaultLocale: nextConfig.i18n.defaultLocale, + locales: nextConfig.i18n.locales, + feedFile: "atom.xml", feedItemsCount: 10, siteDescription: "Blog", activityPubUser: "lawrence", diff --git a/lib/feed.mjs b/lib/feed.mjs index 71a529a4..f3657a0a 100644 --- a/lib/feed.mjs +++ b/lib/feed.mjs @@ -34,7 +34,7 @@ const buildFeed = async () => { fs.writeFileSync(fileName, feed); if (locale === defaultLocale) { // just an alternate for default locale feed (to keep old link working) - fs.writeFileSync("./public/atom.xml", feed); + fs.writeFileSync("./public/" + config.feedFile, feed); } }); }; diff --git a/lib/websub.mjs b/lib/websub.mjs index c08b61de..26055787 100644 --- a/lib/websub.mjs +++ b/lib/websub.mjs @@ -1,8 +1,8 @@ import config from "./config.mjs"; import { getMdPostsData } from "./ssg.mjs"; -const { websubHub, baseURL, feedPath } = config; -const URL = baseURL + feedPath; +const { websubHub, baseURL, feedFile } = config; +const URL = baseURL + "/" + feedFile; /** * Determine if we need to publish the websub. diff --git a/middleware.js b/middleware.js index 8ba4c729..d957de64 100644 --- a/middleware.js +++ b/middleware.js @@ -11,6 +11,10 @@ export function middleware(req) { new URL(`/api/activitypub/blog/${blogId}`, req.url), ); } + if (path.endsWith("atom.xml")) { + const locale = req.nextUrl.locale; + return NextResponse.rewrite(new URL(`/atom.${locale}.xml`, req.url)); + } } // export const config = { diff --git a/next.config.js b/next.config.js index 7692050d..8b4712ca 100644 --- a/next.config.js +++ b/next.config.js @@ -29,11 +29,11 @@ module.exports = { source: "/.well-known/:param", destination: "/api/.well-known/:param", }, - { - source: "/:locale/atom.xml", - destination: "/atom.:locale.xml", - locale: false, - }, + // { + // source: "/:locale/atom.xml", + // destination: "/atom.:locale.xml", + // locale: false, + // }, // { // source: "/blog/:path", // has: [ From 293529fe583c2769e352b6fc761ded3ab0167724 Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Mon, 29 Jan 2024 17:48:31 +0800 Subject: [PATCH 09/14] i18n: chore enhancement --- lib/feed.mjs | 18 ++++++++++++------ middleware.js | 4 +++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/feed.mjs b/lib/feed.mjs index f3657a0a..a527f468 100644 --- a/lib/feed.mjs +++ b/lib/feed.mjs @@ -8,13 +8,15 @@ import nextConfig from "../next.config.js"; import fs from "node:fs"; const { - siteTitle, authorName, authorEmail, baseURL, - websubHub, - feedItemsCount, defaultLocale, + feedFile, + feedItemsCount, + siteTitle, + siteDescription, + websubHub, } = config; // https://datatracker.ietf.org/doc/html/rfc4287 @@ -34,7 +36,7 @@ const buildFeed = async () => { fs.writeFileSync(fileName, feed); if (locale === defaultLocale) { // just an alternate for default locale feed (to keep old link working) - fs.writeFileSync("./public/" + config.feedFile, feed); + fs.writeFileSync(`./public/${feedFile}`, feed); } }); }; @@ -79,11 +81,15 @@ function createRSS(blogPosts = [], locale = defaultLocale) { .map(mapToAtomEntry) .slice(0, Math.min(feedItemsCount, blogPosts.length)) .reduce((a, b) => a + b, ""); + let feedURL = `${baseURL}/${locale}/${feedFile}`; + if (locale === defaultLocale) { + feedURL = `${baseURL}/${feedFile}`; + } return ` ${siteTitle} - Blog - + ${siteDescription} + ${baseURL}/ diff --git a/middleware.js b/middleware.js index d957de64..7ab02db6 100644 --- a/middleware.js +++ b/middleware.js @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import cfg from "./lib/config.mjs"; // This function can be marked `async` if using `await` inside export function middleware(req) { @@ -11,7 +12,8 @@ export function middleware(req) { new URL(`/api/activitypub/blog/${blogId}`, req.url), ); } - if (path.endsWith("atom.xml")) { + // RSS Feed i18n + if (path.endsWith(cfg.feedFile)) { const locale = req.nextUrl.locale; return NextResponse.rewrite(new URL(`/atom.${locale}.xml`, req.url)); } From 959330b745d13bcdd4178ffa5888e742cab9754d Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Mon, 29 Jan 2024 18:11:00 +0800 Subject: [PATCH 10/14] rss: fix link for i18n --- components/blog.js | 4 ++-- lib/feed.mjs | 2 +- lib/ssg.mjs | 9 ++++----- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/components/blog.js b/components/blog.js index 5054cc4b..d8ad5cd3 100644 --- a/components/blog.js +++ b/components/blog.js @@ -24,7 +24,7 @@ export default withView(props => { pageURL, image, i18n, - postLocale, + locale, } = props; const [replies, setReplies] = useState([]); const [likes, setLikes] = useState([]); @@ -89,7 +89,7 @@ export default withView(props => { {i18n?.length > 1 && ( <> {i18n - .filter(language => language !== postLocale) + .filter(language => language !== locale) .map(language => { return ( post.postLocale === locale) + .filter(post => post.locale === locale) .map(mapToAtomEntry) .slice(0, Math.min(feedItemsCount, blogPosts.length)) .reduce((a, b) => a + b, ""); diff --git a/lib/ssg.mjs b/lib/ssg.mjs index 79080be7..199fadbb 100644 --- a/lib/ssg.mjs +++ b/lib/ssg.mjs @@ -88,9 +88,8 @@ export const getMdContentById = (id, mdDirectory, withHTMLString = true) => { const mdContent = fs.readFileSync(fullPath, "utf8"); const fileStat = fs.statSync(fullPath); const matterResult = matter(mdContent); - const postLocale = id.includes(".") - ? id.split(".")[1] - : config.defaultLocale; + const locale = id.includes(".") ? id.split(".")[1] : config.defaultLocale; + const idWithoutLocale = id.replace(`.${locale}`, ""); const languages = getI18nLanguagesByTitle( mdPostName.split(".")[0], mdPostNames, @@ -100,13 +99,13 @@ export const getMdContentById = (id, mdDirectory, withHTMLString = true) => { isBlog, ); return { - id, + id: idWithoutLocale, birthTime: fileStat.birthtime.toISOString(), // modifiedTime: fileStat.mtime.toISOString(), author: config.authorName, // dafult authorName // content: matterResult.content, // original markdown string i18n: languages, - postLocale, + locale, htmlStringContent: withHTMLString ? htmlResult.value : "", // rendered html string htmlAst: isBlog ? fromHtml(htmlResult.value, { From 3df5cdb23ad6bfc51524e55d184101afe2f3f7b9 Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Mon, 29 Jan 2024 18:21:41 +0800 Subject: [PATCH 11/14] rss: apply locale for feed link --- lib/feed.mjs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/feed.mjs b/lib/feed.mjs index 11721be2..93a0d4e0 100644 --- a/lib/feed.mjs +++ b/lib/feed.mjs @@ -43,11 +43,14 @@ const buildFeed = async () => { function mapToAtomEntry(post) { const categories = post.tags?.split(","); - return ` - + let postLink = `${baseURL}/${post.locale}/blog/${post.id}`; + if (post.locale === defaultLocale) { + postLink = `${baseURL}/blog/${post.id}`; + } + return ` ${decode(post.title)} - ${baseURL}/blog/${post.id} - + ${postLink} + ${post.date}T00:00:00.000Z ${ post.modified ? post.modified : post.date @@ -75,7 +78,7 @@ function decode(string) { .replace(/'/g, "'"); } -function createRSS(blogPosts = [], locale = defaultLocale) { +function createRSS(blogPosts, locale) { const postsString = blogPosts .filter(post => post.locale === locale) .map(mapToAtomEntry) From fdb831cf28a59483a2a31cb4a8e354b93a6e9865 Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Mon, 29 Jan 2024 18:38:36 +0800 Subject: [PATCH 12/14] i18n: filter locales in config --- lib/ssg.mjs | 2 +- pages/blog/[id].js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/ssg.mjs b/lib/ssg.mjs index 199fadbb..7d05c13d 100644 --- a/lib/ssg.mjs +++ b/lib/ssg.mjs @@ -93,7 +93,7 @@ export const getMdContentById = (id, mdDirectory, withHTMLString = true) => { const languages = getI18nLanguagesByTitle( mdPostName.split(".")[0], mdPostNames, - ); + ).filter(language => config.locales.includes(language)); const htmlResult = await renderHTMLfromMarkdownString( matterResult.content, isBlog, diff --git a/pages/blog/[id].js b/pages/blog/[id].js index 08b2a8b6..6b05a665 100644 --- a/pages/blog/[id].js +++ b/pages/blog/[id].js @@ -8,6 +8,7 @@ import path from "path"; import { lazy } from "react"; import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import { toJsxRuntime } from "hast-util-to-jsx-runtime"; +import config from "../../lib/config.mjs"; const Douban = lazy(() => import("../../components/douban")); const Bilibili = lazy(() => import("../../components/bilibili")); const Tweet = lazy(() => import("../../components/twitter")); @@ -55,7 +56,9 @@ export const getStaticPaths = async () => { const paths = mdPostsData.map(data => { return { params: { id: data.id }, - locale: data.locale, + locale: config.locales.includes(data.locale) + ? data.locale + : config.defaultLocale, }; }); // const paths = [ From c4480ef4cc462f7b5ec15db2229bed5510081f18 Mon Sep 17 00:00:00 2001 From: Lawrence Li Date: Fri, 23 Feb 2024 20:27:30 +0800 Subject: [PATCH 13/14] chore: update articles --- pages/blog/example.js | 2 +- posts/2022-in-review.md | 8 ++- posts/activitypub.md | 2 +- posts/app-defaults-2023.zh.md | 10 ++-- posts/apple-tv.md | 18 +++++-- posts/chat-ops.md | 14 ++--- posts/cloudflare.md | 50 +++++++++--------- posts/how.md | 18 ++++--- posts/invert-binary-tree.en.md | 2 + posts/invert-binary-tree.zh.md | 64 +++++++++++++++++++++++ posts/load-balancing.md | 6 +-- posts/long-live-rss.en.md | 28 +++++----- posts/long-live-rss.zh.md | 57 ++++++++++++++++++++ posts/makeshift-troupe.md | 4 +- posts/mechanical-empathy.md | 1 + posts/path-dependence-and-cargo-cult.md | 2 +- public/images/long-live-rss/freshrss.png | Bin 0 -> 231835 bytes 17 files changed, 207 insertions(+), 79 deletions(-) create mode 100644 posts/invert-binary-tree.zh.md create mode 100644 posts/long-live-rss.zh.md create mode 100644 public/images/long-live-rss/freshrss.png diff --git a/pages/blog/example.js b/pages/blog/example.js index 962aed3e..a44ecbcc 100644 --- a/pages/blog/example.js +++ b/pages/blog/example.js @@ -13,7 +13,7 @@ export const blogProps = { someKey: "someValeInJSXProps", tags: "example, test, jsx", locale: "en", - visible: true, + visible: false, }; /** diff --git a/posts/2022-in-review.md b/posts/2022-in-review.md index 7aa597bc..a22460ea 100644 --- a/posts/2022-in-review.md +++ b/posts/2022-in-review.md @@ -22,14 +22,12 @@ tags: review, 2022 年底 Spring 6 和 [Spring Boot 3 的 GA](https://spring.io/blog/2022/11/24/spring-boot-3-0-goes-ga) 同样令人欣喜。我们终于可以基于 GraalVM 的 AOT 去做 Spring Native on Cloud Native 了。JDK 8 仍有接近 7 年的寿命。JDK 17 的 ZGC 是最值得研究学习的,另外下个 JDK LTS (JDK 21?) 应该会让协程 (虚拟线程) GA,目前仍在孵化阶段。学 JavaScript / TypeScript 就是玩玩,真正企业级大型项目还得看 Java。为什么这么说,你去看看 [Nest.js](https://docs.nestjs.com/controllers) 就知道了。 -另外又用上了一个比较简单可靠的托管服务(关键是免费):[fly.io](https://fly.io/),除常规项目外,它可以部署 Docker 容器,并提供大概 3 GB 额度的免费磁盘挂载。大多静态页面和轻量 Serverless 都依赖 Vercel 或 CloudFlare Workers,一旦遇到需要更加复杂的场景(比如 WebSocket 或 SSE),我会选择使用 fly.io 来部署。 +另外又用上了一个比较简单可靠的托管服务(关键是免费):[fly.io](https://fly.io/),除常规项目外,它可以部署 Docker 容器,并提供大概 3 GB 额度的免费磁盘挂载。大多静态页面和轻量 Serverless 都依赖 Vercel 或 Cloudflare Workers,一旦遇到需要更加复杂的场景(比如 WebSocket 或 SSE),我会选择使用 fly.io 来部署。 Solidot Robot 已稳定运行将近一千天了,目前依旧基于 Vercel Serverless Function。异常稳定。[Solidot](https://www.solidot.org/) 依旧是我每天都会逛的科技新闻源。 ## Work -COVID-19 导致今年我在上海 WFH 了将近一整年。去办公室的次数寥寥可数 - 这曾导致 10 月份某天回公司时忘了工位在第几层楼。 - 公司内的 OpenShift 今年并没有花太多时间研究,权限、开发环境问题无法在本地快速调试容器。当然我依旧对 Kubernetes 保持高昂的学习热情,并用半个工作日的时间完成公司对所有开发人员提供的 immersive training。 我对所在团队 (CVA Trading Desk[^1] in XVA) 的业务有了一定的认知: @@ -74,9 +72,9 @@ CVA Trading Desk 其实是一家内部保险公司。负责保障 Business Line 文本编辑器方面,我开始尝试使用 [Obsidian](https://obsidian.md/)。目前桌面端和移动端都有一些 bug,作为 Markdown 编辑器,它的使用门槛对小白来说很低 —— 单纯的码字工具而已,而它的上限对习惯折腾的玩家来说也很高 —— 丰富多元的社区第三方插件。 -最后不得不提的便是 CloudFlare 的优秀网络工具 [WARP+](https://1.1.1.1/)。他们开会期间我完全依赖它才能正常上网。使用期间发现 WARP 有流量限制,利用 API 刷到了几十 TB 的额度后发现其下「零信任网络[^2]」是完全免费且不限流量的 —— 我果断切换成此模式,同时仍然续费另外一项网络协议工具互为备选方案来帮我维持突破网络封锁的高可用。 +最后不得不提的便是 Cloudflare 的优秀网络工具 [WARP+](https://1.1.1.1/)。他们开会期间我完全依赖它才能正常上网。使用期间发现 WARP 有流量限制,利用 API 刷到了几十 TB 的额度后发现其下「零信任网络[^2]」是完全免费且不限流量的 —— 我果断切换成此模式,同时仍然续费另外一项网络协议工具互为备选方案来帮我维持突破网络封锁的高可用。 -## Happy Hours +## Have fun, secretly 今年 10 月中下旬,我的网易云账号因在某天评论了一首歌被禁言 366 天。微博帐号也只因**点赞**评论某事件[^3]的微博而被永久封禁。对此我的态度只有: diff --git a/posts/activitypub.md b/posts/activitypub.md index 8d82521a..943cc644 100644 --- a/posts/activitypub.md +++ b/posts/activitypub.md @@ -150,7 +150,7 @@ curl https://lawrenceli.me/blog/ssg-ssr -H "Accept: application/activity+json" ## 社区实现 -很巧合地发现 CloudFlare 也在同一时间段开发了兼容 Mastodon 的 ActivityPub 实现:[WildeBeest](https://github.com/cloudflare/wildebeest),有兴趣可以直接用他们的商业化技术栈来部署一个小型实例,或者直接参考他们的代码,用自己擅长的服务端语言实现自己的 ActivityPub。 +很巧合地发现 Cloudflare 也在同一时间段开发了兼容 Mastodon 的 ActivityPub 实现:[WildeBeest](https://github.com/cloudflare/wildebeest),有兴趣可以直接用他们的商业化技术栈来部署一个小型实例,或者直接参考他们的代码,用自己擅长的服务端语言实现自己的 ActivityPub。
diff --git a/posts/app-defaults-2023.zh.md b/posts/app-defaults-2023.zh.md index dcd00e38..ff254e79 100644 --- a/posts/app-defaults-2023.zh.md +++ b/posts/app-defaults-2023.zh.md @@ -6,7 +6,7 @@ modified: "2023-12-17" tags: app, life, 2023 --- -最近看到很多博主在[App Defaults](https://defaults.rknight.me/)中分享了他们的默认应用程序。以下是我自己的: +最近看到很多博主在 [App Defaults](https://defaults.rknight.me/) 中分享了他们的默认应用程序。以下是我自己的: - 📨 邮件客户端 - Apple 邮件 @@ -52,20 +52,20 @@ tags: app, life, 2023 - [Slidev](https://sli.dev/)(通过 Markdown 生成幻灯片) - 🛒 购物清单 - 在 Apple 提醒共享提醒列表 -- 🍴膳食计划 +- 🍴 膳食计划 - 无 - 💰 预算和个人理财 - 以前用过 [iOS 的 Finances 2](https://hochgatterer.me/finances/ios/),目前正在尝试 [BeanWise](https://apps.apple.com/us/app/beanwise/id6446314789?ref=https://lawrenceli.me) - 📰 新闻 - - [Solidot](https://solidot.org) (中文版 [slashdot](https://slashdot.org) ) + - [Solidot](https://solidot.org) (中文版的 [slashdot](https://slashdot.org)) - [路透社](https://www.reuters.com/) - [Hacker News](https://news.ycombinator.com/) - 🎵 音乐 - [Apple Music](https://music.apple.com/)(学生订阅只需每月 6 元) - [网易云音乐](https://music.163.com/)(作为 Apple Music 的补充) - 🎤 播客 - - [Apple Podcast](https://www.apple.com/apple-podcasts/) 泛用型播客客户端,无审查 -- 🔐密码管理 + - [Apple Podcast](https://www.apple.com/apple-podcasts/) 推荐泛用型播客客户端,无内容审查 +- 🔐 密码管理 - iCloud KeyChain - [Google 身份验证器](https://apps.apple.com/us/app/google-authenticator/id388497605) 多因素身份验证 diff --git a/posts/apple-tv.md b/posts/apple-tv.md index 0ae7f38d..2a397ee8 100644 --- a/posts/apple-tv.md +++ b/posts/apple-tv.md @@ -2,14 +2,12 @@ title: "Apple TV" date: "2023-01-21" description: " 小巧玲珑的电视盒子" -image: https://lawrenceli.me/images/apple-tv/tv.jpg tags: apple, tv -visible: false --- 过年回老家看电视,运营商送的网络电视盒子主屏幕花花绿绿,我一个程序员都费了好久才找到地方卫视的直播频道。 -索性去电商平台搜搜看有没有更好的硬件、翻看各种测评文章和视频。清晰了需求定位后,我果断找了一家有现货的店铺下单了一款美版 Apple TV 2022。 +索性去电商平台搜搜看有没有更好的硬件、翻看各种测评文章和视频。清晰了需求定位后,我果断找了一家有现货的店铺下单了一款美版 Apple TV 2022 (4K)。 ![Apple TV](/images/apple-tv/tv.jpg) @@ -17,8 +15,18 @@ visible: false 由于购买之前就已经熟悉了大部分使用细节,所以安装、使用的时候毫不费力;像是把玩过很久的玩具一样自然流畅。 -用美区 Apple ID 购买了 OBOX,添加了电视直播源就看起了 CCTV。比期待的画质高出不少。遥控器的金属质感像第一次摸到棱角分明的 iPhone 5S 一样爱不释手!还用它和我爸玩了一局桌球游戏。 +用美区 Apple ID 购买了很多付费应用,主要都是一些国内独立开发者的作品。 -种种体验让我想起知乎上一个回答: +- Alplayer +- APTV +- IIVA +- Miao Projects +- VidHub + +很难想象不少开发者会为国内极其小众的平台开发上架了如此小而美的 tvOS App。 + +搜集一些电视直播源,我就反常地看起了 CCTV。比期待的画质高出不少。遥控器的金属质感像第一次摸到棱角分明的 iPhone 5S 一样爱不释手!还用它和我爸玩了一局桌球游戏。 + +种种体验让我想起[知乎上一个回答](https://www.zhihu.com/question/477077785/answer/2425144012): > 长这么大,听过最清晰的《义勇军进行曲》是在 Apple Music。 diff --git a/posts/chat-ops.md b/posts/chat-ops.md index 553f73e4..d4085762 100644 --- a/posts/chat-ops.md +++ b/posts/chat-ops.md @@ -46,7 +46,7 @@ tags: devops, golang, chatops, ci 我们按照 [Kubernetes Prow 的设计语言](/blog/prow),用一个 `/` 来作为 Tag,形式如同 `/test xxx` . -因此这里必然需要做字符串处理了。除了判断 Tag 的存在之外,要取 Tag 后面的参数。项目用 Go 实现,很简单。贴其中一工具函数的代码,各位猜猜这是做什么的: +因此这里必然需要做字符串处理了。除了判断 Tag 的存在之外,要取 Tag 后面的参数。项目用 Go 实现,很简单,贴其中一工具函数的代码: ```go func StringIndexOf(originalArray []string, wordToFind interface{}) []int { @@ -56,11 +56,11 @@ func StringIndexOf(originalArray []string, wordToFind interface{}) []int { interfaceArray[i] = v } var indexArray []int - for i:=0 ; i < length; i++ { - if strings.Compare(wordToFind.(string), originalArray[i]) == 0 { - indexArray = append(indexArray, i) - } - } + for i:=0 ; i < length; i++ { + if strings.Compare(wordToFind.(string), originalArray[i]) == 0 { + indexArray = append(indexArray, i) + } + } return indexArray } ``` @@ -165,6 +165,6 @@ Robot 回复: 当前支持指令列表, 带 * 需要特定人员发起 最近一次更新,让机器人支持了多个仓库,直接在 `/tag` 最后加一个可选参数 `[repo]`,然后 SDK 的参数做出相应的变动就实现了。 -相关链接: +此项实践已作为 ThoughtWorks 员工构建的知识体系 Ledge DevOps 对 ChatOps 这一模式的展示案例: - [Pattern#ChatOps from Ledge —— DevOps knowledge learning platform](https://devops.phodal.com/pattern#chatops) diff --git a/posts/cloudflare.md b/posts/cloudflare.md index 0b6d2b8d..f2256ddc 100644 --- a/posts/cloudflare.md +++ b/posts/cloudflare.md @@ -1,5 +1,5 @@ --- -title: "CloudFlare" +title: "Cloudflare" themeColor: "#f88100" date: "2023-06-11" modified: "2023-11-29" @@ -7,7 +7,7 @@ description: "一家免费提供 DDoS 防护的 CDN 厂商。" tags: cloudflare, network, cdn, company, ddos, tls --- -CloudFlare 是一家在业内比较知名的 CDN 服务商,提供包含 DNS 解析、WAF 防火墙、CDN 加速、DDoS 防护,后续推出了一系列比较方便开发人员的许多功能:CloudFlare Workers、KV、Zero Trust Tunnel、WARP... 一切都是为了提供一个安全、快速的互联网环境。如果说 Vercel 给前端开发人员提供了基础设施,那 CloudFlare 则为数千万网站后端流量提供了基础设施。 +Cloudflare 是一家在业内比较知名的 CDN 服务商,提供包含 DNS 解析、WAF 防火墙、CDN 加速、DDoS 防护,后续推出了一系列比较方便开发人员的许多功能:Cloudflare Workers、KV、Zero Trust Tunnel、WARP... 一切都是为了提供一个安全、快速的互联网环境。如果说 Vercel 给前端开发人员提供了基础设施,那 Cloudflare 则为数千万网站后端流量提供了基础设施。 今年年初,[Cloudflare 缓解了破纪录的 7100 万个请求/秒的 DDoS 攻击](https://blog.cloudflare.com/zh-cn/cloudflare-mitigates-record-breaking-71-million-request-per-second-ddos-attack-zh-cn/)。 @@ -15,24 +15,24 @@ CloudFlare 是一家在业内比较知名的 CDN 服务商,提供包含 DNS 三年前,我把我的博客从 WordPress 迁移到了 [Vercel](https://vercel.com),老用户们或许都记得,那时候的域名可还都是 `*.now.sh`。初次使用 Vercel 的感受可以说是如获至宝,在今天看来可能显得很幼稚了——如果不是自己知道如何从 0 到 1 申请域名去部署一个全栈的 Web 项目的话,很难理解 Vercel 这种平台背后做了哪些复杂工作。这是我所理解的美国公司的一贯作法 —— 他们总是把庞大、精密、复杂的技术或基础设施掩盖在简约、优雅的产品外观之下。而我每次都会保持警惕和观察力,如果换我做,我怎么来实现?后来我便学习起了 Kubernetes。 -说回 CloudFlare。从我第一次买域名(2015 年)一直到现在,我全部把解析权安排在 CloudFlare 上。归因于 [Vercel 的一次网络问题](https://isdown.app/integrations/vercel/incidents/50745-errors-accessing-from-china),国内的网络受某种不可抗力在 2021 年的时候突然无法访问 Vercel 的部分域名了,尽管我的博客每天仅有为数不多的访客,但作为一个以中文为主的博客,还是有必要保持国内网络访问的畅通。根据官方提供的新的 CNAME 值,我在 CloudFlare 上更换了解析记录,也算顺利解决。也就在当时,我才注意到 DNS Records 控制台之前一个一直忽视的选项: +说回 Cloudflare。从我第一次买域名(2015 年)一直到现在,我全部把解析权安排在 Cloudflare 上。归因于 [Vercel 的一次网络问题](https://isdown.app/integrations/vercel/incidents/50745-errors-accessing-from-china),国内的网络受某种不可抗力在 2021 年的时候突然无法访问 Vercel 的部分域名了,尽管我的博客每天仅有为数不多的访客,但作为一个以中文为主的博客,还是有必要保持国内网络访问的畅通。根据官方提供的新的 CNAME 值,我在 Cloudflare 上更换了解析记录,也算顺利解决。也就在当时,我才注意到 DNS Records 控制台之前一个一直忽视的选项:
Proxied
-我好奇地把它启用了,即从灰色 `DNS only` 换成了这个橙色的 `Proxied`,那时我还没意识到,其实 CloudFlare 从那刻起已经完全接管了我的网站的全部流量并进行任播 (Anycast);换句话说,我在现有的 Vercel CDN 之上,又套了一层 CloudFlare CDN。是的,这迎来了两个问题: +我好奇地把它启用了,即从灰色 `DNS only` 换成了这个橙色的 `Proxied`,那时我还没意识到,其实 Cloudflare 从那刻起已经完全接管了我的网站的全部流量并进行任播 (Anycast);换句话说,我在现有的 Vercel CDN 之上,又套了一层 Cloudflare CDN。是的,这迎来了两个问题: 1. Vercel 后台警告 CNAME 解析异常 2. 缓存时间问题 -3. 客户端 IP 全部被识别 CloudFlare IP,所有发往 Vercel 的请求都会从 CloudFlare 数据中心发出 +3. 客户端 IP 全部被识别 Cloudflare IP,所有发往 Vercel 的请求都会从 Cloudflare 数据中心发出 -不可能没人像我这样做吧?事实上,[Vercel 并不推荐在其基础上使用另一层 CDN](https://vercel.com/guides/why-running-another-cdn-on-top-of-vercel-is-not-recommended)。后续我也依次找寻到了解决方案:对于 CNAME 来说,Vercel 会定时访问网站跟路径下的 `.well-known` 路径下的资源来识别包括 CNAME、HTTPS 证书这类配置验证网站控制权信息,因此我们可以直接在 CloudFlare 的 WAF 中,把这类路径作为白名单让 WAF 跳过其他安全规则直接放行。对于客户端 IP,可以参考 [Available Managed Transforms](https://developers.cloudflare.com/rules/transform/managed-transforms/reference/),将一些客户端原始信息置于请求头中。缓存时间方面,还是要熟悉 MDN 上的一些标准 HTTP 协商协议,细粒度地对不同资源设置不同的 TTL,尽可能发挥 CloudFlare CDN 和浏览器自身缓存的优势 - 一个博客而已,是不是有点大炮打蚊子了? +不可能没人像我这样做吧?事实上,[Vercel 并不推荐在其基础上使用另一层 CDN](https://vercel.com/guides/why-running-another-cdn-on-top-of-vercel-is-not-recommended)。后续我也依次找寻到了解决方案:对于 CNAME 来说,Vercel 会定时访问网站跟路径下的 `.well-known` 路径下的资源来识别包括 CNAME、HTTPS 证书这类配置验证网站控制权信息,因此我们可以直接在 Cloudflare 的 WAF 中,把这类路径作为白名单让 WAF 跳过其他安全规则直接放行。对于客户端 IP,可以参考 [Available Managed Transforms](https://developers.cloudflare.com/rules/transform/managed-transforms/reference/),将一些客户端原始信息置于请求头中。缓存时间方面,还是要熟悉 MDN 上的一些标准 HTTP 协商协议,细粒度地对不同资源设置不同的 TTL,尽可能发挥 Cloudflare CDN 和浏览器自身缓存的优势 - 一个博客而已,是不是有点大炮打蚊子了? -CloudFlare 的整体防御从 L3 到 L7,遍布了所有能覆盖的防御范围。一个请求进入 CloudFlare 所代理的网站流量会经历顺序由上到下: +Cloudflare 的整体防御从 L3 到 L7,遍布了所有能覆盖的防御范围。一个请求进入 Cloudflare 所代理的网站流量会经历顺序由上到下: -| Traffic Sequence in CloudFlare | +| Traffic Sequence in Cloudflare | | :----------------------------: | | DDoS | | URL Rewrites | @@ -48,35 +48,35 @@ CloudFlare 的整体防御从 L3 到 L7,遍布了所有能覆盖的防御范 | Access | | Workers | -这些流量经过内部的层层筛选,以及我们自己定义的一些 Rule,最终反代到源站。因此,在决定使用任何 CDN 产品的时候,有必要将服务端源站 IP 妥善隐藏,尽可能不暴露任何历史解析值,否则一切防御都是徒劳。如果源站 IP 已经暴露,只能及时更换新的地址。在新的规则录入好后,CloudFlare 的全球网络会立刻应用规则并实时生效,这里或多或少要归功于大佬 [agentzh 章亦春](https://mp.weixin.qq.com/s/xfphy67PTbtjeggo7LpjSA) 开源的高性能网关 [OpenResty](https://openresty.org/cn/)。 +这些流量经过内部的层层筛选,以及我们自己定义的一些 Rule,最终反代到源站。因此,在决定使用任何 CDN 产品的时候,有必要将服务端源站 IP 妥善隐藏,尽可能不暴露任何历史解析值,否则一切防御都是徒劳。如果源站 IP 已经暴露,只能及时更换新的地址。在新的规则录入好后,Cloudflare 的全球网络会立刻应用规则并实时生效,这里或多或少要归功于大佬 [agentzh 章亦春](https://mp.weixin.qq.com/s/xfphy67PTbtjeggo7LpjSA) 开源的高性能网关 [OpenResty](https://openresty.org/cn/)。 -## CloudFlare Workers & Serverless +## Cloudflare Workers & Serverless 我们可以将一个个「函数」部署在公有云的「边缘计算节点」之上,并暴露 Socket 给这些节点上的函数,来实现无需忽略底层服务器,直接部署可随意伸缩的 HTTP 服务的能力。当然,这要求这些函数尽可能无状态。在没有任何请求,闲置一定时间时,这些函数进程会直接消失以腾出计算资源,直到下次事件驱动它们迅速重新启动并继续提供服务。这便是老生常谈的 Serverless。 -初次了解 Serverless 也是非常惊讶。AWS Lambda 竟能将 function 如此商业化 (FaaS),Vercel 在此之上也做到了开箱即用。借助 CloudFlare 现有的数据中心,CloudFlare 也推出他们的 Serverless 解决方案 - [CloudFlare Workers](https://blog.cloudflare.com/introducing-cloudflare-workers/)。不同的是,CloudFlare Workers 相比原始的 Vercel Serverless Function 而言能够做 Server Sent Event、WebSocket 这类支持长连接的请求。尽管后续 Vercel Edge Function 也能实现,但是它能支持的 Node.js Module 实在太少了。(作者注:后来我才了解到 Vercel Edge Function 其实构建于 CloudFlare Workers 之上) +初次了解 Serverless 也是非常惊讶。AWS Lambda 竟能将 function 如此商业化 (FaaS),Vercel 在此之上也做到了开箱即用。借助 Cloudflare 现有的数据中心,Cloudflare 也推出他们的 Serverless 解决方案 - [Cloudflare Workers](https://blog.cloudflare.com/introducing-cloudflare-workers/)。不同的是,Cloudflare Workers 相比原始的 Vercel Serverless Function 而言能够做 Server Sent Event、WebSocket 这类支持长连接的请求。尽管后续 Vercel Edge Function 也能实现,但是它能支持的 Node.js Module 实在太少了。(作者注:后来我才了解到 Vercel Edge Function 其实构建于 Cloudflare Workers 之上) -前不久,CloudFlare [开源了 Workers 运行时 workerd](https://blog.cloudflare.com/workerd-open-source-workers-runtime/)。 +前不久,Cloudflare [开源了 Workers 运行时 workerd](https://blog.cloudflare.com/workerd-open-source-workers-runtime/)。
-CloudFlare Workers 有许多应用场景。比如实现一个简单的[短 URL 重定向服务](https://lucjan.medium.com/free-url-shortener-with-cloudflare-workers-125eaf87b1ec)、[GitHub Proxy](https://github.com/hunshcn/gh-proxy)、以及一大堆各自实现的 ChatGPT API Proxy...方便了太多国内用户。 +Cloudflare Workers 有许多应用场景。比如实现一个简单的[短 URL 重定向服务](https://lucjan.medium.com/free-url-shortener-with-cloudflare-workers-125eaf87b1ec)、[GitHub Proxy](https://github.com/hunshcn/gh-proxy)、以及一大堆各自实现的 ChatGPT API Proxy...方便了太多国内用户。 Node.js 作者 Ryan Dahl 这几年给 JavaScript 写的另一个全新运行时 [Deno 也有类似的 Serverless 服务](https://dash.deno.com),体验也很友好,同样[支持 Web Standard API](https://twitter.com/la3rence/status/1642798082294251520)。 为了实现 Serverless 的更多数据持久化功能,他们也各自推出了自家的 KV 存储实现服务,或者说是 Serverless 数据库。 -## CloudFlare 在 TLS 协议上的努力 +## Cloudflare 在 TLS 协议上的努力 ### Client Hello - SNI -再来谈谈技术方面的一些进展。很多读者都知道 Server Name Indication(服务器名称指示,SNI)的存在,它是 TLS/SSL 协议在最初的 Client Hello 阶段由客户端发往服务端的一个字段,内容是网站的主机名或域名。引用 CloudFlare 的形象解释: +再来谈谈技术方面的一些进展。很多读者都知道 Server Name Indication(服务器名称指示,SNI)的存在,它是 TLS/SSL 协议在最初的 Client Hello 阶段由客户端发往服务端的一个字段,内容是网站的主机名或域名。引用 Cloudflare 的形象解释: > SNI 有点像邮寄包裹到公寓楼而不是独栋房子。将邮件邮寄到某人的独栋房子时,仅街道地址就足以将包裹发送给收件人。但是,当包裹进入公寓楼时,除了街道地址外,还需要公寓号码。否则,包裹可能无法送达收件人或根本无法交付。许多 Web 服务器更像是公寓大楼而不是独栋房子:它们承载多个域名,因此仅 IP 地址不足以指示用户尝试访问哪个域名.....当多个网站托管在一台服务器上并共享一个 IP 地址,并且每个网站都有自己的 SSL 证书,在客户端设备尝试安全地连接到其中一个网站时,服务器可能不知道显示哪个 SSL 证书。这是因为 SSL/TLS 握手发生在客户端设备通过 HTTP 指示连接到某个网站之前。 -有点类似于 HTTP 协议中的 `Host` 请求头(如果在同一台服务器上用 Nginx 配置过多个虚拟主机应该都熟悉),但是 SNI 是作用在 L4,而且在 TCP 握手前完成。起初它并不是 TLS 协议的一部分,最早在 2003 年作为扩展字段增加到 TLS 协议中 ([RFC 6066](https://datatracker.ietf.org/doc/html/rfc6066#section-3))。现代浏览器等客户端都早已支持这个字段。我们会发现一个细节问题,对基于同一 CDN 的网站的 HTTPS 请求,我们传入的 TLS `SNI` 和 HTTP Header `Host` 会有不一致的情况,在不严格校验 SNI 的情况下,这类请求有可能被路由到 `Host` 所定义的主机上,本质也就无视了 `SNI`,因此对于某些防火墙来说,由于它们能通过 SNI 来侦测到用户所请求的 HTTPS 站点,它们无法得到后续 TLS 握手后的 HTTP 报文内容,在客户端更换了 Header `Host` 后,实际返回的 HTTP 报文内容其实已被调包 —— 这种攻击方式,或者说叫伪装方式被称为[域前置(Domain Fronting)](https://zh.wikipedia.org/zh-cn/%E5%9F%9F%E5%89%8D%E7%BD%AE)技术。CloudFlare、CloudFront 都会校验二者的一致性返回 403,但依然有部分 CDN 对这一做法采取保留,比如 Fastly。 +有点类似于 HTTP 协议中的 `Host` 请求头(如果在同一台服务器上用 Nginx 配置过多个虚拟主机应该都熟悉),但是 SNI 是作用在 L4,而且在 TCP 握手前完成。起初它并不是 TLS 协议的一部分,最早在 2003 年作为扩展字段增加到 TLS 协议中 ([RFC 6066](https://datatracker.ietf.org/doc/html/rfc6066#section-3))。现代浏览器等客户端都早已支持这个字段。我们会发现一个细节问题,对基于同一 CDN 的网站的 HTTPS 请求,我们传入的 TLS `SNI` 和 HTTP Header `Host` 会有不一致的情况,在不严格校验 SNI 的情况下,这类请求有可能被路由到 `Host` 所定义的主机上,本质也就无视了 `SNI`,因此对于某些防火墙来说,由于它们能通过 SNI 来侦测到用户所请求的 HTTPS 站点,它们无法得到后续 TLS 握手后的 HTTP 报文内容,在客户端更换了 Header `Host` 后,实际返回的 HTTP 报文内容其实已被调包 —— 这种攻击方式,或者说叫伪装方式被称为[域前置(Domain Fronting)](https://zh.wikipedia.org/zh-cn/%E5%9F%9F%E5%89%8D%E7%BD%AE)技术。Cloudflare、CloudFront 都会校验二者的一致性返回 403,但依然有部分 CDN 对这一做法采取保留,比如 Fastly。 我们可以用 WireShack 抓包获取到 `SNI` 字段。应用这个过滤条件 `ssl.handshake.extensions_server_name`,尝试抓包发送一次 TLS 请求 @@ -86,7 +86,7 @@ openssl s_client -connect lawrenceli.me:443 -servername lawrenceli.me -state -de ![SNI](/images/cloudflare/sni-field.png) -可以从结果看出,SNI 确实使用了明文进行传输,这就导致了前文提到的一个问题 - 就算经过 TLS/HTTPS 加密的流量,仍然明文地暴露了我们在访问的域名。「这又如何?DNS 不也暴露了嘛?」好问题 - DoH 解决了 DNS 请求的明文风险 ([RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484))。因此,实际上 TLS 目前唯一在数据上有泄密风险的就只有这个字段了。CloudFlare 先后搬出了两个解决方案:[ESNI](https://www.cloudflare-cn.com/learning/ssl/what-is-encrypted-sni/) 以及 [ECH](https://blog.cloudflare.com/encrypted-client-hello/)。 +可以从结果看出,SNI 确实使用了明文进行传输,这就导致了前文提到的一个问题 - 就算经过 TLS/HTTPS 加密的流量,仍然明文地暴露了我们在访问的域名。「这又如何?DNS 不也暴露了嘛?」好问题 - DoH 解决了 DNS 请求的明文风险 ([RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484))。因此,实际上 TLS 目前唯一在数据上有泄密风险的就只有这个字段了。Cloudflare 先后搬出了两个解决方案:[ESNI](https://www.cloudflare-cn.com/learning/ssl/what-is-encrypted-sni/) 以及 [ECH](https://blog.cloudflare.com/encrypted-client-hello/)。 我们可以使用 Chrome 的开关 `chrome://flags/#encrypted-client-hello` 来开启浏览器 ECH 的客户端支持。通过 Chrome DevTool 的 Security Tab 能够查看 HTTPS 流量的安全性信息。我们可以通过[这个链接](https://crypto.cloudflare.com/cdn-cgi/trace)来测试客户端对这个方案的支持情况,当然,这些需要服务端做相应的配置才能完全启用。话题就此结束,我不能再细说了。 @@ -102,30 +102,30 @@ Updated:[2023 年 9 月底,Cloudflare 宣布向所有基于 TLS 1.3 的代
-简而言之,TLS 握手过程中客户端发送的字节数组,也就是 Client Hello 阶段的一些字段和扩展名,通过固定方式拼接,基于摘要 MD5 来生成一个唯一的字符串,称为 JA3 指纹。不同的浏览器或 TLS 客户端有不同指纹。在大量的数据采样中,CloudFlare 就能够基于此数据 (JA3 & JA3S,后者包含了 Server Hello 阶段的服务端指纹) 统计出哪些请求来自于僵尸网络、机器人爬虫、Python 库还是正常用户的浏览器、或者手机访问。这也就解释了很多同学写爬虫时,利用 HTTP 协议更换 `User-Agent` 这一请求头无效的情况,因为 CloudFlare 的防御处在更底层的 L4 TLS 阶段。ChatGPT 的 Web 端也部署了 [CloudFlare 的 TLS JA3 指纹鉴定 WAF (仅限 Enterprise 账户)](https://developers.cloudflare.com/bots/concepts/ja3-fingerprint/);GitHub 上我也找到了相关的代码实现通过更换 TLS Client 的方式来绕过这一防御。对于多数人来说,这已经有很大的防爬门槛了;而且 CloudFlare 可以随时更换 WAF 策略让旧的指纹失效。 +简而言之,TLS 握手过程中客户端发送的字节数组,也就是 Client Hello 阶段的一些字段和扩展名,通过固定方式拼接,基于摘要 MD5 来生成一个唯一的字符串,称为 JA3 指纹。不同的浏览器或 TLS 客户端有不同指纹。在大量的数据采样中,Cloudflare 就能够基于此数据 (JA3 & JA3S,后者包含了 Server Hello 阶段的服务端指纹) 统计出哪些请求来自于僵尸网络、机器人爬虫、Python 库还是正常用户的浏览器、或者手机访问。这也就解释了很多同学写爬虫时,利用 HTTP 协议更换 `User-Agent` 这一请求头无效的情况,因为 Cloudflare 的防御处在更底层的 L4 TLS 阶段。ChatGPT 的 Web 端也部署了 [Cloudflare 的 TLS JA3 指纹鉴定 WAF (仅限 Enterprise 账户)](https://developers.cloudflare.com/bots/concepts/ja3-fingerprint/);GitHub 上我也找到了相关的代码实现通过更换 TLS Client 的方式来绕过这一防御。对于多数人来说,这已经有很大的防爬门槛了;而且 Cloudflare 可以随时更换 WAF 策略让旧的指纹失效。 JA3 由来自 salesforce 的三位工程师共同实现:John Althouse, Jeff Atkinson & Josh Atkins。看到这里,想必你也知道为什么 JA3 叫 `JA3` 了。 ## 盈利模式 -和 Vercel,Netlify 如出一辙,Cloudflare 采用「免费试用,付费增值」的商业模式。CloudFlare CEO Matthew Prince 曾在 StackOverflow 上回答过这个问题:「[How can CloudFlare offer a free CDN with unlimited bandwidth?](https://webmasters.stackexchange.com/questions/88659/how-can-cloudflare-offer-a-free-cdn-with-unlimited-bandwidth)」: +和 Vercel,Netlify 如出一辙,Cloudflare 采用「免费试用,付费增值」的商业模式。Cloudflare CEO Matthew Prince 曾在 StackOverflow 上回答过这个问题:「[How can Cloudflare offer a free CDN with unlimited bandwidth?](https://webmasters.stackexchange.com/questions/88659/how-can-cloudflare-offer-a-free-cdn-with-unlimited-bandwidth)」: - 更多的免费用户意味着更多的数据,这些数据能更好地帮助保护付费用户 -- 很多大客户的来源正是由于这些公司的员工是 CloudFlare 的免费用户,他们在工作中向公司推荐了 CloudFlare +- 很多大客户的来源正是由于这些公司的员工是 Cloudflare 的免费用户,他们在工作中向公司推荐了 Cloudflare - 免费这一举措就是在做宣传,可以减少招聘成本,能雇到全球最厉害的工程师 - 免费用户体验新功能的同时也能就帮助了这个新功能的测试,缩短了迭代周期 - 带宽成本的鸡生蛋、蛋生鸡问题:用户数量庞大才能在面对全球各地的电信营运商时有议价权 -2019 年,CloudFlare 在纽交所上市,股票代码:NET。发行价 US $15,目前 US $63,上涨了 320%。画外音:现在买它还来得及吗? +2019 年,Cloudflare 在纽交所上市,股票代码:NET。发行价 US $15,目前 US $63,上涨了 320%。画外音:现在买它还来得及吗? -在中国目前和京东云合作,仅限企业用户。500 强企业中目前有三分之一使用 CloudFlare,还有很多上升空间。OpenAI 的 [ChatGPT](https://chat.openai.com) 上线后,CloudFlare 获得了大量曝光,防御了大量滥用用户和潜在威胁请求。 +在中国目前和京东云合作,仅限企业用户。500 强企业中目前有三分之一使用 Cloudflare,还有很多上升空间。OpenAI 的 [ChatGPT](https://chat.openai.com) 上线后,Cloudflare 获得了大量曝光,防御了大量滥用用户和潜在威胁请求。 ## 价值观 -CloudFlare 因坚持网络中立原则受到了一些批评。 +Cloudflare 因坚持网络中立原则受到了一些批评。 -比较典型的一件事是 CloudFlare 因舆论和法律的压力[终止对 8chan 的服务](https://blog.cloudflare.com/zh-cn/terminating-service-for-8chan-zh-cn)。CloudFlare 声称自己是一家私营公司,并且 CloudFlare 半数营收来自于美国之外的地区,可以不受美国宪法第一修正案的约束,其服务的客户对象是整个互联网市场。由于业务量大,有些包含恐怖主义、仇恨言论的网站也免不了会使用其服务。这也是大多数大型互联网公司所面临的问题。和快播王欣事件类似,他们都不愿扮演内容仲裁者。互联网诞生至今,法律的步伐总是跟不上技术的发展。 +比较典型的一件事是 Cloudflare 因舆论和法律的压力[终止对 8chan 的服务](https://blog.cloudflare.com/zh-cn/terminating-service-for-8chan-zh-cn)。Cloudflare 声称自己是一家私营公司,并且 Cloudflare 半数营收来自于美国之外的地区,可以不受美国宪法第一修正案的约束,其服务的客户对象是整个互联网市场。由于业务量大,有些包含恐怖主义、仇恨言论的网站也免不了会使用其服务。这也是大多数大型互联网公司所面临的问题。和快播王欣事件类似,他们都不愿扮演内容仲裁者。互联网诞生至今,法律的步伐总是跟不上技术的发展。 ## 尾声 -OpenAI 的 ChatGPT 对 CloudFlare 作了一次很好的展示,我向读者推荐 CloudFlare。一方面是因为它一直提供永久的个人免费服务,另一方面是它的易用性以及全球视野。我也用过国内某厂商的 WAF 产品,界面纷繁错乱,一看账单都不知道为什么收费,套路太深,价格高昂(可能怪我太穷)。 +OpenAI 的 ChatGPT 对 Cloudflare 作了一次很好的展示,我向读者推荐 Cloudflare。一方面是因为它一直提供永久的个人免费服务,另一方面是它的易用性以及全球视野。我也用过国内某套路厂商的 WAF 产品,界面纷繁错乱,一看账单都不知道为什么收费,套路太深,价格高昂(可能怪我太穷)。 diff --git a/posts/how.md b/posts/how.md index 90739039..c114027b 100644 --- a/posts/how.md +++ b/posts/how.md @@ -20,7 +20,7 @@ tags: markdown, example, guide, jamstack, ssg, vercel, nextjs 博客相关的信息配置,如标题、作者等可在 `lib/config.mjs` 文件中配置。 -推荐使用 [pnpm](https://pnpm.io/zh/) 来作为 node.js 的依赖管理工具。 +推荐使用 [pnpm](https://pnpm.io/zh/) 来作为 node.js 的依赖管理工具(相比官方 npm,pnpm 拥有非常大的优势: 速度更快,且节省空间)。 ```shell pnpm install @@ -32,9 +32,11 @@ pnpm dev 通过 `pnpm build` 来打包,`pnpm start` 则用于生产环境的启动。 通过 `pnpm fmt` 来将所有代码和文本进行格式化。 +这篇文章展示了此博客项目所能展示的一切媒体信息,比如代码引用、表格展示、图片、视频、豆瓣卡片等等。 + ## 技术细节 -此站点由 `Next.js` 框架和 `TailwindCSS` 样式构成。前者是一项基于 React 的 SSG/SSR 开源项目,后者是一个目前流行的原子化 CSS 库,让不太会写 CSS、基础薄弱的我也能快速的写出灵活的样式。 +此站点由 Vercel 公司开源的 `Next.js` 框架和 `TailwindCSS` 样式构成。前者是一项基于 React 的 SSG/SSR 开源项目,后者是一个目前流行的原子化 CSS 库,让不太会写 CSS、基础薄弱的我也能快速的写出灵活的样式。 Next.js 会主动调用我们写好的一些函数 (`getStaticProps()`),让组件得到数据的输入,从而**在构建阶段**将 React 组件提前渲染完成。`remark` 库可以将原生的 markdown 语法编译成 html 对应的 dom - 在此项目中,我们让它固定遍历 `posts` 文件夹下的 markdown 文件,依次编译,让其作为 `[id].js` 的动态路由页面的 `props`,从而渲染出博客文章: @@ -88,16 +90,14 @@ Result: ### Images -![Random Image](https://picsum.photos/400/600?grayscale) - -~~我被横线划过。~~ +![Random Image](https://proxy.lawrenceli.me/picsum.photos/400/600?grayscale) ### Video -Same as the movie component: +可以直接通过 `` 组件展示来自于 B 站视频,基于 iframe.
- +