@@ -366,12 +381,14 @@ function InsideBlock({ rows, device, title, href, follow, t }: {
);
})}
{!confirmed && (
-
- {t('inside_ask')} {t('inside_ask_link')} ↗ . {t('inside_ask_after')}
-
+
+
+ {t('inside_ask')} setSending(true)}>
+ {t('inside_ask_link')}
+ . {t('inside_ask_after')}
+
+ {sending &&
}
+
)}
);
diff --git a/frontend/apps/site/src/components/club/Club.test.tsx b/frontend/apps/site/src/components/club/Club.test.tsx
new file mode 100644
index 00000000..5e228e3e
--- /dev/null
+++ b/frontend/apps/site/src/components/club/Club.test.tsx
@@ -0,0 +1,27 @@
+// @vitest-environment jsdom
+/**
+ * A sign-in link opened in a browser other than the one that asked for it
+ * signs nothing in by itself: /club names the account and waits for a click.
+ */
+import { afterEach, expect, test, vi } from 'vitest';
+import { cleanup, fireEvent, render, waitFor } from '@testing-library/preact';
+import Club from './Club.tsx';
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); localStorage.clear(); });
+
+test('a link from another browser asks whose account it is before signing in', async () => {
+ window.history.replaceState({}, '', '/club/?confirm=abc123');
+ const calls: string[] = [];
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
+ calls.push(`${init?.method ?? 'GET'} ${url}`);
+ if (url.startsWith('/api/v1/club/finish/who')) return new Response(JSON.stringify({ provider: 'email', who: 'someone@example.org' }));
+ if (url === '/api/v1/club/finish') return new Response(JSON.stringify({ member: { id: 'm-1', name: 'x', maintainer: false, quiet: false, identities: [], stars: 0, pending: 0 } }));
+ return new Response(JSON.stringify({ member: null, sign_in: { telegram: null, github: false, email: true } }));
+ }));
+ const { findByText, getByRole } = render(
);
+ expect(await findByText('Sign in as someone@example.org?')).toBeTruthy();
+ expect(calls).not.toContain('POST /api/v1/club/finish');
+ expect(window.location.search).toBe('');
+ fireEvent.click(getByRole('button', { name: 'Sign in' }));
+ await waitFor(() => expect(calls).toContain('POST /api/v1/club/finish'));
+});
diff --git a/frontend/apps/site/src/components/club/Club.tsx b/frontend/apps/site/src/components/club/Club.tsx
new file mode 100644
index 00000000..999e6208
--- /dev/null
+++ b/frontend/apps/site/src/components/club/Club.tsx
@@ -0,0 +1,380 @@
+/**
+ * /club: signing in, and once in, the member's own page -- their stars and
+ * every report they sent, its state and what it earned (service/internal/club).
+ *
+ * The page is static; who is signed in comes from /api/v1/club/me in the
+ * browser. Telegram signs in through the bot: this browser shows a code (a
+ * QR code on a desktop), the person taps Start in Telegram, and this page,
+ * polling, finds itself signed in.
+ */
+import { useEffect, useRef, useState } from 'preact/hooks';
+import { QrCodeWidget } from '@openipc/ui';
+import { useBoardsTranslations, type BoardsT } from '../../lib/boards-i18n';
+import { pathFor, type Locale } from '../../lib/i18n';
+import {
+ clock, fetchMe, fetchMine, finishConfirm, finishWho, pollLogin, rename, secondsLeft, setQuiet, signOut, startEmail, startTelegram,
+ type Me, type Member, type MemberReport,
+} from '../../lib/club';
+import { size } from '../../lib/reports';
+import { STATUS_TONE, Stars } from './parts';
+
+type Load = { state: 'loading' } | { state: 'ok'; me: Me } | { state: 'error' };
+
+export default function Club({ locale }: { locale: Locale }) {
+ const t = useBoardsTranslations(locale);
+ const [load, setLoad] = useState
({ state: 'loading' });
+ const [notice, setNotice] = useState(null);
+ // A sign-in link opened in a browser other than the one that asked for
+ // it: whose account it is for, asked before anything is signed in.
+ const [confirm, setConfirm] = useState<{ code: string; who: string } | null>(null);
+
+ const reload = () => fetchMe().then((me) => setLoad({ state: 'ok', me })).catch(() => setLoad({ state: 'error' }));
+
+ useEffect(() => {
+ const params = new URLSearchParams(location.search);
+ const q = params.get('signin');
+ const code = params.get('confirm');
+ if (q && q !== 'ok') setNotice(t(`club.signin_${q === 'unavailable' ? 'failed' : q}`, { fallback: t('club.signin_failed') }));
+ if (code) {
+ finishWho(code)
+ .then((r) => setConfirm({ code, who: r.who }))
+ .catch(() => setNotice(t('club.signin_expired')));
+ }
+ if (q || code) history.replaceState(null, '', location.pathname);
+ reload();
+ }, []);
+
+ if (load.state === 'loading') return
;
+ if (load.state === 'error') return {t('club.load_failed')}
;
+ const { me } = load;
+ return (
+ <>
+ {notice && {notice}
}
+ {confirm && (
+
+ {t('club.confirm_title', { who: confirm.who })}
+ {t('club.confirm_text', { who: confirm.who })}
+
+ finishConfirm(confirm.code)
+ .then(() => { setConfirm(null); reload(); })
+ .catch(() => { setConfirm(null); setNotice(t('club.signin_expired')); })}>
+ {t('club.confirm_button')}
+
+ setConfirm(null)}>{t('club.confirm_cancel')}
+
+
+ )}
+ {me.member
+ ?
+ : }
+ >
+ );
+}
+
+function SignIn({ ways, locale, t, onSignedIn, compact }: {
+ ways: Me['sign_in']; locale: Locale; t: BoardsT; onSignedIn: () => void; compact?: boolean;
+}) {
+ const [sheet, setSheet] = useState(false);
+ const [email, setEmail] = useState('');
+ const [mail, setMail] = useState<{ state: 'idle' | 'sending' } | { state: 'sent'; to: string } | { state: 'error'; error: string }>({ state: 'idle' });
+
+ const sendLink = (e: Event) => {
+ e.preventDefault();
+ setMail({ state: 'sending' });
+ startEmail(email, locale)
+ .then(() => setMail({ state: 'sent', to: email }))
+ .catch((err: Error) => setMail({ state: 'error', error: err.message }));
+ };
+
+ const provider = 'flex w-full items-center gap-3 rounded-lg border border-hairline bg-white px-3.5 py-2.5 text-left font-semibold text-ink enabled:hover:border-brand-blue disabled:cursor-not-allowed disabled:opacity-55';
+ const mark = 'grid size-[30px] shrink-0 place-items-center rounded-[7px] font-mono text-[13px] font-bold text-white';
+ const buttons = (
+
+
setSheet(true)}>
+ TG
+ {t('club.tg_button')}{ways.telegram ? t('club.tg_hint') : t('club.unavailable')}
+
+ {ways.github
+ ? (
+
+ GH
+ {t('club.gh_button')}{t('club.gh_hint')}
+
+ )
+ : (
+
+ GH
+ {t('club.gh_button')}{t('club.unavailable')}
+
+ )}
+
{t('club.or')}
+ {mail.state === 'sent'
+ ?
{t('club.email_sent', { email: mail.to })}
+ : (
+
+ )}
+
+ );
+
+ return (
+ <>
+ {compact
+ ? buttons
+ : (
+
+
+
{t('club.why_title')}
+
+ {t('club.why_1')} {t('club.why_2')} {t('club.why_3')}
+
+
{t('club.why_note')}
+
+ {buttons}
+
+ )}
+ {sheet && ways.telegram && setSheet(false)} onSignedIn={() => { setSheet(false); onSignedIn(); }} />}
+ {!compact && }
+ >
+ );
+}
+
+/**
+ * The QR code, the steps, and a countdown, while the page polls every two
+ * seconds for the Start tap. A phone gets the link to open straight away.
+ */
+function TelegramSheet({ bot, t, onClose, onSignedIn }: { bot: string; t: BoardsT; onClose: () => void; onSignedIn: () => void }) {
+ const dialog = useRef(null);
+ const [link, setLink] = useState<{ url: string; expires: string } | null>(null);
+ const [left, setLeft] = useState(600);
+ const [error, setError] = useState(null);
+ const [round, setRound] = useState(0);
+
+ useEffect(() => { dialog.current?.showModal(); }, []);
+ useEffect(() => {
+ let live = true;
+ setLink(null);
+ setError(null);
+ startTelegram()
+ .then((r) => {
+ if (!live) return;
+ setLink({ url: r.link, expires: r.expires_at });
+ // A phone has Telegram on it: go there now rather than show a code
+ // to scan with the same phone.
+ if (matchMedia('(pointer: coarse)').matches) location.href = r.link;
+ })
+ .catch((e: Error) => live && setError(e.message));
+ return () => { live = false; };
+ }, [round]);
+ useEffect(() => {
+ if (!link) return;
+ let live = true;
+ const tick = setInterval(() => setLeft(secondsLeft(link.expires)), 1000);
+ const poll = async () => {
+ while (live) {
+ await new Promise((r) => setTimeout(r, 2000));
+ if (!live || document.hidden) continue;
+ try {
+ const p = await pollLogin();
+ if (p.state === 'signed_in') { onSignedIn(); return; }
+ if (p.state === 'expired' || p.state === 'none') { setLeft(0); return; }
+ } catch { /* a dropped request; the next one tries again */ }
+ }
+ };
+ void poll();
+ return () => { live = false; clearInterval(tick); };
+ }, [link]);
+
+ return (
+
+
+
{t('club.tg_title')}
+ dialog.current?.close()} class="cursor-pointer px-2 text-xl leading-none text-body-secondary hover:text-body">✕
+
+
+ {/* The shared widget frames its code in grey for the Wi-Fi page; here
+ the tile is the frame, so the code fills it. */}
+
+ {link && left > 0
+ ?
+ :
{error ?? (left === 0 ? t('club.tg_expired') : '…')}
}
+
+
+ {t('club.tg_step1')}
+ {t('club.tg_step2', { bot: `@${bot}` })}
+ {t('club.tg_step3')}
+
+
+
+ {link && left > 0 &&
{t('club.tg_open')} }
+ {left === 0 &&
{ setLeft(600); setRound(round + 1); }}>{t('club.tg_retry')} }
+
dialog.current?.close()}>{t('club.tg_other')}
+
+ {link && left > 0 && (
+
+
+ {t('club.tg_waiting', { time: clock(left) })}
+
+ )}
+
+ );
+}
+
+function MemberPage({ member, ways, locale, t, onChange }: {
+ member: Member; ways: Me['sign_in']; locale: Locale; t: BoardsT; onChange: () => void;
+}) {
+ const [reports, setReports] = useState(null);
+ const [error, setError] = useState(false);
+ const [linking, setLinking] = useState(false);
+ const [naming, setNaming] = useState(false);
+ const [name, setName] = useState(member.name);
+ const [nameError, setNameError] = useState(null);
+ // What waits for review comes with the reports, not with /me: the navbar
+ // asks /me on every page and needs only the total.
+ const pending = (reports ?? []).reduce((n, r) => n + (r.status === 'pending' ? r.pending : 0), 0);
+
+ useEffect(() => {
+ fetchMine().then((r) => setReports(r.reports)).catch(() => setError(true));
+ }, [member.id]);
+
+ const telegram = member.identities.some((i) => i.provider === 'telegram' && i.chat);
+ const missing = (['telegram', 'github', 'email'] as const).filter((p) => !member.identities.some((i) => i.provider === p));
+ const initials = member.name.split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase();
+
+ return (
+
+
+
+
{initials}
+
+
{member.name}
+
{member.identities.map((i) => t(`club.provider_${i.provider}`)).join(' · ')}
+
+
+
+
+ {pending > 0 &&
{t('club.pending', { n: pending })}
}
+
+ {naming
+ ? (
+
+ )
+ : setNaming(true)}>{t('club.rename')} }
+ {member.maintainer && {t('club.review_link')} }
+ {telegram && (
+
+ {member.quiet ? t('club.bot_muted') : t('club.bot_on')}
+ setQuiet(!member.quiet).then(onChange)}>
+ {member.quiet ? t('club.unmute') : t('club.mute')}
+
+
+ )}
+ {missing.length > 0 && (
+ linking
+ ?
+ : setLinking(true)}>{t('club.link_more')}
+ )}
+ signOut().then(onChange)}>{t('club.sign_out')}
+
+
+
{t('club.mine_title')}
+ {error &&
{t('club.load_failed')}
}
+ {!error && reports === null &&
}
+ {reports && reports.length === 0 && (
+
{t('club.mine_empty')} {t('club.boards_link')}
+ )}
+ {reports && reports.length > 0 &&
}
+
+
+
+ );
+}
+
+function Ledger({ reports, locale, t }: { reports: MemberReport[]; locale: Locale; t: BoardsT }) {
+ return (
+
+
+
+
+ {t('club.col_submission')}
+ {t('club.col_status')}
+ {t('club.col_stars')}
+
+
+
+ {reports.map((r) => {
+ const shown = r.status === 'pending' ? r.pending : r.stars;
+ const board = r.board ? `${r.board.manufacturer} ${r.board.model}` : (r.chip || t('club.no_board'));
+ return (
+
+
+
+ {r.board ? {board} : board}
+ {r.id}
+
+
+ {r.files.map((f) => (
+
+ {t(`club.kind_${f.kind}`)} · {f.name}
+ {size(f.bytes)}
+ {f.private && {t('club.private')} }
+
+ ))}
+
+ {r.duplicate && {t('club.duplicate')} }
+ {r.review_note && (
+ {t('club.review_note_label')}: {r.review_note}
+ )}
+
+ {t(`club.status_${r.status}`)}
+ 0 ? 'text-[#9a5b00]' : 'text-body-secondary'}`}>
+ {shown > 0 ? `+${shown} ★` : '0'}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+function Rules({ t }: { t: BoardsT }) {
+ return (
+
+ {t('club.rules_title')}
+
+
+ {([['rules_item', '+1 ★'], ['rules_dump', '+10 ★'], ['rules_none', '0']] as const).map(([k, v]) => (
+
+ {t(`club.${k}`)}
+ {v}
+
+ ))}
+
+
+ {t('club.rules_note')}
+
+ );
+}
+
+export { SignIn };
diff --git a/frontend/apps/site/src/components/club/ClubBadge.test.tsx b/frontend/apps/site/src/components/club/ClubBadge.test.tsx
new file mode 100644
index 00000000..c66e0948
--- /dev/null
+++ b/frontend/apps/site/src/components/club/ClubBadge.test.tsx
@@ -0,0 +1,22 @@
+// @vitest-environment jsdom
+/** The navbar's badge follows a sign-in made on the page it is on. */
+import { afterEach, expect, test, vi } from 'vitest';
+import { act, cleanup, render } from '@testing-library/preact';
+import { CHANGED, fetchMe } from '../../lib/club';
+import ClubBadge from './ClubBadge.tsx';
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); localStorage.clear(); });
+
+const ivan = { id: 'm-1', name: 'Ivan', maintainer: false, quiet: false, identities: [], stars: 13, pending: 0 };
+
+test('a first sign-in on this page shows the badge without a reload, and a sign-out hides it', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ member: ivan, sign_in: {} }))));
+ const { container } = render( );
+ expect(container.textContent).toBe('');
+ expect(fetch).not.toHaveBeenCalled();
+ await act(async () => { await fetchMe(); });
+ expect(container.textContent).toContain('13');
+ expect(container.textContent).toContain('Ivan');
+ act(() => { window.dispatchEvent(new CustomEvent(CHANGED, { detail: null })); });
+ expect(container.textContent).toBe('');
+});
diff --git a/frontend/apps/site/src/components/club/ClubBadge.tsx b/frontend/apps/site/src/components/club/ClubBadge.tsx
new file mode 100644
index 00000000..7ef34c6a
--- /dev/null
+++ b/frontend/apps/site/src/components/club/ClubBadge.tsx
@@ -0,0 +1,29 @@
+/**
+ * The navbar's ★ and name, for a signed-in member: a link to their page.
+ * A browser that never signed in asks nothing and shows nothing; one that
+ * did (the flag lib/club.ts keeps) asks /api/v1/club/me once per page, and
+ * a sign-in or sign-out on the page itself updates it at once.
+ */
+import { useEffect, useState } from 'preact/hooks';
+import { pathFor, type Locale } from '../../lib/i18n';
+import { CHANGED, fetchMe, remembered, type Member } from '../../lib/club';
+import { Stars } from './parts';
+
+export default function ClubBadge({ locale }: { locale: Locale }) {
+ const [member, setMember] = useState(null);
+ useEffect(() => {
+ // The page may sign in or out while open (/club, the Telegram sheet):
+ // whatever learns who is signed in announces it, and the badge follows.
+ const follow = (e: Event) => setMember((e as CustomEvent).detail);
+ window.addEventListener(CHANGED, follow);
+ if (remembered()) fetchMe().catch(() => {});
+ return () => window.removeEventListener(CHANGED, follow);
+ }, []);
+ if (!member) return null;
+ return (
+
+
+ {member.name}
+
+ );
+}
diff --git a/frontend/apps/site/src/components/club/Review.tsx b/frontend/apps/site/src/components/club/Review.tsx
new file mode 100644
index 00000000..ef816057
--- /dev/null
+++ b/frontend/apps/site/src/components/club/Review.tsx
@@ -0,0 +1,140 @@
+/**
+ * /club/review: the maintainers' review queue. Each report shows who sent it,
+ * the board they named or ipctool's guess, its files (private dumps
+ * included, for the reviewer), and what publishing it would earn the sender.
+ * Publishing links it to its boards, lists its text there, awards the stars
+ * and tells the sender; rejecting takes back anything it had earned.
+ *
+ * The same decisions remain available as `openipc reports publish|reject`.
+ */
+import { useEffect, useState } from 'preact/hooks';
+import { useBoardsTranslations, type BoardsT } from '../../lib/boards-i18n';
+import { pathFor, type Locale } from '../../lib/i18n';
+import { ClubError, decide, fetchMe, fetchQueue, type Queued } from '../../lib/club';
+import { size } from '../../lib/reports';
+import { STATUS_TONE } from './parts';
+
+type Load = { state: 'loading' } | { state: 'ok'; list: Queued[] } | { state: 'forbidden' } | { state: 'signed_out' } | { state: 'error' };
+const TABS = ['pending', 'published', 'rejected'] as const;
+
+export default function Review({ locale }: { locale: Locale }) {
+ const t = useBoardsTranslations(locale);
+ const [tab, setTab] = useState<(typeof TABS)[number]>('pending');
+ const [load, setLoad] = useState({ state: 'loading' });
+
+ const reload = () => {
+ setLoad({ state: 'loading' });
+ fetchQueue(tab)
+ .then((r) => setLoad({ state: 'ok', list: r.reports }))
+ .catch((e) => {
+ if (e instanceof ClubError && e.status === 401) setLoad({ state: 'signed_out' });
+ else if (e instanceof ClubError && e.status === 403) setLoad({ state: 'forbidden' });
+ else setLoad({ state: 'error' });
+ });
+ };
+ useEffect(() => { void fetchMe().catch(() => {}); }, []);
+ useEffect(reload, [tab]);
+
+ if (load.state === 'signed_out' || load.state === 'forbidden') {
+ return (
+
+ {t('club.review_forbidden')} {t('club.sign_in_link')}
+
+ );
+ }
+ return (
+
+
+ {TABS.map((k) => (
+ setTab(k)}
+ class={`cursor-pointer rounded-md border px-3 py-1 text-sm ${tab === k ? 'border-brand-blue bg-[#eef0fc] font-semibold text-[#3d4dad]' : 'border-hairline bg-white'}`}>
+ {t(`club.tab_${k}`)}
+
+ ))}
+
+ {load.state === 'loading' &&
}
+ {load.state === 'error' &&
{t('club.load_failed')}
}
+ {load.state === 'ok' && load.list.length === 0 &&
{t('club.review_empty')}
}
+ {load.state === 'ok' && load.list.map((q) =>
)}
+
+ );
+}
+
+function Item({ q, locale, t, onDone }: { q: Queued; locale: Locale; t: BoardsT; onDone: () => void }) {
+ const [models, setModels] = useState(q.models.join(', '));
+ const [note, setNote] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+ const received = new Date(q.received_at).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
+
+ const act = (decision: 'publish' | 'reject') => {
+ setBusy(true);
+ setError(null);
+ const ids = models.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
+ decide(q.id, decision, ids, note)
+ .then((r) => { setResult(t(`club.review_done_${decision}`, { points: r.points })); setTimeout(onDone, 1200); })
+ .catch((e: Error) => setError(e.message))
+ .finally(() => setBusy(false));
+ };
+
+ return (
+
+
+
+ {q.board ? `${q.board.manufacturer} ${q.board.model}` : (q.chip || q.id)}
+ {q.id} · {q.channel}
+
+
+ {received}
+ {t(`club.status_${q.status}`)}
+
+
+
+ {t('club.col_submission')}
+ {q.member ? t('club.review_from', { who: q.member }) : t('club.review_anon')}
+ {q.board && <>{t('club.review_board')}
+ {q.board.id} >}
+ {q.guess && <>ipctool {t('club.review_guess', { board: `${q.guess.manufacturer} ${q.guess.model}` })} {q.guess.model_id} >}
+ {(q.chip || q.sensor) && <>SoC {[q.chip, q.sensor].filter(Boolean).join(' · ')} >}
+ {q.note && <>{t('club.kind_note')} {q.note} >}
+
+
+ {q.file_list.map((f) => (
+
+ {t(`club.kind_${f.kind}`)} · {f.name}
+ {size(f.bytes)}
+ {f.private && {t('club.private')} }
+ {f.points > 0 ? `+${f.points} ★` : f.kind === 'backup' ? t('club.duplicate') : ''}
+
+ ))}
+
+ {q.yaml && (
+
+ {t('club.show_yaml')}
+ {q.yaml}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/apps/site/src/components/club/SendForm.test.tsx b/frontend/apps/site/src/components/club/SendForm.test.tsx
new file mode 100644
index 00000000..c2655a92
--- /dev/null
+++ b/frontend/apps/site/src/components/club/SendForm.test.tsx
@@ -0,0 +1,92 @@
+// @vitest-environment jsdom
+/**
+ * The board panel's send form, rendered: what it posts is what the service
+ * reads (service/internal/reports Submit) -- channel web, the board, the
+ * pasted text as the kind chosen, and a dump private unless ticked.
+ */
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { cleanup, fireEvent, render, waitFor } from '@testing-library/preact';
+import { useBoardsTranslations } from '../../lib/boards-i18n';
+import { clock, secondsLeft } from '../../lib/club';
+import SendForm from './SendForm.tsx';
+
+const t = useBoardsTranslations('en');
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ localStorage.clear();
+});
+
+function capture() {
+ const sent: FormData[] = [];
+ vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => {
+ sent.push(init?.body as FormData);
+ return new Response(JSON.stringify({ id: 'r-abcd2345', receipt_url: '', files: [] }), { status: 201 });
+ }));
+ return sent;
+}
+
+describe('the send form', () => {
+ test('posts the pasted console as uboot_env for the board, and shows the receipt', async () => {
+ const sent = capture();
+ const { container, getByText } = render( );
+ fireEvent.input(container.querySelector('textarea')!, { target: { value: 'bootcmd=sf probe 0\n' } });
+ fireEvent.submit(container.querySelector('form')!);
+ await waitFor(() => expect(getByText(/Received as r-abcd2345/)).toBeTruthy());
+ const f = sent[0];
+ expect(f.get('channel')).toBe('web');
+ expect(f.get('model')).toBe('anjoy-ms-j10');
+ expect(await (f.get('uboot_env') as File).text()).toBe('bootcmd=sf probe 0\n');
+ expect(f.get('consent')).toBeNull();
+ // A guest's receipt is the receipt page, not the club.
+ expect(container.querySelector('a[href="/cameras/report?id=r-abcd2345"]')).not.toBeNull();
+ });
+
+ test('a dump is private unless its owner ticks the box', async () => {
+ const sent = capture();
+ const { container, getByText } = render( );
+ const dump = new File([new Uint8Array(1 << 20)], 'flash.bin');
+ fireEvent.change(container.querySelector('input[type=file]')!, { target: { files: [dump] } });
+ fireEvent.submit(container.querySelector('form')!);
+ await waitFor(() => expect(getByText(/Received/)).toBeTruthy());
+ expect(sent[0].get('consent')).toBe('private');
+ expect((sent[0].get('backup') as File).name).toBe('flash.bin');
+ });
+
+ test('refuses to send nothing, without a request', () => {
+ const sent = capture();
+ const { container, getByRole } = render( );
+ fireEvent.submit(container.querySelector('form')!);
+ expect(getByRole('alert').textContent).toBe(t('club.send_empty'));
+ expect(sent).toHaveLength(0);
+ });
+
+ test('a visitor who never signed in is not asked who they are', () => {
+ const f = capture();
+ render( );
+ expect(f).toHaveLength(0);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+});
+
+describe('the Telegram countdown', () => {
+ test('counts down to zero and prints minutes', () => {
+ expect(secondsLeft('2026-10-02T10:10:00Z', Date.parse('2026-10-02T10:00:19Z'))).toBe(581);
+ expect(secondsLeft('2026-10-02T10:00:00Z', Date.parse('2026-10-02T10:05:00Z'))).toBe(0);
+ expect(clock(581)).toBe('9:41');
+ expect(clock(5)).toBe('0:05');
+ });
+});
+
+describe('a browser that signed in before', () => {
+ test('says nothing about signing in until it knows, then names the member', async () => {
+ localStorage.setItem('openipc.club', '1');
+ let answer: (r: Response) => void = () => {};
+ vi.stubGlobal('fetch', vi.fn(() => new Promise((r) => { answer = r; })));
+ const { queryByText, findByText } = render( );
+ expect(queryByText(/without signing in/)).toBeNull();
+ answer(new Response(JSON.stringify({ member: { id: 'm-1', name: 'Ivan', maintainer: false, quiet: false, identities: [], stars: 3, pending: 0 }, sign_in: {} })));
+ expect(await findByText(/Signed in as Ivan/)).toBeTruthy();
+ });
+});
diff --git a/frontend/apps/site/src/components/club/SendForm.tsx b/frontend/apps/site/src/components/club/SendForm.tsx
new file mode 100644
index 00000000..a3a27796
--- /dev/null
+++ b/frontend/apps/site/src/components/club/SendForm.tsx
@@ -0,0 +1,117 @@
+/**
+ * The board panel's "Have this board?" ask, answered on the page: paste a
+ * boot log or a U-Boot console, drop photos or a flash dump, and it goes to
+ * the owner-reports queue for the board (POST /api/v1/club/reports, channel
+ * web, model = the board). It replaced a prefilled GitHub issue (#365, #366).
+ *
+ * Signing in is not required. A signed-in sender's report is theirs: it is
+ * listed on /club with its state, its dump is private to them and the
+ * maintainers, and it earns stars once accepted.
+ */
+import { useEffect, useState } from 'preact/hooks';
+import type { BoardsT } from '../../lib/boards-i18n';
+import { pathFor, type Locale } from '../../lib/i18n';
+import { STARS, fetchMe, remembered, sendReport, type Member, type Sent } from '../../lib/club';
+
+export type SendKind = 'boot_log' | 'uboot_env' | 'photo' | 'backup';
+const KINDS: SendKind[] = ['uboot_env', 'boot_log', 'photo', 'backup'];
+
+export default function SendForm({ model, kind, locale, t, note }: {
+ model: string; kind: SendKind; locale: Locale; t: BoardsT; note?: string;
+}) {
+ const [chosen, setChosen] = useState(kind);
+ const [text, setText] = useState('');
+ const [files, setFiles] = useState([]);
+ const [extra, setExtra] = useState(note ?? '');
+ const [publicDump, setPublicDump] = useState(false);
+ // undefined while a browser that has signed in before asks who it is:
+ // the line under the button says nothing until it knows.
+ const [member, setMember] = useState(() => (remembered() ? undefined : null));
+ const [state, setState] = useState<{ s: 'idle' | 'sending' } | { s: 'sent'; sent: Sent } | { s: 'error'; error: string }>({ s: 'idle' });
+
+ useEffect(() => {
+ if (remembered()) fetchMe().then((me) => setMember(me.member)).catch(() => setMember(null));
+ }, []);
+
+ const textual = chosen === 'boot_log' || chosen === 'uboot_env';
+ const submit = (e: Event) => {
+ e.preventDefault();
+ if (!text.trim() && files.length === 0 && !extra.trim()) {
+ setState({ s: 'error', error: t('club.send_empty') });
+ return;
+ }
+ const form = new FormData();
+ form.set('channel', 'web');
+ form.set('model', model);
+ if (extra.trim()) form.set('note', extra.trim());
+ if (textual && text.trim()) form.append(chosen, new Blob([text], { type: 'text/plain' }), `${chosen}.txt`);
+ for (const f of files) form.append(chosen, f, f.name);
+ if (chosen === 'backup') form.set('consent', publicDump ? 'public' : 'private');
+ setState({ s: 'sending' });
+ sendReport(form)
+ .then((sent) => setState({ s: 'sent', sent }))
+ .catch((err: Error) => setState({ s: 'error', error: t('club.send_failed', { error: err.message }) }));
+ };
+
+ if (state.s === 'sent') {
+ const id = state.sent.id;
+ return (
+
+
{t(member ? 'club.sent_member' : 'club.sent_guest', { id })}
+
+ {member
+ ? {t('club.my_link')}
+ : {t('club.receipt')} }
+ { setText(''); setFiles([]); setState({ s: 'idle' }); }}>
+ {t('club.send_another')}
+
+
+
+ );
+ }
+
+ const reward = (k: SendKind) => t('club.reward', { n: k === 'backup' ? STARS.dump : STARS.item });
+ return (
+
+ );
+}
diff --git a/frontend/apps/site/src/components/club/parts.tsx b/frontend/apps/site/src/components/club/parts.tsx
new file mode 100644
index 00000000..efeb9957
--- /dev/null
+++ b/frontend/apps/site/src/components/club/parts.tsx
@@ -0,0 +1,24 @@
+/** Small pieces the club's islands share. */
+import type { ReportState } from '../../lib/reports';
+
+export const STATUS_TONE: Record = {
+ pending: 'border border-dashed border-hairline bg-surface-alt text-body-secondary',
+ published: 'bg-[#e3f5ec] text-[#146c3c]',
+ rejected: 'bg-[#fbe9ea] text-[#a3262e]',
+ withdrawn: 'bg-surface-alt text-body-secondary',
+};
+
+/**
+ * ★ 13. The big one's star is the site's amber, which is never text on
+ * white (3.6:1); the count beside it is ink, and the small form is the
+ * darker amber that passes.
+ */
+export function Stars({ n, big, onDark }: { n: number; big?: boolean; onDark?: boolean }) {
+ return big
+ ? (
+
+ ★ {n}
+
+ )
+ : ★ {n} ;
+}
diff --git a/frontend/apps/site/src/components/pages/Club.astro b/frontend/apps/site/src/components/pages/Club.astro
new file mode 100644
index 00000000..6988fae5
--- /dev/null
+++ b/frontend/apps/site/src/components/pages/Club.astro
@@ -0,0 +1,20 @@
+---
+/**
+ * The OpenIPC Club (service/internal/club): signing in with Telegram, GitHub
+ * or an emailed link, and a member's own page -- their stars and every report
+ * they sent through a board's send form. Static: the island asks
+ * /api/v1/club/me who this browser is.
+ */
+import Club from '../club/Club.tsx';
+import { useTranslations, type Locale } from '../../lib/i18n';
+
+interface Props { locale: Locale }
+const { locale } = Astro.props;
+const t = useTranslations(locale);
+---
+
+
{t('pages.club.eyebrow')}
+
{t('pages.club.title')}
+
{t('pages.club.lede')}
+
+
diff --git a/frontend/apps/site/src/components/pages/ClubReview.astro b/frontend/apps/site/src/components/pages/ClubReview.astro
new file mode 100644
index 00000000..cfbd3ec4
--- /dev/null
+++ b/frontend/apps/site/src/components/pages/ClubReview.astro
@@ -0,0 +1,19 @@
+---
+/**
+ * The maintainers' review queue for owner reports (service/internal/club).
+ * Only a member the service calls a maintainer -- a GitHub identity in the
+ * OpenIPC organisation -- gets the queue; everyone else is told so.
+ */
+import Review from '../club/Review.tsx';
+import { useTranslations, type Locale } from '../../lib/i18n';
+
+interface Props { locale: Locale }
+const { locale } = Astro.props;
+const t = useTranslations(locale);
+---
+
+
{t('pages.club_review.eyebrow')}
+
{t('pages.club_review.title')}
+
{t('pages.club_review.lede')}
+
+
diff --git a/frontend/apps/site/src/i18n/boards.en.json b/frontend/apps/site/src/i18n/boards.en.json
index 28fa441b..8226239d 100644
--- a/frontend/apps/site/src/i18n/boards.en.json
+++ b/frontend/apps/site/src/i18n/boards.en.json
@@ -10,6 +10,127 @@
"card_inside_likely": "Most likely inside:",
"clear": "Clear filters",
"close": "Close",
+ "club": {
+ "boards_link": "Camera boards",
+ "bot_muted": "The bot's messages are muted.",
+ "bot_on": "The OpenIPC bot tells you when something you sent is reviewed.",
+ "close": "Close",
+ "col_stars": "Stars",
+ "col_status": "Status",
+ "col_submission": "Submission",
+ "confirm_button": "Sign in",
+ "confirm_cancel": "Cancel",
+ "confirm_text": "This link was asked for in another browser. Sign in only if {who} is yours: whatever you send while signed in goes to that account.",
+ "confirm_title": "Sign in as {who}?",
+ "duplicate": "The catalogue already has this dump",
+ "email_button": "Email me a link",
+ "email_label": "We'll email you a link that signs you in. No password.",
+ "email_placeholder": "you@example.com",
+ "email_sent": "Check {email}: the link signs you in once, within ten minutes.",
+ "eyebrow": "OpenIPC Club",
+ "gh_button": "Continue with GitHub",
+ "gh_hint": "Maintainers sign in here",
+ "header_club": "Club",
+ "kind_backup": "Full flash dump",
+ "kind_boot_log": "Boot log",
+ "kind_document": "Document",
+ "kind_note": "Note",
+ "kind_photo": "Photo",
+ "kind_uboot_env": "U-Boot console",
+ "kind_yaml": "ipctool report",
+ "link_more": "Add another way in",
+ "load_failed": "The club could not be reached. Try again in a minute.",
+ "loading": "Loading…",
+ "mine_empty": "Nothing sent yet. Open your board in the catalogue and send what it prints.",
+ "mine_title": "My submissions",
+ "mute": "Mute the bot",
+ "my_link": "My submissions",
+ "no_board": "No board named",
+ "or": "or",
+ "pending": "+{n} waiting for review",
+ "private": "Only you and maintainers",
+ "provider_email": "Email",
+ "provider_github": "GitHub",
+ "provider_telegram": "Telegram",
+ "publish": "Publish",
+ "receipt": "Receipt",
+ "reject": "Reject",
+ "rename": "Change name",
+ "rename_label": "Your name, as it appears on the boards your reports reach",
+ "rename_save": "Save",
+ "review_anon": "Sent without signing in",
+ "review_board": "Board named by the sender",
+ "review_done_publish": "Published. {points} ★ to the sender.",
+ "review_done_reject": "Rejected.",
+ "review_empty": "Nothing is waiting.",
+ "review_forbidden": "Only OpenIPC's maintainers review. Sign in with GitHub as a member of the OpenIPC organisation.",
+ "review_from": "From {who}",
+ "review_guess": "ipctool says it looks like {board}",
+ "review_link": "Review queue",
+ "review_models": "Board ids to publish it on, comma-separated (empty: the board the sender named)",
+ "review_note": "Note for the sender",
+ "review_note_label": "Reviewer",
+ "review_points": "{points} ★",
+ "review_title": "Review queue",
+ "reward": "+{n} ★",
+ "rules_dump": "Full flash dump the catalogue doesn't have",
+ "rules_item": "Boot log, U-Boot console, photo, pinout, ipctool report",
+ "rules_none": "A dump the catalogue already holds, or anything not accepted",
+ "rules_note": "Stars count when a maintainer accepts what you sent, never on upload.",
+ "rules_title": "How stars work",
+ "send_another": "Send something else",
+ "send_button": "Send to the catalogue",
+ "send_dump": "The whole chip, as a programmer read it or as an ipctool backup",
+ "send_empty": "Add a file, a photo or some text first.",
+ "send_failed": "Not sent: {error}",
+ "send_file": "File",
+ "send_guest": "You can send without signing in. Sign in to collect stars and follow what happens to it.",
+ "send_kind_backup": "Full flash dump",
+ "send_kind_boot_log": "Boot log",
+ "send_kind_photo": "Photos",
+ "send_kind_uboot_env": "U-Boot console",
+ "send_kinds_label": "What you are sending",
+ "send_note": "Anything else: where you bought it, what it is sold as",
+ "send_paste": "Paste the text here, or choose a file below",
+ "send_photos": "Photos: the front, the back, the UART pins",
+ "send_public": "Publish the dump with the report. It holds Wi-Fi keys and passwords: leave this off to keep it private.",
+ "send_signed_in": "Signed in as {name}. Stars count once a maintainer accepts it.",
+ "sending": "Sending…",
+ "sent_guest": "Received as {id}. A maintainer reviews it before it is shown.",
+ "sent_member": "Received as {id}. A maintainer reviews it before it is shown; follow it in My submissions.",
+ "show_yaml": "ipctool output",
+ "sign_in_link": "Sign in",
+ "sign_out": "Sign out",
+ "signin_elsewhere": "That sign-in belongs to the browser that started it. Start again here.",
+ "signin_expired": "That sign-in link has expired or was already used. Start again below.",
+ "signin_failed": "The sign-in did not complete. Try again, or use another way in.",
+ "status_pending": "Waiting for review",
+ "status_published": "Accepted",
+ "status_rejected": "Not accepted",
+ "status_withdrawn": "Withdrawn",
+ "tab_pending": "Waiting",
+ "tab_published": "Published",
+ "tab_rejected": "Rejected",
+ "tg_button": "Continue with Telegram",
+ "tg_expired": "The code expired.",
+ "tg_hint": "No password: tap Start in the OpenIPC bot",
+ "tg_open": "Open Telegram",
+ "tg_other": "Use GitHub or email instead",
+ "tg_retry": "Get a new code",
+ "tg_step1": "Scan the code with your phone's camera, or open Telegram on this computer.",
+ "tg_step2": "Tap Start in the chat with {bot}, then Yes.",
+ "tg_step3": "Come back here. This page signs you in by itself.",
+ "tg_title": "Sign in with Telegram",
+ "tg_waiting": "Waiting for Telegram… This code works for {time} more.",
+ "unavailable": "Not available on this site yet.",
+ "unmute": "Unmute the bot",
+ "ways": "Signed in with",
+ "why_1": "See whether what you sent was accepted, and where it appears.",
+ "why_2": "Keep your flash dumps private: only you and OpenIPC's maintainers can download them.",
+ "why_3": "Collect stars, and your name next to the boards you documented.",
+ "why_note": "You don't need an account to browse or download firmware. The site sets no cookie for anyone who doesn't sign in.",
+ "why_title": "Why sign in"
+ },
"cov_boot_log": "Boot log",
"cov_document": "Docs",
"cov_flash_dump": "Flash dump",
@@ -285,6 +406,7 @@
"send_pinout": "Send us the pinout",
"send_uboot_env": "Send us the U-Boot console",
"sensor": "Sensor",
+ "sent_by": "Sent by {who}",
"shared_and": " and ",
"shared_colon": ": ",
"shared_count": {
diff --git a/frontend/apps/site/src/i18n/boards.ru.json b/frontend/apps/site/src/i18n/boards.ru.json
index 9af882b5..3514d1b0 100644
--- a/frontend/apps/site/src/i18n/boards.ru.json
+++ b/frontend/apps/site/src/i18n/boards.ru.json
@@ -12,6 +12,127 @@
"card_inside_likely": "Скорее всего внутри:",
"clear": "Сбросить фильтры",
"close": "Закрыть",
+ "club": {
+ "boards_link": "Платы камер",
+ "bot_muted": "Сообщения бота отключены.",
+ "bot_on": "Бот OpenIPC сообщает, когда присланное проверено.",
+ "close": "Закрыть",
+ "col_stars": "Звёзды",
+ "col_status": "Статус",
+ "col_submission": "Материал",
+ "confirm_button": "Войти",
+ "confirm_cancel": "Отмена",
+ "confirm_text": "Эту ссылку запросили в другом браузере. Входите, только если {who} — это вы: всё, что вы пришлёте после входа, попадёт в эту учётную запись.",
+ "confirm_title": "Войти как {who}?",
+ "duplicate": "Такой дамп в каталоге уже есть",
+ "email_button": "Прислать ссылку",
+ "email_label": "Пришлём на почту ссылку для входа. Без пароля.",
+ "email_placeholder": "you@example.com",
+ "email_sent": "Проверьте {email}: ссылка выполнит вход один раз в течение десяти минут.",
+ "eyebrow": "Клуб OpenIPC",
+ "gh_button": "Войти через GitHub",
+ "gh_hint": "Мейнтейнеры входят здесь",
+ "header_club": "Клуб",
+ "kind_backup": "Полный дамп флеш-памяти",
+ "kind_boot_log": "Лог загрузки",
+ "kind_document": "Документ",
+ "kind_note": "Заметка",
+ "kind_photo": "Фото",
+ "kind_uboot_env": "Консоль U-Boot",
+ "kind_yaml": "Отчёт ipctool",
+ "link_more": "Добавить способ входа",
+ "load_failed": "Не удалось связаться с клубом. Попробуйте через минуту.",
+ "loading": "Загрузка…",
+ "mine_empty": "Вы пока ничего не присылали. Откройте свою плату в каталоге и пришлите то, что она выводит.",
+ "mine_title": "Мои материалы",
+ "mute": "Отключить бота",
+ "my_link": "Мои материалы",
+ "no_board": "Плата не указана",
+ "or": "или",
+ "pending": "+{n} ждут проверки",
+ "private": "Только вам и мейнтейнерам",
+ "provider_email": "Почта",
+ "provider_github": "GitHub",
+ "provider_telegram": "Telegram",
+ "publish": "Опубликовать",
+ "receipt": "Квитанция",
+ "reject": "Отклонить",
+ "rename": "Изменить имя",
+ "rename_label": "Ваше имя — так оно показывается у плат, куда попали ваши материалы",
+ "rename_save": "Сохранить",
+ "review_anon": "Прислано без входа",
+ "review_board": "Плата, указанная отправителем",
+ "review_done_publish": "Опубликовано. Отправителю {points} ★.",
+ "review_done_reject": "Отклонено.",
+ "review_empty": "Ничего не ждёт проверки.",
+ "review_forbidden": "Проверяют только мейнтейнеры OpenIPC. Войдите через GitHub как участник организации OpenIPC.",
+ "review_from": "От {who}",
+ "review_guess": "По данным ipctool похоже на {board}",
+ "review_link": "Очередь проверки",
+ "review_models": "Id плат для публикации через запятую (пусто — плата, указанная отправителем)",
+ "review_note": "Пояснение для отправителя",
+ "review_note_label": "Проверяющий",
+ "review_points": "{points} ★",
+ "review_title": "Очередь проверки",
+ "reward": "+{n} ★",
+ "rules_dump": "Полный дамп флеш-памяти, которого нет в каталоге",
+ "rules_item": "Лог загрузки, консоль U-Boot, фото, распиновка, отчёт ipctool",
+ "rules_none": "Дамп, который уже есть в каталоге, или непринятый материал",
+ "rules_note": "Звёзды начисляются, когда мейнтейнер принимает присланное, а не при загрузке.",
+ "rules_title": "Как начисляются звёзды",
+ "send_another": "Прислать что-то ещё",
+ "send_button": "Отправить в каталог",
+ "send_dump": "Вся микросхема: как её считал программатор, или резервная копия ipctool",
+ "send_empty": "Сначала добавьте файл, фото или текст.",
+ "send_failed": "Не отправлено: {error}",
+ "send_file": "Файл",
+ "send_guest": "Отправить можно и без входа. Войдите, чтобы получать звёзды и следить за проверкой.",
+ "send_kind_backup": "Полный дамп флеш-памяти",
+ "send_kind_boot_log": "Лог загрузки",
+ "send_kind_photo": "Фото",
+ "send_kind_uboot_env": "Консоль U-Boot",
+ "send_kinds_label": "Что вы присылаете",
+ "send_note": "Что-нибудь ещё: где купили, под каким названием продаётся",
+ "send_paste": "Вставьте текст сюда или выберите файл ниже",
+ "send_photos": "Фото: лицевая сторона, обратная, контакты UART",
+ "send_public": "Опубликовать дамп вместе с отчётом. В нём ключи Wi-Fi и пароли: не отмечайте, чтобы он остался закрытым.",
+ "send_signed_in": "Вы вошли как {name}. Звёзды начислятся, когда мейнтейнер примет присланное.",
+ "sending": "Отправляем…",
+ "sent_guest": "Получено, номер {id}. Мейнтейнер проверит перед публикацией.",
+ "sent_member": "Получено, номер {id}. Мейнтейнер проверит перед публикацией; следите в разделе «Мои материалы».",
+ "show_yaml": "Вывод ipctool",
+ "sign_in_link": "Войти",
+ "sign_out": "Выйти",
+ "signin_elsewhere": "Этот вход принадлежит браузеру, в котором он начат. Начните заново здесь.",
+ "signin_expired": "Ссылка для входа устарела или уже использована. Начните заново ниже.",
+ "signin_failed": "Вход не завершился. Попробуйте ещё раз или выберите другой способ.",
+ "status_pending": "Ждёт проверки",
+ "status_published": "Принято",
+ "status_rejected": "Не принято",
+ "status_withdrawn": "Отозвано",
+ "tab_pending": "Ждут",
+ "tab_published": "Опубликованы",
+ "tab_rejected": "Отклонены",
+ "tg_button": "Войти через Telegram",
+ "tg_expired": "Код устарел.",
+ "tg_hint": "Без пароля: нажмите «Старт» в боте OpenIPC",
+ "tg_open": "Открыть Telegram",
+ "tg_other": "Войти через GitHub или почту",
+ "tg_retry": "Получить новый код",
+ "tg_step1": "Отсканируйте код камерой телефона или откройте Telegram на этом компьютере.",
+ "tg_step2": "Нажмите «Старт» в чате с {bot}, затем «Да».",
+ "tg_step3": "Вернитесь сюда: страница выполнит вход сама.",
+ "tg_title": "Вход через Telegram",
+ "tg_waiting": "Ждём Telegram… Код действует ещё {time}.",
+ "unavailable": "На этом сайте пока недоступно.",
+ "unmute": "Включить бота",
+ "ways": "Способы входа",
+ "why_1": "Видеть, приняли ли присланное и где оно появилось.",
+ "why_2": "Держать дампы флеш-памяти закрытыми: скачать их можете только вы и мейнтейнеры OpenIPC.",
+ "why_3": "Получать звёзды и видеть своё имя рядом с платами, которые вы описали.",
+ "why_note": "Чтобы смотреть каталог и скачивать прошивки, учётная запись не нужна. Тем, кто не входит, сайт не ставит ни одной cookie.",
+ "why_title": "Зачем входить"
+ },
"cov_boot_log": "Лог загрузки",
"cov_document": "Документы",
"cov_flash_dump": "Дамп флеш",
@@ -307,6 +428,7 @@
"send_pinout": "Пришлите нам распиновку",
"send_uboot_env": "Пришлите нам консоль U-Boot",
"sensor": "Сенсор",
+ "sent_by": "Прислано: {who}",
"shared_and": " и ",
"shared_colon": ": ",
"shared_count": {
diff --git a/frontend/apps/site/src/i18n/boards.zh.json b/frontend/apps/site/src/i18n/boards.zh.json
index 64504b87..301bd047 100644
--- a/frontend/apps/site/src/i18n/boards.zh.json
+++ b/frontend/apps/site/src/i18n/boards.zh.json
@@ -10,6 +10,127 @@
"card_inside_likely": "很可能内置:",
"clear": "清除筛选",
"close": "关闭",
+ "club": {
+ "boards_link": "摄像头电路板",
+ "bot_muted": "机器人消息已静音。",
+ "bot_on": "提交内容被审核后,OpenIPC 机器人会通知你。",
+ "close": "关闭",
+ "col_stars": "星星",
+ "col_status": "状态",
+ "col_submission": "提交内容",
+ "confirm_button": "登录",
+ "confirm_cancel": "取消",
+ "confirm_text": "此链接是在另一个浏览器中请求的。只有当 {who} 是你本人时才登录:登录后提交的内容都会进入该账户。",
+ "confirm_title": "以 {who} 身份登录?",
+ "duplicate": "目录中已有此转储",
+ "email_button": "发送登录链接",
+ "email_label": "我们会通过邮件发送登录链接,无需密码。",
+ "email_placeholder": "you@example.com",
+ "email_sent": "请查收 {email}:链接仅可使用一次,十分钟内有效。",
+ "eyebrow": "OpenIPC 俱乐部",
+ "gh_button": "使用 GitHub 登录",
+ "gh_hint": "维护者从这里登录",
+ "header_club": "俱乐部",
+ "kind_backup": "完整闪存转储",
+ "kind_boot_log": "启动日志",
+ "kind_document": "文档",
+ "kind_note": "备注",
+ "kind_photo": "照片",
+ "kind_uboot_env": "U-Boot 控制台",
+ "kind_yaml": "ipctool 报告",
+ "link_more": "添加其他登录方式",
+ "load_failed": "无法连接俱乐部服务,请稍后再试。",
+ "loading": "加载中…",
+ "mine_empty": "还没有提交。在目录中打开你的电路板,提交它的输出。",
+ "mine_title": "我的提交",
+ "mute": "静音机器人",
+ "my_link": "我的提交",
+ "no_board": "未指定电路板",
+ "or": "或",
+ "pending": "+{n} 等待审核",
+ "private": "仅你和维护者可见",
+ "provider_email": "邮箱",
+ "provider_github": "GitHub",
+ "provider_telegram": "Telegram",
+ "publish": "发布",
+ "receipt": "回执",
+ "reject": "拒绝",
+ "rename": "修改名字",
+ "rename_label": "你的名字,会显示在你的提交所属的电路板上",
+ "rename_save": "保存",
+ "review_anon": "未登录提交",
+ "review_board": "提交者指定的电路板",
+ "review_done_publish": "已发布。提交者获得 {points} ★。",
+ "review_done_reject": "已拒绝。",
+ "review_empty": "没有待审核的内容。",
+ "review_forbidden": "只有 OpenIPC 维护者可以审核。请以 OpenIPC 组织成员身份使用 GitHub 登录。",
+ "review_from": "来自 {who}",
+ "review_guess": "ipctool 显示类似 {board}",
+ "review_link": "审核队列",
+ "review_models": "发布到的电路板 id,用逗号分隔(留空:提交者指定的电路板)",
+ "review_note": "给提交者的说明",
+ "review_note_label": "审核者",
+ "review_points": "{points} ★",
+ "review_title": "审核队列",
+ "reward": "+{n} ★",
+ "rules_dump": "目录中没有的完整闪存转储",
+ "rules_item": "启动日志、U-Boot 控制台、照片、引脚图、ipctool 报告",
+ "rules_none": "目录中已有的转储,或未通过的内容",
+ "rules_note": "维护者通过你的提交时才计星,上传时不计。",
+ "rules_title": "星星如何计算",
+ "send_another": "再提交其他内容",
+ "send_button": "提交到目录",
+ "send_dump": "整颗芯片:编程器读出的镜像,或 ipctool 备份",
+ "send_empty": "请先添加文件、照片或文本。",
+ "send_failed": "未提交:{error}",
+ "send_file": "文件",
+ "send_guest": "无需登录也可提交。登录后可获得星星并跟踪审核进度。",
+ "send_kind_backup": "完整闪存转储",
+ "send_kind_boot_log": "启动日志",
+ "send_kind_photo": "照片",
+ "send_kind_uboot_env": "U-Boot 控制台",
+ "send_kinds_label": "提交内容类型",
+ "send_note": "其他信息:购买渠道、商品名称",
+ "send_paste": "在此粘贴文本,或在下方选择文件",
+ "send_photos": "照片:正面、背面、UART 引脚",
+ "send_public": "随报告公开此转储。其中包含 Wi-Fi 密钥和密码:不勾选则保持私密。",
+ "send_signed_in": "已以 {name} 登录。维护者通过后计星。",
+ "sending": "提交中…",
+ "sent_guest": "已收到,编号 {id}。维护者审核后才会公开。",
+ "sent_member": "已收到,编号 {id}。维护者审核后才会公开;可在“我的提交”中跟踪。",
+ "show_yaml": "ipctool 输出",
+ "sign_in_link": "登录",
+ "sign_out": "退出登录",
+ "signin_elsewhere": "该登录只属于发起它的浏览器,请在此重新开始。",
+ "signin_expired": "该登录链接已过期或已被使用,请在下方重新开始。",
+ "signin_failed": "登录未完成,请重试或换一种方式。",
+ "status_pending": "等待审核",
+ "status_published": "已通过",
+ "status_rejected": "未通过",
+ "status_withdrawn": "已撤回",
+ "tab_pending": "待审核",
+ "tab_published": "已发布",
+ "tab_rejected": "已拒绝",
+ "tg_button": "使用 Telegram 登录",
+ "tg_expired": "此码已过期。",
+ "tg_hint": "无需密码:在 OpenIPC 机器人中点击“开始”",
+ "tg_open": "打开 Telegram",
+ "tg_other": "改用 GitHub 或邮箱",
+ "tg_retry": "获取新码",
+ "tg_step1": "用手机相机扫描二维码,或在本电脑上打开 Telegram。",
+ "tg_step2": "在与 {bot} 的对话中点击“开始”,然后点“是”。",
+ "tg_step3": "回到此页面,它会自动完成登录。",
+ "tg_title": "使用 Telegram 登录",
+ "tg_waiting": "正在等待 Telegram… 此码还剩 {time} 有效。",
+ "unavailable": "本站暂不可用。",
+ "unmute": "取消静音",
+ "ways": "登录方式",
+ "why_1": "查看你提交的内容是否通过审核,以及出现在哪里。",
+ "why_2": "让固件转储保持私密:只有你和 OpenIPC 维护者可以下载。",
+ "why_3": "获得星星,并在你记录的电路板旁显示你的名字。",
+ "why_note": "浏览目录或下载固件无需账户。对不登录的访客,本站不设置任何 cookie。",
+ "why_title": "为什么要登录"
+ },
"cov_boot_log": "启动日志",
"cov_document": "文档",
"cov_flash_dump": "闪存转储",
@@ -285,6 +406,7 @@
"send_pinout": "把引脚图发给我们",
"send_uboot_env": "把 U-Boot 控制台输出发给我们",
"sensor": "传感器",
+ "sent_by": "{who} 提供",
"shared_and": "和",
"shared_colon": ":",
"shared_count": {
diff --git a/frontend/apps/site/src/i18n/en.json b/frontend/apps/site/src/i18n/en.json
index d194f28b..925d3da7 100644
--- a/frontend/apps/site/src/i18n/en.json
+++ b/frontend/apps/site/src/i18n/en.json
@@ -153,6 +153,16 @@
"why4_title": "Open source is your escrow",
"why_title": "Why companies build on OpenIPC"
},
+ "club": {
+ "eyebrow": "OpenIPC Club",
+ "lede": "Sign in to follow what you sent to the board catalogue, keep your flash dumps private, and collect stars for what is accepted.",
+ "title": "OpenIPC Club"
+ },
+ "club_review": {
+ "eyebrow": "OpenIPC Club",
+ "lede": "Owner reports waiting for a maintainer's decision.",
+ "title": "Review queue"
+ },
"community": {
"bot_warning": "New members answer two quick questions from our Welcome Bot after joining. If you miss the prompt and cannot post, leave and re-join the group, then watch for the bot's message.",
"channel_dev": "Build notifications straight from GitHub.",
diff --git a/frontend/apps/site/src/i18n/ru.json b/frontend/apps/site/src/i18n/ru.json
index 8912e072..d30b49d5 100644
--- a/frontend/apps/site/src/i18n/ru.json
+++ b/frontend/apps/site/src/i18n/ru.json
@@ -153,6 +153,16 @@
"why4_title": "Open source — ваша страховка",
"why_title": "Почему компании строят на OpenIPC"
},
+ "club": {
+ "eyebrow": "Клуб OpenIPC",
+ "lede": "Войдите, чтобы следить за тем, что вы прислали в каталог плат, держать дампы флеш-памяти закрытыми и получать звёзды за принятое.",
+ "title": "Клуб OpenIPC"
+ },
+ "club_review": {
+ "eyebrow": "Клуб OpenIPC",
+ "lede": "Отчёты владельцев, которые ждут решения мейнтейнера.",
+ "title": "Очередь проверки"
+ },
"community": {
"bot_warning": "Новые участники отвечают на два коротких вопроса нашего приветственного бота. Если пропустили сообщение и не можете писать — выйдите из группы, зайдите снова и дождитесь сообщения бота.",
"channel_dev": "Уведомления о сборках прямо из GitHub.",
diff --git a/frontend/apps/site/src/i18n/zh.json b/frontend/apps/site/src/i18n/zh.json
index 7d80844b..0656967e 100644
--- a/frontend/apps/site/src/i18n/zh.json
+++ b/frontend/apps/site/src/i18n/zh.json
@@ -153,6 +153,16 @@
"why4_title": "开源就是你的托管保障",
"why_title": "企业为什么选择 OpenIPC"
},
+ "club": {
+ "eyebrow": "OpenIPC 俱乐部",
+ "lede": "登录后可跟踪你提交到电路板目录的内容、让固件转储保持私密,并为通过审核的内容获得星星。",
+ "title": "OpenIPC 俱乐部"
+ },
+ "club_review": {
+ "eyebrow": "OpenIPC 俱乐部",
+ "lede": "等待维护者审核的用户报告。",
+ "title": "审核队列"
+ },
"community": {
"bot_warning": "新成员加入后需要回答欢迎机器人提出的两个简单问题。如果你错过了提示且无法发言,请退出并重新加入群组,然后留意机器人的消息。",
"channel_dev": "直接来自 GitHub 的构建通知。",
diff --git a/frontend/apps/site/src/lib/boards/boards.test.ts b/frontend/apps/site/src/lib/boards/boards.test.ts
index 51c997ad..abfc251b 100644
--- a/frontend/apps/site/src/lib/boards/boards.test.ts
+++ b/frontend/apps/site/src/lib/boards/boards.test.ts
@@ -11,7 +11,7 @@ import zh from '../../i18n/boards.zh.json';
import {
KNOWN_LINES, lineLabel,
addsIPeye, buildGroups, bySeller, cardFiles, formatDay, foundIn, insideOf, cardPhotos, codeIndex, couplerDevices, deviceIdOf, entries, kindOf, tally, filterBoards, filterHits, heading, matchBoards, newestFirst, printedCode, firstMissing, flashOf, formatBytes, frontPhoto,
- highlight, layout, lead, lineOptions, linkCodes, lines, normaliseCode, ownPhoto, paragraphs, sensorKey, sensorOptions, slug,
+ highlight, layout, lead, lineOptions, linkCodes, lines, normaliseCode, ownPhoto, paragraphs, sensorKey, sensorOptions, sentBy, slug,
socKey, socOptions, stats, subtitle, unitFiles, unitPhotos,
} from './model';
import { EMPTY, readQueryString, writeQueryString } from './url';
@@ -377,6 +377,23 @@ describe('stats', () => {
});
});
+describe('who sent a unit', () => {
+ const unit = (source: string, source_ref: string, contributed_by: string) => ({ source, source_ref, contributed_by });
+
+ test('an owner, credited with the issue they sent it in', () => {
+ expect(sentBy(unit('contributor', 'https://github.com/OpenIPC/website/issues/365', 'sansarus')))
+ .toEqual({ who: 'sansarus', url: 'https://github.com/OpenIPC/website/issues/365', label: '#365' });
+ expect(sentBy(unit('contributor', 'https://github.com/OpenIPC/firmware/issues/12', 'x'))?.label).toBe('firmware#12');
+ expect(sentBy(unit('contributor', 'https://example.org/post/1', 'x'))?.label).toBe('example.org');
+ expect(sentBy(unit('contributor', 'https://openipc.org/cameras/report/?id=r-abcd2345', 'Ivan'))?.label).toBe('r-abcd2345');
+ expect(sentBy(unit('contributor', 'not a url', 'x'))).toEqual({ who: 'x', url: '', label: '' });
+ });
+
+ test('nobody, for a unit a catalogue brought', () => {
+ expect(sentBy(unit('openhisiipcam', 'https://github.com/OpenHisiIpCam', 'OpenHisiIpCam'))).toBeNull();
+ });
+});
+
describe('a card', () => {
const m = model('e', {}, null, [
photo('photo_other', 'o.jpg'), photo('photo_front', 'f1.jpg'), photo('photo_front', 'f2.jpg'),
diff --git a/frontend/apps/site/src/lib/boards/model.ts b/frontend/apps/site/src/lib/boards/model.ts
index c1fe6859..7497f03d 100644
--- a/frontend/apps/site/src/lib/boards/model.ts
+++ b/frontend/apps/site/src/lib/boards/model.ts
@@ -5,7 +5,7 @@
* a description that link to other boards. Pure functions, so they are
* tested without a browser.
*/
-import type { BoardFile, BoardsFile, Content, Hit, Manufacturer, Model, ModelBuild, VendorFirmware } from './types';
+import type { BoardFile, BoardsFile, Content, Hit, Manufacturer, Model, ModelBuild, Unit, VendorFirmware } from './types';
import type { BoardsState, Missing } from './url';
/** A board model with the manufacturer it is filed under. */
@@ -58,6 +58,27 @@ export function firstMissing(m: Model): CoverageKey | null {
return ASK.find((k) => !(k === 'photos' ? ownPhoto(m) : has(m, k))) ?? null;
}
+/**
+ * Who sent a unit and where, when an owner did (source contributor): the
+ * sender as they signed, and the issue as "#365" (another repository's as
+ * "firmware#12"), a report's receipt as its id, any other page as its host. Null for a unit a catalogue
+ * brought.
+ */
+export function sentBy(u: Pick): { who: string; url: string; label: string } | null {
+ if (u.source !== 'contributor' || !u.contributed_by) return null;
+ let url: URL;
+ try {
+ url = new URL(u.source_ref);
+ } catch {
+ return { who: u.contributed_by, url: '', label: '' };
+ }
+ const issue = url.host === 'github.com' ? url.pathname.match(/^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)$/) : null;
+ // A report sent through the board's send form links to its receipt.
+ const receipt = /\/cameras\/report\/?$/.test(url.pathname) ? url.searchParams.get('id') : null;
+ const label = receipt ?? (!issue ? url.host : `${issue[1] === 'OpenIPC' && issue[2] === 'website' ? '' : issue[2]}#${issue[3]}`);
+ return { who: u.contributed_by, url: u.source_ref, label };
+}
+
/**
* The key a sensor is filtered by: the part without its maker, upper-cased,
* so "SONY IMX323" and "Sony imx323" are one sensor.
diff --git a/frontend/apps/site/src/lib/club.ts b/frontend/apps/site/src/lib/club.ts
new file mode 100644
index 00000000..8e667109
--- /dev/null
+++ b/frontend/apps/site/src/lib/club.ts
@@ -0,0 +1,186 @@
+/**
+ * The OpenIPC Club, as the site's own API answers it (service/internal/club):
+ * who this browser is signed in as, the ways in, the member's own reports
+ * and the maintainers' review queue.
+ *
+ * The session cookie's path is /api/v1/club, so the pages never carry it and
+ * stay cached for everyone; a page learns who is signed in by asking
+ * /api/v1/club/me. To spare every visitor that request, a browser that has
+ * signed in remembers so in localStorage, and only then does the header ask.
+ */
+import type { ReportFile, ReportState } from './reports';
+
+export interface Identity { provider: 'telegram' | 'github' | 'email'; handle: string; chat?: boolean }
+
+export interface Member {
+ id: string;
+ name: string;
+ maintainer: boolean;
+ quiet: boolean;
+ identities: Identity[];
+ stars: number;
+ pending: number;
+}
+
+export interface Me {
+ member: Member | null;
+ sign_in: { telegram: string | null; github: boolean; email: boolean };
+}
+
+export interface MemberFile {
+ position: number;
+ kind: ReportFile['kind'];
+ name: string;
+ bytes: number;
+ private?: boolean;
+ url: string;
+ points: number;
+}
+
+export interface BoardRef { id: string; model: string; manufacturer: string }
+
+export interface MemberReport {
+ id: string;
+ received_at: string;
+ status: ReportState;
+ reviewed_at?: string;
+ note?: string;
+ review_note?: string;
+ board?: BoardRef;
+ chip?: string;
+ files: MemberFile[];
+ stars: number;
+ pending: number;
+ duplicate?: boolean;
+}
+
+export interface Queued {
+ id: string;
+ received_at: string;
+ channel: string;
+ status: ReportState;
+ chip: string;
+ sensor: string;
+ board: BoardRef | null;
+ models: string[];
+ backup_consent: string;
+ note?: string;
+ tool?: string;
+ yaml?: string;
+ member?: string;
+ guess?: { model_id: string; model: string; manufacturer: string };
+ file_list: MemberFile[];
+ potential: number;
+}
+
+const BASE = '/api/v1/club';
+const FLAG = 'openipc.club';
+
+export class ClubError extends Error {
+ constructor(public status: number, message: string) { super(message); }
+}
+
+async function call(path: string, init?: RequestInit): Promise {
+ const r = await fetch(BASE + path, { credentials: 'same-origin', ...init, headers: { Accept: 'application/json', ...(init?.headers ?? {}) } });
+ let body: unknown = null;
+ try { body = await r.json(); } catch { /* an empty or HTML answer */ }
+ if (!r.ok) throw new ClubError(r.status, (body as { error?: string } | null)?.error ?? `HTTP ${r.status}`);
+ return body as T;
+}
+
+const post = (path: string, data?: unknown) => call(path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: data === undefined ? undefined : JSON.stringify(data),
+});
+
+/** The flag the header reads; storage can be refused, and then it is just absent. */
+export function remembered(): boolean {
+ try { return localStorage.getItem(FLAG) === '1'; } catch { return false; }
+}
+
+/** The event the navbar's badge listens for: who is signed in now, or null. */
+export const CHANGED = 'openipc-club';
+
+function remember(member: Member | null) {
+ try {
+ if (member) localStorage.setItem(FLAG, '1');
+ else localStorage.removeItem(FLAG);
+ } catch { /* private mode */ }
+ if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent(CHANGED, { detail: member }));
+}
+
+export async function fetchMe(): Promise {
+ const me = await call('/me');
+ remember(me.member);
+ return me;
+}
+
+export const startTelegram = () => post<{ link: string; expires_at: string }>('/telegram');
+
+export type Poll =
+ | { state: 'none' | 'expired' }
+ | { state: 'pending'; expires_at: string }
+ | { state: 'signed_in'; member: Member };
+
+export async function pollLogin(): Promise {
+ const p = await call('/login');
+ if (p.state === 'signed_in') remember(p.member);
+ return p;
+}
+
+export const startEmail = (email: string, locale: string) => post<{ sent: boolean }>('/email', { email, locale });
+
+export async function signOut(): Promise {
+ await post('/logout');
+ remember(null);
+}
+
+export const setQuiet = (quiet: boolean) => post<{ member: Member }>('/quiet', { quiet });
+
+export async function rename(name: string): Promise {
+ const r = await post<{ member: Member }>('/name', { name });
+ remember(r.member);
+ return r.member;
+}
+
+/** Whose account a link opened in another browser signs into. */
+export const finishWho = (code: string) =>
+ call<{ provider: 'telegram' | 'email'; who: string }>(`/finish/who?${new URLSearchParams({ code })}`);
+
+export async function finishConfirm(code: string): Promise {
+ const r = await post<{ member: Member }>('/finish', { code });
+ remember(r.member);
+ return r.member;
+}
+
+export const fetchMine = () => call<{ member: Member; reports: MemberReport[] }>('/reports');
+
+export const fetchQueue = (status = 'pending') => call<{ reports: Queued[] }>(`/review?${new URLSearchParams({ status })}`);
+
+export const decide = (id: string, decision: 'publish' | 'reject', models: string[], note: string) =>
+ post<{ points: number; total: number }>(`/review/${encodeURIComponent(id)}`, { decision, models, note });
+
+/** The send form's answer: the report's receipt. */
+export interface Sent { id: string; receipt_url: string; files: { kind: string; name: string; private?: boolean }[] }
+
+export async function sendReport(form: FormData): Promise {
+ const r = await fetch(`${BASE}/reports`, { method: 'POST', body: form, credentials: 'same-origin' });
+ let body: unknown = null;
+ try { body = await r.json(); } catch { /* not JSON */ }
+ if (!r.ok) throw new ClubError(r.status, (body as { error?: string } | null)?.error ?? `HTTP ${r.status}`);
+ return body as Sent;
+}
+
+/** What one accepted thing earns, as the service counts it (reports.Points). */
+export const STARS = { item: 1, dump: 10 } as const;
+
+/** Seconds left until an ISO time, never below zero. */
+export function secondsLeft(iso: string, now = Date.now()): number {
+ return Math.max(0, Math.round((Date.parse(iso) - now) / 1000));
+}
+
+/** 9:41 */
+export function clock(seconds: number): string {
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
+}
diff --git a/frontend/apps/site/src/lib/page-paths.ts b/frontend/apps/site/src/lib/page-paths.ts
index 6a4efa2c..cf32a0d3 100644
--- a/frontend/apps/site/src/lib/page-paths.ts
+++ b/frontend/apps/site/src/lib/page-paths.ts
@@ -91,6 +91,11 @@ export const PAGE_PATHS: PagePath[] = [
{ path: '/cameras/boards', titleKey: 'pages.boards.title', descriptionKey: 'pages.boards.lede' },
// How a new board reaches the catalogue: ipctool or an agent, and a report's receipt (?id=).
{ path: '/cameras/report', titleKey: 'pages.report.title', descriptionKey: 'pages.report.lede' },
+ // The OpenIPC Club: signing in, a member's own reports and stars (service/internal/club).
+ { path: '/club', titleKey: 'pages.club.title', descriptionKey: 'pages.club.lede' },
+ // The maintainers' review queue. The page is a shell: the queue is only
+ // ever in the answer /api/v1/club/review gives a maintainer.
+ { path: '/club/review', titleKey: 'pages.club_review.title', descriptionKey: 'pages.club_review.lede' },
{ path: '/business', titleKey: 'pages.business.title' },
{ path: '/community', titleKey: 'pages.community.title' },
diff --git a/frontend/apps/site/src/lib/pages.ts b/frontend/apps/site/src/lib/pages.ts
index b4e29b98..155d6df8 100644
--- a/frontend/apps/site/src/lib/pages.ts
+++ b/frontend/apps/site/src/lib/pages.ts
@@ -19,6 +19,8 @@ import Smoke from '../components/Smoke.astro';
import Wall from '../components/pages/Wall.astro';
import Boards from '../components/pages/Boards.astro';
import Report from '../components/pages/Report.astro';
+import Club from '../components/pages/Club.astro';
+import ClubReview from '../components/pages/ClubReview.astro';
import Business from '../components/pages/Business.astro';
import Community from '../components/pages/Community.astro';
import Donate from '../components/pages/Donate.astro';
@@ -92,6 +94,8 @@ const COMPONENTS: Record = {
// The board catalogue, beside the SoC catalogue it links into.
'/cameras/boards': { component: Boards },
'/cameras/report': { component: Report },
+ '/club': { component: Club },
+ '/club/review': { component: ClubReview },
'/business': { component: Business },
'/community': { component: Community },
diff --git a/service/README.md b/service/README.md
index 65ee7425..46e949e9 100644
--- a/service/README.md
+++ b/service/README.md
@@ -110,6 +110,33 @@ The goldens below are fixed: nothing regenerates them.
replaced by keyed hashes; a backup is served only if its owner sent
`consent=public`.
+- **The club signs people in without making the site dynamic.** Telegram,
+ GitHub or an emailed link (`internal/club`, each only when its settings
+ are there), one session cookie whose path is `/api/v1/club`, and pages
+ that stay static: a page asks `/api/v1/club/me` who it is showing. A
+ Telegram sign-in is tied to the browser that showed the code, so a
+ forwarded QR code signs in nobody else. Stars are rows in an append-only
+ ledger (`report_stars`) that only a review writes: +1 per accepted item,
+ +10 per flash dump the catalogue did not already hold, and the negative
+ of each when a published report is rejected afterwards.
+
+ | setting | what it turns on |
+ |---|---|
+ | `CLUB_SITE_URL` | where links point and cookies are for (`https://dev.openipc.org` on dev) |
+ | `TELEGRAM_BOT_TOKEN` | the bot, one per environment: @OpenIPCClubBot_bot, @OpenIPCClubBotDev_bot; its webhook is set at start |
+ | `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET` | GitHub sign-in; callback `/api/v1/club/github/callback` |
+ | `CLUB_MAINTAINER_ORG` | its active members review (default `OpenIPC`) |
+ | `CLUB_MAINTAINERS` | member ids that review without it |
+ | `CLUB_SMTP_ADDR`, `CLUB_SMTP_USER`, `CLUB_SMTP_PASSWORD`, `CLUB_MAIL_FROM` | email sign-in: `172.18.0.1:25`, the host's own exim on the docker bridge (no TLS on that hop, so no password either); any other relay must offer STARTTLS |
+
+ The host's exim says HELO as `webber-eu.openipc.org`, the PTR of
+ 37.27.251.71, and signs openipc.org's DKIM with selector `webber2026`
+ (`/etc/exim4/dkim/`, `/etc/exim4/conf.d/main/00_local_macros`). In
+ openipc.org's zone on Hetzner DNS: SPF `v=spf1 ip4:194.58.109.202
+ ip4:37.27.251.71 ~all` (natrium and this host), `webber2026._domainkey`,
+ `_dmarc` at `p=none`, and `webber-eu` with its own A and `v=spf1 a -all`.
+ mail-tester scored a sign-in link 10/10 on 2026-10-02.
+
## Operating it
- `deploy/install-go-service.sh`: PostgreSQL, the two databases, and
diff --git a/service/cmd/openipc/boards.go b/service/cmd/openipc/boards.go
index 3cc1e0fc..b73c6b8a 100644
--- a/service/cmd/openipc/boards.go
+++ b/service/cmd/openipc/boards.go
@@ -104,8 +104,32 @@ func importSnapshot(ctx context.Context, cfg *config.Config, log *slog.Logger, a
return err
}
log.Info("boards: snapshot imported", "source", *source, "new_models", n)
- // A confirmation waiting for a device this snapshot brought applies now.
- return applyConfirmations(ctx, pool, log)
+ // A confirmation or a contribution waiting for a model this snapshot
+ // brought applies now.
+ if err := applyConfirmations(ctx, pool, log); err != nil {
+ return err
+ }
+ return applyContributions(ctx, pool, log, cfg.BoardsRoot)
+}
+
+// applyContributions publishes contributions.yml: what owners sent about
+// their own boards, as units credited to them. An entry whose model the
+// catalogue does not have yet waits, reported, for the import that brings it.
+func applyContributions(ctx context.Context, pool *pgxpool.Pool, log *slog.Logger, root string) error {
+ list, err := boards.Contributions()
+ if err != nil {
+ return err
+ }
+ im := &boards.Importer{Pool: pool, Log: log, Root: root}
+ missing, err := im.ApplyContributions(ctx, list)
+ if err != nil {
+ return fmt.Errorf("boards: contributions.yml: %w", err)
+ }
+ for _, m := range missing {
+ log.Warn("boards: contributions.yml names a model the catalogue does not have", "model", m)
+ }
+ log.Info("boards: contributions applied", "units", len(list)-len(missing))
+ return nil
}
// applyConfirmations publishes contents.yml: what owners found inside the
diff --git a/service/cmd/openipc/club.go b/service/cmd/openipc/club.go
new file mode 100644
index 00000000..fe959a8d
--- /dev/null
+++ b/service/cmd/openipc/club.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/OpenIPC/website/service/internal/boards"
+ "github.com/OpenIPC/website/service/internal/club"
+ "github.com/OpenIPC/website/service/internal/config"
+ "github.com/OpenIPC/website/service/internal/reports"
+)
+
+// newClub builds the club from the settings. A way in that is not
+// configured is simply not offered; the bot learns its name and sets its
+// webhook in the background, and until it has, Telegram is not offered.
+func newClub(bg context.Context, cfg *config.Config, log *slog.Logger, pool *pgxpool.Pool, ownerReports *reports.API) *club.API {
+ httpc := &http.Client{Timeout: 20 * time.Second}
+ api := &club.API{DB: pool, Log: log, Reports: ownerReports,
+ Cfg: club.Config{SiteURL: cfg.ClubSiteURL, MaintainerOrg: cfg.ClubMaintainerOrg, Maintainers: cfg.ClubMaintainers},
+ OnReviewed: func(ctx context.Context) { refreshReportUnits(ctx, cfg, log, pool) },
+ }
+ if cfg.TelegramBotToken != "" {
+ api.Telegram = &club.Telegram{Token: cfg.TelegramBotToken, API: "https://api.telegram.org", HTTP: httpc, Log: log}
+ go func() {
+ for attempt := 0; ; attempt++ {
+ err := api.Telegram.Start(bg, cfg.ClubSiteURL)
+ if err == nil {
+ log.Info("club: telegram bot ready", "bot", api.Telegram.Username())
+ return
+ }
+ log.Warn("club: telegram bot not ready", "err", err)
+ select {
+ case <-bg.Done():
+ return
+ case <-time.After(time.Duration(min(attempt+1, 10)) * time.Minute):
+ }
+ }
+ }()
+ }
+ if cfg.GitHubClientID != "" && cfg.GitHubClientSecret != "" {
+ api.GitHub = &club.GitHub{ClientID: cfg.GitHubClientID, Secret: cfg.GitHubClientSecret,
+ Web: "https://github.com", API: "https://api.github.com", HTTP: httpc}
+ }
+ if cfg.SMTPAddr != "" {
+ api.Mail = &club.SMTP{Addr: cfg.SMTPAddr, User: cfg.SMTPUser, Password: cfg.SMTPPassword, From: cfg.MailFrom}
+ }
+ return api
+}
+
+// refreshReportUnits lists every published owner report's text and photos
+// on the boards it was linked to, as contributed units: searchable and
+// counted like any board's files. It runs at start and after each review;
+// a failure is logged, and the next run catches up.
+func refreshReportUnits(ctx context.Context, cfg *config.Config, log *slog.Logger, pool *pgxpool.Pool) {
+ texts, err := (&reports.Store{DB: pool}).PublishedTexts(ctx)
+ if err != nil {
+ log.Error("boards: published reports unreadable", "err", err)
+ return
+ }
+ kinds := map[string]string{"photo": "photo_other", "boot_log": "boot_log", "uboot_env": "uboot_env", "note": "note"}
+ var list []boards.Contribution
+ for _, t := range texts {
+ by := t.By
+ if by == "" {
+ by = "an owner"
+ }
+ // A report published on several boards is a unit on each, and each
+ // unit's reference is its own (board_units.source_ref is unique).
+ c := boards.Contribution{Unit: t.Model + "-" + t.Report, Model: t.Model, By: by,
+ Evidence: []string{cfg.ClubSiteURL + boards.ReceiptMark + t.Report + "&board=" + t.Model}}
+ for _, f := range t.Files {
+ c.Files = append(c.Files, boards.ContributedFile{Kind: kinds[f.Kind], File: fmt.Sprintf("%d-%s", f.Position, f.Name), Source: f.Path})
+ }
+ list = append(list, c)
+ }
+ im := &boards.Importer{Pool: pool, Log: log, Root: cfg.BoardsRoot}
+ missing, err := im.ApplyReportUnits(ctx, os.DirFS(cfg.ReportsRoot), list)
+ if err != nil {
+ log.Error("boards: published reports not listed", "err", err)
+ return
+ }
+ for _, m := range missing {
+ log.Warn("boards: a published report names a board the catalogue does not have", "model", m)
+ }
+}
diff --git a/service/cmd/openipc/main.go b/service/cmd/openipc/main.go
index 8898c203..72f6cdc5 100644
--- a/service/cmd/openipc/main.go
+++ b/service/cmd/openipc/main.go
@@ -37,6 +37,7 @@ import (
"github.com/OpenIPC/website/service/internal/boards"
"github.com/OpenIPC/website/service/internal/builds"
"github.com/OpenIPC/website/service/internal/catalogue"
+ "github.com/OpenIPC/website/service/internal/club"
"github.com/OpenIPC/website/service/internal/config"
"github.com/OpenIPC/website/service/internal/db"
"github.com/OpenIPC/website/service/internal/downloads"
@@ -172,6 +173,24 @@ var routes = []Route{
{"web", "GET", "/api/v1/reports/{id}"},
{"web", "GET", "/api/v1/reports/{id}/files/{position}"},
{"web", "POST", "/api/v1/boards/identify"},
+ {"web", "GET", "/api/v1/club/me"},
+ {"web", "POST", "/api/v1/club/logout"},
+ {"web", "POST", "/api/v1/club/quiet"},
+ {"web", "GET", "/api/v1/club/login"},
+ {"web", "GET", "/api/v1/club/finish"},
+ {"web", "GET", "/api/v1/club/finish/who"},
+ {"web", "POST", "/api/v1/club/finish"},
+ {"web", "POST", "/api/v1/club/name"},
+ {"web", "POST", "/api/v1/club/telegram"},
+ {"web", "POST", "/api/v1/club/telegram/webhook"},
+ {"web", "POST", "/api/v1/club/email"},
+ {"web", "GET", "/api/v1/club/github"},
+ {"web", "GET", "/api/v1/club/github/callback"},
+ {"web", "POST", "/api/v1/club/reports"},
+ {"web", "GET", "/api/v1/club/reports"},
+ {"web", "GET", "/api/v1/club/reports/{id}/files/{position}"},
+ {"web", "GET", "/api/v1/club/review"},
+ {"web", "POST", "/api/v1/club/review/{id}"},
{"web", "PUT", "/api/v1/tools/{name}"},
{"web", "GET", "/api/v1/tools"},
{"share", "GET", "/up"},
@@ -320,6 +339,13 @@ func web(ctx context.Context, cfg *config.Config, log *slog.Logger, pool *pgxpoo
lock.Release()
return nil, err
}
+ // What owners sent about their own boards (boards/contributions.yml),
+ // the same way, and what the club's maintainers published since.
+ if err := applyContributions(ctx, pool, log, cfg.BoardsRoot); err != nil {
+ lock.Release()
+ return nil, err
+ }
+ refreshReportUnits(ctx, cfg, log, pool)
store := &snapshots.Store{DB: pool, TokenKey: cfg.CameraTokenKey}
wallFS := variants.Wall{Root: cfg.WallRoot}
@@ -389,8 +415,14 @@ func web(ctx context.Context, cfg *config.Config, log *slog.Logger, pool *pgxpoo
}
// Owner reports (internal/reports): uploaded by anyone, public after
// review, and kept apart from everything the board importers touch.
- for k, h := range (&reports.API{DB: pool, Files: &reports.Files{Root: cfg.ReportsRoot},
- AccelPrefix: cfg.ReportsAccelPrefix, Log: log}).Handlers() {
+ ownerReports := &reports.API{DB: pool, Files: &reports.Files{Root: cfg.ReportsRoot},
+ AccelPrefix: cfg.ReportsAccelPrefix, Log: log}
+ for k, h := range ownerReports.Handlers() {
+ handlers[k] = h
+ }
+ // The OpenIPC Club (internal/club): signing in, the send form, members'
+ // own reports and the maintainers' review. Each way in only when set.
+ for k, h := range newClub(bg, cfg, log, pool, ownerReports).Handlers() {
handlers[k] = h
}
// ipctool's builds, pushed by its release job (tools/PUSH.md).
@@ -577,6 +609,12 @@ func runPurge(ctx context.Context, cfg *config.Config, log *slog.Logger, args []
if err != nil {
return err
}
+ // The club's expired sessions and sign-ins: the web role's database too.
+ sessions, logins, err := club.Purge(ctx, pool)
+ log.Info("purge: club", "sessions", sessions, "logins", logins)
+ if err != nil {
+ return err
+ }
}
return nil
}
diff --git a/service/cmd/openipc/reports.go b/service/cmd/openipc/reports.go
index b9560600..8187852b 100644
--- a/service/cmd/openipc/reports.go
+++ b/service/cmd/openipc/reports.go
@@ -95,15 +95,15 @@ func reportsCommand(ctx context.Context, cfg *config.Config, log *slog.Logger, a
if err := need(1); err != nil {
return err
}
- for _, m := range models {
- if err := st.Link(ctx, pos[0], m, *by); err != nil {
- return fmt.Errorf("link %s: %w", m, err)
- }
- }
- if err := st.Review(ctx, pos[0], cmd, *by, *note); err != nil {
+ // The same decision the club's review page makes: links, the
+ // review, the sender's stars, and the boards' copy of the text.
+ d, err := st.Decide(ctx, pos[0], cmd, *by, *note, models)
+ if err != nil {
return err
}
- log.Info("reports: reviewed", "report", pos[0], "decision", cmd, "by", *by, "models", []string(models))
+ refreshReportUnits(ctx, cfg, log, pool)
+ log.Info("reports: reviewed", "report", pos[0], "decision", cmd, "by", *by, "models", []string(models),
+ "member", d.Member, "stars", d.Points)
return nil
case "link":
if err := need(2); err != nil {
diff --git a/service/go.mod b/service/go.mod
index 5bbb78a8..4faf206d 100644
--- a/service/go.mod
+++ b/service/go.mod
@@ -15,6 +15,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+ golang.org/x/image v0.31.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
diff --git a/service/go.sum b/service/go.sum
index 88529b78..f09fb8cf 100644
--- a/service/go.sum
+++ b/service/go.sum
@@ -24,6 +24,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
+golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
diff --git a/service/internal/boards/contributions.go b/service/internal/boards/contributions.go
new file mode 100644
index 00000000..31dcc6db
--- /dev/null
+++ b/service/internal/boards/contributions.go
@@ -0,0 +1,369 @@
+package boards
+
+import (
+ "context"
+ "crypto/sha256"
+ "embed"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "sync"
+
+ "github.com/jackc/pgx/v5"
+ "go.yaml.in/yaml/v3"
+)
+
+// What owners sent about their own boards (contributions.yml): a boot log or
+// a U-Boot console pasted into an issue, reviewed in the repository and
+// published as a unit of the model, source contributor, credited to them.
+
+// applying queues this process's applies in front of the advisory lock.
+var applying sync.Mutex
+
+// Contributors is the source of contributed units (migration 003's enum).
+const Contributors = "contributor"
+
+// contributedPosition is past any position an import gives a unit.
+const contributedPosition = 1_000_000
+
+//go:embed contributions.yml contributions
+var contributionsFS embed.FS
+
+// Contribution is one entry of contributions.yml.
+type Contribution struct {
+ Unit string `yaml:"unit"`
+ Model string `yaml:"model"`
+ By string `yaml:"by"`
+ Evidence []string `yaml:"evidence"`
+ Sensor string `yaml:"sensor"`
+ FlashChip string `yaml:"flash_chip"`
+ FlashMB int `yaml:"flash_mb"`
+ Note string `yaml:"note"`
+ Files []ContributedFile `yaml:"files"`
+}
+
+type ContributedFile struct {
+ Kind string `yaml:"kind"`
+ File string `yaml:"file"`
+ // Source is the file's path in the tree it is read from: for
+ // contributions.yml, contributions//.
+ Source string `yaml:"-"`
+}
+
+// ReceiptMark is in the reference of every unit an owner report made: such
+// units are ApplyReportUnits', and contributions.yml never touches them.
+const ReceiptMark = "/cameras/report/?id="
+
+// contributedKinds are what an owner's paste can be. A flash dump is not
+// one: it is an owner report's backup, never a file in the repository.
+var contributedKinds = map[string]bool{
+ "uboot_env": true, "boot_log": true, "note": true,
+ "photo_front": true, "photo_back": true, "photo_other": true, "pinout": true,
+}
+
+var (
+ unitShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,159}$`)
+ fileShape = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
+)
+
+// Contributions are the entries of contributions.yml, checked for shape,
+// with every file they name present.
+func Contributions() ([]Contribution, error) {
+ return parseContributions(contributionsFS)
+}
+
+func parseContributions(fsys fs.FS) ([]Contribution, error) {
+ b, err := fs.ReadFile(fsys, "contributions.yml")
+ if err != nil {
+ return nil, err
+ }
+ var list []Contribution
+ if err := yaml.Unmarshal(b, &list); err != nil {
+ return nil, fmt.Errorf("contributions.yml: %w", err)
+ }
+ units, refs := map[string]bool{}, map[string]bool{}
+ for i, c := range list {
+ where := fmt.Sprintf("contributions.yml entry %d (%s)", i+1, c.Unit)
+ if !unitShape.MatchString(c.Unit) || c.Model == "" || c.By == "" || len(c.Evidence) == 0 || len(c.Files) == 0 {
+ return nil, fmt.Errorf("%s: needs unit, model, by, evidence and files", where)
+ }
+ if units[c.Unit] || refs[c.Evidence[0]] {
+ return nil, fmt.Errorf("%s: the unit or its first evidence is listed twice", where)
+ }
+ units[c.Unit], refs[c.Evidence[0]] = true, true
+ for _, e := range c.Evidence {
+ if !httpURL(e) {
+ return nil, fmt.Errorf("%s: evidence %q is not a web page", where, e)
+ }
+ }
+ if c.FlashMB < 0 {
+ return nil, fmt.Errorf("%s: flash_mb %d", where, c.FlashMB)
+ }
+ names := map[string]bool{}
+ for _, f := range c.Files {
+ if !contributedKinds[f.Kind] {
+ return nil, fmt.Errorf("%s: kind %q cannot be contributed", where, f.Kind)
+ }
+ if !fileShape.MatchString(f.File) || names[f.File] {
+ return nil, fmt.Errorf("%s: file name %q", where, f.File)
+ }
+ names[f.File] = true
+ if _, err := fs.Stat(fsys, "contributions/"+c.Unit+"/"+f.File); err != nil {
+ return nil, fmt.Errorf("%s: %w", where, err)
+ }
+ }
+ }
+ return list, nil
+}
+
+// ApplyContributions makes the contributed units exactly what list says.
+// An unchanged unit is left alone, a changed one is replaced, and one no
+// longer listed is removed with its files. A model the catalogue does not
+// have is reported and its entry waits.
+func (im *Importer) ApplyContributions(ctx context.Context, list []Contribution) (missing []string, err error) {
+ return im.applyContributions(ctx, contributionsFS, list)
+}
+
+// ApplyReportUnits does the same for the published owner reports' text and
+// photos (reports.PublishedTexts), read from fsys -- the reports' store --
+// at each file's Source. Its units are those whose reference is a receipt.
+func (im *Importer) ApplyReportUnits(ctx context.Context, fsys fs.FS, list []Contribution) (missing []string, err error) {
+ return im.apply(ctx, fsys, list, true)
+}
+
+func (im *Importer) applyContributions(ctx context.Context, fsys fs.FS, list []Contribution) (missing []string, err error) {
+ return im.apply(ctx, fsys, list, false)
+}
+
+func (im *Importer) apply(ctx context.Context, fsys fs.FS, list []Contribution, fromReports bool) (missing []string, err error) {
+ // One apply at a time, across processes: the web role at start, after
+ // each review, and `openipc reports publish` in the same container all
+ // write these units and their directories. Within a process a mutex
+ // queues them first, so the waiters do not each hold a pool connection
+ // on the advisory lock and leave none for the apply that has it.
+ applying.Lock()
+ defer applying.Unlock()
+ conn, err := im.Pool.Acquire(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer conn.Release()
+ if _, err := conn.Exec(ctx, `SELECT pg_advisory_lock(hashtextextended('board-contributions', 0))`); err != nil {
+ return nil, err
+ }
+ defer func() {
+ _, _ = conn.Exec(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock(hashtextextended('board-contributions', 0))`)
+ }()
+ var keep []string
+ for i, c := range list {
+ var exists bool
+ if err := im.Pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM board_models WHERE id = $1)`, c.Model).Scan(&exists); err != nil {
+ return nil, err
+ }
+ if !exists {
+ missing = append(missing, c.Model)
+ continue
+ }
+ keep = append(keep, c.Unit)
+ // After every catalogue's own units of the model: the source's photos
+ // and pinout lead, what an owner sent follows.
+ u := &Unit{ID: c.Unit, Sensor: c.Sensor, FlashChip: c.FlashChip, FlashSizeMB: c.FlashMB,
+ SourceRef: c.Evidence[0], Position: contributedPosition + i, Source: Contributors, ContributedBy: c.By}
+ for _, f := range c.Files {
+ src := f.Source
+ if src == "" {
+ src = "contributions/" + c.Unit + "/" + f.File
+ }
+ u.Files = append(u.Files, File{Kind: f.Kind, Name: f.File, Source: src})
+ }
+ if err := im.applyOne(ctx, fsys, c, u); err != nil {
+ if !fromReports {
+ return nil, err
+ }
+ // One report's unusable file (a photo nothing can decode) costs
+ // that report its unit, not every report after it. What it had
+ // is kept as it was.
+ im.Log.Error("boards: a published report's unit not listed", "unit", c.Unit, "err", err)
+ }
+ }
+ return missing, im.prune(ctx, keep, fromReports)
+}
+
+// applyOne makes one unit what its entry says.
+func (im *Importer) applyOne(ctx context.Context, fsys fs.FS, c Contribution, u *Unit) error {
+ same, err := im.unchanged(ctx, fsys, c, u)
+ if err != nil {
+ return fmt.Errorf("%s: %w", c.Unit, err)
+ }
+ if same {
+ return nil
+ }
+ // Every file must read, and every picture decode, before anything of
+ // the unit's is touched: a unit that cannot be written stays as it was.
+ for _, f := range u.Files {
+ b, err := fs.ReadFile(fsys, f.Source)
+ if err != nil {
+ return fmt.Errorf("%s: %w", c.Unit, err)
+ }
+ switch f.Kind {
+ case "photo_front", "photo_back", "photo_other", "pinout":
+ if _, _, _, err := Thumbnail(b, 8); err != nil {
+ return fmt.Errorf("%s: %s: %w", c.Unit, f.Name, err)
+ }
+ }
+ }
+ // Checked before the directory is touched: it may be another source's
+ // unit.
+ var other string
+ err = im.Pool.QueryRow(ctx, `SELECT source::text FROM board_units WHERE id = $1`, c.Unit).Scan(&other)
+ if err == nil && other != Contributors {
+ return fmt.Errorf("%s: the unit id is already taken by %s", c.Unit, other)
+ }
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return err
+ }
+ if err := os.RemoveAll(filepath.Join(im.Root, c.Unit)); err != nil {
+ return err
+ }
+ arts, err := im.files(fsys, u)
+ if err != nil {
+ return fmt.Errorf("%s: %w", c.Unit, err)
+ }
+ if err := pgx.BeginFunc(ctx, im.Pool, func(tx pgx.Tx) error {
+ if _, err := tx.Exec(ctx, `DELETE FROM board_units WHERE source = $1 AND (id = $2 OR source_ref = $3)`,
+ Contributors, c.Unit, u.SourceRef); err != nil {
+ return err
+ }
+ var flash *int
+ if c.FlashMB > 0 {
+ flash = &c.FlashMB
+ }
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO board_units (id, model_id, sensor, flash_chip, flash_size_mb, source, source_ref, contributed_by, notes, position)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
+ u.ID, c.Model, null(c.Sensor), null(c.FlashChip), flash, Contributors, u.SourceRef, c.By, null(c.Note), u.Position); err != nil {
+ return err
+ }
+ return insertArtifacts(ctx, tx, u.ID, arts)
+ }); err != nil {
+ return fmt.Errorf("%s: %w", c.Unit, err)
+ }
+ im.Log.Info("boards: contribution applied", "unit", c.Unit, "files", len(arts))
+ return nil
+}
+
+// prune removes the units of this list (contributions.yml's, or the
+// reports') that keep does not name, with their files, and lists the
+// contributor source only while it has a unit.
+func (im *Importer) prune(ctx context.Context, keep []string, fromReports bool) error {
+ if keep == nil {
+ keep = []string{}
+ }
+ return pgx.BeginFunc(ctx, im.Pool, func(tx pgx.Tx) error {
+ rows, err := tx.Query(ctx, `
+ DELETE FROM board_units WHERE source = $1 AND NOT id = ANY($2) AND (strpos(source_ref, $3) > 0) = $4
+ RETURNING id`, Contributors, keep, ReceiptMark, fromReports)
+ if err != nil {
+ return err
+ }
+ gone, err := pgx.CollectRows(rows, pgx.RowTo[string])
+ if err != nil {
+ return err
+ }
+ for _, id := range gone {
+ if err := os.RemoveAll(filepath.Join(im.Root, id)); err != nil {
+ return err
+ }
+ im.Log.Info("boards: contribution removed", "unit", id)
+ }
+ var any bool
+ if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM board_units WHERE source = $1)`, Contributors).Scan(&any); err != nil {
+ return err
+ }
+ if !any {
+ _, err = tx.Exec(ctx, `DELETE FROM board_sources WHERE id = $1`, Contributors)
+ return err
+ }
+ _, err = tx.Exec(ctx, `
+ INSERT INTO board_sources (id, name, url, note, ref, position)
+ VALUES ($1, 'Board owners', 'https://github.com/OpenIPC/website/issues',
+ 'What owners sent about their own boards: boot logs and U-Boot consoles, each reviewed and credited to them.', '',
+ (SELECT coalesce(max(position), 0) + 1 FROM board_sources))
+ ON CONFLICT (id) DO NOTHING`, Contributors)
+ return err
+ })
+}
+
+// unchanged says whether the unit is stored exactly as the entry describes
+// it: the same fields, and the same files in the same order, byte for byte.
+func (im *Importer) unchanged(ctx context.Context, fsys fs.FS, c Contribution, u *Unit) (bool, error) {
+ var model, ref, by string
+ var sensor, chip, note *string
+ var flash *int
+ var position int
+ err := im.Pool.QueryRow(ctx, `
+ SELECT model_id, source_ref, coalesce(contributed_by, ''), sensor, flash_chip, flash_size_mb, notes, position
+ FROM board_units WHERE id = $1 AND source = $2`, c.Unit, Contributors).
+ Scan(&model, &ref, &by, &sensor, &chip, &flash, ¬e, &position)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ if model != c.Model || ref != u.SourceRef || by != c.By || deref(sensor) != c.Sensor ||
+ deref(chip) != c.FlashChip || deref(note) != c.Note || derefInt(flash) != c.FlashMB || position != u.Position {
+ return false, nil
+ }
+ rows, err := im.Pool.Query(ctx, `SELECT kind::text || ' ' || name || ' ' || sha256 FROM board_artifacts WHERE unit_id = $1 ORDER BY position`, c.Unit)
+ if err != nil {
+ return false, err
+ }
+ stored, err := pgx.CollectRows(rows, pgx.RowTo[string])
+ if err != nil {
+ return false, err
+ }
+ var want []string
+ for _, f := range u.Files {
+ b, err := fs.ReadFile(fsys, f.Source)
+ if err != nil {
+ return false, err
+ }
+ want = append(want, f.Kind+" "+f.Name+" "+sha256Hex(b))
+ }
+ if !slices.Equal(stored, want) {
+ return false, nil
+ }
+ // The rows are right; the files must be there too (a restored database
+ // on an empty BOARDS_ROOT).
+ for _, f := range u.Files {
+ if _, err := os.Stat(filepath.Join(im.Root, c.Unit, f.Name)); err != nil {
+ return false, nil
+ }
+ }
+ return true, nil
+}
+
+func deref(s *string) string {
+ if s == nil {
+ return ""
+ }
+ return *s
+}
+
+func derefInt(n *int) int {
+ if n == nil {
+ return 0
+ }
+ return *n
+}
+
+func sha256Hex(b []byte) string {
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])
+}
diff --git a/service/internal/boards/contributions.yml b/service/internal/boards/contributions.yml
new file mode 100644
index 00000000..b657e6cb
--- /dev/null
+++ b/service/internal/boards/contributions.yml
@@ -0,0 +1,31 @@
+# What board owners sent about their own boards -- boot logs and U-Boot
+# consoles, pasted into an issue -- each checked by a person and recorded
+# here, the text under contributions//. The web role applies the file
+# whenever it starts: each entry is one unit on an existing model, credited
+# to whoever sent it, and the catalogue's contributed units are exactly
+# these. A changed entry replaces its unit; a removed one is removed.
+#
+# unit: the unit's id, -c
+# model: the catalogue's model id; a contribution never creates one
+# by: who sent it, as they signed the issue
+# evidence: where they sent it; the first is the unit's reference
+# sensor, flash_chip, flash_mb: what the text itself shows, when it does
+# note: anything a reader needs to read the files right
+# files: kind (boot_log, uboot_env, note, or a photo kind) and the file
+# under contributions//
+
+- unit: anjoy-ms-j10-c365
+ model: anjoy-ms-j10
+ by: sansarus
+ evidence:
+ - https://github.com/OpenIPC/website/issues/365
+ - https://github.com/OpenIPC/website/issues/366
+ sensor: IMX307
+ flash_chip: GD25Q64
+ flash_mb: 8
+ note: Stock firmware 3.3.0.2 (builddate 202312281015). The kernel calls the SoC INFINITY6B0 SSC009A-S01A, Anjoy calls it SSC335.
+ files:
+ - kind: uboot_env
+ file: uboot.txt
+ - kind: boot_log
+ file: boot.log
diff --git a/service/internal/boards/contributions/anjoy-ms-j10-c365/boot.log b/service/internal/boards/contributions/anjoy-ms-j10-c365/boot.log
new file mode 100644
index 00000000..2e4ee2f2
--- /dev/null
+++ b/service/internal/boards/contributions/anjoy-ms-j10-c365/boot.log
@@ -0,0 +1,527 @@
+IPL g05c44be
+D-15
+SPI 54M
+64MB
+BIST0_0001-OK
+[SPI_NOR]
+MXP found at 0x0000f000
+offset:00007000
+Checksum OK
+
+IPL_CUST g05c44be
+runUBOOT()
+[SPI_NOR]
+MXP found at 0x0000f000
+offset:00020000
+ -Decompress BDMA XZ
+ -Decompress BDMA XZ
+XZ decomp_size=0x00049be4
+ pvLoadAddr:23d00000
+ pvRunAddr:23e00000
+ -Verify CRC32 passed!
+ decomp_size=0x00000000
+Disable MMU and D-cache before jump to UBOOT▒
+
+U-Boot 2015.01 (Nov 24 2022 - 17:53:59)
+
+Version: I6g#######
+I2C: ready
+DRAM:
+WARNING: Caches not enabled
+MMC: MStar SD/MMC: 0
+nor_flash_mxp allocated success!!
+Flash is detected (0x0B03, 0xC8, 0x40, 0x17)
+SF: Detected nor0 with total size 8 MiB
+MXP found at mxp_offset[2]=0x0000F000, size=0x1000
+env_offset=0x3F000 env_size=0x1000
+Flash is detected (0x0B03, 0xC8, 0x40, 0x17)
+SF: Detected nor0 with total size 8 MiB
+In: serial
+Out: serial
+Err: serial
+Net: MAC Address 00:00:B4:67:B7:76
+Auto-Negotiation...
+Link Status Speed:100 Full-duplex:1
+sstar_emac
+reset key up !!!
+Init wifi gpio
+run sdstar
+_[sdmmc_0] Card Detect Fail!
+** Bad device mmc 0 **
+aj_update_by_sdcard(dev=0, partion=0, ret=0)
+_[sdmmc_0] Card Detect Fail!
+** Bad device mmc 0 **
+aj_update_by_sdcard(dev=0, partion=1, ret=0)
+MMC Device 1 not found
+** Bad device mmc 1 **
+aj_update_by_sdcard(dev=1, partion=0, ret=0)
+MMC Device 1 not found
+** Bad device mmc 1 **
+aj_update_by_sdcard(dev=1, partion=1, ret=0)
+run udpbc
+uuid 0x850e 0xcf26 0x88a7
+uuid 0x850e 0xcf26 0x88a7 150254316324110
+Flash is detected (0x0B03, 0xC8, 0x40, 0x17)
+SF: Detected nor0 with total size 8 MiB
+SPI flash sector read 64 return 0
+anjvision_autoupdate::MC200J6_V0
+uuid = 150254316324110
+setenv ipaddr 192.168.1.167 --> return 0
+macaddr 86 0e cf 26 88 a7
+MAC Address 86:0E:CF:26:88:A7
+MSJ10_V1: Send upgrade request, ready to receive...
+uboot net upgrade timeout
+Flash is detected (0x0B03, 0xC8, 0x40, 0x17)
+SF: Detected nor0 with total size 8 MiB
+SF: 1835008 bytes @ 0x40000 Read: OK
+## Booting kernel from Legacy Image at 21000000 ...
+ Image Name: MVX4##I6B0g#######KL_LX409##[BR:
+ Image Type: ARM Linux Kernel Image (lzma compressed)
+ Data Size: 1597176 Bytes = 1.5 MiB
+ Load Address: 20008000
+ Entry Point: 20008000
+ Verifying Checksum ... OK
+ Uncompressing Kernel Image ...
+[XZ] !!!reserved 0x21000000 length=0x 1000000 for xz!!
+ XZ: uncompressed size=0x33a000, ret=7
+OK
+atags:0x20000000
+
+Starting kernel ...
+
+Booting Linux on physical CPU 0x0
+Linux version 4.9.84 (root@ubuntu) (gcc version 4.9.4 (Buildroot 2017.08-g239d7d0) ) #117 PREEMPT Fri Feb 5 11:24:04 CST 2021
+CPU: ARMv7 Processor [410fc075] revision 5 (ARMv7), cr=50c53c7d
+CPU: div instructions available: patching division code
+CPU: PIPT / VIPT nonaliasing data cache, VIPT aliasing instruction cache
+early_atags_to_fdt() success
+OF: fdt:Machine model: INFINITY6B0 SSC009A-S01A QFN88
+[ERR] LX_MEM, LX_MEM2, LX_MEM3 not 1MB aligned
+LXmem is 0x3fe0000 PHYS_OFFSET is 0x20000000
+Add mem start 0x20000000 size 0x3fe0000!!!!
+
+LX_MEM = 0x20000000, 0x3fe0000
+LX_MEM2 = 0x0, 0x0
+LX_MEM3 = 0x0, 0x0
+EMAC_LEN= 0x0
+DRAM_LEN= 0x0
+deal_with_reserve_mma_heap memblock_reserve success mma_config[0].reserved_start=
+0x22ab4000
+
+cma: Reserved 2 MiB at 0x22800000
+Memory policy: Data cache writeback
+CPU: All CPU(s) started in SVC mode.
+Built 1 zonelists in Zone order, mobility grouping on. Total pages: 10846
+Kernel command line: console=ttyS0,115200 root=/dev/mtdblock2 rootfstype=squashfs ro init=/linuxrc LX_MEM=0x3fe0000 mma_heap=mma_heap_name0,miu=0,sz=0x152C000 mma_memblock_remove=1
+PID hash table entries: 256 (order: -2, 1024 bytes)
+Dentry cache hash table entries: 8192 (order: 3, 32768 bytes)
+Inode-cache hash table entries: 4096 (order: 2, 16384 bytes)
+Memory: 37620K/43728K available (1888K kernel code, 203K rwdata, 996K rodata, 96K init, 137K bss, 4060K reserved, 2048K cma-reserved)
+Virtual kernel memory layout:
+ vector : 0xffff0000 - 0xffff1000 ( 4 kB)
+ fixmap : 0xffc00000 - 0xfff00000 (3072 kB)
+ vmalloc : 0xc3000000 - 0xff800000 ( 968 MB)
+ lowmem : 0xc0000000 - 0xc2ab4000 ( 42 MB)
+ modules : 0xbf800000 - 0xc0000000 ( 8 MB)
+ .text : 0xc0008000 - 0xc01e0470 (1890 kB)
+ .init : 0xc02f6000 - 0xc030e000 ( 96 kB)
+ .data : 0xc030e000 - 0xc0340f18 ( 204 kB)
+ .bss : 0xc0342000 - 0xc03646b4 ( 138 kB)
+SLUB: HWalign=64, Order=0-3, MinObjects=0, CPUs=1, Nodes=1
+Preemptible hierarchical RCU implementation.
+ Build-time adjustment of leaf fanout to 32.
+NR_IRQS:16 nr_irqs:16 16
+ms_init_main_intc: np->name=ms_main_intc, parent=gic
+ms_init_pm_intc: np->name=ms_pm_intc, parent=ms_main_intc
+ss_init_gpi_intc: np->name=ms_gpi_intc, parent=ms_main_intc
+Find CLK_cpupll_clk, hook ms_cpuclk_ops
+arm_arch_timer: Architected cp15 timer(s) running at 6.00MHz (virt).
+clocksource: arch_sys_counter: mask: 0xffffffffffffff max_cycles: 0x1623fa770, max_idle_ns: 440795202238 ns
+sched_clock: 56 bits at 6MHz, resolution 166ns, wraps every 4398046511055ns
+Switching to timer-based delay loop, resolution 166ns
+console [ttyS0] enabled
+Calibrating delay loop (skipped), value calculated using timer frequency.. 12.00 BogoMIPS (lpj=60000)
+pid_max: default: 4096 minimum: 301
+Mount-cache hash table entries: 1024 (order: 0, 4096 bytes)
+Mountpoint-cache hash table entries: 1024 (order: 0, 4096 bytes)
+CPU: Testing write buffer coherency: ok
+Setting up static identity map for 0x200081c0 - 0x200081f0
+devtmpfs: initialized
+VFP support v0.3: implementor 41 architecture 2 part 30 variant 7 rev 5
+clocksource: jiffies: mask: 0xffffffff max_cycles: 0xffffffff, max_idle_ns: 19112604462750000 ns
+futex hash table entries: 16 (order: -4, 448 bytes)
+NET: Registered protocol family 16
+DMA: preallocated 256 KiB pool for atomic coherent allocations
+
+
+Version : MVX4##I6B0g#######KL_LX409##[BR:g]#XVM
+
+GPIO: probe end[ss_gpi_intc_domain_alloc] hw:42 -> v:49
+[MS_PM_INTC] hw:20 -> v:52
+hw-breakpoint: found 5 (+1 reserved) breakpoint and 4 watchpoint registers.
+hw-breakpoint: maximum watchpoint size is 8 bytes.
+clocksource: Switched to clocksource arch_sys_counter
+NET: Registered protocol family 2
+TCP established hash table entries: 1024 (order: 0, 4096 bytes)
+TCP bind hash table entries: 1024 (order: 2, 20480 bytes)
+TCP: Hash tables configured (established 1024 bind 1024)
+UDP hash table entries: 128 (order: 0, 6144 bytes)
+UDP-Lite hash table entries: 128 (order: 0, 6144 bytes)
+NET: Registered protocol family 1
+hw perfevents: enabled with armv7_cortex_a7 PMU driver, 5 counters available
+workingset: timestamp_bits=30 max_order=14 bucket_order=0
+squashfs: version 4.0 (2009/01/31) Phillip Lougher
+jffs2: version 2.2. © 2001-2006 Red Hat, Inc.
+io scheduler noop registered
+io scheduler deadline registered (default)
+[Padmux]reset PAD61(reg 0xe00:1c; mask0x10) t0 GPIO (org: PM_IRIN_MODE)
+[Padmux]reset PAD46(reg 0x101e00:f; mask0x3) t0 GPIO (org: EJ_MODE_2)
+335 set wifi to low
+335 set wifi to high
+IRLED driver v2.0 2021025
+libphy: Fixed MDIO Bus: probed
+i2c /dev entries driver
+1f221000.uart0: ttyS0 at MMIO 0x0 (irq = 37, base_baud = 10800000) is a unknown
+1f221200.uart1: ttyS1 at MMIO 0x0 (irq = 38, base_baud = 10800000) is a unknown
+1f220400.uart2: ttyS2 at MMIO 0x0 (irq = 39, base_baud = 10800000) is a unknown
+>> [sdmmc] ms_sdmmc_probe
+[Padmux]reset PAD60(reg 0xe00:28; mask0x4000) t0 GPIO (org: PM_SD_CDZ_MODE)
+[Padmux]reset PAD54(reg 0x101e00:8; mask0xc) t0 GPIO (org: SD_MODE)
+>> [sdmmc_0] Probe Platform Devices
+MSYS: DMEM request: [emac0_buff]:0x00000812
+MSYS: DMEM request: [emac0_buff]:0x00000812 success, CPU phy:@0x22843000, virt:@0xC2843000
+libphy: mdio: probed
+mdio_bus mdio-bus@emac0: /soc/emac0/mdio-bus/ethernet-phy@0 has invalid PHY address
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 0
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 1
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 2
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 3
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 4
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 5
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 6
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 7
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 8
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 9
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 10
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 11
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 12
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 13
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 14
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 15
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 16
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 17
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 18
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 19
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 20
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 21
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 22
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 23
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 24
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 25
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 26
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 27
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 28
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 29
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 30
+mdio_bus mdio-bus@emac0: scan phy ethernet-phy at address 31
+[emac_phy_connect][3306] connected mac emac0 to PHY at mdio-bus@emac0:00 [uid=11112222, driver=SStar 10/100 Ethernet Phy]
+ms_rtcpwc 1f006800.rtcpwc: rtc core: registered 1f006800.rtcpwc as rtc0
+[gpioi2c] sda-gpio=8, scl-gpio=9
+MSYS: DMEM request: [BDMA_FSP_WBUFF]:0x00010040
+MSYS: DMEM request: [BDMA_FSP_WBUFF]:0x00010040 success, CPU phy:@0x22850000, virt:@0xC2850000
+[Ser flash] phys=0x22850000, virt=0xc2850000, bus=0x02850000 len:0x10040
+[FSP] Flash is detected (0x0B03, 0xC8, 0x40, 0x17) ver1.1
+[FSP] 1-1-4 QUAD_READ MODE
+mtd .name = NOR_FLASH, .size = 0x00800000 (8MiB)
+ .erasesize = 0x00010000 .numeraseregions = 0
+MXP_PARTS!!
+MXP found at mxp_offset[2]=0x0000F000, size=0x1000
+Creating 5 MTD partitions on "NOR_FLASH":
+0x000000000000-0x000000020000 : "BOOT"
+0x000000040000-0x0000001d0000 : "KERNEL"
+0x0000001d0000-0x0000007b0000 : "SYSTEM"
+0x000000020000-0x000000040000 : "UBOOT"
+0x0000007b0000-0x000000800000 : "DATA"
+MSYS: DMEM request: [AESDMA_ENG]:0x00001000
+MSYS: DMEM request: [AESDMA_ENG]:0x00001000 success, CPU phy:@0x22844000, virt:@0xC2844000
+MSYS: DMEM request: [AESDMA_ENG1]:0x00001000
+MSYS: DMEM request: [AESDMA_ENG1]:0x00001000 success, CPU phy:@0x22845000, virt:@0xC2845000
+[ms_cpufreq_init] Current clk=799999872
+[NOTICE]pwm-isr(53) success. If not i6e or i6b0, pls confirm it on .dtsi
+NET: Registered protocol family 17
+ThumbEE CPU extension supported.
+Please set rtc timer (hwclock -w)
+ms_rtcpwc 1f006800.rtcpwc: setting system clock to 1970-01-01 00:00:00 UTC (0)
+OF: fdt:not creating '/sys/firmware/fdt': CRC check failed
+VFS: Mounted root (squashfs filesystem) readonly on device 31:2.
+devtmpfs: mounted
+This architecture does not have kernel memory protection.
+random: linuxrc: uninitialized urandom read (4 bytes read)
+random: rcS: uninitialized urandom read (4 bytes read)
+random: mount: uninitialized urandom read (4 bytes read)
+random: mount: uninitialized urandom read (4 bytes read)
+random: busybox: uninitialized urandom read (4 bytes read)
+random: cat: uninitialized urandom read (4 bytes read)
+SSC009A-S01A
+random: mount: uninitialized urandom read (4 bytes read)
+random: mkdir: uninitialized urandom read (4 bytes read)
+random: mkdir: uninitialized urandom read (4 bytes read)
+random: mount: uninitialized urandom read (4 bytes read)
+mount: can't find devpts in /etc/fstab
+umount: can't unmount /mnt/usb: Invalid argument
+rmmod: can't unload 'mhal': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_common': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_sys': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_ai': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_ao': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_rgn': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_divp': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_ipu': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_vpe': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_sensor': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_vif': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_venc': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'mi_shadow': unknown symbol in module, or unknown parameter
+rmmod: can't unload 'imx307_MIPI': unknown symbol in module, or unknown parameter
+mhal: loading out-of-tree module taints kernel.
+mhal: module license 'PROPRIETARY' taints kernel.
+Disabling lock debugging due to kernel taint
+mhal driver init
+[CSI] probe
+Request CSI IRQ[0]#32
+CSI interrupt registered
+vif driver probe
+Create device file. vif_ints,0
+venc_probe:859 venc driver is probed.
+jpe driver probed
+[DRV_SCL_MODULE] [_DrvSclVpeModuleInit @ 140]
+[Isp_Driver_Init]
+[s32CurClkIdx] = 5
+[ISP] Request IRQ: 31, 57
+[IspMid_Driver_Init]
+ispsclttl:0
+DivpProcInit 526
+[emac_phy_link_adjust] EMAC Link Up
+module [sys] init
+MI_SYSCFG_SetupMmapLoader default_config_path:/config/config_tool, argv1:/config/load_mmap,argv2:/config/mmap.ini
+Function = init_glob_miu_kranges, Line = 654, Insert KProtect for LX @ MIU: 0
+Function = init_glob_miu_kranges, Line = 663, [INIT] for LX0 kprotect: from 0x20000000 to 0x23FE0000, using block 0
+function:parese_Cmdline,pCmd_Section:0x3fe0000
+mm
+a_
+he
+ap
+_n
+am
+e0
+ miu=0,sz=152c000 reserved_start=22ab4000
+r_front->miuBlockIndex:0,r_front->start_cpu_bus_pa:0x20000000,r_front->start_cpu_bus_pa+r_front->length:0x22ab4000
+mi_sys_mma_allocator_create success, heap_base_addr=22ab4000 length=152c000
+Kernel CONFIG_HZ = 100
+Sigmastar Module version: project_commit.e54754f sdk_commit.a45f135 build_time.20200319174625
+mknod: /dev/mi_sys: File exists
+module [ai] init
+mknod: /dev/mi_ai: File exists
+module [ao] init
+mknod: /dev/mi_ao: File exists
+module [rgn] init
+mknod: /dev/mi_rgn: File exists
+module [divp] init
+mknod: /dev/mi_divp: File exists
+insmod: can't insert '/config/modules/4.9.84/mi_ipu.ko': No such file or directory
+module [vpe] init
+mknod: /dev/mi_vpe: File exists
+module [sensor] init
+mknod: /dev/mi_sensor: File exists
+module [vif] init
+mknod: /dev/mi_vif: File exists
+module [venc] init Apr 15 2020 15:09:14
+mknod: /dev/mi_venc: File exists
+module [shadow] init
+mknod: /dev/mi_shadow: File exists
+exFAT: file-system version 2.2.0-3arter97
+net.core.somaxconn = 1024
+net.core.rmem_default = 163840
+net.core.rmem_max = 163840
+net.core.wmem_default = 524288
+net.core.wmem_max = 1048576
+net.ipv4.tcp_mem = 924 1232 1848
+net.ipv4.tcp_rmem = 4096 87380 325120
+net.ipv4.tcp_wmem = 4096 131072 393216
+net.ipv4.tcp_tw_reuse = 1
+net.ipv4.tcp_fin_timeout = 30
+
+(none) login: 7329 procman [main:1610] detected sensor type is 407
+
+client [475] connected, module:sys
+Get random encript data.
+00 e5 d0 b4 67 b7 76 9f 4e 55 4c 4c 00 00 ff ff
+procman SN: 00E5D0B467B7769F
+7389 procman [redirect_print:1416] procman enable console
+
+/opt/ch/unicode_16x16.font: font count: 6915
+ # # # # # # #
+ # # # # # # ########
+ ############ # # #### ###### #### # ## ## ####### ###### # # # # #
+ # # # ######### ## ## ## ## ## ### ### ### ## ## ## ## ### ############ # # #
+ # # # # # ## ## ## ## # ## ## ####### ## # ## ## ## ## # # ####### # #
+ ###### ###### # ## # # ## ## ## ## ## ## ####### ## # ## ## ## ## ####### # # # # ###### ######
+ # # # ############ ## ##### ## ## ## ## # ## #### ##### ## ## # # # # # # # #
+ ############### # # # ## ## ## ####### ## ## ## # ## ## ####### # # # # ## # # #
+ ###### ###### # # # # ## ## ## ## ## ## ## ## ## ## ## ## ######## # # ## # # # ###### ######
+ # # # # ## ## ## # ## ## ## ## ## # ## ## ## ## # # # # # # # ## ## #
+ random: fast init done
+ ## # # ######### ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## # ## # # # # #
+ ## # # # #### #### #### ## ## ## ## ####### ### ## ## ## # # # # # # #
+ ## # # # # ## # # # #
+ # # # # # # # # # # # # # # #
+ ## ## ############ # # # # # # # ####
+ ## # # # ## # #
+7618 procman [redirect_print:1448] procman disable console
+
+procman (475): /proc/505/oom_adj is deprecated, please use /proc/505/oom_score_adj instead.
+updating oom_score_adj for 475 (procman) from 0 to -1000 because it shares mm with 505 (procman). Report if this is unexpected.
+[ss_gpi_intc_domain_alloc] hw:26 -> v:61
+[ss_gpi_intc_domain_alloc] hw:27 -> v:62
+[ss_gpi_intc_domain_alloc] hw:29 -> v:64
+[MS_PM_INTC] hw:4 -> v:65
+[ss_gpi_intc_domain_alloc] hw:69 -> v:66
+Connect IMX307_HDR_init_driver linear to sensor pad 0
+CamOsMutexInit already inited, LR:0xBF82641B
+Connect IMX307_HDR_init_driver SEF to vif sensor pad 0
+CamOsMutexInit already inited, LR:0xBF82641B
+Connect IMX307_HDR_init_driver LEF to sensor pad 0
+client [505] connected, module:sys
+updating oom_score_adj for 475 (procman) from -1000 to 0 because it shares mm with 580 (procman). Report if this is unexpected.
+client [584] connected, module:sys
+updating oom_score_adj for 475 (procman) from 0 to -1000 because it shares mm with 595 (procman). Report if this is unexpected.
+client [603] connected, module:sys
+updating oom_score_adj for 475 (procman) from -1000 to 0 because it shares mm with 708 (procman). Report if this is unexpected.
+client [708] connected, module:sys
+[MI WRN ]: _MI_SYS_IMPL_GetOutputPortInfo[2810]: not found [eModId 7,u32DevId 0, chnID=0, outputPortId=2]
+[MI WRN ]: _MI_SYS_IMPL_GetOutputPortInfo[2810]: not found [eModId 12,u32DevId 0, chnID=1, outputPortId=0]
+
+[MI WRN ]: _MI_SYS_IMPL_GetOutputPortInfo[2810]: not found [eModId 12,u32DevId 0, chnID=0, outputPortId=0]
+[MI WRN ]: _MI_SYS_IMPL_GetOutputPortInfo[2810]: not found [eModId 4,u32DevId 0, chnID=0, outputPortId=0]
+
+client [584] connected, module:sensor
+client [584] connected, module:vif
+vif channel 0, workmode 3, dataprec 2, FBCmode 0
+client [584] connected, module:rgn
+client [584] connected, module:vpe
+chipidx 4
+[DRV_SCL_MODULE] [DrvSclModuleClkInit @ 314] Use Default Clk [240000000]
+CmdqProcInit 674
+[CMDQ0] Virtual IRQ: 30
+CmdQ Free ID = 0, IspLocalCmdQHnd = 0xbf8f6e08
+CamOsMutexInit already inited, LR:0xBF836481
+CameraIsp_EnableChannel3A, ch:0 data: (null)
+CameraIsp_EnableCus3A AE=1, AWB=0, AF=0
+CameraIsp_EnableChannel3A, ch:0 data: (null)
+CameraIsp_EnableCus3A AE=1, AWB=1, AF=0
+client [584] connected, module:divp
+DIP Device ID [0] has already init.
+[DIP] Virtual IRQ: 36
+
+client [584] connected, module:venc
+Create mi DEV0
+Create mi DEV0
+[MI WRN ]: _MI_VENC_CreateMiDevice[6870]: mi device:0 has been inited
+Create mi DEV1
+Create mhal DEV0.
+[ven-m][wrap] fw_path /config/venc_fw/chagall.bin
+[CMDQ1] Virtual IRQ: 30
+ChData Size = 99168
+
+===== [MhalCameraOpen] begin ===== VifMask : 3
+Ch= 0, SNR ID: 0, VifCh = 0, PreOpen = 0
+3DNR = 2, Rotation = 0, AckTie : 0
+Crop: (0, 0, 1920, 1080)
+PixelFmt: 2, BayerID: 0, YuvOrder: 0
+Mode: 0, HdrType: 0
+In (w, h) = (1920, 1080), Crop (1920, 1080), ROT = 0
+[ISP_STATIS]Sz=0x27a80 , Vir=0xc34c0000, Miu=0x2b35000, C=1 ==
+ISP Mload base remap: 0xFD000000
+
+Mload mload_handle Size = 104
+
+Mload MLOAD_IQ_MEM Size = 304
+
+~$[Mload]SIZE_OF_MLOAD_LAYOUT=11456
+[CameraReadIqData] isp root is /config/iqfile
+[ISP_WDR]Sz=0x800 , Vir=0xc355e000, Miu=0x2b60000, C=1 ==
+[DNR_INFO1]Sz=0x21c00 , Vir=0xc3580000, Miu=0x2b61000, C=0 ==
+[DNR_INFO0]Sz=0x32a000 , Vir=0xc3800000, Miu=0x2b83000, C=0 ==
+[ISP_CMDQ]Sz=0xe000 , Vir=0xc3560000, Miu=0x2ead000, C=1 ==
+Create mhal DEV1.
+[ven-w][wrap] venc MMA callback function duplicate registration Force OverWrite!!
+Create mhal DEV2.
+client [584] connected, module:ao
+[_MI_AO_Init:1228] Init Ao Gain.
+IRCUT control too fast from last time, modify timer
+[CMDQ2] Virtual IRQ: 30
+client [584] connected, module:ai
+[_MI_AI_Init:3098] Init Ai Gain.
+[_MI_AI_IMPL_AllocTmpBuffer:3542] tmp buffer addr[c35e2000].
+[IQ_API20_SET_DUMMY_EX] Dummy0[0~5] = IQ_YEE_SCALE_SFT_X
+[IQ_API20_SET_DUMMY_EX] Dummy1[0~2] = IQ_NR3D_STICKY_SOLVER_EN, IQ_NR3D_STICKY_SOLVER_TH, IQ_NR3D_STICKY_SOLVER_EN_TH
+[IQ_API20_SET_DUMMY_EX] Dummy3 = IQ_NR3D_HIST_MOT_MAP_SW
+[IQ_API20_SET_DUMMY_EX] Dummy4 = ALSC table mirror/flip/rotate
+[IQ_API20_SET_DUMMY_EX] Dummy0[0~5] = IQ_YEE_SCALE_SFT_X
+[IQ_API20_SET_DUMMY_EX] Dummy1[0~2] = IQ_NR3D_STICKY_SOLVER_EN, IQ_NR3D_STICKY_SOLVER_TH, IQ_NR3D_STICKY_SOLVER_EN_TH
+[IQ_API20_SET_DUMMY_EX] Dummy3 = IQ_NR3D_HIST_MOT_MAP_SW
+[IQ_API20_SET_DUMMY_EX] Dummy4 = ALSC table mirror/flip/rotate
+[IQ_API20_SET_DUMMY] Dummy0[0~3] = AE acc controls 3DNR MdTh and MdGain.
+[IQ_API20_SET_DUMMY] Dummy0[0] = AEDiff_Th. If max of AE acc difference smaller than the value, MdTh and MdGain would be adjusted by the following ratio.
+[IQ_API20_SET_DUMMY] Dummy0[1] = MdTh_Ratio.
+[IQ_API20_SET_DUMMY] Dummy0[2] = MdGain_Ratio.
+[IQ_API20_SET_DUMMY] Dummy0[3] = Log_En.
+[IQ_API20_SET_DUMMY] Dummy1[0] = IQ_BNR_WEI_X
+[IQ_API20_SET_DUMMY] Dummy1[1~32] = IQ_BNR_WEI_Y
+[IQ_API20_SET_DUMMY] Dummy2[0~15] = IQ_YEE_DTL_OFFSET
+[IQ_API20_SET_DUMMY_EX] Dummy0[0~5] = IQ_YEE_SCALE_SFT_X
+[IQ_API20_SET_DUMMY_EX] Dummy1[0~2] = IQ_NR3D_STICKY_SOLVER_EN, IQ_NR3D_STICKY_SOLVER_TH, IQ_NR3D_STICKY_SOLVER_EN_TH
+[IQ_API20_SET_DUMMY_EX] Dummy3 = IQ_NR3D_HIST_MOT_MAP_SW
+[IQ_API20_SET_DUMMY_EX] Dummy4 = ALSC table mirror/flip/rotate
+IRCUT close
+reg=0x1F003410 clk=12000000, period=0x257
+reg=0x1F003408 clk=12000000, u32Duty=0x0
+reg=0x1F003410 clk=12000000, period=0x257
+reg=0x1F003408 clk=12000000, u32Duty=0x257
+reg=0x1F003410 clk=12000000, period=0x257
+reg=0x1F003408 clk=12000000, u32Duty=0x0
+reg=0x1F003490 clk=12000000, period=0x257
+reg=0x1F003488 clk=12000000, u32Duty=0x0
+reg=0x1F003490 clk=12000000, period=0x257
+reg=0x1F003488 clk=12000000, u32Duty=0x5
+reg=0x1F003490 clk=12000000, period=0x257
+reg=0x1F003488 clk=12000000, u32Duty=0x0
+updating oom_score_adj for 475 (procman) from 0 to -1000 because it shares mm with 791 (procman). Report if this is unexpected.
+client [814] connected, module:sys
+[IQ_API20_SET_DUMMY_EX] Dummy0[0~5] = IQ_YEE_SCALE_SFT_X
+[IQ_API20_SET_DUMMY_EX] Dummy1[0~2] = IQ_NR3D_STICKY_SOLVER_EN, IQ_NR3D_STICKY_SOLVER_TH, IQ_NR3D_STICKY_SOLVER_EN_TH
+[IQ_API20_SET_DUMMY_EX] Dummy3 = IQ_NR3D_HIST_MOT_MAP_SW
+[IQ_API20_SET_DUMMY_EX] Dummy4 = ALSC table mirror/flip/rotate
+[IQ_API20_SET_DUMMY_EX] Dummy0[0~5] = IQ_YEE_SCALE_SFT_X
+[IQ_API20_SET_DUMMY_EX] Dummy1[0~2] = IQ_NR3D_STICKY_SOLVER_EN, IQ_NR3D_STICKY_SOLVER_TH, IQ_NR3D_STICKY_SOLVER_EN_TH
+[IQ_API20_SET_DUMMY_EX] Dummy3 = IQ_NR3D_HIST_MOT_MAP_SW
+[IQ_API20_SET_DUMMY_EX] Dummy4 = ALSC table mirror/flip/rotate
+[IQ_API20_SET_DUMMY] Dummy0[0~3] = AE acc controls 3DNR MdTh and MdGain.
+[IQ_API20_SET_DUMMY] Dummy0[0] = AEDiff_Th. If max of AE acc difference smaller than the value, MdTh and MdGain would be adjusted by the following ratio.
+[IQ_API20_SET_DUMMY] Dummy0[1] = MdTh_Ratio.
+[IQ_API20_SET_DUMMY] Dummy0[2] = MdGain_Ratio.
+[IQ_API20_SET_DUMMY] Dummy0[3] = Log_En.
+[IQ_API20_SET_DUMMY] Dummy1[0] = IQ_BNR_WEI_X
+[IQ_API20_SET_DUMMY] Dummy1[1~32] = IQ_BNR_WEI_Y
+[IQ_API20_SET_DUMMY] Dummy2[0~15] = IQ_YEE_DTL_OFFSET
+[IQ_API20_SET_DUMMY_EX] Dummy0[0~5] = IQ_YEE_SCALE_SFT_X
+[IQ_API20_SET_DUMMY_EX] Dummy1[0~2] = IQ_NR3D_STICKY_SOLVER_EN, IQ_NR3D_STICKY_SOLVER_TH, IQ_NR3D_STICKY_SOLVER_EN_TH
+[IQ_API20_SET_DUMMY_EX] Dummy3 = IQ_NR3D_HIST_MOT_MAP_SW
+[IQ_API20_SET_DUMMY_EX] Dummy4 = ALSC table mirror/flip/rotate
+[IQ_API20_SET_DUMMY_EX] Dummy0[0~5] = IQ_YEE_SCALE_SFT_X
+[IQ_API20_SET_DUMMY_EX] Dummy1[0~2] = IQ_NR3D_STICKY_SOLVER_EN, IQ_NR3D_STICKY_SOLVER_TH, IQ_NR3D_STICKY_SOLVER_EN_TH
+[IQ_API20_SET_DUMMY_EX] Dummy3 = IQ_NR3D_HIST_MOT_MAP_SW
+[IQ_API20_SET_DUMMY_EX] Dummy4 = ALSC table mirror/flip/rotate
+random: crng init done
+client [791] connected, module:sys
+[MDev_EMAC_ndo_change_mtu][3390] change mtu size from 1500 to 1480
diff --git a/service/internal/boards/contributions/anjoy-ms-j10-c365/uboot.txt b/service/internal/boards/contributions/anjoy-ms-j10-c365/uboot.txt
new file mode 100644
index 00000000..553cec35
--- /dev/null
+++ b/service/internal/boards/contributions/anjoy-ms-j10-c365/uboot.txt
@@ -0,0 +1,147 @@
+IPL g05c44be
+D-15
+SPI 54M
+64MB
+BIST0_0001-OK
+[SPI_NOR]
+MXP found at 0x0000f000
+offset:00007000
+Checksum OK
+
+IPL_CUST g05c44be
+runUBOOT()
+[SPI_NOR]
+MXP found at 0x0000f000
+offset:00020000
+ -Decompress BDMA XZ
+ -Decompress BDMA XZ
+XZ decomp_size=0x00049be4
+ pvLoadAddr:23d00000
+ pvRunAddr:23e00000
+ -Verify CRC32 passed!
+ decomp_size=0x00000000
+Disable MMU and D-cache before jump to UBOOT▒
+
+U-Boot 2015.01 (Nov 24 2022 - 17:53:59)
+
+Version: I6g#######
+I2C: ready
+DRAM:
+WARNING: Caches not enabled
+MMC: MStar SD/MMC: 0
+nor_flash_mxp allocated success!!
+Flash is detected (0x0B03, 0xC8, 0x40, 0x17)
+SF: Detected nor0 with total size 8 MiB
+MXP found at mxp_offset[2]=0x0000F000, size=0x1000
+env_offset=0x3F000 env_size=0x1000
+Flash is detected (0x0B03, 0xC8, 0x40, 0x17)
+SF: Detected nor0 with total size 8 MiB
+In: serial
+Out: serial
+Err: serial
+Net: MAC Address 00:00:B4:67:B7:76
+Auto-Negotiation...
+Link Status Speed:100 Full-duplex:1
+sstar_emac
+reset key up !!!
+Init wifi gpio
+
+
+Anjoy # printenv
+baudrate=115200
+bootargs=console=ttyS0,115200 root=/dev/mtdblock2 rootfstype=squashfs ro init=/linuxrc LX_MEM=0x3fe0000 mma_heap=mma_heap_name0,miu=0,sz=0x152C000 mma_memblock_remove=1
+bootcmd=sf probe 0;sf read 0x21000000 0x00040000 0x001C0000;bootm 0x21000000
+bootdelay=0
+builddate=202312281015
+ethact=sstar_emac
+ethaddr=00:00:B4:67:B7:76
+fileaddr=21000000
+filesize=70954c
+fsversion=3.3.0.2
+ipaddr=192.168.1.197
+reset_key=up
+sdupgrade=1
+serverip=192.168.1.253
+stderr=serial
+stdin=serial
+stdout=serial
+version_kernel=MSTAR_I6B0_LINUX_335
+version_rootfs=MSJ10_V1
+
+Environment size: 602/4092 bytes
+
+
+Anjoy # help
+? - alias for 'help'
+aes - Control Mstar AES engine
+base - print or set address offset
+bootm - boot application image from memory
+bootp - boot image via network using BOOTP/TFTP protocol
+btime - Show booting time
+cmp - memory compare
+cp - memory copy
+crc32 - checksum calculation
+dbg - set debug message level. Default level is INFO
+dcache - enable or disable data cache
+debug - Disable uart rx via PAD_DDCA to use debug tool
+dhcp - boot image via network using DHCP/TFTP protocol
+dstar - script via SD/MMC
+eeprom - EEPROM sub-system
+env - environment handling commands
+estar - script via network
+estart - EMAC start
+exit - exit script
+false - do nothing, unsuccessfully
+fatinfo - print information about filesystem
+fatload - load binary file from a dos filesystem
+fatls - list files in a directory (default /)
+fatsize - determine a file's size
+go - start application at address 'addr'
+gpio - Config gpio port
+help - print command description/usage
+i2c - I2C sub-system
+icache - enable or disable instruction cache
+initDbgLevel- Initial varaible 'dbgLevel'
+loop - infinite loop on address range
+macaddr - setup EMAC MAC addr
+md - memory display
+mm - memory modify (auto-incrementing address)
+mmc - MMC sub system
+mmcinfo - display MMC info
+mssdmmc - Mstar SD/MMC IP Verification System
+mstar - script via TFTP
+mw - memory write (fill)
+mxp - MXP function for Mstar MXP partition
+nm - memory modify (constant address)
+ping - send ICMP ECHO_REQUEST to network host
+printenv- print environment variables
+pwm - Set 5000Hz duty 50% pwm waveform and use PAD_FUART_RX
+Sample: pwm 0 100000 200000 14
+
+reset - Perform RESET of the CPU
+riu - riu - riu command
+
+run - run commands in an environment variable
+saveenv - save environment variables to persistent storage
+sdstar - script via sd package
+secauth - Control Sstar security authenticate sequence
+setenv - set environment variables
+sf - SPI flash sub-system
+sfbin - for uploading sf image to a server(via network using TFTP protocol)
+showvar - print local hushshell variables
+sigauth - Only verify digital signature and aes
+srcfg - sensor pin and mclk configuration.
+test - minimal test like /bin/sh
+test_sig- Test runAuthenticate2
+
+tftpboot- boot image via network using TFTP protocol
+true - do nothing, successfully
+uart - UART sub-system
+udpbc - Send or receive UDP broadcast to/from server using UDP protocol
+version - print monitor, compiler and linker version
+
+Anjoy # version
+
+U-Boot 2015.01 (Nov 24 2022 - 17:53:59)
+arm-buildroot-linux-uclibcgnueabihf-gcc.br_real (Buildroot 2017.08-g239d7d0) 4.9.4
+GNU ld (GNU Binutils) 2.28.1
diff --git a/service/internal/boards/contributions_test.go b/service/internal/boards/contributions_test.go
new file mode 100644
index 00000000..a3e461b5
--- /dev/null
+++ b/service/internal/boards/contributions_test.go
@@ -0,0 +1,389 @@
+package boards
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "testing/fstest"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// contributed is a contributions tree for the fixture's xiongmai-53h20-s.
+func contributed(yml, console string) fstest.MapFS {
+ return fstest.MapFS{
+ "contributions.yml": {Data: []byte(yml)},
+ "contributions/xiongmai-53h20-s-c9/uboot.txt": {Data: []byte(console)},
+ "contributions/xiongmai-53h20-s-c9/boot.log": {Data: []byte("Linux version 3.0.8\nhi3516cv100 System startup\n")},
+ }
+}
+
+const oneContribution = `
+- unit: xiongmai-53h20-s-c9
+ model: xiongmai-53h20-s
+ by: someone
+ evidence: [https://github.com/OpenIPC/website/issues/9]
+ sensor: IMX222
+ flash_chip: MX25L6406E
+ flash_mb: 8
+ note: Stock firmware.
+ files:
+ - {kind: uboot_env, file: uboot.txt}
+ - {kind: boot_log, file: boot.log}
+`
+
+const ownersConsole = "U-Boot 2010.06\nhisilicon # printenv\nbootcmd=sf probe 0;bootm 0x82000000\nethaddr=00:12:34:56:78:9a\n"
+
+// contributedTree is as much of GET /api/v1/boards as these tests read.
+type contributedTree struct {
+ Manufacturers []struct {
+ Models []struct {
+ Units []struct {
+ ID string
+ ContributedBy *string `json:"contributed_by"`
+ Files []struct{ Kind string }
+ }
+ }
+ }
+ Sources []struct{ ID, Name string }
+}
+
+func applyFS(t *testing.T, im *Importer, fsys fstest.MapFS) []string {
+ t.Helper()
+ list, err := parseContributions(fsys)
+ if err != nil {
+ t.Fatal(err)
+ }
+ missing, err := im.applyContributions(context.Background(), fsys, list)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return missing
+}
+
+func TestTheShippedContributionsParse(t *testing.T) {
+ list, err := Contributions()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(list) == 0 {
+ t.Fatal("contributions.yml is empty")
+ }
+ // The MS-J10's console (#366) is a printenv the catalogue can query.
+ b, err := contributionsFS.ReadFile("contributions/anjoy-ms-j10-c365/uboot.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+ vars := UBootVars(string(b))
+ if !strings.HasPrefix(vars["bootcmd"], "sf probe 0;") || vars["version_rootfs"] != "MSJ10_V1" {
+ t.Errorf("console variables: %v", vars)
+ }
+ if strings.Contains(string(b), "**") {
+ t.Error("the console still carries the issue's markdown")
+ }
+}
+
+func TestAContributionIsAUnitCreditedToItsSender(t *testing.T) {
+ pool, root := imported(t)
+ ctx := context.Background()
+ im := &Importer{Pool: pool, Log: quiet(), Root: root}
+ if missing := applyFS(t, im, contributed(oneContribution, ownersConsole)); len(missing) != 0 {
+ t.Fatalf("missing %v", missing)
+ }
+
+ var source, ref, by, notes string
+ if err := pool.QueryRow(ctx, `SELECT source::text, source_ref, contributed_by, notes FROM board_units WHERE id = 'xiongmai-53h20-s-c9'`).
+ Scan(&source, &ref, &by, ¬es); err != nil {
+ t.Fatal(err)
+ }
+ if source != "contributor" || ref != "https://github.com/OpenIPC/website/issues/9" || by != "someone" || notes != "Stock firmware." {
+ t.Errorf("unit: %s %s %s %q", source, ref, by, notes)
+ }
+ var v string
+ if err := pool.QueryRow(ctx, `
+ SELECT v.value FROM board_uboot_vars v JOIN board_artifacts a ON a.id = v.artifact_id
+ WHERE a.unit_id = 'xiongmai-53h20-s-c9' AND v.key = 'ethaddr'`).Scan(&v); err != nil || v != "00:12:34:56:78:9a" {
+ t.Errorf("ethaddr %q, %v", v, err)
+ }
+ // It follows the model's own units.
+ var after bool
+ if err := pool.QueryRow(ctx, `SELECT (SELECT position FROM board_units WHERE id = 'xiongmai-53h20-s-c9') >
+ (SELECT max(position) FROM board_units WHERE source <> 'contributor')`).Scan(&after); err != nil || !after {
+ t.Errorf("the contributed unit sorts before the archive's: %v", err)
+ }
+ if b, err := os.ReadFile(filepath.Join(root, "xiongmai-53h20-s-c9", "boot.log")); err != nil || !strings.Contains(string(b), "System startup") {
+ t.Errorf("boot.log on disk: %q, %v", b, err)
+ }
+
+ // The tree lists the unit with its credit and the source with a name,
+ // and search reads its text.
+ s := serve(t, pool)
+ var tree contributedTree
+ getJSON(t, s.URL+"/api/v1/boards", &tree)
+ found := false
+ for _, m := range tree.Manufacturers {
+ for _, md := range m.Models {
+ for _, u := range md.Units {
+ if u.ID == "xiongmai-53h20-s-c9" {
+ found = u.ContributedBy != nil && *u.ContributedBy == "someone" && len(u.Files) == 2
+ }
+ }
+ }
+ }
+ if !found {
+ t.Error("the tree does not show the contributed unit with its credit and files")
+ }
+ named := false
+ for _, src := range tree.Sources {
+ named = named || (src.ID == "contributor" && src.Name != "")
+ }
+ if !named {
+ t.Errorf("sources: %+v", tree.Sources)
+ }
+ var a searchAnswer
+ getJSON(t, s.URL+"/api/v1/boards/search?q=sf+probe&kind=uboot_env", &a)
+ if len(a.Hits) != 1 || a.Hits[0].UnitID != "xiongmai-53h20-s-c9" {
+ t.Errorf("search: %+v", a.Hits)
+ }
+}
+
+func TestApplyingTwiceChangesNothingAndAnEditReplacesTheUnit(t *testing.T) {
+ pool, root := imported(t)
+ ctx := context.Background()
+ im := &Importer{Pool: pool, Log: quiet(), Root: root}
+ applyFS(t, im, contributed(oneContribution, ownersConsole))
+ stamp := func() (s string) {
+ _ = pool.QueryRow(ctx, `SELECT u.ingested_at::text || string_agg(a.id::text, ',' ORDER BY a.id)
+ FROM board_units u JOIN board_artifacts a ON a.unit_id = u.id
+ WHERE u.id = 'xiongmai-53h20-s-c9' GROUP BY u.ingested_at`).Scan(&s)
+ return s
+ }
+ first := stamp()
+ applyFS(t, im, contributed(oneContribution, ownersConsole))
+ if stamp() != first {
+ t.Error("an unchanged contribution was stored again")
+ }
+
+ // A file lost from disk (a database restored on an empty BOARDS_ROOT)
+ // is written back.
+ _ = os.RemoveAll(filepath.Join(root, "xiongmai-53h20-s-c9"))
+ applyFS(t, im, contributed(oneContribution, ownersConsole))
+ if _, err := os.Stat(filepath.Join(root, "xiongmai-53h20-s-c9", "uboot.txt")); err != nil {
+ t.Error(err)
+ }
+
+ applyFS(t, im, contributed(oneContribution, ownersConsole+"bootdelay=1\n"))
+ var n int
+ _ = pool.QueryRow(ctx, `SELECT count(*) FROM board_uboot_vars v JOIN board_artifacts a ON a.id = v.artifact_id
+ WHERE a.unit_id = 'xiongmai-53h20-s-c9'`).Scan(&n)
+ if n != 3 {
+ t.Errorf("an edited console has %d variables, want 3", n)
+ }
+}
+
+func TestARemovedContributionLeavesNothingBehind(t *testing.T) {
+ pool, root := imported(t)
+ ctx := context.Background()
+ im := &Importer{Pool: pool, Log: quiet(), Root: root}
+ applyFS(t, im, contributed(oneContribution, ownersConsole))
+ applyFS(t, im, contributed("[]", ownersConsole))
+ var units, sources int
+ _ = pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM board_units WHERE source = 'contributor'),
+ (SELECT count(*) FROM board_sources WHERE id = 'contributor')`).Scan(&units, &sources)
+ if units != 0 || sources != 0 {
+ t.Errorf("%d units, %d source rows left", units, sources)
+ }
+ if _, err := os.Stat(filepath.Join(root, "xiongmai-53h20-s-c9")); !os.IsNotExist(err) {
+ t.Errorf("the unit's directory: %v", err)
+ }
+ // The archive's own units are untouched.
+ var archive int
+ _ = pool.QueryRow(ctx, `SELECT count(*) FROM board_units`).Scan(&archive)
+ if archive != 6 {
+ t.Errorf("%d units, want the archive's 6", archive)
+ }
+}
+
+func TestAContributionWaitsForItsModel(t *testing.T) {
+ pool, root := imported(t)
+ im := &Importer{Pool: pool, Log: quiet(), Root: root}
+ missing := applyFS(t, im, contributed(strings.Replace(oneContribution, "model: xiongmai-53h20-s", "model: nobody-x1", 1), ownersConsole))
+ if len(missing) != 1 || missing[0] != "nobody-x1" {
+ t.Errorf("missing %v", missing)
+ }
+ var n int
+ _ = pool.QueryRow(context.Background(), `SELECT count(*) FROM board_units WHERE source = 'contributor'`).Scan(&n)
+ if n != 0 {
+ t.Errorf("%d contributed units stored for a model nobody has", n)
+ }
+}
+
+func TestAContributionCannotTakeAnotherSourcesUnit(t *testing.T) {
+ pool, root := imported(t)
+ im := &Importer{Pool: pool, Log: quiet(), Root: root}
+ yml := strings.ReplaceAll(oneContribution, "xiongmai-53h20-s-c9", "xiongmai-53h20-s-u1")
+ fsys := fstest.MapFS{
+ "contributions.yml": {Data: []byte(yml)},
+ "contributions/xiongmai-53h20-s-u1/uboot.txt": {Data: []byte(ownersConsole)},
+ "contributions/xiongmai-53h20-s-u1/boot.log": {Data: []byte("x\n")},
+ }
+ list, err := parseContributions(fsys)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := im.applyContributions(context.Background(), fsys, list); err == nil || !strings.Contains(err.Error(), "already taken") {
+ t.Errorf("err = %v", err)
+ }
+ // and the archive's files are where they were.
+ if _, err := os.Stat(filepath.Join(root, "xiongmai-53h20-s-u1", "front.jpg")); err != nil {
+ t.Error(err)
+ }
+}
+
+func TestContributionsYAMLIsChecked(t *testing.T) {
+ for name, yml := range map[string]string{
+ "a flash dump": strings.Replace(oneContribution, "kind: boot_log", "kind: flash_dump", 1),
+ "a lost file": strings.Replace(oneContribution, "file: boot.log", "file: boot2.log", 1),
+ "no evidence": strings.Replace(oneContribution, "evidence: [https://github.com/OpenIPC/website/issues/9]", "evidence: []", 1),
+ "not a web page": strings.Replace(oneContribution, "https://github.com/OpenIPC/website/issues/9", "issue 9", 1),
+ "twice": oneContribution + strings.TrimPrefix(oneContribution, "\n"),
+ } {
+ if _, err := parseContributions(contributed(yml, ownersConsole)); err == nil {
+ t.Errorf("%s: accepted", name)
+ }
+ }
+}
+
+// A published owner report's text is a unit too (ApplyReportUnits), and the
+// two lists keep out of each other's way: contributions.yml never removes a
+// report's unit, nor the reports' list one of contributions.yml's.
+func TestReportUnitsAndContributionsLeaveEachOtherAlone(t *testing.T) {
+ pool, root := imported(t)
+ ctx := context.Background()
+ im := &Importer{Pool: pool, Log: quiet(), Root: root}
+ applyFS(t, im, contributed(oneContribution, ownersConsole))
+
+ store := fstest.MapFS{"ab/abcdef": {Data: []byte("U-Boot 2015.01\nbootcmd=sf probe 0\n")}}
+ report := []Contribution{{Unit: "xiongmai-53h20-s-r-abcd2345", Model: "xiongmai-53h20-s", By: "Ivan",
+ Evidence: []string{"https://openipc.org" + ReceiptMark + "r-abcd2345"},
+ Files: []ContributedFile{{Kind: "uboot_env", File: "2-uboot_env.txt", Source: "ab/abcdef"}}}}
+ if _, err := im.ApplyReportUnits(ctx, store, report); err != nil {
+ t.Fatal(err)
+ }
+ count := func() (yml, rep int) {
+ _ = pool.QueryRow(ctx, `SELECT count(*) FILTER (WHERE strpos(source_ref, $1) = 0), count(*) FILTER (WHERE strpos(source_ref, $1) > 0)
+ FROM board_units WHERE source = 'contributor'`, ReceiptMark).Scan(&yml, &rep)
+ return
+ }
+ if y, r := count(); y != 1 || r != 1 {
+ t.Fatalf("%d from contributions.yml, %d from reports", y, r)
+ }
+ applyFS(t, im, contributed(oneContribution, ownersConsole))
+ if _, r := count(); r != 1 {
+ t.Error("applying contributions.yml removed a report's unit")
+ }
+ if _, err := im.ApplyReportUnits(ctx, store, nil); err != nil {
+ t.Fatal(err)
+ }
+ if y, r := count(); y != 1 || r != 0 {
+ t.Errorf("after the report was withdrawn: %d from contributions.yml, %d from reports", y, r)
+ }
+}
+
+// A tiny lossless WebP (1x1), as the send form accepts them.
+var webp1x1 = []byte{0x52, 0x49, 0x46, 0x46, 0x1a, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4c,
+ 0x0d, 0x00, 0x00, 0x00, 0x2f, 0x00, 0x00, 0x00, 0x10, 0x07, 0x10, 0x11, 0x11, 0x88, 0x88, 0xfe, 0x07, 0x00}
+
+func reportUnit(unit, model, report, src, kind, file string) Contribution {
+ return Contribution{Unit: unit, Model: model, By: "Ivan",
+ Evidence: []string{"https://openipc.org" + ReceiptMark + report + "&board=" + model},
+ Files: []ContributedFile{{Kind: kind, File: file, Source: src}}}
+}
+
+func TestAWebPPhotoIsThumbnailedAndABrokenOneCostsOnlyItsReport(t *testing.T) {
+ pool, root := imported(t)
+ ctx := context.Background()
+ im := &Importer{Pool: pool, Log: quiet(), Root: root}
+ store := fstest.MapFS{
+ "aa/webp": {Data: webp1x1},
+ "bb/broken": {Data: []byte("not a picture")},
+ "cc/text": {Data: []byte("U-Boot\n")},
+ }
+ list := []Contribution{
+ reportUnit("xiongmai-53h20-s-r-aaaaaaaa", "xiongmai-53h20-s", "r-aaaaaaaa", "aa/webp", "photo_other", "1-front.webp"),
+ reportUnit("xiongmai-53h20-s-r-bbbbbbbb", "xiongmai-53h20-s", "r-bbbbbbbb", "bb/broken", "photo_other", "1-x.webp"),
+ reportUnit("xiongmai-53h20-s-r-cccccccc", "xiongmai-53h20-s", "r-cccccccc", "cc/text", "boot_log", "1-boot.txt"),
+ }
+ if _, err := im.ApplyReportUnits(ctx, store, list); err != nil {
+ t.Fatal(err)
+ }
+ var units []string
+ rows, _ := pool.Query(ctx, `SELECT id FROM board_units WHERE source = 'contributor' ORDER BY id`)
+ units, _ = pgx.CollectRows(rows, pgx.RowTo[string])
+ if strings.Join(units, " ") != "xiongmai-53h20-s-r-aaaaaaaa xiongmai-53h20-s-r-cccccccc" {
+ t.Errorf("units: %v", units)
+ }
+ if _, err := os.Stat(filepath.Join(root, "xiongmai-53h20-s-r-aaaaaaaa", "thumb-1-front.jpg")); err != nil {
+ t.Error(err)
+ }
+
+ // A unit that was fine and now cannot be written stays as it was.
+ list[0].Files[0].Source = "bb/broken"
+ if _, err := im.ApplyReportUnits(ctx, store, list); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(root, "xiongmai-53h20-s-r-aaaaaaaa", "1-front.webp")); err != nil {
+ t.Errorf("the earlier unit's file: %v", err)
+ }
+}
+
+func TestAReportOnTwoBoardsIsAUnitOnEachAndTwoRefreshesAtOnceAgree(t *testing.T) {
+ pool, root := imported(t)
+ ctx := context.Background()
+ store := fstest.MapFS{"cc/text": {Data: []byte("U-Boot\n")}}
+ list := []Contribution{
+ reportUnit("xiongmai-53h20-s-r-cccccccc", "xiongmai-53h20-s", "r-cccccccc", "cc/text", "boot_log", "1-boot.txt"),
+ reportUnit("unknown-unidentified-hi3516cv200-3-r-cccccccc", "unknown-unidentified-hi3516cv200-3", "r-cccccccc", "cc/text", "boot_log", "1-boot.txt"),
+ }
+ // A pool as small as CI's: waiters must not starve the apply that holds
+ // the lock of the connection it needs.
+ cfg := pool.Config()
+ cfg.MaxConns = 2
+ small, err := pgxpool.NewWithConfig(ctx, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer small.Close()
+ var wg sync.WaitGroup
+ errs := make(chan error, 6)
+ for i := 0; i < 6; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ im := &Importer{Pool: small, Log: quiet(), Root: root}
+ _, err := im.ApplyReportUnits(ctx, store, list)
+ errs <- err
+ }()
+ }
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ var n int
+ _ = pool.QueryRow(ctx, `SELECT count(*) FROM board_units WHERE source = 'contributor'`).Scan(&n)
+ if n != 2 {
+ t.Errorf("%d units, want one on each board", n)
+ }
+ for _, c := range list {
+ if _, err := os.Stat(filepath.Join(root, c.Unit, "1-boot.txt")); err != nil {
+ t.Error(err)
+ }
+ }
+}
diff --git a/service/internal/boards/text.go b/service/internal/boards/text.go
index 99c75c18..b09544f5 100644
--- a/service/internal/boards/text.go
+++ b/service/internal/boards/text.go
@@ -6,6 +6,10 @@ import (
"image/color"
"image/jpeg"
_ "image/png"
+
+ // The send form takes WebP photos too; a published one is thumbnailed
+ // like any other.
+ _ "golang.org/x/image/webp"
"regexp"
"strings"
)
diff --git a/service/internal/club/api.go b/service/internal/club/api.go
new file mode 100644
index 00000000..e6824269
--- /dev/null
+++ b/service/internal/club/api.go
@@ -0,0 +1,631 @@
+package club
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/OpenIPC/website/service/internal/reports"
+)
+
+const (
+ sessionCookie = "openipc_club"
+ loginCookie = "openipc_club_login"
+ cookiePath = "/api/v1/club"
+)
+
+// Config is what the club needs from the environment. Each way in works
+// only when it is configured; /api/v1/club/me says which do.
+type Config struct {
+ // SiteURL is where links point and cookies are for:
+ // https://openipc.org, https://dev.openipc.org.
+ SiteURL string
+ // Telegram: the bot's token from @BotFather. Its username is read from
+ // Telegram when the role starts.
+ TelegramToken string
+ // GitHub: an OAuth app whose callback is /api/v1/club/github/callback.
+ GitHubClientID, GitHubSecret string
+ // MaintainerOrg: a GitHub identity in this organisation reviews.
+ MaintainerOrg string
+ // Maintainers: member ids that review without it (CLUB_MAINTAINERS).
+ Maintainers []string
+}
+
+// API is the club's addresses on the web role.
+type API struct {
+ DB *pgxpool.Pool
+ Log *slog.Logger
+ Cfg Config
+ Reports *reports.API
+ Telegram *Telegram
+ GitHub *GitHub
+ Mail Mailer
+ // OnReviewed runs after a decision: the published text joins the boards.
+ OnReviewed func(context.Context)
+ Now func() time.Time
+
+ limits limiter
+}
+
+func (a *API) now() time.Time {
+ if a.Now != nil {
+ return a.Now()
+ }
+ return time.Now()
+}
+
+func (a *API) Handlers() map[string]http.Handler {
+ return map[string]http.Handler{
+ "GET /api/v1/club/me": http.HandlerFunc(a.me),
+ "POST /api/v1/club/logout": a.post(a.logout),
+ "POST /api/v1/club/quiet": a.post(a.quiet),
+ "GET /api/v1/club/login": http.HandlerFunc(a.poll),
+ "GET /api/v1/club/finish": http.HandlerFunc(a.finish),
+ "GET /api/v1/club/finish/who": http.HandlerFunc(a.finishWho),
+ "POST /api/v1/club/finish": a.post(a.finishConfirmed),
+ "POST /api/v1/club/name": a.post(a.rename),
+ "POST /api/v1/club/telegram": a.post(a.telegramStart),
+ "POST /api/v1/club/telegram/webhook": http.HandlerFunc(a.telegramWebhook),
+ "POST /api/v1/club/email": a.post(a.emailStart),
+ "GET /api/v1/club/github": http.HandlerFunc(a.githubStart),
+ "GET /api/v1/club/github/callback": http.HandlerFunc(a.githubCallback),
+ "POST /api/v1/club/reports": a.post(a.send),
+ "GET /api/v1/club/reports": http.HandlerFunc(a.mine),
+ "GET /api/v1/club/reports/{id}/files/{position}": http.HandlerFunc(a.file),
+ "GET /api/v1/club/review": http.HandlerFunc(a.queue),
+ "POST /api/v1/club/review/{id}": a.post(a.decide),
+ }
+}
+
+// post refuses a state-changing request from another site's page. The
+// session cookie is SameSite=Lax already; this is the second lock.
+func (a *API) post(h http.HandlerFunc) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if o := r.Header.Get("Origin"); o != "" && !a.sameSite(o) {
+ a.refuse(w, http.StatusForbidden, "this request comes from another site")
+ return
+ }
+ if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
+ a.refuse(w, http.StatusForbidden, "this request comes from another site")
+ return
+ }
+ h(w, r)
+ })
+}
+
+func (a *API) sameSite(origin string) bool {
+ o, err := url.Parse(origin)
+ s, err2 := url.Parse(a.Cfg.SiteURL)
+ return err == nil && err2 == nil && o.Scheme == s.Scheme && o.Host == s.Host
+}
+
+func (a *API) secure() bool { return strings.HasPrefix(a.Cfg.SiteURL, "https://") }
+
+func (a *API) setCookie(w http.ResponseWriter, name, value string, maxAge time.Duration) {
+ c := &http.Cookie{Name: name, Value: value, Path: cookiePath, HttpOnly: true, Secure: a.secure(),
+ SameSite: http.SameSiteLaxMode}
+ if value == "" {
+ c.MaxAge = -1
+ } else {
+ c.MaxAge = int(maxAge.Seconds())
+ }
+ http.SetCookie(w, c)
+}
+
+// session is the signed-in member of the request, or "".
+func (a *API) session(r *http.Request) (string, error) {
+ c, err := r.Cookie(sessionCookie)
+ if err != nil || c.Value == "" || len(c.Value) > 100 {
+ return "", nil
+ }
+ var member string
+ err = a.DB.QueryRow(r.Context(), `SELECT member_id FROM club_sessions WHERE token_sha256 = $1 AND expires_at > $2`,
+ sha(c.Value), a.now()).Scan(&member)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return "", nil
+ }
+ return member, err
+}
+
+// signedIn answers 401 itself when nobody is.
+func (a *API) signedIn(w http.ResponseWriter, r *http.Request) (*Member, bool) {
+ id, err := a.session(r)
+ if err != nil {
+ a.fail(w, "the session", err)
+ return nil, false
+ }
+ if id == "" {
+ a.refuse(w, http.StatusUnauthorized, "sign in first")
+ return nil, false
+ }
+ m, err := a.member(r.Context(), id)
+ if err != nil {
+ a.fail(w, "the member", err)
+ return nil, false
+ }
+ return m, true
+}
+
+// me is GET /api/v1/club/me: who this browser is signed in as (null when
+// nobody), and which ways in this site offers.
+func (a *API) me(w http.ResponseWriter, r *http.Request) {
+ out := map[string]any{"member": nil, "sign_in": a.ways()}
+ id, err := a.session(r)
+ if err != nil {
+ a.fail(w, "the session", err)
+ return
+ }
+ if id != "" {
+ m, err := a.member(r.Context(), id)
+ if err != nil {
+ a.fail(w, "the member", err)
+ return
+ }
+ out["member"] = m
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+func (a *API) ways() map[string]any {
+ ways := map[string]any{"telegram": nil, "github": a.GitHub != nil, "email": a.Mail != nil}
+ if a.Telegram != nil && a.Telegram.Username() != "" {
+ ways["telegram"] = a.Telegram.Username()
+ }
+ return ways
+}
+
+func (a *API) logout(w http.ResponseWriter, r *http.Request) {
+ if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
+ if _, err := a.DB.Exec(r.Context(), `DELETE FROM club_sessions WHERE token_sha256 = $1`, sha(c.Value)); err != nil {
+ a.fail(w, "signing out", err)
+ return
+ }
+ }
+ a.setCookie(w, sessionCookie, "", 0)
+ writeJSON(w, http.StatusOK, map[string]any{"member": nil})
+}
+
+// quiet is POST /api/v1/club/quiet {"quiet": true}: the bot's messages off
+// or on, as /quiet and /loud do in Telegram.
+func (a *API) quiet(w http.ResponseWriter, r *http.Request) {
+ m, ok := a.signedIn(w, r)
+ if !ok {
+ return
+ }
+ var in struct {
+ Quiet bool `json:"quiet"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil {
+ a.refuse(w, http.StatusBadRequest, `send {"quiet": true} or {"quiet": false}`)
+ return
+ }
+ if _, err := a.DB.Exec(r.Context(), `UPDATE club_members SET quiet = $2 WHERE id = $1`, m.ID, in.Quiet); err != nil {
+ a.fail(w, "the setting", err)
+ return
+ }
+ m.Quiet = in.Quiet
+ writeJSON(w, http.StatusOK, map[string]any{"member": m})
+}
+
+// newLogin starts a sign-in tied to this browser: a fresh secret in the
+// login cookie, its sha256 on the row.
+func (a *API) newLogin(w http.ResponseWriter, r *http.Request, provider, email string) (code string, expires time.Time, err error) {
+ forMember, err := a.session(r)
+ if err != nil {
+ return "", time.Time{}, err
+ }
+ secret := randomString(24)
+ code = randomString(18)
+ expires = a.now().Add(loginTTL)
+ _, err = a.DB.Exec(r.Context(), `
+ INSERT INTO club_logins (code_sha256, provider, browser_sha256, email, for_member, expires_at, requested_from)
+ VALUES ($1, $2, $3, nullif($4, ''), nullif($5, ''), $6, $7)`,
+ sha(code), provider, sha(secret), email, forMember, expires, clientKey(r))
+ if err == nil {
+ a.setCookie(w, loginCookie, secret, loginTTL)
+ }
+ return code, expires, err
+}
+
+// poll is GET /api/v1/club/login: has the sign-in this browser started
+// been finished on the other side (Start tapped in Telegram)? When it has,
+// this browser is signed in now -- once.
+func (a *API) poll(w http.ResponseWriter, r *http.Request) {
+ c, err := r.Cookie(loginCookie)
+ if err != nil || c.Value == "" {
+ writeJSON(w, http.StatusOK, map[string]any{"state": "none"})
+ return
+ }
+ ctx := r.Context()
+ var member *string
+ var expires time.Time
+ var code string
+ err = a.DB.QueryRow(ctx, `
+ SELECT code_sha256, member_id, expires_at FROM club_logins
+ WHERE browser_sha256 = $1 AND used_at IS NULL ORDER BY created_at DESC LIMIT 1`, sha(c.Value)).
+ Scan(&code, &member, &expires)
+ if errors.Is(err, pgx.ErrNoRows) || (err == nil && member == nil && !expires.After(a.now())) {
+ writeJSON(w, http.StatusOK, map[string]any{"state": "expired"})
+ return
+ }
+ if err != nil {
+ a.fail(w, "the sign-in", err)
+ return
+ }
+ if member == nil {
+ writeJSON(w, http.StatusOK, map[string]any{"state": "pending", "expires_at": expires})
+ return
+ }
+ if !a.signInOnce(w, r, code, *member) {
+ return
+ }
+ m, err := a.member(ctx, *member)
+ if err != nil {
+ a.fail(w, "the member", err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"state": "signed_in", "member": m})
+}
+
+// signInOnce marks a finished login used and gives this browser a session.
+// Two requests racing on one login: one wins, the other is refused.
+func (a *API) signInOnce(w http.ResponseWriter, r *http.Request, code, member string) bool {
+ var token string
+ err := pgx.BeginFunc(r.Context(), a.DB, func(tx pgx.Tx) error {
+ tag, err := tx.Exec(r.Context(), `UPDATE club_logins SET used_at = now() WHERE code_sha256 = $1 AND used_at IS NULL`, code)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return errUsed
+ }
+ token, err = a.newSession(r.Context(), tx, member)
+ return err
+ })
+ if errors.Is(err, errUsed) {
+ a.refuse(w, http.StatusConflict, "this sign-in was already used")
+ return false
+ }
+ if err != nil {
+ a.fail(w, "the session", err)
+ return false
+ }
+ a.setCookie(w, sessionCookie, token, sessionDays*24*time.Hour)
+ a.setCookie(w, loginCookie, "", 0)
+ return true
+}
+
+var errUsed = errors.New("used")
+
+// finish is GET /api/v1/club/finish?code=: a link that signs a browser in
+// -- the emailed link, or the one the bot sends into a member's own chat
+// when a sign-in expired. Opened in the browser that asked for it, it signs
+// in at once. Opened anywhere else it only asks: the page shows whose
+// account the link is for (finishWho) and signs in when that is confirmed
+// (finishConfirmed). Otherwise anyone could ask for a link to their own
+// address, get it opened in someone else's browser, and have what that
+// person sends next land in their account.
+//
+// A browser-bound Telegram code (the QR code) is never finished here.
+func (a *API) finish(w http.ResponseWriter, r *http.Request) {
+ code := r.URL.Query().Get("code")
+ l, err := a.login(r.Context(), code)
+ if err != nil {
+ a.fail(w, "the sign-in", err)
+ return
+ }
+ switch {
+ case l == nil || !l.usable(a.now()):
+ http.Redirect(w, r, "/club/?signin=expired", http.StatusSeeOther)
+ case l.bound != "" && l.provider != "email":
+ http.Redirect(w, r, "/club/?signin=elsewhere", http.StatusSeeOther)
+ case l.bound != "" && a.sameBrowser(r, l.bound):
+ if a.complete(w, r, code, l) {
+ http.Redirect(w, r, "/club/?signin=ok", http.StatusSeeOther)
+ }
+ default:
+ http.Redirect(w, r, "/club/?confirm="+url.QueryEscape(code), http.StatusSeeOther)
+ }
+}
+
+// finishWho is GET /api/v1/club/finish/who?code=: whose account a link
+// opened in another browser signs into, for the page to ask about. Only
+// the holder of the link can ask, and it tells them nothing the link would
+// not.
+func (a *API) finishWho(w http.ResponseWriter, r *http.Request) {
+ l, err := a.login(r.Context(), r.URL.Query().Get("code"))
+ if err != nil {
+ a.fail(w, "the sign-in", err)
+ return
+ }
+ if l == nil || !l.usable(a.now()) || (l.bound != "" && l.provider != "email") {
+ a.refuse(w, http.StatusGone, "this sign-in link has expired or was already used")
+ return
+ }
+ who := deref(l.email)
+ if l.member != nil {
+ var handle string
+ _ = a.DB.QueryRow(r.Context(), `SELECT handle FROM club_identities WHERE member_id = $1 AND provider = $2 ORDER BY seen_at DESC LIMIT 1`,
+ *l.member, l.provider).Scan(&handle)
+ who = handle
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"provider": l.provider, "who": who})
+}
+
+// finishConfirmed is POST /api/v1/club/finish {"code": ...}: the page's
+// "Yes, that is my account".
+func (a *API) finishConfirmed(w http.ResponseWriter, r *http.Request) {
+ var in struct {
+ Code string `json:"code"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil || in.Code == "" {
+ a.refuse(w, http.StatusBadRequest, `send {"code": "..."}`)
+ return
+ }
+ l, err := a.login(r.Context(), in.Code)
+ if err != nil {
+ a.fail(w, "the sign-in", err)
+ return
+ }
+ if l == nil || !l.usable(a.now()) || (l.bound != "" && l.provider != "email") {
+ a.refuse(w, http.StatusGone, "this sign-in link has expired or was already used")
+ return
+ }
+ if !a.complete(w, r, in.Code, l) {
+ return
+ }
+ m, err := a.member(r.Context(), deref(l.member))
+ if err != nil {
+ a.fail(w, "the member", err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"member": m})
+}
+
+// pending is one row of club_logins, as finish reads it.
+type pending struct {
+ provider string
+ email, forMember *string
+ member *string
+ bound string
+ expires time.Time
+ used bool
+}
+
+func (l *pending) usable(now time.Time) bool { return !l.used && l.expires.After(now) }
+
+func (a *API) login(ctx context.Context, code string) (*pending, error) {
+ if code == "" || len(code) > 100 {
+ return nil, nil
+ }
+ l := &pending{}
+ var bound *string
+ var used *time.Time
+ err := a.DB.QueryRow(ctx, `
+ SELECT provider, email, for_member, member_id, browser_sha256, expires_at, used_at
+ FROM club_logins WHERE code_sha256 = $1`, sha(code)).
+ Scan(&l.provider, &l.email, &l.forMember, &l.member, &bound, &l.expires, &used)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ l.bound, l.used = deref(bound), used != nil
+ return l, err
+}
+
+func (a *API) sameBrowser(r *http.Request, bound string) bool {
+ c, err := r.Cookie(loginCookie)
+ return err == nil && c.Value != "" && sha(c.Value) == bound
+}
+
+// complete signs this browser in with a login: an emailed one becomes the
+// address's identity first.
+func (a *API) complete(w http.ResponseWriter, r *http.Request, code string, l *pending) bool {
+ ctx := r.Context()
+ if l.provider == "email" && l.member == nil {
+ var id string
+ err := pgx.BeginFunc(ctx, a.DB, func(tx pgx.Tx) error {
+ addr := strings.ToLower(deref(l.email))
+ var err error
+ // The name is never the address: it is shown on the boards a
+ // member's reports are listed on. They choose one on /club.
+ id, err = identify(ctx, tx, signIn{Provider: "email", Subject: addr, Handle: addr, Name: ""}, deref(l.forMember))
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(ctx, `UPDATE club_logins SET member_id = $2 WHERE code_sha256 = $1`, sha(code), id)
+ return err
+ })
+ if err != nil {
+ a.fail(w, "the sign-in", err)
+ return false
+ }
+ l.member = &id
+ }
+ if l.member == nil {
+ a.refuse(w, http.StatusGone, "this sign-in link has expired or was already used")
+ return false
+ }
+ return a.signInOnce(w, r, sha(code), *l.member)
+}
+
+// rename is POST /api/v1/club/name {"name": ...}: what a member is called
+// on their page and in the credit on the boards their reports reach.
+func (a *API) rename(w http.ResponseWriter, r *http.Request) {
+ m, ok := a.signedIn(w, r)
+ if !ok {
+ return
+ }
+ var in struct {
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil {
+ a.refuse(w, http.StatusBadRequest, `send {"name": "..."}`)
+ return
+ }
+ name := cleanName(in.Name)
+ if strings.TrimSpace(in.Name) == "" || strings.Contains(name, "@") {
+ a.refuse(w, http.StatusBadRequest, "a name is 1 to 80 characters, and not an email address")
+ return
+ }
+ if _, err := a.DB.Exec(r.Context(), `UPDATE club_members SET name = $2 WHERE id = $1`, m.ID, name); err != nil {
+ a.fail(w, "the name", err)
+ return
+ }
+ m.Name = name
+ if a.OnReviewed != nil {
+ // The credit on the boards is the name; list it anew.
+ a.OnReviewed(context.WithoutCancel(r.Context()))
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"member": m})
+}
+
+// send is POST /api/v1/club/reports: the site's send form. Signed in or
+// not, it is an owner report like any other (channel web); signed in, it is
+// the member's.
+func (a *API) send(w http.ResponseWriter, r *http.Request) {
+ member, err := a.session(r)
+ if err != nil {
+ a.fail(w, "the session", err)
+ return
+ }
+ a.Reports.Submit(w, r, member)
+}
+
+// mine is GET /api/v1/club/reports: what the member sent, its state and
+// its stars.
+func (a *API) mine(w http.ResponseWriter, r *http.Request) {
+ m, ok := a.signedIn(w, r)
+ if !ok {
+ return
+ }
+ list, err := a.Reports.Store().Mine(r.Context(), m.ID)
+ if err != nil {
+ a.fail(w, "the reports", err)
+ return
+ }
+ for _, rep := range list {
+ m.Pending += rep.Pending
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"member": m, "reports": list})
+}
+
+// file is GET /api/v1/club/reports/{id}/files/{position}: any file of a
+// report -- its private backup too -- for the member who sent it and for
+// the maintainers. Anyone else gets the 404 a missing file gets.
+func (a *API) file(w http.ResponseWriter, r *http.Request) {
+ m, ok := a.signedIn(w, r)
+ if !ok {
+ return
+ }
+ id := r.PathValue("id")
+ pos, err := strconv.Atoi(r.PathValue("position"))
+ if err != nil || pos < 1 {
+ http.NotFound(w, r)
+ return
+ }
+ if !m.Maintainer {
+ owner, err := a.Reports.Store().Owner(r.Context(), id)
+ if err != nil {
+ a.fail(w, "the report", err)
+ return
+ }
+ if owner != m.ID {
+ http.NotFound(w, r)
+ return
+ }
+ }
+ a.Log.Info("club: file sent", "report", id, "position", pos, "member", m.ID, "maintainer", m.Maintainer)
+ a.Reports.ServeStored(w, r, id, pos)
+}
+
+// queue is GET /api/v1/club/review: the maintainers' review queue.
+func (a *API) queue(w http.ResponseWriter, r *http.Request) {
+ m, ok := a.signedIn(w, r)
+ if !ok {
+ return
+ }
+ if !m.Maintainer {
+ a.refuse(w, http.StatusForbidden, "only OpenIPC's maintainers review")
+ return
+ }
+ q, err := a.Reports.Store().Queue(r.Context(), r.URL.Query().Get("status"))
+ if err != nil {
+ a.fail(w, "the queue", err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"reports": q})
+}
+
+// decide is POST /api/v1/club/review/{id} {"decision": "publish"|"reject",
+// "models": [...], "note": "..."}: a maintainer's decision, its stars, and
+// a message to the sender when the bot can reach them.
+func (a *API) decide(w http.ResponseWriter, r *http.Request) {
+ m, ok := a.signedIn(w, r)
+ if !ok {
+ return
+ }
+ if !m.Maintainer {
+ a.refuse(w, http.StatusForbidden, "only OpenIPC's maintainers review")
+ return
+ }
+ var in struct {
+ Decision string `json:"decision"`
+ Models []string `json:"models"`
+ Note string `json:"note"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&in); err != nil {
+ a.refuse(w, http.StatusBadRequest, `send {"decision": "publish" or "reject", "models": [...], "note": "..."}`)
+ return
+ }
+ id := r.PathValue("id")
+ by := m.Name + " (" + m.ID + ")"
+ d, err := a.Reports.Store().Decide(r.Context(), id, in.Decision, by, in.Note, in.Models)
+ if errors.Is(err, reports.ErrNotFound) {
+ a.refuse(w, http.StatusNotFound, "no report has this id")
+ return
+ }
+ if err != nil {
+ a.refuse(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ a.Log.Info("club: reviewed", "report", id, "decision", in.Decision, "by", m.ID, "member", d.Member, "points", d.Points)
+ if a.OnReviewed != nil {
+ a.OnReviewed(context.WithoutCancel(r.Context()))
+ }
+ if d.Member != "" {
+ a.notifyDecision(context.WithoutCancel(r.Context()), d.Member, id, in.Decision, d)
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"id": id, "decision": in.Decision, "points": d.Points, "total": d.Total})
+}
+
+func (a *API) refuse(w http.ResponseWriter, status int, reason string) {
+ writeJSON(w, status, map[string]string{"error": reason})
+}
+
+func (a *API) fail(w http.ResponseWriter, what string, err error) {
+ a.Log.Error("club: "+what+" failed", "err", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "something failed on our side; try again"})
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func deref(s *string) string {
+ if s == nil {
+ return ""
+ }
+ return *s
+}
diff --git a/service/internal/club/api_test.go b/service/internal/club/api_test.go
new file mode 100644
index 00000000..fc632810
--- /dev/null
+++ b/service/internal/club/api_test.go
@@ -0,0 +1,775 @@
+package club
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "mime/multipart"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/OpenIPC/website/service/internal/db/dbtest"
+ "github.com/OpenIPC/website/service/internal/reports"
+)
+
+func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
+
+const site = "https://openipc.test"
+
+// fakeTelegram is the Bot API: it remembers what the bot sent.
+type fakeTelegram struct {
+ mu sync.Mutex
+ sent []map[string]any
+}
+
+func (f *fakeTelegram) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ var in map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&in)
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/getMe"):
+ _, _ = io.WriteString(w, `{"ok":true,"result":{"username":"OpenIPCTestBot"}}`)
+ case strings.HasSuffix(r.URL.Path, "/sendMessage"), strings.HasSuffix(r.URL.Path, "/editMessageText"):
+ f.mu.Lock()
+ f.sent = append(f.sent, in)
+ f.mu.Unlock()
+ _, _ = io.WriteString(w, `{"ok":true,"result":{}}`)
+ default:
+ _, _ = io.WriteString(w, `{"ok":true,"result":true}`)
+ }
+}
+
+func (f *fakeTelegram) last(t *testing.T) (text string, button string) {
+ t.Helper()
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if len(f.sent) == 0 {
+ t.Fatal("the bot sent nothing")
+ }
+ m := f.sent[len(f.sent)-1]
+ text, _ = m["text"].(string)
+ if rm, ok := m["reply_markup"].(map[string]any); ok {
+ if kb := rm["inline_keyboard"].([]any); len(kb) > 0 {
+ b := kb[0].([]any)[0].(map[string]any)
+ button, _ = b["url"].(string)
+ if button == "" {
+ button, _ = b["callback_data"].(string)
+ }
+ }
+ }
+ return
+}
+
+type fakeMail struct{ to, subject, body string }
+
+func (f *fakeMail) Send(to, subject, body string) error {
+ f.to, f.subject, f.body = to, subject, body
+ return nil
+}
+
+type env struct {
+ pool *pgxpool.Pool
+ api *API
+ mux *http.ServeMux
+ tg *fakeTelegram
+ mail *fakeMail
+ clock time.Time
+}
+
+func newEnv(t *testing.T) *env {
+ t.Helper()
+ pool := dbtest.New(t)
+ e := &env{pool: pool, tg: &fakeTelegram{}, mail: &fakeMail{}, clock: time.Now()}
+ tgs := httptest.NewServer(e.tg)
+ t.Cleanup(tgs.Close)
+ gh := httptest.NewServer(http.HandlerFunc(fakeGitHub))
+ t.Cleanup(gh.Close)
+ rep := &reports.API{DB: pool, Files: &reports.Files{Root: t.TempDir()}, AccelPrefix: "/report-files/", Log: quiet()}
+ e.api = &API{DB: pool, Log: quiet(), Reports: rep, Cfg: Config{SiteURL: site, MaintainerOrg: "OpenIPC"},
+ Telegram: &Telegram{Token: "123:abc", API: tgs.URL, HTTP: tgs.Client(), Log: quiet()},
+ GitHub: &GitHub{ClientID: "id", Secret: "secret", Web: gh.URL, API: gh.URL, HTTP: gh.Client()},
+ Mail: e.mail, Now: func() time.Time { return e.clock }}
+ if err := e.api.Telegram.Start(context.Background(), site); err != nil {
+ t.Fatal(err)
+ }
+ e.mux = http.NewServeMux()
+ for k, h := range e.api.Handlers() {
+ e.mux.Handle(k, h)
+ }
+ for _, sql := range []string{
+ `INSERT INTO board_manufacturers (id, name) VALUES ('anjoy', 'Anjoy Vision')`,
+ `INSERT INTO board_models (id, manufacturer_id, model, soc, soc_label) VALUES ('anjoy-ms-j10', 'anjoy', 'MS-J10', 'ssc335', 'SSC335')`,
+ } {
+ if _, err := pool.Exec(context.Background(), sql); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return e
+}
+
+// fakeGitHub: code "maint" is octocat in the organisation, any other code a
+// stranger outside it.
+func fakeGitHub(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/login/oauth/access_token":
+ _ = r.ParseForm()
+ _, _ = io.WriteString(w, `{"access_token":"tok-`+r.Form.Get("code")+`"}`)
+ case "/user":
+ if r.Header.Get("Authorization") == "Bearer tok-maint" {
+ _, _ = io.WriteString(w, `{"id":583231,"login":"octocat","name":"The Octocat"}`)
+ return
+ }
+ _, _ = io.WriteString(w, `{"id":99,"login":"stranger","name":""}`)
+ case "/user/memberships/orgs/OpenIPC":
+ if r.Header.Get("Authorization") == "Bearer tok-maint" {
+ _, _ = io.WriteString(w, `{"state":"active"}`)
+ return
+ }
+ http.NotFound(w, r)
+ default:
+ http.NotFound(w, r)
+ }
+}
+
+// browser keeps its own cookies, as a browser does for /api/v1/club.
+type browser struct {
+ e *env
+ cookies map[string]string
+ ip string
+}
+
+func (e *env) browser(ip string) *browser {
+ return &browser{e: e, cookies: map[string]string{}, ip: ip}
+}
+
+func (b *browser) do(t *testing.T, method, path string, body io.Reader, ct string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(method, site+path, body)
+ if ct != "" {
+ req.Header.Set("Content-Type", ct)
+ }
+ if method == "POST" {
+ req.Header.Set("Origin", site)
+ }
+ req.RemoteAddr = b.ip + ":4000"
+ for k, v := range b.cookies {
+ req.AddCookie(&http.Cookie{Name: k, Value: v})
+ }
+ rec := httptest.NewRecorder()
+ b.e.mux.ServeHTTP(rec, req)
+ for _, c := range rec.Result().Cookies() {
+ if c.Path != "/api/v1/club" || !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode {
+ t.Errorf("cookie %s: path %q httponly %v secure %v samesite %v", c.Name, c.Path, c.HttpOnly, c.Secure, c.SameSite)
+ }
+ if c.MaxAge < 0 {
+ delete(b.cookies, c.Name)
+ } else {
+ b.cookies[c.Name] = c.Value
+ }
+ }
+ return rec
+}
+
+func (b *browser) json(t *testing.T, method, path string, in any) (int, map[string]any) {
+ t.Helper()
+ var body io.Reader
+ if in != nil {
+ raw, _ := json.Marshal(in)
+ body = bytes.NewReader(raw)
+ }
+ rec := b.do(t, method, path, body, "application/json")
+ var out map[string]any
+ _ = json.Unmarshal(rec.Body.Bytes(), &out)
+ return rec.Code, out
+}
+
+func (b *browser) me(t *testing.T) map[string]any {
+ t.Helper()
+ _, out := b.json(t, "GET", "/api/v1/club/me", nil)
+ m, _ := out["member"].(map[string]any)
+ return m
+}
+
+// webhook is Telegram delivering a message someone wrote to the bot.
+func (e *env) webhook(t *testing.T, from int64, username, lang, text string) {
+ t.Helper()
+ u := map[string]any{"update_id": 1, "message": map[string]any{
+ "chat": map[string]any{"id": from, "type": "private"},
+ "from": map[string]any{"id": from, "is_bot": false, "username": username, "first_name": "Ivan", "language_code": lang},
+ "text": text,
+ }}
+ raw, _ := json.Marshal(u)
+ req := httptest.NewRequest("POST", site+"/api/v1/club/telegram/webhook", bytes.NewReader(raw))
+ req.Header.Set("X-Telegram-Bot-Api-Secret-Token", e.api.Telegram.Secret())
+ rec := httptest.NewRecorder()
+ e.mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("webhook: %d %s", rec.Code, rec.Body)
+ }
+}
+
+// answer is Telegram delivering a tap on a button under the bot's message.
+func (e *env) answer(t *testing.T, from int64, data string) {
+ t.Helper()
+ u := map[string]any{"update_id": 2, "callback_query": map[string]any{
+ "id": "cb", "from": map[string]any{"id": from, "language_code": "en"}, "data": data,
+ "message": map[string]any{"message_id": 5, "chat": map[string]any{"id": from}},
+ }}
+ raw, _ := json.Marshal(u)
+ req := httptest.NewRequest("POST", site+"/api/v1/club/telegram/webhook", bytes.NewReader(raw))
+ req.Header.Set("X-Telegram-Bot-Api-Secret-Token", e.api.Telegram.Secret())
+ rec := httptest.NewRecorder()
+ e.mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("callback: %d %s", rec.Code, rec.Body)
+ }
+}
+
+// startAndConfirm is a person tapping Start on a link and then Yes.
+func (e *env) startAndConfirm(t *testing.T, from int64, username, lang, code string) {
+ t.Helper()
+ e.webhook(t, from, username, lang, "/start "+code)
+ if _, data := e.tg.last(t); data != "y:"+code {
+ t.Fatalf("the bot did not ask; its button is %q", data)
+ }
+ e.answer(t, from, "y:"+code)
+}
+
+func codeOf(t *testing.T, link string) string {
+ t.Helper()
+ u, err := url.Parse(link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c := u.Query().Get("start"); c != "" {
+ return c
+ }
+ return u.Query().Get("code")
+}
+
+func TestAVisitorWhoNeverSignsInIsSentNoCookie(t *testing.T) {
+ e := newEnv(t)
+ b := e.browser("198.51.100.1")
+ rec := b.do(t, "GET", "/api/v1/club/me", nil, "")
+ if rec.Code != 200 || rec.Header().Get("Set-Cookie") != "" {
+ t.Fatalf("%d, Set-Cookie %q", rec.Code, rec.Header().Get("Set-Cookie"))
+ }
+ var out struct {
+ Member any `json:"member"`
+ SignIn map[string]any `json:"sign_in"`
+ }
+ _ = json.Unmarshal(rec.Body.Bytes(), &out)
+ if out.Member != nil || out.SignIn["telegram"] != "OpenIPCTestBot" || out.SignIn["github"] != true || out.SignIn["email"] != true {
+ t.Errorf("%s", rec.Body)
+ }
+}
+
+func TestTelegramSignsInTheBrowserThatAskedAndOnlyOnce(t *testing.T) {
+ e := newEnv(t)
+ b := e.browser("198.51.100.1")
+ code, out := b.json(t, "POST", "/api/v1/club/telegram", nil)
+ if code != 200 || !strings.HasPrefix(out["link"].(string), "https://t.me/OpenIPCTestBot?start=") {
+ t.Fatalf("%d %v", code, out)
+ }
+ start := codeOf(t, out["link"].(string))
+ if _, out := b.json(t, "GET", "/api/v1/club/login", nil); out["state"] != "pending" {
+ t.Fatalf("before Start: %v", out)
+ }
+
+ // Somebody else holding the code -- a forwarded QR code -- cannot use it.
+ other := e.browser("203.0.113.9")
+ if _, out := other.json(t, "GET", "/api/v1/club/login", nil); out["state"] != "none" {
+ t.Errorf("another browser polls: %v", out)
+ }
+ if rec := other.do(t, "GET", "/api/v1/club/finish?code="+start, nil, ""); !strings.Contains(rec.Header().Get("Location"), "signin=elsewhere") && !strings.Contains(rec.Header().Get("Location"), "signin=expired") {
+ t.Errorf("finish with a browser-bound code: %d %s", rec.Code, rec.Header().Get("Location"))
+ }
+
+ // A forged webhook is refused.
+ req := httptest.NewRequest("POST", site+"/api/v1/club/telegram/webhook", strings.NewReader(`{}`))
+ rec := httptest.NewRecorder()
+ e.mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Errorf("a webhook without the secret: %d", rec.Code)
+ }
+
+ // Start alone signs nobody in: the bot asks, naming where the sign-in
+ // was asked for, so a start link someone else sent is recognisable.
+ e.webhook(t, 777, "ivan_k", "ru", "/start "+start)
+ if text, data := e.tg.last(t); !strings.Contains(text, "@ivan_k") || !strings.Contains(text, "198.51.100.1") || data != "y:"+start {
+ t.Errorf("the bot asked %q with %q", text, data)
+ }
+ if _, out := b.json(t, "GET", "/api/v1/club/login", nil); out["state"] != "pending" {
+ t.Fatalf("after Start, before Yes: %v", out)
+ }
+ // Somebody else's Yes is nobody's.
+ e.answer(t, 888, "y:"+start)
+ if _, out := b.json(t, "GET", "/api/v1/club/login", nil); out["state"] != "pending" {
+ t.Fatalf("after a stranger's Yes: %v", out)
+ }
+ e.answer(t, 777, "y:"+start)
+ if text, _ := e.tg.last(t); !strings.Contains(text, "@ivan_k") {
+ t.Errorf("after Yes the bot said %q", text)
+ }
+ _, out = b.json(t, "GET", "/api/v1/club/login", nil)
+ if out["state"] != "signed_in" {
+ t.Fatalf("after Start: %v", out)
+ }
+ m := b.me(t)
+ if m == nil || m["name"] != "Ivan" || m["maintainer"] != false {
+ t.Fatalf("me: %v", m)
+ }
+ if _, out := b.json(t, "GET", "/api/v1/club/login", nil); out["state"] == "signed_in" {
+ t.Error("the login signed in twice")
+ }
+ if other.me(t) != nil {
+ t.Error("the other browser is signed in")
+ }
+
+ // Signing out ends this browser's session.
+ if code, _ := b.json(t, "POST", "/api/v1/club/logout", nil); code != 200 || b.me(t) != nil {
+ t.Error("still signed in after signing out")
+ }
+}
+
+func TestNoInTelegramSignsNobodyIn(t *testing.T) {
+ e := newEnv(t)
+ b := e.browser("198.51.100.1")
+ _, out := b.json(t, "POST", "/api/v1/club/telegram", nil)
+ start := codeOf(t, out["link"].(string))
+ e.webhook(t, 777, "ivan_k", "en", "/start "+start)
+ e.answer(t, 777, "n:"+start)
+ if text, _ := e.tg.last(t); !strings.HasPrefix(text, "Nobody was signed in") {
+ t.Errorf("after No the bot said %q", text)
+ }
+ e.answer(t, 777, "y:"+start)
+ if _, out := b.json(t, "GET", "/api/v1/club/login", nil); out["state"] == "signed_in" || b.me(t) != nil {
+ t.Errorf("signed in after No: %v", out)
+ }
+}
+
+func TestAnExpiredStartStillSignsInFromTheChat(t *testing.T) {
+ e := newEnv(t)
+ b := e.browser("198.51.100.1")
+ _, out := b.json(t, "POST", "/api/v1/club/telegram", nil)
+ start := codeOf(t, out["link"].(string))
+ e.clock = e.clock.Add(11 * time.Minute)
+ if _, out := b.json(t, "GET", "/api/v1/club/login", nil); out["state"] != "expired" {
+ t.Errorf("after ten minutes: %v", out)
+ }
+ e.webhook(t, 777, "ivan_k", "en", "/start "+start)
+ text, button := e.tg.last(t)
+ if !strings.Contains(text, "expired") || !strings.HasPrefix(button, site+"/api/v1/club/finish?code=") {
+ t.Fatalf("%q %q", text, button)
+ }
+ // The chat's link asks before it signs in: the page shows whose account.
+ phone := e.browser("192.0.2.4")
+ code := codeOf(t, button)
+ rec := phone.do(t, "GET", strings.TrimPrefix(button, site), nil, "")
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/club/?confirm="+code || phone.me(t) != nil {
+ t.Fatalf("the chat's link: %d %s", rec.Code, rec.Header().Get("Location"))
+ }
+ if _, who := phone.json(t, "GET", "/api/v1/club/finish/who?code="+code, nil); who["who"] != "@ivan_k" {
+ t.Errorf("who: %v", who)
+ }
+ if st, out := phone.json(t, "POST", "/api/v1/club/finish", map[string]string{"code": code}); st != 200 || phone.me(t) == nil {
+ t.Fatalf("confirm: %d %v", st, out)
+ }
+ again := e.browser("192.0.2.5")
+ if st, _ := again.json(t, "POST", "/api/v1/club/finish", map[string]string{"code": code}); st != http.StatusGone || again.me(t) != nil {
+ t.Errorf("the chat's link signed in a second browser: %d", st)
+ }
+}
+
+func TestEmailSendsALinkThatSignsInOnce(t *testing.T) {
+ e := newEnv(t)
+ b := e.browser("198.51.100.1")
+ if code, _ := b.json(t, "POST", "/api/v1/club/email", map[string]string{"email": "not an address"}); code != 400 {
+ t.Errorf("a bad address: %d", code)
+ }
+ code, _ := b.json(t, "POST", "/api/v1/club/email", map[string]string{"email": "Owner@Example.org", "locale": "ru"})
+ if code != http.StatusAccepted || e.mail.to != "owner@example.org" || e.mail.subject != "Вход на openipc.org" {
+ t.Fatalf("%d, mail to %q %q", code, e.mail.to, e.mail.subject)
+ }
+ i := strings.Index(e.mail.body, site)
+ link := strings.Fields(e.mail.body[i:])[0]
+ // Opened on a phone, not where it was asked for: the page asks first,
+ // so a link someone sent for their own address is not taken blindly.
+ phone := e.browser("192.0.2.4")
+ rec := phone.do(t, "GET", strings.TrimPrefix(link, site), nil, "")
+ if !strings.HasPrefix(rec.Header().Get("Location"), "/club/?confirm=") || phone.me(t) != nil {
+ t.Fatalf("a link from another browser: %s", rec.Header().Get("Location"))
+ }
+ if _, who := phone.json(t, "GET", "/api/v1/club/finish/who?code="+codeOf(t, link), nil); who["who"] != "owner@example.org" {
+ t.Errorf("who: %v", who)
+ }
+ phone.json(t, "POST", "/api/v1/club/finish", map[string]string{"code": codeOf(t, link)})
+ m := phone.me(t)
+ // The public name is never the address.
+ if m == nil || m["name"] != "OpenIPC member" {
+ t.Fatalf("me: %v", m)
+ }
+ ids := m["identities"].([]any)
+ if len(ids) != 1 || ids[0].(map[string]any)["handle"] != "owner@example.org" {
+ t.Errorf("identities: %v", ids)
+ }
+ rec = e.browser("192.0.2.5").do(t, "GET", strings.TrimPrefix(link, site), nil, "")
+ if !strings.Contains(rec.Header().Get("Location"), "expired") {
+ t.Errorf("the link worked twice: %s", rec.Header().Get("Location"))
+ }
+
+ // Opened in the browser that asked for it, a link signs in at once.
+ b.json(t, "POST", "/api/v1/club/email", map[string]string{"email": "owner@example.org"})
+ j := strings.Index(e.mail.body, site)
+ rec = b.do(t, "GET", strings.TrimPrefix(strings.Fields(e.mail.body[j:])[0], site), nil, "")
+ if rec.Header().Get("Location") != "/club/?signin=ok" || b.me(t) == nil {
+ t.Errorf("the asking browser: %s", rec.Header().Get("Location"))
+ }
+
+ // A member names themselves; an address is refused as a name.
+ if st, _ := phone.json(t, "POST", "/api/v1/club/name", map[string]string{"name": "me@example.org"}); st != 400 {
+ t.Errorf("an address as a name: %d", st)
+ }
+ if st, out := phone.json(t, "POST", "/api/v1/club/name", map[string]string{"name": " Ivan K. "}); st != 200 || phone.me(t)["name"] != "Ivan K." {
+ t.Errorf("rename: %d %v", st, out)
+ }
+}
+
+func TestGitHubMakesAMaintainerOfTheOrganisationsMembers(t *testing.T) {
+ e := newEnv(t)
+ b := e.browser("198.51.100.1")
+ rec := b.do(t, "GET", "/api/v1/club/github", nil, "")
+ loc, _ := url.Parse(rec.Header().Get("Location"))
+ state := loc.Query().Get("state")
+ if loc.Path != "/login/oauth/authorize" || state == "" || loc.Query().Get("redirect_uri") != site+"/api/v1/club/github/callback" {
+ t.Fatalf("%s", loc)
+ }
+ // GitHub sends back another browser with the same state: refused.
+ stranger := e.browser("203.0.113.9")
+ stranger.do(t, "GET", "/api/v1/club/github/callback?code=maint&state="+state, nil, "")
+ if stranger.me(t) != nil {
+ t.Fatal("another browser finished this browser's GitHub sign-in")
+ }
+ rec = b.do(t, "GET", "/api/v1/club/github/callback?code=maint&state="+state, nil, "")
+ if rec.Header().Get("Location") != "/club/?signin=ok" {
+ t.Fatalf("%d %s", rec.Code, rec.Header().Get("Location"))
+ }
+ if m := b.me(t); m == nil || m["maintainer"] != true || m["name"] != "The Octocat" {
+ t.Fatalf("me: %v", m)
+ }
+ // A GitHub sign-in proves membership for a week; after that, review
+ // waits for the next one, which asks GitHub again.
+ e.clock = e.clock.Add(8 * 24 * time.Hour)
+ if m := b.me(t); m["maintainer"] != false {
+ t.Errorf("a maintainer a week on: %v", m)
+ }
+ if code, _ := b.json(t, "GET", "/api/v1/club/review", nil); code != http.StatusForbidden {
+ t.Errorf("the queue a week on: %d", code)
+ }
+}
+
+func TestASecondWayInJoinsTheAccountItWasAskedFrom(t *testing.T) {
+ e := newEnv(t)
+ b := e.browser("198.51.100.1")
+ _, out := b.json(t, "POST", "/api/v1/club/telegram", nil)
+ e.startAndConfirm(t, 777, "ivan_k", "en", codeOf(t, out["link"].(string)))
+ b.json(t, "GET", "/api/v1/club/login", nil)
+ first := b.me(t)["id"]
+
+ rec := b.do(t, "GET", "/api/v1/club/github", nil, "")
+ loc, _ := url.Parse(rec.Header().Get("Location"))
+ b.do(t, "GET", "/api/v1/club/github/callback?code=other&state="+loc.Query().Get("state"), nil, "")
+ m := b.me(t)
+ if m["id"] != first || len(m["identities"].([]any)) != 2 {
+ t.Errorf("after linking GitHub: %v", m)
+ }
+}
+
+func TestAnotherSitesPageCannotPost(t *testing.T) {
+ e := newEnv(t)
+ req := httptest.NewRequest("POST", site+"/api/v1/club/telegram", nil)
+ req.Header.Set("Origin", "https://evil.example")
+ rec := httptest.NewRecorder()
+ e.mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden || rec.Header().Get("Set-Cookie") != "" {
+ t.Errorf("%d %q", rec.Code, rec.Header().Get("Set-Cookie"))
+ }
+}
+
+// signedIn makes a member through Telegram and returns their browser.
+func (e *env) signedIn(t *testing.T, tgID int64, name, ip string) *browser {
+ t.Helper()
+ b := e.browser(ip)
+ _, out := b.json(t, "POST", "/api/v1/club/telegram", nil)
+ e.startAndConfirm(t, tgID, name, "en", codeOf(t, out["link"].(string)))
+ if _, out := b.json(t, "GET", "/api/v1/club/login", nil); out["state"] != "signed_in" {
+ t.Fatalf("sign-in: %v", out)
+ }
+ return b
+}
+
+func (b *browser) send(t *testing.T, fields map[string]string, files map[string][]byte) (int, map[string]any) {
+ t.Helper()
+ var body bytes.Buffer
+ mw := multipart.NewWriter(&body)
+ for k, v := range fields {
+ _ = mw.WriteField(k, v)
+ }
+ for k, v := range files {
+ w, _ := mw.CreateFormFile(strings.SplitN(k, "#", 2)[0], k+".bin")
+ _, _ = w.Write(v)
+ }
+ _ = mw.Close()
+ rec := b.do(t, "POST", "/api/v1/club/reports", &body, mw.FormDataContentType())
+ var out map[string]any
+ _ = json.Unmarshal(rec.Body.Bytes(), &out)
+ return rec.Code, out
+}
+
+func TestWhatAMemberSendsIsTheirsAndEarnsStarsWhenAccepted(t *testing.T) {
+ e := newEnv(t)
+ ctx := context.Background()
+ ivan := e.signedIn(t, 777, "ivan_k", "198.51.100.1")
+ dump := bytes.Repeat([]byte{0x5a}, 1<<20) // a programmer's read of a 1 MB chip
+ code, out := ivan.send(t, map[string]string{"channel": "web", "model": "anjoy-ms-j10", "note": "from a camera I bought"},
+ map[string][]byte{"backup": dump, "boot_log": []byte("U-Boot 2015.01\nSF: Detected nor0 with total size 8 MiB\n")})
+ if code != http.StatusCreated {
+ t.Fatalf("send: %d %v", code, out)
+ }
+ id := out["id"].(string)
+
+ // Without ipctool's output and without a board, nothing is accepted.
+ if code, _ := ivan.send(t, map[string]string{"channel": "web"}, map[string][]byte{"boot_log": []byte("text\n")}); code != 400 {
+ t.Errorf("no board, no yaml: %d", code)
+ }
+
+ _, mine := ivan.json(t, "GET", "/api/v1/club/reports", nil)
+ list := mine["reports"].([]any)
+ r := list[0].(map[string]any)
+ if len(list) != 1 || r["id"] != id || r["status"] != "pending" || r["pending"].(float64) != 11 ||
+ r["board"].(map[string]any)["id"] != "anjoy-ms-j10" {
+ t.Fatalf("mine: %v", r)
+ }
+ files := r["files"].([]any)
+ backup := files[0].(map[string]any)
+ if backup["kind"] != "backup" || backup["private"] != true || backup["name"] != "flash.bin" {
+ t.Fatalf("files: %v", files)
+ }
+
+ // The private dump: its sender gets it, another member gets a 404.
+ rec := ivan.do(t, "GET", backup["url"].(string), nil, "")
+ if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("X-Accel-Redirect"), "/report-files/") ||
+ rec.Header().Get("Cache-Control") != "private, no-store" {
+ t.Fatalf("owner download: %d %v", rec.Code, rec.Header())
+ }
+ petr := e.signedIn(t, 888, "petr", "198.51.100.2")
+ if rec := petr.do(t, "GET", backup["url"].(string), nil, ""); rec.Code != 404 {
+ t.Errorf("another member's download: %d", rec.Code)
+ }
+ if code, _ := petr.json(t, "GET", "/api/v1/club/review", nil); code != 403 {
+ t.Errorf("a member's review queue: %d", code)
+ }
+
+ // A maintainer sees it in the queue, fetches the dump, and publishes.
+ maint := e.browser("198.51.100.3")
+ loc, _ := url.Parse(maint.do(t, "GET", "/api/v1/club/github", nil, "").Header().Get("Location"))
+ maint.do(t, "GET", "/api/v1/club/github/callback?code=maint&state="+loc.Query().Get("state"), nil, "")
+ _, q := maint.json(t, "GET", "/api/v1/club/review", nil)
+ queued := q["reports"].([]any)
+ if len(queued) != 1 || queued[0].(map[string]any)["member"] != "Ivan" || queued[0].(map[string]any)["potential"].(float64) != 11 {
+ t.Fatalf("queue: %v", queued)
+ }
+ if rec := maint.do(t, "GET", backup["url"].(string), nil, ""); rec.Code != 200 {
+ t.Errorf("maintainer download: %d", rec.Code)
+ }
+ code, d := maint.json(t, "POST", "/api/v1/club/review/"+id, map[string]any{"decision": "publish"})
+ if code != 200 || d["points"].(float64) != 11 || d["total"].(float64) != 11 {
+ t.Fatalf("publish: %d %v", code, d)
+ }
+ if text, _ := e.tg.last(t); !strings.Contains(text, "+11 ★") {
+ t.Errorf("Ivan was told %q", text)
+ }
+ if m := ivan.me(t); m["stars"].(float64) != 11 || m["pending"].(float64) != 0 {
+ t.Errorf("Ivan's stars: %v", m)
+ }
+ // Published on the board its sender named, without the review naming it.
+ if _, mine := ivan.json(t, "GET", "/api/v1/club/reports", nil); mine["reports"].([]any)[0].(map[string]any)["status"] != "published" {
+ t.Errorf("after publishing: %v", mine)
+ }
+ if pub, _ := e.api.Reports.Store().Public(ctx, id); len(pub.Models) != 1 || pub.Models[0].ID != "anjoy-ms-j10" {
+ t.Errorf("linked to %v", pub.Models)
+ }
+
+ // The same dump again, from Petr: accepted, but it earns nothing for the
+ // dump -- the catalogue has it.
+ _, out = petr.send(t, map[string]string{"channel": "web", "model": "anjoy-ms-j10"}, map[string][]byte{"backup": dump})
+ _, d = maint.json(t, "POST", "/api/v1/club/review/"+out["id"].(string), map[string]any{"decision": "publish"})
+ if d["points"].(float64) != 0 {
+ t.Errorf("a known dump earned %v", d["points"])
+ }
+ // Ivan's dump was the first: it stays the one that counted.
+ if _, mine := ivan.json(t, "GET", "/api/v1/club/reports", nil); mine["reports"].([]any)[0].(map[string]any)["duplicate"] == true {
+ t.Error("the first copy of a dump is called a duplicate once a second is published")
+ }
+ if _, theirs := petr.json(t, "GET", "/api/v1/club/reports", nil); theirs["reports"].([]any)[0].(map[string]any)["duplicate"] != true {
+ t.Error("the second copy is not called a duplicate")
+ }
+
+ // Rejected afterwards: Ivan's stars are taken back, and the ledger says so.
+ _, d = maint.json(t, "POST", "/api/v1/club/review/"+id, map[string]any{"decision": "reject", "note": "wrong board"})
+ if d["points"].(float64) != -11 || ivan.me(t)["stars"].(float64) != 0 {
+ t.Errorf("reject: %v, stars %v", d, ivan.me(t)["stars"])
+ }
+ // Published again, Ivan's report is whole again: he sent the dump first,
+ // and Petr's later copy does not take it from him.
+ _, d = maint.json(t, "POST", "/api/v1/club/review/"+id, map[string]any{"decision": "publish"})
+ if d["points"].(float64) != 11 || ivan.me(t)["stars"].(float64) != 11 {
+ t.Errorf("published again: %v, stars %v", d, ivan.me(t)["stars"])
+ }
+ if _, err := e.pool.Exec(ctx, `UPDATE report_stars SET points = 100`); err == nil {
+ t.Error("the ledger accepted an edit")
+ }
+}
+
+func TestBotCommandsMuteAndUnlink(t *testing.T) {
+ e := newEnv(t)
+ b := e.signedIn(t, 777, "ivan_k", "198.51.100.1")
+ e.webhook(t, 777, "ivan_k", "en", "/quiet")
+ if text, _ := e.tg.last(t); !strings.HasPrefix(text, "Muted") || b.me(t)["quiet"] != true {
+ t.Errorf("quiet: %q %v", text, b.me(t))
+ }
+ e.webhook(t, 777, "ivan_k", "en", "/stop")
+ ids := b.me(t)["identities"].([]any)
+ if ids[0].(map[string]any)["chat"] == true {
+ t.Errorf("after /stop the bot can still write: %v", ids)
+ }
+ e.webhook(t, 999, "nobody", "zh", "/quiet")
+ if text, _ := e.tg.last(t); !strings.Contains(text, "还没有") {
+ t.Errorf("a stranger's /quiet: %q", text)
+ }
+}
+
+func TestThePurgeDropsWhatExpiredAndKeepsWhatLives(t *testing.T) {
+ e := newEnv(t)
+ ctx := context.Background()
+ b := e.signedIn(t, 777, "ivan_k", "198.51.100.1")
+ _, _ = e.pool.Exec(ctx, `INSERT INTO club_logins (code_sha256, provider, expires_at) VALUES (repeat('a', 64), 'telegram', now() - interval '2 days')`)
+ _, _ = e.pool.Exec(ctx, `INSERT INTO club_sessions (token_sha256, member_id, expires_at)
+ SELECT repeat('b', 64), member_id, now() - interval '1 minute' FROM club_sessions LIMIT 1`)
+ s, l, err := Purge(ctx, e.pool)
+ if err != nil || s != 1 || l != 1 {
+ t.Fatalf("purged %d sessions, %d logins: %v", s, l, err)
+ }
+ if b.me(t) == nil {
+ t.Error("the purge signed out a live session")
+ }
+}
+
+// fakeSMTP is a relay that offers STARTTLS it cannot back with a certificate
+// the client could verify -- the host's exim seen from the docker bridge.
+func fakeSMTP(t *testing.T) (addr string, got chan string) {
+ t.Helper()
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { l.Close() })
+ got = make(chan string, 1)
+ go func() {
+ c, err := l.Accept()
+ if err != nil {
+ return
+ }
+ defer c.Close()
+ r := bufio.NewReader(c)
+ say := func(s string) { _, _ = io.WriteString(c, s+"\r\n") }
+ say("220 relay")
+ var data strings.Builder
+ inData := false
+ for {
+ line, err := r.ReadString('\n')
+ if err != nil {
+ return
+ }
+ if inData {
+ if line == ".\r\n" {
+ inData = false
+ say("250 queued")
+ got <- data.String()
+ continue
+ }
+ data.WriteString(line)
+ continue
+ }
+ switch cmd := strings.ToUpper(strings.TrimSpace(line)); {
+ case strings.HasPrefix(cmd, "EHLO"):
+ say("250-relay")
+ say("250 STARTTLS")
+ case strings.HasPrefix(cmd, "STARTTLS"):
+ say("454 not here")
+ case cmd == "DATA":
+ inData = true
+ say("354 go")
+ case cmd == "QUIT":
+ say("221 bye")
+ return
+ default:
+ say("250 ok")
+ }
+ }
+ }()
+ return l.Addr().String(), got
+}
+
+func TestTheHostsOwnRelayTakesTheMailWithoutTLS(t *testing.T) {
+ addr, got := fakeSMTP(t)
+ m := &SMTP{Addr: addr, From: "OpenIPC "}
+ if err := m.Send("owner@example.org", "Вход на openipc.org", "link\n"); err != nil {
+ t.Fatal(err)
+ }
+ msg := <-got
+ if !strings.Contains(msg, "Subject: =?utf-8?b?") || !strings.Contains(msg, "From: \"OpenIPC\" ") {
+ t.Errorf("%s", msg)
+ }
+ // A password is never sent in the clear, even to the host's own relay.
+ m.User, m.Password = "u", "p"
+ addr2, _ := fakeSMTP(t)
+ m.Addr = addr2
+ if err := m.Send("owner@example.org", "x", "y"); err == nil || !strings.Contains(err.Error(), "without TLS") {
+ t.Errorf("err = %v", err)
+ }
+}
+
+func TestRepublishingARejectedReportEarnsItsStarsBack(t *testing.T) {
+ e := newEnv(t)
+ ivan := e.signedIn(t, 777, "ivan_k", "198.51.100.1")
+ _, out := ivan.send(t, map[string]string{"channel": "web", "model": "anjoy-ms-j10"}, map[string][]byte{"boot_log": []byte("U-Boot\n")})
+ id := out["id"].(string)
+ maint := e.browser("198.51.100.3")
+ loc, _ := url.Parse(maint.do(t, "GET", "/api/v1/club/github", nil, "").Header().Get("Location"))
+ maint.do(t, "GET", "/api/v1/club/github/callback?code=maint&state="+loc.Query().Get("state"), nil, "")
+ for i, step := range []struct {
+ decision string
+ points, total float64
+ }{{"publish", 1, 1}, {"reject", -1, 0}, {"publish", 1, 1}, {"publish", 0, 1}} {
+ _, d := maint.json(t, "POST", "/api/v1/club/review/"+id, map[string]any{"decision": step.decision, "note": "step"})
+ if d["points"] != step.points || d["total"] != step.total {
+ t.Errorf("step %d %s: %v", i, step.decision, d)
+ }
+ }
+ // The sender reads the reviewer's note with the decision.
+ _, mine := ivan.json(t, "GET", "/api/v1/club/reports", nil)
+ if r := mine["reports"].([]any)[0].(map[string]any); r["review_note"] != "step" {
+ t.Errorf("review note: %v", r)
+ }
+}
diff --git a/service/internal/club/email.go b/service/internal/club/email.go
new file mode 100644
index 00000000..4e419fae
--- /dev/null
+++ b/service/internal/club/email.go
@@ -0,0 +1,200 @@
+package club
+
+import (
+ "crypto/tls"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "net/mail"
+ "net/smtp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/OpenIPC/website/service/internal/httpx"
+)
+
+// Mailer sends the sign-in link. SMTP is the real one.
+type Mailer interface {
+ Send(to, subject, body string) error
+}
+
+// SMTP sends through a relay that may send for openipc.org: on the host,
+// its own exim (CLUB_SMTP_ADDR=172.18.0.1:25), which signs openipc.org's
+// DKIM and whose address openipc.org's SPF names.
+type SMTP struct {
+ Addr string // host:port
+ User string
+ Password string
+ From string // "OpenIPC "
+}
+
+func (s *SMTP) Send(to, subject, body string) error {
+ from, err := mail.ParseAddress(s.From)
+ if err != nil {
+ return err
+ }
+ host, _, _ := net.SplitHostPort(s.Addr)
+ msg := strings.Join([]string{
+ "From: " + from.String(),
+ "To: " + to,
+ "Subject: " + mimeWord(subject),
+ "Date: " + time.Now().UTC().Format(time.RFC1123Z),
+ "Message-ID: <" + randomString(12) + "@openipc.org>",
+ "MIME-Version: 1.0",
+ "Content-Type: text/plain; charset=utf-8",
+ "Content-Transfer-Encoding: 8bit",
+ "Auto-Submitted: auto-generated",
+ "",
+ body,
+ }, "\r\n")
+ return s.deliver(host, from.Address, to, []byte(msg))
+}
+
+// deliver hands the message to the relay. The host's own exim, over the
+// docker bridge, offers STARTTLS with a certificate for its public name,
+// which no client dialling 172.18.0.1 can verify; a hop that never leaves
+// the host is sent in the clear. Any other relay must take TLS, and a
+// password only ever travels inside it.
+func (s *SMTP) deliver(host, from, to string, msg []byte) error {
+ c, err := smtp.Dial(s.Addr)
+ if err != nil {
+ return err
+ }
+ defer c.Close()
+ local := false
+ if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate()) {
+ local = true
+ }
+ if !local {
+ if ok, _ := c.Extension("STARTTLS"); !ok {
+ return fmt.Errorf("%s offers no STARTTLS", s.Addr)
+ }
+ if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
+ return err
+ }
+ }
+ if s.User != "" {
+ if local {
+ return fmt.Errorf("refusing to send a password to %s without TLS", s.Addr)
+ }
+ if err := c.Auth(smtp.PlainAuth("", s.User, s.Password, host)); err != nil {
+ return err
+ }
+ }
+ if err := c.Mail(from); err != nil {
+ return err
+ }
+ if err := c.Rcpt(to); err != nil {
+ return err
+ }
+ w, err := c.Data()
+ if err != nil {
+ return err
+ }
+ if _, err := w.Write(msg); err != nil {
+ return err
+ }
+ if err := w.Close(); err != nil {
+ return err
+ }
+ return c.Quit()
+}
+
+// mimeWord encodes a non-ASCII subject (Russian, Chinese) for the header.
+func mimeWord(s string) string {
+ for _, r := range s {
+ if r > 127 {
+ return "=?utf-8?b?" + b64(s) + "?="
+ }
+ }
+ return s
+}
+
+// emailStart is POST /api/v1/club/email {"email": ..., "locale": ...}: a
+// link that signs in, sent to the address. The answer is the same whether
+// or not the address has an account.
+func (a *API) emailStart(w http.ResponseWriter, r *http.Request) {
+ if a.Mail == nil {
+ a.refuse(w, http.StatusServiceUnavailable, "email sign-in is not available on this site")
+ return
+ }
+ var in struct {
+ Email string `json:"email"`
+ Locale string `json:"locale"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in); err != nil {
+ a.refuse(w, http.StatusBadRequest, `send {"email": "you@example.com"}`)
+ return
+ }
+ addr, err := mail.ParseAddress(strings.TrimSpace(in.Email))
+ if err != nil || addr.Name != "" || len(addr.Address) > 254 || !strings.Contains(addr.Address, ".") {
+ a.refuse(w, http.StatusBadRequest, "that does not look like an email address")
+ return
+ }
+ email := strings.ToLower(addr.Address)
+ now := a.now()
+ if !a.limits.allow("mail:"+clientKey(r), 10, time.Hour, now) || !a.limits.allow("to:"+email, 3, time.Hour, now) {
+ a.refuse(w, http.StatusTooManyRequests, "too many links asked for; try again in an hour")
+ return
+ }
+ code, _, err := a.newLogin(w, r, "email", email)
+ if err != nil {
+ a.fail(w, "the sign-in", err)
+ return
+ }
+ l := in.Locale
+ if l != "ru" && l != "zh" {
+ l = "en"
+ }
+ link := a.Cfg.SiteURL + "/api/v1/club/finish?code=" + code
+ if err := a.Mail.Send(email, mails[l][0], fmt.Sprintf(mails[l][1], link)); err != nil {
+ a.Log.Error("club: sign-in mail failed", "err", err)
+ a.refuse(w, http.StatusBadGateway, "the email could not be sent; try again, or sign in another way")
+ return
+ }
+ writeJSON(w, http.StatusAccepted, map[string]any{"sent": true})
+}
+
+var mails = map[string][2]string{
+ "en": {"Sign in to openipc.org", "Open this link to sign in to openipc.org:\n\n%s\n\nIt works once, for ten minutes. If you did not ask for it, ignore this message: nothing happens until the link is opened.\n\n-- OpenIPC\n"},
+ "ru": {"Вход на openipc.org", "Откройте ссылку, чтобы войти на openipc.org:\n\n%s\n\nОна работает один раз в течение десяти минут. Если вы её не запрашивали, просто удалите письмо: без перехода по ссылке ничего не произойдёт.\n\n-- OpenIPC\n"},
+ "zh": {"登录 openipc.org", "打开以下链接登录 openipc.org:\n\n%s\n\n链接仅可使用一次,十分钟内有效。如果不是你本人请求的,请忽略此邮件:不打开链接就不会发生任何事。\n\n-- OpenIPC\n"},
+}
+
+// limiter counts attempts per key in a fixed window: enough to keep the
+// sign-in endpoints from being a mail cannon or a login-row flood. It is
+// per process, which is per database (one web role).
+type limiter struct {
+ mu sync.Mutex
+ hits map[string][]time.Time
+}
+
+func (l *limiter) allow(key string, n int, window time.Duration, now time.Time) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.hits == nil {
+ l.hits = map[string][]time.Time{}
+ }
+ if len(l.hits) > 50000 {
+ l.hits = map[string][]time.Time{}
+ }
+ kept := l.hits[key][:0]
+ for _, t := range l.hits[key] {
+ if now.Sub(t) < window {
+ kept = append(kept, t)
+ }
+ }
+ if len(kept) >= n {
+ l.hits[key] = kept
+ return false
+ }
+ l.hits[key] = append(kept, now)
+ return true
+}
+
+func clientKey(r *http.Request) string { return httpx.ClientIP(r) }
+
+func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
diff --git a/service/internal/club/github.go b/service/internal/club/github.go
new file mode 100644
index 00000000..77322f9d
--- /dev/null
+++ b/service/internal/club/github.go
@@ -0,0 +1,166 @@
+package club
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// GitHub signs people in with their GitHub account (an OAuth app), and
+// tells whether they are in the maintainers' organisation.
+type GitHub struct {
+ ClientID, Secret string
+ // Web is https://github.com, API https://api.github.com (a test's server).
+ Web, API string
+ HTTP *http.Client
+}
+
+// githubStart is GET /api/v1/club/github: off to GitHub, with a state that
+// only this browser can bring back.
+func (a *API) githubStart(w http.ResponseWriter, r *http.Request) {
+ if a.GitHub == nil {
+ http.Redirect(w, r, "/club/?signin=unavailable", http.StatusSeeOther)
+ return
+ }
+ if !a.limits.allow("gh:"+clientKey(r), 20, time.Hour, a.now()) {
+ a.refuse(w, http.StatusTooManyRequests, "too many sign-ins from this address; try again in an hour")
+ return
+ }
+ state, _, err := a.newLogin(w, r, "github", "")
+ if err != nil {
+ a.fail(w, "the sign-in", err)
+ return
+ }
+ q := url.Values{
+ "client_id": {a.GitHub.ClientID},
+ "redirect_uri": {a.Cfg.SiteURL + "/api/v1/club/github/callback"},
+ "scope": {"read:org"},
+ "state": {state},
+ "allow_signup": {"true"},
+ }
+ http.Redirect(w, r, a.GitHub.Web+"/login/oauth/authorize?"+q.Encode(), http.StatusSeeOther)
+}
+
+// githubCallback is where GitHub sends the browser back.
+func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
+ if a.GitHub == nil {
+ http.NotFound(w, r)
+ return
+ }
+ ctx := r.Context()
+ state, code := r.URL.Query().Get("state"), r.URL.Query().Get("code")
+ c, err := r.Cookie(loginCookie)
+ if err != nil || state == "" || code == "" {
+ http.Redirect(w, r, "/club/?signin=expired", http.StatusSeeOther)
+ return
+ }
+ var forMember *string
+ err = a.DB.QueryRow(ctx, `
+ SELECT for_member FROM club_logins WHERE code_sha256 = $1 AND provider = 'github'
+ AND browser_sha256 = $2 AND used_at IS NULL AND expires_at > $3`, sha(state), sha(c.Value), a.now()).Scan(&forMember)
+ if errors.Is(err, pgx.ErrNoRows) {
+ http.Redirect(w, r, "/club/?signin=expired", http.StatusSeeOther)
+ return
+ }
+ if err != nil {
+ a.fail(w, "the sign-in", err)
+ return
+ }
+ who, err := a.GitHub.user(ctx, code, a.Cfg.SiteURL+"/api/v1/club/github/callback", a.Cfg.MaintainerOrg)
+ if err != nil {
+ a.Log.Warn("club: github sign-in failed", "err", err)
+ http.Redirect(w, r, "/club/?signin=failed", http.StatusSeeOther)
+ return
+ }
+ var member string
+ err = pgx.BeginFunc(ctx, a.DB, func(tx pgx.Tx) error {
+ var err error
+ member, err = identify(ctx, tx, who, deref(forMember))
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec(ctx, `UPDATE club_logins SET member_id = $2 WHERE code_sha256 = $1`, sha(state), member)
+ return err
+ })
+ if err != nil {
+ a.fail(w, "the sign-in", err)
+ return
+ }
+ if !a.signInOnce(w, r, sha(state), member) {
+ return
+ }
+ a.Log.Info("club: github sign-in", "member", member, "maintainer", who.Maintainer)
+ http.Redirect(w, r, "/club/?signin=ok", http.StatusSeeOther)
+}
+
+// user trades GitHub's code for a token, reads who it is, and whether they
+// are an active member of org. The token is used for these calls only and
+// never stored.
+func (g *GitHub) user(ctx context.Context, code, redirect, org string) (signIn, error) {
+ var tok struct {
+ AccessToken string `json:"access_token"`
+ Error string `json:"error_description"`
+ }
+ form := url.Values{"client_id": {g.ClientID}, "client_secret": {g.Secret}, "code": {code}, "redirect_uri": {redirect}}
+ req, _ := http.NewRequestWithContext(ctx, http.MethodPost, g.Web+"/login/oauth/access_token", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+ if err := g.do(req, &tok); err != nil {
+ return signIn{}, err
+ }
+ if tok.AccessToken == "" {
+ return signIn{}, fmt.Errorf("no token: %s", tok.Error)
+ }
+ var u struct {
+ ID int64 `json:"id"`
+ Login string `json:"login"`
+ Name string `json:"name"`
+ }
+ if err := g.get(ctx, tok.AccessToken, "/user", &u); err != nil {
+ return signIn{}, err
+ }
+ in := signIn{Provider: "github", Subject: strconv.FormatInt(u.ID, 10), Handle: u.Login, Name: orElse(u.Name, u.Login)}
+ if org != "" {
+ var mem struct {
+ State string `json:"state"`
+ }
+ err := g.get(ctx, tok.AccessToken, "/user/memberships/orgs/"+url.PathEscape(org), &mem)
+ in.Maintainer = err == nil && mem.State == "active"
+ }
+ return in, nil
+}
+
+func (g *GitHub) get(ctx context.Context, token, path string, out any) error {
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, g.API+path, nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/vnd.github+json")
+ return g.do(req, out)
+}
+
+func (g *GitHub) do(req *http.Request, out any) error {
+ resp, err := g.HTTP.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s %s: %d", req.Method, req.URL.Path, resp.StatusCode)
+ }
+ return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out)
+}
+
+func orElse(a, b string) string {
+ if strings.TrimSpace(a) != "" {
+ return a
+ }
+ return b
+}
diff --git a/service/internal/club/members.go b/service/internal/club/members.go
new file mode 100644
index 00000000..cd640aae
--- /dev/null
+++ b/service/internal/club/members.go
@@ -0,0 +1,197 @@
+// Package club is the OpenIPC Club: signing in to openipc.org with Telegram,
+// GitHub or an emailed link, to follow what one sent to the board catalogue,
+// keep one's flash dumps private to oneself and the maintainers, and collect
+// stars for what is accepted (migration 019).
+//
+// It holds accounts and sessions and nothing else. Every row about a report
+// -- who sent it, its files, its review, its stars -- is owner reports'
+// (internal/reports), which this package calls and never queries.
+//
+// The session cookie's path is /api/v1/club: the pages are static and cached
+// for everyone, a visitor who never signs in is never sent a cookie, and the
+// signed-in browser shows who it is by asking /api/v1/club/me.
+package club
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+)
+
+type execer interface {
+ Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
+}
+
+const (
+ sessionDays = 90
+ loginTTL = 10 * time.Minute
+ // maintainerDays is how long a GitHub sign-in proves membership of the
+ // maintainers' organisation. Someone removed from it stops reviewing
+ // within a week, whatever sessions they hold.
+ maintainerDays = 7
+)
+
+// Member is a signed-in person, as /api/v1/club/me shows them.
+type Member struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Maintainer bool `json:"maintainer"`
+ Quiet bool `json:"quiet"`
+ Identities []Identity `json:"identities"`
+ Stars int `json:"stars"`
+ // Pending is filled where the member's reports are read anyway
+ // (GET /api/v1/club/reports); /me leaves it 0.
+ Pending int `json:"pending"`
+}
+
+type Identity struct {
+ Provider string `json:"provider"`
+ Handle string `json:"handle"`
+ // Telegram: the bot can write to them
+ Chat bool `json:"chat,omitempty"`
+}
+
+// signIn is one way in, completed: who the provider says this is.
+type signIn struct {
+ Provider string
+ Subject string
+ Handle string
+ Name string
+ Maintainer bool
+ ChatID *int64
+ Locale string
+}
+
+func randomString(n int) string {
+ b := make([]byte, n)
+ _, _ = rand.Read(b)
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+func sha(s string) string {
+ h := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(h[:])
+}
+
+func newMemberID() string {
+ const alphabet = "abcdefghijkmnpqrstuvwxyz23456789"
+ var b [10]byte
+ _, _ = rand.Read(b[:])
+ out := []byte("m-")
+ for _, c := range b {
+ out = append(out, alphabet[int(c)%len(alphabet)])
+ }
+ return string(out)
+}
+
+// cleanName is a display name as a member may carry it: one line, 1-80
+// characters, never empty.
+func cleanName(s string) string {
+ s = strings.Join(strings.Fields(strings.ToValidUTF8(s, "")), " ")
+ for utf8.RuneCountInString(s) > 80 {
+ _, size := utf8.DecodeLastRuneInString(s)
+ s = s[:len(s)-size]
+ }
+ if s == "" {
+ s = "OpenIPC member"
+ }
+ return s
+}
+
+// identify finds or makes the member an identity belongs to. An identity
+// already known is its member's, whoever asks; a new one joins forMember
+// when the asking browser was signed in, and is a new member otherwise.
+func identify(ctx context.Context, tx pgx.Tx, in signIn, forMember string) (string, error) {
+ var member string
+ err := tx.QueryRow(ctx, `
+ UPDATE club_identities SET handle = $3, maintainer = $4,
+ chat_id = coalesce($5, chat_id), locale = coalesce(nullif($6, ''), locale), seen_at = now()
+ WHERE provider = $1 AND subject = $2 RETURNING member_id`,
+ in.Provider, in.Subject, in.Handle, in.Maintainer, in.ChatID, in.Locale).Scan(&member)
+ if err == nil {
+ return member, nil
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return "", err
+ }
+ member = forMember
+ if member == "" {
+ member = newMemberID()
+ if _, err := tx.Exec(ctx, `INSERT INTO club_members (id, name) VALUES ($1, $2)`, member, cleanName(in.Name)); err != nil {
+ return "", err
+ }
+ }
+ locale := in.Locale
+ if locale == "" {
+ locale = "en"
+ }
+ _, err = tx.Exec(ctx, `
+ INSERT INTO club_identities (provider, subject, member_id, handle, maintainer, chat_id, locale)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
+ in.Provider, in.Subject, member, in.Handle, in.Maintainer, in.ChatID, locale)
+ return member, err
+}
+
+// newSession signs a browser in: the token goes in its cookie, only the
+// token's sha256 is stored.
+func (a *API) newSession(ctx context.Context, q execer, member string) (string, error) {
+ token := randomString(32)
+ _, err := q.Exec(ctx, `INSERT INTO club_sessions (token_sha256, member_id, expires_at) VALUES ($1, $2, $3)`,
+ sha(token), member, a.now().Add(sessionDays*24*time.Hour))
+ return token, err
+}
+
+// member reads one member whole, with their identities and stars.
+func (a *API) member(ctx context.Context, id string) (*Member, error) {
+ m := &Member{ID: id, Identities: []Identity{}}
+ if err := a.DB.QueryRow(ctx, `SELECT name, quiet FROM club_members WHERE id = $1`, id).Scan(&m.Name, &m.Quiet); err != nil {
+ return nil, err
+ }
+ rows, err := a.DB.Query(ctx, `
+ SELECT provider, handle, maintainer AND provider = 'github' AND seen_at > $2, chat_id IS NOT NULL FROM club_identities
+ WHERE member_id = $1 ORDER BY provider`, id, a.now().Add(-maintainerDays*24*time.Hour))
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var i Identity
+ var maint bool
+ if err := rows.Scan(&i.Provider, &i.Handle, &maint, &i.Chat); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ m.Maintainer = m.Maintainer || maint
+ m.Identities = append(m.Identities, i)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ for _, x := range a.Cfg.Maintainers {
+ m.Maintainer = m.Maintainer || x == id
+ }
+ m.Stars, err = a.Reports.Store().StarsOf(ctx, id)
+ return m, err
+}
+
+// Purge drops what the nightly purge may: sessions past their expiry, and
+// sign-ins a day after theirs (kept that long so a late Start still finds
+// its code, and says it expired rather than that it never existed).
+func Purge(ctx context.Context, db execer) (sessions, logins int64, err error) {
+ tag, err := db.Exec(ctx, `DELETE FROM club_sessions WHERE expires_at < now()`)
+ if err != nil {
+ return 0, 0, err
+ }
+ sessions = tag.RowsAffected()
+ tag, err = db.Exec(ctx, `DELETE FROM club_logins WHERE expires_at < now() - interval '1 day'`)
+ return sessions, tag.RowsAffected(), err
+}
diff --git a/service/internal/club/telegram.go b/service/internal/club/telegram.go
new file mode 100644
index 00000000..37882921
--- /dev/null
+++ b/service/internal/club/telegram.go
@@ -0,0 +1,511 @@
+package club
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+
+ "github.com/OpenIPC/website/service/internal/reports"
+)
+
+// Telegram is the OpenIPC bot: it signs people in when they tap Start on a
+// link the site made, and tells members what happened to what they sent.
+// Telegram calls it (a webhook); it calls Telegram's Bot API.
+type Telegram struct {
+ Token string
+ // API is Telegram's Bot API, https://api.telegram.org (a test's server).
+ API string
+ HTTP *http.Client
+ Log *slog.Logger
+
+ mu sync.Mutex
+ username string
+}
+
+// Username is the bot's @name without the @, once Start has read it.
+func (t *Telegram) Username() string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.username
+}
+
+// Secret is what Telegram sends back in X-Telegram-Bot-Api-Secret-Token:
+// derived from the token, so it is one setting fewer and changes with it.
+func (t *Telegram) Secret() string {
+ m := hmac.New(sha256.New, []byte(t.Token))
+ m.Write([]byte("openipc club webhook"))
+ return hex.EncodeToString(m.Sum(nil))[:48]
+}
+
+func (t *Telegram) call(ctx context.Context, method string, in, out any) error {
+ body, _ := json.Marshal(in)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(t.API, "/")+"/bot"+t.Token+"/"+method, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := t.HTTP.Do(req)
+ if err != nil {
+ // the token is in the URL; never let it into a log line
+ return fmt.Errorf("telegram %s: %s", method, strings.ReplaceAll(err.Error(), t.Token, ""))
+ }
+ defer resp.Body.Close()
+ var env struct {
+ OK bool `json:"ok"`
+ Result json.RawMessage `json:"result"`
+ Description string `json:"description"`
+ }
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&env); err != nil {
+ return fmt.Errorf("telegram %s: %d", method, resp.StatusCode)
+ }
+ if !env.OK {
+ return fmt.Errorf("telegram %s: %s", method, env.Description)
+ }
+ if out != nil {
+ return json.Unmarshal(env.Result, out)
+ }
+ return nil
+}
+
+// Start learns the bot's username and points its webhook at this site.
+func (t *Telegram) Start(ctx context.Context, siteURL string) error {
+ var me struct {
+ Username string `json:"username"`
+ }
+ if err := t.call(ctx, "getMe", map[string]any{}, &me); err != nil {
+ return err
+ }
+ t.mu.Lock()
+ t.username = me.Username
+ t.mu.Unlock()
+ return t.call(ctx, "setWebhook", map[string]any{
+ "url": strings.TrimSuffix(siteURL, "/") + "/api/v1/club/telegram/webhook",
+ "secret_token": t.Secret(),
+ "allowed_updates": []string{"message", "callback_query"},
+ }, nil)
+}
+
+// Button is an inline button under a message: one that opens a page (URL)
+// or one that answers the bot (Data).
+type Button struct {
+ Text string `json:"text"`
+ URL string `json:"url,omitempty"`
+ Data string `json:"callback_data,omitempty"`
+}
+
+// Send writes to a private chat. text is Telegram HTML.
+func (t *Telegram) Send(ctx context.Context, chat int64, text string, buttons ...Button) error {
+ msg := map[string]any{"chat_id": chat, "text": text, "parse_mode": "HTML", "link_preview_options": map[string]bool{"is_disabled": true}}
+ if len(buttons) > 0 {
+ msg["reply_markup"] = keyboard(buttons)
+ }
+ return t.call(ctx, "sendMessage", msg, nil)
+}
+
+func keyboard(buttons []Button) map[string]any {
+ rows := [][]Button{}
+ for _, b := range buttons {
+ rows = append(rows, []Button{b})
+ }
+ return map[string]any{"inline_keyboard": rows}
+}
+
+// Edit replaces a message the bot sent, and its buttons with none.
+func (t *Telegram) Edit(ctx context.Context, chat int64, message int64, text string) error {
+ return t.call(ctx, "editMessageText", map[string]any{"chat_id": chat, "message_id": message, "text": text,
+ "parse_mode": "HTML", "reply_markup": map[string]any{"inline_keyboard": [][]Button{}}}, nil)
+}
+
+// Answer stops the spinner on a tapped button.
+func (t *Telegram) Answer(ctx context.Context, id, text string) error {
+ return t.call(ctx, "answerCallbackQuery", map[string]any{"callback_query_id": id, "text": text}, nil)
+}
+
+// telegramStart is POST /api/v1/club/telegram: a sign-in for this browser,
+// as a link that opens the bot with the code as Start's parameter.
+func (a *API) telegramStart(w http.ResponseWriter, r *http.Request) {
+ if a.Telegram == nil || a.Telegram.Username() == "" {
+ a.refuse(w, http.StatusServiceUnavailable, "Telegram sign-in is not available on this site")
+ return
+ }
+ if !a.limits.allow("tg:"+clientKey(r), 20, time.Hour, a.now()) {
+ a.refuse(w, http.StatusTooManyRequests, "too many sign-ins from this address; try again in an hour")
+ return
+ }
+ code, expires, err := a.newLogin(w, r, "telegram", "")
+ if err != nil {
+ a.fail(w, "the sign-in", err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "link": "https://t.me/" + a.Telegram.Username() + "?start=" + code, "expires_at": expires,
+ })
+}
+
+type tgUpdate struct {
+ Message *struct {
+ Chat struct {
+ ID int64 `json:"id"`
+ Type string `json:"type"`
+ } `json:"chat"`
+ From *struct {
+ ID int64 `json:"id"`
+ IsBot bool `json:"is_bot"`
+ Username string `json:"username"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ LanguageCode string `json:"language_code"`
+ } `json:"from"`
+ Text string `json:"text"`
+ } `json:"message"`
+ Callback *struct {
+ ID string `json:"id"`
+ From struct {
+ ID int64 `json:"id"`
+ LanguageCode string `json:"language_code"`
+ } `json:"from"`
+ Data string `json:"data"`
+ Message *struct {
+ ID int64 `json:"message_id"`
+ Chat struct {
+ ID int64 `json:"id"`
+ } `json:"chat"`
+ } `json:"message"`
+ } `json:"callback_query"`
+}
+
+// telegramWebhook is POST /api/v1/club/telegram/webhook: Telegram, with
+// what someone wrote to the bot. Anything else is refused by the secret.
+func (a *API) telegramWebhook(w http.ResponseWriter, r *http.Request) {
+ if a.Telegram == nil {
+ http.NotFound(w, r)
+ return
+ }
+ got := r.Header.Get("X-Telegram-Bot-Api-Secret-Token")
+ if subtle.ConstantTimeCompare([]byte(got), []byte(a.Telegram.Secret())) != 1 {
+ a.refuse(w, http.StatusForbidden, "not Telegram")
+ return
+ }
+ var u tgUpdate
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&u); err != nil {
+ a.refuse(w, http.StatusBadRequest, "not an update")
+ return
+ }
+ // Telegram retries anything but a 200; what went wrong is ours to log.
+ w.WriteHeader(http.StatusOK)
+ if cb := u.Callback; cb != nil {
+ if err := a.onAnswer(context.WithoutCancel(r.Context()), cb.ID, cb.From.ID, localeOf(cb.From.LanguageCode), cb.Data, cb.Message); err != nil {
+ a.Log.Error("club: telegram answer failed", "err", err)
+ }
+ return
+ }
+ m := u.Message
+ if m == nil || m.From == nil || m.From.IsBot || m.Chat.Type != "private" {
+ return
+ }
+ ctx := context.WithoutCancel(r.Context())
+ if err := a.onMessage(ctx, m.Chat.ID, signIn{
+ Provider: "telegram", Subject: fmt.Sprint(m.From.ID),
+ Handle: handleOf(m.From.Username, m.From.FirstName), Name: strings.TrimSpace(m.From.FirstName + " " + m.From.LastName),
+ ChatID: &m.Chat.ID, Locale: localeOf(m.From.LanguageCode),
+ }, strings.TrimSpace(m.Text)); err != nil {
+ a.Log.Error("club: telegram message failed", "err", err)
+ }
+}
+
+func handleOf(username, first string) string {
+ if username != "" {
+ return "@" + username
+ }
+ return first
+}
+
+func localeOf(code string) string {
+ switch {
+ case strings.HasPrefix(code, "ru"), strings.HasPrefix(code, "uk"), strings.HasPrefix(code, "be"), strings.HasPrefix(code, "kk"):
+ return "ru"
+ case strings.HasPrefix(code, "zh"):
+ return "zh"
+ }
+ return "en"
+}
+
+func (a *API) onMessage(ctx context.Context, chat int64, who signIn, text string) error {
+ l := who.Locale
+ cmd, arg, _ := strings.Cut(text, " ")
+ cmd = strings.ToLower(strings.SplitN(cmd, "@", 2)[0])
+ switch cmd {
+ case "/start":
+ return a.tgStart(ctx, chat, who, strings.TrimSpace(arg))
+ case "/quiet", "/loud":
+ quiet := cmd == "/quiet"
+ tag, err := a.DB.Exec(ctx, `UPDATE club_members SET quiet = $2 WHERE id =
+ (SELECT member_id FROM club_identities WHERE provider = 'telegram' AND subject = $1)`, who.Subject, quiet)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return a.Telegram.Send(ctx, chat, t(l, "not_member"))
+ }
+ if quiet {
+ return a.Telegram.Send(ctx, chat, t(l, "quiet"))
+ }
+ return a.Telegram.Send(ctx, chat, t(l, "loud"))
+ case "/stop":
+ if _, err := a.DB.Exec(ctx, `UPDATE club_identities SET chat_id = NULL WHERE provider = 'telegram' AND subject = $1`, who.Subject); err != nil {
+ return err
+ }
+ return a.Telegram.Send(ctx, chat, t(l, "stopped"))
+ }
+ return a.Telegram.Send(ctx, chat, t(l, "help"), Button{Text: t(l, "open_club"), URL: a.Cfg.SiteURL + "/club/"})
+}
+
+// tgStart takes the code Start carried. It does not sign anyone in yet: it
+// asks the person who tapped Start whether they asked for this sign-in,
+// where and when it was asked for -- a start link sent to them by someone
+// else would otherwise sign that someone's browser in as them. Their Yes
+// (onAnswer) finishes it.
+//
+// Without a code, or with one that expired, it still signs the person up
+// and sends a link that opens the site; the site asks before it signs in.
+func (a *API) tgStart(ctx context.Context, chat int64, who signIn, code string) error {
+ l := who.Locale
+ var member, from string
+ var asked time.Time
+ var valid bool
+ err := pgx.BeginFunc(ctx, a.DB, func(tx pgx.Tx) error {
+ var forMember *string
+ var expires time.Time
+ var finished *string
+ err := tx.QueryRow(ctx, `
+ SELECT for_member, expires_at, member_id, requested_from, created_at FROM club_logins
+ WHERE code_sha256 = $1 AND provider = 'telegram' AND used_at IS NULL FOR UPDATE`, sha(code)).
+ Scan(&forMember, &expires, &finished, &from, &asked)
+ valid = code != "" && err == nil && finished == nil && expires.After(a.now())
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return err
+ }
+ link := ""
+ if valid {
+ link = deref(forMember)
+ }
+ member, err = identify(ctx, tx, who, link)
+ if err != nil {
+ return err
+ }
+ if valid {
+ _, err = tx.Exec(ctx, `UPDATE club_logins SET claimed_by = $2 WHERE code_sha256 = $1`, sha(code), member)
+ }
+ return err
+ })
+ if err != nil {
+ return err
+ }
+ if valid {
+ return a.Telegram.Send(ctx, chat,
+ fmt.Sprintf(t(l, "confirm"), html.EscapeString(who.Handle), html.EscapeString(orElse(from, "?")), asked.UTC().Format("15:04")),
+ Button{Text: t(l, "confirm_yes"), Data: "y:" + code},
+ Button{Text: t(l, "confirm_no"), Data: "n:" + code})
+ }
+ link := randomString(18)
+ if _, err := a.DB.Exec(ctx, `
+ INSERT INTO club_logins (code_sha256, provider, member_id, expires_at) VALUES ($1, 'telegram', $2, $3)`,
+ sha(link), member, a.now().Add(loginTTL)); err != nil {
+ return err
+ }
+ key := "expired"
+ if code == "" {
+ key = "welcome"
+ }
+ return a.Telegram.Send(ctx, chat, t(l, key),
+ Button{Text: t(l, "open_signed_in"), URL: a.Cfg.SiteURL + "/api/v1/club/finish?code=" + link})
+}
+
+// onAnswer is a tap on Yes or No under the sign-in question. Only the
+// Telegram account that tapped Start may answer it.
+func (a *API) onAnswer(ctx context.Context, id string, from int64, l, data string, msg *struct {
+ ID int64 `json:"message_id"`
+ Chat struct {
+ ID int64 `json:"id"`
+ } `json:"chat"`
+}) error {
+ verb, code, ok := strings.Cut(data, ":")
+ if !ok || (verb != "y" && verb != "n") {
+ return a.Telegram.Answer(ctx, id, "")
+ }
+ var handle string
+ var done bool
+ err := pgx.BeginFunc(ctx, a.DB, func(tx pgx.Tx) error {
+ var claimed *string
+ var expires time.Time
+ var member *string
+ err := tx.QueryRow(ctx, `
+ SELECT claimed_by, expires_at, member_id FROM club_logins
+ WHERE code_sha256 = $1 AND provider = 'telegram' AND used_at IS NULL FOR UPDATE`, sha(code)).
+ Scan(&claimed, &expires, &member)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ // The answer must come from whoever tapped Start.
+ if claimed == nil || member != nil || !expires.After(a.now()) {
+ return nil
+ }
+ if err := tx.QueryRow(ctx, `SELECT handle FROM club_identities WHERE provider = 'telegram' AND subject = $1 AND member_id = $2`,
+ fmt.Sprint(from), *claimed).Scan(&handle); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ }
+ return err
+ }
+ if verb == "y" {
+ _, err = tx.Exec(ctx, `UPDATE club_logins SET member_id = claimed_by WHERE code_sha256 = $1`, sha(code))
+ } else {
+ _, err = tx.Exec(ctx, `UPDATE club_logins SET expires_at = $2 WHERE code_sha256 = $1`, sha(code), a.now())
+ }
+ done = err == nil
+ return err
+ })
+ if err != nil {
+ return err
+ }
+ text := t(l, "confirm_gone")
+ switch {
+ case done && verb == "y":
+ text = fmt.Sprintf(t(l, "signed_in"), html.EscapeString(handle))
+ case done:
+ text = t(l, "confirm_refused")
+ }
+ if err := a.Telegram.Answer(ctx, id, ""); err != nil {
+ return err
+ }
+ if msg != nil {
+ return a.Telegram.Edit(ctx, msg.Chat.ID, msg.ID, text)
+ }
+ return nil
+}
+
+// notify writes to a member through the bot, when they have it and have
+// not muted it. A member without Telegram reads the same on their page.
+func (a *API) notify(ctx context.Context, member string, text func(locale string) string, buttons func(locale string) []Button) {
+ if a.Telegram == nil {
+ return
+ }
+ var chat int64
+ var locale string
+ err := a.DB.QueryRow(ctx, `
+ SELECT i.chat_id, i.locale FROM club_identities i JOIN club_members m ON m.id = i.member_id
+ WHERE i.member_id = $1 AND i.provider = 'telegram' AND i.chat_id IS NOT NULL AND NOT m.quiet
+ ORDER BY i.seen_at DESC LIMIT 1`, member).Scan(&chat, &locale)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return
+ }
+ if err == nil {
+ err = a.Telegram.Send(ctx, chat, text(locale), buttons(locale)...)
+ }
+ if err != nil {
+ a.Log.Warn("club: telegram notice failed", "member", member, "err", err)
+ }
+}
+
+func (a *API) notifyDecision(ctx context.Context, member, report, decision string, d reports.Decided) {
+ a.notify(ctx, member, func(l string) string {
+ switch {
+ case decision == "publish" && d.Points > 0:
+ return fmt.Sprintf(t(l, "accepted"), report, d.Points, d.Total)
+ case decision == "publish":
+ return fmt.Sprintf(t(l, "accepted_known"), report)
+ default:
+ return fmt.Sprintf(t(l, "rejected"), report)
+ }
+ }, func(l string) []Button {
+ return []Button{{Text: t(l, "open_club"), URL: a.Cfg.SiteURL + "/club/"}}
+ })
+}
+
+var messages = map[string]map[string]string{
+ "en": {
+ "signed_in": "✅ Signed in to openipc.org as %s . You can go back to the browser.",
+ "welcome": "Hi! I sign you in to openipc.org and tell you what happens to the boards, logs and dumps you send. I never see your chats or your phone number.",
+ "expired": "That sign-in code has expired. This button opens openipc.org signed in:",
+ "open_signed_in": "Open openipc.org signed in",
+ "open_club": "My submissions",
+ "help": "I sign you in to openipc.org and tell you what happens to what you send.\n/quiet mutes me, /loud unmutes, /stop unlinks Telegram.",
+ "quiet": "Muted. Your submissions are still listed on openipc.org/club. Send /loud to hear from me again, or /stop to unlink Telegram.",
+ "loud": "I'll tell you again when something you sent is reviewed.",
+ "stopped": "Telegram is unlinked from your openipc.org account. Sign in with Telegram again to link it back.",
+ "not_member": "You haven't signed in to openipc.org with Telegram yet.",
+ "accepted": "✅ Your report %s was accepted. +%d ★ , %d in total.",
+ "accepted_known": "✅ Your report %s was accepted. The catalogue already had what it brings, so it earns no stars this time. Thank you anyway.",
+ "rejected": "⚪ Your report %s was not accepted. Your page on openipc.org says why when the reviewer left a note.",
+ "confirm": "Sign in to openipc.org as %s ?\nAsked for from %s at %s UTC. Tap Yes only if you asked for it yourself, just now.",
+ "confirm_yes": "✅ Yes, sign me in",
+ "confirm_no": "✖ No, it was not me",
+ "confirm_refused": "Nobody was signed in. If someone sent you that link, they wanted your account: ignore them.",
+ "confirm_gone": "That sign-in has expired or was already answered. Start again on openipc.org.",
+ },
+ "ru": {
+ "signed_in": "✅ Вы вошли на openipc.org как %s . Можно вернуться в браузер.",
+ "welcome": "Привет! Я вхожу за вас на openipc.org и сообщаю, что стало с платами, логами и дампами, которые вы прислали. Ваших чатов и номера телефона я не вижу.",
+ "expired": "Этот код входа устарел. Кнопка ниже откроет openipc.org уже с входом:",
+ "open_signed_in": "Открыть openipc.org с входом",
+ "open_club": "Мои материалы",
+ "help": "Я вхожу за вас на openipc.org и сообщаю, что стало с присланным.\n/quiet — не писать, /loud — писать снова, /stop — отвязать Telegram.",
+ "quiet": "Больше не пишу. Ваши материалы по-прежнему видны на openipc.org/club. /loud — писать снова, /stop — отвязать Telegram.",
+ "loud": "Снова сообщу, когда присланное проверят.",
+ "stopped": "Telegram отвязан от вашей учётной записи на openipc.org. Войдите через Telegram снова, чтобы привязать.",
+ "not_member": "Вы ещё не входили на openipc.org через Telegram.",
+ "accepted": "✅ Ваш отчёт %s принят. +%d ★ , всего %d.",
+ "accepted_known": "✅ Ваш отчёт %s принят. В каталоге это уже было, поэтому звёзд в этот раз нет. Всё равно спасибо.",
+ "rejected": "⚪ Ваш отчёт %s не принят. Если проверяющий оставил пояснение, оно на вашей странице на openipc.org.",
+ "confirm": "Войти на openipc.org как %s ?\nЗапрос с адреса %s в %s UTC. Нажмите «Да», только если вы сами запросили вход только что.",
+ "confirm_yes": "✅ Да, войти",
+ "confirm_no": "✖ Нет, это не я",
+ "confirm_refused": "Вход не выполнен. Если ссылку вам прислал кто-то другой, он хотел получить доступ к вашей учётной записи: не отвечайте ему.",
+ "confirm_gone": "Этот вход устарел или на него уже ответили. Начните заново на openipc.org.",
+ },
+ "zh": {
+ "signed_in": "✅ 已以 %s 身份登录 openipc.org。现在可以回到浏览器。",
+ "welcome": "你好!我帮你登录 openipc.org,并告诉你提交的电路板、日志和固件转储的审核结果。我看不到你的聊天记录或手机号。",
+ "expired": "该登录码已过期。点击下方按钮即可直接登录 openipc.org:",
+ "open_signed_in": "登录并打开 openipc.org",
+ "open_club": "我的提交",
+ "help": "我帮你登录 openipc.org,并告诉你提交内容的审核结果。\n/quiet 静音,/loud 取消静音,/stop 解除 Telegram 绑定。",
+ "quiet": "已静音。你的提交仍列在 openipc.org/club。发送 /loud 恢复通知,或 /stop 解除 Telegram 绑定。",
+ "loud": "你的提交被审核后我会再通知你。",
+ "stopped": "已从你的 openipc.org 账户解除 Telegram 绑定。再次用 Telegram 登录即可重新绑定。",
+ "not_member": "你还没有用 Telegram 登录过 openipc.org。",
+ "accepted": "✅ 你的报告 %s 已通过。+%d ★ ,共 %d。",
+ "accepted_known": "✅ 你的报告 %s 已通过。目录中已有相同内容,因此本次没有星星。仍然感谢!",
+ "rejected": "⚪ 你的报告 %s 未通过。审核者如留有说明,可在 openipc.org 的个人页面查看。",
+ "confirm": "以 %s 身份登录 openipc.org?\n请求来自 %s,时间 %s UTC。仅当这是你刚刚亲自发起的登录时才点“是”。",
+ "confirm_yes": "✅ 是,登录",
+ "confirm_no": "✖ 不是我",
+ "confirm_refused": "未登录任何账户。如果是别人发给你的链接,对方想获取你的账户:请不要理会。",
+ "confirm_gone": "该登录已过期或已处理。请在 openipc.org 重新开始。",
+ },
+}
+
+func t(locale, key string) string {
+ if s, ok := messages[locale][key]; ok {
+ return s
+ }
+ return messages["en"][key]
+}
diff --git a/service/internal/config/config.go b/service/internal/config/config.go
index 0bb56695..f95b6faf 100644
--- a/service/internal/config/config.go
+++ b/service/internal/config/config.go
@@ -37,6 +37,18 @@ type Config struct {
// hands a published file to nginx's internal location ReportsAccelPrefix.
ReportsRoot string
ReportsAccelPrefix string
+ // The OpenIPC Club (internal/club): where its links point, and each way
+ // in, which works only when configured.
+ ClubSiteURL string // CLUB_SITE_URL: https://openipc.org, https://dev.openipc.org
+ TelegramBotToken string // TELEGRAM_BOT_TOKEN, from @BotFather; one bot per environment
+ GitHubClientID string // GITHUB_OAUTH_CLIENT_ID
+ GitHubClientSecret string // GITHUB_OAUTH_CLIENT_SECRET
+ ClubMaintainerOrg string // CLUB_MAINTAINER_ORG: its members review; OpenIPC
+ ClubMaintainers []string // CLUB_MAINTAINERS: member ids that review without it
+ SMTPAddr string // CLUB_SMTP_ADDR host:port, a relay openipc.org's SPF names
+ SMTPUser string // CLUB_SMTP_USER
+ SMTPPassword string // CLUB_SMTP_PASSWORD
+ MailFrom string // CLUB_MAIL_FROM
// ipctool's builds, pushed by its release job and served by nginx over
// plain HTTP at http://openipc.org/ (internal/tools); the NFS role
// exports the same directory read-only.
@@ -81,6 +93,16 @@ func Load() (*Config, error) {
ReportsRoot: str("REPORTS_ROOT", "/srv/owner-reports"),
ReportsAccelPrefix: str("REPORTS_ACCEL_PREFIX", "/report-files/"),
ToolsRoot: str("TOOLS_ROOT", "/srv/tools"),
+ ClubSiteURL: strings.TrimSuffix(str("CLUB_SITE_URL", "https://openipc.org"), "/"),
+ TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
+ GitHubClientID: os.Getenv("GITHUB_OAUTH_CLIENT_ID"),
+ GitHubClientSecret: os.Getenv("GITHUB_OAUTH_CLIENT_SECRET"),
+ ClubMaintainerOrg: str("CLUB_MAINTAINER_ORG", "OpenIPC"),
+ ClubMaintainers: list("CLUB_MAINTAINERS"),
+ SMTPAddr: os.Getenv("CLUB_SMTP_ADDR"),
+ SMTPUser: os.Getenv("CLUB_SMTP_USER"),
+ SMTPPassword: os.Getenv("CLUB_SMTP_PASSWORD"),
+ MailFrom: str("CLUB_MAIL_FROM", "OpenIPC "),
NFSAddr: str("NFS_ADDR", ":2049"),
PortmapAddr: str("PORTMAP_ADDR", ":111"),
CatalogueDir: str("CATALOGUE_DIR", "/app/catalogue"),
diff --git a/service/internal/db/migrations/019_club.sql b/service/internal/db/migrations/019_club.sql
new file mode 100644
index 00000000..54f5b0f3
--- /dev/null
+++ b/service/internal/db/migrations/019_club.sql
@@ -0,0 +1,71 @@
+-- The OpenIPC Club: people who sign in to follow what they sent to the board
+-- catalogue, keep their flash dumps private to themselves and the
+-- maintainers, and collect stars for what is accepted (internal/club).
+--
+-- This reverses #288's "no sign-in" deliberately, and only this far: the
+-- session cookie is scoped to /api/v1/club, so the pages stay static and
+-- cached, and a visitor who never signs in is never sent a cookie.
+--
+-- member one person, however many ways they sign in
+-- identity one way in: a Telegram account, a GitHub account, an email
+-- address. A GitHub identity in OpenIPC's organisation makes
+-- its member a maintainer, who reviews what members send.
+-- session a signed-in browser; only the token's sha256 is stored
+-- login one sign-in in progress: the code in a Telegram link, an
+-- emailed link or GitHub's state, tied to the browser that
+-- asked for it; ten minutes, used once
+
+CREATE TABLE club_members (
+ id text PRIMARY KEY CHECK (id ~ '^m-[a-z0-9]{10}$'),
+ name text NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ -- the member muted the bot (/quiet); the site still lists everything
+ quiet boolean NOT NULL DEFAULT false
+);
+
+CREATE TABLE club_identities (
+ provider text NOT NULL CHECK (provider IN ('telegram', 'github', 'email')),
+ -- Telegram's and GitHub's numeric user id, or the address in lower case
+ subject text NOT NULL CHECK (length(subject) BETWEEN 1 AND 320),
+ member_id text NOT NULL REFERENCES club_members ON DELETE CASCADE,
+ -- what the person is called there: @username, the GitHub login, the address
+ handle text NOT NULL DEFAULT '',
+ -- GitHub: a member of the maintainers' organisation at the last sign-in
+ maintainer boolean NOT NULL DEFAULT false,
+ -- Telegram: the private chat the bot writes to; NULL after /stop
+ chat_id bigint,
+ -- the language the bot writes in: Telegram's, or the page's
+ locale text NOT NULL DEFAULT 'en' CHECK (locale IN ('en', 'ru', 'zh')),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ seen_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (provider, subject)
+);
+CREATE INDEX club_identities_by_member ON club_identities (member_id);
+
+CREATE TABLE club_sessions (
+ token_sha256 text PRIMARY KEY CHECK (token_sha256 ~ '^[0-9a-f]{64}$'),
+ member_id text NOT NULL REFERENCES club_members ON DELETE CASCADE,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ expires_at timestamptz NOT NULL
+);
+CREATE INDEX club_sessions_by_member ON club_sessions (member_id);
+
+CREATE TABLE club_logins (
+ code_sha256 text PRIMARY KEY CHECK (code_sha256 ~ '^[0-9a-f]{64}$'),
+ provider text NOT NULL CHECK (provider IN ('telegram', 'github', 'email')),
+ -- sha256 of the secret in the asking browser's login cookie; NULL for a
+ -- link the bot sends into a member's own chat
+ browser_sha256 text CHECK (browser_sha256 ~ '^[0-9a-f]{64}$'),
+ email text,
+ -- the member already signed in on the asking browser: what this login
+ -- adds is linked to them rather than made a new account
+ for_member text REFERENCES club_members ON DELETE CASCADE,
+ -- set when the person finished on the other side (tapped Start)
+ member_id text REFERENCES club_members ON DELETE CASCADE,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ expires_at timestamptz NOT NULL,
+ -- set when a browser was signed in by it; a login signs in once
+ used_at timestamptz
+);
+CREATE INDEX club_logins_by_browser ON club_logins (browser_sha256);
+CREATE INDEX club_logins_by_email ON club_logins (lower(email), created_at);
diff --git a/service/internal/db/migrations/020_report_submissions.sql b/service/internal/db/migrations/020_report_submissions.sql
new file mode 100644
index 00000000..4c840f5c
--- /dev/null
+++ b/service/internal/db/migrations/020_report_submissions.sql
@@ -0,0 +1,72 @@
+-- Who sent a report, and for which board, when it came through the site's
+-- send form (POST /api/v1/club/reports); and the stars it earned. Both are
+-- owner reports' own rows (internal/reports), guarded like the rest:
+-- inserted, never changed.
+--
+-- A report sent by a signed-in member is theirs: they see its state and
+-- download its private backup, and nobody else but a maintainer can. The
+-- board it names is the sender's word, not a review's link -- report_models
+-- stays the reviewer's.
+CREATE TABLE report_submissions (
+ report_id text PRIMARY KEY REFERENCES reports ON DELETE RESTRICT,
+ member_id text REFERENCES club_members ON DELETE SET NULL,
+ model_id text REFERENCES board_models ON DELETE RESTRICT ON UPDATE RESTRICT
+);
+CREATE INDEX report_submissions_by_member ON report_submissions (member_id);
+
+-- The stars ledger. A row is written when a maintainer publishes a report
+-- (an award per file, and one for ipctool's output) and its negative when
+-- a published report is rejected or withdrawn afterwards; nothing is ever
+-- edited, so a member's stars are the sum of their rows.
+CREATE TABLE report_stars (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ member_id text NOT NULL REFERENCES club_members ON DELETE CASCADE,
+ report_id text NOT NULL REFERENCES reports ON DELETE RESTRICT,
+ -- the file's position, 0 for the report's ipctool output
+ position int NOT NULL CHECK (position >= 0),
+ points int NOT NULL,
+ -- award: what was accepted; revoke: the award taken back
+ kind text NOT NULL CHECK (kind IN ('award', 'revoke')),
+ reason text NOT NULL,
+ at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (report_id, position, kind)
+);
+CREATE INDEX report_stars_by_member ON report_stars (member_id, at);
+
+-- ON DELETE SET NULL and CASCADE above run as the system, which the guard
+-- would refuse: an account's deletion detaches its reports (they stay, as
+-- reports always do) and drops its ledger. The guard lets those through by
+-- looking at what changed: only member_id, to NULL.
+CREATE FUNCTION report_submissions_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF coalesce(current_setting('openipc.reports_guard', true), '') = 'off' THEN
+ RETURN NEW;
+ END IF;
+ IF TG_OP = 'UPDATE' AND NEW.report_id = OLD.report_id AND NEW.model_id IS NOT DISTINCT FROM OLD.model_id
+ AND NEW.member_id IS NULL THEN
+ RETURN NEW;
+ END IF;
+ RAISE EXCEPTION 'owner reports are never changed or deleted (% on %); see service/internal/reports', TG_OP, TG_TABLE_NAME
+ USING ERRCODE = 'insufficient_privilege';
+END $$;
+CREATE TRIGGER report_submissions_guard BEFORE UPDATE OR DELETE ON report_submissions
+ FOR EACH ROW EXECUTE FUNCTION report_submissions_guard();
+CREATE TRIGGER report_submissions_no_truncate BEFORE TRUNCATE ON report_submissions
+ FOR EACH STATEMENT EXECUTE FUNCTION reports_guard();
+
+-- The ledger refuses updates; a row goes only with its member's account.
+CREATE FUNCTION report_stars_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP = 'DELETE' AND NOT EXISTS (SELECT 1 FROM club_members WHERE id = OLD.member_id) THEN
+ RETURN OLD;
+ END IF;
+ IF coalesce(current_setting('openipc.reports_guard', true), '') = 'off' THEN
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
+ END IF;
+ RAISE EXCEPTION 'the stars ledger is never changed (% on %); see service/internal/reports', TG_OP, TG_TABLE_NAME
+ USING ERRCODE = 'insufficient_privilege';
+END $$;
+CREATE TRIGGER report_stars_guard BEFORE UPDATE OR DELETE ON report_stars
+ FOR EACH ROW EXECUTE FUNCTION report_stars_guard();
+CREATE TRIGGER report_stars_no_truncate BEFORE TRUNCATE ON report_stars
+ FOR EACH STATEMENT EXECUTE FUNCTION reports_guard();
diff --git a/service/internal/db/migrations/021_reports_club_review.sql b/service/internal/db/migrations/021_reports_club_review.sql
new file mode 100644
index 00000000..17ed4dc3
--- /dev/null
+++ b/service/internal/db/migrations/021_reports_club_review.sql
@@ -0,0 +1,16 @@
+-- The club after its first review (PR #378).
+--
+-- Stars are a net, not a one-off: a report published, rejected and
+-- published again earns its stars back, so the ledger may hold several
+-- award and revoke rows for one part of a report. What a part has earned
+-- is the sum of its rows; the review writes the difference.
+ALTER TABLE report_stars DROP CONSTRAINT report_stars_report_id_position_kind_key;
+CREATE INDEX report_stars_by_part ON report_stars (report_id, position);
+
+-- A Telegram sign-in is finished only when the person who tapped Start
+-- confirms it in the chat: a start link sent to someone else by whoever
+-- asked for it would otherwise sign that browser in as them. claimed_by is
+-- who tapped Start, waiting for Yes; requested_from is the asking address,
+-- shown in the question so a sign-in nobody asked for is recognisable.
+ALTER TABLE club_logins ADD COLUMN claimed_by text REFERENCES club_members ON DELETE CASCADE;
+ALTER TABLE club_logins ADD COLUMN requested_from text NOT NULL DEFAULT '';
diff --git a/service/internal/reports/club.go b/service/internal/reports/club.go
new file mode 100644
index 00000000..abe65e6f
--- /dev/null
+++ b/service/internal/reports/club.go
@@ -0,0 +1,465 @@
+package reports
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// What the OpenIPC Club (internal/club) asks of owner reports: a member's
+// own reports and their files, the maintainers' review queue, the stars a
+// decision writes, and the published text that joins the board catalogue.
+// The club holds accounts and nothing else; every row about a report is
+// read and written here.
+
+// Stars for what a maintainer accepts. A flash dump the catalogue already
+// holds -- the same bytes in a board's files, or in a published report sent
+// before this one -- earns nothing. The first sender of a dump keeps it: a
+// later copy published meanwhile does not take it from them if their own
+// report is rejected and published again.
+const (
+ StarsPerItem = 1
+ StarsPerDump = 10
+)
+
+// Points is what one accepted thing earns: a file of kind, or ipctool's
+// output (kind "yaml").
+func Points(kind string, known bool) int {
+ switch kind {
+ case "backup":
+ if known {
+ return 0
+ }
+ return StarsPerDump
+ case "yaml", "photo", "boot_log", "uboot_env", "note", "document":
+ return StarsPerItem
+ }
+ return 0
+}
+
+// MemberReport is one report as its sender sees it on their page.
+type MemberReport struct {
+ ID string `json:"id"`
+ ReceivedAt time.Time `json:"received_at"`
+ Status string `json:"status"`
+ ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
+ Note string `json:"note,omitempty"`
+ // ReviewNote is what the reviewer wrote for the sender with the decision.
+ ReviewNote string `json:"review_note,omitempty"`
+ Board *ViewModel `json:"board,omitempty"`
+ Chip string `json:"chip,omitempty"`
+ Files []MemberFile `json:"files"`
+ // Stars: what it earned, net of anything taken back; Pending: what it
+ // would earn if accepted, while it waits.
+ Stars int `json:"stars"`
+ Pending int `json:"pending"`
+ // Duplicate: a dump the catalogue already had.
+ Duplicate bool `json:"duplicate,omitempty"`
+}
+
+type MemberFile struct {
+ Position int `json:"position"`
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+ Bytes int64 `json:"bytes"`
+ // Private: served to its sender and the maintainers only.
+ Private bool `json:"private,omitempty"`
+ URL string `json:"url"`
+ Points int `json:"points"`
+}
+
+// Owner is the member who sent a report, or "".
+func (s *Store) Owner(ctx context.Context, id string) (string, error) {
+ var m *string
+ err := s.DB.QueryRow(ctx, `SELECT member_id FROM report_submissions WHERE report_id = $1`, id).Scan(&m)
+ if errors.Is(err, pgx.ErrNoRows) || m == nil {
+ return "", nil
+ }
+ return *m, err
+}
+
+// Mine is every report a member sent, newest first.
+func (s *Store) Mine(ctx context.Context, member string) ([]MemberReport, error) {
+ rows, err := s.DB.Query(ctx, `
+ SELECT r.id FROM reports r JOIN report_submissions rs ON rs.report_id = r.id
+ WHERE rs.member_id = $1 ORDER BY r.received_at DESC LIMIT 200`, member)
+ if err != nil {
+ return nil, err
+ }
+ ids, err := pgx.CollectRows(rows, pgx.RowTo[string])
+ if err != nil {
+ return nil, err
+ }
+ out := []MemberReport{}
+ for _, id := range ids {
+ m, err := s.memberReport(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *m)
+ }
+ return out, nil
+}
+
+func (s *Store) memberReport(ctx context.Context, id string) (*MemberReport, error) {
+ m := &MemberReport{ID: id, Files: []MemberFile{}}
+ var yaml string
+ err := s.DB.QueryRow(ctx, `
+ SELECT r.received_at, r.note, r.yaml, trim(r.chip_vendor || ' ' || r.chip_model)
+ FROM reports r WHERE r.id = $1`, id).Scan(&m.ReceivedAt, &m.Note, &yaml, &m.Chip)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ st, err := s.status(ctx, s.DB, id)
+ if err != nil {
+ return nil, err
+ }
+ m.Status, m.ReviewedAt = st.State, st.At
+ if m.Status == "published" || m.Status == "rejected" {
+ if err := s.DB.QueryRow(ctx, `SELECT note FROM report_reviews WHERE report_id = $1 ORDER BY id DESC LIMIT 1`, id).
+ Scan(&m.ReviewNote); err != nil {
+ return nil, err
+ }
+ }
+ var b ViewModel
+ err = s.DB.QueryRow(ctx, `
+ SELECT bm.id, coalesce(bm.model, ''), mf.name FROM report_submissions rs
+ JOIN board_models bm ON bm.id = rs.model_id JOIN board_manufacturers mf ON mf.id = bm.manufacturer_id
+ WHERE rs.report_id = $1`, id).Scan(&b.ID, &b.Model, &b.Manufacturer)
+ if err == nil {
+ m.Board = &b
+ } else if !errors.Is(err, pgx.ErrNoRows) {
+ return nil, err
+ }
+ potential, err := s.potential(ctx, id, yaml != "")
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.DB.Query(ctx, `
+ SELECT position, kind, name, bytes, public_sha256 IS NULL FROM report_files WHERE report_id = $1 ORDER BY position`, id)
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var f MemberFile
+ if err := rows.Scan(&f.Position, &f.Kind, &f.Name, &f.Bytes, &f.Private); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ f.URL = "/api/v1/club/reports/" + id + "/files/" + strconv.Itoa(f.Position)
+ f.Points = potential[f.Position]
+ if f.Kind == "backup" && f.Points == 0 {
+ m.Duplicate = true
+ }
+ m.Files = append(m.Files, f)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if err := s.DB.QueryRow(ctx, `SELECT coalesce(sum(points), 0) FROM report_stars WHERE report_id = $1`, id).Scan(&m.Stars); err != nil {
+ return nil, err
+ }
+ // Once decided, a dump is a duplicate only if it earned nothing: the
+ // first copy stays the one that counted when a later copy is published.
+ if m.Status != "pending" {
+ m.Duplicate = false
+ for _, f := range m.Files {
+ if f.Kind != "backup" {
+ continue
+ }
+ var earned bool
+ if err := s.DB.QueryRow(ctx, `SELECT coalesce(sum(points), 0) > 0 FROM report_stars WHERE report_id = $1 AND position = $2`,
+ id, f.Position).Scan(&earned); err != nil {
+ return nil, err
+ }
+ m.Duplicate = m.Duplicate || (!earned && m.Status == "published")
+ }
+ }
+ if m.Status == "pending" {
+ for _, p := range potential {
+ m.Pending += p
+ }
+ }
+ return m, nil
+}
+
+// potential is what each part of a report would earn now: position 0 for
+// ipctool's output, the file's position for each file.
+func (s *Store) potential(ctx context.Context, id string, hasYAML bool) (map[int]int, error) {
+ out := map[int]int{}
+ if hasYAML {
+ out[0] = Points("yaml", false)
+ }
+ rows, err := s.DB.Query(ctx, `
+ SELECT f.position, f.kind,
+ f.kind = 'backup' AND (
+ EXISTS (SELECT 1 FROM report_files o JOIN reports ro ON ro.id = o.report_id
+ WHERE o.sha256 = f.sha256 AND o.report_id <> f.report_id
+ AND ro.received_at < (SELECT received_at FROM reports WHERE id = f.report_id)
+ AND (SELECT decision FROM report_reviews rv WHERE rv.report_id = o.report_id ORDER BY rv.id DESC LIMIT 1) = 'publish')
+ OR EXISTS (SELECT 1 FROM board_artifacts a WHERE a.sha256 = f.sha256))
+ FROM report_files f WHERE f.report_id = $1`, id)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var pos int
+ var kind string
+ var known bool
+ if err := rows.Scan(&pos, &kind, &known); err != nil {
+ return nil, err
+ }
+ out[pos] = Points(kind, known)
+ }
+ return out, rows.Err()
+}
+
+// StarsOf is a member's stars: one sum over the ledger. What their reports
+// waiting for review would add is Mine's to say, where the reports are
+// read anyway; the navbar asks for the total on every page.
+func (s *Store) StarsOf(ctx context.Context, member string) (total int, err error) {
+ err = s.DB.QueryRow(ctx, `SELECT coalesce(sum(points), 0) FROM report_stars WHERE member_id = $1`, member).Scan(&total)
+ return
+}
+
+// Stored is any file of a report, for its sender or a maintainer: the
+// original bytes (a private backup included), never the redacted copy.
+func (s *Store) StoredFile(ctx context.Context, id string, position int) (sum, name, mime, kind string, err error) {
+ err = s.DB.QueryRow(ctx, `SELECT sha256, name, mime, kind FROM report_files WHERE report_id = $1 AND position = $2`,
+ id, position).Scan(&sum, &name, &mime, &kind)
+ if errors.Is(err, pgx.ErrNoRows) {
+ err = ErrNotFound
+ }
+ return
+}
+
+// Queued is one report in the maintainers' review queue.
+type Queued struct {
+ Listed
+ Note string `json:"note,omitempty"`
+ Tool string `json:"tool,omitempty"`
+ YAML string `json:"yaml,omitempty"`
+ Member string `json:"member,omitempty"`
+ Board *ViewModel `json:"board,omitempty"`
+ Guess *Match `json:"guess,omitempty"`
+ FileList []MemberFile `json:"file_list"`
+ Potential int `json:"potential"`
+}
+
+// Queue is the review queue: the reports in one state (pending by default),
+// newest first, each with what the reviewer needs to decide.
+func (s *Store) Queue(ctx context.Context, state string) ([]Queued, error) {
+ if state == "" {
+ state = "pending"
+ }
+ list, err := s.List(ctx, state)
+ if err != nil {
+ return nil, err
+ }
+ if len(list) > 100 {
+ list = list[:100]
+ }
+ out := []Queued{}
+ for _, l := range list {
+ q := Queued{Listed: l}
+ r, err := s.Private(ctx, l.ID)
+ if err != nil {
+ return nil, err
+ }
+ q.Note, q.Tool, q.YAML = r.Note, r.Tool, r.YAML
+ m, err := s.memberReport(ctx, l.ID)
+ if err != nil {
+ return nil, err
+ }
+ q.Board, q.FileList = m.Board, m.Files
+ for _, f := range m.Files {
+ q.Potential += f.Points
+ }
+ if r.YAML != "" {
+ q.Potential += Points("yaml", false)
+ _, facts, _ := Parse(r.YAML)
+ if id, err := Identify(ctx, s.DB, facts); err == nil && len(id.Matches) > 0 {
+ q.Guess = &id.Matches[0]
+ }
+ }
+ _ = s.DB.QueryRow(ctx, `SELECT cm.name FROM report_submissions rs JOIN club_members cm ON cm.id = rs.member_id
+ WHERE rs.report_id = $1`, l.ID).Scan(&q.Member)
+ out = append(out, q)
+ }
+ return out, nil
+}
+
+// Decided is what a review did to the sender's stars.
+type Decided struct {
+ Member string
+ Points int
+ Total int
+}
+
+// Decide records a maintainer's decision and its stars: publishing links
+// the report to its boards (the sender's board when none is named) and
+// awards each accepted part; rejecting takes back anything it had earned.
+func (s *Store) Decide(ctx context.Context, id, decision, by, note string, models []string) (Decided, error) {
+ var d Decided
+ if decision != "publish" && decision != "reject" {
+ return d, fmt.Errorf("a review publishes or rejects")
+ }
+ owner, err := s.Owner(ctx, id)
+ if err != nil {
+ return d, err
+ }
+ d.Member = owner
+ if decision == "publish" {
+ if len(models) == 0 {
+ var hint *string
+ _ = s.DB.QueryRow(ctx, `SELECT model_id FROM report_submissions WHERE report_id = $1`, id).Scan(&hint)
+ if hint != nil {
+ models = []string{*hint}
+ }
+ }
+ for _, m := range models {
+ if err := s.Link(ctx, id, m, by); err != nil {
+ return d, fmt.Errorf("link %s: %w", m, err)
+ }
+ }
+ }
+ // The potential is read before the decision, so this report's own
+ // backup is not found "already published" by itself.
+ var yaml string
+ if err := s.DB.QueryRow(ctx, `SELECT yaml FROM reports WHERE id = $1`, id).Scan(&yaml); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return d, ErrNotFound
+ }
+ return d, err
+ }
+ potential, err := s.potential(ctx, id, yaml != "")
+ if err != nil {
+ return d, err
+ }
+ if err := s.Review(ctx, id, decision, by, note); err != nil {
+ return d, err
+ }
+ if owner == "" {
+ return d, nil
+ }
+ // The ledger is a net per part of the report: publishing brings each
+ // part up to what it earns now, rejecting brings it back to zero. A
+ // report published, rejected and published again is whole again; one
+ // published twice earns once. Under the report's lock, so two reviews
+ // at once cannot both write the difference.
+ err = pgx.BeginFunc(ctx, s.DB, func(tx pgx.Tx) error {
+ if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('report-stars:' || $1, 0))`, id); err != nil {
+ return err
+ }
+ rows, err := tx.Query(ctx, `SELECT position, sum(points)::int FROM report_stars WHERE report_id = $1 GROUP BY position`, id)
+ if err != nil {
+ return err
+ }
+ net := map[int]int{}
+ for rows.Next() {
+ var pos, pts int
+ if err := rows.Scan(&pos, &pts); err != nil {
+ rows.Close()
+ return err
+ }
+ net[pos] = pts
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return err
+ }
+ want := map[int]int{}
+ if decision == "publish" {
+ want = potential
+ }
+ positions := map[int]bool{}
+ for p := range want {
+ positions[p] = true
+ }
+ for p := range net {
+ positions[p] = true
+ }
+ for pos := range positions {
+ diff := want[pos] - net[pos]
+ if diff == 0 {
+ continue
+ }
+ kind, reason := "award", "published by "+by
+ if diff < 0 {
+ kind, reason = "revoke", decision+"ed by "+by
+ }
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO report_stars (member_id, report_id, position, points, kind, reason)
+ VALUES ($1, $2, $3, $4, $5, $6)`, owner, id, pos, diff, kind, reason); err != nil {
+ return err
+ }
+ d.Points += diff
+ }
+ return nil
+ })
+ if err != nil {
+ return d, err
+ }
+ err = s.DB.QueryRow(ctx, `SELECT coalesce(sum(points), 0) FROM report_stars WHERE member_id = $1`, owner).Scan(&d.Total)
+ return d, err
+}
+
+// BoardText is a published report's text and photos, to be listed on the
+// boards it was linked to (boards.ApplyContributions): what the site's send
+// form brought becomes searchable and counted like any board's files.
+type BoardText struct {
+ Report string
+ Model string
+ By string
+ Files []BoardTextFile
+}
+
+type BoardTextFile struct {
+ Position int
+ Kind string
+ Name string
+ // The public copy's place under the reports' root (Rel).
+ Path string
+}
+
+// PublishedTexts lists, for every published report linked to a board, its
+// served text and photo files. A private backup is never among them.
+func (s *Store) PublishedTexts(ctx context.Context) ([]BoardText, error) {
+ rows, err := s.DB.Query(ctx, `
+ SELECT r.id, rm.model_id, coalesce(cm.name, ''), f.position, f.kind, f.name, f.public_sha256
+ FROM reports r
+ JOIN report_models rm ON rm.report_id = r.id
+ JOIN report_files f ON f.report_id = r.id
+ LEFT JOIN report_submissions rs ON rs.report_id = r.id
+ LEFT JOIN club_members cm ON cm.id = rs.member_id
+ WHERE f.public_sha256 IS NOT NULL AND f.kind IN ('photo', 'boot_log', 'uboot_env', 'note')
+ AND (SELECT decision FROM report_reviews rv WHERE rv.report_id = r.id ORDER BY rv.id DESC LIMIT 1) = 'publish'
+ ORDER BY r.received_at, r.id, rm.model_id, f.position`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []BoardText
+ for rows.Next() {
+ var id, model, by, kind, name, sum string
+ var pos int
+ if err := rows.Scan(&id, &model, &by, &pos, &kind, &name, &sum); err != nil {
+ return nil, err
+ }
+ if n := len(out); n == 0 || out[n-1].Report != id || out[n-1].Model != model {
+ out = append(out, BoardText{Report: id, Model: model, By: by})
+ }
+ t := &out[len(out)-1]
+ t.Files = append(t.Files, BoardTextFile{Position: pos, Kind: kind, Name: name, Path: Rel(sum)})
+ }
+ return out, rows.Err()
+}
diff --git a/service/internal/reports/handler.go b/service/internal/reports/handler.go
index f31d93b6..65c43a2b 100644
--- a/service/internal/reports/handler.go
+++ b/service/internal/reports/handler.go
@@ -54,6 +54,9 @@ func (a *API) Handlers() map[string]http.Handler {
func (a *API) store() *Store { return &Store{DB: a.DB} }
+// Store is the reports' rows, for the club's pages.
+func (a *API) Store() *Store { return a.store() }
+
func (a *API) now() time.Time {
if a.Now != nil {
return a.Now()
@@ -85,6 +88,15 @@ var fileKinds = map[string]int64{"photo": maxPhoto, "boot_log": maxText, "uboot_
// `ipctool | curl --data-binary @- .../api/v1/reports`, ipctool's output as
// the whole body.
func (a *API) upload(w http.ResponseWriter, r *http.Request) {
+ a.Submit(w, r, "")
+}
+
+// Submit is an upload, from ipctool or from the site's send form
+// (POST /api/v1/club/reports, member set when the sender is signed in).
+// A send that names a catalogue board (the field model) needs no ipctool
+// output: a boot log or a photo of a known board is a report too, and so is
+// a flash dump read with a programmer rather than ipctool.
+func (a *API) Submit(w http.ResponseWriter, r *http.Request, member string) {
ctx := r.Context()
st := a.store()
key, err := st.Key(ctx)
@@ -122,27 +134,60 @@ func (a *API) upload(w http.ResponseWriter, r *http.Request) {
return
}
+ model := in.fields["model"]
+ if model != "" {
+ if channel != "web" {
+ a.refuse(w, http.StatusBadRequest, "model is the send form's field: channel web")
+ return
+ }
+ known, err := st.ModelExists(ctx, model)
+ if err != nil {
+ a.fail(w, "the board", err)
+ return
+ }
+ if !known {
+ a.refuse(w, http.StatusBadRequest, "model: the catalogue has no board "+model)
+ return
+ }
+ }
+
// A backup carries the YAML it was taken with; alone, it is the report.
+ // For a named board, a whole flash image read with a programmer is one
+ // too: the bytes as they are, a power of two from 1 MB.
var backup Backup
+ raw := false
if in.backup != nil {
f, err := in.backup.Open()
if err == nil {
backup, err = ReadBackup(f)
}
+ if err != nil && model != "" && flashImage(in.backup.Bytes) {
+ raw, err = true, nil
+ }
if err != nil {
a.refuse(w, http.StatusBadRequest, "backup: "+err.Error())
return
}
- if in.yaml == "" {
+ if raw {
+ // the image says nothing about itself; the board is the sender's word
+ } else if in.yaml == "" {
in.yaml = backup.YAML
} else if Clean(in.yaml) != Clean(backup.YAML) {
a.refuse(w, http.StatusBadRequest, "the backup was taken with other ipctool output than the yaml sent with it")
return
}
}
- doc, facts, err := Parse(in.yaml)
- if err != nil {
- a.refuse(w, http.StatusBadRequest, err.Error())
+ var doc string
+ var facts Facts
+ if in.yaml != "" || model == "" {
+ doc, facts, err = Parse(in.yaml)
+ if err != nil {
+ a.refuse(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ }
+ if doc == "" && in.backup == nil && len(in.parts) == 0 && in.fields["note"] == "" {
+ a.refuse(w, http.StatusBadRequest, "nothing to send: add a file, a photo or a note")
return
}
consent := "none"
@@ -159,6 +204,7 @@ func (a *API) upload(w http.ResponseWriter, r *http.Request) {
NotePublic: Redact(in.fields["note"], facts, key),
YAML: doc, YAMLPublic: Redact(doc, facts, key), Facts: facts,
IDHashes: facts.IDHashes(key), Consent: consent, ClientHash: client,
+ Member: member, Model: model,
}
sum := sha256.Sum256([]byte(doc))
rep.YAMLSHA256 = hex.EncodeToString(sum[:])
@@ -195,7 +241,11 @@ func (a *API) upload(w http.ResponseWriter, r *http.Request) {
}
all := in.parts
if in.backup != nil {
- all = append([]part{{kind: "backup", name: "backup.bin", mime: "application/octet-stream", in: in.backup}}, all...)
+ name := "backup.bin"
+ if raw {
+ name = "flash.bin"
+ }
+ all = append([]part{{kind: "backup", name: name, mime: "application/octet-stream", in: in.backup}}, all...)
}
for _, p := range all {
f, err := prepare(p)
@@ -247,12 +297,30 @@ func (a *API) upload(w http.ResponseWriter, r *http.Request) {
"next": "OpenIPC's maintainers review each report before it is published. Nothing identifying the camera " +
"(MAC, die ID, cloud ID) is ever shown; the receipt shows the report's state.",
}
- if in.backup != nil {
+ if raw {
+ out["backup"] = map[string]any{"partitions": 0, "flash_bytes": in.backup.Bytes}
+ } else if in.backup != nil {
out["backup"] = map[string]any{"partitions": len(backup.Blocks), "flash_bytes": backup.Size()}
}
+ if member != "" {
+ out["receipt_url"] = clubURL(r)
+ }
writeJSON(w, http.StatusCreated, out)
}
+// flashImage: the size of a whole NOR or NAND chip, 1 to 256 MB.
+func flashImage(n int64) bool {
+ return n >= 1<<20 && n <= MaxBackup && n&(n-1) == 0
+}
+
+func clubURL(r *http.Request) string {
+ host := r.Host
+ if host == "" {
+ host = "openipc.org"
+ }
+ return "https://" + host + "/club/"
+}
+
func receiptURL(r *http.Request, id string) string {
host := r.Host
if host == "" {
@@ -315,7 +383,7 @@ func (a *API) read(r *http.Request) (*received, int, error) {
name := p.FormName()
// "note" is both a field (a line of text) and a file kind (a note
// file): a part with a filename is a file, one without is a field.
- isField := p.FileName() == "" && (name == "consent" || name == "channel" || name == "tool" || name == "note")
+ isField := p.FileName() == "" && (name == "consent" || name == "channel" || name == "tool" || name == "note" || name == "model")
switch {
case isField:
b, err := io.ReadAll(io.LimitReader(p, maxField+1))
@@ -354,10 +422,10 @@ func (a *API) read(r *http.Request) (*received, int, error) {
}
in.parts[len(in.parts)-1].mime = mt
default:
- return in, http.StatusBadRequest, fmt.Errorf("%q is not a part a report has: yaml, backup, photo, boot_log, uboot_env, note, document, consent, channel, tool", name)
+ return in, http.StatusBadRequest, fmt.Errorf("%q is not a part a report has: yaml, backup, photo, boot_log, uboot_env, note, document, consent, channel, tool, model", name)
}
}
- if in.yaml == "" && in.backup == nil {
+ if in.yaml == "" && in.backup == nil && in.fields["model"] == "" {
return in, http.StatusBadRequest, errors.New("a report needs ipctool's output: a yaml part, or a backup")
}
return in, 0, nil
@@ -464,14 +532,34 @@ func (a *API) file(w http.ResponseWriter, r *http.Request) {
a.fail(w, "the file", err)
return
}
+ a.send(w, r.PathValue("id"), sum, name, mt, kind, "public, max-age=86400")
+}
+
+// ServeStored is any file of a report -- a private backup too, as it was
+// sent -- for its sender or a maintainer (internal/club decides who). The
+// caller has checked; this only hands the file to nginx.
+func (a *API) ServeStored(w http.ResponseWriter, r *http.Request, id string, position int) {
+ sum, name, mt, kind, err := a.store().StoredFile(r.Context(), id, position)
+ if errors.Is(err, ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ a.fail(w, "the file", err)
+ return
+ }
+ a.send(w, id, sum, name, mt, kind, "private, no-store")
+}
+
+func (a *API) send(w http.ResponseWriter, id, sum, name, mt, kind, cache string) {
disposition := "inline"
if kind == "backup" || kind == "document" {
disposition = "attachment"
}
w.Header().Set("Content-Type", mt)
- w.Header().Set("Content-Disposition", disposition+`; filename="`+r.PathValue("id")+"-"+name+`"`)
+ w.Header().Set("Content-Disposition", disposition+`; filename="`+id+"-"+name+`"`)
w.Header().Set("X-Content-Type-Options", "nosniff")
- w.Header().Set("Cache-Control", "public, max-age=86400")
+ w.Header().Set("Cache-Control", cache)
w.Header().Set("X-Accel-Redirect", strings.TrimSuffix(a.AccelPrefix, "/")+"/"+Rel(sum))
w.WriteHeader(http.StatusOK)
}
diff --git a/service/internal/reports/store.go b/service/internal/reports/store.go
index d4f57ff7..37f83356 100644
--- a/service/internal/reports/store.go
+++ b/service/internal/reports/store.go
@@ -34,6 +34,10 @@ type Report struct {
Consent string
ClientHash string
Files []File
+ // Through the site's send form: the signed-in sender, and the board they
+ // said it is. Empty for ipctool's uploads.
+ Member string
+ Model string
}
// File is one file a report brought.
@@ -161,10 +165,30 @@ func (s *Store) Insert(ctx context.Context, r *Report, limit int, place func() e
}
r.Files[i].Position = i + 1
}
+ if r.Member != "" || r.Model != "" {
+ if _, err := tx.Exec(ctx, `INSERT INTO report_submissions (report_id, member_id, model_id) VALUES ($1, $2, $3)`,
+ r.ID, nullable(r.Member), nullable(r.Model)); err != nil {
+ return err
+ }
+ }
return nil
})
}
+// ModelExists says whether the catalogue has the board a sender named.
+func (s *Store) ModelExists(ctx context.Context, model string) (bool, error) {
+ var ok bool
+ err := s.DB.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM board_models WHERE id = $1)`, model).Scan(&ok)
+ return ok, err
+}
+
+func nullable(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
+
// RemoveUnreferenced deletes a stored file if, under its exclusive lock, no
// row names it any more. Takedown's candidates go through here one by one.
func (s *Store) RemoveUnreferenced(ctx context.Context, files *Files, sum string) (bool, error) {
diff --git a/service/routes.json b/service/routes.json
index 53028ce5..4d4fdfc1 100644
--- a/service/routes.json
+++ b/service/routes.json
@@ -134,6 +134,96 @@
"method": "POST",
"path": "/api/v1/boards/identify"
},
+ {
+ "role": "web",
+ "method": "GET",
+ "path": "/api/v1/club/me"
+ },
+ {
+ "role": "web",
+ "method": "POST",
+ "path": "/api/v1/club/logout"
+ },
+ {
+ "role": "web",
+ "method": "POST",
+ "path": "/api/v1/club/quiet"
+ },
+ {
+ "role": "web",
+ "method": "GET",
+ "path": "/api/v1/club/login"
+ },
+ {
+ "role": "web",
+ "method": "GET",
+ "path": "/api/v1/club/finish"
+ },
+ {
+ "role": "web",
+ "method": "GET",
+ "path": "/api/v1/club/finish/who"
+ },
+ {
+ "role": "web",
+ "method": "POST",
+ "path": "/api/v1/club/finish"
+ },
+ {
+ "role": "web",
+ "method": "POST",
+ "path": "/api/v1/club/name"
+ },
+ {
+ "role": "web",
+ "method": "POST",
+ "path": "/api/v1/club/telegram"
+ },
+ {
+ "role": "web",
+ "method": "POST",
+ "path": "/api/v1/club/telegram/webhook"
+ },
+ {
+ "role": "web",
+ "method": "POST",
+ "path": "/api/v1/club/email"
+ },
+ {
+ "role": "web",
+ "method": "GET",
+ "path": "/api/v1/club/github"
+ },
+ {
+ "role": "web",
+ "method": "GET",
+ "path": "/api/v1/club/github/callback"
+ },
+ {
+ "role": "web",
+ "method": "POST",
+ "path": "/api/v1/club/reports"
+ },
+ {
+ "role": "web",
+ "method": "GET",
+ "path": "/api/v1/club/reports"
+ },
+ {
+ "role": "web",
+ "method": "GET",
+ "path": "/api/v1/club/reports/{id}/files/{position}"
+ },
+ {
+ "role": "web",
+ "method": "GET",
+ "path": "/api/v1/club/review"
+ },
+ {
+ "role": "web",
+ "method": "POST",
+ "path": "/api/v1/club/review/{id}"
+ },
{
"role": "web",
"method": "PUT",