diff --git a/README.md b/README.md index cadbd37..df40316 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Full settings, credentials, optional encryption, migration instructions and the | `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | | `MAX_UPLOAD_BYTES` | 33,554,432 (32 MiB) | | `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 | | `FONT_DIR` | Unset; optional administrator-managed webfont directory | @@ -155,3 +155,5 @@ The fixture downloader checksum-pins an actual upstream KiCad SchDoc. Unit-test ## 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. 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). diff --git a/backend/app/cleanup.py b/backend/app/cleanup.py index 670cd5d..84ffbd2 100644 --- a/backend/app/cleanup.py +++ b/backend/app/cleanup.py @@ -11,7 +11,7 @@ log=logging.getLogger(__name__) def cleanup(batch=100): now=time.time(); removed=0 with sessions().begin() as db: - q=select(Job).where(Job.expires_at<=now, or_(Job.status!='processing', Job.lease_untilsettings().link_ttl:error('invalid_expiry','expires_in exceeds the configured link lifetime',422) + now=time.time();expires_at=expiration(expires_in,now) try: old_name,old=await read_schematic(old_file) new_name,new=await read_schematic(new_file) finally: 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, 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, job.input_key:json.dumps({'schema_version':'1.0','old_filename':old_name,'new_filename':new_name}).encode()} def persist(): @@ -58,7 +58,7 @@ def register_comparison_routes(app, *, authorize, require_api, error): 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']),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) for key,body in files.items(): try: storage().put(key,body) diff --git a/backend/app/db.py b/backend/app/db.py index 80eb70c..95fc6af 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -6,7 +6,7 @@ to Redis. Versioned artifact keys fence out expired worker leases. """ import time 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 .config import settings @@ -39,6 +39,9 @@ def engine(): @lru_cache 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(): # Initial schema only. Subsequent schema changes must use explicit migrations. if engine().dialect.name == 'postgresql': @@ -55,7 +58,7 @@ def claim(): now = time.time() with sessions().begin() as db: # 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) if engine().dialect.name == 'postgresql': q = q.with_for_update(skip_locked=True) job = db.scalar(q) diff --git a/backend/app/expiry.py b/backend/app/expiry.py new file mode 100644 index 0000000..8c97060 --- /dev/null +++ b/backend/app/expiry.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 3a33000..13f58df 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,7 +16,8 @@ from sqlalchemy import func, select, text from sqlalchemy.exc import SQLAlchemyError from starlette.concurrency import run_in_threadpool 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 .fonts import fonts, stylesheet from .models.api import UploadResponse, RenderStatus, ErrorEnvelope @@ -119,7 +120,7 @@ def authorize(identifier, key, token, *, comparison=False, resource_kind=None): # 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)))): 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 def public_status(job): @@ -136,22 +137,21 @@ def public_status(job): 503: {'description': 'Queue or storage unavailable'}}) async def upload(file: Annotated[UploadFile, File(description='Native binary OLE or ASCII .SchDoc')], 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): require_api(x_api_key) - ttl = expires_in or settings().link_ttl - if ttl > settings().link_ttl: error('invalid_expiry', 'expires_in exceeds the configured link lifetime', 422) + now = time.time() + expires_at = expiration(expires_in, now) name, body = await read_schematic(file) identifier = secrets.token_hex(24) token = secrets.token_urlsafe(32) - now = time.time() 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(): with sessions().begin() as db: # Serialize admission checks across API replicas on PostgreSQL. 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) try: storage().put(job.input_key, bytes(body)) except Exception: error('storage_unavailable', 'Upload storage unavailable', 503) @@ -203,7 +203,7 @@ def index(): return FileResponse(FRONTEND / 'index.html') @app.get('/assets/{name}', include_in_schema=False) 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','pcb.js','pcb.css','pcb-layers.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) @app.get('/docs', include_in_schema=False) diff --git a/backend/app/models/api.py b/backend/app/models/api.py index 9181628..12c4977 100644 --- a/backend/app/models/api.py +++ b/backend/app/models/api.py @@ -15,7 +15,7 @@ class RenderStatus(BaseModel): id: str = Field(description='Unpredictable 192-bit identifier, encoded as 48 hex characters') status: Literal['queued','processing','completed','failed'] 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'] schema_version: str | None = None compatibility: Literal['partial','supported_subset'] | None = None diff --git a/backend/app/pcb_api.py b/backend/app/pcb_api.py index 9b1f39e..37d648b 100644 --- a/backend/app/pcb_api.py +++ b/backend/app/pcb_api.py @@ -11,7 +11,8 @@ 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 .db import Job, sessions +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 @@ -34,18 +35,17 @@ def register_pcb_routes(app, *, authorize, require_api, error): return result async def create(files, access, expires_in, comparison): - ttl = expires_in or settings().link_ttl - if ttl > settings().link_ttl: error('invalid_expiry','expires_in exceeds configured link lifetime',422) + 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); now = time.time() + 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=now+ttl, attempt=0, input_key=f'{identifier}/{input_name}') + 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', @@ -56,7 +56,7 @@ def register_pcb_routes(app, *, authorize, require_api, error): 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']),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) for key, body in bodies.items(): # Include the attempted key so a write-then-raise backend is also cleaned up. @@ -78,14 +78,14 @@ def register_pcb_routes(app, *, authorize, require_api, error): @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(gt=0)]=None, x_api_key:Annotated[str|None,Header()]=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): 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(gt=0)]=None,x_api_key:Annotated[str|None,Header()]=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): require_api(x_api_key) return await create({'old_file':old_file,'new_file':new_file},access,expires_in,True) diff --git a/backend/tests/test_expiry.py b/backend/tests/test_expiry.py new file mode 100644 index 0000000..4a0aae1 --- /dev/null +++ b/backend/tests/test_expiry.py @@ -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 diff --git a/docs/API.md b/docs/API.md index 8ddde17..23df5ca 100644 --- a/docs/API.md +++ b/docs/API.md @@ -7,9 +7,9 @@ Cross-origin browser access is allowed only from `https://git.sadentel.net`, wit Optional fields: - `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. @@ -49,3 +49,21 @@ A worker crash is retried after its lease expires, at most twice total. A parse ## 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 + +``` diff --git a/docs/PCB.md b/docs/PCB.md index 8996e32..71cdd21 100644 --- a/docs/PCB.md +++ b/docs/PCB.md @@ -27,7 +27,7 @@ curl -X POST https://render.sadentel.net/api/v1/pcbs/comparisons \ -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. Expiry is measured from admission, including queue time, and cannot exceed `LINK_TTL_SECONDS`. +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): @@ -161,3 +161,5 @@ Real HTTP smoke checks passed against an isolated loopback Uvicorn API with SQLi 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). diff --git a/docs/S3.md b/docs/S3.md index 2f84641..140e8b9 100644 --- a/docs/S3.md +++ b/docs/S3.md @@ -69,7 +69,7 @@ S3 replaces persistent **document object storage**, not the database. PostgreSQL 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; database rows and S3 objects should be backed up together. +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. diff --git a/docs/SCHEMATIC_DIFF.md b/docs/SCHEMATIC_DIFF.md index a4d863b..9929f31 100644 --- a/docs/SCHEMATIC_DIFF.md +++ b/docs/SCHEMATIC_DIFF.md @@ -87,3 +87,5 @@ Tests cover record reordering, component UID matching/renumbering, values, pure 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. diff --git a/frontend/compare-upload.html b/frontend/compare-upload.html index ca91340..b8c4eb5 100644 --- a/frontend/compare-upload.html +++ b/frontend/compare-upload.html @@ -1,2 +1,2 @@ -Compare Altium schematics

