diff --git a/frontend/apps/site/src/components/club/NewCamera.test.tsx b/frontend/apps/site/src/components/club/NewCamera.test.tsx
new file mode 100644
index 00000000..4ce922dd
--- /dev/null
+++ b/frontend/apps/site/src/components/club/NewCamera.test.tsx
@@ -0,0 +1,109 @@
+// @vitest-environment jsdom
+/**
+ * A camera the catalogue does not have, both ends: the form posts what the
+ * service reads (reports.proposalOf -- channel web, maker, board, soc, the
+ * photos, no model), and the review page publishes it with the board the
+ * service suggested, as the reviewer corrected it.
+ */
+import { afterEach, expect, test, vi } from 'vitest';
+import { cleanup, fireEvent, render, waitFor } from '@testing-library/preact';
+import NewCameraForm from './NewCameraForm.tsx';
+import Review from './Review.tsx';
+import type { Queued } from '../../lib/club';
+
+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;
+}
+
+/** The field whose label reads label: exactly, else at its start. */
+function input(c: HTMLElement, label: string) {
+ const labels = [...c.querySelectorAll('label')];
+ const own = (l: HTMLLabelElement) => l.childNodes[0]?.textContent?.trim() ?? '';
+ const l = labels.find((x) => own(x) === label) ?? labels.find((x) => own(x).startsWith(label));
+ return l!.querySelector('input, textarea')!;
+}
+
+test('the form sends the maker, the marking and the photos, and no board id', async () => {
+ const sent = capture();
+ const { container, getByText } = render( );
+ fireEvent.input(input(container, 'Maker'), { target: { value: ' Jooan ' } });
+ fireEvent.input(input(container, 'Board marking'), { target: { value: 'Q9 v2' } });
+ fireEvent.input(input(container, 'SoC'), { target: { value: 'SSC335' } });
+ fireEvent.input(input(container, 'Boot log'), { target: { value: 'U-Boot 2015.01\n' } });
+ const photos = [new File(['a'], 'front.jpg', { type: 'image/jpeg' }), new File(['b'], 'back.jpg', { type: 'image/jpeg' })];
+ fireEvent.change(container.querySelector('input[type=file]')!, { target: { files: photos } });
+ 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('maker')).toBe('Jooan');
+ expect(f.get('board')).toBe('Q9 v2');
+ expect(f.get('soc')).toBe('SSC335');
+ expect(f.get('model')).toBeNull();
+ expect(f.get('yaml')).toBeNull();
+ expect(f.getAll('photo').map((p) => (p as File).name)).toEqual(['front.jpg', 'back.jpg']);
+ expect(await (f.get('boot_log') as File).text()).toBe('U-Boot 2015.01\n');
+});
+
+test('the form asks for the maker, the board and a photo before sending', () => {
+ const sent = capture();
+ const { container, getByRole } = render( );
+ fireEvent.input(input(container, 'Maker'), { target: { value: 'Jooan' } });
+ fireEvent.input(input(container, 'Board marking'), { target: { value: 'Q9' } });
+ fireEvent.submit(container.querySelector('form')!);
+ expect(getByRole('alert').textContent).toContain('at least one photo');
+ expect(sent).toHaveLength(0);
+});
+
+const queued = (over: Partial): Queued => ({
+ id: 'r-new23456', received_at: '2026-10-06T10:00:00Z', channel: 'web', status: 'pending', chip: '', sensor: '',
+ board: null, models: [], backup_consent: 'none', member: 'Ivan', file_list: [], potential: 2,
+ proposal: { maker: 'Jooan', board: 'Q9 v2', soc: 'SSC335' },
+ new_board: { maker_id: 'jooan', maker_name: 'Jooan', model_id: 'jooan-q9-v2', model: 'Q9 v2', soc: 'SSC335', maker_known: false },
+ ...over,
+});
+
+function review(q: Queued) {
+ const calls: { url: string; body: unknown }[] = [];
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
+ if (url.startsWith('/api/v1/club/review?')) return new Response(JSON.stringify({ reports: [q] }));
+ if (url.startsWith('/api/v1/club/review/')) {
+ calls.push({ url, body: JSON.parse(String(init?.body)) });
+ return new Response(JSON.stringify({ points: 2, total: 2, board: 'jooan-q9' }));
+ }
+ return new Response(JSON.stringify({ member: null }));
+ }));
+ return calls;
+}
+
+test('the reviewer publishes a proposal as the board they corrected', async () => {
+ const calls = review(queued({}));
+ const { container, findByText, getByText, getByRole } = render( );
+ expect(await findByText('Jooan Q9 v2')).toBeTruthy();
+ expect(getByText('Jooan · Q9 v2 · SSC335')).toBeTruthy();
+ expect(getByText(/a new maker/)).toBeTruthy();
+ fireEvent.input(input(container, 'Board id'), { target: { value: 'jooan-q9' } });
+ fireEvent.click(getByRole('button', { name: /^Publish ·/ }));
+ await waitFor(() => expect(calls).toHaveLength(1));
+ expect(calls[0].body).toEqual({
+ decision: 'publish', models: [], note: '',
+ new_board: { maker_id: 'jooan', maker_name: 'Jooan', model_id: 'jooan-q9', model: 'Q9 v2', soc: 'SSC335' },
+ });
+ expect(await findByText(/jooan-q9 added to the catalogue/)).toBeTruthy();
+});
+
+test('a proposal the catalogue already has is published on that board, not a twin', async () => {
+ const calls = review(queued({ new_board: { maker_id: 'jooan', maker_name: 'Jooan', model_id: 'jooan-q9-v2', model: 'Q9 v2', maker_known: true, existing: 'jooan-q9-v2' } }));
+ const { findByText, getByRole } = render( );
+ expect(await findByText(/already has jooan-q9-v2/)).toBeTruthy();
+ fireEvent.click(getByRole('button', { name: /^Publish ·/ }));
+ await waitFor(() => expect(calls).toHaveLength(1));
+ expect(calls[0].body).toEqual({ decision: 'publish', models: ['jooan-q9-v2'], note: '' });
+});
diff --git a/frontend/apps/site/src/components/club/NewCameraForm.tsx b/frontend/apps/site/src/components/club/NewCameraForm.tsx
new file mode 100644
index 00000000..7fe1a1f2
--- /dev/null
+++ b/frontend/apps/site/src/components/club/NewCameraForm.tsx
@@ -0,0 +1,118 @@
+/**
+ * A camera the catalogue does not have: its maker, its board's marking and
+ * photos of it are a report (POST /api/v1/club/reports, channel web, maker,
+ * board and soc instead of a board's model). ipctool's output and a boot log
+ * help the reviewer and are optional. A maintainer who publishes the report
+ * adds the board to the catalogue, and the photos become its first unit.
+ *
+ * Signing in is not required, as with SendForm; stars are a member's.
+ */
+import { useEffect, useState } from 'preact/hooks';
+import { useBoardsTranslations } from '../../lib/boards-i18n';
+import { pathFor, type Locale } from '../../lib/i18n';
+import { STARS, fetchMe, remembered, sendReport, type Member, type Sent } from '../../lib/club';
+
+const field = 'rounded-md border border-hairline px-2.5 py-1.5 text-sm text-body';
+const area = 'min-h-[72px] w-full resize-y rounded-md border border-hairline bg-surface-alt p-2 font-mono text-[12.5px] leading-snug text-body';
+
+export default function NewCameraForm({ locale }: { locale: Locale }) {
+ const t = useBoardsTranslations(locale);
+ const [maker, setMaker] = useState('');
+ const [board, setBoard] = useState('');
+ const [soc, setSoc] = useState('');
+ const [photos, setPhotos] = useState([]);
+ const [ipctool, setIpctool] = useState('');
+ const [bootLog, setBootLog] = useState('');
+ const [note, setNote] = useState('');
+ 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 submit = (e: Event) => {
+ e.preventDefault();
+ if (!maker.trim() || !board.trim() || photos.length === 0) {
+ setState({ s: 'error', error: t('club.new_need') });
+ return;
+ }
+ const form = new FormData();
+ form.set('channel', 'web');
+ form.set('maker', maker.trim());
+ form.set('board', board.trim());
+ if (soc.trim()) form.set('soc', soc.trim());
+ if (note.trim()) form.set('note', note.trim());
+ if (ipctool.trim()) form.set('yaml', ipctool);
+ if (bootLog.trim()) form.append('boot_log', new Blob([bootLog], { type: 'text/plain' }), 'boot_log.txt');
+ for (const f of photos) form.append('photo', f, f.name);
+ 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')} }
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/apps/site/src/components/club/Review.tsx b/frontend/apps/site/src/components/club/Review.tsx
index ef816057..216f62bc 100644
--- a/frontend/apps/site/src/components/club/Review.tsx
+++ b/frontend/apps/site/src/components/club/Review.tsx
@@ -3,14 +3,16 @@
* 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.
+ * and tells the sender; rejecting takes back anything it had earned. A
+ * report about a camera the catalogue does not have shows the board
+ * publishing would add, for the reviewer to correct; publishing adds it.
*
* 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 { ClubError, decide, fetchMe, fetchQueue, type NewBoard, type Queued } from '../../lib/club';
import { size } from '../../lib/reports';
import { STATUS_TONE } from './parts';
@@ -60,8 +62,15 @@ export default function Review({ locale }: { locale: Locale }) {
);
}
+const NEW_FIELDS: (keyof NewBoard)[] = ['maker_name', 'maker_id', 'model', 'model_id', 'soc'];
+
function Item({ q, locale, t, onDone }: { q: Queued; locale: Locale; t: BoardsT; onDone: () => void }) {
- const [models, setModels] = useState(q.models.join(', '));
+ const sug = q.new_board;
+ const [models, setModels] = useState(q.models.length ? q.models.join(', ') : sug?.existing ?? '');
+ const [create, setCreate] = useState(!!sug && !sug.existing && q.status !== 'published');
+ const [board, setBoard] = useState(sug
+ ? { maker_id: sug.maker_id, maker_name: sug.maker_name, model_id: sug.model_id, model: sug.model, soc: sug.soc ?? '' }
+ : null);
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
@@ -72,8 +81,11 @@ function Item({ q, locale, t, onDone }: { q: Queued; locale: Locale; t: BoardsT;
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); })
+ decide(q.id, decision, ids, note, decision === 'publish' && create && board ? board : undefined)
+ .then((r) => {
+ setResult(r.board ? t('club.review_done_board', { board: r.board, points: r.points }) : t(`club.review_done_${decision}`, { points: r.points }));
+ setTimeout(onDone, 1200);
+ })
.catch((e: Error) => setError(e.message))
.finally(() => setBusy(false));
};
@@ -82,7 +94,7 @@ function Item({ q, locale, t, onDone }: { q: Queued; locale: Locale; t: BoardsT;
- {q.board ? `${q.board.manufacturer} ${q.board.model}` : (q.chip || q.id)}
+ {q.board ? `${q.board.manufacturer} ${q.board.model}` : q.proposal ? `${q.proposal.maker} ${q.proposal.board}` : (q.chip || q.id)}
{q.id} · {q.channel}
@@ -95,6 +107,8 @@ function Item({ q, locale, t, onDone }: { q: Queued; locale: Locale; t: BoardsT;
{q.member ? t('club.review_from', { who: q.member }) : t('club.review_anon')}
{q.board && <>{t('club.review_board')}
{q.board.id} >}
+ {q.proposal && <>{t('club.review_proposal')}
+ {[q.proposal.maker, q.proposal.board, q.proposal.soc].filter(Boolean).join(' · ')} >}
{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} >}
@@ -116,6 +130,30 @@ function Item({ q, locale, t, onDone }: { q: Queued; locale: Locale; t: BoardsT;
)}
+ {sug?.existing && q.status !== 'published' && (
+
{t('club.review_existing', { board: sug.existing })}
+ )}
+ {board && q.status !== 'published' && (
+
+
+
+ setCreate((e.target as HTMLInputElement).checked)} />
+ {t('club.review_new_board')}
+ {sug && · {t(sug.maker_known ? 'club.review_maker_known' : 'club.review_maker_new')} }
+
+
+ {create && (
+
+ {NEW_FIELDS.map((k) => (
+ {t(`club.review_new_${k}`)}
+ setBoard({ ...board, [k]: (e.target as HTMLInputElement).value })}
+ class={`rounded-md border border-hairline px-2.5 py-1.5 text-sm text-body ${k.endsWith('_id') ? 'font-mono' : ''}`} />
+
+ ))}
+
+ )}
+
+ )}
{t('club.review_models')}
setModels((e.target as HTMLInputElement).value)} placeholder={q.board?.id ?? ''}
class="rounded-md border border-hairline px-2.5 py-1.5 font-mono text-sm text-body" />
diff --git a/frontend/apps/site/src/components/pages/Report.astro b/frontend/apps/site/src/components/pages/Report.astro
index 9bf7eaef..60a289d1 100644
--- a/frontend/apps/site/src/components/pages/Report.astro
+++ b/frontend/apps/site/src/components/pages/Report.astro
@@ -15,10 +15,15 @@
* freezes a connection to the origin after ~20 KB, and the mirror's port 80
* proxies ipctool and the report (deploy/nginx/mirrors/ru.openipc.snippet).
* NFS is not mirrored, so there it is not offered.
+ *
+ * #new is the way without a shell: the camera's maker, its marking and
+ * photos of it, sent from the browser (club/NewCameraForm). The catalogue's
+ * empty searches and /club link here.
*/
import Terminal from '../Terminal.astro';
import UgetCopy from '../reports/UgetCopy.tsx';
import Receipt from '../reports/Receipt.tsx';
+import NewCameraForm from '../club/NewCameraForm.tsx';
import { useTranslations, type Locale } from '../../lib/i18n';
interface Props { locale: Locale }
@@ -39,6 +44,11 @@ const flags: [string, string][] = [
+
+
{p('shell_title')}
diff --git a/frontend/apps/site/src/i18n/boards.en.json b/frontend/apps/site/src/i18n/boards.en.json
index 063edf4c..6f2bde1c 100644
--- a/frontend/apps/site/src/i18n/boards.en.json
+++ b/frontend/apps/site/src/i18n/boards.en.json
@@ -96,10 +96,23 @@
"mine_title": "My submissions",
"mute": "Mute the bot",
"my_link": "My submissions",
+ "new_board": "Board marking or model",
+ "new_board_hint": "What is printed on the board, or the model on the box",
+ "new_boot_log": "Boot log or U-Boot console",
+ "new_ipctool": "ipctool's output",
+ "new_lede": "Name the maker and the board, and add photos of it: the board's front and back, and the label. A maintainer adds it to the catalogue when they publish your report; each photo and file earns {n} ★.",
+ "new_link": "Add a camera that is not in the catalogue",
+ "new_maker": "Maker",
+ "new_maker_hint": "Xiongmai, Jooan, Hikvision…",
+ "new_more": "Have ipctool's output or a boot log? Add them too",
+ "new_need": "Name the maker and the board, and add at least one photo.",
+ "new_photos": "Photos (at least one)",
+ "new_soc": "SoC, if you know it",
"no_board": "No board named",
"or": "or",
"pending": "+{n} waiting for review",
"private": "Only you and maintainers",
+ "proposed": "New: {board}",
"provider_email": "Email",
"provider_github": "GitHub",
"provider_telegram": "Telegram",
@@ -111,17 +124,28 @@
"rename_save": "Save",
"review_anon": "Sent without signing in",
"review_board": "Board named by the sender",
+ "review_done_board": "Published, and {board} added to the catalogue. {points} ★ to the sender.",
"review_done_publish": "Published. {points} ★ to the sender.",
"review_done_reject": "Rejected.",
"review_empty": "Nothing is waiting.",
+ "review_existing": "The catalogue already has {board}: the report is published there unless you change the board ids below.",
"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_maker_known": "its maker is in the catalogue",
+ "review_maker_new": "a new maker",
"review_models": "Board ids to publish it on, comma-separated (empty: the board the sender named)",
+ "review_new_board": "Add this board to the catalogue when publishing",
+ "review_new_maker_id": "Maker id",
+ "review_new_maker_name": "Maker",
+ "review_new_model": "Marking, as printed",
+ "review_new_model_id": "Board id",
+ "review_new_soc": "SoC",
"review_note": "Note for the sender",
"review_note_label": "Reviewer",
"review_points": "{points} ★",
+ "review_proposal": "Proposed by the sender",
"review_title": "Review queue",
"reward": "+{n} ★",
"rules_dump": "Full flash dump the catalogue doesn't have",
@@ -354,6 +378,8 @@
"missing_photo": "no photo of its own",
"missing_pinout": "no pinout",
"missing_uboot_env": "no U-Boot console",
+ "new_add": "Add your camera",
+ "new_missing": "Not in the catalogue?",
"new_tab": "opens in a new tab",
"no_photos": "No photos yet",
"original": "Original",
diff --git a/frontend/apps/site/src/i18n/boards.ru.json b/frontend/apps/site/src/i18n/boards.ru.json
index f89305f4..58af13a5 100644
--- a/frontend/apps/site/src/i18n/boards.ru.json
+++ b/frontend/apps/site/src/i18n/boards.ru.json
@@ -98,10 +98,23 @@
"mine_title": "Мои материалы",
"mute": "Отключить бота",
"my_link": "Мои материалы",
+ "new_board": "Маркировка платы или модель",
+ "new_board_hint": "Что напечатано на плате или модель на коробке",
+ "new_boot_log": "Лог загрузки или консоль U-Boot",
+ "new_ipctool": "Вывод ipctool",
+ "new_lede": "Укажите производителя и плату и приложите фото: плату с обеих сторон и наклейку. Мейнтейнер добавит её в каталог, когда опубликует ваш отчёт; каждое фото и файл приносят {n} ★.",
+ "new_link": "Добавить камеру, которой нет в каталоге",
+ "new_maker": "Производитель",
+ "new_maker_hint": "Xiongmai, Jooan, Hikvision…",
+ "new_more": "Есть вывод ipctool или лог загрузки? Добавьте и их",
+ "new_need": "Укажите производителя и плату и приложите хотя бы одно фото.",
+ "new_photos": "Фото (хотя бы одно)",
+ "new_soc": "SoC, если знаете",
"no_board": "Плата не указана",
"or": "или",
"pending": "+{n} ждут проверки",
"private": "Только вам и мейнтейнерам",
+ "proposed": "Новая: {board}",
"provider_email": "Почта",
"provider_github": "GitHub",
"provider_telegram": "Telegram",
@@ -113,17 +126,28 @@
"rename_save": "Сохранить",
"review_anon": "Прислано без входа",
"review_board": "Плата, указанная отправителем",
+ "review_done_board": "Опубликовано, {board} добавлена в каталог. Отправителю {points} ★.",
"review_done_publish": "Опубликовано. Отправителю {points} ★.",
"review_done_reject": "Отклонено.",
"review_empty": "Ничего не ждёт проверки.",
+ "review_existing": "В каталоге уже есть {board}: отчёт будет опубликован там, если не поменять идентификаторы плат ниже.",
"review_forbidden": "Проверяют только мейнтейнеры OpenIPC. Войдите через GitHub как участник организации OpenIPC.",
"review_from": "От {who}",
"review_guess": "По данным ipctool похоже на {board}",
"review_link": "Очередь проверки",
+ "review_maker_known": "производитель есть в каталоге",
+ "review_maker_new": "новый производитель",
"review_models": "Id плат для публикации через запятую (пусто — плата, указанная отправителем)",
+ "review_new_board": "Добавить эту плату в каталог при публикации",
+ "review_new_maker_id": "Идентификатор производителя",
+ "review_new_maker_name": "Производитель",
+ "review_new_model": "Маркировка, как напечатана",
+ "review_new_model_id": "Идентификатор платы",
+ "review_new_soc": "SoC",
"review_note": "Пояснение для отправителя",
"review_note_label": "Проверяющий",
"review_points": "{points} ★",
+ "review_proposal": "Предложено отправителем",
"review_title": "Очередь проверки",
"reward": "+{n} ★",
"rules_dump": "Полный дамп флеш-памяти, которого нет в каталоге",
@@ -376,6 +400,8 @@
"missing_photo": "нет собственного фото",
"missing_pinout": "нет распиновки",
"missing_uboot_env": "нет консоли U-Boot",
+ "new_add": "Добавьте свою камеру",
+ "new_missing": "Нет в каталоге?",
"new_tab": "откроется в новой вкладке",
"no_photos": "Фотографий пока нет",
"original": "Оригинал",
diff --git a/frontend/apps/site/src/i18n/boards.zh.json b/frontend/apps/site/src/i18n/boards.zh.json
index a8cbb2e9..6b1576f3 100644
--- a/frontend/apps/site/src/i18n/boards.zh.json
+++ b/frontend/apps/site/src/i18n/boards.zh.json
@@ -96,10 +96,23 @@
"mine_title": "我的提交",
"mute": "静音机器人",
"my_link": "我的提交",
+ "new_board": "电路板丝印或型号",
+ "new_board_hint": "电路板上印的文字,或包装盒上的型号",
+ "new_boot_log": "启动日志或 U-Boot 控制台",
+ "new_ipctool": "ipctool 输出",
+ "new_lede": "填写厂商和电路板,并附上照片:电路板正反面和标签。维护者发布你的报告时会把它加入目录;每张照片和每个文件可获得 {n} ★。",
+ "new_link": "添加目录中没有的摄像头",
+ "new_maker": "厂商",
+ "new_maker_hint": "Xiongmai、Jooan、Hikvision…",
+ "new_more": "有 ipctool 输出或启动日志?也一起添加",
+ "new_need": "请填写厂商和电路板,并至少添加一张照片。",
+ "new_photos": "照片(至少一张)",
+ "new_soc": "SoC(如果知道)",
"no_board": "未指定电路板",
"or": "或",
"pending": "+{n} 等待审核",
"private": "仅你和维护者可见",
+ "proposed": "新:{board}",
"provider_email": "邮箱",
"provider_github": "GitHub",
"provider_telegram": "Telegram",
@@ -111,17 +124,28 @@
"rename_save": "保存",
"review_anon": "未登录提交",
"review_board": "提交者指定的电路板",
+ "review_done_board": "已发布,{board} 已加入目录。发送者获得 {points} ★。",
"review_done_publish": "已发布。提交者获得 {points} ★。",
"review_done_reject": "已拒绝。",
"review_empty": "没有待审核的内容。",
+ "review_existing": "目录中已有 {board}:除非修改下方的电路板 ID,报告将发布在那里。",
"review_forbidden": "只有 OpenIPC 维护者可以审核。请以 OpenIPC 组织成员身份使用 GitHub 登录。",
"review_from": "来自 {who}",
"review_guess": "ipctool 显示类似 {board}",
"review_link": "审核队列",
+ "review_maker_known": "厂商已在目录中",
+ "review_maker_new": "新厂商",
"review_models": "发布到的电路板 id,用逗号分隔(留空:提交者指定的电路板)",
+ "review_new_board": "发布时将此电路板加入目录",
+ "review_new_maker_id": "厂商 ID",
+ "review_new_maker_name": "厂商",
+ "review_new_model": "丝印(按原样)",
+ "review_new_model_id": "电路板 ID",
+ "review_new_soc": "SoC",
"review_note": "给提交者的说明",
"review_note_label": "审核者",
"review_points": "{points} ★",
+ "review_proposal": "发送者提议",
"review_title": "审核队列",
"reward": "+{n} ★",
"rules_dump": "目录中没有的完整闪存转储",
@@ -354,6 +378,8 @@
"missing_photo": "缺本板照片",
"missing_pinout": "缺引脚图",
"missing_uboot_env": "缺 U-Boot 控制台",
+ "new_add": "添加你的摄像头",
+ "new_missing": "目录中没有?",
"new_tab": "在新标签页中打开",
"no_photos": "暂无照片",
"original": "原文",
diff --git a/frontend/apps/site/src/i18n/en.json b/frontend/apps/site/src/i18n/en.json
index 013f8a4e..bd90991b 100644
--- a/frontend/apps/site/src/i18n/en.json
+++ b/frontend/apps/site/src/i18n/en.json
@@ -660,6 +660,7 @@
"flag_public": "the backup is published with the report, once reviewed",
"flag_upload": "the hardware report: SoC, sensor, flash layout, firmware versions",
"lede": "A camera nobody has reported yet reaches the catalogue from one report: what ipctool reads off the board and, if you want to help port OpenIPC to it, the flash it came with.",
+ "new_title": "No shell on the camera? Send its photos",
"nfs_hint_html": "Other ways in, over UART, TFTP or an SD card: ipctool's README .",
"nfs_title": "The firmware has an NFS client (most Xiongmai do)",
"shell_lede_html": "Telnet into the stock firmware, or a UART console. Stock firmware has no curl, so ipctool comes from this site over plain HTTP, fetched by uget : a 5 KB downloader you paste in as text.",
diff --git a/frontend/apps/site/src/i18n/ru.json b/frontend/apps/site/src/i18n/ru.json
index 2869d3d8..f22f2129 100644
--- a/frontend/apps/site/src/i18n/ru.json
+++ b/frontend/apps/site/src/i18n/ru.json
@@ -666,6 +666,7 @@
"flag_public": "бэкап публикуется вместе с отчётом после проверки",
"flag_upload": "отчёт о железе: SoC, сенсор, разметка флеша, версии прошивки",
"lede": "Камера, о которой ещё никто не сообщил, попадает в каталог по одному отчёту: что ipctool прочитал с платы, а если хотите помочь с портированием OpenIPC — ещё и заводская прошивка с флеша.",
+ "new_title": "Нет доступа к консоли камеры? Пришлите фото",
"nfs_hint_html": "Другие способы — через UART, TFTP или SD-карту: README ipctool .",
"nfs_title": "В прошивке есть NFS-клиент (у большинства Xiongmai есть)",
"shell_lede_html": "Telnet в заводскую прошивку или консоль UART. В заводских прошивках нет curl, поэтому ipctool скачивается с этого сайта по обычному HTTP утилитой uget : это загрузчик на 5 КБ, который вставляется в сессию как текст.",
diff --git a/frontend/apps/site/src/i18n/zh.json b/frontend/apps/site/src/i18n/zh.json
index 2bffcea6..05c96a5e 100644
--- a/frontend/apps/site/src/i18n/zh.json
+++ b/frontend/apps/site/src/i18n/zh.json
@@ -660,6 +660,7 @@
"flag_public": "审核后,备份随报告一起公开",
"flag_upload": "硬件报告:SoC、传感器、闪存分区、固件版本",
"lede": "一台还没人报告过的摄像头,只需一份报告就能进入目录:ipctool 从板子上读到的信息;如果您愿意帮助移植 OpenIPC,再附上出厂闪存。",
+ "new_title": "无法进入摄像头的命令行?发送照片",
"nfs_hint_html": "其他途径(UART、TFTP 或 SD 卡):见 ipctool 的 README 。",
"nfs_title": "固件带 NFS 客户端(大多数雄迈固件都有)",
"shell_lede_html": "通过 telnet 进入原厂固件,或使用 UART 控制台。原厂固件没有 curl,所以 ipctool 由 uget 通过普通 HTTP 从本站下载:uget 是一个 5 KB 的下载器,以文本形式粘贴进去即可。",
diff --git a/frontend/apps/site/src/lib/club.ts b/frontend/apps/site/src/lib/club.ts
index ee6bff0f..0a863c2a 100644
--- a/frontend/apps/site/src/lib/club.ts
+++ b/frontend/apps/site/src/lib/club.ts
@@ -42,6 +42,19 @@ export interface MemberFile {
export interface BoardRef { id: string; model: string; manufacturer: string }
+/** A camera the catalogue does not have, as its sender named it. */
+export interface Proposal { maker: string; board: string; soc?: string }
+
+/** The board publishing a proposal adds (service boards.NewModel). */
+export interface NewBoard { maker_id: string; maker_name: string; model_id: string; model: string; soc?: string }
+
+/** What the review page offers for a proposal (boards.Suggestion). */
+export interface Suggestion extends NewBoard {
+ maker_known: boolean;
+ /** A board that already answers to the marking: link to it instead. */
+ existing?: string;
+}
+
export interface MemberReport {
id: string;
received_at: string;
@@ -50,6 +63,7 @@ export interface MemberReport {
note?: string;
review_note?: string;
board?: BoardRef;
+ proposal?: Proposal;
chip?: string;
files: MemberFile[];
stars: number;
@@ -74,6 +88,8 @@ export interface Queued {
guess?: { model_id: string; model: string; manufacturer: string };
file_list: MemberFile[];
potential: number;
+ proposal?: Proposal;
+ new_board?: Suggestion;
}
const BASE = '/api/v1/club';
@@ -161,8 +177,9 @@ export const fetchMine = () => call<{ member: Member; reports: MemberReport[] }>
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 });
+export const decide = (id: string, decision: 'publish' | 'reject', models: string[], note: string, newBoard?: NewBoard) =>
+ post<{ points: number; total: number; board?: string }>(`/review/${encodeURIComponent(id)}`,
+ newBoard ? { decision, models, note, new_board: newBoard } : { 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 }[] }
diff --git a/service/cmd/openipc/main.go b/service/cmd/openipc/main.go
index e7a99a3b..ad957a82 100644
--- a/service/cmd/openipc/main.go
+++ b/service/cmd/openipc/main.go
@@ -368,6 +368,13 @@ func web(ctx context.Context, cfg *config.Config, log *slog.Logger, pool *pgxpoo
// review, and kept apart from everything the board importers touch.
ownerReports := &reports.API{DB: pool, Files: &reports.Files{Root: cfg.ReportsRoot},
AccelPrefix: cfg.ReportsAccelPrefix, Log: log}
+ // A board a review adds keeps its SoC as written; with the catalogue it
+ // is also filed under the SoC's slug, as the importers file theirs.
+ if cat, err := catalogue.Load(cfg.CatalogueDir); err == nil {
+ ownerReports.SoC = socResolver(cat)
+ } else {
+ log.Warn("reports: no catalogue; a board a review adds keeps only its SoC's label", "err", err)
+ }
// The OpenIPC Club (internal/club): signing in, the send form, members'
// own reports and cameras, and the maintainers' review. Each way in only
// when set. Built before the variant workers start, because a published
diff --git a/service/cmd/openipc/reports.go b/service/cmd/openipc/reports.go
index 8187852b..cf6d7376 100644
--- a/service/cmd/openipc/reports.go
+++ b/service/cmd/openipc/reports.go
@@ -10,6 +10,8 @@ import (
"os"
"os/user"
+ "github.com/OpenIPC/website/service/internal/boards"
+ "github.com/OpenIPC/website/service/internal/catalogue"
"github.com/OpenIPC/website/service/internal/config"
"github.com/OpenIPC/website/service/internal/reports"
)
@@ -17,7 +19,8 @@ import (
const reportsUsage = `usage: openipc reports
list [--status pending|published|rejected|withdrawn] the review queue, newest first (JSON)
show the report whole, as sent: YAML, identifiers' hashes, files
- publish [--model ]... [--by who] [--note text]
+ publish [--model ]... [--new-board] [--by who] [--note text]
+ --new-board: add the camera the report proposed, as the review page suggests
reject [--by who] [--note text]
link [--by who] say which board a report is from
unlink undo a link made in error
@@ -45,6 +48,7 @@ func reportsCommand(ctx context.Context, cfg *config.Config, log *slog.Logger, a
status := fs.String("status", "", "list only reports in this state")
var models multi
fs.Var(&models, "model", "the board model id the report is from (repeatable)")
+ newBoard := fs.Bool("new-board", false, "add the camera the report proposed to the catalogue, and link the report to it")
cmd := args[0]
pos, err := parseInterleaved(fs, args[1:])
if err != nil {
@@ -97,13 +101,19 @@ func reportsCommand(ctx context.Context, cfg *config.Config, log *slog.Logger, a
}
// 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)
+ var nb *boards.NewModel
+ if *newBoard {
+ if nb, err = suggestedBoard(ctx, cfg, st, pos[0]); err != nil {
+ return err
+ }
+ }
+ d, err := st.Decide(ctx, pos[0], cmd, *by, *note, models, nb)
if err != nil {
return err
}
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)
+ "new_board", d.Board, "member", d.Member, "stars", d.Points)
return nil
case "link":
if err := need(2); err != nil {
@@ -152,6 +162,39 @@ func reportsCommand(ctx context.Context, cfg *config.Config, log *slog.Logger, a
return errors.New(reportsUsage)
}
+// suggestedBoard is the board the review page would add for a report's
+// proposal, with the catalogue to resolve its SoC.
+func suggestedBoard(ctx context.Context, cfg *config.Config, st *reports.Store, id string) (*boards.NewModel, error) {
+ p, err := st.ProposalOf(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if p == nil {
+ return nil, fmt.Errorf("%s proposes no new camera; link it with --model", id)
+ }
+ sug, err := boards.Suggest(ctx, st.DB, p.Maker, p.Board, p.SoC)
+ if err != nil {
+ return nil, err
+ }
+ if sug.Existing != "" {
+ return nil, fmt.Errorf("the catalogue already has %s; publish with --model %s", sug.Existing, sug.Existing)
+ }
+ if cat, err := catalogue.Load(cfg.CatalogueDir); err == nil {
+ st.SoC = socResolver(cat)
+ }
+ return &sug.NewModel, nil
+}
+
+// socResolver maps a SoC to the catalogue's slug, as the importers do.
+func socResolver(cat *catalogue.Catalogue) func(string) string {
+ return func(label string) string {
+ if s := cat.SoC(label); s != nil {
+ return s.URLName
+ }
+ return ""
+ }
+}
+
// parseInterleaved lets flags follow the positional arguments
// (`publish r-abc --model x`), which flag.Parse alone stops at.
func parseInterleaved(fs *flag.FlagSet, args []string) ([]string, error) {
diff --git a/service/deploytest/reports_test.go b/service/deploytest/reports_test.go
index 46498793..fb937d10 100644
--- a/service/deploytest/reports_test.go
+++ b/service/deploytest/reports_test.go
@@ -17,8 +17,8 @@ import (
// names the reports' tables in SQL, stands the guard down, or touches their
// files' root is an error here, in Go, SQL and shell alike. Tests may.
func TestOnlyTheReportsPackageTouchesReports(t *testing.T) {
- sql := regexp.MustCompile(`(?i)\b(from|into|update|join|truncate|table|references)\s+(only\s+)?(reports|report_files|report_reviews|report_models|report_key)\b`)
- named := regexp.MustCompile(`\b(report_files|report_reviews|report_models|report_key|reports_guard)\b`)
+ sql := regexp.MustCompile(`(?i)\b(from|into|update|join|truncate|table|references)\s+(only\s+)?(reports|report_files|report_reviews|report_models|report_key|report_proposals)\b`)
+ named := regexp.MustCompile(`\b(report_files|report_reviews|report_models|report_key|report_proposals|reports_guard)\b`)
root := regexp.MustCompile(`REPORTS_ROOT|owner-reports`)
migration := regexp.MustCompile(`^service/internal/db/migrations/\d+_reports?(_[a-z_]+)?\.sql$`)
diff --git a/service/internal/boards/newmodel.go b/service/internal/boards/newmodel.go
new file mode 100644
index 00000000..7cc7ab22
--- /dev/null
+++ b/service/internal/boards/newmodel.go
@@ -0,0 +1,141 @@
+package boards
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// A board the catalogue did not have, added by a maintainer publishing an
+// owner's report about it (reports.Store.Decide). The importers make every
+// other model; this one is made from what the sender proposed and the
+// reviewer confirmed, in the decision's transaction, and answers to its
+// marking in the alias index (source "club") so that an archive imported
+// later finds it instead of adding a twin.
+
+// NewModel is a board to add.
+type NewModel struct {
+ MakerID string `json:"maker_id"`
+ MakerName string `json:"maker_name"`
+ ModelID string `json:"model_id"`
+ // Model is the marking as printed: the PCB code or the product name.
+ Model string `json:"model"`
+ // SoC as the sender or the reviewer wrote it; kept as soc_label, and as
+ // the catalogue's slug when it names a SoC the catalogue has.
+ SoC string `json:"soc,omitempty"`
+}
+
+// Suggestion is what the review page offers for a proposal: the board to
+// add, whether its maker is one the catalogue has, and the model that
+// already answers to its marking, if one does -- then the report is linked
+// to that one rather than a new board made.
+type Suggestion struct {
+ NewModel
+ MakerKnown bool `json:"maker_known"`
+ Existing string `json:"existing,omitempty"`
+}
+
+var (
+ makerID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
+ modelID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`)
+)
+
+type queryer interface {
+ QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
+}
+
+// Suggest turns a sender's maker, marking and SoC into the board a reviewer
+// would add: the catalogue's own maker when the name or one of its aliases
+// matches, ids made the way the importers make them.
+func Suggest(ctx context.Context, q queryer, maker, model, soc string) (Suggestion, error) {
+ s := Suggestion{NewModel: NewModel{MakerID: slug(maker), MakerName: strings.TrimSpace(maker),
+ Model: strings.TrimSpace(model), SoC: strings.TrimSpace(soc)}}
+ err := q.QueryRow(ctx, `
+ SELECT id, name FROM board_manufacturers
+ WHERE id = $1 OR lower(name) = lower($2) OR lower($2) = ANY (SELECT lower(a) FROM unnest(aliases) a)
+ ORDER BY id = $1 DESC, position, id LIMIT 1`, s.MakerID, s.MakerName).Scan(&s.MakerID, &s.MakerName)
+ switch {
+ case err == nil:
+ s.MakerKnown = true
+ case !errors.Is(err, pgx.ErrNoRows):
+ return s, err
+ }
+ s.ModelID = slug(s.MakerID + "-" + s.Model)
+ err = q.QueryRow(ctx, `
+ SELECT model_id FROM board_model_aliases WHERE maker_id = $1 AND code_norm = $2
+ UNION ALL SELECT id FROM board_models WHERE id = $3
+ LIMIT 1`, s.MakerID, NormCode(s.Model), s.ModelID).Scan(&s.Existing)
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return s, err
+ }
+ return s, nil
+}
+
+// CreateModel adds the board, and its maker when the catalogue has none by
+// that id, inside the caller's transaction. It refuses a model id that is
+// taken and a marking its maker already answers to: the report belongs on
+// that board, not on a twin of it. resolve maps a SoC to the catalogue's
+// slug ("" when it has none), as Importer.Resolve does.
+func CreateModel(ctx context.Context, tx pgx.Tx, m NewModel, resolve func(string) string) (string, error) {
+ m.MakerName, m.Model, m.SoC = strings.TrimSpace(m.MakerName), strings.TrimSpace(m.Model), strings.TrimSpace(m.SoC)
+ switch {
+ case !makerID.MatchString(m.MakerID):
+ return "", fmt.Errorf("new board: maker id %q is lower-case letters, digits and hyphens", m.MakerID)
+ case !modelID.MatchString(m.ModelID):
+ return "", fmt.Errorf("new board: model id %q is lower-case letters, digits and hyphens", m.ModelID)
+ case m.MakerName == "" || utf8.RuneCountInString(m.MakerName) > 80:
+ return "", errors.New("new board: the maker's name is 1 to 80 characters")
+ case m.Model == "" || utf8.RuneCountInString(m.Model) > 80:
+ return "", errors.New("new board: the marking is 1 to 80 characters")
+ case utf8.RuneCountInString(m.SoC) > 40:
+ return "", errors.New("new board: the SoC is at most 40 characters")
+ }
+ code := NormCode(m.Model)
+ if !codeShape.MatchString(code) {
+ return "", fmt.Errorf("new board: the marking %q has no letters or digits the catalogue can key it by", m.Model)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO board_manufacturers (id, name, position)
+ VALUES ($1, $2, (SELECT coalesce(max(position), 0) + 1 FROM board_manufacturers)) ON CONFLICT (id) DO NOTHING`,
+ m.MakerID, m.MakerName); err != nil {
+ return "", err
+ }
+ var taken string
+ err := tx.QueryRow(ctx, `
+ SELECT model_id FROM board_model_aliases WHERE maker_id = $1 AND code_norm = $2
+ UNION ALL SELECT id FROM board_models WHERE id = $3
+ LIMIT 1`, m.MakerID, code, m.ModelID).Scan(&taken)
+ if err == nil {
+ return "", fmt.Errorf("new board: the catalogue already has %s; link the report to it", taken)
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return "", err
+ }
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO board_models (id, manufacturer_id, model, soc, soc_label, kind, position)
+ VALUES ($1, $2, $3, $4, $5, 'board', (SELECT coalesce(max(position), 0) + 1 FROM board_models WHERE manufacturer_id = $2))`,
+ m.ModelID, m.MakerID, m.Model, null(socSlug(m.SoC, resolve)), null(m.SoC)); err != nil {
+ return "", err
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO board_model_aliases (maker_id, code_norm, model_id, source, code_as_printed)
+ VALUES ($1, $2, $3, 'club', $4)`, m.MakerID, code, m.ModelID, m.Model); err != nil {
+ return "", err
+ }
+ return m.ModelID, nil
+}
+
+// socSlug is the catalogue's slug for a SoC as a person or a shop wrote it.
+func socSlug(label string, resolve func(string) string) string {
+ l := strings.ToLower(strings.ReplaceAll(label, " ", ""))
+ if full, ok := socShorthand[l]; ok {
+ l = full
+ }
+ if resolve == nil || l == "" {
+ return ""
+ }
+ return resolve(l)
+}
diff --git a/service/internal/boards/newmodel_test.go b/service/internal/boards/newmodel_test.go
new file mode 100644
index 00000000..cb511f12
--- /dev/null
+++ b/service/internal/boards/newmodel_test.go
@@ -0,0 +1,97 @@
+package boards
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+func create(t *testing.T, pool *pgxpool.Pool, m NewModel) (string, error) {
+ t.Helper()
+ var id string
+ err := pgx.BeginFunc(context.Background(), pool, func(tx pgx.Tx) error {
+ var err error
+ id, err = CreateModel(context.Background(), tx, m, supported)
+ return err
+ })
+ return id, err
+}
+
+// A board a review adds from an owner's proposal is filed under the maker the
+// catalogue already has, answers to its marking, and is the one an archive
+// imported later finds rather than a twin of it.
+func TestABoardAReviewAddsIsTheOneALaterArchiveFinds(t *testing.T) {
+ pool, root := imported(t)
+ ctx := context.Background()
+ sug, err := Suggest(ctx, pool, "XIONGMAI", "IPC_NEW 77", "Hi3516CV300")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !sug.MakerKnown || sug.MakerID != "xiongmai" || sug.ModelID != "xiongmai-ipc-new-77" || sug.Existing != "" {
+ t.Fatalf("suggested %+v", sug)
+ }
+ id, err := create(t, pool, sug.NewModel)
+ if err != nil || id != "xiongmai-ipc-new-77" {
+ t.Fatalf("created %q: %v", id, err)
+ }
+ var soc, label string
+ _ = pool.QueryRow(ctx, `SELECT coalesce(soc, ''), coalesce(soc_label, '') FROM board_models WHERE id = $1`, id).Scan(&soc, &label)
+ if soc != "hi3516cv300" || label != "Hi3516CV300" {
+ t.Errorf("soc %q, label %q", soc, label)
+ }
+ if n := count(t, pool, `SELECT count(*) FROM board_model_aliases WHERE model_id = $1 AND code_norm = 'IPC-NEW-77' AND source = 'club'`, id); n != 1 {
+ t.Errorf("%d club aliases", n)
+ }
+
+ // The same marking under another id is the same board: refused, and
+ // the suggestion now names the board that has it.
+ if _, err := create(t, pool, NewModel{MakerID: "xiongmai", MakerName: "Xiongmai", ModelID: "xiongmai-twin", Model: "ipc-new-77"}); err == nil ||
+ !strings.Contains(err.Error(), "already has xiongmai-ipc-new-77") {
+ t.Errorf("a twin: %v", err)
+ }
+ if sug, _ := Suggest(ctx, pool, "Xiongmai", "IPC NEW-77", ""); sug.Existing != id {
+ t.Errorf("a second proposal of it: %+v", sug)
+ }
+
+ // A donor describing the same board joins it.
+ im := &Importer{Pool: pool, Log: quiet(), Root: root, Resolve: supported}
+ if n, err := im.FromSnapshot(ctx, donor(t, "cctvsp", model("xiongmai", "IPC-NEW-77", nil))); err != nil || n != 0 {
+ t.Fatalf("donor created %d (%v), want 0: the board is the club's", n, err)
+ }
+ if n := count(t, pool, `SELECT count(*) FROM board_units WHERE model_id = $1 AND source = 'cctvsp'`, id); n != 1 {
+ t.Errorf("%d donor units on the club's board", n)
+ }
+}
+
+// A maker the catalogue does not have is added with the board; ids that are
+// not ids, and markings with nothing to key them by, are refused.
+func TestANewMakerComesWithItsBoardAndBadIdsAreRefused(t *testing.T) {
+ pool, _ := imported(t)
+ ctx := context.Background()
+ sug, err := Suggest(ctx, pool, "Jooan Tech", "Q9 v2", "")
+ if err != nil || sug.MakerKnown || sug.MakerID != "jooan-tech" || sug.ModelID != "jooan-tech-q9-v2" {
+ t.Fatalf("suggested %+v (%v)", sug, err)
+ }
+ if _, err := create(t, pool, sug.NewModel); err != nil {
+ t.Fatal(err)
+ }
+ if n := count(t, pool, `SELECT count(*) FROM board_manufacturers WHERE id = 'jooan-tech' AND name = 'Jooan Tech'`); n != 1 {
+ t.Error("the maker was not added")
+ }
+ for _, m := range []NewModel{
+ {MakerID: "Jooan", MakerName: "Jooan", ModelID: "jooan-x", Model: "X"},
+ {MakerID: "jooan", MakerName: "Jooan", ModelID: "-x", Model: "X"},
+ {MakerID: "jooan", MakerName: "", ModelID: "jooan-x", Model: "X"},
+ {MakerID: "jooan", MakerName: "Jooan", ModelID: "jooan-x", Model: "--"},
+ } {
+ if _, err := create(t, pool, m); err == nil {
+ t.Errorf("accepted %+v", m)
+ }
+ }
+ if n := count(t, pool, `SELECT count(*) FROM board_manufacturers WHERE id = 'jooan'`); n != 0 {
+ t.Error("a refused board left its maker behind")
+ }
+}
diff --git a/service/internal/boards/snapshot.go b/service/internal/boards/snapshot.go
index 0e181ea2..39644703 100644
--- a/service/internal/boards/snapshot.go
+++ b/service/internal/boards/snapshot.go
@@ -429,14 +429,7 @@ func (im *Importer) resolve(ctx context.Context, q querier, maker, code string,
}
func (im *Importer) socFor(label string) string {
- l := strings.ToLower(strings.ReplaceAll(label, " ", ""))
- if full, ok := socShorthand[l]; ok {
- l = full
- }
- if im.Resolve == nil || l == "" {
- return ""
- }
- return im.Resolve(l)
+ return socSlug(label, im.Resolve)
}
func (im *Importer) saveModel(ctx context.Context, tx pgx.Tx, fsys fs.FS, src string, position int, m SnapModel, dec map[[2]string]string) (string, bool, error) {
diff --git a/service/internal/club/api.go b/service/internal/club/api.go
index 46c025c7..830eb3dc 100644
--- a/service/internal/club/api.go
+++ b/service/internal/club/api.go
@@ -14,6 +14,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
+ "github.com/OpenIPC/website/service/internal/boards"
"github.com/OpenIPC/website/service/internal/reports"
"github.com/OpenIPC/website/service/internal/wallstars"
)
@@ -586,8 +587,10 @@ func (a *API) queue(w http.ResponseWriter, r *http.Request) {
}
// 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.
+// "models": [...], "new_board": {...}, "note": "..."}: a maintainer's
+// decision, its stars, and a message to the sender when the bot can reach
+// them. new_board (boards.NewModel) adds the camera a report proposed to
+// the catalogue, and links the report to it.
func (a *API) decide(w http.ResponseWriter, r *http.Request) {
m, ok := a.signedIn(w, r)
if !ok {
@@ -598,9 +601,10 @@ func (a *API) decide(w http.ResponseWriter, r *http.Request) {
return
}
var in struct {
- Decision string `json:"decision"`
- Models []string `json:"models"`
- Note string `json:"note"`
+ Decision string `json:"decision"`
+ Models []string `json:"models"`
+ NewBoard *boards.NewModel `json:"new_board"`
+ 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": "..."}`)
@@ -608,7 +612,7 @@ func (a *API) decide(w http.ResponseWriter, r *http.Request) {
}
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)
+ d, err := a.Reports.Store().Decide(r.Context(), id, in.Decision, by, in.Note, in.Models, in.NewBoard)
if errors.Is(err, reports.ErrNotFound) {
a.refuse(w, http.StatusNotFound, "no report has this id")
return
@@ -617,14 +621,14 @@ func (a *API) decide(w http.ResponseWriter, r *http.Request) {
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)
+ a.Log.Info("club: reviewed", "report", id, "decision", in.Decision, "by", m.ID, "member", d.Member, "points", d.Points, "new_board", d.Board)
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})
+ writeJSON(w, http.StatusOK, map[string]any{"id": id, "decision": in.Decision, "points": d.Points, "total": d.Total, "board": d.Board})
}
func (a *API) refuse(w http.ResponseWriter, status int, reason string) {
diff --git a/service/internal/club/newcamera_test.go b/service/internal/club/newcamera_test.go
new file mode 100644
index 00000000..b8996c75
--- /dev/null
+++ b/service/internal/club/newcamera_test.go
@@ -0,0 +1,106 @@
+package club
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+var photo = append([]byte{0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 'J', 'F', 'I', 'F', 0}, bytes.Repeat([]byte{7}, 2000)...)
+
+func (e *env) maintainer(t *testing.T) *browser {
+ t.Helper()
+ m := e.browser("198.51.100.3")
+ loc, _ := url.Parse(m.do(t, "GET", "/api/v1/club/github", nil, "").Header().Get("Location"))
+ m.do(t, "GET", "/api/v1/club/github/callback?code=maint&state="+loc.Query().Get("state"), nil, "")
+ return m
+}
+
+// A member sends photos of a camera the catalogue does not have, named by
+// its maker and marking. The maintainer sees the board publishing would add,
+// publishes it, and the catalogue has the board with the report on it; the
+// member earns a star a photo. A second owner of the same camera is linked
+// to that board, never given a twin of it.
+func TestANewCameraBecomesABoardWhenItsReportIsPublished(t *testing.T) {
+ e := newEnv(t)
+ ctx := context.Background()
+ ivan := e.signedIn(t, 777, "ivan_k", "198.51.100.1")
+ code, out := ivan.send(t, map[string]string{"channel": "web", "maker": "Jooan", "board": "Q9 v2", "soc": "SSC335"},
+ map[string][]byte{"photo": photo, "photo#2": photo})
+ if code != http.StatusCreated {
+ t.Fatalf("send: %d %v", code, out)
+ }
+ id := out["id"].(string)
+
+ _, mine := ivan.json(t, "GET", "/api/v1/club/reports", nil)
+ r := mine["reports"].([]any)[0].(map[string]any)
+ if p, _ := r["proposal"].(map[string]any); p["maker"] != "Jooan" || p["board"] != "Q9 v2" || r["board"] != nil || r["pending"].(float64) != 2 {
+ t.Fatalf("mine: %v", r)
+ }
+
+ maint := e.maintainer(t)
+ _, q := maint.json(t, "GET", "/api/v1/club/review", nil)
+ queued := q["reports"].([]any)[0].(map[string]any)
+ nb, _ := queued["new_board"].(map[string]any)
+ if nb["maker_id"] != "jooan" || nb["model_id"] != "jooan-q9-v2" || nb["maker_known"] != false || nb["soc"] != "SSC335" || nb["existing"] != nil {
+ t.Fatalf("suggested %v", nb)
+ }
+
+ // A board id the catalogue has is refused, and leaves nothing behind:
+ // no maker, no review.
+ taken := map[string]any{"maker_id": "jooan", "maker_name": "Jooan", "model_id": "anjoy-ms-j10", "model": "Q9 v2"}
+ if code, d := maint.json(t, "POST", "/api/v1/club/review/"+id, map[string]any{"decision": "publish", "new_board": taken}); code != 400 ||
+ !strings.Contains(d["error"].(string), "already has anjoy-ms-j10") {
+ t.Errorf("a taken id: %d %v", code, d)
+ }
+ var makers int
+ _ = e.pool.QueryRow(ctx, `SELECT count(*) FROM board_manufacturers WHERE id = 'jooan'`).Scan(&makers)
+ _, mine = ivan.json(t, "GET", "/api/v1/club/reports", nil)
+ if makers != 0 || mine["reports"].([]any)[0].(map[string]any)["status"] != "pending" {
+ t.Errorf("a refused decision left %d makers, or decided the report", makers)
+ }
+ // Rejecting does not make boards.
+ if code, _ := maint.json(t, "POST", "/api/v1/club/review/"+id, map[string]any{"decision": "reject", "new_board": nb}); code != 400 {
+ t.Errorf("reject with a new board: %d", code)
+ }
+
+ code, d := maint.json(t, "POST", "/api/v1/club/review/"+id, map[string]any{"decision": "publish", "new_board": nb})
+ if code != 200 || d["board"] != "jooan-q9-v2" || d["points"].(float64) != 2 {
+ t.Fatalf("publish: %d %v", code, d)
+ }
+ if m := ivan.me(t); m["stars"].(float64) != 2 {
+ t.Errorf("Ivan's stars: %v", m)
+ }
+ var model, label string
+ if err := e.pool.QueryRow(ctx, `SELECT coalesce(model, ''), coalesce(soc_label, '') FROM board_models WHERE id = 'jooan-q9-v2' AND manufacturer_id = 'jooan'`).
+ Scan(&model, &label); err != nil || model != "Q9 v2" || label != "SSC335" {
+ t.Errorf("the board: %q %q %v", model, label, err)
+ }
+ if pub, _ := e.api.Reports.Store().Public(ctx, id); len(pub.Models) != 1 || pub.Models[0].ID != "jooan-q9-v2" {
+ t.Errorf("linked to %v", pub.Models)
+ }
+ // Its photos are what the board's page will list (refreshReportUnits).
+ texts, err := e.api.Reports.Store().PublishedTexts(ctx)
+ if err != nil || len(texts) != 1 || texts[0].Model != "jooan-q9-v2" || len(texts[0].Files) != 2 || texts[0].By != "Ivan" {
+ t.Errorf("published texts: %+v (%v)", texts, err)
+ }
+
+ // Petr has the same camera, and spells it his way: the review offers the
+ // board that exists, and adding a twin is refused.
+ petr := e.signedIn(t, 888, "petr", "198.51.100.2")
+ _, out = petr.send(t, map[string]string{"channel": "web", "maker": "JOOAN", "board": "Q9-V2"}, map[string][]byte{"photo": photo})
+ _, q = maint.json(t, "GET", "/api/v1/club/review", nil)
+ nb2 := q["reports"].([]any)[0].(map[string]any)["new_board"].(map[string]any)
+ if nb2["existing"] != "jooan-q9-v2" || nb2["maker_known"] != true {
+ t.Fatalf("second proposal: %v", nb2)
+ }
+ if code, _ := maint.json(t, "POST", "/api/v1/club/review/"+out["id"].(string), map[string]any{"decision": "publish", "new_board": nb2}); code != 400 {
+ t.Errorf("a twin board: %d", code)
+ }
+ if code, d := maint.json(t, "POST", "/api/v1/club/review/"+out["id"].(string), map[string]any{"decision": "publish", "models": []string{"jooan-q9-v2"}}); code != 200 || d["points"].(float64) != 1 {
+ t.Errorf("linked to the board that exists: %d %v", code, d)
+ }
+}
diff --git a/service/internal/db/migrations/028_report_proposals.sql b/service/internal/db/migrations/028_report_proposals.sql
new file mode 100644
index 00000000..3310fe39
--- /dev/null
+++ b/service/internal/db/migrations/028_report_proposals.sql
@@ -0,0 +1,21 @@
+-- A camera the catalogue does not have yet, as the sender names it on the
+-- site's send form (POST /api/v1/club/reports with maker and board instead
+-- of model): photos of it and its maker and marking are a report too. A
+-- maintainer who publishes it confirms or corrects the proposal, and the
+-- decision adds the board (boards.CreateModel) before linking the report to
+-- it. The proposal is the sender's word and stays as they wrote it; the
+-- board made from it is the reviewer's.
+--
+-- An owner report's row, guarded like the rest (migration 016): inserted,
+-- never changed.
+CREATE TABLE report_proposals (
+ report_id text PRIMARY KEY REFERENCES reports ON DELETE RESTRICT,
+ maker text NOT NULL CHECK (length(maker) BETWEEN 1 AND 80),
+ -- the PCB marking, or the product's name when there is none to read
+ board text NOT NULL CHECK (length(board) BETWEEN 1 AND 80),
+ soc text NOT NULL DEFAULT '' CHECK (length(soc) <= 40)
+);
+CREATE TRIGGER report_proposals_guard BEFORE UPDATE OR DELETE ON report_proposals
+ FOR EACH ROW EXECUTE FUNCTION reports_guard();
+CREATE TRIGGER report_proposals_no_truncate BEFORE TRUNCATE ON report_proposals
+ FOR EACH STATEMENT EXECUTE FUNCTION reports_guard();
diff --git a/service/internal/reports/api_test.go b/service/internal/reports/api_test.go
index 1d3458e4..f34cc96b 100644
--- a/service/internal/reports/api_test.go
+++ b/service/internal/reports/api_test.go
@@ -478,3 +478,65 @@ func TestTakedownLeavesAFileANewReportNames(t *testing.T) {
t.Error("the file the new report names is not on disk")
}
}
+
+// A camera the catalogue does not have: the send form proposes it by its
+// maker and marking, with a photo of it, and needs no ipctool output. The
+// proposal is kept as sent, and only the send form's channel may make one.
+func TestANewCameraIsProposedWithAPhotoAndNoIpctool(t *testing.T) {
+ e := newEnv(t)
+ ctx := context.Background()
+ newCam := map[string]string{"channel": "web", "maker": "Jooan", "board": "Q9 v2", "soc": "SSC335", "note": "flashed in December"}
+ rec, out := e.post(t, upload{fields: newCam, files: map[string][]byte{"photo": jpeg, "photo#2": jpeg, "boot_log": []byte("U-Boot 2015.01\n")}}, "203.0.113.20")
+ if rec.Code != 201 {
+ t.Fatalf("%d %s", rec.Code, rec.Body)
+ }
+ id := out["id"].(string)
+ p, err := e.api.Store().ProposalOf(ctx, id)
+ if err != nil || p == nil || *p != (Proposal{Maker: "Jooan", Board: "Q9 v2", SoC: "SSC335"}) {
+ t.Fatalf("proposal %+v (%v)", p, err)
+ }
+ if _, err := e.pool.Exec(ctx, `UPDATE report_proposals SET board = 'other'`); err == nil {
+ t.Error("a proposal was changed")
+ }
+
+ with := func(over map[string]string) map[string]string {
+ m := map[string]string{}
+ for k, v := range newCam {
+ m[k] = v
+ }
+ for k, v := range over {
+ if v == "" {
+ delete(m, k)
+ } else {
+ m[k] = v
+ }
+ }
+ return m
+ }
+ photo := map[string][]byte{"photo": jpeg}
+ for name, u := range map[string]upload{
+ "no photo": {fields: newCam, files: map[string][]byte{"boot_log": []byte("U-Boot\n")}},
+ "no maker": {fields: with(map[string]string{"maker": ""}), files: photo},
+ "no board": {fields: with(map[string]string{"board": ""}), files: photo},
+ "a catalogue one": {fields: with(map[string]string{"model": "xiongmai-50h20l"}), files: photo},
+ "not the form": {fields: with(map[string]string{"channel": "ipctool"}), files: photo},
+ "a long name": {fields: with(map[string]string{"board": strings.Repeat("Q", 81)}), files: photo},
+ "a long soc": {fields: with(map[string]string{"soc": strings.Repeat("s", 41)}), files: photo},
+ "a raw flash dump": {fields: newCam, files: map[string][]byte{"photo": jpeg, "backup": bytes.Repeat([]byte{1}, 1<<20)}},
+ } {
+ if rec, _ := e.post(t, u, "203.0.113.21"); rec.Code != 400 {
+ t.Errorf("%s: %d %s", name, rec.Code, rec.Body)
+ }
+ }
+ var n int
+ _ = e.pool.QueryRow(ctx, `SELECT count(*) FROM reports`).Scan(&n)
+ if n != 1 {
+ t.Errorf("%d reports, want only the first", n)
+ }
+
+ // With ipctool's output too, the output is read as always.
+ rec, out = e.post(t, upload{fields: with(map[string]string{"yaml": fixture(t, "hi3516cv300-imx291.txt")}), files: photo}, "203.0.113.22")
+ if rec.Code != 201 || out["facts"].(map[string]any)["chip_model"] == nil {
+ t.Errorf("with yaml: %d %v", rec.Code, out)
+ }
+}
diff --git a/service/internal/reports/club.go b/service/internal/reports/club.go
index abe65e6f..9a921528 100644
--- a/service/internal/reports/club.go
+++ b/service/internal/reports/club.go
@@ -8,6 +8,9 @@ import (
"time"
"github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+
+ "github.com/OpenIPC/website/service/internal/boards"
)
// What the OpenIPC Club (internal/club) asks of owner reports: a member's
@@ -49,10 +52,12 @@ type MemberReport struct {
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"`
+ ReviewNote string `json:"review_note,omitempty"`
+ Board *ViewModel `json:"board,omitempty"`
+ // Proposal: the camera the sender named, when the catalogue had none.
+ Proposal *Proposal `json:"proposal,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"`
@@ -138,6 +143,9 @@ func (s *Store) memberReport(ctx context.Context, id string) (*MemberReport, err
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
+ if m.Proposal, err = s.ProposalOf(ctx, id); err != nil {
+ return nil, err
+ }
potential, err := s.potential(ctx, id, yaml != "")
if err != nil {
return nil, err
@@ -253,6 +261,10 @@ type Queued struct {
Guess *Match `json:"guess,omitempty"`
FileList []MemberFile `json:"file_list"`
Potential int `json:"potential"`
+ // Proposal and NewBoard: a camera the catalogue does not have, as the
+ // sender named it, and the board publishing it would add.
+ Proposal *Proposal `json:"proposal,omitempty"`
+ NewBoard *boards.Suggestion `json:"new_board,omitempty"`
}
// Queue is the review queue: the reports in one state (pending by default),
@@ -280,7 +292,14 @@ func (s *Store) Queue(ctx context.Context, state string) ([]Queued, error) {
if err != nil {
return nil, err
}
- q.Board, q.FileList = m.Board, m.Files
+ q.Board, q.FileList, q.Proposal = m.Board, m.Files, m.Proposal
+ if p := m.Proposal; p != nil {
+ sug, err := boards.Suggest(ctx, s.DB, p.Maker, p.Board, p.SoC)
+ if err != nil {
+ return nil, err
+ }
+ q.NewBoard = &sug
+ }
for _, f := range m.Files {
q.Potential += f.Points
}
@@ -298,38 +317,38 @@ func (s *Store) Queue(ctx context.Context, state string) ([]Queued, error) {
return out, nil
}
-// Decided is what a review did to the sender's stars.
+// Decided is what a review did to the sender's stars, and the board it
+// added, if it added one.
type Decided struct {
Member string
Points int
Total int
+ Board string
}
// 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) {
+// the report to its boards (the sender's board when none is named), adds
+// the board newBoard describes first when the catalogue did not have it,
+// 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, newBoard *boards.NewModel) (Decided, error) {
var d Decided
if decision != "publish" && decision != "reject" {
return d, fmt.Errorf("a review publishes or rejects")
}
+ if newBoard != nil && decision != "publish" {
+ return d, errors.New("a new board is added by publishing the report")
+ }
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)
- }
+ if decision == "publish" && len(models) == 0 && newBoard == nil {
+ 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}
}
}
// The potential is read before the decision, so this report's own
@@ -345,7 +364,33 @@ func (s *Store) Decide(ctx context.Context, id, decision, by, note string, model
if err != nil {
return d, err
}
- if err := s.Review(ctx, id, decision, by, note); err != nil {
+ // The board a review adds, the report's links and the review are one
+ // transaction: a board that cannot be added, or a link to one the
+ // catalogue does not have, leaves nothing behind.
+ err = pgx.BeginFunc(ctx, s.DB, func(tx pgx.Tx) error {
+ if newBoard != nil {
+ id, err := boards.CreateModel(ctx, tx, *newBoard, s.SoC)
+ if err != nil {
+ return err
+ }
+ d.Board, models = id, append(models, id)
+ }
+ for _, m := range models {
+ if _, err := tx.Exec(ctx, `INSERT INTO report_models (report_id, model_id, by) VALUES ($1,$2,$3)
+ ON CONFLICT DO NOTHING`, id, m, by); err != nil {
+ var pe *pgconn.PgError
+ if errors.As(err, &pe) && pe.Code == "23503" {
+ return fmt.Errorf("link %s: the catalogue has no such board", m)
+ }
+ return fmt.Errorf("link %s: %w", m, err)
+ }
+ }
+ _, err := tx.Exec(ctx, `INSERT INTO report_reviews (report_id, decision, by, note) VALUES ($1,$2,$3,$4)`,
+ id, decision, by, note)
+ return err
+ })
+ if err != nil {
+ d.Board = ""
return d, err
}
if owner == "" {
diff --git a/service/internal/reports/handler.go b/service/internal/reports/handler.go
index 65c43a2b..4a96a87d 100644
--- a/service/internal/reports/handler.go
+++ b/service/internal/reports/handler.go
@@ -40,6 +40,8 @@ type API struct {
AccelPrefix string
Log *slog.Logger
Now func() time.Time
+ // SoC maps a SoC as written to the catalogue's slug (Store.SoC).
+ SoC func(string) string
}
func (a *API) Handlers() map[string]http.Handler {
@@ -52,7 +54,7 @@ func (a *API) Handlers() map[string]http.Handler {
}
}
-func (a *API) store() *Store { return &Store{DB: a.DB} }
+func (a *API) store() *Store { return &Store{DB: a.DB, SoC: a.SoC} }
// Store is the reports' rows, for the club's pages.
func (a *API) Store() *Store { return a.store() }
@@ -95,7 +97,9 @@ func (a *API) upload(w http.ResponseWriter, r *http.Request) {
// (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.
+// a flash dump read with a programmer rather than ipctool. So is a camera
+// the catalogue does not have, proposed by its maker and marking (the
+// fields maker, board and soc) with at least one photo of it.
func (a *API) Submit(w http.ResponseWriter, r *http.Request, member string) {
ctx := r.Context()
st := a.store()
@@ -135,6 +139,11 @@ func (a *API) Submit(w http.ResponseWriter, r *http.Request, member string) {
}
model := in.fields["model"]
+ prop, err := proposalOf(in, channel, model)
+ if err != nil {
+ a.refuse(w, http.StatusBadRequest, err.Error())
+ return
+ }
if model != "" {
if channel != "web" {
a.refuse(w, http.StatusBadRequest, "model is the send form's field: channel web")
@@ -179,7 +188,7 @@ func (a *API) Submit(w http.ResponseWriter, r *http.Request, member string) {
}
var doc string
var facts Facts
- if in.yaml != "" || model == "" {
+ if in.yaml != "" || model == "" && prop == nil {
doc, facts, err = Parse(in.yaml)
if err != nil {
a.refuse(w, http.StatusBadRequest, err.Error())
@@ -204,7 +213,7 @@ func (a *API) Submit(w http.ResponseWriter, r *http.Request, member string) {
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,
+ Member: member, Model: model, Proposal: prop,
}
sum := sha256.Sum256([]byte(doc))
rep.YAMLSHA256 = hex.EncodeToString(sum[:])
@@ -308,6 +317,36 @@ func (a *API) Submit(w http.ResponseWriter, r *http.Request, member string) {
writeJSON(w, http.StatusCreated, out)
}
+var formFields = map[string]bool{"consent": true, "channel": true, "tool": true, "note": true, "model": true,
+ "maker": true, "board": true, "soc": true}
+
+// proposalOf is the camera a send proposes, nil when it proposes none: the
+// send form's, for a board the catalogue does not have, with a photo of it.
+func proposalOf(in *received, channel, model string) (*Proposal, error) {
+ p := &Proposal{Maker: in.fields["maker"], Board: in.fields["board"], SoC: in.fields["soc"]}
+ if p.Maker == "" && p.Board == "" && p.SoC == "" {
+ return nil, nil
+ }
+ switch {
+ case channel != "web":
+ return nil, errors.New("maker, board and soc are the send form's fields: channel web")
+ case model != "":
+ return nil, errors.New("a report names a catalogue board (model) or proposes a new one (maker, board), not both")
+ case p.Maker == "" || p.Board == "":
+ return nil, errors.New("a new camera needs its maker and its board's marking or model name")
+ case utf8.RuneCountInString(p.Maker) > 80 || utf8.RuneCountInString(p.Board) > 80:
+ return nil, errors.New("maker and board: at most 80 characters each")
+ case utf8.RuneCountInString(p.SoC) > 40:
+ return nil, errors.New("soc: at most 40 characters")
+ }
+ for _, pt := range in.parts {
+ if pt.kind == "photo" {
+ return p, nil
+ }
+ }
+ return nil, errors.New("a new camera needs at least one photo of it")
+}
+
// 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
@@ -383,7 +422,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" || name == "model")
+ isField := p.FileName() == "" && formFields[name]
switch {
case isField:
b, err := io.ReadAll(io.LimitReader(p, maxField+1))
@@ -422,10 +461,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, model", 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, maker, board, soc", name)
}
}
- if in.yaml == "" && in.backup == nil && in.fields["model"] == "" {
+ if in.yaml == "" && in.backup == nil && in.fields["model"] == "" && in.fields["maker"] == "" && in.fields["board"] == "" {
return in, http.StatusBadRequest, errors.New("a report needs ipctool's output: a yaml part, or a backup")
}
return in, 0, nil
diff --git a/service/internal/reports/store.go b/service/internal/reports/store.go
index 37f83356..e60e1d21 100644
--- a/service/internal/reports/store.go
+++ b/service/internal/reports/store.go
@@ -16,6 +16,9 @@ import (
// migration's triggers refuse any change it does not make through Unguarded.
type Store struct {
DB *pgxpool.Pool
+ // SoC maps a SoC as written to the catalogue's slug, for a board a
+ // review adds (boards.CreateModel); nil keeps only the label.
+ SoC func(string) string
}
// Report is one upload, as stored.
@@ -38,6 +41,16 @@ type Report struct {
// said it is. Empty for ipctool's uploads.
Member string
Model string
+ // Proposal: a camera the catalogue does not have, as the sender names it.
+ Proposal *Proposal
+}
+
+// Proposal is a camera the catalogue does not have yet, as its sender names
+// it (migration 028).
+type Proposal struct {
+ Maker string `json:"maker"`
+ Board string `json:"board"`
+ SoC string `json:"soc,omitempty"`
}
// File is one file a report brought.
@@ -171,10 +184,26 @@ func (s *Store) Insert(ctx context.Context, r *Report, limit int, place func() e
return err
}
}
+ if p := r.Proposal; p != nil {
+ if _, err := tx.Exec(ctx, `INSERT INTO report_proposals (report_id, maker, board, soc) VALUES ($1, $2, $3, $4)`,
+ r.ID, p.Maker, p.Board, p.SoC); err != nil {
+ return err
+ }
+ }
return nil
})
}
+// ProposalOf is the camera a report proposes, nil when it names none.
+func (s *Store) ProposalOf(ctx context.Context, id string) (*Proposal, error) {
+ p := &Proposal{}
+ err := s.DB.QueryRow(ctx, `SELECT maker, board, soc FROM report_proposals WHERE report_id = $1`, id).Scan(&p.Maker, &p.Board, &p.SoC)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ return p, err
+}
+
// 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
From babed95a4df7b4f93c0ae52ede1dbfaed6e086b3 Mon Sep 17 00:00:00 2001
From: AI Dev
Date: Tue, 6 Oct 2026 10:33:15 +0000
Subject: [PATCH 2/2] Address review: redact MACs without ipctool, decide in
one transaction, link only on publish, importers join club boards
- Redact replaces any MAC written as six colon- or hyphen-separated pairs,
not only the one ipctool named: a boot log or U-Boot environment sent
without ipctool's output (to a catalogue board, or with a new camera's
photos) was published with its ethaddr.
- Decide writes the new board, the links, the review and the sender's
stars in one transaction: a ledger that cannot be written no longer
leaves the board published and the decision unrepeatable.
- Rejecting links nothing again; the links had moved outside the publish
branch.
- The archive importer puts a unit on the board a review added under the
reviewer's id (alias source club) instead of making a twin; the snapshot
importer accepts a maker a review added; Suggest knows the importers'
makers and cuts the ids it makes to their lengths.
- NewCamera.test.tsx: its field helper takes an Element (typecheck).
---
.../src/components/club/NewCamera.test.tsx | 2 +-
service/internal/boards/import.go | 16 ++++-
service/internal/boards/newmodel.go | 35 +++++++++-
service/internal/boards/newmodel_test.go | 46 +++++++++++++
service/internal/boards/snapshot.go | 10 +++
service/internal/club/newcamera_test.go | 18 +++++
service/internal/reports/club.go | 67 ++++++++++---------
service/internal/reports/parse.go | 19 ++++++
service/internal/reports/parse_test.go | 13 ++++
9 files changed, 189 insertions(+), 37 deletions(-)
diff --git a/frontend/apps/site/src/components/club/NewCamera.test.tsx b/frontend/apps/site/src/components/club/NewCamera.test.tsx
index 4ce922dd..1f15e981 100644
--- a/frontend/apps/site/src/components/club/NewCamera.test.tsx
+++ b/frontend/apps/site/src/components/club/NewCamera.test.tsx
@@ -23,7 +23,7 @@ function capture() {
}
/** The field whose label reads label: exactly, else at its start. */
-function input(c: HTMLElement, label: string) {
+function input(c: Element, label: string) {
const labels = [...c.querySelectorAll('label')];
const own = (l: HTMLLabelElement) => l.childNodes[0]?.textContent?.trim() ?? '';
const l = labels.find((x) => own(x) === label) ?? labels.find((x) => own(x).startsWith(label));
diff --git a/service/internal/boards/import.go b/service/internal/boards/import.go
index 3d7e3252..7cf101e2 100644
--- a/service/internal/boards/import.go
+++ b/service/internal/boards/import.go
@@ -6,6 +6,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
+ "errors"
"fmt"
"io"
"io/fs"
@@ -227,12 +228,25 @@ type beginner interface {
func (im *Importer) saveIn(ctx context.Context, db beginner, u *Unit, arts []artifact) (bool, error) {
saved := false
err := pgx.BeginFunc(ctx, db, func(tx pgx.Tx) error {
- m, mk := u.Model, u.Model.Manufacturer
+ m, mk := *u.Model, u.Model.Manufacturer
if _, err := tx.Exec(ctx, `
INSERT INTO board_manufacturers (id, name, aliases, position) VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO NOTHING`, mk.ID, mk.Name, nonNil(mk.Aliases), mk.Position); err != nil {
return err
}
+ // A board a review added from an owner's photos (CreateModel) under
+ // an id of the reviewer's: the archive's unit of the same marking
+ // joins it rather than making a twin of it.
+ if m.Model != "" {
+ var club string
+ err := tx.QueryRow(ctx, `SELECT model_id FROM board_model_aliases WHERE maker_id = $1 AND code_norm = $2 AND source = 'club'`,
+ mk.ID, NormCode(m.Model)).Scan(&club)
+ if err == nil {
+ m.ID = club
+ } else if !errors.Is(err, pgx.ErrNoRows) {
+ return err
+ }
+ }
if _, err := tx.Exec(ctx, `
INSERT INTO board_models (id, manufacturer_id, model, soc, soc_label, family, position)
VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING`,
diff --git a/service/internal/boards/newmodel.go b/service/internal/boards/newmodel.go
index 7cc7ab22..f5b0e4d3 100644
--- a/service/internal/boards/newmodel.go
+++ b/service/internal/boards/newmodel.go
@@ -51,9 +51,10 @@ type queryer interface {
// Suggest turns a sender's maker, marking and SoC into the board a reviewer
// would add: the catalogue's own maker when the name or one of its aliases
-// matches, ids made the way the importers make them.
+// matches -- in the database, or among the makers the importers know -- and
+// ids made the way the importers make them, cut to the ids' lengths.
func Suggest(ctx context.Context, q queryer, maker, model, soc string) (Suggestion, error) {
- s := Suggestion{NewModel: NewModel{MakerID: slug(maker), MakerName: strings.TrimSpace(maker),
+ s := Suggestion{NewModel: NewModel{MakerID: fit(slug(maker), 64), MakerName: strings.TrimSpace(maker),
Model: strings.TrimSpace(model), SoC: strings.TrimSpace(soc)}}
err := q.QueryRow(ctx, `
SELECT id, name FROM board_manufacturers
@@ -64,8 +65,12 @@ func Suggest(ctx context.Context, q queryer, maker, model, soc string) (Suggesti
s.MakerKnown = true
case !errors.Is(err, pgx.ErrNoRows):
return s, err
+ default:
+ if mk, ok := knownMaker(s.MakerName); ok {
+ s.MakerID, s.MakerName, s.MakerKnown = mk.ID, mk.Name, true
+ }
}
- s.ModelID = slug(s.MakerID + "-" + s.Model)
+ s.ModelID = fit(slug(s.MakerID+"-"+s.Model), 128)
err = q.QueryRow(ctx, `
SELECT model_id FROM board_model_aliases WHERE maker_id = $1 AND code_norm = $2
UNION ALL SELECT id FROM board_models WHERE id = $3
@@ -128,6 +133,30 @@ func CreateModel(ctx context.Context, tx pgx.Tx, m NewModel, resolve func(string
return m.ModelID, nil
}
+// knownMaker is the importers' maker (parse.go's makers) a name means: by
+// the archive's vendor column, the id, the name or an alias, in any case.
+func knownMaker(name string) (Manufacturer, bool) {
+ for vendor, m := range makers {
+ if strings.EqualFold(name, vendor) || strings.EqualFold(name, m.ID) || strings.EqualFold(name, m.Name) {
+ return m, true
+ }
+ for _, a := range m.Aliases {
+ if strings.EqualFold(name, a) {
+ return m, true
+ }
+ }
+ }
+ return Manufacturer{}, false
+}
+
+// fit cuts an id made by slug to n characters, never ending on a hyphen.
+func fit(id string, n int) string {
+ if len(id) > n {
+ id = strings.TrimRight(id[:n], "-")
+ }
+ return id
+}
+
// socSlug is the catalogue's slug for a SoC as a person or a shop wrote it.
func socSlug(label string, resolve func(string) string) string {
l := strings.ToLower(strings.ReplaceAll(label, " ", ""))
diff --git a/service/internal/boards/newmodel_test.go b/service/internal/boards/newmodel_test.go
index cb511f12..eeaa63e2 100644
--- a/service/internal/boards/newmodel_test.go
+++ b/service/internal/boards/newmodel_test.go
@@ -7,6 +7,8 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/OpenIPC/website/service/internal/db/dbtest"
)
func create(t *testing.T, pool *pgxpool.Pool, m NewModel) (string, error) {
@@ -95,3 +97,47 @@ func TestANewMakerComesWithItsBoardAndBadIdsAreRefused(t *testing.T) {
t.Error("a refused board left its maker behind")
}
}
+
+// The archive imported after a review added one of its boards under the
+// reviewer's id puts its unit on that board instead of making a twin; a
+// donor naming a maker only a review added is imported, not refused.
+func TestImportersJoinWhatAReviewAdded(t *testing.T) {
+ pool := dbtest.New(t)
+ ctx := context.Background()
+ if _, err := create(t, pool, NewModel{MakerID: "xiongmai", MakerName: "Xiongmai", ModelID: "xiongmai-club-53h20", Model: "53H20-S"}); err != nil {
+ t.Fatal(err)
+ }
+ im := &Importer{Pool: pool, Log: quiet(), Root: t.TempDir(), Resolve: supported}
+ if _, err := im.FromFS(ctx, archive()); err != nil {
+ t.Fatal(err)
+ }
+ if n := count(t, pool, `SELECT count(*) FROM board_models WHERE id = 'xiongmai-53h20-s'`); n != 0 {
+ t.Error("the archive made a twin of the club's board")
+ }
+ if n := count(t, pool, `SELECT count(*) FROM board_units WHERE model_id = 'xiongmai-club-53h20' AND source = 'openhisiipcam'`); n == 0 {
+ t.Error("the archive's unit is not on the club's board")
+ }
+
+ if _, err := create(t, pool, NewModel{MakerID: "jooan", MakerName: "Jooan", ModelID: "jooan-q9", Model: "Q9"}); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := im.FromSnapshot(ctx, donor(t, "cctvsp", model("jooan", "Q9", nil), model("jooan", "Q10", nil))); err != nil || n != 1 {
+ t.Fatalf("a donor with a club maker: created %d (%v), want Q10 only", n, err)
+ }
+}
+
+// A suggested id never outgrows what CreateModel accepts.
+func TestASuggestedIdFitsTheCatalogue(t *testing.T) {
+ pool := dbtest.New(t)
+ sug, err := Suggest(context.Background(), pool, strings.Repeat("m", 70), strings.Repeat("q", 80), "")
+ if err != nil || len(sug.MakerID) != 64 || len(sug.ModelID) > 128 || !modelID.MatchString(sug.ModelID) || !makerID.MatchString(sug.MakerID) {
+ t.Fatalf("%q %q (%v)", sug.MakerID, sug.ModelID, err)
+ }
+ if _, err := create(t, pool, NewModel{MakerID: sug.MakerID, MakerName: "M", ModelID: sug.ModelID, Model: sug.Model}); err != nil {
+ t.Error(err)
+ }
+ // A maker the importers know but the database has no board of yet.
+ if sug, _ := Suggest(context.Background(), pool, "anjoy vision", "X1", ""); sug.MakerID != "anjoy" || !sug.MakerKnown {
+ t.Errorf("%+v", sug)
+ }
+}
diff --git a/service/internal/boards/snapshot.go b/service/internal/boards/snapshot.go
index 39644703..93ddd263 100644
--- a/service/internal/boards/snapshot.go
+++ b/service/internal/boards/snapshot.go
@@ -443,6 +443,16 @@ func (im *Importer) saveModel(ctx context.Context, tx pgx.Tx, fsys fs.FS, src st
return "", false, fmt.Errorf("code %q does not normalise to a code", m.Code)
}
maker, ok := makersByID()[m.Maker]
+ if !ok {
+ // A maker a review added with its first board (CreateModel) was a
+ // maintainer's decision too.
+ err := tx.QueryRow(ctx, `SELECT id, name, aliases, position FROM board_manufacturers WHERE id = $1`, m.Maker).
+ Scan(&maker.ID, &maker.Name, &maker.Aliases, &maker.Position)
+ ok = err == nil
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return "", false, err
+ }
+ }
if !ok {
return "", false, fmt.Errorf("unknown maker %q", m.Maker)
}
diff --git a/service/internal/club/newcamera_test.go b/service/internal/club/newcamera_test.go
index b8996c75..f27402e1 100644
--- a/service/internal/club/newcamera_test.go
+++ b/service/internal/club/newcamera_test.go
@@ -104,3 +104,21 @@ func TestANewCameraBecomesABoardWhenItsReportIsPublished(t *testing.T) {
t.Errorf("linked to the board that exists: %d %v", code, d)
}
}
+
+// Rejecting a report never links it to a board, whatever board ids the
+// review page sent with it.
+func TestRejectingLinksNothing(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{"photo": photo})
+ id := out["id"].(string)
+ maint := e.maintainer(t)
+ if code, _ := maint.json(t, "POST", "/api/v1/club/review/"+id, map[string]any{"decision": "reject", "models": []string{"anjoy-ms-j10"}}); code != 200 {
+ t.Fatalf("reject: %d", code)
+ }
+ var links int
+ _ = e.pool.QueryRow(context.Background(), `SELECT count(*) FROM report_models WHERE report_id = $1`, id).Scan(&links)
+ if links != 0 {
+ t.Errorf("a rejected report has %d links", links)
+ }
+}
diff --git a/service/internal/reports/club.go b/service/internal/reports/club.go
index 9a921528..bc98384f 100644
--- a/service/internal/reports/club.go
+++ b/service/internal/reports/club.go
@@ -364,38 +364,11 @@ func (s *Store) Decide(ctx context.Context, id, decision, by, note string, model
if err != nil {
return d, err
}
- // The board a review adds, the report's links and the review are one
- // transaction: a board that cannot be added, or a link to one the
- // catalogue does not have, leaves nothing behind.
- err = pgx.BeginFunc(ctx, s.DB, func(tx pgx.Tx) error {
- if newBoard != nil {
- id, err := boards.CreateModel(ctx, tx, *newBoard, s.SoC)
- if err != nil {
- return err
- }
- d.Board, models = id, append(models, id)
- }
- for _, m := range models {
- if _, err := tx.Exec(ctx, `INSERT INTO report_models (report_id, model_id, by) VALUES ($1,$2,$3)
- ON CONFLICT DO NOTHING`, id, m, by); err != nil {
- var pe *pgconn.PgError
- if errors.As(err, &pe) && pe.Code == "23503" {
- return fmt.Errorf("link %s: the catalogue has no such board", m)
- }
- return fmt.Errorf("link %s: %w", m, err)
- }
- }
- _, err := tx.Exec(ctx, `INSERT INTO report_reviews (report_id, decision, by, note) VALUES ($1,$2,$3,$4)`,
- id, decision, by, note)
- return err
- })
- if err != nil {
- d.Board = ""
- return d, err
- }
- if owner == "" {
- return d, nil
- }
+ // The board a review adds, the report's links, the review and the
+ // sender's stars are one transaction: a board that cannot be added, a
+ // link to one the catalogue does not have, or a ledger that cannot be
+ // written leaves nothing behind, and the same decision can be made again.
+ //
// 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
@@ -405,6 +378,32 @@ func (s *Store) Decide(ctx context.Context, id, decision, by, note string, model
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('report-stars:' || $1, 0))`, id); err != nil {
return err
}
+ if decision == "publish" {
+ if newBoard != nil {
+ board, err := boards.CreateModel(ctx, tx, *newBoard, s.SoC)
+ if err != nil {
+ return err
+ }
+ d.Board, models = board, append(models, board)
+ }
+ for _, m := range models {
+ if _, err := tx.Exec(ctx, `INSERT INTO report_models (report_id, model_id, by) VALUES ($1,$2,$3)
+ ON CONFLICT DO NOTHING`, id, m, by); err != nil {
+ var pe *pgconn.PgError
+ if errors.As(err, &pe) && pe.Code == "23503" {
+ return fmt.Errorf("link %s: the catalogue has no such board", m)
+ }
+ return fmt.Errorf("link %s: %w", m, err)
+ }
+ }
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO report_reviews (report_id, decision, by, note) VALUES ($1,$2,$3,$4)`,
+ id, decision, by, note); err != nil {
+ return err
+ }
+ if owner == "" {
+ return nil
+ }
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
@@ -452,8 +451,12 @@ func (s *Store) Decide(ctx context.Context, id, decision, by, note string, model
return nil
})
if err != nil {
+ d.Board, d.Points = "", 0
return d, err
}
+ if owner == "" {
+ return d, nil
+ }
err = s.DB.QueryRow(ctx, `SELECT coalesce(sum(points), 0) FROM report_stars WHERE member_id = $1`, owner).Scan(&d.Total)
return d, err
}
diff --git a/service/internal/reports/parse.go b/service/internal/reports/parse.go
index d32fb07d..c9ac7420 100644
--- a/service/internal/reports/parse.go
+++ b/service/internal/reports/parse.go
@@ -306,14 +306,33 @@ func (f Facts) IDHashes(key string) map[string]string {
// naming its keyed hash, so the public copy still shows that two reports
// come from one board. A MAC is found with any separator or none -- colons,
// hyphens, dots, Cisco's 0012.3456.789a -- in either case.
+//
+// A MAC written as six colon- or hyphen-separated pairs is replaced even when
+// ipctool's output did not name it: a boot log or a U-Boot environment sent
+// without ipctool (to a catalogue board, or with a new camera's photos)
+// carries its ethaddr all the same.
func Redact(text string, f Facts, key string) string {
for _, id := range f.Identifiers() {
mark := "<" + id.Name + ":" + Keyed(key, id.Name, id.Value) + ">"
text = spellings(id).ReplaceAllString(text, mark)
}
+ for _, re := range anyMAC {
+ text = re.ReplaceAllStringFunc(text, func(m string) string {
+ if zeroish(m) {
+ return m
+ }
+ v := strings.ToLower(strings.ReplaceAll(m, "-", ":"))
+ return ""
+ })
+ }
return text
}
+var anyMAC = []*regexp.Regexp{
+ regexp.MustCompile(`(?i)\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b`),
+ regexp.MustCompile(`(?i)\b(?:[0-9a-f]{2}-){5}[0-9a-f]{2}\b`),
+}
+
func spellings(id Identifier) *regexp.Regexp {
v := strings.TrimSpace(id.Value)
if id.Name == "mac" {
diff --git a/service/internal/reports/parse_test.go b/service/internal/reports/parse_test.go
index b9e6fccf..80b367f2 100644
--- a/service/internal/reports/parse_test.go
+++ b/service/internal/reports/parse_test.go
@@ -211,3 +211,16 @@ func TestAnIdentifierARepeatedKeyShadowsIsRedactedToo(t *testing.T) {
t.Errorf("the mac hash is %s, not the board's own %s", got, want)
}
}
+
+// Without ipctool's output there are no facts to redact by, and a boot log
+// or a U-Boot environment still carries the camera's MAC: any MAC written
+// as six pairs is replaced, the same board hashing the same in either case.
+func TestAMACIsRedactedWithoutIpctoolsOutput(t *testing.T) {
+ env := "bootargs=mem=64M console=ttyAMA0\nethaddr=00:12:41:AB:cd:ef\nother=00-12-41-ab-cd-ef\nnone=00:00:00:00:00:00\n"
+ got := Redact(env, Facts{}, "k")
+ mark := ""
+ if strings.Contains(strings.ToLower(got), "41:ab:cd") || strings.Contains(strings.ToLower(got), "41-ab-cd") ||
+ strings.Count(got, mark) != 2 || !strings.Contains(got, "none=00:00:00:00:00:00") {
+ t.Errorf("redacted:\n%s", got)
+ }
+}