مقایسه کامیت‌‌ها

..
7 کامیت‌ها
مولف SHA1 پیام تاریخ
nearology 19824bd180 اضافه شدن قابلیت share و نگهداری بلندمدت
Verify / test (push) Failing after 22s
2026-11-09 14:55:36 +03:30
nearology a4e8f0c6c0 اضافه شدن قابلیت حافظه S3 به پروژه
Verify / test (push) Failing after 19s
2026-11-09 13:32:00 +03:30
nearology ea9fb855b3 توسعه صفحه فرانتاند اولیه برای تست وبسرویس ها
Verify / test (push) Failing after 18s
2026-11-09 12:40:29 +03:30
nearology 4eb2cb3d6f اضافه شدن قابلیت رندر پی سی بی
Verify / test (push) Failing after 20s
2026-11-09 12:34:50 +03:30
nearology a599086c11 اصلاح هدر ها برای نمایش ایفریم
Verify / test (push) Failing after 1m19s
2026-11-09 12:05:03 +03:30
nearology 68c3dc0e41 اصلاح قابلیت مقایسه شماتیک ها
Verify / test (push) Failing after 19s
2026-11-07 15:56:04 +03:30
nearology 6b1f408fde صلاح قابلیت مقایسه شماتیک
Verify / test (push) Failing after 16s
2026-11-07 15:51:09 +03:30
57فایلهای تغییر یافته به همراه3329 افزوده شده و 117 حذف شده
+21 -4
مشاهده پرونده
@@ -8,9 +8,26 @@ PARSE_TIMEOUT_SECONDS=60
LINK_TTL_SECONDS=604800 LINK_TTL_SECONDS=604800
MAX_PENDING_JOBS=100 MAX_PENDING_JOBS=100
# Production: APP_ENV=production and PUBLIC_BASE_URL=https://your-domain.example # Production: APP_ENV=production and PUBLIC_BASE_URL=https://your-domain.example
# Optional private S3-compatible storage, same settings for API and workers: # Storage: auto uses S3 when S3_BUCKET is set; otherwise local. Use s3 to require S3.
# S3_BUCKET=private-schematics STORAGE_BACKEND=auto
# S3_ENDPOINT_URL=https://s3.example.com S3_BUCKET=
# AWS S3: leave endpoint blank. S3-compatible provider: its service endpoint, not a public file URL.
S3_ENDPOINT_URL=
S3_REGION=us-east-1
# Optional namespace inside the bucket, e.g. altium/production (empty preserves old S3 keys).
S3_PREFIX=
# auto, path (commonly MinIO/S3-compatible), or virtual
S3_ADDRESSING_STYLE=auto
S3_CONNECT_TIMEOUT=5
S3_READ_TIMEOUT=60
S3_MAX_ATTEMPTS=3
# Credentials are server-only. Omit for IAM/workload credentials.
# AWS_ACCESS_KEY_ID= # AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY= # AWS_SECRET_ACCESS_KEY=
# AWS_DEFAULT_REGION=us-east-1 # AWS_SESSION_TOKEN=
# Optional custom CA bundle path, available in both API/worker containers:
# AWS_CA_BUNDLE=/path/to/provider-ca.pem
# Optional encryption: empty (provider default), AES256 or aws:kms.
S3_SERVER_SIDE_ENCRYPTION=
S3_KMS_KEY_ID=
# API and workers must use exactly the same bucket/prefix/endpoint/region.
+2 -2
مشاهده پرونده
@@ -1,9 +1,9 @@
{$DOMAIN} { {$DOMAIN} {
@comparison path /api/compare /api/v1/comparisons @comparison path /api/compare /api/v1/comparisons /api/v1/pcbs/comparisons
request_body @comparison { request_body @comparison {
max_size 65MiB max_size 65MiB
} }
@single not path /api/compare /api/v1/comparisons @single not path /api/compare /api/v1/comparisons /api/v1/pcbs/comparisons
request_body @single { request_body @single {
max_size 33MB max_size 33MB
} }
+12 -4
مشاهده پرونده
@@ -98,17 +98,23 @@ The workspace displays original/modified SVGs side by side with synchronized zoo
## Storage and configuration ## Storage and configuration
Local files are private, written atomically with generated keys. Client filenames never determine storage paths. For private S3-compatible storage set the same `S3_BUCKET`, `S3_ENDPOINT_URL`, AWS credentials and region on API and workers; no bucket ACL or public URL is created. Prefer workload credentials/IAM and configure bucket encryption/lifecycle at the provider. On a multi-host deployment use S3; a Docker named volume is shared only on one host. Storage/database orphan reconciliation after a machine failure is an operational maintenance task; normal completion, retries and expiry cleanup are implemented. Local files are private, written atomically with generated keys. Client filenames never determine storage paths. For private S3-compatible storage set `STORAGE_BACKEND=s3` and the same `S3_BUCKET`, `S3_PREFIX`, `S3_ENDPOINT_URL`, credentials and region on API and workers; no bucket ACL or public URL is created. Prefer workload credentials/IAM and configure bucket encryption/lifecycle at the provider. On a multi-host deployment use S3; a Docker named volume is shared only on one host. Storage/database orphan reconciliation after a machine failure is an operational maintenance task; normal completion, retries and expiry cleanup are implemented.
Full settings, credentials, optional encryption, migration instructions and the local-versus-temporary-file boundary are documented in [S3 storage](docs/S3.md). Existing local files are not moved automatically; a verified, non-deleting migration utility is provided.
| Environment | Default | | Environment | Default |
| --- | --- | | --- | --- |
| `DATABASE_URL` | SQLite under `STORAGE_DIR`; Compose uses PostgreSQL | | `DATABASE_URL` | SQLite under `STORAGE_DIR`; Compose uses PostgreSQL |
| `STORAGE_DIR` | `./data` (`/data` in Compose) | | `STORAGE_DIR` | `./data` (`/data` in Compose); local objects / SQLite metadata only |
| `STORAGE_BACKEND` | `auto`: S3 when bucket is set, local otherwise; `s3` requires S3 |
| `S3_BUCKET` / `S3_PREFIX` | Existing private bucket / optional object namespace |
| `S3_ENDPOINT_URL` / `S3_REGION` | Provider endpoint / region (AWS default endpoint when blank) |
| `S3_ADDRESSING_STYLE` | `auto`, `path` or `virtual` |
| `API_KEY` | Empty in local development; required in production | | `API_KEY` | Empty in local development; required in production |
| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | | `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` |
| `MAX_UPLOAD_BYTES` | 33,554,432 (32 MiB) | | `MAX_UPLOAD_BYTES` | 33,554,432 (32 MiB) |
| `PARSE_TIMEOUT_SECONDS` | 60 wall-clock seconds | | `PARSE_TIMEOUT_SECONDS` | 60 wall-clock seconds |
| `LINK_TTL_SECONDS` | 604,800 (7 days), maximum accepted `expires_in` | | `LINK_TTL_SECONDS` | 604,800 (7 days), finite default/maximum; `expires_in=-1` disables expiry |
| `MAX_PENDING_JOBS` | 100 active/unexpired jobs | | `MAX_PENDING_JOBS` | 100 active/unexpired jobs |
| `FONT_DIR` | Unset; optional administrator-managed webfont directory | | `FONT_DIR` | Unset; optional administrator-managed webfont directory |
@@ -148,4 +154,6 @@ The fixture downloader checksum-pins an actual upstream KiCad SchDoc. Unit-test
## Architecture and PCB extension ## Architecture and PCB extension
See [architecture](docs/ARCHITECTURE.md). `app/documents.py` is the parser registry, the schema preserves source records separately from vector primitives, `app/render.py` is the SVG serializer, and workers/storage are independent of schematic record decoding. A future PCB adapter can introduce layer-aware primitives and an appropriate viewer through these boundaries; no PCB parser or PCB rendering is shipped here. `.PcbDoc` uploads are rejected. See [architecture](docs/ARCHITECTURE.md). `app/documents.py` is the parser registry, the schema preserves source records separately from vector primitives, `app/render.py` is the SVG serializer, and workers/storage are independent of schematic record decoding. Native PCB support uses separate `POST /api/v1/pcbs/render` and `POST /api/v1/pcbs/comparisons` endpoints, with layer-aware fullscreen viewers and structural comparison. Existing SchDoc endpoints remain unchanged and reject PCB uploads. See [PCB API, supported format subset and deployment](docs/PCB.md).
All upload operations accept `expires_in=-1` for unlimited retention. Upload results and viewers offer Share with a copyable iframe snippet, including a private read token when needed. See [expiry and embedding](docs/API.md#unlimited-links-and-embedding).
+2
مشاهده پرونده
@@ -7,3 +7,5 @@ Runtime packages are installed with upstream package metadata/license files: Fas
Format research is linked from `docs/COMPATIBILITY.md`. No GPL KiCad importer code is incorporated. The checksum-pinned KiCad QA fixture is downloaded to an ignored directory and excluded from Docker/source distribution; consult upstream repository and fixture licensing before redistributing it. The user's local designs are not included in project source or the image. Format research is linked from `docs/COMPATIBILITY.md`. No GPL KiCad importer code is incorporated. The checksum-pinned KiCad QA fixture is downloaded to an ignored directory and excluded from Docker/source distribution; consult upstream repository and fixture licensing before redistributing it. The user's local designs are not included in project source or the image.
Administrator-supplied fonts must have appropriate web-serving licenses. Embedded document images and symbols retain their owners' rights; uploading does not grant redistribution rights. Administrator-supplied fonts must have appropriate web-serving licenses. Embedded document images and symbols retain their owners' rights; uploading does not grant redistribution rights.
The native PCB binary/geometry/parser modules also originate from that independently written local checkout and are adapted to this service. Format layouts were rechecked against the [KiCad developer format reference](https://dev-docs.kicad.org/en/import-formats/altium/index.html). No KiCad GPL importer implementation or proprietary PCB sample is incorporated. See [PCB provenance and compatibility](docs/PCB.md).
+3 -3
مشاهده پرونده
@@ -4,19 +4,19 @@ import time
from sqlalchemy import select, delete, or_ from sqlalchemy import select, delete, or_
from .db import Job, sessions, engine from .db import Job, sessions, engine
from .storage import storage from .storage import storage
from .job_types import comparison_sources
log=logging.getLogger(__name__) log=logging.getLogger(__name__)
def cleanup(batch=100): def cleanup(batch=100):
now=time.time(); removed=0 now=time.time(); removed=0
with sessions().begin() as db: with sessions().begin() as db:
q=select(Job).where(Job.expires_at<=now, or_(Job.status!='processing', Job.lease_until<now)).limit(batch) q=select(Job).where(Job.expires_at != -1, Job.expires_at<=now, or_(Job.status!='processing', Job.lease_until<now)).limit(batch)
if engine().dialect.name=='postgresql': q=q.with_for_update(skip_locked=True) if engine().dialect.name=='postgresql': q=q.with_for_update(skip_locked=True)
for job in db.scalars(q): for job in db.scalars(q):
try: try:
storage().delete(job.input_key) storage().delete(job.input_key)
if job.input_key.endswith('/comparison.json'): for source in comparison_sources(job.input_key): storage().delete(source)
for side in ('old','new'): storage().delete(f'{job.id}/{side}.SchDoc')
for attempt in (1,2): for attempt in (1,2):
for name in ('data.json','drawing.svg','old.svg','new.svg'): for name in ('data.json','drawing.svg','old.svg','new.svg'):
storage().delete(f'{job.id}/attempt-{attempt}/{name}') storage().delete(f'{job.id}/attempt-{attempt}/{name}')
+7 -7
مشاهده پرونده
@@ -10,7 +10,8 @@ from fastapi.responses import FileResponse,JSONResponse,Response
from sqlalchemy import select,func,text from sqlalchemy import select,func,text
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from .config import settings from .config import settings
from .db import Job,sessions from .expiry import expiration
from .db import Job,sessions,active_jobs
from .storage import storage from .storage import storage
from .uploads import read_schematic from .uploads import read_schematic
from .models.api import ComparisonStatus,ComparisonUploadResponse from .models.api import ComparisonStatus,ComparisonUploadResponse
@@ -37,20 +38,19 @@ def register_comparison_routes(app, *, authorize, require_api, error):
@app.post('/api/v1/comparisons',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons']) @app.post('/api/v1/comparisons',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons'])
async def create(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()], async def create(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()],
access:Annotated[Literal['private','unlisted'],Form()]='private', access:Annotated[Literal['private','unlisted'],Form()]='private',
expires_in:Annotated[int|None,Form(gt=0)]=None, expires_in:Annotated[int|None,Form(ge=-1, description="-1 for no expiry, or positive seconds")]=None,
x_api_key:Annotated[str|None,Header()]=None): x_api_key:Annotated[str|None,Header()]=None):
require_api(x_api_key) require_api(x_api_key)
ttl=expires_in or settings().link_ttl now=time.time();expires_at=expiration(expires_in,now)
if ttl>settings().link_ttl:error('invalid_expiry','expires_in exceeds the configured link lifetime',422)
try: try:
old_name,old=await read_schematic(old_file) old_name,old=await read_schematic(old_file)
new_name,new=await read_schematic(new_file) new_name,new=await read_schematic(new_file)
finally: finally:
await old_file.close();await new_file.close() await old_file.close();await new_file.close()
identifier=secrets.token_hex(24);token=secrets.token_urlsafe(32);now=time.time() identifier=secrets.token_hex(24);token=secrets.token_urlsafe(32)
job=Job(id=identifier,filename='Schematic comparison',status='queued',access=access, job=Job(id=identifier,filename='Schematic comparison',status='queued',access=access,
token_hash=hashlib.sha256(token.encode()).hexdigest(),created_at=now,updated_at=now, token_hash=hashlib.sha256(token.encode()).hexdigest(),created_at=now,updated_at=now,
expires_at=now+ttl,attempt=0,input_key=identifier+'/comparison.json') expires_at=expires_at,attempt=0,input_key=identifier+'/comparison.json')
files={identifier+'/old.SchDoc':old,identifier+'/new.SchDoc':new, files={identifier+'/old.SchDoc':old,identifier+'/new.SchDoc':new,
job.input_key:json.dumps({'schema_version':'1.0','old_filename':old_name,'new_filename':new_name}).encode()} job.input_key:json.dumps({'schema_version':'1.0','old_filename':old_name,'new_filename':new_name}).encode()}
def persist(): def persist():
@@ -58,7 +58,7 @@ def register_comparison_routes(app, *, authorize, require_api, error):
try: try:
with sessions().begin() as db: with sessions().begin() as db:
if db.bind.dialect.name=='postgresql':db.execute(text('SELECT pg_advisory_xact_lock(84721309)')) if db.bind.dialect.name=='postgresql':db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
pending=db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),Job.expires_at>now)) pending=db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),active_jobs(now)))
if pending>=settings().max_pending:error('queue_full','Rendering queue is full; try again later',503) if pending>=settings().max_pending:error('queue_full','Rendering queue is full; try again later',503)
for key,body in files.items(): for key,body in files.items():
try: storage().put(key,body) try: storage().put(key,body)
+45 -3
مشاهده پرونده
@@ -3,6 +3,7 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
@@ -18,10 +19,30 @@ class Settings:
s3_bucket: str s3_bucket: str
s3_endpoint: str | None s3_endpoint: str | None
s3_region: str s3_region: str
storage_backend: str
s3_prefix: str
s3_addressing_style: str
s3_connect_timeout: int
s3_read_timeout: int
s3_max_attempts: int
s3_encryption: str
s3_kms_key_id: str
@lru_cache @lru_cache
def settings(): def settings():
root = Path(os.getenv('STORAGE_DIR', './data')).resolve() root = Path(os.getenv('STORAGE_DIR', './data')).resolve()
bucket = os.getenv('S3_BUCKET', '').strip()
backend = os.getenv('STORAGE_BACKEND', 'auto').strip().lower()
if backend == 'auto': backend = 's3' if bucket else 'local'
if backend not in ('local', 's3'):
raise RuntimeError('STORAGE_BACKEND must be auto, local or s3')
if backend == 's3' and not bucket:
raise RuntimeError('S3_BUCKET is required when STORAGE_BACKEND=s3')
prefix = os.getenv('S3_PREFIX', '').strip('/')
if (len(prefix.encode('utf-8')) > 512 or '\\' in prefix or
any(ord(c) < 32 or ord(c) == 127 for c in prefix) or
(prefix and any(part in ('', '.', '..') for part in prefix.split('/')))):
raise RuntimeError('S3_PREFIX must be a relative object prefix without empty, dot or traversal segments (max 512 UTF-8 bytes)')
config = Settings( config = Settings(
environment=os.getenv('APP_ENV', 'development'), environment=os.getenv('APP_ENV', 'development'),
database_url=os.getenv('DATABASE_URL', f'sqlite:///{root}/jobs.db'), database_url=os.getenv('DATABASE_URL', f'sqlite:///{root}/jobs.db'),
@@ -32,9 +53,17 @@ def settings():
parse_timeout=int(os.getenv('PARSE_TIMEOUT_SECONDS', '60')), parse_timeout=int(os.getenv('PARSE_TIMEOUT_SECONDS', '60')),
link_ttl=int(os.getenv('LINK_TTL_SECONDS', '604800')), link_ttl=int(os.getenv('LINK_TTL_SECONDS', '604800')),
max_pending=int(os.getenv('MAX_PENDING_JOBS', '100')), max_pending=int(os.getenv('MAX_PENDING_JOBS', '100')),
s3_bucket=os.getenv('S3_BUCKET', ''), s3_bucket=bucket,
s3_endpoint=os.getenv('S3_ENDPOINT_URL') or None, s3_endpoint=os.getenv('S3_ENDPOINT_URL') or None,
s3_region=os.getenv('AWS_DEFAULT_REGION', 'us-east-1'), s3_region=os.getenv('S3_REGION') or os.getenv('AWS_DEFAULT_REGION') or 'us-east-1',
storage_backend=backend,
s3_prefix=prefix,
s3_addressing_style=os.getenv('S3_ADDRESSING_STYLE', 'auto').strip().lower(),
s3_connect_timeout=int(os.getenv('S3_CONNECT_TIMEOUT', '5')),
s3_read_timeout=int(os.getenv('S3_READ_TIMEOUT', '60')),
s3_max_attempts=int(os.getenv('S3_MAX_ATTEMPTS', '3')),
s3_encryption=os.getenv('S3_SERVER_SIDE_ENCRYPTION', '').strip(),
s3_kms_key_id=os.getenv('S3_KMS_KEY_ID', '').strip(),
) )
if config.environment == 'production' and len(config.api_key) < 32: if config.environment == 'production' and len(config.api_key) < 32:
raise RuntimeError('Production requires API_KEY with at least 32 characters') raise RuntimeError('Production requires API_KEY with at least 32 characters')
@@ -42,5 +71,18 @@ def settings():
raise RuntimeError('Production requires an HTTPS PUBLIC_BASE_URL') raise RuntimeError('Production requires an HTTPS PUBLIC_BASE_URL')
if min(config.max_upload_bytes, config.parse_timeout, config.link_ttl, config.max_pending) <= 0: if min(config.max_upload_bytes, config.parse_timeout, config.link_ttl, config.max_pending) <= 0:
raise RuntimeError('Upload, parse, expiry and queue limits must be positive') raise RuntimeError('Upload, parse, expiry and queue limits must be positive')
root.mkdir(parents=True, exist_ok=True, mode=0o700) if config.s3_addressing_style not in ('auto', 'path', 'virtual'):
raise RuntimeError('S3_ADDRESSING_STYLE must be auto, path or virtual')
if min(config.s3_connect_timeout, config.s3_read_timeout, config.s3_max_attempts) <= 0:
raise RuntimeError('S3 timeouts and max attempts must be positive')
if config.s3_encryption not in ('', 'AES256', 'aws:kms'):
raise RuntimeError('S3_SERVER_SIDE_ENCRYPTION must be empty, AES256 or aws:kms')
if config.s3_kms_key_id and config.s3_encryption != 'aws:kms':
raise RuntimeError('S3_KMS_KEY_ID requires S3_SERVER_SIDE_ENCRYPTION=aws:kms')
if config.s3_endpoint:
url = urlsplit(config.s3_endpoint)
if url.scheme not in ('http', 'https') or not url.hostname or url.username or url.password or url.query or url.fragment:
raise RuntimeError('S3_ENDPOINT_URL must be an HTTP(S) service endpoint without credentials, query or fragment')
if backend == 'local' or config.database_url.startswith('sqlite:'):
root.mkdir(parents=True, exist_ok=True, mode=0o700)
return config return config
+5 -2
مشاهده پرونده
@@ -6,7 +6,7 @@ to Redis. Versioned artifact keys fence out expired worker leases.
""" """
import time import time
from functools import lru_cache from functools import lru_cache
from sqlalchemy import create_engine, String, Text, Float, Integer, select, update, func, text from sqlalchemy import create_engine, String, Text, Float, Integer, select, update, func, text, or_
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
from .config import settings from .config import settings
@@ -39,6 +39,9 @@ def engine():
@lru_cache @lru_cache
def sessions(): return sessionmaker(engine(), expire_on_commit=False) def sessions(): return sessionmaker(engine(), expire_on_commit=False)
def active_jobs(now):
return or_(Job.expires_at == -1, Job.expires_at > now)
def initialize(): def initialize():
# Initial schema only. Subsequent schema changes must use explicit migrations. # Initial schema only. Subsequent schema changes must use explicit migrations.
if engine().dialect.name == 'postgresql': if engine().dialect.name == 'postgresql':
@@ -55,7 +58,7 @@ def claim():
now = time.time() now = time.time()
with sessions().begin() as db: with sessions().begin() as db:
# Expired jobs are never revived. A crashed worker can be retried once. # Expired jobs are never revived. A crashed worker can be retried once.
eligible = ((Job.status == 'queued') | ((Job.status == 'processing') & (Job.lease_until < now))) & (Job.expires_at > now) eligible = ((Job.status == 'queued') | ((Job.status == 'processing') & (Job.lease_until < now))) & active_jobs(now)
q = select(Job).where(eligible).order_by(Job.created_at).limit(1) q = select(Job).where(eligible).order_by(Job.created_at).limit(1)
if engine().dialect.name == 'postgresql': q = q.with_for_update(skip_locked=True) if engine().dialect.name == 'postgresql': q = q.with_for_update(skip_locked=True)
job = db.scalar(q) job = db.scalar(q)
+2 -1
مشاهده پرونده
@@ -2,12 +2,13 @@
from typing import Protocol from typing import Protocol
from .models.schematic import Document from .models.schematic import Document
from .parser.schdoc_parser import parse_schdoc from .parser.schdoc_parser import parse_schdoc
from .parser.pcb_parser import parse_pcbdoc
from .parser.record_decoder import ParseError from .parser.record_decoder import ParseError
class DocumentParser(Protocol): class DocumentParser(Protocol):
def __call__(self, data: bytes, filename: str, identifier: str, **options) -> Document: ... def __call__(self, data: bytes, filename: str, identifier: str, **options) -> Document: ...
PARSERS: dict[str, DocumentParser] = {'schematic': parse_schdoc} PARSERS: dict[str, DocumentParser] = {'schematic': parse_schdoc, 'pcb': parse_pcbdoc}
def parse_document(data: bytes, filename: str, identifier: str, document_type='schematic', **options) -> Document: def parse_document(data: bytes, filename: str, identifier: str, document_type='schematic', **options) -> Document:
parser=PARSERS.get(document_type) parser=PARSERS.get(document_type)
+15
مشاهده پرونده
@@ -0,0 +1,15 @@
"""Link expiry policy. -1 is a persistent no-expiry sentinel, not a timestamp."""
from fastapi import HTTPException
from .config import settings
NEVER_EXPIRES = -1
def expiration(expires_in, now):
if expires_in == NEVER_EXPIRES:
return NEVER_EXPIRES
ttl = settings().link_ttl if expires_in is None else expires_in
if ttl <= 0 or ttl > settings().link_ttl:
raise HTTPException(422, {'code': 'invalid_expiry',
'message': 'expires_in must be -1 (no expiry) or positive seconds within the configured lifetime'})
return now + ttl
+20
مشاهده پرونده
@@ -0,0 +1,20 @@
"""Server-owned immutable input names classify jobs, including legacy uploads."""
INPUT_KINDS = {
'input.SchDoc': 'schematic_render',
'comparison.json': 'schematic_comparison',
'input.PcbDoc': 'pcb_render',
'pcb-comparison.json': 'pcb_comparison',
}
def job_kind(input_key):
return INPUT_KINDS.get(input_key.rsplit('/', 1)[-1])
def comparison_sources(input_key):
kind = job_kind(input_key)
if kind not in ('schematic_comparison', 'pcb_comparison'):
return ()
prefix = input_key.rsplit('/', 1)[0]
suffix = 'PcbDoc' if kind == 'pcb_comparison' else 'SchDoc'
return tuple(f'{prefix}/{side}.{suffix}' for side in ('old', 'new'))
+18 -14
مشاهده پرونده
@@ -16,12 +16,14 @@ from sqlalchemy import func, select, text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from .config import settings from .config import settings
from .db import Job, initialize, sessions from .expiry import expiration
from .db import Job, initialize, sessions, active_jobs
from .storage import storage from .storage import storage
from .fonts import fonts, stylesheet from .fonts import fonts, stylesheet
from .models.api import UploadResponse, RenderStatus, ErrorEnvelope from .models.api import UploadResponse, RenderStatus, ErrorEnvelope
from .models.schematic import Document from .models.schematic import Document
from .uploads import read_schematic from .uploads import read_schematic
from .job_types import job_kind
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend' FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
ID = re.compile(r'^[a-f0-9]{48}$') ID = re.compile(r'^[a-f0-9]{48}$')
@@ -54,7 +56,7 @@ class UploadLimit:
def __init__(self, app): self.app = app def __init__(self, app): self.app = app
async def __call__(self, scope, receive, send): async def __call__(self, scope, receive, send):
if scope['type'] != 'http': return await self.app(scope, receive, send) if scope['type'] != 'http': return await self.app(scope, receive, send)
file_count = 2 if scope['path'] in ('/api/compare','/api/v1/comparisons') else 1 file_count = 2 if scope['path'] in ('/api/compare','/api/v1/comparisons','/api/v1/pcbs/comparisons') else 1
limit = file_count * settings().max_upload_bytes + 1024 * 1024 limit = file_count * settings().max_upload_bytes + 1024 * 1024
received = 0 received = 0
async def bounded_receive(): async def bounded_receive():
@@ -71,11 +73,11 @@ app.add_middleware(UploadLimit)
@app.middleware('http') @app.middleware('http')
async def security(request, call_next): async def security(request, call_next):
# Reject unauthorized POST bodies before the multipart parser reads them. # Reject unauthorized POST bodies before the multipart parser reads them.
if request.method == 'POST' and request.url.path in ('/api/v1/schematics/render','/api/compare','/api/v1/comparisons'): if request.method == 'POST' and request.url.path in ('/api/v1/schematics/render','/api/compare','/api/v1/comparisons','/api/v1/pcbs/render','/api/v1/pcbs/comparisons'):
if settings().api_key and not authenticated(request.headers.get('x-api-key')): if settings().api_key and not authenticated(request.headers.get('x-api-key')):
return JSONResponse({'success': False, 'error': {'code': 'unauthorized', 'message': 'Provide a valid X-API-Key header'}}, 401) return JSONResponse({'success': False, 'error': {'code': 'unauthorized', 'message': 'Provide a valid X-API-Key header'}}, 401)
response = await call_next(request) response = await call_next(request)
is_viewer = request.url.path.startswith('/view/') is_viewer = request.url.path.startswith(('/view/', '/compare/'))
frame_ancestors = "'self' https://git.sadentel.net" if is_viewer else "'none'" frame_ancestors = "'self' https://git.sadentel.net" if is_viewer else "'none'"
response.headers.update({ response.headers.update({
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
@@ -109,15 +111,16 @@ def health():
with sessions()() as db: db.execute(text('SELECT 1')) with sessions()() as db: db.execute(text('SELECT 1'))
return {'status': 'ok'} return {'status': 'ok'}
def authorize(identifier, key, token, *, comparison=False): def authorize(identifier, key, token, *, comparison=False, resource_kind=None):
if not ID.fullmatch(identifier): error('not_found', 'Schematic not found', 404) if not ID.fullmatch(identifier): error('not_found', 'Schematic not found', 404)
with sessions()() as db: job = db.get(Job, identifier) with sessions()() as db: job = db.get(Job, identifier)
if job is None or job.input_key.endswith('/comparison.json') != comparison: expected_kind = resource_kind or ('schematic_comparison' if comparison else 'schematic_render')
if job is None or job_kind(job.input_key) != expected_kind:
error('not_found', 'Schematic not found', 404) error('not_found', 'Schematic not found', 404)
# Private existence and expiry are not disclosed to unauthenticated callers. # Private existence and expiry are not disclosed to unauthenticated callers.
if job.access == 'private' and not (authenticated(key) or (token and hmac.compare_digest(job.token_hash, digest(token)))): if job.access == 'private' and not (authenticated(key) or (token and hmac.compare_digest(job.token_hash, digest(token)))):
error('not_found', 'Schematic not found', 404) error('not_found', 'Schematic not found', 404)
if job.expires_at <= time.time(): error('link_expired', 'This schematic link has expired', 410) if job.expires_at != -1 and job.expires_at <= time.time(): error('link_expired', 'This schematic link has expired', 410)
return job return job
def public_status(job): def public_status(job):
@@ -134,22 +137,21 @@ def public_status(job):
503: {'description': 'Queue or storage unavailable'}}) 503: {'description': 'Queue or storage unavailable'}})
async def upload(file: Annotated[UploadFile, File(description='Native binary OLE or ASCII .SchDoc')], async def upload(file: Annotated[UploadFile, File(description='Native binary OLE or ASCII .SchDoc')],
access: Annotated[Literal['private', 'unlisted'], Form()] = 'private', access: Annotated[Literal['private', 'unlisted'], Form()] = 'private',
expires_in: Annotated[int | None, Form(gt=0, description='Link lifetime in seconds, up to configured maximum')] = None, expires_in: Annotated[int | None, Form(ge=-1, description='-1 for no expiry, or positive seconds up to configured maximum')] = None,
x_api_key: Annotated[str | None, Header()] = None): x_api_key: Annotated[str | None, Header()] = None):
require_api(x_api_key) require_api(x_api_key)
ttl = expires_in or settings().link_ttl now = time.time()
if ttl > settings().link_ttl: error('invalid_expiry', 'expires_in exceeds the configured link lifetime', 422) expires_at = expiration(expires_in, now)
name, body = await read_schematic(file) name, body = await read_schematic(file)
identifier = secrets.token_hex(24) identifier = secrets.token_hex(24)
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
now = time.time()
job = Job(id=identifier, filename=name, status='queued', access=access, token_hash=digest(token), job = Job(id=identifier, filename=name, status='queued', access=access, token_hash=digest(token),
created_at=now, updated_at=now, expires_at=now+ttl, attempt=0, input_key=f'{identifier}/input.SchDoc') created_at=now, updated_at=now, expires_at=expires_at, attempt=0, input_key=f'{identifier}/input.SchDoc')
def persist(): def persist():
with sessions().begin() as db: with sessions().begin() as db:
# Serialize admission checks across API replicas on PostgreSQL. # Serialize admission checks across API replicas on PostgreSQL.
if db.bind.dialect.name == 'postgresql': db.execute(text('SELECT pg_advisory_xact_lock(84721309)')) if db.bind.dialect.name == 'postgresql': db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
pending = db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']), Job.expires_at > now)) pending = db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']), active_jobs(now)))
if pending >= settings().max_pending: error('queue_full', 'Rendering queue is full; try again later', 503) if pending >= settings().max_pending: error('queue_full', 'Rendering queue is full; try again later', 503)
try: storage().put(job.input_key, bytes(body)) try: storage().put(job.input_key, bytes(body))
except Exception: error('storage_unavailable', 'Upload storage unavailable', 503) except Exception: error('storage_unavailable', 'Upload storage unavailable', 503)
@@ -201,7 +203,7 @@ def index(): return FileResponse(FRONTEND / 'index.html')
@app.get('/assets/{name}', include_in_schema=False) @app.get('/assets/{name}', include_in_schema=False)
def asset(name: str): def asset(name: str):
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js','compare.js','compare.css','compare-upload.js','interactions.js'}: error('not_found', 'Asset not found', 404) if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js','compare.js','compare.css','compare-upload.js','interactions.js','pcb.js','pcb.css','pcb-layers.js','share.js','share.css'}: error('not_found', 'Asset not found', 404)
return FileResponse(FRONTEND / name) return FileResponse(FRONTEND / name)
@app.get('/docs', include_in_schema=False) @app.get('/docs', include_in_schema=False)
@@ -224,3 +226,5 @@ async def database_error(request, exc):
# Comparison jobs share the existing queue, access policy and error envelope. # Comparison jobs share the existing queue, access policy and error envelope.
from .compare_api import register_comparison_routes from .compare_api import register_comparison_routes
register_comparison_routes(app, authorize=authorize, require_api=require_api, error=error) register_comparison_routes(app, authorize=authorize, require_api=require_api, error=error)
from .pcb_api import register_pcb_routes
register_pcb_routes(app, authorize=authorize, require_api=require_api, error=error)
+1 -1
مشاهده پرونده
@@ -15,7 +15,7 @@ class RenderStatus(BaseModel):
id: str = Field(description='Unpredictable 192-bit identifier, encoded as 48 hex characters') id: str = Field(description='Unpredictable 192-bit identifier, encoded as 48 hex characters')
status: Literal['queued','processing','completed','failed'] status: Literal['queued','processing','completed','failed']
viewer_url: str = Field(description='Base viewer URL; only initial upload includes private token fragment') viewer_url: str = Field(description='Base viewer URL; only initial upload includes private token fragment')
expires_at: float = Field(description='Unix timestamp, UTC seconds') expires_at: float = Field(description='Unix timestamp in UTC seconds; -1 means no expiration')
access: Literal['private','unlisted'] access: Literal['private','unlisted']
schema_version: str | None = None schema_version: str | None = None
compatibility: Literal['partial','supported_subset'] | None = None compatibility: Literal['partial','supported_subset'] | None = None
+67
مشاهده پرونده
@@ -0,0 +1,67 @@
"""PCB contracts extend geometry without changing the schematic wire schema."""
from typing import Literal
from pydantic import BaseModel, Field
from .schematic import Document, Primitive, SchematicObject, Point, Bounds
from .api import RenderStatus
class PCBPrimitive(Primitive):
layer_ids: list[str] = Field(default_factory=list)
class PCBObject(SchematicObject):
native_uid: str = ''
layer_ids: list[str] = Field(default_factory=list)
class Footprint(BaseModel):
id: str
designator: str = ''
name: str = ''
value: str = ''
comment: str = ''
description: str = ''
library_reference: str = ''
source_library: str = ''
position: Point
rotation: float = 0
mirrored: bool = False
bounds: Bounds
parameters: dict[str, str] = Field(default_factory=dict)
primitive_ids: list[str] = Field(default_factory=list)
object_ids: list[str] = Field(default_factory=list)
class PCBData(Document):
document_type: Literal['pcb'] = 'pcb'
components: list[Footprint]
primitives: list[PCBPrimitive]
objects: list[PCBObject]
class PCBStatus(RenderStatus):
document_type: Literal['pcb', 'pcb_comparison']
status_url: str
layers: list[dict] | None = None
net_count: int | None = None
class PCBUploadResponse(PCBStatus):
viewer_token: str | None = None
class PCBComparisonStatus(PCBStatus):
comparison_id: str
url: str
summary: dict[Literal['added','removed','modified','moved'], int] | None = None
total_changed: int | None = None
changes: list[dict] | None = None
old: dict | None = None
new: dict | None = None
nets: dict | None = None
matching: dict | None = None
limitations: list[str] | None = None
class PCBComparisonUploadResponse(PCBComparisonStatus):
viewer_token: str | None = None
@@ -0,0 +1,106 @@
"""Checked Altium PCB framing. No embedded content is decompressed or executed."""
import math
import struct
from .record_decoder import ParseError, properties
MAX_ITEMS = 100_000
MAX_BLOCK = 16 * 1024 * 1024
class Reader:
def __init__(self, data, label="PCB"):
self.data = memoryview(data)
self.offset = 0
self.label = label
@property
def remaining(self):
return len(self.data) - self.offset
def take(self, length):
if length < 0 or length > self.remaining:
raise ParseError(
f"{self.label}: truncated data at byte {self.offset}, need {length}"
)
start = self.offset
self.offset += length
return self.data[start : self.offset]
def read(self, fmt):
result = struct.unpack("<" + fmt, self.take(struct.calcsize("<" + fmt)))
if any(isinstance(v, float) and not math.isfinite(v) for v in result):
raise ParseError(f"{self.label}: non-finite binary number")
return result[0] if len(result) == 1 else result
def block(self):
length = self.read("I")
if length > MAX_BLOCK:
raise ParseError(f"{self.label}: subrecord exceeds {MAX_BLOCK} bytes")
return self.take(length)
def pascal(self):
return bytes(self.take(self.read("B"))).decode("cp1252", errors="replace")
def text_records(data, name):
reader = Reader(data, name)
result = []
while reader.remaining:
if len(result) >= MAX_ITEMS:
raise ParseError(f"{name}: excessive record count")
result.append(properties(bytes(reader.block())))
return result
def binary_records(data, name, tag, blocks=1):
reader = Reader(data, name)
index = 0
while reader.remaining:
if index >= MAX_ITEMS:
raise ParseError(f"{name}: excessive record count")
actual = reader.read("B")
if actual != tag:
raise ParseError(
f"{name}: unsupported tag {actual} at record {index}, expected {tag}"
)
yield index, [bytes(reader.block()) for _ in range(blocks)]
index += 1
def wide_strings(data):
reader = Reader(data, "WideStrings6")
result = {}
while reader.remaining:
if len(result) >= MAX_ITEMS:
raise ParseError("Excessive wide string count")
index = reader.read("I")
length = reader.read("I")
# Altium writes empty strings with length=2 but no payload in some exports.
if (
length == 2
and bytes(reader.data[reader.offset : reader.offset + 2]) != b"\0\0"
):
payload = b""
else:
if length > MAX_BLOCK:
raise ParseError("Wide string exceeds size limit")
payload = reader.take(length)
if len(payload) % 2:
raise ParseError("Odd UTF-16 string length")
result[index] = (
bytes(payload).decode("utf-16le", errors="replace").rstrip("\x00")
)
return result
def at(data, offset, fmt, default=None):
size = struct.calcsize("<" + fmt)
if offset < 0 or offset + size > len(data):
if default is not None:
return default
raise ParseError(f"Truncated PCB field at offset {offset}, need {size} bytes")
values = struct.unpack_from("<" + fmt, data, offset)
if any(isinstance(v, float) and not math.isfinite(v) for v in values):
raise ParseError("Non-finite PCB field")
return values[0] if len(values) == 1 else values
@@ -0,0 +1,44 @@
"""Cheap bounded PCB envelope validation; complete decoding stays in the worker."""
import io
import struct
import olefile
from .record_decoder import ParseError
from .pcb_binary import Reader, text_records
SIGNATURE = b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'
def identify_pcb(data):
if not data.startswith(SIGNATURE):
raise ParseError('PcbDoc must be an OLE compound document')
try:
with olefile.OleFileIO(io.BytesIO(data), raise_defects=olefile.DEFECT_INCORRECT) as ole:
if len(ole.listdir()) > 1024:
raise ParseError('Excessive PCB stream count')
for name, limit in [('FileHeaderSix', 4096), ('Board6/Data', 4 * 1024**2)]:
if not ole.exists(name) or ole.get_type(name) != 2:
raise ParseError(f'Missing PCB stream {name}')
if ole.get_size(name) > limit:
raise ParseError(f'PCB stream {name} exceeds size limit')
reader = Reader(ole.openstream('FileHeaderSix').read(), 'FileHeaderSix')
length = reader.read('I'); envelope = reader.pascal()
if length != len(envelope) or envelope != 'PCB 6.0 Binary File':
raise ParseError('Unsupported PCB file envelope')
records = text_records(ole.openstream('Board6/Data').read(), 'Board6')
if len(records) != 1 or records[0].get('KIND') != 'Protel_Advanced_PCB':
raise ParseError('Invalid Board6 identification')
if records[0].get('VERSION') not in ('5.0', '5.01', '6.0'):
raise ParseError('Unsupported PCB board version')
except (OSError, IOError, ValueError, IndexError, struct.error) as exc:
if isinstance(exc, ParseError): raise
raise ParseError('Invalid PCB compound document') from exc
def contains_pcb(data):
"""Reject PCB data renamed to SchDoc without altering legacy corrupt-file handling."""
if not data.startswith(SIGNATURE): return False
try:
with olefile.OleFileIO(io.BytesIO(data)) as ole:
return ole.exists('Board6/Data')
except (OSError, ValueError, IndexError, struct.error):
return False
@@ -0,0 +1,155 @@
import math
import re
from app.models.schematic import Point, Bounds
from .geometry import bounds, poly_path, transform_point
from .record_decoder import ParseError
# PCB integer coordinate unit = 1/10000 mil. Common viewer unit = 10 mil.
BINARY_SCALE = 100000
def raw_unit(value):
n = value / BINARY_SCALE
if not math.isfinite(n) or abs(n) > 100000:
raise ParseError("Invalid or out-of-range PCB coordinate")
return n
def unit(value):
match = re.fullmatch(r"\s*([-+\d.eE]+)\s*(mil|mm|in|inch)?\s*", str(value), re.I)
if not match:
raise ParseError(f"Invalid PCB unit: {str(value)[:80]}")
n = float(match[1])
suffix = (match[2] or "mil").lower()
factor = {"mil": 0.1, "mm": 1 / 0.254, "in": 100, "inch": 100}[suffix]
result = n * factor
if not math.isfinite(result) or abs(result) > 100000:
raise ParseError("PCB unit out of bounds")
return result
def xy(x, y):
return Point(x=raw_unit(x), y=-raw_unit(y))
def ellipse_path(cx, cy, rx, ry=None):
ry = rx if ry is None else ry
return f"M{cx - rx:.15g},{cy:.15g} A{rx:.15g},{ry:.15g} 0 1 0 {cx + rx:.15g},{cy:.15g} A{rx:.15g},{ry:.15g} 0 1 0 {cx - rx:.15g},{cy:.15g} Z"
def arc_path(center, radius, start, end):
if not all(math.isfinite(v) for v in (radius,start,end)): raise ParseError("Non-finite arc geometry")
if radius <= 0:
raise ParseError("Arc radius must be positive")
if abs(end - start) >= 359.99999 or start == end:
return ellipse_path(center.x, center.y, radius)
a, b = math.radians(start), math.radians(end)
x1, y1 = center.x + radius * math.cos(a), center.y - radius * math.sin(a)
x2, y2 = center.x + radius * math.cos(b), center.y - radius * math.sin(b)
return f"M{x1:.15g},{y1:.15g} A{radius:.15g},{radius:.15g} 0 {int((end - start) % 360 > 180)} 0 {x2:.15g},{y2:.15g}"
def pad_shape(position, w, h, shape, rotation, radius_percent=0):
if w <= 0 or h <= 0:
raise ParseError("Pad dimensions must be positive")
# Build in local coordinates, then place absolute points. Exact arcs use a path transform.
angle = math.radians(-rotation)
def q(x, y):
return Point(
x=position.x + x * math.cos(angle) - y * math.sin(angle),
y=position.y + x * math.sin(angle) + y * math.cos(angle),
)
def pair(p):
return f"{p.x:.15g},{p.y:.15g}"
if (
shape == 1
): # Circle or oval/obround; unequal sizes form a capsule, not an ellipse.
r = min(w, h) / 2
elif shape == 9:
r = min(w, h) * radius_percent / 200
else:
r = 0
if shape == 3:
# Altium octagon chamfers at 45 degrees.
c = min(w, h) / (2 + math.sqrt(2))
pts = [
q(x, y)
for x, y in [
(-w / 2 + c, -h / 2),
(w / 2 - c, -h / 2),
(w / 2, -h / 2 + c),
(w / 2, h / 2 - c),
(w / 2 - c, h / 2),
(-w / 2 + c, h / 2),
(-w / 2, h / 2 - c),
(-w / 2, -h / 2 + c),
]
]
return poly_path(pts, True), bounds(pts)
if shape not in (1, 2, 9):
raise ParseError(f"Unsupported pad shape {shape}")
corners = [
q(x, y)
for x, y in [(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2)]
]
if r <= 0:
return poly_path(corners, True), bounds(corners)
pts = [
q(x, y)
for x, y in [
(-w / 2 + r, -h / 2),
(w / 2 - r, -h / 2),
(w / 2, -h / 2 + r),
(w / 2, h / 2 - r),
(w / 2 - r, h / 2),
(-w / 2 + r, h / 2),
(-w / 2, h / 2 - r),
(-w / 2, -h / 2 + r),
]
]
d = f"M{pair(pts[0])} L{pair(pts[1])} A{r:.15g},{r:.15g} {-rotation:.15g} 0 1 {pair(pts[2])} L{pair(pts[3])} A{r:.15g},{r:.15g} 0 0 1 {pair(pts[4])} L{pair(pts[5])} A{r:.15g},{r:.15g} 0 0 1 {pair(pts[6])} L{pair(pts[7])} A{r:.15g},{r:.15g} 0 0 1 {pair(pts[0])} Z"
return d, bounds(corners)
def property_outline(p):
vertices = []
for i in range(20000):
if f"VX{i}" not in p:
break
if f"VY{i}" not in p: raise ParseError("PCB outline vertex is missing Y")
if int(p.get(f"KIND{i}",0)) and not all(f"{key}{i}" in p for key in ("CX","CY","R","SA","EA")):
raise ParseError("PCB outline arc is missing native geometry")
vertices.append(
(
Point(x=unit(p[f"VX{i}"]), y=-unit(p.get(f"VY{i}", "0mil"))),
int(p.get(f"KIND{i}", 0)),
Point(
x=unit(p.get(f"CX{i}", "0mil")), y=-unit(p.get(f"CY{i}", "0mil"))
),
unit(p.get(f"R{i}", "0mil")),
float(p.get(f"SA{i}", 0)),
float(p.get(f"EA{i}", 0)),
)
)
if not vertices:
return "", bounds([])
pts = [v[0] for v in vertices]
commands = [f"M{pts[0].x:.15g},{pts[0].y:.15g}"]
for i, (pos, kind, center, r, sa, ea) in enumerate(vertices):
if kind and r > 0:
# Arc belongs to its starting vertex, ending at the next vertex.
arc = arc_path(center, r, sa, ea)
commands.append("L" + arc.split(" ", 1)[0][1:])
commands.append(arc.split(" ", 1)[1])
pts.extend(
[
Point(x=center.x - r, y=center.y - r),
Point(x=center.x + r, y=center.y + r),
]
)
nxt = vertices[(i + 1) % len(vertices)][0]
commands.append(f"L{nxt.x:.15g},{nxt.y:.15g}")
return " ".join(commands) + " Z", bounds(pts)
@@ -0,0 +1,947 @@
"""Independent, bounded reader for the documented PcbDoc 6.0 binary record family."""
import hashlib
import io
import math
import re
import struct
from collections import Counter
import olefile
from app.models.schematic import Sheet, Point, Bounds, Diagnostic, BoardLayer, Net
from app.models.pcb import PCBData as Document, Footprint as Component, PCBObject as SchematicObject, PCBPrimitive as Primitive
from .record_decoder import ParseError, properties
from .pcb_binary import Reader, at, text_records, binary_records, wide_strings
from .pcb_geometry import (
raw_unit,
unit,
xy,
ellipse_path,
arc_path,
pad_shape,
property_outline,
)
from .geometry import bounds, union, poly_path, transform_point
MAX_TOTAL = 64 * 1024 * 1024
SPECIAL = {
32: ("Bottom Layer", "#5484ed", "copper"),
33: ("Top Overlay", "#f6e4b1", "silkscreen"),
34: ("Bottom Overlay", "#c29ef1", "silkscreen"),
35: ("Top Paste", "#92b8a0", "paste"),
36: ("Bottom Paste", "#81a0b5", "paste"),
37: ("Top Solder", "#966ac1", "mask"),
38: ("Bottom Solder", "#b78b99", "mask"),
55: ("Drill Guide", "#d5d8dc", "mechanical"),
56: ("Keep-Out Layer", "#d374dc", "mechanical"),
73: ("Drill Drawing", "#cccbd0", "mechanical"),
74: ("Multi-Layer", "#d9b660", "copper"),
81: ("Drill holes", "#08151b", "drill"),
0: ("Board Outline", "#67bc9d", "outline"),
}
def layer_number(name):
name = name.upper().replace(" ", "").replace("_", "")
fixed = {
"TOP": 1,
"TOPLAYER": 1,
"BOTTOM": 32,
"BOTTOMLAYER": 32,
"TOPOVERLAY": 33,
"BOTTOMOVERLAY": 34,
"TOPPASTE": 35,
"BOTTOMPASTE": 36,
"TOPSOLDER": 37,
"BOTTOMSOLDER": 38,
"KEEPOUT": 56,
"KEEPOUTLAYER": 56,
"MULTILAYER": 74,
"DRILLGUIDE": 55,
"DRILLDRAWING": 73,
}
if name in fixed:
return fixed[name]
m = re.fullmatch(r"(MID(?:LAYER)?|PLANE|MECHANICAL)(\d+)", name)
if m:
n = int(m[2])
if m[1].startswith("MID"):
return n + 1
if m[1] == "PLANE":
return n + 38
return n + 56 if n <= 16 else 0x01020000 + n
return 0
def resolve_layer(legacy, extended=0):
# The standard copper/overlay IDs remain authoritative in V6 records.
if 0x01020011 <= extended <= 0x0102FFFF:
return extended
return legacy
def parse_pcbdoc(data: bytes, filename: str, document_id: str, **options) -> Document:
if not data.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
raise ParseError("PcbDoc must be an OLE compound document")
try:
ole = olefile.OleFileIO(
io.BytesIO(data), raise_defects=olefile.DEFECT_INCORRECT
)
except (OSError, ValueError, struct.error) as exc:
raise ParseError(f"Invalid PCB container: {exc}") from exc
with ole:
entries = ole.listdir(streams=True, storages=False)
if len(entries) > 1024:
raise ParseError("Excessive PCB stream count")
streams = [{"name": "/".join(e), "size": ole.get_size(e)} for e in entries]
if (
any(s["size"] > 32 * 1024 * 1024 for s in streams)
or sum(s["size"] for s in streams) > MAX_TOTAL
):
raise ParseError("PCB stream size limit exceeded")
def read(name, required=False):
if not ole.exists(name):
if required:
raise ParseError(f"Missing PCB stream {name}")
return b""
return ole.openstream(name).read()
header = read("FileHeaderSix", True)
hreader = Reader(header, "FileHeaderSix")
header_length = hreader.read("I")
envelope = hreader.pascal()
if header_length != len(envelope):
raise ParseError("PCB version string length mismatch")
if envelope != "PCB 6.0 Binary File":
raise ParseError(f"Unsupported PCB envelope: {envelope[:100]}")
board_records = text_records(read("Board6/Data", True), "Board6")
if len(board_records) != 1:
raise ParseError("Expected exactly one Board6 property record")
board = board_records[0]
if board.get("KIND") != "Protel_Advanced_PCB" or board.get("VERSION") not in (
"5.0",
"5.01",
"6.0",
):
raise ParseError(f"Unsupported PCB board version {board.get('VERSION')}")
diagnostics = []
grouped = {}
counts = {}
variants = {}
objects = []
primitives = []
layers = {}
component_boxes = {}
def report(code, message, record=None, severity="warning"):
key = (code, message)
if key in grouped:
grouped[key].count += 1
return
d = Diagnostic(
severity=severity, code=code, message=message, record_id=record
)
diagnostics.append(d)
grouped[key] = d
def ensure_layer(n):
key = str(n)
if key in layers:
return layers[key]
if n == 1:
name, col, category = "Top Layer", "#d5624e", "copper"
elif 2 <= n <= 31:
name, col, category = (
f"Mid Layer {n - 1}",
("#a5bd59", "#b67ac7", "#d19e4d", "#63bdb5")[(n - 2) % 4],
"copper",
)
elif n in SPECIAL:
name, col, category = SPECIAL[n]
elif 39 <= n <= 54:
name, col, category = f"Internal Plane {n - 38}", "#698a68", "plane"
elif 57 <= n <= 72 or n >= 0x01020000:
name, col, category = (
f"Mechanical {n - 56 if n < 100 else n - 0x01020000}",
"#8196a8",
"mechanical",
)
else:
name, col, category = f"Layer {n}", "#8196a8", "unknown"
name = board.get(f"LAYER{n}NAME", name)
if n >= 0x01020000:
for k, v in board.items():
if (
k.startswith("LAYERV7_")
and k.endswith("LAYERID")
and v == str(n)
):
name = board.get(k[:-7] + "NAME", name)
order = (
0
if n == 0
else 1000
if n == 81
else 900
if n == 33
else 890
if n == 34
else 800
if n == 74
else 700
if n == 1
else 600
if n == 32
else 500 - n
if 2 <= n <= 31
else 850
)
layer = BoardLayer(
id=key,
name=name,
color=col,
category=category,
visible=n in (0, 1, 33, 74, 81),
order=order,
)
layers[key] = layer
return layer
def props_records(name):
result = text_records(read(name + "/Data"), name)
check_count(name, len(result))
return result
def check_count(name, count):
counts[name] = count
hdr = read(name + "/Header")
if len(hdr) == 4 and at(hdr, 0, "I") != count:
report(
"record_count_mismatch",
f"{name}: header declares {at(hdr, 0, 'I')}, decoded {count}",
)
def typed(name, tag, blocks=1):
count = 0
for idx, parts in binary_records(read(name + "/Data"), name, tag, blocks):
count += 1
signature = ",".join(str(len(p)) for p in parts)
variants.setdefault(name, Counter())[signature] += 1
if sum(counts.values()) + count > 100000:
raise ParseError("Excessive PCB object count")
yield idx, parts
check_count(name, count)
uid_map = {}
uid_stream = text_records(read("UniqueIDPrimitiveInformation/Data"), "UniqueIDPrimitiveInformation")
for item in uid_stream:
key = (item.get("PRIMITIVEOBJECTID", "").lower(), int(item.get("PRIMITIVEINDEX", -1)))
if key in uid_map: raise ParseError("Duplicate primitive UID mapping")
uid_map[key] = item.get("UNIQUEID", "")
nets = [
Net(id=f"net:{i}", name=p.get("NAME", f"Net {i}"), properties=p)
for i, p in enumerate(props_records("Nets6"))
]
components = []
for i, p in enumerate(props_records("Components6")):
if not {"X","Y","LAYER"}.issubset(p):
raise ParseError(f"Components6:{i}: missing native placement fields")
pos = Point(x=unit(p.get("X", "0mil")), y=-unit(p.get("Y", "0mil")))
components.append(
Component(
id=f"Components6:{i}",
designator=p.get("SOURCEDESIGNATOR", ""),
name=p.get("SOURCELIBREFERENCE", p.get("PATTERN", "")),
value=p.get("COMMENT", ""),
comment=p.get("COMMENT", ""),
description=p.get("SOURCEDESCRIPTION", ""),
library_reference=p.get("PATTERN", ""),
source_library=p.get("SOURCEFOOTPRINTLIBRARY", ""),
position=pos,
rotation=float(p.get("ROTATION", 0)),
mirrored=layer_number(p.get("LAYER", "TOP")) == 32,
bounds=bounds([pos], 1),
parameters=p,
)
)
if any(not math.isfinite(c.rotation) for c in components):
raise ParseError("Non-finite footprint rotation")
def common(name, index, p, ext=0):
if len(p) < 13:
raise ParseError(f"{name}:{index}: short primitive header")
net, component = at(p, 3, "H"), at(p, 7, "H")
if net != 65535 and net >= len(nets):
raise ParseError(f"{name}:{index}: invalid net index {net}")
if component != 65535 and component >= len(components):
raise ParseError(f"{name}:{index}: invalid component index {component}")
lid = resolve_layer(p[0], ext)
layer = ensure_layer(lid)
c = components[component] if component != 65535 else None
props = {
"LAYER_ID": str(lid),
"NET_INDEX": str(net),
"NET_NAME": nets[net].name if net != 65535 else "",
"COMPONENT_INDEX": str(component),
"LOCKED": str(not bool(p[1] & 4)),
"FLAGS_1": str(p[1]),
"FLAGS_2": str(p[2]),
}
obj = SchematicObject(
id=f"{name}:{index}",
kind=name.removesuffix("6").lower(),
native_uid=uid_map.get(({"Tracks6":"track","Arcs6":"arc","Pads6":"pad","Vias6":"via","Texts6":"text","Fills6":"fill","Regions6":"region","ShapeBasedRegions6":"region"}.get(name,""),index), ""),
record_type={
"Tracks6": 4,
"Arcs6": 1,
"Pads6": 2,
"Vias6": 3,
"Texts6": 5,
"Fills6": 6,
"Regions6": 11,
"ShapeBasedRegions6": 11,
}.get(name, 0),
source_stream=name,
source_index=index,
owner_id=c.id if c else None,
component_id=c.id if c else None,
layer_id=layer.id,
net_id=nets[net].id if net != 65535 else None,
position=Point(x=0, y=0),
bounds=bounds([]),
properties=props,
)
objects.append(obj)
layer.object_count += 1
if c:
c.object_ids.append(obj.id)
return obj, c
def shape(
obj,
path,
box,
fill="none",
width=0.0,
layer_id=None,
role="",
stroke=None,
fill_rule="nonzero",
position=None,
text="",
rotation=0,
mirrored=False,
font_size=10,
font_family="Arial",
bold=False,
italic=False,
):
layer = ensure_layer(
int(layer_id if layer_id is not None else obj.layer_id or 0)
)
col = layer.color
primitive = Primitive(
id=f"{obj.id}:p{len(obj.primitive_ids)}",
kind="text" if text else "path",
object_id=obj.id,
component_id=obj.component_id,
layer_id=layer.id,
net_id=obj.net_id,
role=role,
path=path,
bounds=box,
stroke=col if stroke is None else stroke,
fill=col if fill == "layer" else fill,
width=width,
fill_rule=fill_rule,
position=position or obj.position,
text=text,
rotation=rotation,
mirrored=mirrored,
font_size=font_size,
font_family=font_family,
bold=bold,
italic=italic,
)
if len(primitives) >= 250000: raise ParseError("PCB drawable limit exceeded")
primitives.append(primitive)
obj.primitive_ids.append(primitive.id)
obj.bounds = union([obj.bounds, box]) if len(obj.primitive_ids) > 1 else box
if obj.component_id:
ci = int(obj.component_id.split(":")[1])
components[ci].primitive_ids.append(primitive.id)
if layer.category not in ("mechanical", "paste", "mask"):
component_boxes.setdefault(obj.component_id, []).append(box)
return primitive
# Outline is extracted from actual Board6 vertices, including rounded corners.
outline, board_bounds = property_outline(board)
if not outline:
raise ParseError("PCB has no supported Board6 outline")
outline_obj = SchematicObject(
id="Board6:0",
kind="board_outline",
record_type=0,
source_stream="Board6",
source_index=0,
layer_id="0",
position=Point(x=board_bounds.x, y=board_bounds.y),
bounds=board_bounds,
properties=board,
)
objects.append(outline_obj)
shape(
outline_obj, outline, board_bounds, fill="#13312e", width=0.18, role="board"
)
counts["Board6"] = 1
# Preserve polygon definitions as outlines only; the filled copper is read from Regions6.
polygons = props_records("Polygons6")
for i, p in enumerate(polygons):
lid = layer_number(p.get("LAYER", "TOP"))
layer = ensure_layer(lid)
path, box = property_outline(p)
net = int(p.get("NET", 65535))
if net != 65535 and not 0 <= net < len(nets): raise ParseError("Invalid polygon net reference")
o = SchematicObject(
id=f"Polygons6:{i}",
kind="polygon_definition",
native_uid=p.get("UNIQUEID", ""),
record_type=0,
source_stream="Polygons6",
source_index=i,
layer_id=layer.id,
net_id=nets[net].id if 0 <= net < len(nets) else None,
visible=False,
position=Point(x=box.x, y=box.y),
bounds=box,
properties=p,
)
objects.append(o)
for i, (p,) in typed("Tracks6", 4):
obj, c = common("Tracks6", i, p, at(p, 41, "I", 0))
a, b = xy(*at(p, 13, "ii")), xy(*at(p, 21, "ii"))
w = raw_unit(at(p, 29, "i"))
if w < 0:
raise ParseError("Negative track width")
obj.position = a
obj.connection_points = [a, b]
obj.kind = "track"
obj.properties["WIDTH_MIL"] = str(w * 10)
polygon = at(p, 5, "H")
obj.properties["POLYGON_INDEX"] = str(polygon)
shape(
obj,
poly_path([a, b]),
bounds([a, b], w / 2),
width=w,
role="polygon_outline" if p[1] & 2 else "track",
)
for i, (p,) in typed("Arcs6", 1):
obj, c = common("Arcs6", i, p, at(p, 52, "I", 0))
pos = xy(*at(p, 13, "ii"))
r = raw_unit(at(p, 21, "i"))
start, end = at(p, 25, "dd")
w = raw_unit(at(p, 41, "i"))
if w < 0: raise ParseError("Negative arc width")
obj.kind = "arc"
obj.position = pos
obj.properties.update(
{
"RADIUS_MIL": str(r * 10),
"START_ANGLE": str(start),
"END_ANGLE": str(end),
"WIDTH_MIL": str(w * 10),
}
)
shape(
obj,
arc_path(pos, r, start, end),
Bounds(
x=pos.x - r - w / 2,
y=pos.y - r - w / 2,
width=2 * r + w,
height=2 * r + w,
),
width=w,
role="track",
)
for i, (p,) in typed("Fills6", 6):
obj, c = common("Fills6", i, p, at(p, 42, "I", 0))
a, b = xy(*at(p, 13, "ii")), xy(*at(p, 21, "ii"))
rotation = at(p, 29, "d")
center = Point(x=(a.x + b.x) / 2, y=(a.y + b.y) / 2)
obj.position = center
obj.kind = "fill"
path, box = pad_shape(center, abs(b.x - a.x), abs(b.y - a.y), 2, rotation)
obj.properties["ROTATION"] = str(rotation)
shape(obj, path, box, fill="layer", role="fill")
# Render saved tessellated regions with exact interior holes. Keep definition streams separate.
def regions(name, extended):
for i, (p,) in typed(name, 11):
obj, c = common(name, i, p)
obj.kind = "region"
reader = Reader(p, name)
reader.take(14)
hole_count = reader.read("H")
reader.take(2)
props = properties(bytes(reader.block()))
obj.properties.update(props)
n = reader.read("I") + (1 if extended else 0)
if n > 50000 or hole_count > 20000:
raise ParseError("Excessive region geometry")
vertices = []
commands = []
all_points = []
for _ in range(n):
if extended:
curved = reader.read("B")
pos = xy(*reader.read("ii"))
center = xy(*reader.read("ii"))
r = raw_unit(reader.read("i"))
sa, ea = reader.read("dd")
vertices.append((pos, curved, center, r, sa, ea))
else:
vertices.append((xy(*reader.read("dd")), 0, None, 0, 0, 0))
for j, (pos, curved, center, r, sa, ea) in enumerate(vertices):
commands.append(("M" if j == 0 else "L") + f"{pos.x:.15g},{pos.y:.15g}")
all_points.append(pos)
if curved and r > 0:
arc = arc_path(center, r, sa, ea)
if " " in arc:
commands.extend(
["L" + arc.split(" ", 1)[0][1:], arc.split(" ", 1)[1]]
)
all_points.extend(
[
Point(x=center.x - r, y=center.y - r),
Point(x=center.x + r, y=center.y + r),
]
)
commands.append("Z")
for _ in range(hole_count):
count = reader.read("I")
if count > 50000:
raise ParseError("Excessive region hole points")
pts = [xy(*reader.read("dd")) for _ in range(count)]
commands.append(poly_path(pts, True))
all_points.extend(pts)
obj.properties["OUTLINE_PATH"] = " ".join(commands)
obj.bounds = bounds(all_points)
obj.position = all_points[0] if all_points else Point(x=0, y=0)
obj.properties["HOLE_COUNT"] = str(hole_count)
obj.properties["VERTEX_COUNT"] = str(n)
obj.properties["POLYGON_INDEX"] = str(at(p, 5, "H"))
kind = int(props.get("KIND", 0))
polygon = at(p, 5, "H")
is_cutout = props.get("ISBOARDCUTOUT", "FALSE").upper() == "TRUE"
if extended:
# When saved fills exist, shape definitions must not cover clearances twice.
if polygon != 65535 and not is_cutout:
obj.visible = False
continue
# Standalone shapes are taken from this stream, with circular arcs intact.
role = "region"
else:
if polygon == 65535 and not is_cutout:
obj.visible = False
continue
role = "pour"
if kind != 0 and not is_cutout:
obj.visible = False
report(
"region_definition",
f"Region kind {kind} is retained as a definition; saved copper holes govern the fill",
obj.id,
"info",
)
continue
if is_cutout:
shape(
obj,
" ".join(commands),
obj.bounds,
fill="#08151b",
layer_id="0",
role="board_cutout",
fill_rule="evenodd",
)
elif len(vertices) >= 3:
shape(
obj,
" ".join(commands),
obj.bounds,
fill="layer",
role=role,
fill_rule="evenodd",
)
if reader.remaining:
report(
"region_extension",
f"{name} contains uninterpreted region extension bytes",
obj.id,
)
regions("Regions6", False)
regions("ShapeBasedRegions6", True)
poured = {o.properties.get("POLYGON_INDEX") for o in objects if o.source_stream == "Regions6" and o.visible}
for index, definition in enumerate(polygons):
if str(index) not in poured:
report("polygon_without_saved_pour", "Polygons6 definition has no decoded saved copper pour; no fill is invented", f"Polygons6:{index}")
# Active copper layers are obtained from legacy stack links, not from LAYERSETSCOUNT (view presets).
copper_ids = [1]
while True:
nxt = int(board.get(f"LAYER{copper_ids[-1]}NEXT", "0"))
if not nxt: break
if nxt in copper_ids or nxt not in range(1,55) or 33 <= nxt <= 38:
raise ParseError("Invalid copper stack links")
copper_ids.append(nxt)
if 32 not in copper_ids:
copper_ids = [1,32]
report("copper_stack", "No complete layer chain; only explicit top/bottom copper stack supported")
for index, lid in enumerate(copper_ids):
ensure_layer(lid).order = 700 - index * 10
for i, parts in typed("Pads6", 2, 6):
name = Reader(parts[0], "Pad name").pascal()
p, stack = parts[4], parts[5]
if len(p) < 110:
raise ParseError("Unsupported short pad geometry")
obj, c = common("Pads6", i, p)
obj.kind = "pad"
pos = xy(*at(p, 13, "ii"))
obj.position = pos
sizes = [
(raw_unit(x), raw_unit(y))
for x, y in [at(p, 21, "ii"), at(p, 29, "ii"), at(p, 37, "ii")]
]
hole = raw_unit(at(p, 45, "i"))
if hole < 0: raise ParseError("Negative pad drill diameter")
shapes = list(at(p, 49, "BBB"))
rotation = at(p, 52, "d")
mode = at(p, 62, "B")
obj.properties.update(
{
"PAD_NAME": name,
"HOLE_MIL": str(hole * 10),
"ROTATION": str(rotation),
"PLATED": str(bool(p[60])),
"BOTTOM_SIZE_MIL": f"{sizes[2][0]*10:.15g} x {sizes[2][1]*10:.15g}",
"MID_SIZE_MIL": f"{sizes[1][0]*10:.15g} x {sizes[1][1]*10:.15g}",
"TOP_SIZE_MIL": f"{sizes[0][0] * 10:.15g} x {sizes[0][1] * 10:.15g}",
"SHAPE": str(shapes[0]),
"PAD_MODE": str(mode),
}
)
target_layers = (
copper_ids if int(obj.layer_id) == 74 else [int(obj.layer_id)]
)
obj.layer_ids = [str(lid) for lid in target_layers]
for lid in target_layers:
if mode not in (0,1,2) or (mode == 2 and len(stack) < 596):
report("pad_stack", "Unsupported pad stack mode or truncated full-stack variant", obj.id)
continue
which = 0 if lid == 1 else 2 if lid == 32 else 1
w, h = sizes[which]
s = shapes[which]
radius = 0
if mode == 2 and len(stack) >= 596 and 2 <= lid <= 30:
w, h = (
raw_unit(at(stack, 4 * (lid - 2), "i")),
raw_unit(at(stack, 116 + 4 * (lid - 2), "i")),
)
s = stack[232 + lid - 2]
stack_index = 0 if lid == 1 else 31 if lid == 32 else lid - 1
if mode == 2 and lid > 32:
report("pad_plane_stack", "Full-stack pad geometry on internal plane layers is not decoded", obj.id)
continue
if len(stack) >= 596 and lid <= 32:
alt = stack[532 + stack_index]
radius = stack[564 + stack_index]
if alt in (1, 2, 3, 9):
s = alt
elif alt != 0:
report(
"pad_alt_shape",
f"Unsupported alternative pad shape {alt}",
obj.id,
)
continue
try:
path, box = pad_shape(pos, w, h, s, rotation, radius)
except ParseError as exc:
report("pad_shape", str(exc), obj.id)
continue
shape(obj, path, box, fill="layer", layer_id=str(lid), role="pad")
if hole > 0:
hole_shape = stack[262] if len(stack) >= 596 else 0
slot = raw_unit(at(stack, 263, "i")) if len(stack) >= 596 else 0
hole_angle = at(stack, 267, "d") if len(stack) >= 596 else 0
dx = raw_unit(at(stack, 275, "i")) if len(stack) >= 596 else 0
dy = -raw_unit(at(stack, 403, "i")) if len(stack) >= 596 else 0
hpos = transform_point(Point(x=dx, y=dy), pos, rotation)
if hole_shape == 2 and slot > hole:
path, box = pad_shape(hpos, slot, hole, 1, rotation + hole_angle)
elif hole_shape in (0, 1):
path, box = pad_shape(
hpos, hole, hole, 2 if hole_shape == 1 else 1, rotation
)
else:
report(
"drill_shape",
f"Unsupported pad hole shape {hole_shape}",
obj.id,
)
continue
drill = shape(
obj,
path,
box,
fill="#08151b",
layer_id="81",
role="drill",
stroke="none",
)
drill.layer_ids = obj.layer_ids
for i, (p,) in typed("Vias6", 3):
obj, c = common("Vias6", i, p)
obj.kind = "via"
pos = xy(*at(p, 13, "ii"))
obj.position = pos
diameter, hole = raw_unit(at(p, 21, "i")), raw_unit(at(p, 25, "i"))
if diameter <= 0 or hole < 0:
raise ParseError("Invalid via diameter")
obj.properties.update(
{
"DIAMETER_MIL": str(diameter * 10),
"HOLE_MIL": str(hole * 10),
"START_LAYER": str(at(p, 29, "B")),
"END_LAYER": str(at(p, 30, "B")),
}
)
start_layer, end_layer = int(obj.properties["START_LAYER"]), int(obj.properties["END_LAYER"])
if start_layer not in copper_ids or end_layer not in copper_ids:
raise ParseError("Via span refers to an inactive/unsupported copper layer")
a, b = sorted((copper_ids.index(start_layer), copper_ids.index(end_layer)))
span = copper_ids[a:b+1]
obj.layer_ids = [str(lid) for lid in span]
obj.properties["LAYER_SPAN"] = ",".join(obj.layer_ids)
diameters = at(p,75,"32i") if len(p) >= 203 else None
for lid in span:
if diameters and lid > 32 and len(set(diameters)) > 1:
report("via_plane_stack", "Nonuniform via diameter on internal plane layers is not decoded", obj.id)
continue
size = raw_unit(diameters[lid-1]) if diameters and lid <= 32 else diameter
if size <= 0: raise ParseError("Invalid per-layer via diameter")
shape(obj, ellipse_path(pos.x,pos.y,size/2),bounds([pos],size/2),fill="layer",layer_id=str(lid),role="via")
if hole:
drill = shape(obj,ellipse_path(pos.x,pos.y,hole/2),bounds([pos],hole/2),fill="#08151b",layer_id="81",role="drill",stroke="none")
drill.layer_ids = obj.layer_ids
strings = wide_strings(read("WideStrings6/Data"))
for i, parts in typed("Texts6", 5, 2):
p = parts[0]
obj, c = common("Texts6", i, p, at(p, 226, "I", 0))
obj.kind = "text"
pos = xy(*at(p, 13, "ii"))
obj.position = pos
height = raw_unit(at(p, 21, "i"))
if height < 0: raise ParseError("Negative text height")
rotation = at(p, 27, "d")
mirror = bool(p[35])
font = "Arial"
bold = italic = False
is_comment = len(p) >= 123 and bool(p[40])
is_designator = len(p) >= 123 and bool(p[41])
if len(p) >= 123:
font = (
bytes(p[46:110]).decode("utf-16le", errors="replace").split("\0")[0]
or "Arial"
)
bold = bool(p[44])
italic = bool(p[45])
legacy = Reader(parts[1], "PCB text").pascal() if parts[1] else ""
value = (
strings.get(at(p, 115, "I", 4294967295), legacy)
if len(p) >= 123
else legacy
)
if c and is_designator:
c.designator = value or c.designator
if c.parameters.get("NAMEON", "TRUE") == "FALSE":
obj.visible = False
if c and is_comment:
c.value = value
c.comment = value
if c.parameters.get("COMMENTON", "FALSE") == "FALSE":
obj.visible = False
obj.properties.update(
{
"TEXT_HEIGHT": str(height),
"TEXT_MIRRORED": str(mirror),
"TEXT": value,
"ROTATION": str(rotation),
"FONT_NAME": font,
"DESIGNATOR": str(is_designator),
"COMMENT": str(is_comment),
}
)
if not obj.visible or not value:
continue
if len(p) > 123 and (p[43] == 2 or p[110] or p[123]):
report(
"text_style",
"PCB barcode/inverted text is approximated by readable text",
obj.id,
)
# Native font substitution is explicitly diagnosed below. Geometry remains vector text.
size = max(height * 1.35, 0.01)
w = max(size * 0.5, len(value) * size * 0.75)
corners = [
transform_point(Point(x=x, y=y), pos, rotation, mirror)
for x, y in [(0, 0), (w, 0), (w, -size), (0, -size)]
]
shape(
obj,
"",
bounds(corners),
position=pos,
text=value,
rotation=-rotation,
mirrored=mirror,
font_size=size,
font_family=font,
bold=bold,
italic=italic,
role="text",
)
for c in components:
if c.id in component_boxes:
c.bounds = union(component_boxes[c.id])
if not c.primitive_ids:
report(
"empty_footprint",
f"No supported 2D geometry for {c.designator or c.id}",
)
if len(primitives) > 250000:
raise ParseError("PCB drawable limit exceeded")
# Group layers once; stable within-layer priority keeps pours behind tracks/pads and drills above all.
priority = {
"board": 0,
"pour": 1,
"region": 2,
"fill": 3,
"track": 4,
"polygon_outline": 4,
"pad": 5,
"via": 6,
"text": 7,
"board_cutout": 8,
"drill": 9,
}
primitives.sort(
key=lambda p: (
1001 if p.role == "board_cutout" else layers[p.layer_id].order,
priority.get(p.role, 4),
)
)
supported = {
"FileHeader",
"FileHeaderSix",
"UniqueIDPrimitiveInformation",
"Board6",
"Components6",
"Nets6",
"Tracks6",
"Arcs6",
"Pads6",
"Vias6",
"Fills6",
"Texts6",
"WideStrings6",
"Regions6",
"ShapeBasedRegions6",
"Polygons6",
}
omitted = [
s["name"]
for s in streams
if s["size"]
and s["name"].endswith("/Data")
and s["name"].split("/")[0] not in supported
]
if omitted:
for stream in omitted:
header = read(stream.removesuffix('/Data')+'/Header')
count = at(header,0,'I') if len(header)==4 else 1
report("uninterpreted_stream", f"Not interpreted for this 2D viewer: {stream}", stream)
grouped[("uninterpreted_stream",f"Not interpreted for this 2D viewer: {stream}")].count = count
report(
"embedded_resources",
"Embedded STEP/3D models and fonts are not loaded or executed. This is a 2D PCB viewer.",
"",
"info",
)
report(
"pcb_text_metrics",
"PCB stroke/TrueType text uses browser font substitution; glyph metrics and special text styles can differ.",
)
report(
"pcb_colors",
"Layer colors use a viewer palette; stored layer identity, geometry, net and footprint ownership are retained.",
"",
"info",
)
report(
"pcb_scope",
"Saved copper fills are shown without repouring or executing design rules. Negative internal planes, mask-rule expansions and 3D bodies are not reconstructed.",
)
return Document(
schema_version="1.1",
document_type="pcb",
id=document_id,
filename=filename,
metadata={
"format": "ole",
"header": envelope,
"minor_version": board.get("VERSION"),
"sha256": hashlib.sha256(data).hexdigest(),
"input_bytes": len(data),
"streams": streams,
"record_counts": counts,
"record_count": sum(counts.values()),
"object_count": len(objects),
"record_variants": {k: dict(v) for k, v in variants.items()},
"component_count": len(components),
"primitive_count": len(primitives),
"units": "10mil",
"coordinates": "PCB integer /100000; X right, Y down",
"compatibility": "partial",
"header_properties": {
"ENVELOPE": envelope,
"BOARD_VERSION": board.get("VERSION", ""),
},
"net_count": len(nets),
"copper_stack": [str(lid) for lid in copper_ids],
"palette_source": "viewer",
"board_size_mm": {
"width": board_bounds.width * 0.254,
"height": board_bounds.height * 0.254,
},
"uninterpreted_streams": omitted,
},
sheet=Sheet(
name=filename,
background="#08151b",
bounds=board_bounds,
properties=board,
),
bounds=board_bounds,
components=components,
symbols=[],
objects=objects,
primitives=primitives,
diagnostics=diagnostics,
layers=sorted(layers.values(), key=lambda l: l.order, reverse=True),
nets=nets,
)
+130
مشاهده پرونده
@@ -0,0 +1,130 @@
"""Two PCB creation endpoints using the existing durable queue and capabilities."""
import hashlib
import json
import re
import secrets
import time
from pathlib import Path
from typing import Annotated, Literal
from fastapi import File, Form, Header, UploadFile
from fastapi.responses import FileResponse, JSONResponse, Response
from sqlalchemy import select, func, text
from starlette.concurrency import run_in_threadpool
from .config import settings
from .expiry import expiration
from .db import Job, sessions, active_jobs
from .storage import storage
from .uploads import read_pcb
from .models.pcb import PCBData, PCBStatus, PCBUploadResponse, PCBComparisonStatus, PCBComparisonUploadResponse
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
def register_pcb_routes(app, *, authorize, require_api, error):
def state(job, comparison=False):
base = settings().public_url
api = f'/api/v1/pcbs/comparisons/{job.id}' if comparison else f'/api/v1/pcbs/{job.id}'
view = f'/compare/pcb/{job.id}' if comparison else f'/view/pcb/{job.id}'
result = dict(success=job.status != 'failed', id=job.id, status=job.status,
document_type='pcb_comparison' if comparison else 'pcb',
viewer_url=base+view, status_url=base+api+('' if comparison else '/status'),
access=job.access, expires_at=job.expires_at)
if comparison: result.update(comparison_id=job.id, url=base+view)
if job.summary: result.update(json.loads(job.summary))
if job.error_code: result['error'] = dict(code=job.error_code, message=job.error_message)
return result
async def create(files, access, expires_in, comparison):
now=time.time();expires_at=expiration(expires_in,now)
sources = {}
try:
for field, file in files.items(): sources[field] = await read_pcb(file)
finally:
for file in files.values(): await file.close()
identifier = secrets.token_hex(24); token = secrets.token_urlsafe(32)
input_name = 'pcb-comparison.json' if comparison else 'input.PcbDoc'
job = Job(id=identifier, filename='PCB comparison' if comparison else sources['file'][0],
status='queued', access=access, token_hash=hashlib.sha256(token.encode()).hexdigest(),
created_at=now, updated_at=now, expires_at=expires_at, attempt=0, input_key=f'{identifier}/{input_name}')
if comparison:
bodies = {f'{identifier}/{side}.PcbDoc':sources[side+'_file'][1] for side in ('old','new')}
bodies[job.input_key] = json.dumps(dict(schema_version='1.0',document_type='pcb_comparison',
old_filename=sources['old_file'][0],new_filename=sources['new_file'][0])).encode()
else: bodies = {job.input_key:sources['file'][1]}
def persist():
written = []
try:
with sessions().begin() as db:
if db.bind.dialect.name == 'postgresql': db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
pending = db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),active_jobs(now)))
if pending >= settings().max_pending: error('queue_full','Rendering queue is full; try again later',503)
for key, body in bodies.items():
# Include the attempted key so a write-then-raise backend is also cleaned up.
written.append(key)
try: storage().put(key,body)
except Exception: error('storage_unavailable','Upload storage unavailable',503)
db.add(job); db.flush()
except Exception:
for key in written:
try: storage().delete(key)
except Exception: pass
raise
await run_in_threadpool(persist)
result = state(job,comparison)
if access == 'private':
result['viewer_token'] = token; result['viewer_url'] += '#token='+token
if comparison: result['url'] = result['viewer_url']
return JSONResponse(result,status_code=202,headers={'Location':result['status_url'],'Retry-After':'1'})
@app.post('/api/v1/pcbs/render', status_code=202, response_model=PCBUploadResponse, response_model_exclude_none=True, tags=['PCB'])
async def render(file:Annotated[UploadFile,File()], access:Annotated[Literal['private','unlisted'],Form()]='private',
expires_in:Annotated[int|None,Form(ge=-1, description="-1 for no expiry, or positive seconds")]=None, x_api_key:Annotated[str|None,Header()]=None):
require_api(x_api_key)
return await create({'file':file},access,expires_in,False)
@app.post('/api/v1/pcbs/comparisons',status_code=202,response_model=PCBComparisonUploadResponse,response_model_exclude_none=True,tags=['PCB'])
async def compare(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()],
access:Annotated[Literal['private','unlisted'],Form()]='private',
expires_in:Annotated[int|None,Form(ge=-1, description="-1 for no expiry, or positive seconds")]=None,x_api_key:Annotated[str|None,Header()]=None):
require_api(x_api_key)
return await create({'old_file':old_file,'new_file':new_file},access,expires_in,True)
def get_job(identifier,key,token,comparison=False):
return authorize(identifier,key,token,resource_kind='pcb_comparison' if comparison else 'pcb_render')
def artifact(job,name):
if job.status == 'failed': error(job.error_code,job.error_message,422)
if job.status != 'completed': error('not_ready','PCB processing is still in progress',409)
try: body = storage().get(job.output_prefix+'/'+name)
except Exception: error('storage_unavailable','PCB artifact unavailable',503)
return Response(body,media_type='application/json' if name.endswith('.json') else 'image/svg+xml')
@app.get('/api/v1/pcbs/comparisons/{identifier}',response_model=PCBComparisonStatus,response_model_exclude_none=True,tags=['PCB'])
def comparison_status(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
job=get_job(identifier,x_api_key,x_viewer_token,True); result=state(job,True)
if job.status == 'completed':
try: result.update(json.loads(storage().get(job.output_prefix+'/data.json')))
except Exception: error('storage_unavailable','PCB comparison unavailable',503)
return result
@app.get('/api/v1/pcbs/comparisons/{identifier}/svg/{side}',response_class=Response,tags=['PCB'])
def comparison_svg(identifier:str,side:Literal['old','new'],x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
return artifact(get_job(identifier,x_api_key,x_viewer_token,True),side+'.svg')
@app.get('/api/v1/pcbs/{identifier}/status',response_model=PCBStatus,response_model_exclude_none=True,tags=['PCB'])
def status(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
return state(get_job(identifier,x_api_key,x_viewer_token))
@app.get('/api/v1/pcbs/{identifier}/data',response_model=PCBData,tags=['PCB'])
def data(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
return artifact(get_job(identifier,x_api_key,x_viewer_token),'data.json')
@app.get('/api/v1/pcbs/{identifier}/svg',response_class=Response,tags=['PCB'])
def svg(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
return artifact(get_job(identifier,x_api_key,x_viewer_token),'drawing.svg')
@app.get('/view/pcb/{identifier}',include_in_schema=False)
@app.get('/compare/pcb/{identifier}',include_in_schema=False)
def page(identifier:str):
if not re.fullmatch('[a-f0-9]{48}',identifier): error('not_found','PCB not found',404)
return FileResponse(FRONTEND/'pcb.html')
+134
مشاهده پرونده
@@ -0,0 +1,134 @@
"""PCB object comparison; reuses only the format-independent entity matcher."""
import math
import re
from collections import defaultdict
from .diff import Entity, digest, match_entities
TOKEN = re.compile(r'[MLAZ]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?')
IGNORE = {'NET_INDEX','COMPONENT_INDEX','POLYGON_INDEX','INDEX','UNIQUEID','SOURCEUNIQUEID',
'LOCKED','FLAGS_1','FLAGS_2','OUTLINE_PATH','X','Y','ROTATION','LAYER'}
def number(n):
return round(float(n), 7) or 0.0
def point(x, y, origin, rotation=0, mirrored=False):
x,y = x-origin[0],y-origin[1]
a=math.radians(rotation)
x,y=x*math.cos(a)-y*math.sin(a),x*math.sin(a)+y*math.cos(a)
return (number(-x if mirrored else x),number(y))
def path_signature(path, origin, rotation=0, mirrored=False):
"""Canonical local geometry for the M/L/A/Z vocabulary emitted by the PCB parser."""
tokens=TOKEN.findall(path); out=[]; i=0
while i<len(tokens):
cmd=tokens[i]; i+=1
if cmd in ('M','L'):
out.append((cmd,*point(float(tokens[i]),float(tokens[i+1]),origin,rotation,mirrored)));i+=2
elif cmd=='A':
rx,ry,angle,large,sweep,x,y=map(float,tokens[i:i+7]);i+=7
angle=(angle+rotation)%180
if mirrored: angle=(-angle)%180;sweep=1-sweep
out.append(('A',number(rx),number(ry),number(angle),int(large),int(sweep),*point(x,y,origin,rotation,mirrored)))
elif cmd=='Z':out.append(('Z',))
else: raise ValueError('Unexpected PCB SVG path command')
# A straight track has no electrical direction.
if len(out)==2 and out[0][0]=='M' and out[1][0]=='L':
ends=sorted([out[0][1:],out[1][1:]])
out=[('M',*ends[0]),('L',*ends[1])]
return out
def entities(doc):
primitive_map={p.id:p for p in doc.primitives};children=defaultdict(list)
for o in doc.objects:
if o.component_id:children[o.component_id].append(o)
def properties(o,origin,rotation=0,mirrored=False):
p={k:v for k,v in o.properties.items() if k not in IGNORE and not k.endswith('_INDEX')}
# Region definitions may be invisible; their real retained boundary still participates.
if 'OUTLINE_PATH' in o.properties:
p['outline']=path_signature(o.properties['OUTLINE_PATH'],origin,rotation,mirrored)
if 'ROTATION' in o.properties:p['rotation']=number((float(o.properties['ROTATION'])-rotation)%360)
if o.kind=='board_outline':p={}
if o.kind=='polygon_definition':
p={k:v for k,v in p.items() if not re.fullmatch(r'(?:VX|VY|CX|CY|SA|EA|R|KIND)\d+',k) and k!='NET'}
p['net']=next((n.name for n in doc.nets if n.id==o.net_id),None)
from .parser.pcb_geometry import property_outline
p['outline']=path_signature(property_outline(o.properties)[0],origin,rotation,mirrored)
geometry=[]
for pid in o.primitive_ids:
primitive=primitive_map[pid]
item=dict(kind=primitive.kind,layer=primitive.layer_id,layers=getattr(primitive,'layer_ids',[]),
width=primitive.width,fill_rule=primitive.fill_rule,role=primitive.role,
net=next((n.name for n in doc.nets if n.id==primitive.net_id),None))
if primitive.kind=='path':item['path']=path_signature(primitive.path,origin,rotation,mirrored)
else:item.update(text=primitive.text,position=point(primitive.position.x,primitive.position.y,origin,rotation,mirrored),
rotation=number((primitive.rotation+rotation)%360),mirrored=primitive.mirrored != mirrored,
font=primitive.font_family,size=primitive.font_size,bold=primitive.bold,italic=primitive.italic)
geometry.append(item)
p['geometry']=sorted(geometry,key=digest)
p['visible']=o.visible
return p
result=[]
for c in doc.components:
origin=(c.position.x,c.position.y)
p={k:v for k,v in c.parameters.items() if k not in IGNORE}
p.update(designator=c.designator,pattern=c.library_reference,value=c.value,
rotation=number(c.rotation%360),mirrored=c.mirrored)
p['children']=sorted([dict(kind=o.kind,properties=properties(o,origin,c.rotation,c.mirrored))
for o in children[c.id]],key=digest)
result.append(Entity(c.id,'footprint',c.parameters.get('UNIQUEID',c.parameters.get('SOURCEUNIQUEID','')),
c.designator,origin,c.bounds.model_dump(),p,digest(p),c.primitive_ids,0))
for o in doc.objects:
if o.component_id:continue
origin=(o.position.x,o.position.y)
if o.kind=='track' and o.connection_points:
origin=min((p.x,p.y) for p in o.connection_points)
p=properties(o,origin)
result.append(Entity(o.id,o.kind,getattr(o,'native_uid',''),'',origin,
o.bounds.model_dump() if o.primitive_ids else None,p,digest(p),o.primitive_ids,o.record_type))
# The layer stack is independent of source record order and object counts.
layer_props={k:v for k,v in doc.sheet.properties.items() if re.fullmatch(r'LAYER\d+(?:NAME|PREV|NEXT)|LAYERV7_\d+(?:NAME|LAYERID|PREV|NEXT)',k)}
layer_props['copper_stack']=doc.metadata.get('copper_stack',[])
result.append(Entity('pcb-layer-stack','layer_stack','board-layer-stack','',(0,0),None,layer_props,digest(layer_props),[],0))
return result
def compare_pcbs(old,new):
if old.document_type!='pcb' or new.document_type!='pcb':raise ValueError('PCB documents required')
before,after=entities(old),entities(new)
pairs,removed,added,ambiguous=match_entities(before,after);changes=[]
def change(status,a,b,method='',fields=None):
e=b or a
changes.append(dict(id=f'change-{len(changes)}',status=status,kind=e.kind,
label=e.properties.get('designator') or e.properties.get('PAD_NAME') or e.properties.get('TEXT') or e.kind,
old_id=a.id if a else None,new_id=b.id if b else None,
old_bounds=a.bounds if a else None,new_bounds=b.bounds if b else None,
old_primitive_ids=a.primitive_ids if a else [],new_primitive_ids=b.primitive_ids if b else [],match=method,
moved=bool(a and b and a.position!=b.position),
movement=dict(dx=number(b.position[0]-a.position[0]),dy=number(b.position[1]-a.position[1])) if a and b else None,
rotation_change=number(b.properties.get('rotation',0)-a.properties.get('rotation',0)) if a and b else None,
fields=fields or []))
for a,b,method in pairs:
fields=[dict(property=k,old=a.properties.get(k),new=b.properties.get(k)) for k in sorted(a.properties.keys()|b.properties.keys()) if a.properties.get(k)!=b.properties.get(k)]
if fields or a.position!=b.position:change('modified' if fields else 'moved',a,b,method,fields)
for e in removed:change('removed',e,None)
for e in added:change('added',None,e)
def info(doc):
return dict(filename=doc.filename,sha256=doc.metadata['sha256'],compatibility=doc.metadata['compatibility'],
sheet=doc.sheet.bounds.model_dump(),bounds=doc.bounds.model_dump(),layers=[l.model_dump() for l in doc.layers],
diagnostics=[d.model_dump() for d in doc.diagnostics])
a={n.name for n in old.nets};b={n.name for n in new.nets}
return dict(schema_version='1.0',document_type='pcb_comparison',old=info(old),new=info(new),changes=changes,
summary={s:sum(c['status']==s for c in changes) for s in ('added','removed','modified','moved')},total_changed=len(changes),
nets=dict(added=sorted(b-a),removed=sorted(a-b)),
matching=dict(matched=len(pairs),unmatched_old=len(removed),unmatched_new=len(added),ambiguous_groups=ambiguous),
limitations=['PCB object/net-assignment comparison; no DRC or proof of electrical equivalence.',
'Ambiguous identities remain added/removed; non-UID matches are heuristic.',
'Footprint children are compared locally; independent routes remain separate.',
'Unsupported native streams are not compared; inspect both parser diagnostic lists.',
'Saved pours may be stale; no repouring or negative-plane reconstruction.'])
+42
مشاهده پرونده
@@ -0,0 +1,42 @@
"""PCB render and comparison inside the existing parser resource boundary."""
from . import parse_task # Apply CPU/address-space/output/FD limits before parsing.
import json
import sys
from pathlib import Path
from .documents import parse_document
from .pcb_diff import compare_pcbs
from .parser.record_decoder import ParseError
from .render import svg_document
def run():
source,destination,filename,identifier,kind=sys.argv[1:]
dest=Path(destination)
try:
if kind=='pcb_comparison':
manifest=json.loads(Path(source).read_text())
if manifest.get('schema_version')!='1.0' or manifest.get('document_type')!='pcb_comparison':
raise ParseError('Unsupported PCB comparison manifest')
old=parse_document((dest/'old.PcbDoc').read_bytes(),manifest['old_filename'],identifier+'-old',document_type='pcb')
new=parse_document((dest/'new.PcbDoc').read_bytes(),manifest['new_filename'],identifier+'-new',document_type='pcb')
report=compare_pcbs(old,new)
(dest/'data.json').write_text(json.dumps(report,ensure_ascii=False),encoding='utf-8')
for side,doc in [('old',old),('new',new)]:
annotations={c[f'{side}_id']:dict(id=c['id'],status=c['status'],bounds=c[f'{side}_bounds']) for c in report['changes'] if c[f'{side}_id'] is not None}
(dest/f'{side}.svg').write_bytes(svg_document(doc,annotations,id_prefix=side+'-'))
summary=dict(summary=report['summary'],total_changed=report['total_changed'])
elif kind=='pcb_render':
doc=parse_document(Path(source).read_bytes(),filename,identifier,document_type='pcb')
(dest/'data.json').write_text(doc.model_dump_json(),encoding='utf-8')
(dest/'drawing.svg').write_bytes(svg_document(doc))
summary={k:doc.metadata[k] for k in ('compatibility','record_count','component_count','primitive_count','net_count')}
summary.update(schema_version=doc.schema_version,diagnostics=[d.model_dump() for d in doc.diagnostics],layers=[l.model_dump() for l in doc.layers])
else:raise ParseError('Unsupported PCB job kind')
result=dict(ok=True,summary=summary)
except (ParseError,ValueError) as exc:
result=dict(ok=False,code='invalid_pcb',message=str(exc)[:500])
except Exception:
result=dict(ok=False,code='pcb_processing_error',message='PCB processing could not be completed')
(dest/'result.json').write_text(json.dumps(result),encoding='utf-8')
if __name__=='__main__':run()
+9 -1
مشاهده پرونده
@@ -11,8 +11,10 @@ def node(parent, tag, **attrs):
def svg_document(doc: Document, annotations=None, id_prefix="") -> bytes: def svg_document(doc: Document, annotations=None, id_prefix="") -> bytes:
b = doc.sheet.bounds b = doc.sheet.bounds
pcb = doc.document_type == 'pcb'
layer_visibility = {layer.id: layer.visible for layer in doc.layers}
root = Element('svg', {'xmlns': 'http://www.w3.org/2000/svg', 'viewBox': f'{b.x} {b.y} {b.width} {b.height}', root = Element('svg', {'xmlns': 'http://www.w3.org/2000/svg', 'viewBox': f'{b.x} {b.y} {b.width} {b.height}',
'preserveAspectRatio': 'xMidYMid meet', 'role': 'img', 'aria-label': 'Schematic'}) 'preserveAspectRatio': 'xMidYMid meet', 'role': 'img', 'aria-label': 'PCB' if pcb else 'Schematic'})
node(root, 'rect', x=b.x, y=b.y, width=b.width, height=b.height, fill=doc.sheet.background) node(root, 'rect', x=b.x, y=b.y, width=b.width, height=b.height, fill=doc.sheet.background)
defs = node(root, 'defs') defs = node(root, 'defs')
for i, p in enumerate(doc.primitives): for i, p in enumerate(doc.primitives):
@@ -20,6 +22,12 @@ def svg_document(doc: Document, annotations=None, id_prefix="") -> bytes:
if annotations is not None: if annotations is not None:
annotation = annotations.get(p.component_id or p.object_id) annotation = annotations.get(p.component_id or p.object_id)
target = node(root, 'g', data_object_id=p.component_id or p.object_id, data_change_id=annotation['id'] if annotation else '') target = node(root, 'g', data_object_id=p.component_id or p.object_id, data_change_id=annotation['id'] if annotation else '')
if pcb:
span = getattr(p, "layer_ids", [])
visible = layer_visibility.get(p.layer_id, True) and (p.role != "drill" or not span or any(layer_visibility.get(lid, False) for lid in span))
target = node(target, 'g', data_layer_id=p.layer_id or '',
data_layer_span=' '.join(getattr(p, 'layer_ids', [])), data_role=p.role,
display='inline' if visible else 'none')
if p.clip: if p.clip:
clip = node(defs, 'clipPath', id=f'{id_prefix}clip-{i}', clipPathUnits='userSpaceOnUse') clip = node(defs, 'clipPath', id=f'{id_prefix}clip-{i}', clipPathUnits='userSpaceOnUse')
c = p.clip c = p.clip
+28 -13
مشاهده پرونده
@@ -6,16 +6,27 @@ from functools import lru_cache
from pathlib import Path from pathlib import Path
from .config import settings from .config import settings
KEY = re.compile(r'^[a-f0-9]{48}/(?:input\.SchDoc|(?:old|new)\.SchDoc|comparison\.json|attempt-[12]/(?:data\.json|drawing\.svg|old\.svg|new\.svg))$') KEY = re.compile(r'^[a-f0-9]{48}/(?:input\.(?:SchDoc|PcbDoc)|(?:old|new)\.(?:SchDoc|PcbDoc)|comparison\.json|pcb-comparison\.json|attempt-[12]/(?:data\.json|drawing\.svg|old\.svg|new\.svg))$')
class Storage: class Storage:
def __init__(self): def __init__(self):
self.config = settings() self.config = settings()
if self.config.s3_bucket: if self.config.storage_backend == 's3':
import boto3 import boto3
self.s3 = boto3.client('s3', endpoint_url=self.config.s3_endpoint, region_name=self.config.s3_region) from botocore.config import Config
self.s3 = boto3.client('s3', endpoint_url=self.config.s3_endpoint, region_name=self.config.s3_region,
config=Config(signature_version='s3v4',
s3={'addressing_style': self.config.s3_addressing_style},
connect_timeout=self.config.s3_connect_timeout,
read_timeout=self.config.s3_read_timeout,
retries={'mode': 'standard', 'total_max_attempts': self.config.s3_max_attempts}))
else: self.s3 = None else: self.s3 = None
def object_key(self, key):
"""Prefix exists only at the storage boundary, never in API IDs or DB keys."""
if not KEY.fullmatch(key): raise ValueError('Invalid storage key')
return f'{self.config.s3_prefix}/{key}' if self.config.s3_prefix else key
def path(self, key): def path(self, key):
if not KEY.fullmatch(key): raise ValueError('Invalid storage key') if not KEY.fullmatch(key): raise ValueError('Invalid storage key')
p = self.config.storage_dir / 'objects' / key p = self.config.storage_dir / 'objects' / key
@@ -24,10 +35,14 @@ class Storage:
return p return p
def put(self, key, data, content_type='application/octet-stream'): def put(self, key, data, content_type='application/octet-stream'):
p = self.path(key) # validate on both backends remote_key = self.object_key(key)
if self.s3: if self.s3 is not None:
self.s3.put_object(Bucket=self.config.s3_bucket, Key=key, Body=data, ContentType=content_type) options = {}
if self.config.s3_encryption: options['ServerSideEncryption'] = self.config.s3_encryption
if self.config.s3_kms_key_id: options['SSEKMSKeyId'] = self.config.s3_kms_key_id
self.s3.put_object(Bucket=self.config.s3_bucket, Key=remote_key, Body=data, ContentType=content_type, **options)
else: else:
p = self.path(key)
p.parent.mkdir(parents=True, exist_ok=True, mode=0o700) p.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
tmp = p.with_name(p.name + '.' + secrets.token_hex(8) + '.tmp') tmp = p.with_name(p.name + '.' + secrets.token_hex(8) + '.tmp')
try: try:
@@ -38,16 +53,16 @@ class Storage:
finally: tmp.unlink(missing_ok=True) finally: tmp.unlink(missing_ok=True)
def get(self, key): def get(self, key):
p = self.path(key) remote_key = self.object_key(key)
if self.s3: if self.s3 is not None:
with self.s3.get_object(Bucket=self.config.s3_bucket, Key=key)['Body'] as body: with self.s3.get_object(Bucket=self.config.s3_bucket, Key=remote_key)['Body'] as body:
return body.read() return body.read()
return p.read_bytes() return self.path(key).read_bytes()
def delete(self, key): def delete(self, key):
p = self.path(key) remote_key = self.object_key(key)
if self.s3: self.s3.delete_object(Bucket=self.config.s3_bucket, Key=key) if self.s3 is not None: self.s3.delete_object(Bucket=self.config.s3_bucket, Key=remote_key)
else: p.unlink(missing_ok=True) else: self.path(key).unlink(missing_ok=True)
@lru_cache @lru_cache
def storage(): return Storage() def storage(): return Storage()
+23 -5
مشاهده پرونده
@@ -3,18 +3,36 @@ from fastapi import HTTPException
from .config import settings from .config import settings
from .parser.record_decoder import ParseError,detect_format from .parser.record_decoder import ParseError,detect_format
async def read_schematic(file): async def read_native(file, document_type):
try: try:
name=(file.filename or '').replace('\\','/').rsplit('/',1)[-1] name=(file.filename or '').replace('\\','/').rsplit('/',1)[-1]
name=''.join(c for c in name if c.isprintable())[:255] name=''.join(c for c in name if c.isprintable())[:255]
if not name.lower().endswith('.schdoc'): suffix = '.PcbDoc' if document_type == 'pcb' else '.SchDoc'
raise HTTPException(415,{'code':'unsupported_file_type','message':'Upload a .SchDoc file'}) if not name.lower().endswith(suffix.lower()):
raise HTTPException(415,{'code':'unsupported_file_type','message':f'Upload a {suffix} file'})
body=bytearray() body=bytearray()
while chunk:=await file.read(1024*1024): while chunk:=await file.read(1024*1024):
body.extend(chunk) body.extend(chunk)
if len(body)>settings().max_upload_bytes: if len(body)>settings().max_upload_bytes:
raise HTTPException(413,{'code':'upload_too_large','message':'Schematic exceeds configured upload size'}) raise HTTPException(413,{'code':'upload_too_large','message':'Schematic exceeds configured upload size'})
try:detect_format(bytes(body)) from starlette.concurrency import run_in_threadpool
except ParseError as exc:raise HTTPException(422,{'code':'invalid_schematic','message':str(exc)}) from exc from .parser.pcb_container import identify_pcb, contains_pcb
try:
if document_type == 'pcb':
await run_in_threadpool(identify_pcb, bytes(body))
else:
detect_format(bytes(body))
if await run_in_threadpool(contains_pcb, bytes(body)):
raise ParseError('PCB files are not accepted by schematic endpoints')
except ParseError as exc:
raise HTTPException(422,{'code':'invalid_pcb' if document_type == 'pcb' else 'invalid_schematic','message':str(exc)}) from exc
return name,bytes(body) return name,bytes(body)
finally:await file.close() finally:await file.close()
async def read_schematic(file):
return await read_native(file, "schematic")
async def read_pcb(file):
return await read_native(file, "pcb")
+11 -5
مشاهده پرونده
@@ -13,6 +13,7 @@ from .config import settings
from .cleanup import cleanup from .cleanup import cleanup
from .db import Job, claim, initialize, sessions from .db import Job, claim, initialize, sessions
from .storage import storage from .storage import storage
from .job_types import job_kind, comparison_sources
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
stopping = False stopping = False
@@ -23,19 +24,24 @@ def process(job):
try: try:
with tempfile.TemporaryDirectory(prefix='schdoc-') as directory: with tempfile.TemporaryDirectory(prefix='schdoc-') as directory:
root = Path(directory) root = Path(directory)
source = root / 'input.SchDoc' kind = job_kind(job['input_key'])
comparison = job['input_key'].endswith('/comparison.json') if kind is None: raise ValueError('Unknown job input kind')
pcb = kind.startswith('pcb_')
source = root / ('input.PcbDoc' if pcb else 'input.SchDoc')
comparison = kind.endswith('_comparison')
if comparison: if comparison:
manifest = json.loads(storage().get(job['input_key'])) manifest = json.loads(storage().get(job['input_key']))
source = root / 'comparison.json' source = root / 'comparison.json'
source.write_text(json.dumps(manifest), encoding='utf-8') source.write_text(json.dumps(manifest), encoding='utf-8')
for side in ('old','new'): for key in comparison_sources(job['input_key']):
(root / f'{side}.SchDoc').write_bytes(storage().get(f"{job['id']}/{side}.SchDoc")) (root / key.rsplit('/', 1)[-1]).write_bytes(storage().get(key))
else: else:
source.write_bytes(storage().get(job['input_key'])) source.write_bytes(storage().get(job['input_key']))
env = {k: v for k, v in os.environ.items() if k in ('PATH', 'LANG', 'LC_ALL', 'PYTHONPATH')} env = {k: v for k, v in os.environ.items() if k in ('PATH', 'LANG', 'LC_ALL', 'PYTHONPATH')}
# Drain no untrusted output into logs. Kill/wait is handled by subprocess.run. # Drain no untrusted output into logs. Kill/wait is handled by subprocess.run.
subprocess.run([sys.executable, '-m', 'app.compare_task' if comparison else 'app.parse_task', str(source), str(root), job['filename'], job['id']], task = 'app.pcb_task' if pcb else 'app.compare_task' if comparison else 'app.parse_task'
args = [kind] if pcb else []
subprocess.run([sys.executable, '-m', task, str(source), str(root), job['filename'], job['id'], *args],
timeout=config.parse_timeout, check=True, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) timeout=config.parse_timeout, check=True, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
result = json.loads((root / 'result.json').read_text()) result = json.loads((root / 'result.json').read_text())
if result['ok']: if result['ok']:
@@ -0,0 +1,99 @@
"""Tiny CFB writer solely for bounded native-format unit fixtures (never application data)."""
import math
import struct
FREE=0xffffffff; END=0xfffffffe; FAT=0xfffffffd
def cfb(streams):
entries=[('Root Entry',5,b'',[])]
storages={}
for name,data in streams.items():
parts=name.split('/'); parent=0
for part in parts[:-1]:
key=(parent,part)
if key not in storages:
storages[key]=len(entries);entries.append((part,1,b'',[]));entries[parent][3].append(storages[key])
parent=storages[key]
entries[parent][3].append(len(entries));entries.append((parts[-1],2,data,[]))
# This fixture deliberately uses normal >=4096-byte streams, with padding INSIDE native records.
assert all(len(e[2])>=4096 for e in entries if e[1]==2)
sectors=[];links=[];starts={}
def allocate(data):
start=len(sectors); count=math.ceil(len(data)/512)
for i in range(count):sectors.append(data[i*512:(i+1)*512].ljust(512,b'\0'));links.append(start+i+1 if i+1<count else END)
return start
for i,e in enumerate(entries):
if e[1]==2:starts[i]=allocate(e[2])
directory=bytearray(math.ceil(len(entries)/4)*512)
for i,(name,kind,data,children) in enumerate(entries):
offset=i*128;encoded=(name+'\0').encode('utf-16le');directory[offset:offset+len(encoded)]=encoded
struct.pack_into('<HBB',directory,offset+64,len(encoded),kind,1)
siblings=[]
for parent in entries:
if i in parent[3]:siblings=parent[3];break
right=siblings[siblings.index(i)+1] if siblings and siblings.index(i)+1<len(siblings) else FREE
struct.pack_into('<III',directory,offset+68,FREE,right,children[0] if children else FREE)
struct.pack_into('<IQ',directory,offset+116,starts.get(i,END),len(data))
dir_start=allocate(directory)
fat_id=len(sectors); assert fat_id<128
links.append(FAT)
fat=struct.pack('<128I',*(links+[FREE]*(128-len(links))))
sectors.append(fat)
header=bytearray(512);header[:8]=bytes.fromhex('d0cf11e0a1b11ae1')
struct.pack_into('<HHHHH',header,24,0x3e,3,0xfffe,9,6)
struct.pack_into('<IIIIIIIII',header,40,0,1,dir_start,0,4096,END,0,END,0)
struct.pack_into('<109I',header,76,fat_id,*([FREE]*108))
return bytes(header)+b''.join(sectors)
def text(props):
data=('|'+'|'.join(f'{k}={v}' for k,v in props.items())+'|_PADDING=').encode()
data=data.ljust(4091,b' ')+b'\0'
return struct.pack('<I',len(data))+data
def record(tag,data):
data=bytes(data).ljust(4091,b'\0')
return bytes([tag])+struct.pack('<I',len(data))+data
def pcb(*, width=100000, x=100000, net='GND', version='5.01', track=True, corrupt=False):
header=b'PCB 6.0 Binary File'
board={'KIND':'Protel_Advanced_PCB','VERSION':version,'LAYER1NEXT':'32','LAYER32PREV':'1',
'VX0':'0mil','VY0':'0mil','VX1':'100mil','VY1':'0mil','VX2':'100mil','VY2':'100mil','VX3':'0mil','VY3':'100mil'}
streams={'FileHeaderSix':(struct.pack('<IB',len(header),len(header))+header).ljust(4096,b'\0'),
'Board6/Data':text(board),'Nets6/Data':text({'NAME':net})}
if track:
p=bytearray(45);p[0]=1
struct.pack_into('<HHH',p,3,0,65535,65535)
struct.pack_into('<iiiii',p,13,x,100000,x+200000,100000,width)
streams['Tracks6/Data']=record(4,p)
if corrupt:streams['Tracks6/Data']=b'\x01'+streams['Tracks6/Data'][1:]
return cfb(streams)
def rich_pcb():
"""Pad stack and blind via records follow the documented V6 field offsets."""
import io
import olefile
with olefile.OleFileIO(io.BytesIO(pcb())) as ole:
streams={'/'.join(e):ole.openstream(e).read() for e in ole.listdir()}
streams['Components6/Data']=text({'SOURCEDESIGNATOR':'U1','PATTERN':'QFN','SOURCEUNIQUEID':'FOOTPRINT_UID',
'X':'10mil','Y':'10mil','LAYER':'TOP','ROTATION':'0'})
p=bytearray(110);p[0]=74
struct.pack_into('<HHH',p,3,0,65535,0)
struct.pack_into('<ii',p,13,100000,100000)
struct.pack_into('<iiiiiii',p,21,100000,200000,150000,250000,200000,300000,50000)
struct.pack_into('<BBB',p,49,1,2,3);p[60]=1
stack=bytearray(3959);stack[262]=2
struct.pack_into('<i',stack,263,100000) # slotted hole
parts=[b'\x011',b'',b'',b'',bytes(p),bytes(stack)]
streams['Pads6/Data']=b'\x02'+b''.join(struct.pack('<I',len(part))+part for part in parts)
v=bytearray(203);v[0]=74
struct.pack_into('<HHH',v,3,0,65535,65535)
struct.pack_into('<iiii',v,13,500000,500000,100000,50000)
v[29]=1;v[30]=32;struct.pack_into('<32i',v,75,*([100000]*31+[200000]))
streams['Vias6/Data']=record(3,v)
streams['UniqueIDPrimitiveInformation/Data']=text({'PRIMITIVEINDEX':'0','PRIMITIVEOBJECTID':'Pad','UNIQUEID':'PAD_UID'})
return cfb(streams)
@@ -0,0 +1,73 @@
import time
import pytest
from app.config import settings
from app.db import Job,claim,sessions
from app.storage import storage
from app.worker import process
from app.cleanup import cleanup
from test_platform import client,KEY
from pcb_fixture import pcb
ROUTES=['/api/v1/schematics/render','/api/v1/comparisons','/api/v1/pcbs/render','/api/v1/pcbs/comparisons']
def create(client,route,raw,expires=None):
suffix='PcbDoc' if '/pcbs/' in route else 'SchDoc'
files={f'{side}_file':(side+'.'+suffix,raw) for side in ('old','new')} if route.endswith('comparisons') else {'file':('input.'+suffix,raw)}
return client.post(route,headers={'X-API-Key':KEY},files=files,data={} if expires is None else {'expires_in':str(expires)})
@pytest.mark.parametrize('route',ROUTES)
@pytest.mark.parametrize('backend',['local','s3'])
def test_unlimited_retention_survives_claim_read_and_cleanup(client,ascii_schematic,monkeypatch,route,backend):
fake=None
if backend=='s3':
import boto3
from test_storage_s3 import MemoryS3
fake=MemoryS3();monkeypatch.setattr(boto3,'client',lambda *a,**kw:fake)
monkeypatch.setenv('STORAGE_BACKEND','s3');monkeypatch.setenv('S3_BUCKET','test-private');monkeypatch.setenv('S3_PREFIX','unlimited')
settings.cache_clear();storage.cache_clear()
raw=pcb() if '/pcbs/' in route else ascii_schematic
created=create(client,route,raw,-1);assert created.status_code==202,created.text
body=created.json();assert body['expires_at']==-1
with sessions()() as db:assert db.get(Job,body['id']).expires_at==-1
# Advance the application clock far beyond every configured finite TTL.
future=time.time()+100*365*86400;monkeypatch.setattr(time,'time',lambda:future)
claimed=claim();assert claimed is not None and claimed['id']==body['id']
process(claimed)
headers={'X-Viewer-Token':body['viewer_token']}
response=client.get(body['status_url'],headers=headers);assert response.status_code==200
status=response.json();assert status['status']=='completed',status
assert status['expires_at']==-1
base=body['status_url'].removesuffix('/status')
assert client.get(base+('/svg/old' if route.endswith('comparisons') else '/svg'),headers=headers).status_code==200
assert client.get(body['status_url']).status_code==404
assert cleanup()==0
with sessions()() as db:assert db.get(Job,body['id']) is not None
if fake:assert fake.objects and not (settings().storage_dir/'objects').exists()
else:assert list((settings().storage_dir/'objects'/body['id']).rglob('*'))
@pytest.mark.parametrize('route',ROUTES)
@pytest.mark.parametrize('expires',[0,-2,999999999])
def test_invalid_expiry_rejected_for_every_operation(client,ascii_schematic,route,expires):
response=create(client,route,pcb() if '/pcbs/' in route else ascii_schematic,expires)
assert response.status_code==422,response.text
assert claim() is None
@pytest.mark.parametrize('route',ROUTES)
def test_unlimited_jobs_still_count_toward_queue_limit(client,ascii_schematic,monkeypatch,route):
monkeypatch.setenv('MAX_PENDING_JOBS','1');settings.cache_clear()
raw=pcb() if '/pcbs/' in route else ascii_schematic
assert create(client,route,raw,-1).status_code==202
assert create(client,route,raw,-1).status_code==503
@pytest.mark.parametrize('route',ROUTES)
def test_default_and_positive_expiry_remain_finite(client,ascii_schematic,route):
raw=pcb() if '/pcbs/' in route else ascii_schematic
now=time.time()
for expires,ttl in [(None,settings().link_ttl),(60,60)]:
response=create(client,route,raw,expires);assert response.status_code==202
assert now+ttl<=response.json()['expires_at']<=time.time()+ttl
+298
مشاهده پرونده
@@ -0,0 +1,298 @@
import copy
import json
import os
import struct
import time
from pathlib import Path
from xml.etree.ElementTree import fromstring
import pytest
from sqlalchemy import update
from app.documents import parse_document
from app.parser.record_decoder import ParseError
from app.parser.pcb_binary import Reader, at, binary_records, wide_strings
from app.parser.pcb_container import identify_pcb
from app.parser.pcb_geometry import unit,raw_unit,pad_shape
from app.models.schematic import Point
from app.render import svg_document
from app.pcb_diff import compare_pcbs
from app.db import Job,claim,sessions
from app.storage import storage
from app.worker import process
from pcb_fixture import pcb
from test_platform import client,KEY,upload
def parse(raw):return parse_document(raw,'board.PcbDoc','a'*48,document_type='pcb')
def create(client,raw,compare=False,**fields):
files={'old_file':('old.PcbDoc',raw),'new_file':('new.PcbDoc',fields.pop('new',raw))} if compare else {'file':('board.PcbDoc',raw)}
return client.post('/api/v1/pcbs/comparisons' if compare else '/api/v1/pcbs/render',headers={'X-API-Key':KEY},files=files,data=fields)
def test_pcb_units_and_checked_framing():
assert raw_unit(100000)==1 and unit('2.54mm')==pytest.approx(10)
for bad in ['nan','infinity','999999999mm']:
with pytest.raises(ValueError):unit(bad)
with pytest.raises(ParseError):Reader(b'\x01').read('I')
with pytest.raises(ParseError):at(struct.pack('<d',float('nan')),0,'d')
with pytest.raises(ParseError):list(binary_records(b'\x04\xff\xff\xff\x7f','Tracks6',4))
value='مقاومت Ω\0'.encode('utf-16le')
assert wide_strings(struct.pack('<II',1,len(value))+value)[1]=='مقاومت Ω'
for shape in (1,2,3,9):
path,box=pad_shape(Point(x=10,y=-20),8,4,shape,90,30)
assert path.endswith('Z') and box.width==pytest.approx(4) and box.height==pytest.approx(8)
def test_native_fixture_structure_geometry_and_layers():
raw=pcb();identify_pcb(raw);doc=parse(raw)
track=next(o for o in doc.objects if o.kind=='track')
assert track.connection_points==[Point(x=1,y=-1),Point(x=3,y=-1)]
assert track.net_id==doc.nets[0].id and track.properties['NET_NAME']=='GND'
assert doc.metadata['copper_stack']==['1','32']
tree=fromstring(svg_document(doc))
assert tree.attrib['aria-label']=='PCB'
assert any(node.attrib.get('data-layer-id')=='1' for node in tree.iter())
@pytest.mark.parametrize('raw',[b'garbage',b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'+b'\0'*504,pcb(version='9.0')])
def test_invalid_envelopes(raw):
with pytest.raises(ParseError):identify_pcb(raw)
def test_structural_diff_movement_width_and_no_mutation():
old=parse(pcb());same=parse(pcb());before=old.model_dump_json()
assert compare_pcbs(old,same)['total_changed']==0
moved=compare_pcbs(old,parse(pcb(x=200000)))
assert moved['summary']==dict(added=0,removed=0,modified=0,moved=1)
assert moved['changes'][0]['movement']==dict(dx=1,dy=0)
modified=compare_pcbs(old,parse(pcb(width=200000)))
assert modified['summary']['modified']==1
assert compare_pcbs(old,parse(pcb(track=False)))['summary']['removed']==1
assert compare_pcbs(old,parse(pcb(net='VCC')))['summary']['modified']==1
assert old.model_dump_json()==before
@pytest.mark.parametrize('comparison',[False,True])
def test_pcb_api_worker_capabilities_expiry_cleanup(client,comparison):
response=create(client,pcb(),comparison,new=pcb(width=200000)) if comparison else create(client,pcb())
assert response.status_code==202,response.text
result=response.json();identifier=result['id'];headers={'X-Viewer-Token':result['viewer_token']}
base=f'/api/v1/pcbs/comparisons/{identifier}' if comparison else f'/api/v1/pcbs/{identifier}'
status=base if comparison else base+'/status'
svg=base+'/svg/old' if comparison else base+'/svg'
assert 'summary' not in result and '#token=' in result['viewer_url']
assert client.get(status).status_code==404
assert client.get(status,headers={'X-Viewer-Token':'wrong'}).status_code==404
assert client.get(svg,headers=headers).status_code==409
process(claim());result=client.get(status,headers=headers).json()
assert result['status']=='completed',result
assert client.get(svg,headers=headers).status_code==200
if comparison:assert result['summary']['modified']==1
else:
data=client.get(base+'/data',headers=headers).json()
assert data['document_type']=='pcb'
for path in (f'/view/pcb/{identifier}',f'/compare/pcb/{identifier}'):
response=client.get(path);assert response.status_code==200
assert len(response.headers.get_list('content-security-policy'))==1
assert "frame-ancestors 'self' https://git.sadentel.net" in response.headers['content-security-policy']
assert 'x-frame-options' not in response.headers
with sessions().begin() as db:db.execute(update(Job).where(Job.id==identifier).values(expires_at=time.time()-1))
assert client.get(status,headers=headers).status_code==410
from app.cleanup import cleanup
assert cleanup()==1
with pytest.raises(FileNotFoundError):storage().get(identifier+('/pcb-comparison.json' if comparison else '/input.PcbDoc'))
if comparison:
with pytest.raises(FileNotFoundError):storage().get(identifier+'/old.PcbDoc')
@pytest.mark.parametrize('access',['private','unlisted'])
def test_all_four_job_kinds_are_isolated_even_for_service_key(client,ascii_schematic,access):
from test_comparison import create as schematic_compare
schematic=upload(client,ascii_schematic,access=access).json()
sch_compare=schematic_compare(client,ascii_schematic,ascii_schematic,access=access).json()
board=create(client,pcb(),access=access).json();comparison=create(client,pcb(),True,access=access).json()
routes=[lambda id:f'/api/v1/schematics/{id}/status',lambda id:f'/api/v1/comparisons/{id}',
lambda id:f'/api/v1/pcbs/{id}/status',lambda id:f'/api/v1/pcbs/comparisons/{id}']
for i,item in enumerate([schematic,sch_compare,board,comparison]):
for j,route in enumerate(routes):
for headers in ({'X-API-Key':KEY},{'X-Viewer-Token':item.get('viewer_token','')},{}):
response=client.get(route(item['id']),headers=headers)
if i!=j:assert response.status_code==404,(i,j,response.text)
elif 'X-API-Key' in headers or access=='unlisted' or headers.get('X-Viewer-Token'):assert response.status_code==200
def test_auth_cors_upload_validation_and_body_limits(client,ascii_schematic):
for path in ('/api/v1/pcbs/render','/api/v1/pcbs/comparisons'):
assert client.post(path,content=b'invalid multipart').status_code==401
preflight=client.options(path,headers={'Origin':'https://git.sadentel.net','Access-Control-Request-Method':'POST','Access-Control-Request-Headers':'x-api-key'})
assert preflight.status_code==200 and preflight.headers['access-control-allow-origin']=='https://git.sadentel.net'
assert client.post(path,content=b'x'*(4*1048576),headers={'X-API-Key':KEY,'Content-Type':'multipart/form-data; boundary=x'}).status_code==413
assert create(client,ascii_schematic).status_code==422
assert upload(client,pcb()).status_code==422
assert create(client,pcb(version='9.0')).status_code==422
assert create(client,pcb(),expires_in=999999999).status_code==422
assert client.post('/api/v1/pcbs/render',headers={'X-API-Key':KEY},files={'file':('wrong.SchDoc',pcb())}).status_code==415
def test_failed_decode_and_second_storage_write_cleanup(client,monkeypatch):
created=create(client,pcb(corrupt=True)).json();process(claim())
result=client.get(created['status_url'],headers={'X-Viewer-Token':created['viewer_token']}).json()
assert result['status']=='failed' and result['error']['code']=='invalid_pcb'
original=storage().put;keys=[]
def fail(key,body,*args):
keys.append(key);original(key,body,*args)
if len(keys)==2:raise OSError('write acknowledged late')
monkeypatch.setattr(storage(),'put',fail)
assert create(client,pcb(),True).status_code==503
assert all(not storage().path(key).exists() for key in keys)
assert claim() is None
@pytest.fixture(scope='module')
def real_board():
sample=os.getenv('PCBDOC_SAMPLE')
if not sample:pytest.skip('Set PCBDOC_SAMPLE to a private real PCB fixture')
path=Path(sample)
return parse_document(path.read_bytes(),path.name,'a'*48,document_type='pcb')
def test_real_board_and_self_comparison(real_board):
doc=real_board
assert len(doc.components)>0 and len(doc.primitives)>0
assert len({p.id for p in doc.primitives})==len(doc.primitives)
assert compare_pcbs(doc,doc)['total_changed']==0
assert any(p.role=='pad' for p in doc.primitives)
assert any(p.role=='drill' for p in doc.primitives)
assert all(set(o.layer_ids)<={layer.id for layer in doc.layers} for o in doc.objects)
def test_pad_stack_slot_via_layers_and_native_ids():
from pcb_fixture import rich_pcb
doc=parse(rich_pcb());pad=next(o for o in doc.objects if o.kind=='pad');via=next(o for o in doc.objects if o.kind=='via')
assert pad.native_uid=='PAD_UID' and pad.component_id==doc.components[0].id
assert pad.layer_ids==['1','32'] and via.layer_ids==['1','32']
pads=[p for p in doc.primitives if p.object_id==pad.id and p.role=='pad']
assert {p.layer_id for p in pads}=={'1','32'}
assert next(p for p in pads if p.layer_id=='1').bounds.width==1
assert next(p for p in pads if p.layer_id=='32').bounds.width==2
slot=next(p for p in doc.primitives if p.object_id==pad.id and p.role=='drill')
assert slot.bounds.width==1 and slot.bounds.height==.5 and slot.layer_ids==['1','32']
bottom_via=next(p for p in doc.primitives if p.object_id==via.id and p.layer_id=='32')
assert bottom_via.bounds.width==2
def test_footprint_move_rotation_and_child_edits_are_one_change():
from pcb_fixture import rich_pcb
from app.parser.pcb_geometry import pad_shape
old=parse(rich_pcb());new=old.model_copy(deep=True)
component=new.components[0];component.position.x+=4
for o in new.objects:
if o.component_id==component.id:o.position.x+=4;o.bounds.x+=4
# Paths translated independently from their existing SVG string.
for p in new.primitives:
if p.component_id==component.id:
if p.role=='pad':
w,h=(1,2) if p.layer_id=='1' else (2,3)
p.path,p.bounds=pad_shape(Point(x=5,y=-1),w,h,1 if p.layer_id=='1' else 3,0)
elif p.role=='drill':p.path,p.bounds=pad_shape(Point(x=5,y=-1),1,.5,1,0)
# Source parameters retain physical placement too.
component.parameters['X']='50mil'
result=compare_pcbs(old,new)
assert result['summary']==dict(added=0,removed=0,modified=0,moved=1),result['changes']
component.value='new value'
result=compare_pcbs(old,new)
assert result['summary']['modified']==1 and result['changes'][0]['moved']
# Reparse native revision with a rotated footprint/pad: only one footprint modification.
rotated=parse(rich_pcb());rotated.components[0].rotation=90
assert compare_pcbs(old,rotated)['summary']['modified']==1
def test_record_reordering_net_renumber_and_duplicate_ambiguity():
import io
import olefile
from pcb_fixture import cfb,text,record
def native(reverse):
with olefile.OleFileIO(io.BytesIO(pcb())) as ole:streams={'/'.join(e):ole.openstream(e).read() for e in ole.listdir()}
streams['Nets6/Data']=text({'NAME':'VCC'})+text({'NAME':'GND'}) if reverse else text({'NAME':'GND'})+text({'NAME':'VCC'})
records=[]
for x,net in [(100000,0),(900000,1)]:
p=bytearray(45);p[0]=1
struct.pack_into('<HHH',p,3,(1-net) if reverse else net,65535,65535)
struct.pack_into('<iiiii',p,13,x,100000,x+200000,100000,100000)
records.append(record(4,p))
streams['Tracks6/Data']=b''.join(reversed(records) if reverse else records)
return parse(cfb(streams))
assert compare_pcbs(native(False),native(True))['total_changed']==0
old=native(False);new=old.model_copy(deep=True)
track=next(o for o in new.objects if o.kind=='track').model_copy(deep=True);track.id+='-duplicate';new.objects.append(track)
assert compare_pcbs(old,new)['summary']['added']==1
@pytest.mark.parametrize('path',['/view/pcb/invalid','/compare/pcb/invalid'])
def test_pcb_error_page_csp(client,path):
r=client.get(path);assert r.status_code==404
assert len(r.headers.get_list('content-security-policy'))==1
assert "frame-ancestors 'self' https://git.sadentel.net" in r.headers['content-security-policy']
assert 'x-frame-options' not in r.headers
def test_pcb_admission_limit_and_stale_attempt_fencing(client,monkeypatch):
from app.config import settings
monkeypatch.setenv('MAX_PENDING_JOBS','1');settings.cache_clear()
result=create(client,pcb(),access='unlisted').json()
assert create(client,pcb()).status_code==503
old=claim()
with sessions().begin() as db:db.execute(update(Job).where(Job.id==result['id']).values(lease_until=time.time()-1))
new=claim();assert new['attempt']==2
process(new);process(old)
with sessions()() as db:
job=db.get(Job,result['id']);assert job.status=='completed' and job.output_prefix.endswith('/attempt-2')
def test_cutout_is_not_dropped_as_an_unowned_region():
import io
import olefile
from pcb_fixture import cfb,record
with olefile.OleFileIO(io.BytesIO(pcb())) as ole:streams={'/'.join(e):ole.openstream(e).read() for e in ole.listdir()}
p=bytearray(18);p[0]=1;struct.pack_into('<HHH',p,3,65535,65535,65535)
props=b'|KIND=0|ISBOARDCUTOUT=TRUE\0'
p+=struct.pack('<I',len(props))+props+struct.pack('<I',3)
for x,y in [(200000,200000),(400000,200000),(400000,400000),(200000,400000)]:
p+=struct.pack('<Biiiiidd',0,x,y,0,0,0,0.,0.)
streams['ShapeBasedRegions6/Data']=record(11,p)
doc=parse(cfb(streams));cutout=next(p for p in doc.primitives if p.role=='board_cutout')
assert cutout.layer_id=='0' and cutout.bounds.width==2 and cutout.bounds.height==2
assert cutout.fill_rule=='evenodd'
def test_pcb_storage_keys_do_not_allow_traversal_or_arbitrary_formats(client):
for name in ['input.PcbDoc','old.PcbDoc','new.PcbDoc','pcb-comparison.json','attempt-1/old.svg']:
assert storage().path('a'*48+'/'+name).name==name.split('/')[-1]
for name in ['../input.PcbDoc','x.PcbDoc','input.PcbDoc.exe','pcb-comparison.json/extra','attempt-3/drawing.svg']:
with pytest.raises(ValueError):storage().path('a'*48+'/'+name)
def test_comparison_accepts_each_file_limit_and_invalid_side(client,monkeypatch):
from app.config import settings
monkeypatch.setenv('MAX_UPLOAD_BYTES','20000');settings.cache_clear()
# Each is 18 KiB; combined exceeds one per-file limit and is nevertheless a valid pair.
raw=pcb();assert 20000<len(raw)*2 and len(raw)<20000
created=create(client,raw,True).json()
identifier=created['id'];headers={'X-Viewer-Token':created['viewer_token']}
assert client.get(f'/api/v1/pcbs/comparisons/{identifier}/svg/other',headers=headers).status_code==422
assert client.get(f'/api/v1/pcbs/comparisons/{identifier}/svg/../old',headers=headers).status_code==404
assert create(client,raw+b'x'*20000).status_code==413
@pytest.mark.parametrize('prefix',['/view/pcb/','/compare/pcb/'])
@pytest.mark.parametrize('frame_option',['DENY','SAMEORIGIN'])
def test_pcb_preview_replaces_duplicate_csp_and_removes_xfo(client,monkeypatch,prefix,frame_option):
from fastapi.responses import Response
def page(*args,**kwargs):
response=Response('preview',media_type='text/html',headers={'X-Frame-Options':frame_option,'Content-Security-Policy':"frame-ancestors 'none'"})
response.headers.append('Content-Security-Policy',"frame-ancestors 'none'")
return response
monkeypatch.setattr('app.pcb_api.FileResponse',page)
response=client.get(prefix+'a'*48)
assert len(response.headers.get_list('content-security-policy'))==1
assert "frame-ancestors 'self' https://git.sadentel.net" in response.headers['content-security-policy']
assert 'x-frame-options' not in response.headers
+10 -3
مشاهده پرونده
@@ -15,6 +15,7 @@ KEY = 'test-api-key-' + 'x'*40
@pytest.fixture @pytest.fixture
def client(tmp_path, monkeypatch): def client(tmp_path, monkeypatch):
monkeypatch.setenv('STORAGE_BACKEND', 'local')
monkeypatch.setenv('STORAGE_DIR', str(tmp_path)) monkeypatch.setenv('STORAGE_DIR', str(tmp_path))
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path}/test.db') monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path}/test.db')
monkeypatch.setenv('API_KEY', KEY) monkeypatch.setenv('API_KEY', KEY)
@@ -235,6 +236,10 @@ VIEWER_CSP = DEFAULT_CSP.replace("frame-ancestors 'none'", "frame-ancestors 'sel
('GET', '/view/invalid-id', 404), ('GET', '/view/invalid-id', 404),
('GET', '/view/nested/missing', 404), ('GET', '/view/nested/missing', 404),
('POST', '/view/' + '0'*48, 405), ('POST', '/view/' + '0'*48, 405),
('GET', '/compare/' + '0'*48, 200),
('GET', '/compare/invalid-id', 404),
('GET', '/compare/nested/missing', 404),
('POST', '/compare/' + '0'*48, 405),
]) ])
def test_viewer_only_allows_self_and_gitea_frames(client, method, path, expected_status): def test_viewer_only_allows_self_and_gitea_frames(client, method, path, expected_status):
response = client.request(method, path) response = client.request(method, path)
@@ -244,14 +249,15 @@ def test_viewer_only_allows_self_and_gitea_frames(client, method, path, expected
assert response.headers['x-content-type-options'] == 'nosniff' assert response.headers['x-content-type-options'] == 'nosniff'
@pytest.mark.parametrize('path', ['/', '/docs', '/healthz', '/assets/viewer.js', '/view', '/viewer/invalid', '/api/v1/schematics/'+'0'*48+'/status']) @pytest.mark.parametrize('path', ['/', '/docs', '/healthz', '/assets/viewer.js', '/view', '/compare', '/api/compare/'+'0'*48, '/viewer/invalid', '/api/v1/schematics/'+'0'*48+'/status'])
def test_non_viewer_keeps_original_csp(client, path): def test_non_viewer_keeps_original_csp(client, path):
response = client.get(path) response = client.get(path)
assert response.headers.get_list('content-security-policy') == [DEFAULT_CSP] assert response.headers.get_list('content-security-policy') == [DEFAULT_CSP]
@pytest.mark.parametrize('frame_option', ['DENY', 'SAMEORIGIN']) @pytest.mark.parametrize('frame_option', ['DENY', 'SAMEORIGIN'])
def test_conflicting_headers_removed_only_for_viewer(client, monkeypatch, frame_option): @pytest.mark.parametrize('prefix', ['/view/', '/compare/'])
def test_conflicting_headers_removed_only_for_viewer(client, monkeypatch, frame_option, prefix):
from fastapi.responses import Response from fastapi.responses import Response
def page(*args, **kwargs): def page(*args, **kwargs):
response = Response('page', media_type='text/html', headers={ response = Response('page', media_type='text/html', headers={
@@ -260,7 +266,8 @@ def test_conflicting_headers_removed_only_for_viewer(client, monkeypatch, frame_
response.headers.append('Content-Security-Policy', "frame-ancestors 'none'") response.headers.append('Content-Security-Policy', "frame-ancestors 'none'")
return response return response
monkeypatch.setattr('app.main.FileResponse', page) monkeypatch.setattr('app.main.FileResponse', page)
viewer = client.get('/view/'+'0'*48) monkeypatch.setattr('app.compare_api.FileResponse', page)
viewer = client.get(prefix+'0'*48)
assert 'x-frame-options' not in viewer.headers assert 'x-frame-options' not in viewer.headers
assert viewer.headers.get_list('content-security-policy') == [VIEWER_CSP] assert viewer.headers.get_list('content-security-policy') == [VIEWER_CSP]
other = client.get('/docs') other = client.get('/docs')
+164 -13
مشاهده پرونده
@@ -1,23 +1,174 @@
"""Protocol-level S3 contract check with botocore's validation stub (no live S3).""" """S3 protocol/configuration and real API/worker flows with a fake S3 transport.
No request is sent to a live provider and no real credential is required.
"""
import io import io
import importlib.util
from pathlib import Path
import time
import boto3 import boto3
from botocore.exceptions import ClientError, EndpointConnectionError
from botocore.response import StreamingBody from botocore.response import StreamingBody
from botocore.stub import Stubber from botocore.stub import Stubber
from app.storage import Storage import pytest
from sqlalchemy import update
from app.config import settings
from app.storage import Storage, storage
from test_platform import client, KEY as API_KEY
NAMES=['input.SchDoc','input.PcbDoc','old.SchDoc','new.SchDoc','old.PcbDoc','new.PcbDoc',
'comparison.json','pcb-comparison.json','attempt-1/data.json','attempt-1/drawing.svg',
'attempt-1/old.svg','attempt-2/new.svg']
def test_s3_private_round_trip_contract(tmp_path,monkeypatch): @pytest.fixture
from app.config import settings def s3_config(tmp_path,monkeypatch):
from app.storage import storage # Explicit fake credentials/client prevent the SDK's metadata discovery network calls.
monkeypatch.setenv('STORAGE_DIR',str(tmp_path)); monkeypatch.setenv('S3_BUCKET','test-private') for name in ['STORAGE_BACKEND','S3_BUCKET','S3_PREFIX','S3_REGION','S3_ENDPOINT_URL','S3_ADDRESSING_STYLE',
settings.cache_clear(); storage.cache_clear() 'S3_CONNECT_TIMEOUT','S3_READ_TIMEOUT','S3_MAX_ATTEMPTS','S3_SERVER_SIDE_ENCRYPTION','S3_KMS_KEY_ID']:
monkeypatch.delenv(name,raising=False)
monkeypatch.setenv('STORAGE_DIR',str(tmp_path))
monkeypatch.setenv('DATABASE_URL',f'sqlite:///{tmp_path}/storage.db')
monkeypatch.setenv('APP_ENV','development')
monkeypatch.setenv('STORAGE_BACKEND','s3');monkeypatch.setenv('S3_BUCKET','test-private')
client=boto3.client('s3',region_name='us-east-1',aws_access_key_id='test',aws_secret_access_key='test') client=boto3.client('s3',region_name='us-east-1',aws_access_key_id='test',aws_secret_access_key='test')
monkeypatch.setattr(boto3,'client',lambda *a,**kw:client) captures=[]
key='a'*48+'/input.SchDoc'; payload=b'actual bytes' def make_client(*args,**kwargs):captures.append((args,kwargs));return client
monkeypatch.setattr(boto3,'client',make_client)
settings.cache_clear();storage.cache_clear()
yield client,captures,tmp_path
settings.cache_clear();storage.cache_clear()
@pytest.mark.parametrize('name',NAMES)
@pytest.mark.parametrize('prefix',['','altium/production','/altium/production/'])
def test_s3_private_round_trip_contract(s3_config,monkeypatch,name,prefix):
client,_,root=s3_config
monkeypatch.setenv('S3_PREFIX',prefix);settings.cache_clear()
key='a'*48+'/'+name;remote=prefix.strip('/')+'/'+key if prefix else key;payload=b'actual bytes'
with Stubber(client) as stub: with Stubber(client) as stub:
stub.add_response('put_object',{}, {'Bucket':'test-private','Key':key,'Body':payload,'ContentType':'application/octet-stream'}) stub.add_response('put_object',{},dict(Bucket='test-private',Key=remote,Body=payload,ContentType='application/octet-stream'))
stub.add_response('get_object',{'Body':StreamingBody(io.BytesIO(payload),len(payload))},{'Bucket':'test-private','Key':key}) stub.add_response('get_object',{'Body':StreamingBody(io.BytesIO(payload),len(payload))},dict(Bucket='test-private',Key=remote))
stub.add_response('delete_object',{}, {'Bucket':'test-private','Key':key}) stub.add_response('delete_object',{},dict(Bucket='test-private',Key=remote))
service=Storage();service.put(key,payload);assert service.get(key)==payload;service.delete(key) service=Storage();service.put(key,payload);assert service.get(key)==payload;service.delete(key)
stub.assert_no_pending_responses() stub.assert_no_pending_responses()
settings.cache_clear(); storage.cache_clear() assert not (root/'objects').exists()
@pytest.mark.parametrize('mode,kms,options',[
('','',{}),('AES256','',{'ServerSideEncryption':'AES256'}),
('aws:kms','alias/altium',{'ServerSideEncryption':'aws:kms','SSEKMSKeyId':'alias/altium'}),
])
def test_encryption_config_and_sdk_transport(s3_config,monkeypatch,mode,kms,options):
client,captures,_=s3_config
for key,value in {'S3_ENDPOINT_URL':'https://objects.example.test','S3_REGION':'test-region',
'S3_ADDRESSING_STYLE':'path','S3_CONNECT_TIMEOUT':'7','S3_READ_TIMEOUT':'31','S3_MAX_ATTEMPTS':'4',
'S3_SERVER_SIDE_ENCRYPTION':mode,'S3_KMS_KEY_ID':kms}.items():monkeypatch.setenv(key,value)
settings.cache_clear();key='a'*48+'/input.PcbDoc'
with Stubber(client) as stub:
stub.add_response('put_object',{},dict(Bucket='test-private',Key=key,Body=b'pcb',ContentType='application/octet-stream',**options))
Storage().put(key,b'pcb');stub.assert_no_pending_responses()
args,kwargs=captures[0];assert args==('s3',)
assert kwargs['endpoint_url']=='https://objects.example.test' and kwargs['region_name']=='test-region'
config=kwargs['config'];assert config.signature_version=='s3v4' and config.s3['addressing_style']=='path'
assert config.connect_timeout==7 and config.read_timeout==31 and config.retries['total_max_attempts']==4
assert not {'aws_access_key_id','aws_secret_access_key'} & kwargs.keys()
@pytest.mark.parametrize('key,value',[
('STORAGE_BACKEND','unknown'),('S3_BUCKET',''),('S3_PREFIX','../escape'),('S3_PREFIX','nested/../escape'),
('S3_PREFIX','a//b'),('S3_PREFIX','bad\\prefix'),('S3_PREFIX','bad\nname'),('S3_PREFIX','x'*513),
('S3_ADDRESSING_STYLE','invalid'),('S3_CONNECT_TIMEOUT','0'),('S3_READ_TIMEOUT','-1'),('S3_MAX_ATTEMPTS','0'),
('S3_SERVER_SIDE_ENCRYPTION','public'),('S3_KMS_KEY_ID','alias/missing-encryption-mode'),
('S3_ENDPOINT_URL','file:///tmp/data'),('S3_ENDPOINT_URL','https://user:secret@example.test'),
('S3_ENDPOINT_URL','https://example.test/?token=secret'),
])
def test_invalid_s3_configuration_fails_closed(s3_config,monkeypatch,key,value):
monkeypatch.setenv(key,value);settings.cache_clear()
with pytest.raises(RuntimeError):settings()
def test_auto_backend_compatibility_and_no_local_directory_with_postgres(s3_config,monkeypatch):
_,_,root=s3_config
monkeypatch.delenv('STORAGE_BACKEND');settings.cache_clear()
assert settings().storage_backend=='s3'
monkeypatch.setenv('S3_BUCKET','');settings.cache_clear()
assert settings().storage_backend=='local'
monkeypatch.setenv('STORAGE_BACKEND','s3');monkeypatch.setenv('S3_BUCKET','test-private')
monkeypatch.setenv('DATABASE_URL','postgresql+psycopg://unused:unused@localhost/unused')
monkeypatch.setenv('STORAGE_DIR',str(root/'unused-local-dir'));settings.cache_clear()
assert settings().storage_backend=='s3' and not (root/'unused-local-dir').exists()
def test_s3_errors_never_fall_back_to_disk(s3_config,monkeypatch):
client,_,root=s3_config
key='a'*48+'/input.SchDoc'
with Stubber(client) as stub:
stub.add_client_error('put_object',service_error_code='AccessDenied',http_status_code=403)
stub.add_client_error('get_object',service_error_code='AccessDenied',http_status_code=403)
stub.add_client_error('delete_object',service_error_code='AccessDenied',http_status_code=403)
service=Storage()
for operation in (lambda:service.put(key,b'x'),lambda:service.get(key),lambda:service.delete(key)):
with pytest.raises(ClientError):operation()
assert not (root/'objects').exists()
for key in ['../input.SchDoc','a'*48+'/input.SchDoc/other','foreign/'+'a'*48+'/input.PcbDoc']:
with pytest.raises(ValueError):service.object_key(key)
class MemoryS3:
"""Transport fake only: all file validation/parsing/rendering/worker logic is real."""
def __init__(self):self.objects={};self.content_types={}
def put_object(self,Bucket,Key,Body,ContentType,**kwargs):
self.objects[(Bucket,Key)]=bytes(Body);self.content_types[(Bucket,Key)]=ContentType
assert 'ACL' not in kwargs
def get_object(self,Bucket,Key):
if (Bucket,Key) not in self.objects:
raise ClientError({'Error':{'Code':'NoSuchKey'},'ResponseMetadata':{'HTTPStatusCode':404}},'GetObject')
data=self.objects[(Bucket,Key)];return {'Body':StreamingBody(io.BytesIO(data),len(data))}
def delete_object(self,Bucket,Key):self.objects.pop((Bucket,Key),None)
@pytest.mark.parametrize('kind',['schematic_render','schematic_comparison','pcb_render','pcb_comparison'])
def test_api_worker_read_and_expiry_cleanup_use_only_prefixed_s3(client,monkeypatch,ascii_schematic,kind):
from app.db import Job,claim,sessions
from app.worker import process
from app.cleanup import cleanup
from pcb_fixture import pcb
fake=MemoryS3();monkeypatch.setattr(boto3,'client',lambda *a,**kw:fake)
monkeypatch.setenv('STORAGE_BACKEND','s3');monkeypatch.setenv('S3_BUCKET','test-private');monkeypatch.setenv('S3_PREFIX','altium/test')
settings.cache_clear();storage.cache_clear()
pcb_kind=kind.startswith('pcb');comparison=kind.endswith('comparison')
suffix='PcbDoc' if pcb_kind else 'SchDoc';raw=pcb() if pcb_kind else ascii_schematic
route=('/api/v1/pcbs/comparisons' if comparison else '/api/v1/pcbs/render') if pcb_kind else ('/api/v1/comparisons' if comparison else '/api/v1/schematics/render')
files={f'{side}_file':(side+'.'+suffix,raw) for side in ('old','new')} if comparison else {'file':('input.'+suffix,raw)}
response=client.post(route,headers={'X-API-Key':API_KEY},files=files)
assert response.status_code==202,response.text
created=response.json();identifier=created['id'];headers={'X-Viewer-Token':created['viewer_token']}
fake.objects[('test-private','other-prefix/keep')]=b'other service data'
process(claim());status=client.get(created['status_url'],headers=headers).json()
assert status['status']=='completed',status
base=created['status_url'].removesuffix('/status')
assert client.get(base+('/svg/old' if comparison else '/svg'),headers=headers).status_code==200
assert client.get(created['status_url']).status_code==404
if comparison:assert status['total_changed']==0
else:assert client.get(base+'/data',headers=headers).status_code==200
assert all(key.startswith('altium/test/'+identifier+'/') for bucket,key in fake.objects if key!='other-prefix/keep')
assert not (settings().storage_dir/'objects').exists()
with sessions().begin() as db:db.execute(update(Job).where(Job.id==identifier).values(expires_at=time.time()-1))
assert cleanup()==1
assert fake.objects=={('test-private','other-prefix/keep'):b'other service data'}
def test_migration_copies_verifies_skips_identical_and_refuses_overwrite(s3_config,monkeypatch,tmp_path):
fake=MemoryS3();monkeypatch.setattr(boto3,'client',lambda *a,**kw:fake)
monkeypatch.setenv('S3_PREFIX','altium/test');settings.cache_clear()
script=Path(__file__).resolve().parents[2]/'scripts/migrate-to-s3.py'
spec=importlib.util.spec_from_file_location('migration',script);module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
key='a'*48+'/input.PcbDoc';source=tmp_path/'source';file=source/'objects'/key
file.parent.mkdir(parents=True);file.write_bytes(b'original')
assert module.migrate(source)=={'copied_and_verified':1,'already_identical':0}
assert file.read_bytes()==b'original'
assert module.migrate(source)=={'copied_and_verified':0,'already_identical':1}
fake.objects[('test-private','altium/test/'+key)]=b'different'
with pytest.raises(ValueError,match='refusing to overwrite'):module.migrate(source)
assert file.read_bytes()==b'original'
+24 -2
مشاهده پرونده
@@ -7,9 +7,9 @@ Cross-origin browser access is allowed only from `https://git.sadentel.net`, wit
Optional fields: Optional fields:
- `access`: `private` (default) or `unlisted`. - `access`: `private` (default) or `unlisted`.
- `expires_in`: positive integer seconds, maximum `LINK_TTL_SECONDS`. Expiry starts at upload acceptance, including queue time. - `expires_in`: `-1` for unlimited retention, or positive integer seconds up to `LINK_TTL_SECONDS`. Omit to use the configured finite default. `0` and values below `-1` are invalid. Finite expiry starts at upload acceptance, including queue time.
HTTP 202 includes `id`, `status`, `viewer_url`, `status_url`, `expires_at` (Unix seconds), `access`, and a `viewer_token` for private uploads. `Location` points to status and `Retry-After: 1` provides a polling interval. Do not automatically retry a POST after a transport failure: each accepted request creates a new resource. Idempotency keys are not implemented. HTTP 202 includes `id`, `status`, `viewer_url`, `status_url`, `expires_at` (Unix seconds, or `-1` for no expiry), `access`, and a `viewer_token` for private uploads. `Location` points to status and `Retry-After: 1` provides a polling interval. Do not automatically retry a POST after a transport failure: each accepted request creates a new resource. Idempotency keys are not implemented.
`GET .../{id}/status` returns HTTP 200 for existing, accessible, unexpired jobs, including failed jobs with `success: false` and a stable `error.code`. Failed artifacts return HTTP 422. Status does not disclose the original filename, source bytes or read token. The returned base `viewer_url` omits the original token: retain the initial URL or append your retained read token as a fragment. `GET .../{id}/status` returns HTTP 200 for existing, accessible, unexpired jobs, including failed jobs with `success: false` and a stable `error.code`. Failed artifacts return HTTP 422. Status does not disclose the original filename, source bytes or read token. The returned base `viewer_url` omits the original token: retain the initial URL or append your retained read token as a fragment.
@@ -45,3 +45,25 @@ A worker crash is retried after its lease expires, at most twice total. A parse
## Schematic comparisons ## Schematic comparisons
`POST /api/v1/comparisons` (alias `/api/compare`) accepts `old_file` and `new_file`, with the same API key, private tokens, expiry, durable queue and error conventions. `GET /api/v1/comparisons/{id}` (alias `/api/compare/{id}`) returns status and the completed structural diff. `/compare/{id}` opens the side-by-side workspace. See [the comparison contract](SCHEMATIC_DIFF.md). `POST /api/v1/comparisons` (alias `/api/compare`) accepts `old_file` and `new_file`, with the same API key, private tokens, expiry, durable queue and error conventions. `GET /api/v1/comparisons/{id}` (alias `/api/compare/{id}`) returns status and the completed structural diff. `/compare/{id}` opens the side-by-side workspace. See [the comparison contract](SCHEMATIC_DIFF.md).
## PCB preview and comparison
`POST /api/v1/pcbs/render` accepts `file`; `POST /api/v1/pcbs/comparisons` accepts `old_file` and `new_file`. Both require native `.PcbDoc` and reuse the existing asynchronous status, authentication, access and expiry conventions. They do not change schematic routes. See [PCB API and read/viewer endpoints](PCB.md).
## Unlimited links and embedding
All four creation operations (and the schematic comparison alias) accept `expires_in=-1`. The initial response and every status response return `expires_at: -1`. This sentinel is stored in the existing non-null timestamp column; no schema migration is required. Unlimited jobs remain claimable, count toward pending queue limits, retain private-token authorization, and are excluded from automatic expiry cleanup in both local and S3 storage. Other finite expiry behavior is unchanged. This is durable storage retention, not permanent RAM residency. External administrator deletion or provider lifecycle policies can still remove objects.
The upload results and authenticated/accessible viewers provide a **Share** button. It reveals a copyable iframe snippet using the complete viewer URL, including the read-token fragment for a private design. Sharing that snippet shares read access; it does not make the job `unlisted`, expose the service API key or bypass CSP. The viewer recovers its token from the resource-specific browser session if the fragment was already removed. Embedding remains restricted to the service itself and `https://git.sadentel.net`.
```html
<iframe
src="https://render.sadentel.net/view/pcb/PCB_ID#token=READ_TOKEN"
width="100%"
height="800"
title="Altium design viewer"
loading="lazy"
referrerpolicy="no-referrer"
allowfullscreen
></iframe>
```
+2 -2
مشاهده پرونده
@@ -23,11 +23,11 @@ The queue is in PostgreSQL, using `FOR UPDATE SKIP LOCKED` and a conditional upd
API and worker can scale independently. Worker parsing is serial per worker container; increase replicas for concurrency. Parser subprocesses inherit no API key, database URL or storage credentials, receive files in an ephemeral directory, and run under CPU/address-space/file-size/FD limits. The decoder never fetches document URLs or loads dynamic code. This resource boundary is defense in depth; hostile-file deployments may additionally isolate parsers with a hardened runtime/network namespace. API and worker can scale independently. Worker parsing is serial per worker container; increase replicas for concurrency. Parser subprocesses inherit no API key, database URL or storage credentials, receive files in an ephemeral directory, and run under CPU/address-space/file-size/FD limits. The decoder never fetches document URLs or loads dynamic code. This resource boundary is defense in depth; hostile-file deployments may additionally isolate parsers with a hardened runtime/network namespace.
Local object paths are application-generated and validated. Files are mode 0600 in directories created as 0700. Compose uses the same persistent volume and UID for API/worker. For multiple hosts replace local storage with an S3-compatible private bucket. Database metadata and objects must be backed up consistently. Credentials are environment settings, not committed files or source-derived values. Local object paths are application-generated and validated. Files are mode 0600 in directories created as 0700. Compose uses the same persistent volume and UID for API/worker. For multiple hosts select `STORAGE_BACKEND=s3` with an S3-compatible private bucket and a shared `S3_PREFIX`; no document objects are written locally in this mode. See [configuration and migration](S3.md). Database metadata and objects must be backed up consistently. Credentials are environment settings, not committed files or source-derived values.
The normalized document keeps original property maps, owner relationships and active parts separately from drawables. This is necessary for diagnostics and future parser improvements: the SVG is an output, not the only stored interpretation. Source paint order is preserved. Geometry is not laid out by a graph library. The normalized document keeps original property maps, owner relationships and active parts separately from drawables. This is necessary for diagnostics and future parser improvements: the SVG is an output, not the only stored interpretation. Source paint order is preserved. Geometry is not laid out by a graph library.
`app/documents.py` registers format adapters. To support PCB later, add a PCB adapter and PCB-specific data/layer types, reuse queue/storage/capability APIs, and provide layer-aware renderer/viewer behavior. The current endpoint intentionally accepts only SchDoc. Existing optional layer/net schema fields reserve extension points; no saved PCB data is fabricated, and no PCB functionality is implemented. `app/documents.py` registers separate schematic and native PCB adapters. PCB-specific models preserve footprints, native object identity and layer/drill spans. The PCB routes reuse the same durable queue, capability checks, storage and SVG serializer; a separate PCB structural comparator handles native geometry and net assignments. Existing SchDoc routes remain format-specific. See [PCB architecture, API and limitations](PCB.md).
The viewer is dependency-free JavaScript with a pure tested camera module. It manipulates one SVG viewBox, schedules updates with requestAnimationFrame, and delegates vector rendering to the browser. There is no raster redraw loop or symbol substitution. Large native records are processed off the API process. Browser SVG performance at very large primitive counts has not been benchmarked; viewport culling or a hybrid renderer may be warranted after profiling, while preserving the same primitive model. The viewer is dependency-free JavaScript with a pure tested camera module. It manipulates one SVG viewBox, schedules updates with requestAnimationFrame, and delegates vector rendering to the browser. There is no raster redraw loop or symbol substitution. Large native records are processed off the API process. Browser SVG performance at very large primitive counts has not been benchmarked; viewport culling or a hybrid renderer may be warranted after profiling, while preserving the same primitive model.
+1 -1
مشاهده پرونده
@@ -9,7 +9,7 @@ This service is an independent, read-only native-format implementation. There is
- Little-endian length/kind record framing, original physical record indexes, source properties, UTF-8 companion properties, legacy Windows-1252 decoding. - Little-endian length/kind record framing, original physical record indexes, source properties, UTF-8 companion properties, legacy Windows-1252 decoding.
- Unknown binary payloads are preserved as base64, with diagnostics, rather than misinterpreted as text. - Unknown binary payloads are preserved as base64, with diagnostics, rather than misinterpreted as text.
- Major unknown headers, missing sheets, broken CFB/record lengths and files without supported geometry fail. Minor header revisions are reported, not treated as certified Altium application releases. - Major unknown headers, missing sheets, broken CFB/record lengths and files without supported geometry fail. Minor header revisions are reported, not treated as certified Altium application releases.
- `.PcbDoc`, libraries without an instantiated schematic, encrypted/protected variants and unknown document envelopes are not supported. - The schematic endpoints reject `.PcbDoc`. Separate PCB endpoints support a diagnosed native V6 subset; see [PCB compatibility](PCB.md). Libraries without an instantiated schematic, encrypted/protected variants and unknown document envelopes are not supported by the schematic parser.
## Geometry ## Geometry
+165
مشاهده پرونده
@@ -0,0 +1,165 @@
# Native PCB preview and structural comparison
Two new creation endpoints extend the existing FastAPI application. Existing schematic API paths, private capabilities and SVG output remain supported. No database migration, Redis, frontend framework or new runtime dependency is introduced. The original `schematics` table stores all four job kinds.
## API
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/api/v1/pcbs/render` | Upload `file` as `.PcbDoc` |
| POST | `/api/v1/pcbs/comparisons` | Upload `old_file` and `new_file` as `.PcbDoc` |
| GET | `/api/v1/pcbs/{id}/status` | Render status and completed layer inventory |
| GET | `/api/v1/pcbs/{id}/data` | Completed PCB document |
| GET | `/api/v1/pcbs/{id}/svg` | Completed layer-aware SVG |
| GET | `/api/v1/pcbs/comparisons/{id}` | Status and completed structural report |
| GET | `/api/v1/pcbs/comparisons/{id}/svg/{side}` | Annotated SVG; side is `old` or `new` |
| GET | `/view/pcb/{id}` | Fullscreen single PCB shell |
| GET | `/compare/pcb/{id}` | Fullscreen side-by-side PCB shell |
```bash
curl -X POST https://render.sadentel.net/api/v1/pcbs/render \
-H "X-API-Key: $API_KEY" \
-F 'file=@board.PcbDoc' -F 'access=private' -F 'expires_in=3600'
curl -X POST https://render.sadentel.net/api/v1/pcbs/comparisons \
-H "X-API-Key: $API_KEY" \
-F 'old_file=@original.PcbDoc' -F 'new_file=@modified.PcbDoc' \
-F 'access=private' -F 'expires_in=3600'
```
Both creation requests return HTTP **202**, `Location: status_url` and `Retry-After: 1`. `access` defaults to `private`; `unlisted` is supported. For finite links, expiry is measured from admission, including queue time, and cannot exceed `LINK_TTL_SECONDS`. Set `expires_in=-1` for unlimited retention (`expires_at: -1`); `0` and lower negative values are rejected.
Example render response (identifiers, timestamps and capabilities are illustrative):
```json
{
"success": true,
"id": "0123456789abcdef0123456789abcdef0123456789abcdef",
"status": "queued",
"document_type": "pcb",
"viewer_url": "https://render.sadentel.net/view/pcb/0123456789abcdef0123456789abcdef0123456789abcdef#token=READ_TOKEN",
"status_url": "https://render.sadentel.net/api/v1/pcbs/0123456789abcdef0123456789abcdef0123456789abcdef/status",
"viewer_token": "READ_TOKEN",
"access": "private",
"expires_at": 1794818000
}
```
Comparison responses use `document_type: pcb_comparison` and add `comparison_id` and `url`, equal to the initial `viewer_url`. The viewer path is `/compare/pcb/{id}` and status path is `/api/v1/pcbs/comparisons/{id}`. Queued responses do not invent a summary. Completed reports contain `summary` (added/removed/modified/moved), `total_changed`, `changes`, `old`, `new`, `nets`, `matching` and `limitations`.
Retain the initial private URL and token. Status responses never return the capability. Reads accept `X-Viewer-Token` or the service `X-API-Key`. Unauthorized private reads return 404; authorized expired reads return 410 until cleanup. Pending artifacts return 409; failed artifacts return 422. Failed jobs retain their error in an HTTP 200 status response. PCB validation/decoding errors use `invalid_pcb`; resource-limit and infrastructure errors use the existing queue conventions. Filenames and geometry are not disclosed by unauthenticated status requests.
Viewer shells themselves are public static shells, like the schematic viewer; drawing data is fetched with the capability header. Token fragments are removed from browser history and stored in a resource-specific sessionStorage key. Browser storage restrictions in embedded contexts still require browser acceptance testing. No API key is shipped to the browser.
## Queue and storage compatibility
Immutable application-owned input names classify jobs:
| Input name | Job kind |
| --- | --- |
| `input.SchDoc` | schematic_render (legacy) |
| `comparison.json` | schematic_comparison (legacy) |
| `input.PcbDoc` | pcb_render |
| `pcb-comparison.json` | pcb_comparison |
A PCB comparison stores two sources `old.PcbDoc` and `new.PcbDoc`, plus a versioned manifest. The worker dispatches PCB work to `app.pcb_task`, inheriting the existing subprocess CPU, address-space, output, FD and wall-clock limits. Attempts write the same `attempt-{1|2}/data.json`, `drawing.svg` or `old.svg`/`new.svg` artifact names. Completion uses the existing attempt/status fence. API routes require the exact resource kind even for unlisted jobs and service-key reads. The storage regex permits only these named additions. Cleanup removes both source files and all attempts.
Each input remains bounded by `MAX_UPLOAD_BYTES`. Only the new comparison POST joins the existing two-file multipart allowance (`2 * MAX_UPLOAD_BYTES + 1 MiB`). The Caddy comparison matcher includes that exact new path with 65 MiB; other request limits remain unchanged. Adjust external CDN caps if required by your deployment.
PCB filenames are sanitized, MIME is not trusted, and the API checks the actual OLE header/Board6 identification with bounded stream reads. Full binary decoding remains asynchronous. Renaming PCB bytes to `.SchDoc` does not admit them to the schematic API; SchDoc bytes are rejected by PCB validation.
## Native format and geometry
`pcb_binary.py`, `pcb_geometry.py` and `pcb_parser.py` adapt an independently written reader from the user's local Altium viewer checkout; no other application/frontend/service is imported. The binary layouts and units were cross-checked with the [KiCad developer format reference](https://dev-docs.kicad.org/en/import-formats/altium/index.html). No GPL importer implementation is copied, and no Altium SDK is required.
Supported envelope: `PCB 6.0 Binary File` in `FileHeaderSix`, with `Board6/Data` identifying `Protel_Advanced_PCB`. Board format revisions 5.0, 5.01 and 6.0 are recognized; **only 5.01 is verified on the local native files**. These are format revisions, not supported Altium application release promises. Other envelopes/revisions fail explicitly.
Readers validate framing, block sizes, field boundaries, finite numbers, references, stream counts and cumulative stream sizes. They never resynchronize after a bad tag, execute embedded resources or fetch external URLs. Limits include 1024 streams, 64 MiB cumulative stream bytes, 16 MiB subrecords and 250,000 primitives, in addition to subprocess limits. Very large boards may exceed these limits.
Binary coordinates are integer 1/10000 mil. The shared world unit is 10 mil:
```text
world_x = raw_x / 100000
world_y = -raw_y / 100000
```
Placed child geometry is already global and is not rotated twice. PCB-specific models retain footprint metadata, object provenance, optional native UID, layer memberships and drill spans, without turning PCB pads into schematic pins. Source physical indexes remain provenance only.
| Native streams | Current treatment |
| --- | --- |
| Board6 | Real boundary vertices, arcs, dimensions and stack-link metadata |
| Components6 / Nets6 | Footprint placement, parameters and actual net references |
| Tracks6 / Arcs6 | Centerline geometry, widths and layers |
| Pads6 | Top/mid/bottom and supported full-stack shapes, rotation, plated state, round/square/slot drill and supported offsets |
| Vias6 | Recorded span, primary and supported per-layer diameters, drill |
| Fills6 | Rotated filled rectangles |
| Regions6 | Saved polygon copper with interior holes |
| ShapeBasedRegions6 | Standalone solid/curved regions and supported board cutouts; duplicate polygon definitions are not painted twice |
| Polygons6 | Retained definition boundaries; never treated as freshly poured copper |
| Texts6 / WideStrings6 | Stored strings and placement; font/style limitations are diagnosed |
| UniqueIDPrimitiveInformation | Native primitive identity where present |
Copper membership follows actual Board6 links, including plane-layer identities. Pads and vias keep multiple layer memberships; per-layer geometry is drawn separately. Drill visibility follows its recorded span. The safe SVG serializer emits layer identifiers and inert geometry, with top-side visibility initially selected. Layer controls allow Top/Bottom/All and individual selection. Outline is a separate layer. Changing the preset does not geometrically mirror or rearrange the board.
## Fidelity boundaries
Compatibility is explicitly `partial`:
- Layer colors are a viewer palette; Altium application display preferences are not claimed to be embedded or reproduced.
- Browser fonts replace native stroke/TrueType rendering; metrics, barcodes, inverted text and special styles may differ. Font licensing/availability still applies.
- Saved pours are rendered as stored, possibly stale. There is no repour engine, rule execution, negative-plane reconstruction or solder/paste-mask rule expansion.
- Supported board cutouts are rendered as visual cutouts; this is not a fabrication geometry boolean engine.
- Advanced/unknown pad shapes are omitted with diagnostics. Undecoded full-stack pad or nonuniform via dimensions on internal plane layers are reported rather than assigned an invented shape.
- Embedded STEP models, 3D bodies, rules and unsupported streams are not rendered. Diagnostics identify omitted Data streams and declared counts when present.
- Footprints without supported native geometry remain in metadata with diagnostics; substitute symbols are never generated.
- Geometry outside the board bounds remains pannable but may start outside the initial viewBox.
No proprietary sample, Altium software, SDK, font or compiled library is included in the repository or Docker image. No pixel-perfect claim is made.
## Diff interpretation
The PCB diff reuses the format-independent entity matcher, not schematic record-number semantics or schematic coordinate-key normalization. It matches unique native IDs, unique footprint designators, exact geometry/position, unique local structure, then unique same-kind position. Ambiguous objects remain additions/removals; identical duplicates are treated as multisets. Each match records its method.
Footprint-owned geometry is aggregated and compared in local coordinates, while independent tracks/vias remain independent. Layer, net name, width, rotation, drill, rendered geometry and properties participate. Net indexes and record/component/polygon indexes are excluded as cross-file identities. Reversed straight tracks and net renumbering do not create changes by themselves. The stack is a separate semantic entity. Unsupported stream bytes are not semantically compared, and this limitation is part of every report.
Primary added/removed/modified/moved counts are disjoint. A modified-and-moved object has primary `modified` plus `moved: true` and a movement delta. Rotation changes are reported in fields and `rotation_change`. An anchor delta is not proof of rigid translation when geometry also changed.
Net results report observed names added/removed; object fields reveal changed stored assignments. The result is not DRC, ERC, an electrical-equivalence proof, a manufacturing comparison or a project-wide compiled netlist.
## Viewer and framing
The dependency-free PCB shell uses the existing camera/interactions module and spinner. The default is a fullscreen board or two boards, plus a menu button. Layers, diagnostics and comparison controls appear only when the menu opens; there is no new dashboard or upload page. Comparison supports synchronized pan/zoom/pinch, fit, change filters, changes-only, highlights and previous/next focus.
Existing security middleware covers `/view/*` and `/compare/*`, including the new nested PCB pages. Those responses have one CSP with `frame-ancestors 'self' https://git.sadentel.net`; conflicting X-Frame-Options is removed. Other directives and API policies stay intact. Parent-site CSP and any deployed CDN overrides still need environment-specific checking.
## Validation and deployment
```bash
PCBDOC_SAMPLE='/path/to/private-board.PcbDoc' ./scripts/check.sh
API_KEY="$API_KEY" .venv/bin/python scripts/pcb-smoke.py original.PcbDoc modified.PcbDoc \
--url http://127.0.0.1:8000
```
Without a configured native file the real-PCB test is explicitly skipped. Synthetic native unit fixtures are test-only, never fallback viewer content. The HTTP smoke creates private resources, polls the real worker, checks SVGs, denies unauthenticated reads, and checks preview security headers without printing capabilities. Omit `modified.PcbDoc` to verify a same-file comparison has zero changes.
Rebuild/restart both API and worker:
```bash
docker compose up -d --build api worker
```
Reload/recreate Caddy if using the production overlay so its two-file upload matcher is updated. No schema migration is required. No automatic production deployment or unrelated service restart is performed by this feature.
### Local verification evidence (2026-11-09)
The current `/home/nearology/LTE 6/LTE 6.PcbDoc` (SHA-256 `2afb5b464cff6c4b780386bf14f39c810230d5625d7277ff01fe4823999ea561`) decoded 694 footprints, 479 nets, 12,916 tracks, 311 arcs, 2,329 pads, 1,059 vias, 1,822 texts, 13 fills, 45 polygon definitions and 48 records in each region stream. It produced 26,471 layer-aware primitives. A self-comparison reported zero changes. Its native SVG was rasterized locally and the top-side image inspected; this is not an Altium-reference visual regression.
The other local PCB file (SHA-256 `00b812958357b12f62f8d3cca12486f716925b2655d35258115b01c44eaeb314`) decoded 729 footprints and produced 41,285 primitives. Comparing those two real local documents completed with 8,073 added, 12,248 removed, 1,300 modified and 469 moved primary entities; 102 ambiguous signature groups remained. These counts demonstrate a completed structural comparison, not human verification of every pair or design equivalence. No claim is made about the chronological relationship of these files.
Real HTTP smoke checks passed against an isolated loopback Uvicorn API with SQLite and a separate real worker: private single rendering, self-comparison, two-file comparison, both SVG reads, unauthorized reads and preview headers. The test processes and their temporary database/storage were stopped/removed afterwards. This run is not evidence of live PostgreSQL/S3 integration or production deployment. S3 coverage uses botocore protocol stubs. Caddy configuration adaptation passed with 65 MiB on the three comparison upload paths and the original 33 MB elsewhere.
No connected browser was available, so actual pan/pinch/menu/iframe appearance remains unverified in a browser. No trusted Altium export was supplied, so geometric and font fidelity has not been certified against Altium output.
The final automated run includes native SchDoc and PCB fixtures, 154 passing Python tests and three passing frontend test files (camera, pointer interaction adapter and PCB layer policies). The standard Docker build was attempted with both normal and host networking, but dependency download stalled on `Temporary failure in name resolution` for PyPI's package host; those build attempts were stopped. A new Docker image was therefore not validated or deployed in this environment. The existing Dockerfile and dependency lock remain unchanged.
Both PCB viewers and upload results include Share → Copy iframe, preserving any private read capability in the URL fragment. Unlimited jobs are excluded from automatic cleanup; external S3 lifecycle policies must also permit retention. See [expiry and embedding](API.md#unlimited-links-and-embedding).
+124
مشاهده پرونده
@@ -0,0 +1,124 @@
# Private S3 object storage
Schematic and PCB uploads, comparison manifests, normalized JSON and SVG artifacts use the same storage adapter. Configure it once for both API and worker. No API endpoint, viewer URL, token or database schema changes are required.
## Configure `.env`
```dotenv
STORAGE_BACKEND=s3
S3_BUCKET=your-private-bucket
S3_PREFIX=altium/production
S3_ENDPOINT_URL=https://your-s3-service.example
S3_REGION=us-east-1
S3_ADDRESSING_STYLE=path
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
S3_CONNECT_TIMEOUT=5
S3_READ_TIMEOUT=60
S3_MAX_ATTEMPTS=3
S3_SERVER_SIDE_ENCRYPTION=
S3_KMS_KEY_ID=
```
Replace the example values with your provider's actual bucket, service endpoint, credentials and region. The bucket must already exist. The application never creates a bucket, changes its ACL/policy or makes objects public. Keep the bucket private. API-controlled access and expiry continue to apply; the browser does not connect directly to S3 or receive storage credentials.
For AWS S3, leave `S3_ENDPOINT_URL` empty and choose the bucket's region; `auto` addressing is normally suitable. For MinIO and S3-compatible providers use the provider's endpoint and addressing style (often `path`). Do not put the bucket name, access credentials, query parameters or a public asset URL into `S3_ENDPOINT_URL`.
| Variable | Default / behavior |
| --- | --- |
| `STORAGE_BACKEND` | `auto`: S3 if `S3_BUCKET` is nonempty, local otherwise. `s3` requires a bucket and never falls back to disk. `local` explicitly selects local files. |
| `S3_BUCKET` | Existing private bucket name, not a URL |
| `S3_PREFIX` | Empty for backward compatibility; optional namespace, e.g. `altium/production` |
| `S3_ENDPOINT_URL` | Empty uses the AWS endpoint selected by the SDK |
| `S3_REGION` | Falls back to `AWS_DEFAULT_REGION`, then `us-east-1` |
| `S3_ADDRESSING_STYLE` | `auto`, `path` or `virtual`; default `auto` |
| `S3_CONNECT_TIMEOUT` | Positive integer seconds, default 5 |
| `S3_READ_TIMEOUT` | Positive integer seconds, default 60 |
| `S3_MAX_ATTEMPTS` | Positive integer, default 3 total attempts including initial request; standard SDK retry mode |
| `AWS_ACCESS_KEY_ID` | Standard SDK credential chain; no credential is embedded in source |
| `AWS_SECRET_ACCESS_KEY` | Standard SDK credential chain |
| `AWS_SESSION_TOKEN` | Optional for temporary credentials |
| `AWS_CA_BUNDLE` | Optional trusted custom CA PEM path accessible to API and worker; certificate verification remains enabled |
| `S3_SERVER_SIDE_ENCRYPTION` | Empty uses provider defaults; optional `AES256` or `aws:kms` |
| `S3_KMS_KEY_ID` | Optional KMS key/alias, valid only with `aws:kms`; requires provider support and key permissions |
IAM/workload credentials can be used instead of static keys. Do not set empty static credentials when using an IAM role. Keep `.env` private and ignored by Git; do not put credentials in frontend code or `VITE_*` variables. The parser subprocess inherits neither AWS credentials nor storage configuration.
Some compatible providers do not implement newer optional AWS checksum behavior. Where the provider documents this requirement, the SDK's standard `AWS_REQUEST_CHECKSUM_CALCULATION=when_required` / `AWS_RESPONSE_CHECKSUM_VALIDATION=when_required` environment settings can be used without changing application code.
Prefix rules: leading/trailing `/` is stripped; internal empty segments, `.`, `..`, backslashes, controls and prefixes exceeding 512 UTF-8 bytes are rejected. The prefix is a server-controlled namespace, never supplied by a request. Generated suffixes retain the existing strict allowlist. For example:
```text
s3://your-private-bucket/altium/production/{id}/input.SchDoc
s3://your-private-bucket/altium/production/{id}/input.PcbDoc
s3://your-private-bucket/altium/production/{id}/comparison.json
s3://your-private-bucket/altium/production/{id}/pcb-comparison.json
s3://your-private-bucket/altium/production/{id}/old.PcbDoc
s3://your-private-bucket/altium/production/{id}/new.PcbDoc
s3://your-private-bucket/altium/production/{id}/attempt-1/data.json
s3://your-private-bucket/altium/production/{id}/attempt-1/drawing.svg
s3://your-private-bucket/altium/production/{id}/attempt-1/old.svg
s3://your-private-bucket/altium/production/{id}/attempt-1/new.svg
```
The database continues storing logical keys such as `{id}/input.PcbDoc`. Prefix application happens exactly once, at the S3 put/get/delete boundary. Expiry cleanup uses the same mapping and never lists or bulk-deletes a bucket or another prefix.
## What stays outside S3
S3 replaces persistent **document object storage**, not the database. PostgreSQL still stores jobs, authorization hashes, expiry, queue leases and status. SQLite development databases remain local. Keep the same database when switching backends so existing IDs/tokens remain valid.
Multipart spooling and resource-limited parsing still use temporary local files, normally under `/tmp`, and remove them after processing. They are not durable artifact storage. In S3 mode no `STORAGE_DIR/objects` files are created/read as fallback. With PostgreSQL the adapter does not require a local storage directory. Existing Compose data volumes are retained for safe migration/rollback; they no longer receive document objects once S3 is selected. Do not delete a previous volume until its objects have been migrated and backups verified.
The bucket/prefix must be identical on all API and worker replicas. Bucket errors propagate through the existing storage error responses or durable failed-job status. An S3 outage does not switch new uploads to local storage. Object retention/lifecycle policies must not remove data earlier than the application link lifetime; `expires_in=-1` jobs have no automatic expiry and need a provider policy that does not remove them; database rows and S3 objects should be backed up together.
Grant the service object permissions scoped to the chosen namespace: `s3:PutObject`, `s3:GetObject` and `s3:DeleteObject`. The application does not require public ACLs or bucket-wide listing for normal operations. Encryption/KMS may require additional provider permissions. Administrative backup and lifecycle tooling can have separate permissions.
## Existing files and migration
Changing backend/bucket/prefix does **not** move existing objects automatically. Existing S3 deployments keep working with `STORAGE_BACKEND=auto` and empty `S3_PREFIX`. If introducing a prefix for previously unprefixed S3 objects, first copy the existing keys to that namespace using your provider's tools while retaining the original suffixes.
For local-to-S3 migration, `scripts/migrate-to-s3.py`:
- Reads only allowlisted application objects from the original `STORAGE_DIR/objects` tree.
- Copies into the configured private S3 namespace and reads each copy back to verify its SHA-256.
- Skips an already identical destination; refuses to overwrite different bytes.
- Rejects symlinks/unrecognized paths and stops on provider errors.
- Never deletes local files or changes database rows. Completed copies remain if a later object fails, allowing a safe rerun.
Stop admission of uploads and all workers/cleanup while copying so neither side changes under migration. Keep the original database and object volume.
For local execution, load the edited `.env` and point to the **old** storage directory:
```bash
set -a
source .env
set +a
PYTHONPATH=backend .venv/bin/python scripts/migrate-to-s3.py --source-dir ./data
```
For the existing Compose deployment (`/data` is the old named volume):
```bash
docker compose stop api worker
docker compose build api worker
docker compose run --rm --no-deps \
-v "$PWD/scripts:/tools:ro" \
api python /tools/migrate-to-s3.py --source-dir /data
docker compose up -d api worker
```
Only proceed to the final startup after the copy command succeeds. Do not run production workers against new S3 settings before old objects have been copied; otherwise already queued jobs cannot find their sources.
For a new installation with no old local objects, migration is unnecessary:
```bash
docker compose up -d --build api worker
```
Compose already passes `.env` to both services. `./scripts/dev.sh` also exports `.env` for both local processes. Direct `uvicorn`/worker commands need the variables exported explicitly. Editing `.env` requires recreating Compose containers (a plain `docker compose restart` does not load new environment values).
After switching, upload a SchDoc and PcbDoc, check their status and private SVGs, and open an existing pre-migration link. Confirm objects appear only under the configured bucket/prefix. No real provider was configured during implementation; protocol tests use botocore stubs and the end-to-end API/worker tests use a fake S3 transport with the real parsers/renderers.
## SDK references
[Configuration](https://docs.aws.amazon.com/boto3/latest/guide/configuration.html), [credential discovery](https://docs.aws.amazon.com/boto3/latest/guide/credentials.html), and [put_object/encryption parameters](https://docs.aws.amazon.com/boto3/latest/reference/services/s3/client/put_object.html). The application retains its existing pinned boto3 runtime dependency.
+5 -1
مشاهده پرونده
@@ -4,7 +4,7 @@ The comparison feature reuses native SchDoc parsing, the original vector geometr
Open `/compare` to select an original and modified file, or follow the comparison link on the main upload page. After processing, `/compare/{id}#token=…` shows the original on the left and modified document on the right. Original schematic primitives keep their colors and geometry; optional boxes annotate changes. Added objects are green, removed red, modified amber and moved blue. A changes list provides property details, previous/next navigation and click-to-focus. Zoom/pan/pinch, synchronized world coordinates, fit, category filtering, changes-only mode and highlight toggling are available. Both drawings have unique SVG clip identifiers to avoid collisions in the same DOM. Open `/compare` to select an original and modified file, or follow the comparison link on the main upload page. After processing, `/compare/{id}#token=…` shows the original on the left and modified document on the right. Original schematic primitives keep their colors and geometry; optional boxes annotate changes. Added objects are green, removed red, modified amber and moved blue. A changes list provides property details, previous/next navigation and click-to-focus. Zoom/pan/pinch, synchronized world coordinates, fit, category filtering, changes-only mode and highlight toggling are available. Both drawings have unique SVG clip identifiers to avoid collisions in the same DOM.
The single-file viewer and its fullscreen interface remain unchanged. The new comparison page is a separate workspace. Existing iframe policy remains limited to `/view/*`; `/compare/*` is a standalone page and is not additionally granted cross-origin framing permission by this feature. The single-file viewer and its fullscreen interface remain unchanged. The new comparison page is a separate workspace. The security middleware permits preview embedding for `/view/*` and `/compare/*` only by the service itself and `https://git.sadentel.net`; other CSP directives remain unchanged.
## API ## API
@@ -85,3 +85,7 @@ API_KEY="$API_KEY" .venv/bin/python scripts/compare-smoke.py original.SchDoc mod
Tests cover record reordering, component UID matching/renumbering, values, pure movement, combined movement/property edits, additions/removals, reversed wire vertices, duplicate objects, labels/net-name scope, embedded raster replacement, nonmutation, original SVG geometry, clip-ID separation, upload/authentication/CORS, per-file/combined limits, partial storage failures, a real OLE same-file comparison, private result/SVG access, failed processing and expiry cleanup. Frontend camera adapter tests cover pane offsets, synchronized world cameras, pinch, change focus and detached listeners. Synthetic unit circuits never appear as application data. Tests cover record reordering, component UID matching/renumbering, values, pure movement, combined movement/property edits, additions/removals, reversed wire vertices, duplicate objects, labels/net-name scope, embedded raster replacement, nonmutation, original SVG geometry, clip-ID separation, upload/authentication/CORS, per-file/combined limits, partial storage failures, a real OLE same-file comparison, private result/SVG access, failed processing and expiry cleanup. Frontend camera adapter tests cover pane offsets, synchronized world cameras, pinch, change focus and detached listeners. Synthetic unit circuits never appear as application data.
The actual local `Sheet1-before.SchDoc` / `Sheet1-after.SchDoc` pair (205/206 source records) produced 1 moved component, 2 modified wires and 1 added junction. The 1,942-record CPU sheet compared with itself produced zero changes. These are structural integration checks, not a trusted Altium visual comparison or browser UI acceptance test. No proprietary samples are redistributed. Browser interaction and visual validation require a connected browser; absence of browser QA must be reported rather than replaced by camera unit tests. The actual local `Sheet1-before.SchDoc` / `Sheet1-after.SchDoc` pair (205/206 source records) produced 1 moved component, 2 modified wires and 1 added junction. The 1,942-record CPU sheet compared with itself produced zero changes. These are structural integration checks, not a trusted Altium visual comparison or browser UI acceptance test. No proprietary samples are redistributed. Browser interaction and visual validation require a connected browser; absence of browser QA must be reported rather than replaced by camera unit tests.
Implementation verification: 115 Python tests and 6 frontend tests passed with the native fixtures enabled. The Docker image built successfully, the Caddy configuration adapted successfully, and real HTTP comparison plus single-file smoke checks passed against PostgreSQL with two worker replicas. The comparison result and SVG routes enforced private read tokens. A connected browser was unavailable, so interactive workspace appearance and browser behavior are not claimed as verified.
Comparison uploads also accept `expires_in=-1` for unlimited storage and link validity, with `expires_at: -1` in responses. The workspace menu and upload result include Share for a token-preserving iframe snippet; existing access and framing restrictions remain enforced.
+1 -1
مشاهده پرونده
@@ -1,2 +1,2 @@
<!doctype html> <!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Compare Altium schematics</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/compare-upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · STRUCTURAL DIFF</p><h1>Compare schematics.</h1><p>Select the original and modified SchDoc files.</p><form id="compare-upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="old-file">Original schematic</label><input id="old-file" type="file" accept=".SchDoc" required><label for="new-file">Modified schematic</label><input id="new-file" type="file" accept=".SchDoc" required><button type="submit">Compare schematics <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Comparison uses native objects and saved geometry. Connection graphics are compared without a compiled electrical netlist.</p><a href="/">Single schematic viewer</a></main></body></html> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Compare Altium schematics</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/compare-upload.js?v=expiry-share-1"></script><link rel="stylesheet" href="/assets/share.css?v=1"></head><body><main><p class="eyebrow">NATIVE ALTIUM · STRUCTURAL DIFF</p><h1>Compare schematics.</h1><p>Select the original and modified SchDoc files.</p><form id="compare-upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="old-file">Original schematic</label><input id="old-file" type="file" accept=".SchDoc" required><label for="new-file">Modified schematic</label><input id="new-file" type="file" accept=".SchDoc" required><label for="expires-in">Expires in (seconds; -1 for unlimited)</label><input id="expires-in" type="number" min="-1" step="1" placeholder="Server default"><button type="submit">Compare schematics <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><div id="upload-share"></div><p class="note">Comparison uses native objects and saved geometry. Connection graphics are compared without a compiled electrical netlist.</p><a href="/">Single schematic viewer</a></main></body></html>
+5 -2
مشاهده پرونده
@@ -1,12 +1,15 @@
import { mountShare } from './share.js?v=1';
const form=document.querySelector('#compare-upload'),result=document.querySelector('#result'); const form=document.querySelector('#compare-upload'),result=document.querySelector('#result');
form.addEventListener('submit',async e=>{ form.addEventListener('submit',async e=>{
e.preventDefault();const button=form.querySelector('button');button.disabled=true;result.textContent='Uploading both schematics…'; e.preventDefault();document.querySelector('#upload-share').replaceChildren();const button=form.querySelector('button');button.disabled=true;result.textContent='Uploading both schematics…';
const headers={},key=document.querySelector('#key').value; const headers={},key=document.querySelector('#key').value;
if(key)headers['X-API-Key']=key; if(key)headers['X-API-Key']=key;
const body=new FormData();body.append('old_file',document.querySelector('#old-file').files[0]);body.append('new_file',document.querySelector('#new-file').files[0]); const body=new FormData();body.append('old_file',document.querySelector('#old-file').files[0]);body.append('new_file',document.querySelector('#new-file').files[0]);
const expiry=document.querySelector('#expires-in').value;
if(expiry)body.append('expires_in',expiry);
try { try {
const response=await fetch('/api/v1/comparisons',{method:'POST',headers,body});const data=await response.json(); const response=await fetch('/api/v1/comparisons',{method:'POST',headers,body});const data=await response.json();
if(!response.ok)throw new Error(data.error?.message||'Comparison upload failed'); if(!response.ok)throw new Error(data.error?.message||'Comparison upload failed');
result.textContent='Queued. ';const link=document.createElement('a');link.href=data.url;link.textContent='Open comparison';result.append(link); result.textContent='Queued. ';const link=document.createElement('a');link.href=data.url;link.textContent='Open comparison';result.append(link);mountShare(document.querySelector('#upload-share'),data.viewer_url||data.url);
}catch(error){result.textContent=error.message;}finally{button.disabled=false;} }catch(error){result.textContent=error.message;}finally{button.disabled=false;}
}); });
+8 -4
مشاهده پرونده
@@ -1,6 +1,6 @@
body { display: flex; flex-direction: column; font-family: system-ui,sans-serif; color: #233431; background: #f4f6f5; } body { display: flex; flex-direction: column; font-family: system-ui,sans-serif; color: #233431; background: #f4f6f5; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
header { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; padding: 12px 16px; border-bottom: 1px solid #d6dfda; } header { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; min-height: 60px; padding: 12px 16px 12px 60px; border-bottom: 1px solid #d6dfda; }
header > a { color: #244d49; font-size: 15px; font-weight: 650; text-decoration: none; } header > a { color: #244d49; font-size: 15px; font-weight: 650; text-decoration: none; }
#summary, .controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: 12px; } #summary, .controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: 12px; }
.controls { margin-left: auto; } .controls { margin-left: auto; }
@@ -8,7 +8,11 @@ label { display: inline-flex; gap: 4px; align-items: center; }
button, select { border: 1px solid #cbd5ce; border-radius: 4px; padding: 5px 9px; background: white; color: inherit; font: inherit; cursor: pointer; } button, select { border: 1px solid #cbd5ce; border-radius: 4px; padding: 5px 9px; background: white; color: inherit; font: inherit; cursor: pointer; }
button:focus-visible, select:focus-visible, .surface:focus-visible { outline: 2px solid #2676c5; outline-offset: -2px; } button:focus-visible, select:focus-visible, .surface:focus-visible { outline: 2px solid #2676c5; outline-offset: -2px; }
#scope { margin: 0; padding: 7px 16px; font-size: 11px; color: #60716b; border-bottom: 1px solid #d6dfda; } #scope { margin: 0; padding: 7px 16px; font-size: 11px; color: #60716b; border-bottom: 1px solid #d6dfda; }
.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) 270px; } .workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); grid-template-rows: minmax(0,1fr); }
.menu-open .workspace { grid-template-columns: minmax(0,1fr) minmax(0,1fr) 270px; }
[data-comparison-menu][hidden] { display: none !important; }
#menu-toggle { position: fixed; top: 12px; left: 12px; z-index: 10; width: 36px; height: 36px; display: grid; place-items: center; padding: 6px; box-shadow: 0 2px 6px #00000014; }
#menu-toggle[aria-expanded="true"] { background: #e5eee9; }
.pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid #d6dfda; } .pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid #d6dfda; }
h2 { margin: 0; padding: 9px 12px; font-size: 12px; font-weight: 650; background: #edf1ee; display: flex; gap: 10px; } h2 { margin: 0; padding: 9px 12px; font-size: 12px; font-weight: 650; background: #edf1ee; display: flex; gap: 10px; }
h2 span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: normal; color: #61746b; } h2 span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: normal; color: #61746b; }
@@ -28,7 +32,7 @@ aside { display: flex; flex-direction: column; min-height: 0; overflow: hidden;
[data-diff-hidden] { display: none; } [data-diff-hidden] { display: none; }
.no-highlights [data-diff-box] { display: none; } .no-highlights [data-diff-box] { display: none; }
[data-diff-box][data-selected] { stroke-width: 3; fill-opacity: .18; } [data-diff-box][data-selected] { stroke-width: 3; fill-opacity: .18; }
@media (max-width: 900px) { .workspace { grid-template-columns: minmax(0,1fr) minmax(0,1fr); grid-template-rows: minmax(0,1fr) 180px; } aside { grid-column: 1/-1; } #details { max-height: 40%; } } @media (max-width: 900px) { .menu-open .workspace { grid-template-columns: minmax(0,1fr) minmax(0,1fr); grid-template-rows: minmax(0,1fr) 180px; } aside { grid-column: 1/-1; } #details { max-height: 40%; } }
@media (max-width: 550px) { .workspace { grid-template-columns: 1fr; grid-template-rows: minmax(0,1fr) minmax(0,1fr) 140px; } aside { grid-column: auto; } header { gap: 8px; }.controls { margin-left: 0; } } @media (max-width: 550px) { .workspace { grid-template-columns: 1fr; grid-template-rows: minmax(0,1fr) minmax(0,1fr); } .menu-open .workspace { grid-template-columns: 1fr; grid-template-rows: minmax(0,1fr) minmax(0,1fr) 140px; } aside { grid-column: auto; } header { gap: 8px; }.controls { margin-left: 0; } }
#changes button[hidden] { display: none; } #changes button[hidden] { display: none; }
+5 -4
مشاهده پرونده
@@ -1,6 +1,7 @@
<!doctype html> <!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic comparison</title><link rel="stylesheet" href="/fonts.css"><link rel="stylesheet" href="/assets/viewer.css"><link rel="stylesheet" href="/assets/compare.css"><script type="module" src="/assets/compare.js"></script></head><body> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic comparison</title><link rel="stylesheet" href="/fonts.css"><link rel="stylesheet" href="/assets/viewer.css"><link rel="stylesheet" href="/assets/compare.css?v=fullscreen-menu-3"><script type="module" src="/assets/compare.js?v=expiry-share-1"></script><link rel="stylesheet" href="/assets/share.css?v=1"></head><body>
<header><a href="/compare">Schematic Diff</a><div id="summary" aria-live="polite"></div><div class="controls"><label><input id="sync" type="checkbox" checked> Sync views</label><label><input id="highlights" type="checkbox" checked> Highlights</label><label><input id="only-changes" type="checkbox"> Changes only</label><button id="fit" type="button">Fit</button></div></header> <button id="menu-toggle" type="button" aria-label="Show menu" title="Show menu" aria-expanded="false" aria-controls="comparison-toolbar scope comparison-changes"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<p id="scope">Native object comparison · Connection graphics, not a compiled electrical netlist.</p> <header id="comparison-toolbar" data-comparison-menu hidden><a href="/compare">Schematic Diff</a><div id="summary" aria-live="polite"></div><div class="controls"><label><input id="sync" type="checkbox" checked> Sync views</label><label><input id="highlights" type="checkbox" checked> Highlights</label><label><input id="only-changes" type="checkbox"> Changes only</label><div id="comparison-share"></div><button id="fit" type="button">Fit</button></div></header>
<main class="workspace"><section class="pane"><h2>Original <span id="old-name"></span></h2><div id="old-surface" class="surface" tabindex="0" aria-label="Original schematic"></div></section><section class="pane"><h2>Modified <span id="new-name"></span></h2><div id="new-surface" class="surface" tabindex="0" aria-label="Modified schematic"></div></section><aside><div class="change-controls"><label for="filter">Changes</label><select id="filter"><option value="all">All</option><option value="added">Added</option><option value="removed">Removed</option><option value="modified">Modified</option><option value="moved">Moved</option></select><button id="previous" aria-label="Previous change">↑</button><button id="next" aria-label="Next change">↓</button></div><nav id="changes" aria-label="Schematic changes"></nav><pre id="details" aria-live="polite"></pre></aside></main> <p id="scope" data-comparison-menu hidden>Native object comparison · Connection graphics, not a compiled electrical netlist.</p>
<main class="workspace"><section class="pane"><h2 data-comparison-menu hidden>Original <span id="old-name"></span></h2><div id="old-surface" class="surface" tabindex="0" aria-label="Original schematic"></div></section><section class="pane"><h2 data-comparison-menu hidden>Modified <span id="new-name"></span></h2><div id="new-surface" class="surface" tabindex="0" aria-label="Modified schematic"></div></section><aside id="comparison-changes" data-comparison-menu hidden><div class="change-controls"><label for="filter">Changes</label><select id="filter"><option value="all">All</option><option value="added">Added</option><option value="removed">Removed</option><option value="modified">Modified</option><option value="moved">Moved</option></select><button id="previous" aria-label="Previous change">↑</button><button id="next" aria-label="Next change">↓</button></div><nav id="changes" aria-label="Schematic changes"></nav><pre id="details" aria-live="polite"></pre></aside></main>
<output id="state" class="loading" role="status">Loading comparison…</output></body></html> <output id="state" class="loading" role="status">Loading comparison…</output></body></html>
+19 -1
مشاهده پرونده
@@ -1,4 +1,21 @@
import { mountShare, viewerURL } from './share.js?v=1';
import { bindCamera } from './interactions.js'; import { bindCamera } from './interactions.js';
const menuToggle=document.querySelector('#menu-toggle');
const menuPanels=document.querySelectorAll('[data-comparison-menu]');
function setMenu(open){
for(const panel of menuPanels)panel.hidden=!open;
document.body.classList.toggle('menu-open',open);
menuToggle.setAttribute('aria-expanded',String(open));
const label=open?'Hide menu':'Show menu';
menuToggle.setAttribute('aria-label',label);menuToggle.title=label;
}
setMenu(false);
menuToggle.addEventListener('click',()=>setMenu(!document.body.classList.contains('menu-open')));
addEventListener('keydown',event=>{
if(event.key==='Escape'&&document.body.classList.contains('menu-open')){
setMenu(false);menuToggle.focus();
}
});
const state=document.querySelector('#state'),id=location.pathname.split('/').pop(); const state=document.querySelector('#state'),id=location.pathname.split('/').pop();
const token=new URLSearchParams(location.hash.slice(1)).get('token')||sessionStorage.getItem(`comparison:${id}`); const token=new URLSearchParams(location.hash.slice(1)).get('token')||sessionStorage.getItem(`comparison:${id}`);
if(token)sessionStorage.setItem(`comparison:${id}`,token); if(token)sessionStorage.setItem(`comparison:${id}`,token);
@@ -87,6 +104,7 @@ async function start(){
document.querySelector('#previous').addEventListener('click',()=>next(-1));document.querySelector('#next').addEventListener('click',()=>next(1)); document.querySelector('#previous').addEventListener('click',()=>next(-1));document.querySelector('#next').addEventListener('click',()=>next(1));
for(const svg of svgs)svg.addEventListener('click',event=>{const id=event.target.closest('[data-change-id]')?.dataset.changeId;if(byId.has(id))select(byId.get(id));}); for(const svg of svgs)svg.addEventListener('click',event=>{const id=event.target.closest('[data-change-id]')?.dataset.changeId;if(byId.has(id))select(byId.get(id));});
applyFilters(); applyFilters();
function expire(){const remaining=result.expires_at*1000-Date.now();if(remaining<=0){for(const view of views)view.destroy();document.querySelector('.workspace').replaceChildren();state.textContent='This comparison link has expired';}else setTimeout(expire,Math.min(2147483647,remaining));}expire(); mountShare(document.querySelector('#comparison-share'),viewerURL(location.pathname,token));
function expire(){if(result.expires_at===-1)return;const remaining=result.expires_at*1000-Date.now();if(remaining<=0){for(const view of views)view.destroy();document.querySelector('.workspace').replaceChildren();state.textContent='This comparison link has expired';}else setTimeout(expire,Math.min(2147483647,remaining));}expire();
} }
start().catch(error=>{state.classList.remove('loading');state.textContent=error.name==='AbortError'?'':error.message;}); start().catch(error=>{state.classList.remove('loading');state.textContent=error.name==='AbortError'?'':error.message;});
+1 -1
مشاهده پرونده
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic API reference</title><link rel="stylesheet" href="/assets/upload.css"></head><body><main><p class="eyebrow">API V1</p><h1>Schematic rendering API</h1><p>Authenticate uploads with <code>X-API-Key</code>. Native binary OLE and ASCII SchDoc format 5.0 are accepted. Files are parsed asynchronously; HTTP 202 confirms admission, not rendering fidelity.</p><h2>Upload</h2><p><code>POST /api/v1/schematics/render</code></p><p>Multipart fields: <code>file</code> (required), <code>access</code> (private or unlisted; defaults to private), <code>expires_in</code> (optional seconds, bounded by server policy).</p><h2>Poll</h2><p><code>GET /api/v1/schematics/{id}/status</code></p><p>States: queued, processing, completed, failed. Private reads require <code>X-Viewer-Token</code> from the upload response, or the service API key. Completed responses include compatibility and diagnostics.</p><h2>Retrieve</h2><p><code>GET /api/v1/schematics/{id}/data</code> returns the versioned structured document. <code>GET /api/v1/schematics/{id}/svg</code> returns the generated vector drawing.</p><h2>View</h2><p>Open the exact <code>viewer_url</code> from the upload response. The private token is in its fragment. It is stored for this browser tab and sent only as a request header. The viewer has no panels or toolbar.</p><h2>Compare two schematics</h2><p><code>POST /api/v1/comparisons</code> (alias <code>/api/compare</code>) accepts multipart <code>old_file</code> and <code>new_file</code> with the same API key. It returns HTTP 202 and a private comparison URL. Poll <code>GET /api/v1/comparisons/{id}</code> using the returned read token for the completed summary and structural changes. <a href="/compare">Open comparison upload</a>.</p><h2>Errors</h2><p>JSON errors include <code>success: false</code> and <code>error.code</code> / <code>error.message</code>. Common HTTP codes: 401 invalid service key; 404 missing or inaccessible schematic; 409 still processing; 410 expired; 413 too large; 415 wrong extension; 422 invalid schematic; 503 storage unavailable or queue full.</p><p><a href="/openapi.json">Download OpenAPI schema</a> · <a href="/">Upload a schematic</a></p></main></body></html> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Altium API reference</title><link rel="stylesheet" href="/assets/upload.css"></head><body><main><p class="eyebrow">API V1</p><h1>Altium rendering API</h1><p>Authenticate uploads with <code>X-API-Key</code>. Schematic endpoints accept native binary OLE and ASCII SchDoc format 5.0. Separate PCB endpoints accept the diagnosed native PcbDoc V6 subset. Files are parsed asynchronously; HTTP 202 confirms admission, not rendering fidelity.</p><h2>Upload</h2><p><code>POST /api/v1/schematics/render</code></p><p>Multipart fields: <code>file</code> (required), <code>access</code> (private or unlisted; defaults to private), <code>expires_in</code> (optional: -1 for unlimited, or positive seconds bounded by server policy).</p><h2>Poll</h2><p><code>GET /api/v1/schematics/{id}/status</code></p><p>States: queued, processing, completed, failed. Private reads require <code>X-Viewer-Token</code> from the upload response, or the service API key. Completed responses include compatibility and diagnostics.</p><h2>Retrieve</h2><p><code>GET /api/v1/schematics/{id}/data</code> returns the versioned structured document. <code>GET /api/v1/schematics/{id}/svg</code> returns the generated vector drawing.</p><h2>View</h2><p>Open the exact <code>viewer_url</code> from the upload response. The private token is in its fragment. It is stored for this browser tab and sent only as a request header. The viewer has no panels or toolbar.</p><h2>Compare two schematics</h2><p><code>POST /api/v1/comparisons</code> (alias <code>/api/compare</code>) accepts multipart <code>old_file</code> and <code>new_file</code> with the same API key. It returns HTTP 202 and a private comparison URL. Poll <code>GET /api/v1/comparisons/{id}</code> using the returned read token for the completed summary and structural changes. <a href="/compare">Open comparison upload</a>.</p><h2>Preview a PCB</h2><p><code>POST /api/v1/pcbs/render</code> accepts multipart <code>file</code> (.PcbDoc), <code>access</code> and optional <code>expires_in</code>. It returns HTTP 202 with <code>viewer_url</code>, <code>status_url</code> and a private <code>viewer_token</code> when applicable.</p><p>Poll <code>GET /api/v1/pcbs/{id}/status</code>. Read <code>GET /api/v1/pcbs/{id}/data</code> or <code>GET /api/v1/pcbs/{id}/svg</code>. The viewer is <code>/view/pcb/{id}</code>.</p><h2>Compare two PCBs</h2><p><code>POST /api/v1/pcbs/comparisons</code> accepts <code>old_file</code> and <code>new_file</code> (.PcbDoc) with the same upload authentication, access and expiry options. Poll <code>GET /api/v1/pcbs/comparisons/{id}</code>. Read <code>GET /api/v1/pcbs/comparisons/{id}/svg/{side}</code>, where side is <code>old</code> or <code>new</code>. Open the returned <code>/compare/pcb/{id}#token=…</code> URL for the side-by-side viewer.</p><p>PCB preview and comparison have layer controls behind a menu button. Compatibility is partial: fonts, some native shapes, plane reconstruction and mask expansions have limitations. Structural comparison is not DRC or proof of electrical equivalence. Private reads require <code>X-Viewer-Token</code> or the service key.</p><h2>Unlimited links and Share</h2><p>All upload operations accept <code>expires_in=-1</code> to retain files and links without automatic expiration. Responses use <code>expires_at: -1</code>. Zero and lower negative values are invalid. The Share button provides an iframe snippet containing the complete viewer URL, including a private read token when applicable. Sharing that code grants read access. Embedding remains restricted to this service and <code>https://git.sadentel.net</code>.</p><h2>Errors</h2><p>JSON errors include <code>success: false</code> and <code>error.code</code> / <code>error.message</code>. Common HTTP codes: 401 invalid service key; 404 missing or inaccessible schematic; 409 still processing; 410 expired; 413 too large; 415 wrong extension; 422 invalid schematic; 503 storage unavailable or queue full.</p><p><a href="/openapi.json">Download OpenAPI schema</a> · <a href="/">API playground and file upload</a></p></main></body></html>
+19 -1
مشاهده پرونده
@@ -1,2 +1,20 @@
<!doctype html> <!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Altium schematic upload</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · SCHDOC</p><h1>Open your schematic.</h1><p>Upload a native schematic to create a private, expiring viewer link.</p><form id="upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="file">Schematic file</label><input id="file" type="file" accept=".SchDoc" required><button type="submit">Render schematic <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Compatibility diagnostics are returned by the API. Review them before relying on visual fidelity.</p><a href="/compare">Compare two schematics</a> · <a href="/docs">API reference</a></main></body></html> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Altium API playground</title><link rel="stylesheet" href="/assets/upload.css?v=pcb-playground-1"><script type="module" src="/assets/upload.js?v=expiry-share-1"></script><link rel="stylesheet" href="/assets/share.css?v=1"></head><body class="playground"><main>
<p class="eyebrow">NATIVE ALTIUM · SCHDOC + PCBDOC</p><h1>Render or compare your design.</h1><p>Test the rendering APIs with your own files. Uploads create expiring viewer links.</p>
<div class="playground-grid"><section aria-labelledby="test-title"><h2 id="test-title">Test an API</h2>
<form id="upload"><fieldset id="upload-fields"><label for="operation">Operation</label><select id="operation"><option value="schematic">Preview schematic (.SchDoc)</option><option value="schematic-comparison">Compare schematics (.SchDoc)</option><option value="pcb">Preview PCB (.PcbDoc)</option><option value="pcb-comparison">Compare PCBs (.PcbDoc)</option></select><p class="endpoint"><code id="selected-endpoint">POST /api/v1/schematics/render</code></p>
<label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><p class="field-hint">Used for this upload only; never saved in browser storage.</p>
<div id="single-input"><label id="file-label" for="file">Schematic file</label><input id="file" type="file" accept=".SchDoc" required></div>
<div id="comparison-inputs" hidden><label for="old-file">Original file</label><input id="old-file" type="file" accept=".SchDoc" disabled><label for="new-file">Modified file</label><input id="new-file" type="file" accept=".SchDoc" disabled></div>
<div class="upload-options"><div><label for="access">Link access</label><select id="access"><option value="private">Private (read token required)</option><option value="unlisted">Unlisted (anyone with the link)</option></select></div><div><label for="expires-in">Expires in (seconds)</label><input id="expires-in" type="number" min="-1" step="1" placeholder="Server default"></div></div>
<p class="field-hint">Use -1 to keep the files and viewer link indefinitely. Leave blank for the server default.</p></fieldset><button id="submit-upload" type="submit">Render schematic <span aria-hidden="true">↗</span></button></form>
<output id="result" role="status" aria-live="polite"></output><div id="viewer-link"></div><div id="upload-share"></div><details id="response-details" hidden><summary>API response</summary><pre id="response-json"></pre></details>
<p class="note">Compatibility is partial. Review parser diagnostics before relying on visual fidelity. Files are processed asynchronously; a queued response confirms acceptance.</p></section>
<section class="api-list" aria-labelledby="api-title"><h2 id="api-title">Available APIs</h2><p>Uploads use <code>multipart/form-data</code> and <code>X-API-Key</code>. Select a capability to test it.</p>
<article><button type="button" class="api-choice" data-operation="schematic">Schematic preview</button><code>POST /api/v1/schematics/render</code><p>File field: <code>file</code> · .SchDoc</p><code>GET /api/v1/schematics/{id}/status</code><code>GET /api/v1/schematics/{id}/data</code><code>GET /api/v1/schematics/{id}/svg</code><code>GET /view/{id}</code></article>
<article><button type="button" class="api-choice" data-operation="schematic-comparison">Schematic comparison</button><code>POST /api/v1/comparisons</code><p>File fields: <code>old_file</code>, <code>new_file</code> · .SchDoc</p><code>GET /api/v1/comparisons/{id}</code><code>GET /api/v1/comparisons/{id}/svg/{side}</code><code>GET /compare/{id}</code><p>Compatible aliases: <code>POST /api/compare</code>, <code>GET /api/compare/{id}</code>.</p></article>
<article><button type="button" class="api-choice" data-operation="pcb">PCB preview</button><code>POST /api/v1/pcbs/render</code><p>File field: <code>file</code> · .PcbDoc</p><code>GET /api/v1/pcbs/{id}/status</code><code>GET /api/v1/pcbs/{id}/data</code><code>GET /api/v1/pcbs/{id}/svg</code><code>GET /view/pcb/{id}</code></article>
<article><button type="button" class="api-choice" data-operation="pcb-comparison">PCB comparison</button><code>POST /api/v1/pcbs/comparisons</code><p>File fields: <code>old_file</code>, <code>new_file</code> · .PcbDoc</p><code>GET /api/v1/pcbs/comparisons/{id}</code><code>GET /api/v1/pcbs/comparisons/{id}/svg/{side}</code><code>GET /compare/pcb/{id}</code></article>
<p class="field-hint">Read private results with <code>X-Viewer-Token</code> or the service key. SVG <code>side</code> is <code>old</code> or <code>new</code>. Upload options: <code>access</code>, <code>expires_in</code>.</p></section></div>
<nav class="page-links"><a href="/docs">API reference</a><a href="/openapi.json">OpenAPI schema</a><a href="/compare">Schematic comparison upload</a></nav>
</main></body></html>
+9
مشاهده پرونده
@@ -0,0 +1,9 @@
/** Native layer identities; drill visibility also depends on its recorded copper span. */
export function layerVisible(id, span, selected) {
if (!selected.has(id)) return false;
return id !== '81' || !span.length || span.some(layer => selected.has(layer));
}
export function presetLayers(layers, preset) {
return new Set(layers.filter(layer => preset === 'all' || layer.id === '0' || layer.id === '81' ||
(preset === 'top' ? ['1','33'].includes(layer.id) : ['32','34'].includes(layer.id))).map(layer => layer.id));
}
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {layerVisible,presetLayers} from './pcb-layers.js';
test('blind via drills follow actual selected copper span',()=>{
assert.equal(layerVisible('81',['2','3'],new Set(['1','81'])),false);
assert.equal(layerVisible('81',['2','3'],new Set(['2','81'])),true);
assert.equal(layerVisible('81',['1'],new Set(['1'])),false);
assert.equal(layerVisible('0',[],new Set(['0'])),true);
});
test('layer presets preserve native IDs and outline',()=>{
const layers=['0','1','2','32','33','34','81'].map(id=>({id}));
assert.deepEqual([...presetLayers(layers,'top')],['0','1','33','81']);
assert.deepEqual([...presetLayers(layers,'bottom')],['0','32','34','81']);
assert.equal(presetLayers(layers,'all').size,7);
});
+20
مشاهده پرونده
@@ -0,0 +1,20 @@
[hidden] { display: none !important; }
#pcb-workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0,1fr); }
body.pcb-comparison #pcb-workspace { grid-template-columns: minmax(0,1fr) minmax(0,1fr); }
body.menu-open #pcb-workspace { grid-template-columns: minmax(0,1fr) 270px; }
body.pcb-comparison.menu-open #pcb-workspace { grid-template-columns: minmax(0,1fr) minmax(0,1fr) 270px; }
#pcb-menu { padding: 10px; overflow: auto; font-size: 12px; }
#pcb-menu h2 { padding: 5px 0; background: transparent; }
#layers { display: grid; gap: 5px; margin: 12px 0; }
#layer-presets { display: flex; gap: 5px; }
#compatibility { color: #60716b; font-size: 11px; overflow-wrap: anywhere; }
#diff-controls { min-height: 100px; }
#changes { max-height: 35vh; padding: 0; margin-top: 10px; }
#summary { font-size: 12px; }
.surface { background: #08151b; }
@media (max-width: 700px) {
body.pcb-comparison #pcb-workspace { grid-template-columns: 1fr; grid-template-rows: minmax(0,1fr) minmax(0,1fr); }
body.menu-open #pcb-workspace { grid-template-columns: minmax(0,1fr) 210px; }
body.pcb-comparison.menu-open #pcb-workspace { grid-template-columns: minmax(0,1fr) 210px; grid-template-rows: minmax(0,1fr) minmax(0,1fr); }
#pcb-menu { grid-column: 2; grid-row: 1 / -1; }
}
+5
مشاهده پرونده
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PCB Viewer</title><link rel="stylesheet" href="/fonts.css"><link rel="stylesheet" href="/assets/viewer.css"><link rel="stylesheet" href="/assets/compare.css?v=pcb-1"><link rel="stylesheet" href="/assets/pcb.css?v=1"><script type="module" src="/assets/pcb.js?v=expiry-share-1"></script><link rel="stylesheet" href="/assets/share.css?v=1"></head><body>
<button id="menu-toggle" type="button" aria-label="Show menu" aria-expanded="false" aria-controls="pcb-toolbar pcb-menu"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<header id="pcb-toolbar" data-pcb-menu hidden><strong>PCB</strong><div id="summary"></div><div class="controls"><label id="sync-control"><input id="sync" type="checkbox" checked> Sync views</label><label id="highlight-control"><input id="highlights" type="checkbox" checked> Highlights</label><div id="pcb-share"></div><button id="fit" type="button">Fit</button></div></header>
<main id="pcb-workspace"><section class="pane"><div id="old-surface" class="surface" tabindex="0" aria-label="PCB"></div></section><section id="new-pane" class="pane" hidden><div id="new-surface" class="surface" tabindex="0" aria-label="Modified PCB"></div></section><aside id="pcb-menu" data-pcb-menu hidden><section><h2>Layers</h2><div id="layer-presets"><button data-preset="top" type="button">Top</button><button data-preset="bottom" type="button">Bottom</button><button data-preset="all" type="button">All</button></div><div id="layers"></div></section><section id="diff-controls" hidden><div class="change-controls"><select id="filter" aria-label="Change filter"><option value="all">All changes</option><option value="added">Added</option><option value="removed">Removed</option><option value="modified">Modified</option><option value="moved">Moved</option></select><button id="previous" aria-label="Previous change">↑</button><button id="next" aria-label="Next change">↓</button></div><label><input id="only-changes" type="checkbox"> Changes only</label><nav id="changes" aria-label="PCB changes"></nav><pre id="details"></pre></section><p id="compatibility"></p></aside></main><output id="state" class="loading" role="status">Loading PCB…</output></body></html>
+87
مشاهده پرونده
@@ -0,0 +1,87 @@
import { mountShare, viewerURL } from './share.js?v=1';
import { bindCamera } from './interactions.js';
import { layerVisible, presetLayers } from './pcb-layers.js';
const comparison=location.pathname.startsWith('/compare/pcb/');
const id=location.pathname.split('/').pop(), state=document.querySelector('#state');
const key=`pcb:${comparison?'comparison':'render'}:${id}`;
const token=new URLSearchParams(location.hash.slice(1)).get('token')||sessionStorage.getItem(key);
if(token)sessionStorage.setItem(key,token);
if(location.hash)history.replaceState(null,'',location.pathname);
const headers=token?{'X-Viewer-Token':token}:{}, controller=new AbortController(), views=[];
const base=comparison?`/api/v1/pcbs/comparisons/${id}`:`/api/v1/pcbs/${id}`;
const menu=document.querySelector('#menu-toggle');
function setMenu(open){
document.body.classList.toggle('menu-open',open);
for(const panel of document.querySelectorAll('[data-pcb-menu]'))panel.hidden=!open;
menu.setAttribute('aria-expanded',String(open));menu.setAttribute('aria-label',open?'Hide menu':'Show menu');
}
setMenu(false);
menu.addEventListener('click',()=>setMenu(menu.getAttribute('aria-expanded')!=='true'));
addEventListener('keydown',e=>{if(e.key==='Escape'){setMenu(false);menu.focus();}});
addEventListener('pagehide',()=>{controller.abort();for(const view of views)view.destroy();},{once:true});
document.body.classList.toggle('pcb-comparison',comparison);
document.querySelector('#new-pane').hidden=!comparison;
for(const selector of ['#sync-control','#highlight-control','#diff-controls'])document.querySelector(selector).hidden=!comparison;
async function request(path='',text=false){
const r=await fetch(base+path,{headers,signal:controller.signal,cache:'no-store'});
if(!r.ok){const b=await r.json();throw new Error(b.error?.message||'PCB unavailable');}
return text?r.text():r.json();
}
function union(boxes){
const x=Math.min(...boxes.map(b=>b.x)),y=Math.min(...boxes.map(b=>b.y));
return {x,y,width:Math.max(...boxes.map(b=>b.x+b.width))-x,height:Math.max(...boxes.map(b=>b.y+b.height))-y};
}
async function start(){
let result;
for(;;){result=await request(comparison?'':'/status');if(result.status==='completed')break;if(result.status==='failed')throw new Error(result.error?.message||'PCB processing failed');await new Promise(r=>setTimeout(r,1000));}
const texts=await Promise.all((comparison?['/svg/old','/svg/new']:['/svg']).map(path=>request(path,true)));
const svgs=texts.map(text=>{
const xml=new DOMParser().parseFromString(text,'image/svg+xml');
if(xml.querySelector('parsererror')||xml.documentElement.localName!=='svg')throw new Error('Invalid PCB artifact');
return document.importNode(xml.documentElement,true);
});
const boxes=comparison?[result.old.bounds,result.new.bounds]:svgs.map(svg=>{const b=svg.viewBox.baseVal;return {x:b.x,y:b.y,width:b.width,height:b.height};});
const bounds=union(boxes);
for(const [i,svg]of svgs.entries()){
const surface=document.querySelector(i?'#new-surface':'#old-surface');surface.append(svg);
views.push(bindCamera(surface,svg,bounds,source=>{if(comparison&&document.querySelector('#sync').checked)for(const view of views)if(view!==source)view.apply(source.pose());}));
}
await document.fonts.ready;
state.classList.remove('loading');state.textContent='';
document.querySelector('#fit').addEventListener('click',()=>{for(const view of views)view.fit();});
document.querySelector('#sync').addEventListener('change',()=>{if(views[1]&&document.querySelector('#sync').checked)views[1].apply(views[0].pose());});
const layers=comparison?[...new Map([...result.old.layers,...result.new.layers].map(l=>[l.id,l])).values()]:result.layers;
let selected=new Set(layers.filter(l=>l.visible).map(l=>l.id));
const layerNodes=svgs.flatMap(svg=>[...svg.querySelectorAll('[data-layer-id]')]);
function paintLayers(){for(const node of layerNodes)node.setAttribute('display',layerVisible(node.dataset.layerId,(node.dataset.layerSpan||'').split(' ').filter(Boolean),selected)?'inline':'none');}
const checks=new Map();
for(const layer of layers){
const label=document.createElement('label'),input=document.createElement('input');input.type='checkbox';input.checked=selected.has(layer.id);
checks.set(layer.id,input);label.append(input,document.createTextNode(layer.name));document.querySelector('#layers').append(label);
input.addEventListener('change',()=>{if(input.checked)selected.add(layer.id);else selected.delete(layer.id);paintLayers();});
}
for(const button of document.querySelectorAll('[data-preset]'))button.addEventListener('click',()=>{selected=presetLayers(layers,button.dataset.preset);for(const [id,input]of checks)input.checked=selected.has(id);paintLayers();});
paintLayers();
const diagnostics=comparison?[...result.old.diagnostics,...result.new.diagnostics]:result.diagnostics||[];
document.querySelector('#compatibility').textContent=[...new Set(diagnostics.map(d=>d.message))].join('\n');
if(comparison){
const changes=result.changes,filter=document.querySelector('#filter'),rows=new Map();let active=null;
document.querySelector('#summary').textContent=Object.entries(result.summary).map(([k,v])=>`${v} ${k}`).join(' · ');
function focus(change){active=change.id;const boxes=[change.old_bounds,change.new_bounds].filter(Boolean);if(boxes.length)for(const view of views)view.focus(union(boxes));document.querySelector('#details').textContent=JSON.stringify({kind:change.kind,match:change.match,movement:change.movement,fields:change.fields},null,2);for(const [id,row]of rows)row.setAttribute('aria-current',String(id===active));}
for(const change of changes){const button=document.createElement('button');button.type='button';button.className=change.status;button.textContent=`${change.status} · ${change.label}`;button.addEventListener('click',()=>focus(change));document.querySelector('#changes').append(button);rows.set(change.id,button);}
if(!changes.length)document.querySelector('#changes').textContent='No structural changes detected.';
const byId=new Map(changes.map(c=>[c.id,c])),nodes=svgs.flatMap(svg=>[...svg.querySelectorAll('[data-change-id]')]);
function apply(){
for(const node of nodes){const c=byId.get(node.dataset.changeId);node.toggleAttribute('data-diff-hidden',c?(filter.value!=='all'&&filter.value!==c.status):(filter.value!=='all'||document.querySelector('#only-changes').checked));}
for(const change of changes)rows.get(change.id).hidden=filter.value!=='all'&&filter.value!==change.status;
document.body.classList.toggle('no-highlights',!document.querySelector('#highlights').checked);
}
for(const input of [filter,document.querySelector('#only-changes'),document.querySelector('#highlights')])input.addEventListener('change',apply);
for(const [selector,step]of [['#previous',-1],['#next',1]])document.querySelector(selector).addEventListener('click',()=>{const visible=changes.filter(c=>filter.value==='all'||c.status===filter.value);if(!visible.length)return;const i=visible.findIndex(c=>c.id===active);focus(visible[(i<0?(step>0?0:visible.length-1):(i+step+visible.length)%visible.length)]);});
for(const svg of svgs)svg.addEventListener('click',e=>{const c=byId.get(e.target.closest('[data-change-id]')?.dataset.changeId);if(c)focus(c);});
apply();
}
mountShare(document.querySelector('#pcb-share'),viewerURL(location.pathname,token));
function expire(){if(result.expires_at===-1)return;const remaining=result.expires_at*1000-Date.now();if(remaining<=0){for(const view of views)view.destroy();document.querySelector('#pcb-workspace').replaceChildren();state.textContent='This PCB link has expired';}else setTimeout(expire,Math.min(2147483647,remaining));}expire();
}
start().catch(e=>{state.classList.remove('loading');state.textContent=e.name==='AbortError'?'':e.message;});
+13
مشاهده پرونده
@@ -0,0 +1,13 @@
.share-widget { position: relative; display: inline-block; color: #233431; font: 13px system-ui,sans-serif; text-align: left; }
.share-widget button { display: inline-block; margin: 0; padding: 7px 12px; border: 1px solid #b6c8bf; border-radius: 4px; background: #fff; color: #244d49; font: inherit; cursor: pointer; }
.share-widget button:focus-visible, .share-widget textarea:focus-visible { outline: 2px solid #2676c5; outline-offset: 2px; }
.share-panel { position: absolute; top: calc(100% + 8px); left: 0; width: min(430px,calc(100vw - 40px)); max-height: 75vh; overflow: auto; z-index: 30; padding: 14px; border: 1px solid #cbd8d1; border-radius: 5px; background: #fff; box-shadow: 0 6px 24px #0002; }
.share-panel[hidden] { display: none !important; }
.share-panel p { margin: 0 0 10px; font: inherit; line-height: 1.5; color: inherit; }
.share-panel textarea { display: block; width: 100%; resize: vertical; padding: 10px; border: 1px solid #cbd8d1; border-radius: 3px; background: #f6f8f6; color: #233431; font: 12px/1.5 monospace; direction: ltr; box-sizing: border-box; }
.share-panel .share-note { margin-top: 10px; font-size: 11px; color: #60716b; }
.share-panel [role="status"] { display: block; margin-top: 8px; font-size: 11px; }
#viewer-share { position: fixed; top: 12px; right: 12px; z-index: 20; }
#viewer-share .share-panel { left: auto; right: 0; }
#comparison-share .share-panel, #pcb-share .share-panel { left: auto; right: 0; }
#upload-share { margin-top: 12px; }
+31
مشاهده پرونده
@@ -0,0 +1,31 @@
/** Construct inert embed markup, retaining the read capability only in the URL fragment. */
export function viewerURL(value, token, base = location.origin) {
const url = new URL(value, base);
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('Invalid viewer URL');
if (url.username || url.password) throw new Error('Invalid viewer URL');
if (token) url.hash = new URLSearchParams({ token }).toString();
return url.href;
}
export function iframeCode(value, base = location.origin) {
const url = viewerURL(value, null, base);
const escaped = url.replace(/[&"<>']/g, c => ({'&':'&amp;','"':'&quot;','<':'&lt;','>':'&gt;',"'":'&#39;'}[c]));
return `<iframe\n src="${escaped}"\n width="100%"\n height="800"\n title="Altium design viewer"\n loading="lazy"\n referrerpolicy="no-referrer"\n allowfullscreen\n></iframe>`;
}
export function mountShare(host, value) {
const code = iframeCode(value), widget = document.createElement('div');widget.className = 'share-widget';
const toggle = document.createElement('button');toggle.type = 'button';toggle.className = 'share-toggle';toggle.textContent = 'Share';toggle.setAttribute('aria-expanded','false');
const panel = document.createElement('div');panel.className = 'share-panel';panel.hidden = true;
const caption = document.createElement('p');caption.textContent = 'Embed this viewer in Gitea or this service:';
const text = document.createElement('textarea');text.readOnly = true;text.value = code;text.rows = 9;text.setAttribute('aria-label','Viewer iframe code');
const note = document.createElement('p');note.className = 'share-note';
note.textContent = new URL(value,location.origin).hash.includes('token=') ? 'This code includes a read token. Anyone with the code can view this design.' : 'Embedding is allowed only on this service and https://git.sadentel.net.';
const copy = document.createElement('button');copy.type = 'button';copy.textContent = 'Copy iframe';
const status = document.createElement('span');status.setAttribute('role','status');
copy.addEventListener('click',async()=>{
try { await navigator.clipboard.writeText(code);status.textContent = 'Copied'; }
catch { text.focus();text.select();status.textContent = 'Select and copy the code manually.'; }
});
toggle.addEventListener('click',()=>{panel.hidden=!panel.hidden;toggle.setAttribute('aria-expanded',String(!panel.hidden));});
widget.addEventListener('keydown',event=>{if(event.key==='Escape'){panel.hidden=true;toggle.setAttribute('aria-expanded','false');toggle.focus();}});
panel.append(caption,text,note,copy,status);widget.append(toggle,panel);host.append(widget);
}
+46
مشاهده پرونده
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {iframeCode,viewerURL,mountShare} from './share.js';
const base='https://render.sadentel.net';
for(const path of ['/view/','/compare/','/view/pcb/','/compare/pcb/']){
test(`share ${path} preserves the private capability`,()=>{
const url=viewerURL(path+'a'.repeat(48),'read-token',base);
const code=iframeCode(url,base);
assert.ok(code.includes(`src="${base}${path}${'a'.repeat(48)}#token=read-token"`));
assert.ok(code.includes('width="100%"')&&code.includes('height="800"'));
assert.equal(new URL(url).search,'');
assert.ok(!code.includes('X-API-Key'));
});
}
test('unlisted iframe does not gain a token',()=>{
assert.ok(!iframeCode('/view/'+'a'.repeat(48),base).includes('#token='));
});
test('iframe escapes attributes and rejects executable URL schemes',()=>{
const code=iframeCode(base+'/view/test?x=1&y=2#token=" onload="bad',base);
assert.ok(!code.includes(' onload="bad'));
assert.ok(code.includes('%22') && code.includes('&amp;'));
for(const url of ['javascript:alert(1)','data:text/html,hello','https://user:secret@example.test/view/id'])assert.throws(()=>iframeCode(url,base));
});
test('Share reveals the iframe and copies its read token without sending a request',async t=>{
class Element {
constructor(){this.children=[];this.events={};this.attributes={};}
append(...items){this.children.push(...items);}
setAttribute(key,value){this.attributes[key]=value;}
addEventListener(name,callback){this.events[name]=callback;}
focus(){} select(){}
}
const saved=new Map(['document','location','navigator'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
let copied='';
Object.defineProperty(globalThis,'document',{configurable:true,value:{createElement:()=>new Element()}});
Object.defineProperty(globalThis,'location',{configurable:true,value:{origin:base}});
Object.defineProperty(globalThis,'navigator',{configurable:true,value:{clipboard:{writeText:async code=>{copied=code;}}}});
t.after(()=>{for(const [key,descriptor]of saved)if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];});
const host=new Element();mountShare(host,base+'/view/'+'a'.repeat(48)+'#token=private-read');
const [toggle,panel]=host.children[0].children;
assert.equal(panel.hidden,true);toggle.events.click();assert.equal(panel.hidden,false);
const textarea=panel.children[1],copy=panel.children[3];
assert.ok(textarea.value.includes('#token=private-read'));assert.equal(textarea.readOnly,true);
await copy.events.click();assert.equal(copied,textarea.value);
toggle.events.click();assert.equal(panel.hidden,true);
});
+31
مشاهده پرونده
@@ -13,3 +13,34 @@ button:disabled { opacity: .5; cursor: wait; }
output { display: block; font-size: 14px; overflow-wrap: anywhere; line-height: 1.6; } output { display: block; font-size: 14px; overflow-wrap: anywhere; line-height: 1.6; }
a { color: #244d49; font-size: 13px; } a { color: #244d49; font-size: 13px; }
.note { margin-top: 28px; font-size: 12px; } .note { margin-top: 28px; font-size: 12px; }
/* Home API playground only: existing upload and docs layouts stay independent. */
.playground { align-items: start; padding: clamp(20px,4vw,56px); }
.playground main { width: min(100%,1120px); margin: 0 auto; }
.playground h1 { max-width: 750px; }
.playground-grid { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: clamp(24px,5vw,64px); margin-top: 28px; }
.playground h2 { font-size: 20px; margin: 0 0 16px; }
.playground form { margin-top: 0; }
.playground fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; }
.playground select { width: 100%; padding: 13px; border: 1px solid #ccd4ce; background: white; color: inherit; font: inherit; border-radius: 5px; }
.playground label { display: block; margin-bottom: 6px; }
.playground [hidden] { display: none !important; }
.playground .endpoint { margin: 0; font-size: 12px; overflow-wrap: anywhere; }
.playground .field-hint { font-size: 12px; margin: 0; }
.playground .upload-options { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.playground .api-list { min-width: 0; }
.playground .api-list > p { font-size: 13px; }
.playground article { margin: 18px 0; padding: 16px; border: 1px solid #d5ddd6; border-radius: 6px; background: #ffffff80; }
.playground article > code { display: block; font-size: 12px; line-height: 1.8; overflow-wrap: anywhere; }
.playground article p { font-size: 12px; margin: 8px 0; }
.playground .api-choice { display: inline-block; padding: 0; margin: 0 0 10px; background: transparent; color: #244d49; font-weight: 650; text-align: left; border-radius: 0; }
.playground .api-choice[aria-pressed="true"] { text-decoration: underline; text-underline-offset: 4px; }
.playground button:focus-visible, .playground select:focus-visible, .playground a:focus-visible { outline: 2px solid #51706d; outline-offset: 3px; }
.playground #viewer-link { margin-top: 12px; }
.playground #viewer-link a { display: inline-block; padding: 10px 14px; border: 1px solid #9bb4a7; border-radius: 4px; }
.playground details { margin-top: 14px; font-size: 12px; }
.playground summary { cursor: pointer; }
.playground pre { max-height: 320px; overflow: auto; padding: 14px; background: #e9eee8; border-radius: 5px; white-space: pre-wrap; overflow-wrap: anywhere; }
.playground .page-links { display: flex; flex-wrap: wrap; gap: 18px; padding: 24px 0; border-top: 1px solid #d5ddd6; }
@media (max-width: 760px) { .playground-grid { grid-template-columns: 1fr; } }
@media (max-width: 400px) { .playground .upload-options { grid-template-columns: 1fr; } }
+90 -15
مشاهده پرونده
@@ -1,16 +1,91 @@
const form = document.querySelector('#upload'), result = document.querySelector('#result'); import { mountShare } from './share.js?v=1';
form.addEventListener('submit',async e => { const operations = {
e.preventDefault(); schematic: { endpoint:'/api/v1/schematics/render', suffix:'.SchDoc', label:'Render schematic', fileLabel:'Schematic file', comparison:false },
const button = form.querySelector('button'); button.disabled = true; result.textContent = 'Uploading…'; 'schematic-comparison': { endpoint:'/api/v1/comparisons', suffix:'.SchDoc', label:'Compare schematics', comparison:true },
const headers = {}, key = document.querySelector('#key').value; pcb: { endpoint:'/api/v1/pcbs/render', suffix:'.PcbDoc', label:'Render PCB', fileLabel:'PCB file', comparison:false },
if (key) headers['X-API-Key'] = key; 'pcb-comparison': { endpoint:'/api/v1/pcbs/comparisons', suffix:'.PcbDoc', label:'Compare PCBs', comparison:true }
const data = new FormData(); data.append('file',document.querySelector('#file').files[0]); };
try { const form=document.querySelector('#upload'), result=document.querySelector('#result');
const response = await fetch('/api/v1/schematics/render',{ method:'POST',headers,body:data }); const operation=document.querySelector('#operation'), button=document.querySelector('#submit-upload');
const body = await response.json(); const fields=document.querySelector('#upload-fields'), viewerLink=document.querySelector('#viewer-link');
if (!response.ok) throw new Error(body.error?.message || 'Upload failed'); const details=document.querySelector('#response-details'), json=document.querySelector('#response-json');
result.textContent = 'Queued. '; let polling=null, uploading=false;
const link = document.createElement('a'); link.href = body.viewer_url; link.textContent = 'Open schematic'; result.append(link); function reset(){
} catch (e) { result.textContent = e.message; } polling?.abort();polling=null;result.textContent='';viewerLink.replaceChildren();details.hidden=true;json.textContent='';document.querySelector('#upload-share').replaceChildren();
finally { button.disabled = false; } }
function configure(){
if(uploading)return;
reset();const config=operations[operation.value];
document.querySelector('#selected-endpoint').textContent=`POST ${config.endpoint}`;
document.querySelector('#single-input').hidden=config.comparison;
document.querySelector('#comparison-inputs').hidden=!config.comparison;
document.querySelector('#file-label').textContent=config.fileLabel||'Schematic file';
for(const [selector,active]of [['#file',!config.comparison],['#old-file',config.comparison],['#new-file',config.comparison]]){
const input=document.querySelector(selector);input.accept=config.suffix;input.required=active;input.disabled=!active;input.value='';
}
button.textContent=config.label+' ↗';
for(const choice of document.querySelectorAll('[data-operation]'))choice.setAttribute('aria-pressed',String(choice.dataset.operation===operation.value));
}
operation.addEventListener('change',configure);
for(const choice of document.querySelectorAll('[data-operation]'))choice.addEventListener('click',()=>{
if(uploading)return;operation.value=choice.dataset.operation;configure();operation.focus();
}); });
configure();
function showResponse(body){json.textContent=JSON.stringify(body,null,2);details.hidden=false;}
async function decode(response){
let body;
try{body=await response.json();}catch{throw new Error(`Server returned HTTP ${response.status} without a JSON response.`);}
if(!response.ok)throw new Error(body.error?.message||`Request failed (HTTP ${response.status}).`);
return body;
}
function pause(signal){
return new Promise((resolve,reject)=>{
const abort=()=>{clearTimeout(timer);reject(new DOMException('Stopped','AbortError'));};
const timer=setTimeout(()=>{signal.removeEventListener('abort',abort);resolve();},1000);
signal.addEventListener('abort',abort,{once:true});
if(signal.aborted)abort();
});
}
async function poll(created,controller){
const headers=created.viewer_token?{'X-Viewer-Token':created.viewer_token}:{};
const status=new URL(created.status_url,location.origin);
if(status.origin!==location.origin)throw new Error('Status URL must use this service origin. Check PUBLIC_BASE_URL.');
for(;;){
await pause(controller.signal);
const body=await decode(await fetch(status.pathname+status.search,{headers,signal:controller.signal,cache:'no-store'}));
if(controller.signal.aborted)return;
showResponse(body);
if(body.status==='failed')throw new Error(body.error?.message||'Processing failed.');
if(body.status==='completed'){
const summary=body.summary?` ${Object.entries(body.summary).map(([name,count])=>`${count} ${name}`).join(' · ')}.`:'';
result.textContent=`Completed.${summary} Open the viewer below.`;return;
}
result.textContent=body.status==='processing'?'Processing your files…':'Queued. Waiting for a worker…';
}
}
form.addEventListener('submit',async event=>{
event.preventDefault();if(uploading)return;reset();
const config=operations[operation.value],data=new FormData();
const inputs=config.comparison?[['old_file','#old-file'],['new_file','#new-file']]:[['file','#file']];
for(const [name,selector]of inputs){
const file=document.querySelector(selector).files[0];
if(!file||!file.name.toLowerCase().endsWith(config.suffix.toLowerCase())){result.textContent=`Select ${config.suffix} files for this operation.`;return;}
data.append(name,file);
}
data.append('access',document.querySelector('#access').value);
const expiry=document.querySelector('#expires-in').value;
if(expiry){if(expiry!=='-1'&&(!/^\d+$/.test(expiry)||Number(expiry)<1)){result.textContent='Expiry must be -1 (unlimited) or a positive whole number of seconds.';return;}data.append('expires_in',expiry);}
const headers={},key=document.querySelector('#key').value;if(key)headers['X-API-Key']=key;
uploading=true;button.disabled=true;fields.disabled=true;result.textContent='Uploading…';
try{
const created=await decode(await fetch(config.endpoint,{method:'POST',headers,body:data}));showResponse(created);
const url=new URL(created.viewer_url||created.url,location.origin);
if(url.origin!==location.origin)throw new Error('Viewer URL must use this service origin. Check PUBLIC_BASE_URL.');
const link=document.createElement('a');link.href=url.href;link.target='_blank';link.rel='noopener noreferrer';link.textContent=config.comparison?'Open comparison ↗':'Open viewer ↗';viewerLink.append(link);mountShare(document.querySelector('#upload-share'),url.href);
result.textContent='Queued. You can open the viewer while processing continues.';
const controller=new AbortController();polling=controller;
poll(created,controller).catch(error=>{if(error.name!=='AbortError'&&!controller.signal.aborted)result.textContent=`${error.message} The accepted upload is not retried automatically.`;});
}catch(error){result.textContent=`${error.message} No automatic upload retry was made.`;}
finally{uploading=false;button.disabled=false;fields.disabled=false;}
});
addEventListener('pagehide',()=>polling?.abort(),{once:true});
+1 -1
مشاهده پرونده
@@ -1,2 +1,2 @@
<!doctype html> <!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"><title>Schematic</title><link rel="stylesheet" href="/fonts.css"><link rel="stylesheet" href="/assets/viewer.css"><script type="module" src="/assets/viewer.js"></script></head><body><main id="viewport" aria-label="Schematic viewer" tabindex="0"></main><output id="state" class="loading" role="status">Loading schematic…</output></body></html> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"><title>Schematic</title><link rel="stylesheet" href="/fonts.css"><link rel="stylesheet" href="/assets/viewer.css"><script type="module" src="/assets/viewer.js?v=expiry-share-1"></script><link rel="stylesheet" href="/assets/share.css?v=1"></head><body><main id="viewport" aria-label="Schematic viewer" tabindex="0"></main><div id="viewer-share"></div><output id="state" class="loading" role="status">Loading schematic…</output></body></html>
+3
مشاهده پرونده
@@ -1,3 +1,4 @@
import { mountShare, viewerURL } from './share.js?v=1';
import { Camera } from './camera.js'; import { Camera } from './camera.js';
const viewport = document.querySelector('#viewport'); const viewport = document.querySelector('#viewport');
const state = document.querySelector('#state'); const state = document.querySelector('#state');
@@ -87,7 +88,9 @@ async function start() {
camera.x = camera.width/2-center.x*camera.scale; camera.y = camera.height/2-center.y*camera.scale; paint(); camera.x = camera.width/2-center.x*camera.scale; camera.y = camera.height/2-center.y*camera.scale; paint();
}).observe(viewport); }).observe(viewport);
// Enforce configured expiry even when a viewer remains open. // Enforce configured expiry even when a viewer remains open.
mountShare(document.querySelector('#viewer-share'),viewerURL(location.pathname,token));
function expire() { function expire() {
if (result.expires_at === -1) return;
const remaining = result.expires_at*1000-Date.now(); const remaining = result.expires_at*1000-Date.now();
if (remaining <= 0) { viewport.replaceChildren(); state.textContent = 'This schematic link has expired'; } if (remaining <= 0) { viewport.replaceChildren(); state.textContent = 'This schematic link has expired'; }
else setTimeout(expire,Math.min(2147483647,remaining)); else setTimeout(expire,Math.min(2147483647,remaining));
+48
مشاهده پرونده
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Copy and verify existing private local objects into the configured S3 namespace.
No local objects or database rows are deleted or changed. Stop uploads/workers
before copying and keep them stopped until both switch to the same S3 settings.
Run with PYTHONPATH=backend and the service .env exported in the environment.
"""
import argparse
import hashlib
from pathlib import Path
from botocore.exceptions import ClientError
from app.config import settings
from app.storage import KEY, storage
def migrate(source_dir):
config=settings()
if config.storage_backend!='s3':raise ValueError('Configure STORAGE_BACKEND=s3 and S3_BUCKET before migrating')
root=Path(source_dir).resolve()/'objects'
if not root.is_dir() or root.is_symlink():raise ValueError('Source directory must contain the original objects directory (not a symlink)')
service=storage();copied=existing=0
for path in sorted(root.rglob('*')):
if path.is_symlink():raise ValueError('Symlinks are not permitted in the source object tree')
if not path.is_file():continue
key=path.relative_to(root).as_posix()
if not KEY.fullmatch(key):raise ValueError('Unrecognized source object; migration stopped without deleting any files')
data=path.read_bytes();digest=hashlib.sha256(data).digest()
try:remote=service.get(key)
except ClientError as exc:
if exc.response.get('Error',{}).get('Code') not in ('NoSuchKey','404','NotFound'):raise
else:
if hashlib.sha256(remote).digest()!=digest:
raise ValueError(f'Destination object differs; refusing to overwrite {key}')
existing+=1;continue
content_type='application/json' if key.endswith('.json') else 'image/svg+xml' if key.endswith('.svg') else 'application/octet-stream'
service.put(key,data,content_type)
if hashlib.sha256(service.get(key)).digest()!=digest:raise ValueError('Destination verification failed')
copied+=1
return {'copied_and_verified':copied,'already_identical':existing}
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source-dir',type=Path,default=None,help='Old STORAGE_DIR, e.g. ./data or /data; must contain objects/')
args=parser.parse_args()
print(migrate(args.source_dir or settings().storage_dir))
if __name__=='__main__':main()
+58
مشاهده پرونده
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Real HTTP smoke: private PCB render + comparison, without printing read capabilities."""
import argparse
import json
import os
from pathlib import Path
import secrets
import time
import urllib.error
import urllib.request
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('old',type=Path);parser.add_argument('new',type=Path,nargs='?')
parser.add_argument('--url',default='http://127.0.0.1:8000')
parser.add_argument('--timeout',type=int,default=180)
args=parser.parse_args();base=args.url.rstrip('/');key=os.environ.get('API_KEY','')
def request(path,headers=None,data=None):
req=urllib.request.Request(base+path,data=data,headers=headers or {})
with urllib.request.urlopen(req,timeout=30) as response:return response.status,response.headers,response.read()
def upload(path,files):
boundary='pcb-'+secrets.token_hex(16);parts=[]
for field,file in files.items():
parts.extend([f'--{boundary}\r\nContent-Disposition: form-data; name="{field}"; filename="board.PcbDoc"\r\nContent-Type: application/octet-stream\r\n\r\n'.encode(),file.read_bytes(),b'\r\n'])
parts.extend([f'--{boundary}\r\nContent-Disposition: form-data; name="access"\r\n\r\nprivate\r\n--{boundary}--\r\n'.encode()])
status,headers,body=request(path,{'X-API-Key':key,'Content-Type':f'multipart/form-data; boundary={boundary}'},b''.join(parts))
assert status==202;created=json.loads(body);assert '#token=' in created['viewer_url']
return created
for comparison in (False,True):
path='/api/v1/pcbs/comparisons' if comparison else '/api/v1/pcbs/render'
files={'old_file':args.old,'new_file':args.new or args.old} if comparison else {'file':args.old}
created=upload(path,files);identifier=created['id'];headers={'X-Viewer-Token':created['viewer_token']}
resource=f'/api/v1/pcbs/comparisons/{identifier}' if comparison else f'/api/v1/pcbs/{identifier}'
status_path=resource if comparison else resource+'/status'
try:request(status_path)
except urllib.error.HTTPError as exc:assert exc.code==404
else:raise AssertionError('Private PCB accessible without a capability')
deadline=time.monotonic()+args.timeout
while True:
_,_,body=request(status_path,headers);result=json.loads(body)
if result['status']=='failed':raise RuntimeError(result['error'])
if result['status']=='completed':break
if time.monotonic()>deadline:raise TimeoutError('PCB worker did not finish')
time.sleep(.5)
for side in (('old','new') if comparison else ('',)):
suffix='/svg/'+side if comparison else '/svg'
status,_,svg=request(resource+suffix,headers);assert status==200 and b'<svg' in svg and b'data-layer-id' in svg
view=f'/compare/pcb/{identifier}' if comparison else f'/view/pcb/{identifier}'
_,response_headers,html=request(view)
csp=response_headers.get_all('Content-Security-Policy')
assert len(csp)==1 and "frame-ancestors 'self' https://git.sadentel.net" in csp[0]
assert not response_headers.get_all('X-Frame-Options') and b'/assets/pcb.js' in html
if comparison and (args.new is None or args.old.read_bytes()==args.new.read_bytes()):assert result['total_changed']==0
print(json.dumps(dict(kind='pcb_comparison' if comparison else 'pcb_render',id=identifier,status=result['status'],
summary=result.get('summary'),primitive_count=result.get('primitive_count'))))
if __name__=='__main__':main()