NATIVE ALTIUM · STRUCTURAL DIFF

Compare schematics.

Select the original and modified SchDoc files.

Comparison uses native objects and saved geometry. Connection graphics are compared without a compiled electrical netlist.

Single schematic viewer
+Compare Altium schematics

NATIVE ALTIUM · STRUCTURAL DIFF

Compare schematics.

Select the original and modified SchDoc files.

Comparison uses native objects and saved geometry. Connection graphics are compared without a compiled electrical netlist.

Single schematic viewer
diff --git a/frontend/compare-upload.js b/frontend/compare-upload.js index 0a766e0..5d06eb2 100644 --- a/frontend/compare-upload.js +++ b/frontend/compare-upload.js @@ -1,12 +1,15 @@ +import { mountShare } from './share.js?v=1'; const form=document.querySelector('#compare-upload'),result=document.querySelector('#result'); 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; 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 expiry=document.querySelector('#expires-in').value; + if(expiry)body.append('expires_in',expiry); try { 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'); - 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;} }); diff --git a/frontend/compare.html b/frontend/compare.html index ddba50d..b761f81 100644 --- a/frontend/compare.html +++ b/frontend/compare.html @@ -1,7 +1,7 @@ -Schematic comparison +Schematic comparison - +
Loading comparison… diff --git a/frontend/compare.js b/frontend/compare.js index 565af79..05fd367 100644 --- a/frontend/compare.js +++ b/frontend/compare.js @@ -1,3 +1,4 @@ +import { mountShare, viewerURL } from './share.js?v=1'; import { bindCamera } from './interactions.js'; const menuToggle=document.querySelector('#menu-toggle'); const menuPanels=document.querySelectorAll('[data-comparison-menu]'); @@ -103,6 +104,7 @@ async function start(){ 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));}); 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;}); diff --git a/frontend/docs.html b/frontend/docs.html index 1e33173..ae895ac 100644 --- a/frontend/docs.html +++ b/frontend/docs.html @@ -1 +1 @@ -Altium API reference

