1301
This commit is contained in:
1 parent
167a284da9
commit
865d9ec984
9 files changed
+263
-45
No files matched your search
@@ -2,12 +2,12 @@ node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
logs
|
||||
backups
|
||||
recycle
|
||||
/logs
|
||||
/backups
|
||||
/recycle
|
||||
*.md
|
||||
!package.json
|
||||
!package-lock.json
|
||||
nginx
|
||||
systemd
|
||||
scripts
|
||||
/nginx
|
||||
/systemd
|
||||
/scripts
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8016:8016"
|
||||
- "1313:1313"
|
||||
volumes:
|
||||
# Hugo 博客目录(必须)- 映射宿主机博客根目录到容器内 /blog
|
||||
- ..:/blog
|
||||
|
||||
@@ -48,23 +48,6 @@ server {
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Hugo 预览代理(不需要认证)
|
||||
# Hugo 静态资源
|
||||
location ~ ^/(css|js|font|fonts|image/background|image/avatar|image/reward|emotion)/ {
|
||||
auth_request off;
|
||||
proxy_pass http://write-server:1313;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Hugo 页面预览
|
||||
location /preview/ {
|
||||
auth_request off;
|
||||
rewrite ^/preview/(.*)$ /$1 break;
|
||||
proxy_pass http://write-server:1313;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 所有其他请求通过 auth_request 验证
|
||||
location / {
|
||||
auth_request /auth/internal;
|
||||
|
||||
@@ -39,6 +39,26 @@ h1{font-size:20px;color:#1d3d6c;margin-bottom:4px;font-weight:700}
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
var EXPIRE_MS = 3 * 60 * 60 * 1000; // 3小时过期
|
||||
|
||||
function getAuth() {
|
||||
try {
|
||||
var raw = localStorage.getItem('wauth');
|
||||
if (!raw) return null;
|
||||
var data = JSON.parse(raw);
|
||||
if (!data.token || !data.time) { localStorage.removeItem('wauth'); return null; }
|
||||
if (Date.now() - data.time > EXPIRE_MS) { localStorage.removeItem('wauth'); return null; }
|
||||
return data.token;
|
||||
} catch(e) {
|
||||
localStorage.removeItem('wauth');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setAuth(tok) {
|
||||
localStorage.setItem('wauth', JSON.stringify({ token: tok, time: Date.now() }));
|
||||
}
|
||||
|
||||
document.getElementById('form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('btn');
|
||||
@@ -52,22 +72,16 @@ document.getElementById('form').addEventListener('submit', async function(e) {
|
||||
var tok = btoa(u + ':' + p);
|
||||
|
||||
try {
|
||||
// 验证凭据
|
||||
var res = await fetch('/auth/check', {
|
||||
headers: { 'Authorization': 'Basic ' + tok }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('auth failed');
|
||||
}
|
||||
if (!res.ok) throw new Error('auth failed');
|
||||
|
||||
// 存储凭据
|
||||
localStorage.setItem('wauth', tok);
|
||||
setAuth(tok);
|
||||
|
||||
// 注册 Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
var reg = await navigator.serviceWorker.register('/sw.js');
|
||||
// 等待 SW 激活
|
||||
if (reg.installing) {
|
||||
await new Promise(function(resolve) {
|
||||
reg.installing.addEventListener('statechange', function() {
|
||||
@@ -75,18 +89,13 @@ document.getElementById('form').addEventListener('submit', async function(e) {
|
||||
});
|
||||
});
|
||||
}
|
||||
// 发送 token 给 SW
|
||||
var sw = reg.active || reg.waiting || reg.installing;
|
||||
if (sw) {
|
||||
sw.postMessage({ type: 'SET_TOKEN', token: tok });
|
||||
}
|
||||
// 确保 SW 控制当前页面
|
||||
if (sw) sw.postMessage({ type: 'SET_TOKEN', token: tok });
|
||||
if (navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({ type: 'SET_TOKEN', token: tok });
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到主页
|
||||
location.href = '/';
|
||||
} catch (err2) {
|
||||
err.style.display = 'block';
|
||||
@@ -97,7 +106,7 @@ document.getElementById('form').addEventListener('submit', async function(e) {
|
||||
|
||||
// 已登录则验证后跳转
|
||||
(function(){
|
||||
var tok = localStorage.getItem('wauth');
|
||||
var tok = getAuth();
|
||||
if (!tok) return;
|
||||
fetch('/auth/check', { headers: { 'Authorization': 'Basic ' + tok } }).then(function(r) {
|
||||
if (r.ok) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const CACHE = 'v1';
|
||||
const LOGIN = '/login.html';
|
||||
const EXPIRE_MS = 3 * 60 * 60 * 1000; // 3小时
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
|
||||
@@ -14,10 +15,15 @@ self.addEventListener('fetch', e => {
|
||||
|
||||
e.respondWith((async () => {
|
||||
const token = self.__auth_token;
|
||||
if (!token) {
|
||||
// 尝试从 IndexedDB/localStorage 读取(SW 中无法直接访问 localStorage)
|
||||
// 依赖客户端页面消息传递 token
|
||||
return fetch(req);
|
||||
const tokenTime = self.__auth_time || 0;
|
||||
|
||||
if (!token || (Date.now() - tokenTime > EXPIRE_MS)) {
|
||||
// token 过期或不存在,重定向到登录页
|
||||
if (token) {
|
||||
self.__auth_token = null;
|
||||
self.__auth_time = 0;
|
||||
}
|
||||
return Response.redirect(LOGIN, 302);
|
||||
}
|
||||
|
||||
const headers = new Headers(req.headers);
|
||||
@@ -25,6 +31,8 @@ self.addEventListener('fetch', e => {
|
||||
const res = await fetch(new Request(req, { headers }));
|
||||
|
||||
if (res.status === 401) {
|
||||
self.__auth_token = null;
|
||||
self.__auth_time = 0;
|
||||
return Response.redirect(LOGIN, 302);
|
||||
}
|
||||
return res;
|
||||
@@ -35,8 +43,10 @@ self.addEventListener('fetch', e => {
|
||||
self.addEventListener('message', e => {
|
||||
if (e.data && e.data.type === 'SET_TOKEN') {
|
||||
self.__auth_token = e.data.token;
|
||||
self.__auth_time = Date.now();
|
||||
}
|
||||
if (e.data && e.data.type === 'CLEAR_TOKEN') {
|
||||
self.__auth_token = null;
|
||||
self.__auth_time = 0;
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getWechatStatus, syncWechat } from "@/lib/rssapi";
|
||||
import { getWechatStatus, syncWechat } from "@/lib/wechat";
|
||||
import { marked } from "marked";
|
||||
|
||||
function resolveUrl(src: string, slug: string): string {
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function EditPostPage({
|
||||
const res = await fetch("/api/hugo");
|
||||
const data = await res.json();
|
||||
if (data.running) {
|
||||
window.open(`/preview/${post.frontMatter.slug}.html`, "_blank");
|
||||
window.open(`${location.protocol}//${location.hostname}:1313/${post.frontMatter.slug}.html`, "_blank");
|
||||
} else {
|
||||
alert("Hugo 预览启动失败");
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function HomePage() {
|
||||
const res = await fetch("/api/hugo");
|
||||
const data = await res.json();
|
||||
if (data.running) {
|
||||
window.open(`/preview/${slug}.html`, "_blank");
|
||||
window.open(`${location.protocol}//${location.hostname}:1313/${slug}.html`, "_blank");
|
||||
} else {
|
||||
alert("Hugo 预览启动失败,请稍后再试");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { WECHAT_APP_ID, WECHAT_APP_SECRET } from "./config";
|
||||
|
||||
// ---- Token 管理 ----
|
||||
|
||||
interface TokenCache {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let tokenCache: TokenCache | null = null;
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
if (tokenCache && Date.now() < tokenCache.expiresAt) {
|
||||
return tokenCache.token;
|
||||
}
|
||||
|
||||
if (!WECHAT_APP_ID || !WECHAT_APP_SECRET) {
|
||||
throw new Error("微信 AppID 或 AppSecret 未配置");
|
||||
}
|
||||
|
||||
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${WECHAT_APP_ID}&secret=${WECHAT_APP_SECRET}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json() as { access_token?: string; expires_in?: number; errcode?: number; errmsg?: string };
|
||||
|
||||
if (data.errcode) {
|
||||
throw new Error(`获取 access_token 失败: ${data.errmsg} (${data.errcode})`);
|
||||
}
|
||||
|
||||
if (!data.access_token) {
|
||||
throw new Error("获取 access_token 失败: 返回为空");
|
||||
}
|
||||
|
||||
// 提前 5 分钟过期
|
||||
tokenCache = {
|
||||
token: data.access_token,
|
||||
expiresAt: Date.now() + (data.expires_in! - 300) * 1000,
|
||||
};
|
||||
|
||||
return tokenCache.token;
|
||||
}
|
||||
|
||||
// ---- 状态检查 ----
|
||||
|
||||
export async function getWechatStatus(): Promise<{ configured: boolean; appId?: string }> {
|
||||
return {
|
||||
configured: !!(WECHAT_APP_ID && WECHAT_APP_SECRET),
|
||||
appId: WECHAT_APP_ID ? WECHAT_APP_ID.slice(0, 6) + "****" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 上传封面图片 ----
|
||||
|
||||
export async function uploadImage(imageUrl: string): Promise<string> {
|
||||
const token = await getAccessToken();
|
||||
|
||||
// 下载图片
|
||||
const imgRes = await fetch(imageUrl);
|
||||
if (!imgRes.ok) throw new Error(`下载图片失败: ${imgRes.status}`);
|
||||
const imgBuffer = await imgRes.arrayBuffer();
|
||||
|
||||
// 从 URL 推断文件名
|
||||
const urlParts = imageUrl.split("/");
|
||||
const filename = urlParts[urlParts.length - 1] || "cover.jpg";
|
||||
|
||||
// 构造 multipart/form-data
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([imgBuffer], { type: "image/jpeg" });
|
||||
formData.append("media", blob, filename);
|
||||
|
||||
const uploadUrl = `https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${token}&type=image`;
|
||||
const uploadRes = await fetch(uploadUrl, { method: "POST", body: formData });
|
||||
const uploadData = await uploadRes.json() as { media_id?: string; url?: string; errcode?: number; errmsg?: string };
|
||||
|
||||
if (uploadData.errcode) {
|
||||
throw new Error(`上传图片失败: ${uploadData.errmsg} (${uploadData.errcode})`);
|
||||
}
|
||||
|
||||
return uploadData.media_id!;
|
||||
}
|
||||
|
||||
// ---- 上传文章内图片(用于正文中引用的图片) ----
|
||||
|
||||
export async function uploadContentImage(imageUrl: string): Promise<string> {
|
||||
const token = await getAccessToken();
|
||||
|
||||
const imgRes = await fetch(imageUrl);
|
||||
if (!imgRes.ok) throw new Error(`下载图片失败: ${imgRes.status}`);
|
||||
const imgBuffer = await imgRes.arrayBuffer();
|
||||
|
||||
const urlParts = imageUrl.split("/");
|
||||
const filename = urlParts[urlParts.length - 1] || "image.jpg";
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([imgBuffer], { type: "image/jpeg" });
|
||||
formData.append("media", blob, filename);
|
||||
|
||||
const uploadUrl = `https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${token}`;
|
||||
const uploadRes = await fetch(uploadUrl, { method: "POST", body: formData });
|
||||
const uploadData = await uploadRes.json() as { url?: string; errcode?: number; errmsg?: string };
|
||||
|
||||
if (uploadData.errcode) {
|
||||
throw new Error(`上传内容图片失败: ${uploadData.errmsg} (${uploadData.errcode})`);
|
||||
}
|
||||
|
||||
return uploadData.url!;
|
||||
}
|
||||
|
||||
// ---- 替换文章中的图片为微信 URL ----
|
||||
|
||||
async function replaceContentImages(html: string, imageUrls: string[]): Promise<string> {
|
||||
let result = html;
|
||||
for (const url of imageUrls) {
|
||||
try {
|
||||
const wxUrl = await uploadContentImage(url);
|
||||
result = result.replaceAll(url, wxUrl);
|
||||
} catch (e) {
|
||||
console.warn(`[wechat] 跳过图片 ${url}:`, e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- 创建草稿 ----
|
||||
|
||||
interface Article {
|
||||
title: string;
|
||||
author?: string;
|
||||
content: string;
|
||||
content_source_url?: string;
|
||||
thumb_media_id?: string;
|
||||
thumb_media_url?: string;
|
||||
show_cover_pic?: number;
|
||||
need_open_comment?: number;
|
||||
content_image_urls?: { original_url: string }[];
|
||||
}
|
||||
|
||||
interface DraftResult {
|
||||
ok: boolean;
|
||||
media_id?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function syncWechat(articles: Article[]): Promise<DraftResult> {
|
||||
const token = await getAccessToken();
|
||||
|
||||
const processedArticles: Record<string, unknown>[] = [];
|
||||
|
||||
for (const article of articles) {
|
||||
let content = article.content;
|
||||
|
||||
// 替换内容中的图片为微信 URL
|
||||
if (article.content_image_urls && article.content_image_urls.length > 0) {
|
||||
const urls = article.content_image_urls.map(u => u.original_url);
|
||||
content = await replaceContentImages(content, urls);
|
||||
}
|
||||
|
||||
const processed: Record<string, unknown> = {
|
||||
title: article.title,
|
||||
author: article.author || "",
|
||||
content: content,
|
||||
content_source_url: article.content_source_url || "",
|
||||
show_cover_pic: article.show_cover_pic ?? 1,
|
||||
need_open_comment: article.need_open_comment ?? 0,
|
||||
};
|
||||
|
||||
// 上传封面图
|
||||
if (article.thumb_media_url) {
|
||||
try {
|
||||
const mediaId = await uploadImage(article.thumb_media_url);
|
||||
processed.thumb_media_id = mediaId;
|
||||
} catch (e) {
|
||||
console.warn("[wechat] 封面图上传失败:", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
} else if (article.thumb_media_id) {
|
||||
processed.thumb_media_id = article.thumb_media_id;
|
||||
}
|
||||
|
||||
processedArticles.push(processed);
|
||||
}
|
||||
|
||||
// 创建草稿
|
||||
const draftUrl = `https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${token}`;
|
||||
const draftRes = await fetch(draftUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ articles: processedArticles }),
|
||||
});
|
||||
const draftData = await draftRes.json() as { media_id?: string; errcode?: number; errmsg?: string };
|
||||
|
||||
if (draftData.errcode) {
|
||||
return { ok: false, message: `创建草稿失败: ${draftData.errmsg} (${draftData.errcode})` };
|
||||
}
|
||||
|
||||
return { ok: true, media_id: draftData.media_id };
|
||||
}
|
||||
|
||||
// ---- 发布草稿 ----
|
||||
|
||||
export async function publishDraft(mediaId: string): Promise<DraftResult> {
|
||||
const token = await getAccessToken();
|
||||
|
||||
const url = `https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token=${token}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ media_id: mediaId }),
|
||||
});
|
||||
const data = await res.json() as { publish_id?: string; errcode?: number; errmsg?: string };
|
||||
|
||||
if (data.errcode) {
|
||||
return { ok: false, message: `发布失败: ${data.errmsg} (${data.errcode})` };
|
||||
}
|
||||
|
||||
return { ok: true, media_id: data.publish_id };
|
||||
}
|
||||
Reference in new issue
Block a user