ImobView°
Encontre imóveis para comprar ou alugar com busca inteligente, tours 360° e contato direto com imobiliárias e corretores.
翻译准备中
制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗Every year a LUG's organizers sort a big LUGBulk delivery into one bag per member. This app turns the group's order sheet into everything needed for that: a printable label for every bag (part photo, element ID, colors, quantity, whose it is, "3 of 10"), a per-person packing checklist, a parts list and lot counts.
.xlsx or
.csv; it's read in your browser and never leaves it.It's the web counterpart of lugbulk-labels-cli, the command-line version.
AI disclaimer: This project was built with assistance from Claude (Anthropic). Review the code before relying on it, especially the OAuth flow, token storage, and the in-browser PDF generation logic.
Go to lugbulk.aronwk.com, or run your own copy. There are two ways in:
.xlsx (Google Sheets: File → Download → Microsoft Excel) or a
.csv of its "Order Here" tab, pick a label design, and download labels
and reports. The file is read in your browser and never sent anywhere.
Up to 10 MB; an .xlsx may not unpack past 64 MB.keep_parts: off | optimize)..zip. Everything — labels, reports and
the zip — is made in the browser (the server no longer makes any PDF or
zip): an uploaded file never leaves it, and for a saved Google Sheet the
server only passes on the sheet's cells (/sheets/:id/values), BrickLink
data (/bricklink/lookup) and part photos (/img/<id>.jpg). Label PDFs are built in a Web Worker
(static/js/labels_worker.js) with progress ("Fetching photos 120/800",
"Page 3 of 40"); a saved sheet's download is logged
(POST /sheets/:id/runs, the kind and a count only) for its "Last
download" line.Part weight comes from an optional Weight column (grams per piece) on
the sheet, then the BrickLink catalog (if configured, below), then an
estimate from the description's stud dimensions (PLATE 4X8,
BRICK 1X1X1 2/3); parts with none of those sort last. Whichever of the
LEGO Color/BL Color columns is blank is filled from the color table in
src/colors.cpp, or failing that from BrickLink.
The app is a single Docker container. Run the published image:
docker run -d --name lugbulk -p 8080:8080 -v lugbulk-data:/data --env-file .env \
ghcr.io/aronwk-aaron/lugbulk-labels-web:latest # or :canary, or :1.2.0
or with the compose file: LUGBULK_TAG=canary docker compose pull && docker compose up -d.
/data holds the SQLite database, the part-photo cache and the optional
BrickLink catalog. The app serves plain HTTP on port 8080; put it behind a
reverse proxy for TLS.
Copy .env.example to .env; every setting is explained
there.
Without GOOGLE_OAUTH_CLIENT_ID/_SECRET the app runs upload-only (no
TOKEN_ENCRYPTION_KEY needed then). Set PUBLIC_URL to the app's public
address (e.g. https://lugbulk.example.org); it defaults to the origin of
GOOGLE_OAUTH_REDIRECT_URI when Google is on. Adding a Google Sheet also
needs GOOGLE_API_KEY and GOOGLE_APP_ID for the Google Picker (see
Google OAuth setup); without them, signed-in users
can still open sheets already in their list.
With Google sign-in on, set ALLOWED_EMAILS to your organizers' addresses
(or @domains), and TRUST_PROXY=1 behind a reverse proxy — see
Security and abuse limits.
openid, .../auth/userinfo.email and .../auth/drive.file
only. drive.file is non-sensitive, so the app can be published
(Audience → Publish app) without restricted-scope verification.GOOGLE_OAUTH_REDIRECT_URI (e.g. http://localhost:8080/auth/callback
for local dev; your real domain's callback URL in production)..env.https://lugbulk.example.org/*); API restrictions → Google Picker
API only. Put it in GOOGLE_API_KEY. It is sent to signed-in users'
browsers, which is how Picker keys work — the restrictions are what
protect it.GOOGLE_APP_ID is the project number (not the project id): Cloud
Console → the project's Dashboard / IAM & Admin → Settings."Google hasn't verified this app" screen: it shows while the consent
screen is in Testing (only listed test users can sign in; click
Advanced → "Go to lugbulk-labels-web (unsafe)"). Since the app only
asks for openid, email and drive.file — none of them sensitive or
restricted — publishing it (Audience → Publish app) makes sign-in open
to any Google account without the security assessment that restricted
Drive scopes need. Use ALLOWED_EMAILS to keep it to your organizers.
Real part weights (for part order) and BrickLink color names (for colors a sheet is missing) come from BrickLink's catalog download — its API is for sellers only, but any free account can download the catalog at bricklink.com/catalogDownload.asp as Tab-Delimited File:
Put both in <data dir>/bricklink/ (/data/bricklink/ in the container) (e.g.
/mnt/user/appdata/lugbulk-labels-web/bricklink/). Any file names work —
they're recognised by their header row — and replacing them is picked up
automatically on the next report. Without them, weights are estimated.
cp .env.example .env # fill in Google OAuth client id/secret (see below)
docker compose up --build
Serves on http://localhost:8080. /healthz returns 200 ok once the
container is up.
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"
./build/lugbulk_labels_web
Requires: a C++20 compiler, CMake ≥ 3.20, and dev packages for OpenSSL,
SQLite3, libcurl, zlib and standalone Asio (libssl-dev libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libasio-dev on Debian/Ubuntu).
Unit tests (sheet pivoting, colors, ordering, the photo cache, design storage, rate limits...) build alongside the server and run in the Docker build:
ctest --test-dir build --output-on-failure
The browser code has its own tests (Node 22+, no install):
node --test tests/js/ # unit tests + the checked-in fixtures
./build/lugbulk_golden golden && GOLDEN_DIR=golden node --test tests/js/ # + parity with the C++
tests/js/golden/ holds fixtures dumped once from the C++ before its PDF,
CSV and label-layout code was retired (labels.json, reports.json); the
JavaScript is their reference now. tests/js/golden/regenerate-labels.mjs
rewrites the label layouts from it after an intended change to the layout
or the font widths (static/js/afm.js).
.github/workflows/docker-publish.yml builds, tests and publishes the
image to GitHub Container Registry with the repo's built-in
GITHUB_TOKEN (no registry secrets):
| Event | Image tags | GitHub release |
|---|---|---|
| Pull request | — (build + tests + smoke test only) | — |
Push to master | canary, short SHA | Rolling canary pre-release, always the head of master, with the changes since the last release |
Push a vX.Y.Z tag | X.Y.Z, X.Y, X, latest | Release vX.Y.Z with generated notes |
Push a vX.Y.Z-rc.N tag | X.Y.Z-rc.N | Pre-release |
latest only ever moves on a release. Every release attaches
docker-compose.yml and env.example (save it as .env). The running build's version is
shown in the dashboard header and at /version (1.2.0,
canary-<sha>, or dev for local builds).
To cut a release:
git tag v1.2.0 && git push origin v1.2.0
Before anything is pushed, CI runs the unit tests (inside the image
build), boots the image, and checks /healthz, /version, the login
redirect, and that it runs as the unprivileged lugbulk user.
The container starts as root only long enough to hand /data to
lugbulk (so volumes from older, root-run images keep working), then drops
privileges. It has a Docker HEALTHCHECK on /healthz.
FetchContent (header-only, not vendored in-repo)static/js/vendor/). The server itself makes no PDFs./data as a mounted volume (SQLite DB + image cache).Each user authenticates via Google OAuth and authorizes read access to
their own sheets — there is no shared service account like the CLI uses.
OAuth scopes requested: openid email (identity) and drive.file —
Google's non-sensitive per-file scope, which only reaches the files the
user opens with this app through the
Google Picker. The app never
sees the rest of their Drive; the Sheets API reads a picked sheet under
drive.file. Because no sensitive or restricted scope is requested, the
OAuth app can be published without Google's restricted-scope verification.
Users who signed in before the switch (when the app asked for
spreadsheets.readonly + drive.metadata.readonly) are sent through
sign-in again the first time they open the Picker, and a saved sheet Google
refuses to open must be chosen again with Pick a Google Sheet…. The "Order Here" tab's columns are found by header text (Element ID /
Part Number, Description, LEGO Color, BL Color, Weight), and both
known ways of laying out people are recognized: a qty marker row under
the names (the ArkLUG sheet) or (name, running cost) header pairs (2026's
master sheet) — see src/sheet_pivot.h.
Flow: login → Pick a Google Sheet… opens the Google Picker (the page
gets a short-lived drive.file token, the API key and the app id from
GET /auth/picker-token) → save the picked sheet (POST /sheets) →
dashboard listing saved sheets → per sheet, the browser reads the live
sheet's cells (GET /sheets/:id/values), pivots it, asks for BrickLink
data (POST /bricklink/lookup) and part photos (GET /img/<id>.jpg), and
makes the labels, reports and zip itself. The label design is saved with
the sheet (GET/PUT /sheets/:id/design). Nothing generated is stored;
runs (see sql/schema.sql) is a history log only — timestamp, report
type, item count, status (POST /sheets/:id/runs) — not a file store.
Access tokens are never persisted. Only the (encrypted) OAuth refresh token is stored; a short-lived access token is minted from it in-memory on each request that needs to call Google, used immediately, and discarded.
Data model: see sql/schema.sql — users,
sheets, runs, sessions.
The sheet pivot, ordering, color table and label layout mirror the CLI's
pivot.py, ordering.py, colors.py and render_labels.py; keep the
two in step when changing either. In this repo the JavaScript in
static/js/ is the reference: the C++ pivot/ordering/colors/spreadsheet
code that remains is checked against it by the parity tests (see Building
without Docker).
Not carried over from the CLI: the full --manifest summary report,
--per-person PDFs, --sort-by toggle (last-name order only), and
per-event color/weight overrides (fix the sheet instead).
Reading a sheet costs a Google Sheets call, and a big sheet asks for hundreds of part photos (LEGO's CDN) and BrickLink lookups, so the server limits what any one person — or script — can make it do:
| What | Limit |
|---|---|
| Who can sign in | ALLOWED_EMAILS (addresses and/or @domains); verified Google email required. Removing someone locks them out immediately. Set this — unset lets any Google account in and logs a warning at startup. |
| Reading a saved sheet | 6 at once, then one per 2 minutes, per user; one running per user; MAX_CONCURRENT_JOBS (default 2) server-wide. Over the limit gets an immediate "try again" (HTTP 429), never a queue. |
| Uploads | Never reach the server: the file is read in the browser |
| Google Picker tokens / adding sheets | 10 at once, then one per 3 seconds, per user |
| Any request | 120 at once, then 10/second, per client IP (sign-in routes: 10, then one per 6 seconds) |
| Request body | 64 KB (Crow patched at build time — cmake/patch_crow.cmake); bigger requests are dropped |
| Sheet size | 3,000 rows read; 20,000 labels / 2,000 parts per run; 32 MB Google response |
| Saved sheets | 50 per user (only sheets your Google account can open); 10 sessions per user |
| BrickLink lookups / design saves | 20, then 1/second, per visitor / user |
| Part photos | only digit element IDs, only from LEGO's CDN over HTTPS, 2 MB max |
Behind a reverse proxy, set TRUST_PROXY=1 so limits apply per visitor
rather than to the proxy's single IP (only with a proxy that sets
X-Forwarded-For — otherwise clients could fake it). A proxy is also the
place for TLS and connection limits; the app itself serves plain HTTP.
Keeping each organizer's data private:
Cache-Control: no-store, private), so a shared computer's Back button or a caching proxy
can't show one organizer's data to another. (Only public, identical-for-
everyone responses are cacheable: LEGO part photos under /img/ and the
label stock list /label-specs.json.) Logging out also clears the browser cache
for the site, and sign-ins last 14 days.Hardening: a strict Content-Security-Policy (the dashboard's inline
scripts run only with a per-response nonce, plus the app's own
/static/js/ modules; no other script can), nosniff,
X-Frame-Options: DENY, HSTS over https, and a text/plain default;
state-changing requests from another origin are refused; session cookies
are HttpOnly, SameSite=Lax (Secure over https) and stored hashed; refresh
tokens are AES-256-GCM encrypted at rest. Third-party code is pinned by
commit (GitHub Actions, Crow, the QR library) and kept current by
Dependabot.
| Path | Purpose |
|---|---|
src/main.cpp | Entry point, route definitions, security-header middleware |
src/config.{h,cpp} | Env-var configuration loading |
src/crypto.{h,cpp} | AES-256-GCM refresh-token encryption, SHA-256, base64, CSPRNG tokens |
src/db.{h,cpp} | SQLite access layer (users/sheets/sessions/runs) and schema migrations |
src/oauth.{h,cpp} | Google OAuth token exchange + userinfo + Drive sheet search + Sheets read |
src/sheet_layout.{h,cpp} | Header names, qty marker, label stock inventory loading/lookup |
data/label_specs.json | Avery/Dymo label stock inventory (generated by the CLI's tools/update_label_specs.py) |
src/sheet_pivot.{h,cpp} | Sheet rows → one record per (person, part), plus data issues |
src/colors.{h,cpp} | LEGO ↔ BrickLink color name table |
src/ordering.{h,cpp} | Part weight estimates, label order, "N of M" numbering, per-part summaries |
src/label_options.{h,cpp} | Which label parts a saved design switches on or off (validates PUT /sheets/:id/design) |
src/part_images.{h,cpp} | The shared part-photo cache behind /img/<id>.jpg |
static/js/packing.js | "Keep each part on one sheet": exact bin packing of parts onto sheets (bounds, bin completion over size counts, closest-to-order tie-break, time box); run in the labels worker; tests/js/packing.test.mjs |
static/js/labels.js, backdrop.js, labels_worker.js, afm.js | Label PDFs in the browser (pdf-lib, Nayuki's qrcodegen), the gray tile behind light parts, the Web Worker that runs them, and the Helvetica widths (Adobe AFM, by WinAnsi code) labels and reports measure text with; checked by tests/js/labels.test.mjs, afm.test.mjs |
src/bricklink.{h,cpp} | Reads BrickLink's catalog download files: weights and colors |
src/json_check.{h,cpp} |
Copyright (C) 2026 Aaron Kimbrell.
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3 or (at your option) any later version. It is distributed WITHOUT ANY WARRANTY; see LICENSE for the full terms.
Because it's the AGPL, anyone running a modified copy as a web service must offer its users that version's source code — the dashboard and the terms page link to it.
Bundled or fetched components keep their own licenses: Crow (BSD-3-Clause),
pdf-lib (MIT), Nayuki's QR Code generator (MIT), and the gLabels label
template database behind data/label_specs.json (MIT). The Helvetica widths
in static/js/afm.js are from Adobe's Core 14 AFM files.
data/label_specs.json, generated from the gLabels template database by
the CLI's tools/update_label_specs.py.Price / Cost Each column (not
BL Price or B&P Price); rows without an element ID, like fees and
shipping, don't count. All in the browser, nothing stored. The tables
sort and filter in the browser (DataTables); people are picked from
searchable lists; the price tables can be narrowed to one person or
family. Families can be set up here too, and the viewer
chooses whether a family shows as one person or as its members.Price / Cost Each column, with the sheet's currency
sign) and Lot price (that label's quantity times it), printed as
"$0.10 each · Lot $2.00". The same switches carry over to the other
printables: the packing checklist gets Each and Lot price columns and each
person's total, the parts list Each and Total price columns and a grand
total, and lot counts a Total price column (also in the CSVs). The page
says so when a sheet has no price column. Saved with the report settings rather than the
label design's hide list, so designs saved earlier don't start showing them.| Strict JSON check for the report options saved with a sheet's design |
src/spreadsheet.{h,cpp} | Reads .xlsx (bounded unzip + SpreadsheetML) and .csv — no longer used by a route; kept as the reference static/js/spreadsheet.js is tested against |
src/records.{h,cpp} | Row cap, per-run size limits, BrickLink data on records, the "Check sheet" JSON |
static/js/ | Browser ports of the pure data logic (plain ES modules, served at /static/js/<name>.js): pivot.js, ordering.js, colors.js, layout.js, records.js, spreadsheet.js, load.js, and the reports (reports.js, report_options.js, printf.js) and zip writer (zip.js) |
static/js/groups.js, groups_ui.js, combo.js | Families: saved with the report options and applied to the records before anything is made, their editor, and the searchable person dropdown it and Compare years use; tests/js/groups.test.mjs |
static/js/years.js, years_page.js | "Compare years": several sheets combined (people by name plus merges, parts by element ID, prices from the sheet's price column) and its tables and CSVs; the dashboard section that shows them; tests/js/years.test.mjs |
static/js/vendor/ | Third-party browser modules, pinned and unmodified: pdf-lib.js, qrcodegen.js, datatables.js (see its README) |
tests/tests.cpp | Unit tests (ctest) |
tests/golden.cpp | lugbulk_golden <dir>: writes what the C++ makes of each fixture, for the JS parity test |
tests/js/ | node --test tests for static/js/; GOLDEN_DIR=<dir> checks it matches the C++ (CI does this); tests/js/golden/ holds the checked-in fixtures |
tests/fixtures/ | Invented order sheets (.xlsx, .csv) used by the tests |
CMakeLists.txt | Build config; fetches Crow, locates SQLite3/CURL/OpenSSL/zlib/Asio |
sql/schema.sql | SQLite schema: users, sheets, runs, sessions |
templates/dashboard.html | Mustache template for the logged-in dashboard page (Crow's bundled crow::mustache) |
Dockerfile | Multi-stage build (Debian bookworm base); tests run during the build |
src/rate_limits.{h,cpp} | Rate limiter, job gate, sign-in allowlist |
cmake/patch_crow.cmake | Caps Crow's request body size |
docker/entrypoint.sh | Fixes /data ownership, then drops to the lugbulk user |
.github/workflows/docker-publish.yml | CI: builds, tests, publishes the image to GHCR, and creates canary/versioned GitHub releases |
docker-compose.yml | Local dev convenience — build + run with a persistent volume |
.env.example | Template for OAuth client credentials and the token-encryption key |