API V1

Altium rendering API

Authenticate uploads with X-API-Key. 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.

Upload

POST /api/v1/schematics/render

Multipart fields: file (required), access (private or unlisted; defaults to private), expires_in (optional seconds, bounded by server policy).

Poll

GET /api/v1/schematics/{id}/status

States: queued, processing, completed, failed. Private reads require X-Viewer-Token from the upload response, or the service API key. Completed responses include compatibility and diagnostics.

Retrieve

GET /api/v1/schematics/{id}/data returns the versioned structured document. GET /api/v1/schematics/{id}/svg returns the generated vector drawing.

View

Open the exact viewer_url 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.

Compare two schematics

POST /api/v1/comparisons (alias /api/compare) accepts multipart old_file and new_file with the same API key. It returns HTTP 202 and a private comparison URL. Poll GET /api/v1/comparisons/{id} using the returned read token for the completed summary and structural changes. Open comparison upload.

Preview a PCB

POST /api/v1/pcbs/render accepts multipart file (.PcbDoc), access and optional expires_in. It returns HTTP 202 with viewer_url, status_url and a private viewer_token when applicable.

Poll GET /api/v1/pcbs/{id}/status. Read GET /api/v1/pcbs/{id}/data or GET /api/v1/pcbs/{id}/svg. The viewer is /view/pcb/{id}.

Compare two PCBs

POST /api/v1/pcbs/comparisons accepts old_file and new_file (.PcbDoc) with the same upload authentication, access and expiry options. Poll GET /api/v1/pcbs/comparisons/{id}. Read GET /api/v1/pcbs/comparisons/{id}/svg/{side}, where side is old or new. Open the returned /compare/pcb/{id}#token=… URL for the side-by-side viewer.

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 X-Viewer-Token or the service key.

Errors

JSON errors include success: false and error.code / error.message. 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.

Download OpenAPI schema · API playground and file upload

+Altium API reference

API V1

Altium rendering API

Authenticate uploads with X-API-Key. 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.

Upload

POST /api/v1/schematics/render

Multipart fields: file (required), access (private or unlisted; defaults to private), expires_in (optional: -1 for unlimited, or positive seconds bounded by server policy).

Poll

GET /api/v1/schematics/{id}/status

States: queued, processing, completed, failed. Private reads require X-Viewer-Token from the upload response, or the service API key. Completed responses include compatibility and diagnostics.

Retrieve

GET /api/v1/schematics/{id}/data returns the versioned structured document. GET /api/v1/schematics/{id}/svg returns the generated vector drawing.

View

Open the exact viewer_url 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.

Compare two schematics

POST /api/v1/comparisons (alias /api/compare) accepts multipart old_file and new_file with the same API key. It returns HTTP 202 and a private comparison URL. Poll GET /api/v1/comparisons/{id} using the returned read token for the completed summary and structural changes. Open comparison upload.

Preview a PCB

POST /api/v1/pcbs/render accepts multipart file (.PcbDoc), access and optional expires_in. It returns HTTP 202 with viewer_url, status_url and a private viewer_token when applicable.

Poll GET /api/v1/pcbs/{id}/status. Read GET /api/v1/pcbs/{id}/data or GET /api/v1/pcbs/{id}/svg. The viewer is /view/pcb/{id}.

Compare two PCBs

POST /api/v1/pcbs/comparisons accepts old_file and new_file (.PcbDoc) with the same upload authentication, access and expiry options. Poll GET /api/v1/pcbs/comparisons/{id}. Read GET /api/v1/pcbs/comparisons/{id}/svg/{side}, where side is old or new. Open the returned /compare/pcb/{id}#token=… URL for the side-by-side viewer.

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 X-Viewer-Token or the service key.

Unlimited links and Share

All upload operations accept expires_in=-1 to retain files and links without automatic expiration. Responses use expires_at: -1. 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 https://git.sadentel.net.

Errors

JSON errors include success: false and error.code / error.message. 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.

Download OpenAPI schema · API playground and file upload

diff --git a/frontend/index.html b/frontend/index.html index f829b74..4e8ab9b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,14 +1,14 @@ -Altium API playground
+Altium API playground

