Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions data/locales/boards.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ en:
looks_like: "Looks like {board} in the catalogue"
looks_like_why: "not certain until reviewed"
filed_under: "Filed under"
owner_note: 'The owner''s note:'
not_found: "No report has this receipt. Check the address ipctool printed."
load_failed: "The report could not be loaded. Try again in a minute."
reports_title: "Reports from owners"
Expand Down Expand Up @@ -547,6 +548,7 @@ en:
rename_label: 'Your name, as it appears on the boards your reports reach'
rename_save: 'Save'
review_note_label: 'Reviewer'
your_note: 'Your note'
cameras_title: 'My cameras on the Open Wall'
cameras_count: '{n} of {max} count towards stars'
cameras_empty: 'No camera linked yet. Link one below: it earns stars for every month it keeps sending real pictures to the Open Wall.'
Expand Down
2 changes: 2 additions & 0 deletions data/locales/boards.ru.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ ru:
looks_like: "Похоже на {board} из каталога"
looks_like_why: "точно станет ясно после проверки"
filed_under: "Привязан к"
owner_note: 'Примечание владельца:'
not_found: "Отчёта с такой квитанцией нет. Проверьте адрес, который напечатал ipctool."
load_failed: "Не удалось загрузить отчёт. Попробуйте через минуту."
reports_title: "Отчёты владельцев"
Expand Down Expand Up @@ -588,6 +589,7 @@ ru:
rename_label: 'Ваше имя — так оно показывается у плат, куда попали ваши материалы'
rename_save: 'Сохранить'
review_note_label: 'Проверяющий'
your_note: 'Ваше примечание'
cameras_title: 'Мои камеры на Open Wall'
cameras_count: 'Звёзды приносят {n} из {max}'
cameras_empty: 'Пока ни одной камеры. Привяжите камеру ниже: она будет приносить звёзды за каждый месяц, в который присылает на Open Wall настоящие снимки.'
Expand Down
2 changes: 2 additions & 0 deletions data/locales/boards.zh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ zh:
looks_like: "看起来像目录中的 {board}"
looks_like_why: "审核后才能确定"
filed_under: "归属"
owner_note: '所有者的备注:'
not_found: "没有与此回执对应的报告。请检查 ipctool 打印的地址。"
load_failed: "无法加载报告。请稍后再试。"
reports_title: "用户报告"
Expand Down Expand Up @@ -550,6 +551,7 @@ zh:
rename_label: '你的名字,会显示在你的提交所属的电路板上'
rename_save: '保存'
review_note_label: '审核者'
your_note: '你的备注'
cameras_title: '我在 Open Wall 上的摄像头'
cameras_count: '{n} / {max} 台计入星星'
cameras_empty: '还没有绑定摄像头。在下方绑定一台:它每持续一个月向 Open Wall 发送真实画面,就会获得星星。'
Expand Down
3 changes: 3 additions & 0 deletions frontend/apps/site/src/components/club/Club.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,9 @@ function Ledger({ reports, locale, t }: { reports: MemberReport[]; locale: Local
</li>
))}
</ul>
{r.note && (
<span class="text-[12.5px] whitespace-pre-wrap text-body"><b class="font-semibold">{t('club.your_note')}:</b> {r.note}</span>
)}
{r.duplicate && <span class="text-[12.5px] text-body-secondary">{t('club.duplicate')}</span>}
{r.status !== 'rejected' && !r.joins && <IpctoolCode joins={r.id} locale={locale} t={t} label={t('club.code_add_ipctool')} />}
{r.review_note && (
Expand Down
42 changes: 42 additions & 0 deletions frontend/apps/site/src/components/club/Notes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
/**
* The note a sender types under a send -- where it was bought, what it is
* sold as -- is shown: to them on /club as they wrote it, and on the report's
* page once published (the service serves the redacted copy only then).
*/
import { afterEach, expect, test, vi } from 'vitest';
import { cleanup, render } from '@testing-library/preact';
import Club from './Club.tsx';
import Receipt from '../reports/Receipt.tsx';

afterEach(() => { cleanup(); vi.unstubAllGlobals(); localStorage.clear(); history.replaceState(null, '', '/'); });

const member = { id: 'm-1', name: 'Andrei', maintainer: false, quiet: false, identities: [], stars: 0, report_stars: 0, wall_stars: 0, pending: 1 };

test('the sender sees their note beside the report on /club', async () => {
localStorage.setItem('openipc.club', '1');
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url === '/api/v1/club/me') return new Response(JSON.stringify({ member, sign_in: { telegram: null, github: false, email: true } }));
if (url === '/api/v1/club/reports') {
return new Response(JSON.stringify({ member, reports: [{
id: 'r-abcd2345', received_at: '2026-10-08T10:00:00Z', status: 'pending', note: 'bought on Ozon as Jooan Q9',
proposal: { maker: 'Jooan', board: 'Q9' }, files: [], stars: 0, pending: 1,
}] }));
}
if (url === '/api/v1/club/cameras') return new Response(JSON.stringify({ cameras: [], code: null, listed: true, max_cameras: 3 }));
return new Response(JSON.stringify({}));
}));
const { findByText } = render(<Club locale="en" />);
expect((await findByText('bought on Ozon as Jooan Q9')).closest('span')?.textContent).toBe('Your note: bought on Ozon as Jooan Q9');
});

