مقایسه کامیتها
7
کامیتها
1a12b44c24
...
main
| مولف | SHA1 | تاریخ | |
|---|---|---|---|
|
|
19824bd180 | ||
|
|
a4e8f0c6c0 | ||
|
|
ea9fb855b3 | ||
|
|
4eb2cb3d6f | ||
|
|
a599086c11 | ||
|
|
68c3dc0e41 | ||
|
|
6b1f408fde |
+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).
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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}')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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')
|
||||||
@@ -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.'])
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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;}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
<!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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;});
|
||||||
@@ -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; }
|
||||||
@@ -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 => ({'&':'&','"':'"','<':'<','>':'>',"'":'''}[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);
|
||||||
|
}
|
||||||
@@ -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('&'));
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
مرجع در شماره جدید
مسدود کردن کاربر