After moving my blog back to Hugo, I also brought GoToSocial back online. The idea was simple: I wanted a place for short posts and scattered thoughts. I spent a while deciding between memos and GoToSocial, and ended up choosing GoToSocial mainly because I had seen memos change its API rather casually. To avoid unnecessary trouble later, I preferred the more stable option.
The end result is a dedicated page on the blog that can display my posts directly.
The basic approach
GoToSocial enables fairly strict CORS restrictions by default, and reading content requires a token. Since Hugo is a static site generator, putting that token directly in front-end code would be a bad idea.
So the setup needs a small middle layer.
A Cloudflare Worker fits this job nicely:
- the Hugo front end sends requests to the Worker
- the Worker requests GoToSocial data from
https://your-domain/api/v1/accounts/.../statuses - the Worker returns JSON back to the Hugo front end
- Hugo renders the page around that data
This way the token stays on the Worker side instead of being exposed in the browser.
Getting the GoToSocial toot API working
What needs to happen is straightforward:
- register an application at
https://your-domain/api/v1/apps - authorize it in the browser
- exchange the authorization code for an
access_token - store that token as an environment variable in Cloudflare Workers
- let the Hugo site fetch toot data safely through the Worker
Register a new application
Run this in the terminal with curl, and remember to change the app name if you want:
curl -X POST \
-H "Content-Type: application/json" \
-d '{
"client_name": "hugo-gts-proxy",
"redirect_uris": "urn:ietf:wg:oauth:2.0:oob",
"scopes": "read"
}' \
'https://你的域名/api/v1/apps'
A successful response looks like this:
{
"id": "01J1CYJ4QRNFZD6WHQMZV7248G",
"name": "hugo-gts-proxy",
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
"client_id": "xxxxxxxxxxxxxxxx",
"client_secret": "yyyyyyyyyyyyyyyy"
}
Save client_id and client_secret somewhere safe.
Get the authorization code
Open this URL in your browser, replacing YOUR_CLIENT_ID with the value you just received:
https://你的域名/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read
You will be asked to log in and authorize the app.
After clicking Allow, the page will show:
Here's your out-of-band token: YOUR_AUTHORIZATION_CODE
Copy that YOUR_AUTHORIZATION_CODE.
Exchange the code for an access token
Then run:
curl -X POST \
-H "Content-Type: application/json" \
-d '{
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"grant_type": "authorization_code",
"code": "YOUR_AUTHORIZATION_CODE"
}' \
'https://你的域名/oauth/token'
The response should look like this:
{
"access_token": "YOUR_ACCESS_TOKEN",
"created_at": 1729436650,
"scope": "read",
"token_type": "Bearer"
}
That access_token is the one we need.
Check that the token works
You can verify it with:
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
'https://你的域名/api/v1/accounts/verify_credentials'
If it returns your account profile information, such as username, id, and url, then the token is valid.
Configuring the Cloudflare Worker
In Cloudflare, open your Worker and go to:
Settings → Variables → Add Variable
Set:
Name: GTS_TOKEN
Value: YOUR_ACCESS_TOKEN
Save it.
Here is the Worker code example:
export default {
async fetch(request, env) {
const url = new URL(request.url);
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
if (request.method === "OPTIONS") {
return new Response(null, { headers });
}
const target = `https://你的域名${url.pathname}${url.search}`;
const resp = await fetch(target, {
headers: {
"Authorization": "Bearer " + env.GTS_TOKEN,
"User-Agent": "GTS-Proxy-Worker",
},
});
if (!resp.ok) {
return new Response(await resp.text(), {
status: resp.status,
headers: { ...headers, "Content-Type": "application/json" },
});
}
const data = await resp.json();
// 并行拉取每条嘟文的回复
const statuses = await Promise.all(data.map(async (status) => {
let replies = [];
try {
const ctx = await fetch(`https://你的域名/api/v1/statuses/${status.id}/context`, {
headers: {
"Authorization": "Bearer " + env.GTS_TOKEN,
},
});
if (ctx.ok) {
const contextData = await ctx.json();
replies = contextData?.descendants?.map(r => ({
id: r.id,
content: r.content,
account: {
username: r.account?.username,
display_name: r.account?.display_name,
avatar: r.account?.avatar,
}
})) || [];
}
} catch (err) {
console.log("Reply fetch failed:", err);
}
return {
id: status.id,
created_at: status.created_at,
content: status.content,
url: status.url,
account: {
username: status.account?.username,
display_name: status.account?.display_name,
avatar: status.account?.avatar,
},
replies_count: status.replies_count || 0,
reblogs_count: status.reblogs_count || 0,
favourites_count: status.favourites_count || 0,
media_attachments: (status.media_attachments || []).map(media => ({
url: media.url?.startsWith("/") ? "https://你的域名" + media.url : media.url,
preview_url: media.preview_url?.startsWith("/") ? "https://你的域名" + media.preview_url : media.preview_url,
})),
replies,
};
}));
return new Response(JSON.stringify(statuses, null, 2), {
headers: { ...headers, "Content-Type": "application/json" },
});
},
};
A notable detail here is that the Worker not only proxies the timeline request, it also fetches each toot's reply context in parallel and merges those replies into the returned JSON. That avoids exposing the token on the client side while still letting the page show reply threads.
It also normalizes media URLs when GoToSocial returns relative paths, so image links continue to work correctly in Hugo.
Hugo template setup
Create these two files in the root of your Hugo project:
content/toots/_index.md
layouts/_default/toots.html
content/toots/_index.md
---
title: "我的嘟文"
description: "来自 GoToSocial 的最新动态"
---
layouts/_default/toots.html
The template below does the rendering work on the page itself. It loads the JSON returned by the Worker, displays posts in batches, shows media in a grid, and includes a simple lightbox for images. Replies are taken directly from the Worker response, so there is no separate loadReplies() call on the front end anymore.
{{ define "main" }}
<main class="main post-single" id="toots-page">
<header class="page-header">
<h1>{{ .Title }}</h1>
{{ with .Description }}
<p class="page-description">{{ . }}</p>
{{ end }}
</header>
<div id="toots-container" class="toots"></div>
<div class="load-more-wrapper">
<button id="load-more-btn">加载更多嘟文</button>
</div>
<script>
let tootsData = [];
let displayedCount = 0;
const pageSize = 5; // 每次加载条数
// ✅ 加载嘟文
async function loadToots(initial=false) {
if (initial) {
// 请注意:如果您要使用 Cloudflare Worker,这里的 URL 应该是您的 Worker URL,而不是原始 Mastodon/GoToSocial 实例的 URL。
// 假设您的 Worker 地址是 https://worker.yourdomain.com/api/v1/accounts/01M4A5Q58VD6GJH97T2TE6W25J/statuses?exclude_reblogs=true
const url = "https://toots.iliu.org/api/v1/accounts/01M4A5Q58VD6GJH97T2TE6W25J/statuses?exclude_reblogs=true";
const res = await fetch(url);
tootsData = await res.json();
}
const container = document.getElementById("toots-container");
const nextToots = tootsData.slice(displayedCount, displayedCount + pageSize);
for (const t of nextToots) {
const date = new Date(t.created_at).toLocaleString();
const username = t.account?.display_name || t.account?.username || "匿名";
const avatar = t.account?.avatar || "https://l22.org/path-to-default-avatar.png";
// ✅ 九宫格多图布局
let mediaHTML = "";
if (t.media_attachments && t.media_attachments.length > 0) {
mediaHTML = `
<div class="toot-media-grid">
${t.media_attachments.map(m => `
<div class="toot-media-item">
<img src="${m.preview_url || m.url}" alt="${m.description || ''}" loading="lazy" onclick="showLightbox('${m.url}')">
</div>
`).join('')}
</div>
`;
}
// 🚨 【修复点】直接使用 Worker 预取的 t.replies 数据来构建回复内容
let repliesHTML = "";
if (t.replies && t.replies.length > 0) {
repliesHTML = `
<div class="toot-replies">
${t.replies.map(r => {
const avatar = r.account?.avatar || "https://l22.org/path-to-default-avatar.png";
const name = r.account?.display_name || r.account?.username || "匿名";
return `
<div class="toot-reply">
<img src="${avatar}" class="toot-reply-avatar" alt="${name}">
<div class="toot-reply-body">
<strong>${name}</strong>:${r.content}
</div>
</div>
`;
}).join('')}
</div>
`;
} else if (t.replies_count > 0) {
// 如果 Worker 没有返回 replies 但计数大于 0,可能是 Worker 拉取回复失败
repliesHTML = `<div class="toot-replies"><p class='no-reply'>暂无回复(或Worker拉取失败)</p></div>`;
}
const tootHTML = `
<article class="toot-card">
<div class="toot-header">
<img class="toot-avatar" src="${avatar}" alt="${username}">
<span class="toot-username">${username}</span>
</div>
<div class="toot-date">
<a href="${t.url}" target="_blank">${date}</a>
</div>
<div class="toot-content">${t.content}</div>
${mediaHTML}
<div class="toot-footer">
❤️ ${t.favourites_count} 🔁 ${t.reblogs_count} 💬 ${t.replies_count}
</div>
${repliesHTML}
</article>
`;
container.insertAdjacentHTML("beforeend", tootHTML);
// 🚨 【修复点】移除了原有的 loadReplies(t.id) 调用
}
displayedCount += pageSize;
document.getElementById("load-more-btn").style.display =
displayedCount >= tootsData.length ? "none" : "inline-block";
}
// 🚨 【修复点】移除了 loadReplies 函数
// ✅ 初次加载
document.getElementById("load-more-btn").onclick = () => loadToots();
loadToots(true);
// ✅ 简易图片灯箱
function showLightbox(src) {
let lightbox = document.getElementById("lightbox");
if (!lightbox) {
lightbox = document.createElement("div");
lightbox.id = "lightbox";
lightbox.innerHTML = `<img id="lightbox-img"><span id="lightbox-close">×</span>`;
document.body.appendChild(lightbox);
document.getElementById("lightbox-close").onclick = () => lightbox.classList.remove("show");
lightbox.onclick = e => {
if (e.target === lightbox) lightbox.classList.remove("show");
};
}
document.getElementById("lightbox-img").src = src;
lightbox.classList.add("show");
}
</script>
<style>
/* 容器样式 */
#toots-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 2rem;
}
/* 嘟文卡片 */
.toot-card {
background: var(--entry);
border-radius: var(--radius);
padding: 1rem 1.5rem;
box-shadow: var(--shadow);
transition: transform .2s ease, box-shadow .2s ease;
}
.toot-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
}
.toot-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.toot-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.toot-username {
font-weight: bold;
color: var(--primary);
}
.toot-date {
font-size: .85rem;
color: var(--secondary);
margin-bottom: .25rem;
}
.toot-content {
font-size: 1rem;
color: var(--primary);
line-height: 1.6;
overflow-wrap: break-word;
}
/* 九宫格多图 */
.toot-media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 4px;
margin-top: .5rem;
}
.toot-media-item img {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 6px;
cursor: pointer;
transition: transform .2s ease, opacity .3s ease;
}
.toot-media-item img:hover {
transform: scale(1.05);
opacity: .9;
}
/* 回复样式 */
.toot-replies {
margin-top: .75rem;
border-left: 2px solid var(--border);
padding-left: .75rem;
}
.toot-reply {
display: flex;
align-items: flex-start;
gap: .5rem;
margin-top: .5rem;
}
.toot-reply-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
}
.toot-reply-body {
font-size: .9rem;
line-height: 1.4;
}
.no-reply {
color: var(--secondary);
font-size: .85rem;
}
/* 加载更多按钮 */
.load-more-wrapper {
text-align: center;
margin: 2rem 0;
}
#load-more-btn {
padding: .6rem 1.5rem;
border: none;
border-radius: 9999px;
background: linear-gradient(90deg, #1e90ff, #0066cc);
color: #fff;
font-size: 1rem;
cursor: pointer;
box-shadow: 0 3px 10px rgba(0,0,0,.1);
transition: all .3s ease;
}
#load-more-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,.2);
}
/* 灯箱样式 */
#lightbox {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
display: none;
justify-content: center;
align-items: center;
z-index: 9999;
}
#lightbox.show {
display: flex;
}
#lightbox img {
max-width: 90%;
max-height: 85%;
border-radius: 8px;
box-shadow: 0 0 15px rgba(0,0,0,.5);
}
#lightbox-close {
position: absolute;
top: 20px;
right: 30px;
font-size: 2rem;
color: #fff;
cursor: pointer;
}
@media (prefers-color-scheme: dark) {
.toot-card {
background: #1c1c1c;
}
}
</style>
</main>
{{ end }}
I kept the CSS and template together here. If you prefer a cleaner layout, you can split the CSS into a separate file.
Generate the page
Run:
hugo server
Then open:
http://localhost:1313/toots/
You should see the toot list load automatically.
After deployment, the same page will be available at:
https://你的博客域名/toots/
That is basically the whole flow: use Hugo for the static site, let a Cloudflare Worker hold the token and proxy requests, and have the template render posts, media, and replies without exposing credentials in the browser.