test('a published report shows its owner\'s note on its page', async () => {
history.replaceState(null, '', '/cameras/report/?id=r-abcd2345');
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
schema: 1, id: 'r-abcd2345', received_at: '2026-10-08T10:00:00Z', channel: 'web', status: 'published',
backup_consent: 'none', note: 'bought on Ozon, MAC <mac:a0998e99069cb60d>', files: [],
models: [{ id: 'jooan-q9', model: 'Q9', manufacturer: 'Jooan' }], same_board: 0,
}))));
const { findByText } = render(<Receipt locale="en" />);
expect((await findByText(/bought on Ozon/)).textContent).toBe("The owner's note: bought on Ozon, MAC <mac:a0998e99069cb60d>");
});
3 changes: 3 additions & 0 deletions frontend/apps/site/src/components/reports/Receipt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ export default function Receipt({ locale }: { locale: Locale }) {
</table>
</div>

{r.note && (
<p class="m-0 mt-5 text-[.9375rem] whitespace-pre-wrap"><b class="font-semibold">{t('report.owner_note')}</b> {r.note}</p>
)}
{published && r.models.length > 0 && (
<p class="m-0 mt-5 rounded-lg border border-hairline bg-surface-alt px-4 py-3 text-[.9375rem]">
{t('report.filed_under')}{' '}
Expand Down
4 changes: 3 additions & 1 deletion frontend/apps/site/src/i18n/boards.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,8 @@
"why_2": "Keep your flash dumps private: only you and OpenIPC's maintainers can download them.",
"why_3": "Collect stars for what you document and for keeping a camera on the Open Wall, 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"
"why_title": "Why sign in",
"your_note": "Your note"
},
"cov_boot_log": "Boot log",
"cov_document": "Docs",
Expand Down Expand Up @@ -458,6 +459,7 @@
"lookup_button": "Show its state",
"lookup_label": "Have a receipt?",
"not_found": "No report has this receipt. Check the address ipctool printed.",
"owner_note": "The owner's note:",
"received": "Received from {channel}",
"reports_app": "app",
"reports_ask": "Have this board?",
Expand Down
4 changes: 3 additions & 1 deletion frontend/apps/site/src/i18n/boards.ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@
"why_2": "Держать дампы флеш-памяти закрытыми: скачать их можете только вы и мейнтейнеры OpenIPC.",
"why_3": "Получать звёзды за описанные платы и за камеру на Open Wall и видеть своё имя рядом с платами, которые вы описали.",
"why_note": "Чтобы смотреть каталог и скачивать прошивки, учётная запись не нужна. Тем, кто не входит, сайт не ставит ни одной cookie.",
"why_title": "Зачем входить"
"why_title": "Зачем входить",
"your_note": "Ваше примечание"
},
"cov_boot_log": "Лог загрузки",
"cov_document": "Документы",
Expand Down Expand Up @@ -480,6 +481,7 @@
"lookup_button": "Показать состояние",
"lookup_label": "Есть квитанция?",
"not_found": "Отчёта с такой квитанцией нет. Проверьте адрес, который напечатал ipctool.",
"owner_note": "Примечание владельца:",
"received": "Получен от {channel}",
"reports_app": "приложение",
"reports_ask": "Есть такая плата?",
Expand Down
4 changes: 3 additions & 1 deletion frontend/apps/site/src/i18n/boards.zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,8 @@
"why_2": "让固件转储保持私密:只有你和 OpenIPC 维护者可以下载。",
"why_3": "为你记录的电路板和保持在线的 Open Wall 摄像头获得星星,并在你记录的电路板旁显示你的名字。",
"why_note": "浏览目录或下载固件无需账户。对不登录的访客,本站不设置任何 cookie。",
"why_title": "为什么要登录"
"why_title": "为什么要登录",
"your_note": "你的备注"
},
"cov_boot_log": "启动日志",
"cov_document": "文档",
Expand Down Expand Up @@ -458,6 +459,7 @@
"lookup_button": "查看状态",
"lookup_label": "有回执?",
"not_found": "没有与此回执对应的报告。请检查 ipctool 打印的地址。",
"owner_note": "所有者的备注:",
"received": "来自 {channel}",
"reports_app": "应用",
"reports_ask": "有这块板?",
Expand Down
4 changes: 3 additions & 1 deletion service/cmd/openipc/club.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ func refreshReportUnits(ctx context.Context, cfg *config.Config, log *slog.Logge
}
// 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,
// The sender's note -- where it was bought, what it is sold as -- is
// the unit's, as published (redacted).
c := boards.Contribution{Unit: t.Model + "-" + t.Report, Model: t.Model, By: by, Note: t.Note,
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})
Expand Down
51 changes: 51 additions & 0 deletions service/internal/club/newcamera_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,54 @@ func TestANewCameraComesWithAProgrammersDump(t *testing.T) {
t.Errorf("published texts: %+v (%v)", texts, err)
}
}