NATIVE ALTIUM · SCHDOC + PCBDOC

Render or compare your design.

Test the rendering APIs with your own files. Uploads create expiring viewer links.

Test an API

POST /api/v1/schematics/render

Used for this upload only; never saved in browser storage.

-
-
- +
+

Use -1 to keep the files and viewer link indefinitely. Leave blank for the server default.

+

Compatibility is partial. Review parser diagnostics before relying on visual fidelity. Files are processed asynchronously; a queued response confirms acceptance.

Available APIs

Uploads use multipart/form-data and X-API-Key. Select a capability to test it.

POST /api/v1/schematics/render

File field: file · .SchDoc

GET /api/v1/schematics/{id}/statusGET /api/v1/schematics/{id}/dataGET /api/v1/schematics/{id}/svgGET /view/{id}
diff --git a/frontend/pcb.html b/frontend/pcb.html index 13ac84e..40bae15 100644 --- a/frontend/pcb.html +++ b/frontend/pcb.html @@ -1,5 +1,5 @@ -PCB Viewer +PCB Viewer - +
Loading PCB… diff --git a/frontend/pcb.js b/frontend/pcb.js index 0677f71..1298da2 100644 --- a/frontend/pcb.js +++ b/frontend/pcb.js @@ -1,3 +1,4 @@ +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/'); @@ -80,6 +81,7 @@ async function start(){ 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(); } - function expire(){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(); + 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;}); diff --git a/frontend/share.css b/frontend/share.css new file mode 100644 index 0000000..1a9058b --- /dev/null +++ b/frontend/share.css @@ -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; } diff --git a/frontend/share.js b/frontend/share.js new file mode 100644 index 0000000..86318f2 --- /dev/null +++ b/frontend/share.js @@ -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 ``; +} +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); +} diff --git a/frontend/share.test.js b/frontend/share.test.js new file mode 100644 index 0000000..218619d --- /dev/null +++ b/frontend/share.test.js @@ -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); +}); diff --git a/frontend/upload.js b/frontend/upload.js index dbca059..c6f7a65 100644 --- a/frontend/upload.js +++ b/frontend/upload.js @@ -1,3 +1,4 @@ +import { mountShare } from './share.js?v=1'; const operations = { schematic: { endpoint:'/api/v1/schematics/render', suffix:'.SchDoc', label:'Render schematic', fileLabel:'Schematic file', comparison:false }, 'schematic-comparison': { endpoint:'/api/v1/comparisons', suffix:'.SchDoc', label:'Compare schematics', comparison:true }, @@ -10,7 +11,7 @@ const fields=document.querySelector('#upload-fields'), viewerLink=document.query const details=document.querySelector('#response-details'), json=document.querySelector('#response-json'); let polling=null, uploading=false; function reset(){ - polling?.abort();polling=null;result.textContent='';viewerLink.replaceChildren();details.hidden=true;json.textContent=''; + polling?.abort();polling=null;result.textContent='';viewerLink.replaceChildren();details.hidden=true;json.textContent='';document.querySelector('#upload-share').replaceChildren(); } function configure(){ if(uploading)return; @@ -73,14 +74,14 @@ form.addEventListener('submit',async event=>{ } data.append('access',document.querySelector('#access').value); const expiry=document.querySelector('#expires-in').value; - if(expiry){if(!/^\d+$/.test(expiry)||Number(expiry)<1){result.textContent='Expiry must be a positive whole number of seconds.';return;}data.append('expires_in',expiry);} + 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); + 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.`;}); diff --git a/frontend/viewer.html b/frontend/viewer.html index 17792b3..6f6d85f 100644 --- a/frontend/viewer.html +++ b/frontend/viewer.html @@ -1,2 +1,2 @@ -Schematic
Loading schematic… +Schematic
Loading schematic… diff --git a/frontend/viewer.js b/frontend/viewer.js index 6f07610..e791fb3 100644 --- a/frontend/viewer.js +++ b/frontend/viewer.js @@ -1,3 +1,4 @@ +import { mountShare, viewerURL } from './share.js?v=1'; import { Camera } from './camera.js'; const viewport = document.querySelector('#viewport'); 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(); }).observe(viewport); // Enforce configured expiry even when a viewer remains open. + mountShare(document.querySelector('#viewer-share'),viewerURL(location.pathname,token)); function expire() { + if (result.expires_at === -1) return; const remaining = result.expires_at*1000-Date.now(); if (remaining <= 0) { viewport.replaceChildren(); state.textContent = 'This schematic link has expired'; } else setTimeout(expire,Math.min(2147483647,remaining));