Compare commits
2
Commits
4b2ab00e6c
...
4e9a13981d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e9a13981d | ||
|
|
e6ebdb0576 |
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
data
|
||||
public/uploads
|
||||
@@ -0,0 +1,12 @@
|
||||
DATABASE_PATH=/app/data/hub.db
|
||||
SESSION_SECRET=replace-with-a-long-random-secret
|
||||
TOKEN_ENCRYPTION_KEY=replace-with-64-hex-characters
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-before-first-start
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
UPLOAD_MAX_BYTES=10485760
|
||||
SYNC_INTERVAL_MINUTES=60
|
||||
# Optional: create the first Memos source for the bootstrap admin.
|
||||
SEED_MEMOS_NAME=
|
||||
SEED_MEMOS_URL=
|
||||
SEED_MEMOS_TOKEN=
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.next/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
public/uploads/*
|
||||
!public/uploads/.gitkeep
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /app/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/lib ./lib
|
||||
COPY --from=build /app/worker ./worker
|
||||
COPY --from=build /app/tsconfig.json ./tsconfig.json
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
@@ -1,2 +1,118 @@
|
||||
# Mebbling
|
||||
|
||||
自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。
|
||||
|
||||
## 功能
|
||||
|
||||
- 匯入多個 Memos 來源的 `PUBLIC` 貼文、標籤與附件。
|
||||
- 圖片在首頁與文章頁直接顯示;點擊後以站內全螢幕燈箱檢視。
|
||||
- Hub 使用者可留言、表情回應,並可將新貼文推送到已連接的 Memos。
|
||||
- 同一個「Memos 網址 + Memos 帳號」只建立一個共享來源,避免重複同步及重複貼文。
|
||||
- 使用者可加入共享來源並手動同步或發文;僅來源建立者能管理 webhook URL。
|
||||
- Webhook URL 採不可猜測的隨機密鑰路徑、雜湊保存與簡易速率限制。
|
||||
- 控制台會顯示最近一次收到 webhook 的時間及最後同步時間。
|
||||
|
||||
## 快速啟動(WSL/Docker)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 編輯 .env,至少設定 SESSION_SECRET、TOKEN_ENCRYPTION_KEY、ADMIN_USERNAME、ADMIN_PASSWORD
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
預設網址為 [http://localhost:8088](http://localhost:8088)。停止服務:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
查看服務狀態與日誌:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f web worker
|
||||
```
|
||||
|
||||
## 環境變數
|
||||
|
||||
以 `.env.example` 為範本。正式環境請更換所有 secret,且不要將 `.env` 加入 Git。
|
||||
|
||||
| 變數 | 用途 |
|
||||
| --- | --- |
|
||||
| `SESSION_SECRET` | 登入 session 的簽章密鑰。請使用長隨機字串。 |
|
||||
| `TOKEN_ENCRYPTION_KEY` | Memos Token 的 AES-256-GCM 加密金鑰,必須為 64 個十六進位字元。 |
|
||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 首次啟動時建立的管理員帳號。 |
|
||||
| `NEXT_PUBLIC_APP_URL` | Hub 的對外 HTTPS 網址,例如 `https://mebbling.example.com`。Webhook URL 以此組成。 |
|
||||
| `UPLOAD_MAX_BYTES` | Hub 發文上傳附件的單檔上限,預設 10 MiB。 |
|
||||
| `SYNC_INTERVAL_MINUTES` | 背景校正同步的間隔,預設 60 分鐘。 |
|
||||
| `SEED_MEMOS_*` | 選填;首次啟動時自動建立管理員的第一個 Memos 來源。 |
|
||||
|
||||
## 系統架構
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Visitor[訪客/Hub 使用者] --> Web[Next.js Web\nport 8088]
|
||||
Web --> DB[(SQLite\ndata/hub.db)]
|
||||
Web --> Uploads[附件\npublic/uploads]
|
||||
Memos[Memos 來源] -->|Webhook| Web
|
||||
Web -->|建立同步工作| Jobs[同步佇列\nsync_jobs]
|
||||
Worker[背景 Worker] --> Jobs
|
||||
Worker --> DB
|
||||
Worker <-->|Memos API| Memos
|
||||
Worker --> Uploads
|
||||
```
|
||||
|
||||
Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jobs`。背景 Worker 每 5 秒處理一項工作,負責:
|
||||
|
||||
- **Pull**:從 Memos 取得公開貼文,更新 Hub 鏡像;已刪除或非公開的遠端貼文會在 Hub 隱藏。
|
||||
- **Push**:把 Hub 建立的貼文與本機附件上傳/回寫到選定的 Memos 來源。
|
||||
- **排程校正**:依 `SYNC_INTERVAL_MINUTES` 定期建立 Pull 工作,避免 webhook 遺漏造成資料不同步。
|
||||
|
||||
## Webhook 設定與驗證
|
||||
|
||||
1. 來源建立者登入「控制台」。
|
||||
2. 在來源卡片按「產生 webhook URL」,立即複製完整網址。
|
||||
3. 在該 Memos 帳號的 webhook 設定中貼上網址。
|
||||
4. 在 Memos 發布或更新一篇公開貼文。
|
||||
5. 回到 Hub:顯示「最近收到」代表 Hub 確實收到 webhook;「上次同步」更新則代表同步已完成。
|
||||
|
||||
網址格式如下;`來源 ID` 與 `隨機密鑰` 都由系統產生,請勿自行修改:
|
||||
|
||||
```text
|
||||
https://你的網域/api/sync/webhook/來源ID/隨機密鑰
|
||||
```
|
||||
|
||||
重新產生 webhook URL 會立即使舊 URL 失效。共享來源的其他成員可查看接收狀態,但沒有產生或輪替密鑰的權限。
|
||||
|
||||
## 專案結構
|
||||
|
||||
```text
|
||||
.
|
||||
├── app/ # Next.js App Router:頁面、元件與 API
|
||||
│ ├── api/ # 登入、來源、發文、同步、webhook 等路由
|
||||
│ ├── components/ # 可重用 UI,例如附件圖片燈箱
|
||||
│ ├── dashboard/ # 來源管理、發文與 webhook 控制台
|
||||
│ ├── posts/[id]/ # 單篇貼文頁
|
||||
│ ├── page.tsx # 公開貼文首頁
|
||||
│ ├── layout.tsx # 全站版型與導覽
|
||||
│ └── styles.css # 全站樣式
|
||||
├── lib/ # 共用伺服器邏輯
|
||||
│ ├── auth.ts # Session 與權限
|
||||
│ ├── crypto.ts # Token 加解密
|
||||
│ ├── db.ts # SQLite schema 與輕量遷移
|
||||
│ ├── memos.ts # Memos API 封裝
|
||||
│ ├── webhook.ts # Webhook 密鑰產生、雜湊與驗證
|
||||
│ └── rate-limit.ts # Webhook 簡易速率限制
|
||||
├── worker/ # 背景同步 worker
|
||||
├── public/uploads/ # Hub 上傳附件的持久化資料
|
||||
├── data/ # SQLite 資料庫持久化資料
|
||||
├── Dockerfile # Web/Worker 共用映像檔
|
||||
├── docker-compose.yml # web + worker 服務與 volume 掛載
|
||||
└── .env.example # 環境變數範本
|
||||
```
|
||||
|
||||
`data/` 與 `public/uploads/` 是正式資料,備份時請一併備份。`.next/` 與 `node_modules/` 是可重新產生的建置/依賴資料,不需備份。
|
||||
|
||||
## 正式部署
|
||||
|
||||
將 `NEXT_PUBLIC_APP_URL` 設成實際 HTTPS 網域,並以反向代理將該網域導向 Web 容器的 3000 連接埠(或主機的 8088 對應埠)。務必確保外部可連到 webhook URL,否則仍會由定期校正同步補回資料,但不會即時更新。
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http";
|
||||
export async function POST(req: Request) { const form = await req.formData(); const username=String(form.get("username")||""); const password=String(form.get("password")||""); const user=db.prepare("SELECT id,username,password_hash,role,disabled FROM users WHERE username=?").get(username) as any;
|
||||
if (!user || user.disabled || !(await bcrypt.compare(password,user.password_hash))) return NextResponse.redirect(externalUrl(req,"/login?error=invalid")); await createSession({id:user.id,username:user.username,role:user.role}); return NextResponse.redirect(externalUrl(req,"/dashboard")); }
|
||||
@@ -0,0 +1 @@
|
||||
import { NextResponse } from "next/server"; import { clearSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http"; export async function POST(req:Request){await clearSession();return NextResponse.redirect(externalUrl(req,"/"));}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http";
|
||||
export async function POST(req: Request) { const form = await req.formData(); const username = String(form.get("username") || "").trim(); const password = String(form.get("password") || "");
|
||||
if (!/^[A-Za-z0-9_-]{3,32}$/.test(username) || password.length < 10) return NextResponse.redirect(externalUrl(req,"/register?error=invalid"));
|
||||
try { const out = db.prepare("INSERT INTO users(username,password_hash) VALUES (?,?)").run(username, await bcrypt.hash(password, 12)); await createSession({ id: Number(out.lastInsertRowid), username, role: "user" }); return NextResponse.redirect(externalUrl(req,"/dashboard")); } catch { return NextResponse.redirect(externalUrl(req,"/register?error=taken")); }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http";
|
||||
export async function POST(req:Request){try{const user=await requireUser();const f=await req.formData();const postId=Number(f.get('postId'));const content=String(f.get('content')||'').trim();if(!postId||!content||content.length>5000)throw new Error('Invalid comment');db.prepare('INSERT INTO comments(post_id,author_id,content) VALUES(?,?,?)').run(postId,user.id,content);return NextResponse.redirect(externalUrl(req,`/posts/${postId}`));}catch{return NextResponse.redirect(externalUrl(req,'/'));}}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http"; import { mkdir, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { extname, join } from "node:path";
|
||||
export async function POST(req:Request){const json=req.headers.get("accept")?.includes("application/json");try{const user=await requireUser();const f=await req.formData();const content=String(f.get("content")||"").trim();const visibility=String(f.get("visibility")||"PUBLIC");const sourceId=Number(f.get("sourceId"));const tags=String(f.get("tags")||"").split(/\s*,\s*/).filter(Boolean).map(t=>t.replace(/^#/,""));if(!content||!['PRIVATE','PROTECTED','PUBLIC'].includes(visibility)||!sourceId)throw new Error("Invalid post");const source=db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=?").get(sourceId,user.id);if(!source)throw new Error("Source not found");const max=Number(process.env.UPLOAD_MAX_BYTES||10485760);const files=f.getAll('attachments').filter((x):x is File=>x instanceof File&&x.size>0);const attachments:any[]=[];await mkdir(join(process.cwd(),'public','uploads'),{recursive:true});for(const file of files){if(file.size>max)throw new Error(`${file.name} exceeds upload limit`);const id=randomUUID()+extname(file.name);await writeFile(join(process.cwd(),'public','uploads',id),Buffer.from(await file.arrayBuffer()));attachments.push({name:file.name,url:`/uploads/${id}`,type:file.type,size:file.size});}const out=db.prepare("INSERT INTO posts(source_id,author_id,content,visibility,tags_json,attachments_json,origin,sync_status) VALUES(?,?,?,?,?,?,'hub','queued')").run(sourceId,user.id,content,visibility,JSON.stringify(tags),JSON.stringify(attachments));db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json) VALUES(?, 'push', ?)").run(sourceId,JSON.stringify({postId:out.lastInsertRowid}));if(json)return NextResponse.json({id:Number(out.lastInsertRowid)},{status:201});return NextResponse.redirect(externalUrl(req,`/posts/${out.lastInsertRowid}`));}catch(e){const message=e instanceof Error?e.message:'post';if(json)return NextResponse.json({error:message},{status:400});return NextResponse.redirect(externalUrl(req,'/dashboard?error='+encodeURIComponent(message)));}}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http";
|
||||
const allowed=new Set(['👍','❤️','🎉','🤔']);export async function POST(req:Request){try{const user=await requireUser();const f=await req.formData();const postId=Number(f.get('postId'));const emoji=String(f.get('emoji'));if(!postId||!allowed.has(emoji))throw 0;const found=db.prepare('SELECT 1 FROM reactions WHERE post_id=? AND user_id=? AND emoji=?').get(postId,user.id,emoji);if(found)db.prepare('DELETE FROM reactions WHERE post_id=? AND user_id=? AND emoji=?').run(postId,user.id,emoji);else db.prepare('INSERT INTO reactions(post_id,user_id,emoji) VALUES(?,?,?)').run(postId,user.id,emoji);return NextResponse.redirect(externalUrl(req,`/posts/${postId}`));}catch{return NextResponse.redirect(externalUrl(req,'/'));}}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId);
|
||||
const source = db.prepare("SELECT id FROM sources WHERE id=? AND user_id=?").get(id, user.id);
|
||||
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
const secret = createWebhookSecret();
|
||||
db.prepare("UPDATE sources SET webhook_secret_hash=? WHERE id=?").run(webhookSecretHash(secret), id);
|
||||
const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, "");
|
||||
return NextResponse.json({ url: `${publicOrigin}/api/sync/webhook/${id}/${secret}` });
|
||||
} catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { decrypt, encrypt } from "@/lib/crypto"; import { getMemosIdentity, verifyMemos } from "@/lib/memos"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http";
|
||||
export async function POST(req: Request) { try { const user=await requireUser(); const form=await req.formData(); const name=String(form.get("name")||"").trim(); const rawBaseUrl=String(form.get("baseUrl")||"").trim(); const token=String(form.get("token")||"").trim(); let baseUrl=""; try { const url=new URL(rawBaseUrl); if(!['http:','https:'].includes(url.protocol)) throw new Error(); baseUrl=`${url.origin}${url.pathname.replace(/\/+$/,"")}`; } catch { throw new Error("Invalid source URL"); } if(!name||token.length<20) throw new Error("Invalid source"); await verifyMemos(baseUrl,token); const identity=await getMemosIdentity(baseUrl,token);
|
||||
const legacySources=db.prepare("SELECT id,token_encrypted FROM sources WHERE base_url=? AND remote_user IS NULL").all(baseUrl) as {id:number;token_encrypted:string}[];
|
||||
for(const legacy of legacySources){try{const legacyIdentity=await getMemosIdentity(baseUrl,decrypt(legacy.token_encrypted));db.prepare("UPDATE sources SET remote_user=? WHERE id=? AND remote_user IS NULL").run(legacyIdentity.name,legacy.id);}catch{ /* Keep unavailable legacy sources unchanged. */ }}
|
||||
const shared=db.prepare("SELECT id FROM sources WHERE base_url=? AND remote_user=?").get(baseUrl,identity.name) as {id:number}|undefined;
|
||||
if(shared){db.prepare("INSERT OR IGNORE INTO source_members(source_id,user_id) VALUES(?,?)").run(shared.id,user.id);return NextResponse.redirect(externalUrl(req,"/dashboard?source=shared"));}
|
||||
const out=db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,remote_user,sync_status) VALUES(?,?,?,?,?, 'queued')").run(user.id,name,baseUrl,encrypt(token),identity.name);const sourceId=Number(out.lastInsertRowid);db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,'owner')").run(sourceId,user.id);db.prepare("INSERT INTO sync_jobs(source_id,kind) VALUES(?, 'pull')").run(sourceId);return NextResponse.redirect(externalUrl(req,"/dashboard?source=connected"));
|
||||
} catch(e){ return NextResponse.redirect(externalUrl(req,"/dashboard?error="+encodeURIComponent(e instanceof Error?e.message:"source"))); } }
|
||||
@@ -0,0 +1,2 @@
|
||||
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http";
|
||||
export async function POST(req:Request){try{const user=await requireUser();const f=await req.formData();const sourceId=Number(f.get('sourceId'));const source=db.prepare('SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=?').get(sourceId,user.id);if(!source)throw 0;db.prepare("INSERT INTO sync_jobs(source_id,kind) VALUES(?, 'pull')").run(sourceId);return NextResponse.redirect(externalUrl(req,'/dashboard'));}catch{return NextResponse.redirect(externalUrl(req,'/'));}}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { withinRateLimit } from "@/lib/rate-limit";
|
||||
import { webhookSecretMatches } from "@/lib/webhook";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ sourceId: string; secret: string }> }) {
|
||||
const { sourceId, secret } = await params;
|
||||
const id = Number(sourceId);
|
||||
const source = db.prepare("SELECT id, webhook_secret_hash FROM sources WHERE id=?").get(id) as { id: number; webhook_secret_hash: string | null } | undefined;
|
||||
if (!source || !webhookSecretMatches(secret, source.webhook_secret_hash)) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown";
|
||||
if (!withinRateLimit(`webhook:${id}:${forwarded}`)) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
let payload: unknown = {};
|
||||
try { payload = await request.json(); } catch { /* Memos payload is optional; a pull reconciles source state. */ }
|
||||
db.prepare("UPDATE sources SET last_webhook_at=CURRENT_TIMESTAMP WHERE id=?").run(id);
|
||||
db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json) VALUES(?, 'pull', ?)").run(id, JSON.stringify(payload));
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Attachment = { name?: string; filename?: string; url?: string; externalLink?: string; type?: string; size?: string | number };
|
||||
|
||||
function resourceUrl(attachment: Attachment, sourceBaseUrl?: string) {
|
||||
if (attachment.url) return attachment.url;
|
||||
if (attachment.externalLink) return attachment.externalLink;
|
||||
if (!sourceBaseUrl || !attachment.name || !attachment.filename) return null;
|
||||
const resourceName = attachment.name.split("/").map(encodeURIComponent).join("/");
|
||||
return `${sourceBaseUrl.replace(/\/$/, "")}/file/${resourceName}/${encodeURIComponent(attachment.filename)}`;
|
||||
}
|
||||
|
||||
export function Attachments({ json, sourceBaseUrl, compact = false }: { json: string; sourceBaseUrl?: string | null; compact?: boolean }) {
|
||||
const [activeImage, setActiveImage] = useState<{ href: string; label: string } | null>(null);
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setActiveImage(null); };
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, []);
|
||||
|
||||
let attachments: Attachment[] = [];
|
||||
try { attachments = JSON.parse(json); } catch { return null; }
|
||||
const displayable = attachments.map((attachment) => ({ attachment, href: resourceUrl(attachment, sourceBaseUrl || undefined) })).filter((item): item is { attachment: Attachment; href: string } => Boolean(item.href));
|
||||
if (!displayable.length) return null;
|
||||
return <>
|
||||
<section className={`attachments${compact ? " attachments-compact" : ""}`} aria-label="附件">
|
||||
{displayable.map(({ attachment, href }) => {
|
||||
const label = attachment.filename || attachment.name || "附件";
|
||||
if (attachment.type?.startsWith("image/")) return <button type="button" className="attachment-image" onClick={() => setActiveImage({ href, label })} key={href} aria-label={`放大檢視:${label}`}><img src={href} alt={label} /></button>;
|
||||
return <a className="attachment-file" href={href} target="_blank" rel="noreferrer" key={href}>📎 {label}</a>;
|
||||
})}
|
||||
</section>
|
||||
{activeImage && <div className="image-lightbox" role="dialog" aria-modal="true" aria-label={activeImage.label} onClick={() => setActiveImage(null)}>
|
||||
<button type="button" className="image-lightbox-close" onClick={() => setActiveImage(null)} aria-label="關閉圖片檢視">×</button>
|
||||
<img src={activeImage.href} alt={activeImage.label} onClick={(event) => event.stopPropagation()} />
|
||||
</div>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; import { db } from "@/lib/db"; import { PublishForm } from "./publish-form"; import { WebhookControl } from "./webhook-control";
|
||||
export const dynamic="force-dynamic";
|
||||
export default async function Dashboard({searchParams}:{searchParams:Promise<{error?:string;source?:string}>}){const query=await searchParams;const user=await getSession();if(!user)redirect('/login');const sources=db.prepare('SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.last_error,s.webhook_secret_hash,s.last_webhook_at,s.user_id AS owner_id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE sm.user_id=? ORDER BY s.id DESC').all(user.id) as any[];return <><h1>控制台</h1>{query.error&&<p className="error">{query.error}</p>}{query.source==='shared'?<p>你已加入既有的共享 Memos 來源,不會重複同步貼文。</p>:query.source&&<p>來源已連接,首次同步已排入佇列。</p>}<section className="card"><h2>發佈到自己的 Memos</h2>{sources.length?<PublishForm sources={sources}/>:<p className="muted">請先連接一個 Memos 來源。</p>}</section><section className="card"><h2>連接 Memos</h2><form action="/api/sources" method="post"><label>顯示名稱<input name="name" required placeholder="我的 Memos"/></label><label>Memos 網址<input name="baseUrl" type="url" required placeholder="https://memos.example.com"/></label><label>Personal Access Token<input name="token" type="password" required/></label><button>驗證並連接</button></form><p className="muted">Token 會使用伺服器金鑰加密保存。同一個 Memos 帳號與網址會自動共用來源,不會建立重複貼文。</p></section><section><h2>已連接來源</h2>{sources.map(s=><article className="card" key={s.id}><div className="space"><strong>{s.name}</strong><span className="tag">{s.sync_status}</span></div><p className="meta">來源 ID:{s.id}<br/>{s.base_url}<br/>上次同步:{s.last_synced_at||'尚未完成'}<br/>Webhook:{s.webhook_secret_hash?(s.last_webhook_at?`最近收到:${new Date(s.last_webhook_at+'Z').toLocaleString('zh-TW')}`:'已建立 URL,尚未收到呼叫'):'尚未建立 URL'}{s.last_error&&<><br/><span className="error">{s.last_error}</span></>}</p>{s.owner_id===user.id?<WebhookControl sourceId={s.id} configured={Boolean(s.webhook_secret_hash)}/>:<p className="meta">這是共享來源;只有建立者可以管理 webhook。</p>}<form action="/api/sync" method="post"><input type="hidden" name="sourceId" value={s.id}/><button>立即同步</button></form></article>)}</section></>}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
type Source = { id: number; name: string };
|
||||
|
||||
export function PublishForm({ sources }: { sources: Source[] }) {
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true); setError("");
|
||||
try {
|
||||
const response = await fetch("/api/posts", { method: "POST", body: new FormData(event.currentTarget), headers: { Accept: "application/json" } });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || "發佈失敗");
|
||||
window.location.assign(`/posts/${result.id}`);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "發佈失敗");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <form onSubmit={submit} encType="multipart/form-data">
|
||||
<label>內容(Markdown)<textarea name="content" required /></label>
|
||||
<label>標籤(逗號分隔)<input name="tags" placeholder="旅行, 想法" /></label>
|
||||
<label>可見性<select name="visibility" defaultValue="PUBLIC"><option value="PUBLIC">公開</option><option value="PROTECTED">受保護</option><option value="PRIVATE">私人</option></select></label>
|
||||
<label>發佈來源<select name="sourceId" required>{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label>
|
||||
<label>圖片或附件(每檔最多 10 MB)<input name="attachments" type="file" multiple /></label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button disabled={submitting}>{submitting ? "發佈中…" : "發佈並同步"}</button>
|
||||
</form>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function WebhookControl({ sourceId, configured }: { sourceId: number; configured: boolean }) {
|
||||
const [url, setUrl] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false);
|
||||
async function generate() {
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
const response = await fetch(`/api/sources/${sourceId}/webhook`, { method: "POST", headers: { Accept: "application/json" } });
|
||||
const body = await response.json(); if (!response.ok) throw new Error(body.error || "無法產生 webhook URL"); setUrl(body.url);
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "無法產生 webhook URL"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function copy() { if (url) await navigator.clipboard.writeText(url); }
|
||||
return <div className="webhook-control"><p className="meta">Webhook:{configured ? "已設定" : "尚未設定"}</p>
|
||||
{url ? <><label className="sr-only" htmlFor={`webhook-${sourceId}`}>Webhook URL</label><input id={`webhook-${sourceId}`} readOnly value={url} onFocus={(event) => event.currentTarget.select()} /><div className="row"><button type="button" onClick={copy}>複製 URL</button><button type="button" className="danger" onClick={generate} disabled={busy}>重新產生</button></div><p className="meta">請立即複製到 Memos;重新整理後完整密鑰不會再顯示。</p></> : <button type="button" onClick={generate} disabled={busy}>{busy ? "產生中…" : configured ? "重新產生 webhook URL" : "產生 webhook URL"}</button>}
|
||||
{error && <p className="error">{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "./styles.css";
|
||||
import Link from "next/link";
|
||||
import { getSession } from "@/lib/auth";
|
||||
export const metadata = { title: "Mebbling", description: "Your Memos hub" };
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await getSession();
|
||||
return <html lang="zh-Hant"><body><header><Link href="/" className="brand">Mebbling</Link><nav><Link href="/">探索</Link>{user ? <><Link href="/dashboard">控制台</Link><form action="/api/auth/logout" method="post"><button>登出</button></form></> : <><Link href="/login">登入</Link><Link href="/register">註冊</Link></>}</nav></header><main>{children}</main></body></html>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default function Login(){return <><h1>登入</h1><form action="/api/auth/login" method="post"><label>帳號<input name="username" required autoComplete="username"/></label><label>密碼<input name="password" type="password" required autoComplete="current-password"/></label><button>登入</button></form></>}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Link from "next/link"; import { db } from "@/lib/db"; import { Attachments } from "./components/attachments";
|
||||
export const dynamic = "force-dynamic";
|
||||
type Post = { id:number; content:string; tags_json:string; attachments_json:string; created_at:string; username:string; name:string|null; source_base_url:string|null; comment_count:number; reaction_count:number };
|
||||
export default async function Home({ searchParams }: { searchParams: Promise<{ q?: string; tag?: string }> }) {
|
||||
const query = await searchParams;
|
||||
const q = query.q?.trim() || ""; const tag = query.tag?.trim() || "";
|
||||
const where = ["p.visibility = 'PUBLIC'", "p.hidden = 0"]; const args: string[] = [];
|
||||
if (q) { where.push("p.content LIKE ?"); args.push(`%${q}%`); } if (tag) { where.push("p.tags_json LIKE ?"); args.push(`%${JSON.stringify(tag).slice(1,-1)}%`); }
|
||||
const posts = db.prepare(`SELECT p.*, u.username, s.name, s.base_url AS source_base_url, (SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count, (SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE ${where.join(" AND ")} ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100`).all(...args) as Post[];
|
||||
return <><section className="space"><div><h1>公開 Memos Hub</h1><p className="muted">聚合朋友們公開分享的筆記。</p></div><Link className="button" href="/dashboard">發佈/連接來源</Link></section><form className="row" method="get"><input name="q" defaultValue={q} placeholder="搜尋公開貼文"/><input name="tag" defaultValue={tag} placeholder="標籤"/><button>搜尋</button></form>{posts.length ? posts.map(p=><article className="card" key={p.id}><div className="space"><Link className="meta post-name-link" href={`/posts/${p.id}`}>@{p.username}{p.name ? ` · ${p.name}` : ""}</Link><span className="meta">{new Date(p.created_at).toLocaleString("zh-TW")}</span></div><pre>{p.content}</pre><Attachments json={p.attachments_json} sourceBaseUrl={p.source_base_url} compact/><div className="row">{JSON.parse(p.tags_json).map((t:string)=><span className="tag" key={t}>#{t}</span>)}<Link href={`/posts/${p.id}`}>💬 {p.comment_count} 🙂 {p.reaction_count}</Link></div></article>) : <p className="muted">尚無符合的公開貼文。</p>}</>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { notFound, redirect } from "next/navigation"; import { db } from "@/lib/db"; import { getSession } from "@/lib/auth"; import { Attachments } from "@/app/components/attachments";
|
||||
export const dynamic="force-dynamic";
|
||||
export default async function PostPage({params}:{params:Promise<{id:string}>}){const {id:rawId}=await params;const id=Number(rawId);const post=db.prepare('SELECT p.*,u.username,s.name,s.base_url AS source_base_url FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?').get(id) as any;if(!post||post.hidden)notFound();const user=await getSession();if(post.visibility!=='PUBLIC'&&post.author_id!==user?.id)redirect('/');const comments=db.prepare('SELECT c.*,u.username FROM comments c JOIN users u ON u.id=c.author_id WHERE c.post_id=? AND c.hidden=0 ORDER BY c.created_at').all(id) as any[];const reactions=db.prepare('SELECT emoji,count(*) count FROM reactions WHERE post_id=? GROUP BY emoji').all(id) as any[];return <article><p className="meta">@{post.username} · {post.name||'Hub'} · {new Date(post.created_at).toLocaleString('zh-TW')}</p><pre className="card">{post.content}</pre><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url}/><section className="row">{reactions.map((r:any)=><span className="tag" key={r.emoji}>{r.emoji} {r.count}</span>)}{user&&['👍','❤️','🎉','🤔'].map(emoji=><form action="/api/reactions" method="post" key={emoji}><input type="hidden" name="postId" value={id}/><input type="hidden" name="emoji" value={emoji}/><button>{emoji}</button></form>)}</section><section><h2>留言</h2>{user?<form action="/api/comments" method="post"><input type="hidden" name="postId" value={id}/><textarea name="content" required placeholder="在 Hub 留下留言"/><button>送出留言</button></form>:<p>請先登入以留言或表情回應。</p>}{comments.map(c=><div className="card" key={c.id}><strong>@{c.username}</strong><p>{c.content}</p><span className="meta">{new Date(c.created_at).toLocaleString('zh-TW')}</span></div>)}</section></article>}
|
||||
@@ -0,0 +1 @@
|
||||
export default function Register(){return <><h1>建立帳號</h1><form action="/api/auth/register" method="post"><label>帳號<input name="username" required minLength={3} pattern="[A-Za-z0-9_-]+"/></label><label>密碼<input name="password" type="password" required minLength={10}/></label><button>註冊</button></form><p className="muted">帳號建立後即可連接自己的 Memos。</p></>}
|
||||
@@ -0,0 +1 @@
|
||||
:root{color-scheme:dark;font-family:ui-sans-serif,system-ui;background:#10131a;color:#edf1f8}*{box-sizing:border-box}body{margin:0}header{display:flex;justify-content:space-between;align-items:center;padding:1rem max(1.5rem,calc((100% - 1000px)/2));border-bottom:1px solid #293243;background:#151a23;position:sticky;top:0}main{width:min(900px,calc(100% - 2rem));margin:2rem auto}.brand{font-size:1.35rem;font-weight:700;color:#8ab4ff}nav{display:flex;gap:1rem;align-items:center}a{color:#bcd3ff;text-decoration:none}button,.button{background:#3778e5;color:#fff;border:0;border-radius:.5rem;padding:.55rem .8rem;cursor:pointer;font:inherit}button:hover,.button:hover{filter:brightness(1.1)}form{display:grid;gap:.8rem;max-width:580px}input,textarea,select{width:100%;padding:.65rem;border:1px solid #3a455a;border-radius:.45rem;background:#171d28;color:inherit}textarea{min-height:140px}.card{background:#171d28;border:1px solid #293243;border-radius:.75rem;padding:1rem;margin:.8rem 0}.muted{color:#aab4c5}.row{display:flex;gap:.7rem;align-items:center;flex-wrap:wrap}.space{display:flex;justify-content:space-between;gap:1rem}.error{color:#ff9d9d}.tag{background:#25314a;padding:.15rem .45rem;border-radius:.4rem;font-size:.85rem}pre{white-space:pre-wrap;font-family:inherit}.meta{font-size:.86rem;color:#aab4c5}.danger{background:#aa3746}.attachments{display:flex;flex-wrap:wrap;gap:.65rem;margin:.9rem 0}.attachment-image{display:block;max-width:min(100%,520px);padding:0;background:none;border:0;border-radius:.5rem;overflow:hidden}.attachment-image img{display:block;max-width:100%;max-height:520px;border-radius:.5rem;border:1px solid #3a455a}.attachment-image:hover img{border-color:#8ab4ff}.attachments-compact .attachment-image{max-width:220px}.attachments-compact .attachment-image img{max-height:220px;object-fit:cover}.attachment-file{padding:.45rem .65rem;border:1px solid #3a455a;border-radius:.45rem;background:#202838}.image-lightbox{position:fixed;z-index:100;inset:0;display:grid;place-items:center;padding:2rem;background:rgb(0 0 0 / .88);cursor:zoom-out}.image-lightbox img{display:block;max-width:100%;max-height:100%;object-fit:contain;cursor:default}.image-lightbox-close{position:absolute;top:1rem;right:1rem;width:2.5rem;height:2.5rem;padding:0;border-radius:50%;font-size:2rem;line-height:1;background:#25314a}
|
||||
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports: ["8088:3000"]
|
||||
env_file: .env
|
||||
environment: { DATABASE_PATH: /app/data/hub.db }
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./public/uploads:/app/public/uploads
|
||||
restart: unless-stopped
|
||||
worker:
|
||||
build: .
|
||||
command: npm run worker
|
||||
env_file: .env
|
||||
environment: { DATABASE_PATH: /app/data/hub.db }
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./public/uploads:/app/public/uploads
|
||||
restart: unless-stopped
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const secret = () => new TextEncoder().encode(process.env.SESSION_SECRET || "development-only-change-me");
|
||||
export type Session = { id: number; username: string; role: string };
|
||||
export async function createSession(user: Session) {
|
||||
const token = await new SignJWT(user).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("7d").sign(secret());
|
||||
(await cookies()).set("hub_session", token, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 604800 });
|
||||
}
|
||||
export async function getSession(): Promise<Session | null> {
|
||||
const token = (await cookies()).get("hub_session")?.value; if (!token) return null;
|
||||
try { return (await jwtVerify(token, secret())).payload as unknown as Session; } catch { return null; }
|
||||
}
|
||||
export async function requireUser() { const user = await getSession(); if (!user) throw new Error("Unauthorized"); return user; }
|
||||
export async function clearSession() { (await cookies()).delete("hub_session"); }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
|
||||
function key() {
|
||||
const value = process.env.TOKEN_ENCRYPTION_KEY;
|
||||
if (!value || !/^[0-9a-f]{64}$/i.test(value)) throw new Error("TOKEN_ENCRYPTION_KEY must be 64 hexadecimal characters");
|
||||
return Buffer.from(value, "hex");
|
||||
}
|
||||
export function encrypt(value: string) {
|
||||
const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", key(), iv);
|
||||
const body = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64url");
|
||||
}
|
||||
export function decrypt(value: string) {
|
||||
const raw = Buffer.from(value, "base64url"); const decipher = createDecipheriv("aes-256-gcm", key(), raw.subarray(0, 12));
|
||||
decipher.setAuthTag(raw.subarray(12, 28)); return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const path = process.env.HUB_BUILD === "1" ? ":memory:" : (process.env.DATABASE_PATH || "./data/hub.db");
|
||||
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
||||
export const db = new Database(path);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.pragma("busy_timeout = 5000");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, disabled INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL, base_url TEXT NOT NULL, token_encrypted TEXT NOT NULL, remote_user TEXT,
|
||||
webhook_supported INTEGER NOT NULL DEFAULT 0, sync_status TEXT NOT NULL DEFAULT 'pending', last_synced_at TEXT, last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, base_url)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY, source_id INTEGER REFERENCES sources(id) ON DELETE SET NULL,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_memo_name TEXT, content TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'PUBLIC', tags_json TEXT NOT NULL DEFAULT '[]', attachments_json TEXT NOT NULL DEFAULT '[]',
|
||||
origin TEXT NOT NULL DEFAULT 'memos', remote_created_at TEXT, remote_updated_at TEXT, sync_status TEXT NOT NULL DEFAULT 'synced',
|
||||
hidden INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(source_id, remote_memo_name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, hidden INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(post_id, user_id, emoji)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, reporter_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
reason TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, resolved INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sync_jobs (
|
||||
id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL, payload_json TEXT, status TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, run_after TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS source_members (
|
||||
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(source_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS posts_public_idx ON posts(visibility, hidden, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS sync_jobs_idx ON sync_jobs(status, run_after);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS source_remote_identity_unique ON sources(base_url, remote_user) WHERE remote_user IS NOT NULL;
|
||||
`);
|
||||
|
||||
db.exec("INSERT OR IGNORE INTO source_members(source_id,user_id,role) SELECT id,user_id,'owner' FROM sources");
|
||||
|
||||
const sourceColumns = db.prepare("PRAGMA table_info(sources)").all() as { name: string }[];
|
||||
if (!sourceColumns.some((column) => column.name === "webhook_secret_hash")) {
|
||||
db.exec("ALTER TABLE sources ADD COLUMN webhook_secret_hash TEXT");
|
||||
}
|
||||
if (!sourceColumns.some((column) => column.name === "last_webhook_at")) {
|
||||
db.exec("ALTER TABLE sources ADD COLUMN last_webhook_at TEXT");
|
||||
}
|
||||
|
||||
const admin = process.env.ADMIN_USERNAME;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
if (admin && adminPassword) {
|
||||
// Keep bootstrap safe when Next preloads multiple route modules concurrently.
|
||||
const bcrypt = require("bcryptjs");
|
||||
db.prepare("INSERT OR IGNORE INTO users(username, password_hash, role) VALUES (?, ?, 'admin')").run(admin, bcrypt.hashSync(adminPassword, 12));
|
||||
}
|
||||
|
||||
const seedUrl = process.env.SEED_MEMOS_URL?.replace(/\/$/, "");
|
||||
const seedToken = process.env.SEED_MEMOS_TOKEN;
|
||||
if (admin && seedUrl && seedToken) {
|
||||
const user = db.prepare("SELECT id FROM users WHERE username=?").get(admin) as { id: number } | undefined;
|
||||
const source = db.prepare("SELECT id FROM sources WHERE user_id=? AND base_url=?").get(user?.id, seedUrl) as { id: number } | undefined;
|
||||
if (user && !source) {
|
||||
const { encrypt } = require("./crypto") as typeof import("./crypto");
|
||||
db.prepare("INSERT OR IGNORE INTO sources(user_id,name,base_url,token_encrypted,sync_status) VALUES(?,?,?,?, 'queued')").run(user.id, process.env.SEED_MEMOS_NAME || "Initial Memos", seedUrl, encrypt(seedToken));
|
||||
const inserted = db.prepare("SELECT id FROM sources WHERE user_id=? AND base_url=?").get(user.id, seedUrl) as { id: number };
|
||||
db.prepare("INSERT INTO sync_jobs(source_id,kind) SELECT ?, 'pull' WHERE NOT EXISTS (SELECT 1 FROM sync_jobs WHERE source_id=? AND kind='pull' AND status IN ('queued','running'))").run(inserted.id, inserted.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function externalUrl(request: Request, pathname: string) {
|
||||
const current = new URL(request.url);
|
||||
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || current.host;
|
||||
const protocol = request.headers.get("x-forwarded-proto") || current.protocol.replace(":", "");
|
||||
return new URL(pathname, `${protocol}://${host}`);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type MemosMemo = { name: string; content: string; visibility: string; createTime?: string; updateTime?: string; tags?: string[]; attachments?: unknown[]; resources?: unknown[] };
|
||||
const base = (url: string) => url.replace(/\/+$/, "") + "/api/v1";
|
||||
async function request(url: string, token: string, init?: RequestInit) {
|
||||
const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) }, cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Memos API ${res.status}: ${await res.text()}`); return res;
|
||||
}
|
||||
export async function verifyMemos(baseUrl: string, token: string) { await request(`${base(baseUrl)}/memos?pageSize=1`, token); }
|
||||
export async function getMemosIdentity(baseUrl: string, token: string) {
|
||||
const user = await (await request(`${base(baseUrl)}/auth/status`, token, { method: "POST", body: "{}" })).json() as { name: string; username?: string };
|
||||
if (!user.name) throw new Error("Memos did not return an account identity");
|
||||
return user;
|
||||
}
|
||||
export async function listMemos(baseUrl: string, token: string) {
|
||||
const all: MemosMemo[] = []; let pageToken = "";
|
||||
do { const res = await request(`${base(baseUrl)}/memos?pageSize=100${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`, token); const data = await res.json(); all.push(...(data.memos || [])); pageToken = data.nextPageToken || ""; } while (pageToken);
|
||||
return all.filter((memo) => memo.visibility === "PUBLIC");
|
||||
}
|
||||
export async function createMemo(baseUrl: string, token: string, memo: Pick<MemosMemo, "content" | "visibility"> & { attachments?: unknown[]; resources?: unknown[] }) {
|
||||
return (await request(`${base(baseUrl)}/memos`, token, { method: "POST", body: JSON.stringify({ state: "NORMAL", ...memo }) })).json() as Promise<MemosMemo>;
|
||||
}
|
||||
export async function createAttachment(baseUrl: string, token: string, attachment: { filename: string; content: string; type: string }) {
|
||||
return (await request(`${base(baseUrl)}/attachments`, token, { method: "POST", body: JSON.stringify(attachment) })).json() as Promise<{ name: string; filename: string; type: string }>;
|
||||
}
|
||||
export async function setMemoAttachments(baseUrl: string, token: string, memoName: string, attachments: unknown[]) {
|
||||
const memoId = memoName.split("/").at(-1);
|
||||
if (!memoId) throw new Error("Invalid Memos memo name");
|
||||
await request(`${base(baseUrl)}/memos/${encodeURIComponent(memoId)}/attachments`, token, { method: "PATCH", body: JSON.stringify({ name: memoName, attachments }) });
|
||||
}
|
||||
export async function createResource(baseUrl: string, token: string, resource: { filename: string; content: string; type: string; size: string }) {
|
||||
return (await request(`${base(baseUrl)}/resources`, token, { method: "POST", body: JSON.stringify(resource) })).json() as Promise<{ name: string; filename: string; type: string; size: string }>;
|
||||
}
|
||||
export async function createRemoteFile(baseUrl: string, token: string, file: { filename: string; content: string; type: string; size: string }) {
|
||||
try { return { kind: "attachment" as const, value: await createAttachment(baseUrl, token, file) }; }
|
||||
catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.startsWith("Memos API 404")) throw error;
|
||||
return { kind: "resource" as const, value: await createResource(baseUrl, token, file) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const visits = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
export function withinRateLimit(key: string, limit = 30, windowMs = 60_000) {
|
||||
const now = Date.now(); const record = visits.get(key);
|
||||
if (!record || record.resetAt <= now) { visits.set(key, { count: 1, resetAt: now + windowMs }); return true; }
|
||||
if (record.count >= limit) return false;
|
||||
record.count += 1; return true;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export function createWebhookSecret() { return randomBytes(32).toString("base64url"); }
|
||||
export function webhookSecretHash(secret: string) { return createHash("sha256").update(secret).digest("hex"); }
|
||||
export function webhookSecretMatches(secret: string, expectedHash: string | null) {
|
||||
if (!expectedHash) return false;
|
||||
const actual = Buffer.from(webhookSecretHash(secret), "hex");
|
||||
const expected = Buffer.from(expectedHash, "hex");
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,12 @@
|
||||
import path from "node:path";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
outputFileTracingRoot: process.cwd(),
|
||||
webpack(config) {
|
||||
config.resolve.alias["@"] = path.resolve(process.cwd());
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+1925
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "mebbling",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "HUB_BUILD=1 next build",
|
||||
"start": "next start",
|
||||
"worker": "tsx worker/index.ts",
|
||||
"test": "tsx --test tests/**/*.test.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"jose": "^6.2.3",
|
||||
"next": "^15.5.20",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"tsx": "^4.23.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es2022"
|
||||
],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"incremental": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { db } from "../lib/db"; import { decrypt } from "../lib/crypto"; import { createMemo, createRemoteFile, listMemos, setMemoAttachments } from "../lib/memos"; import { readFile } from "node:fs/promises"; import { join } from "node:path";
|
||||
type Source={id:number;user_id:number;base_url:string;token_encrypted:string};
|
||||
function upsertRemote(source:Source,m:any){const tags=JSON.stringify(m.tags||[]),attachments=JSON.stringify(m.attachments||m.resources||[]);db.prepare(`INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden) VALUES(?,?,?,?,?,?,?,?,?,?, 'synced',0) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,visibility=excluded.visibility,tags_json=excluded.tags_json,attachments_json=excluded.attachments_json,remote_updated_at=excluded.remote_updated_at,hidden=0,updated_at=CURRENT_TIMESTAMP`).run(source.id,source.user_id,m.name,m.content,m.visibility,tags,attachments,'memos',m.createTime||null,m.updateTime||null);}
|
||||
async function pull(source:Source){const memos=await listMemos(source.base_url,decrypt(source.token_encrypted));for(const memo of memos)upsertRemote(source,memo);const names=memos.map(m=>m.name);if(names.length){const placeholders=names.map(()=>'?').join(',');db.prepare(`UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL AND remote_memo_name NOT IN (${placeholders})`).run(source.id,...names);}else{db.prepare("UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL").run(source.id);}db.prepare("UPDATE sources SET sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?").run(source.id);}
|
||||
async function push(source:Source,payload:any){const post=db.prepare('SELECT * FROM posts WHERE id=? AND source_id=?').get(payload.postId,source.id) as any;if(!post)return;const token=decrypt(source.token_encrypted);const localAttachments=JSON.parse(post.attachments_json||'[]') as {name:string;url:string;type:string;size:number}[];const attachments=[] as unknown[],resources=[] as unknown[];for(const attachment of localAttachments){const content=(await readFile(join(process.cwd(),'public',attachment.url))).toString('base64');const remote=await createRemoteFile(source.base_url,token,{filename:attachment.name,content,type:attachment.type||'application/octet-stream',size:String(attachment.size)});(remote.kind==='attachment'?attachments:resources).push(remote.value);}const memo=await createMemo(source.base_url,token,{content:post.content,visibility:post.visibility,resources});if(attachments.length)await setMemoAttachments(source.base_url,token,memo.name,attachments);db.prepare("UPDATE posts SET remote_memo_name=?,remote_created_at=?,remote_updated_at=?,sync_status='synced',updated_at=CURRENT_TIMESTAMP WHERE id=?").run(memo.name,memo.createTime||null,memo.updateTime||null,post.id);}
|
||||
async function run(){const job=db.prepare("SELECT * FROM sync_jobs WHERE status='queued' AND run_after<=CURRENT_TIMESTAMP ORDER BY id LIMIT 1").get() as any;if(!job)return;db.prepare("UPDATE sync_jobs SET status='running',attempts=attempts+1 WHERE id=?").run(job.id);const source=db.prepare('SELECT * FROM sources WHERE id=?').get(job.source_id) as Source;try{if(job.kind==='pull')await pull(source);else if(job.kind==='push')await push(source,JSON.parse(job.payload_json||'{}'));db.prepare("UPDATE sync_jobs SET status='done' WHERE id=?").run(job.id);db.prepare("UPDATE sources SET sync_status='synced',last_error=NULL,last_synced_at=CURRENT_TIMESTAMP WHERE id=?").run(source.id);}catch(error){const message=error instanceof Error?error.message:'Sync failure';db.prepare("UPDATE sync_jobs SET status=CASE WHEN attempts>=5 THEN 'failed' ELSE 'queued' END,last_error=?,run_after=datetime('now','+5 minutes') WHERE id=?").run(message,job.id);db.prepare("UPDATE sources SET sync_status='error',last_error=? WHERE id=?").run(message,source.id);}}
|
||||
function schedule(){const interval=Number(process.env.SYNC_INTERVAL_MINUTES||60);db.prepare("INSERT INTO sync_jobs(source_id,kind) SELECT id,'pull' FROM sources WHERE COALESCE(last_synced_at,'1970-01-01') < datetime('now', ?) AND NOT EXISTS (SELECT 1 FROM sync_jobs j WHERE j.source_id=sources.id AND j.kind='pull' AND j.status IN ('queued','running'))").run(`-${interval} minutes`);}
|
||||
setInterval(()=>{schedule();void run();},5000);schedule();void run();
|
||||
Reference in New Issue
Block a user