// The note under a send -- where it was bought, what it is sold as -- is
// the sender's to see on /club as written, and once published it goes onto
// the board's unit redacted: a MAC pasted into it never reaches the page.
func TestTheSendersNoteIsShownAndPublishedRedacted(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
ivan := e.signedIn(t, 777, "ivan_k", "198.51.100.1")
note := "bought on Ozon as Jooan Q9, MAC 00:12:41:ab:cd:ef"
_, out := ivan.send(t, map[string]string{"channel": "web", "model": "anjoy-ms-j10", "note": note}, map[string][]byte{"photo": photo})
id := out["id"].(string)
if _, mine := ivan.json(t, "GET", "/api/v1/club/reports", nil); mine["reports"].([]any)[0].(map[string]any)["note"] != note {
t.Errorf("the sender's own note: %v", mine["reports"])
}
maint := e.maintainer(t)
if code, _ := maint.json(t, "POST", "/api/v1/club/review/"+id, map[string]any{"decision": "publish"}); code != 200 {
t.Fatal("publish")
}
texts, err := e.api.Reports.Store().PublishedTexts(ctx)
if err != nil || len(texts) != 1 {
t.Fatalf("%v %v", texts, err)
}
if n := texts[0].Note; !strings.HasPrefix(n, "bought on Ozon as Jooan Q9, MAC <mac:") || strings.Contains(n, "ab:cd:ef") {
t.Errorf("the board's note: %q", n)
}
}

// A published report with a note and no public file -- a note alone, or one
// beside a private dump -- still makes a unit on its board, for its note;
// the dump never goes with it.
func TestANoteWithoutAPublicFileStillReachesTheBoard(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
ivan := e.signedIn(t, 777, "ivan_k", "198.51.100.1")
maint := e.maintainer(t)
for _, files := range []map[string][]byte{nil, {"backup": bytes.Repeat([]byte{0x5a}, 1<<20)}} {
_, out := ivan.send(t, map[string]string{"channel": "web", "model": "anjoy-ms-j10", "note": "sold as Jooan Q9"}, files)
if code, d := maint.json(t, "POST", "/api/v1/club/review/"+out["id"].(string), map[string]any{"decision": "publish"}); code != 200 {
t.Fatalf("publish: %d %v", code, d)
}
}
texts, err := e.api.Reports.Store().PublishedTexts(ctx)
if err != nil || len(texts) != 2 {
t.Fatalf("%+v %v", texts, err)
}
for _, x := range texts {
if x.Note != "sold as Jooan Q9" || len(x.Files) != 0 || x.Model != "anjoy-ms-j10" {
t.Errorf("%+v", x)
}
}
}
30 changes: 19 additions & 11 deletions service/internal/reports/club.go
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,9 @@ type BoardText struct {
Report string
Model string
By string
Files []BoardTextFile
// Note is the sender's note as published: redacted (note_public).
Note string
Files []BoardTextFile
}

type BoardTextFile struct {
Expand All @@ -487,16 +489,19 @@ type BoardTextFile struct {
}

// PublishedTexts lists, for every published report linked to a board, its
// served text and photo files. A private backup is never among them.
// served text and photo files and its published note. A report with a note
// and no such file -- a note alone, or one beside a private backup -- is
// listed too, for its note. 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
SELECT r.id, rm.model_id, coalesce(cm.name, ''), r.note_public, f.position, f.kind, f.name, f.public_sha256
Comment thread
widgetii marked this conversation as resolved.
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_files f ON f.report_id = r.id
AND f.public_sha256 IS NOT NULL AND f.kind IN ('photo', 'boot_log', 'uboot_env', 'note')
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')
WHERE (f.report_id IS NOT NULL OR r.note_public <> '')
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 {
Expand All @@ -505,16 +510,19 @@ func (s *Store) PublishedTexts(ctx context.Context) ([]BoardText, error) {
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 {
var id, model, by, note string
var pos *int
var kind, name, sum *string
if err := rows.Scan(&id, &model, &by, &note, &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})
out = append(out, BoardText{Report: id, Model: model, By: by, Note: note})
}
if pos != nil {
t := &out[len(out)-1]
t.Files = append(t.Files, BoardTextFile{Position: *pos, Kind: *kind, Name: *name, Path: Rel(*sum)})
}
t := &out[len(out)-1]
t.Files = append(t.Files, BoardTextFile{Position: pos, Kind: kind, Name: name, Path: Rel(sum)})
}
return out, rows.Err()
}
Loading