اضافه شدن قابلیت share و نگهداری بلندمدت
Verify / test (push) Failing after 22s

This commit is contained in:
2026-11-09 14:55:36 +03:30
والد a4e8f0c6c0
کامیت 19824bd180
27فایلهای تغییر یافته به همراه267 افزوده شده و 51 حذف شده
+3 -1
مشاهده پرونده
@@ -114,7 +114,7 @@ Full settings, credentials, optional encryption, migration instructions and the
| `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 |
@@ -155,3 +155,5 @@ 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. 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). 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).
+1 -1
مشاهده پرونده
@@ -11,7 +11,7 @@ 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:
+7 -7
مشاهده پرونده
@@ -10,7 +10,8 @@ from fastapi.responses import FileResponse,JSONResponse,Response
from sqlalchemy import select,func,text from sqlalchemy import select,func,text
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from .config import settings from .config import settings
from .db import Job,sessions from .expiry import expiration
from .db import Job,sessions,active_jobs
from .storage import storage from .storage import storage
from .uploads import read_schematic from .uploads import read_schematic
from .models.api import ComparisonStatus,ComparisonUploadResponse from .models.api import ComparisonStatus,ComparisonUploadResponse
@@ -37,20 +38,19 @@ def register_comparison_routes(app, *, authorize, require_api, error):
@app.post('/api/v1/comparisons',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons']) @app.post('/api/v1/comparisons',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons'])
async def create(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()], async def create(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()],
access:Annotated[Literal['private','unlisted'],Form()]='private', access:Annotated[Literal['private','unlisted'],Form()]='private',
expires_in:Annotated[int|None,Form(gt=0)]=None, expires_in:Annotated[int|None,Form(ge=-1, description="-1 for no expiry, or positive seconds")]=None,
x_api_key:Annotated[str|None,Header()]=None): x_api_key:Annotated[str|None,Header()]=None):
require_api(x_api_key) require_api(x_api_key)
ttl=expires_in or settings().link_ttl now=time.time();expires_at=expiration(expires_in,now)
if ttl>settings().link_ttl:error('invalid_expiry','expires_in exceeds the configured link lifetime',422)
try: try:
old_name,old=await read_schematic(old_file) old_name,old=await read_schematic(old_file)
new_name,new=await read_schematic(new_file) new_name,new=await read_schematic(new_file)
finally: finally:
await old_file.close();await new_file.close() await old_file.close();await new_file.close()
identifier=secrets.token_hex(24);token=secrets.token_urlsafe(32);now=time.time() identifier=secrets.token_hex(24);token=secrets.token_urlsafe(32)
job=Job(id=identifier,filename='Schematic comparison',status='queued',access=access, job=Job(id=identifier,filename='Schematic comparison',status='queued',access=access,
token_hash=hashlib.sha256(token.encode()).hexdigest(),created_at=now,updated_at=now, token_hash=hashlib.sha256(token.encode()).hexdigest(),created_at=now,updated_at=now,
expires_at=now+ttl,attempt=0,input_key=identifier+'/comparison.json') expires_at=expires_at,attempt=0,input_key=identifier+'/comparison.json')
files={identifier+'/old.SchDoc':old,identifier+'/new.SchDoc':new, files={identifier+'/old.SchDoc':old,identifier+'/new.SchDoc':new,
job.input_key:json.dumps({'schema_version':'1.0','old_filename':old_name,'new_filename':new_name}).encode()} job.input_key:json.dumps({'schema_version':'1.0','old_filename':old_name,'new_filename':new_name}).encode()}
def persist(): def persist():
@@ -58,7 +58,7 @@ def register_comparison_routes(app, *, authorize, require_api, error):
try: try:
with sessions().begin() as db: with sessions().begin() as db:
if db.bind.dialect.name=='postgresql':db.execute(text('SELECT pg_advisory_xact_lock(84721309)')) if db.bind.dialect.name=='postgresql':db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
pending=db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),Job.expires_at>now)) pending=db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),active_jobs(now)))
if pending>=settings().max_pending:error('queue_full','Rendering queue is full; try again later',503) if pending>=settings().max_pending:error('queue_full','Rendering queue is full; try again later',503)
for key,body in files.items(): for key,body in files.items():
try: storage().put(key,body) try: storage().put(key,body)
+5 -2
مشاهده پرونده
@@ -6,7 +6,7 @@ to Redis. Versioned artifact keys fence out expired worker leases.
""" """
import time import time
from functools import lru_cache from functools import lru_cache
from sqlalchemy import create_engine, String, Text, Float, Integer, select, update, func, text from sqlalchemy import create_engine, String, Text, Float, Integer, select, update, func, text, or_
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
from .config import settings from .config import settings
@@ -39,6 +39,9 @@ def engine():
@lru_cache @lru_cache
def sessions(): return sessionmaker(engine(), expire_on_commit=False) def sessions(): return sessionmaker(engine(), expire_on_commit=False)
def active_jobs(now):
return or_(Job.expires_at == -1, Job.expires_at > now)
def initialize(): def initialize():
# Initial schema only. Subsequent schema changes must use explicit migrations. # Initial schema only. Subsequent schema changes must use explicit migrations.
if engine().dialect.name == 'postgresql': if engine().dialect.name == 'postgresql':
@@ -55,7 +58,7 @@ def claim():
now = time.time() now = time.time()
with sessions().begin() as db: with sessions().begin() as db:
# Expired jobs are never revived. A crashed worker can be retried once. # Expired jobs are never revived. A crashed worker can be retried once.
eligible = ((Job.status == 'queued') | ((Job.status == 'processing') & (Job.lease_until < now))) & (Job.expires_at > now) eligible = ((Job.status == 'queued') | ((Job.status == 'processing') & (Job.lease_until < now))) & active_jobs(now)
q = select(Job).where(eligible).order_by(Job.created_at).limit(1) q = select(Job).where(eligible).order_by(Job.created_at).limit(1)
if engine().dialect.name == 'postgresql': q = q.with_for_update(skip_locked=True) if engine().dialect.name == 'postgresql': q = q.with_for_update(skip_locked=True)
job = db.scalar(q) job = db.scalar(q)
+15
مشاهده پرونده
@@ -0,0 +1,15 @@
"""Link expiry policy. -1 is a persistent no-expiry sentinel, not a timestamp."""
from fastapi import HTTPException
from .config import settings
NEVER_EXPIRES = -1
def expiration(expires_in, now):
if expires_in == NEVER_EXPIRES:
return NEVER_EXPIRES
ttl = settings().link_ttl if expires_in is None else expires_in
if ttl <= 0 or ttl > settings().link_ttl:
raise HTTPException(422, {'code': 'invalid_expiry',
'message': 'expires_in must be -1 (no expiry) or positive seconds within the configured lifetime'})
return now + ttl
+9 -9
مشاهده پرونده
@@ -16,7 +16,8 @@ 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
@@ -119,7 +120,7 @@ def authorize(identifier, key, token, *, comparison=False, resource_kind=None):
# 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):
@@ -136,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)
@@ -203,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','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) return FileResponse(FRONTEND / name)
@app.get('/docs', include_in_schema=False) @app.get('/docs', include_in_schema=False)
+1 -1
مشاهده پرونده
@@ -15,7 +15,7 @@ class RenderStatus(BaseModel):
id: str = Field(description='Unpredictable 192-bit identifier, encoded as 48 hex characters') id: str = Field(description='Unpredictable 192-bit identifier, encoded as 48 hex characters')
status: Literal['queued','processing','completed','failed'] status: Literal['queued','processing','completed','failed']
viewer_url: str = Field(description='Base viewer URL; only initial upload includes private token fragment') viewer_url: str = Field(description='Base viewer URL; only initial upload includes private token fragment')
expires_at: float = Field(description='Unix timestamp, UTC seconds') expires_at: float = Field(description='Unix timestamp in UTC seconds; -1 means no expiration')
access: Literal['private','unlisted'] access: Literal['private','unlisted']
schema_version: str | None = None schema_version: str | None = None
compatibility: Literal['partial','supported_subset'] | None = None compatibility: Literal['partial','supported_subset'] | None = None
+8 -8
مشاهده پرونده
@@ -11,7 +11,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_pcb from .uploads import read_pcb
from .models.pcb import PCBData, PCBStatus, PCBUploadResponse, PCBComparisonStatus, PCBComparisonUploadResponse from .models.pcb import PCBData, PCBStatus, PCBUploadResponse, PCBComparisonStatus, PCBComparisonUploadResponse
@@ -34,18 +35,17 @@ def register_pcb_routes(app, *, authorize, require_api, error):
return result return result
async def create(files, access, expires_in, comparison): async def create(files, access, expires_in, comparison):
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 configured link lifetime',422)
sources = {} sources = {}
try: try:
for field, file in files.items(): sources[field] = await read_pcb(file) for field, file in files.items(): sources[field] = await read_pcb(file)
finally: finally:
for file in files.values(): await file.close() 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' input_name = 'pcb-comparison.json' if comparison else 'input.PcbDoc'
job = Job(id=identifier, filename='PCB comparison' if comparison else sources['file'][0], job = Job(id=identifier, filename='PCB comparison' if comparison else sources['file'][0],
status='queued', access=access, token_hash=hashlib.sha256(token.encode()).hexdigest(), 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: if comparison:
bodies = {f'{identifier}/{side}.PcbDoc':sources[side+'_file'][1] for side in ('old','new')} 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', 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: 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 bodies.items(): for key, body in bodies.items():
# Include the attempted key so a write-then-raise backend is also cleaned up. # 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']) @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', 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) require_api(x_api_key)
return await create({'file':file},access,expires_in,False) 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']) @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()], async def compare(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,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) require_api(x_api_key)
return await create({'old_file':old_file,'new_file':new_file},access,expires_in,True) return await create({'old_file':old_file,'new_file':new_file},access,expires_in,True)
@@ -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
+20 -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.
@@ -49,3 +49,21 @@ A worker crash is retried after its lease expires, at most twice total. A parse
## PCB preview and comparison ## 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). `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>
```
+3 -1
مشاهده پرونده
@@ -27,7 +27,7 @@ curl -X POST https://render.sadentel.net/api/v1/pcbs/comparisons \
-F 'access=private' -F 'expires_in=3600' -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): 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. 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. 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).
+1 -1
مشاهده پرونده
@@ -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. 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. 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.
+2
مشاهده پرونده
@@ -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. 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. Implementation verification: 115 Python tests and 6 frontend tests passed with the native fixtures enabled. The Docker image built successfully, the Caddy configuration adapted successfully, and real HTTP comparison plus single-file smoke checks passed against PostgreSQL with two worker replicas. The comparison result and SVG routes enforced private read tokens. A connected browser was unavailable, so interactive workspace appearance and browser behavior are not claimed as verified.
Comparison uploads also accept `expires_in=-1` for unlimited storage and link validity, with `expires_at: -1` in responses. The workspace menu and upload result include Share for a token-preserving iframe snippet; existing access and framing restrictions remain enforced.
+1 -1
مشاهده پرونده
@@ -1,2 +1,2 @@
<!doctype html> <!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Compare Altium schematics</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/compare-upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · STRUCTURAL DIFF</p><h1>Compare schematics.</h1><p>Select the original and modified SchDoc files.</p><form id="compare-upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="old-file">Original schematic</label><input id="old-file" type="file" accept=".SchDoc" required><label for="new-file">Modified schematic</label><input id="new-file" type="file" accept=".SchDoc" required><button type="submit">Compare schematics <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Comparison uses native objects and saved geometry. Connection graphics are compared without a compiled electrical netlist.</p><a href="/">Single schematic viewer</a></main></body></html> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Compare Altium schematics</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/compare-upload.js?v=expiry-share-1"></script><link rel="stylesheet" href="/assets/share.css?v=1"></head><body><main><p class="eyebrow">NATIVE ALTIUM · STRUCTURAL DIFF</p><h1>Compare schematics.</h1><p>Select the original and modified SchDoc files.</p><form id="compare-upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="old-file">Original schematic</label><input id="old-file" type="file" accept=".SchDoc" required><label for="new-file">Modified schematic</label><input id="new-file" type="file" accept=".SchDoc" required><label for="expires-in">Expires in (seconds; -1 for unlimited)</label><input id="expires-in" type="number" min="-1" step="1" placeholder="Server default"><button type="submit">Compare schematics <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><div id="upload-share"></div><p class="note">Comparison uses native objects and saved geometry. Connection graphics are compared without a compiled electrical netlist.</p><a href="/">Single schematic viewer</a></main></body></html>
+5 -2
مشاهده پرونده
@@ -1,12 +1,15 @@
import { mountShare } from './share.js?v=1';
const form=document.querySelector('#compare-upload'),result=document.querySelector('#result'); const form=document.querySelector('#compare-upload'),result=document.querySelector('#result');
form.addEventListener('submit',async e=>{ form.addEventListener('submit',async e=>{
e.preventDefault();const button=form.querySelector('button');button.disabled=true;result.textContent='Uploading both schematics…'; e.preventDefault();document.querySelector('#upload-share').replaceChildren();const button=form.querySelector('button');button.disabled=true;result.textContent='Uploading both schematics…';
const headers={},key=document.querySelector('#key').value; const headers={},key=document.querySelector('#key').value;
if(key)headers['X-API-Key']=key; if(key)headers['X-API-Key']=key;
const body=new FormData();body.append('old_file',document.querySelector('#old-file').files[0]);body.append('new_file',document.querySelector('#new-file').files[0]); const body=new FormData();body.append('old_file',document.querySelector('#old-file').files[0]);body.append('new_file',document.querySelector('#new-file').files[0]);
const expiry=document.querySelector('#expires-in').value;
if(expiry)body.append('expires_in',expiry);
try { try {
const response=await fetch('/api/v1/comparisons',{method:'POST',headers,body});const data=await response.json(); const response=await fetch('/api/v1/comparisons',{method:'POST',headers,body});const data=await response.json();
if(!response.ok)throw new Error(data.error?.message||'Comparison upload failed'); if(!response.ok)throw new Error(data.error?.message||'Comparison upload failed');
result.textContent='Queued. ';const link=document.createElement('a');link.href=data.url;link.textContent='Open comparison';result.append(link); result.textContent='Queued. ';const link=document.createElement('a');link.href=data.url;link.textContent='Open comparison';result.append(link);mountShare(document.querySelector('#upload-share'),data.viewer_url||data.url);
}catch(error){result.textContent=error.message;}finally{button.disabled=false;} }catch(error){result.textContent=error.message;}finally{button.disabled=false;}
}); });
+2 -2
مشاهده پرونده
@@ -1,7 +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?v=fullscreen-menu-3"><script type="module" src="/assets/compare.js?v=fullscreen-menu-3"></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>
<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> <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>
<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><button id="fit" type="button">Fit</button></div></header> <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>
<p id="scope" data-comparison-menu hidden>Native object comparison · Connection graphics, not a compiled electrical netlist.</p> <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> <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>
+3 -1
مشاهده پرونده
@@ -1,3 +1,4 @@
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 menuToggle=document.querySelector('#menu-toggle');
const menuPanels=document.querySelectorAll('[data-comparison-menu]'); 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)); document.querySelector('#previous').addEventListener('click',()=>next(-1));document.querySelector('#next').addEventListener('click',()=>next(1));
for(const svg of svgs)svg.addEventListener('click',event=>{const id=event.target.closest('[data-change-id]')?.dataset.changeId;if(byId.has(id))select(byId.get(id));}); for(const svg of svgs)svg.addEventListener('click',event=>{const id=event.target.closest('[data-change-id]')?.dataset.changeId;if(byId.has(id))select(byId.get(id));});
applyFilters(); applyFilters();
function expire(){const remaining=result.expires_at*1000-Date.now();if(remaining<=0){for(const view of views)view.destroy();document.querySelector('.workspace').replaceChildren();state.textContent='This comparison link has expired';}else setTimeout(expire,Math.min(2147483647,remaining));}expire(); mountShare(document.querySelector('#comparison-share'),viewerURL(location.pathname,token));
function expire(){if(result.expires_at===-1)return;const remaining=result.expires_at*1000-Date.now();if(remaining<=0){for(const view of views)view.destroy();document.querySelector('.workspace').replaceChildren();state.textContent='This comparison link has expired';}else setTimeout(expire,Math.min(2147483647,remaining));}expire();
} }
start().catch(error=>{state.classList.remove('loading');state.textContent=error.name==='AbortError'?'':error.message;}); start().catch(error=>{state.classList.remove('loading');state.textContent=error.name==='AbortError'?'':error.message;});
+1 -1
مشاهده پرونده
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>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 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>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> <!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>
+4 -4
مشاهده پرونده
@@ -1,14 +1,14 @@
<!doctype html> <!doctype 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=pcb-playground-1"></script></head><body class="playground"><main> <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> <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> <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> <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> <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="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 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> <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>
</fieldset><button id="submit-upload" type="submit">Render schematic <span aria-hidden="true">↗</span></button></form> <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><details id="response-details" hidden><summary>API response</summary><pre id="response-json"></pre></details> <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> <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> <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">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>
+2 -2
مشاهده پرونده
@@ -1,5 +1,5 @@
<!doctype html> <!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=1"></script></head><body> <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> <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><button id="fit" type="button">Fit</button></div></header> <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> <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>
+3 -1
مشاهده پرونده
@@ -1,3 +1,4 @@
import { mountShare, viewerURL } from './share.js?v=1';
import { bindCamera } from './interactions.js'; import { bindCamera } from './interactions.js';
import { layerVisible, presetLayers } from './pcb-layers.js'; import { layerVisible, presetLayers } from './pcb-layers.js';
const comparison=location.pathname.startsWith('/compare/pcb/'); 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);}); 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(); 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;}); start().catch(e=>{state.classList.remove('loading');state.textContent=e.name==='AbortError'?'':e.message;});
+13
مشاهده پرونده
@@ -0,0 +1,13 @@
.share-widget { position: relative; display: inline-block; color: #233431; font: 13px system-ui,sans-serif; text-align: left; }
.share-widget button { display: inline-block; margin: 0; padding: 7px 12px; border: 1px solid #b6c8bf; border-radius: 4px; background: #fff; color: #244d49; font: inherit; cursor: pointer; }
.share-widget button:focus-visible, .share-widget textarea:focus-visible { outline: 2px solid #2676c5; outline-offset: 2px; }
.share-panel { position: absolute; top: calc(100% + 8px); left: 0; width: min(430px,calc(100vw - 40px)); max-height: 75vh; overflow: auto; z-index: 30; padding: 14px; border: 1px solid #cbd8d1; border-radius: 5px; background: #fff; box-shadow: 0 6px 24px #0002; }
.share-panel[hidden] { display: none !important; }
.share-panel p { margin: 0 0 10px; font: inherit; line-height: 1.5; color: inherit; }
.share-panel textarea { display: block; width: 100%; resize: vertical; padding: 10px; border: 1px solid #cbd8d1; border-radius: 3px; background: #f6f8f6; color: #233431; font: 12px/1.5 monospace; direction: ltr; box-sizing: border-box; }
.share-panel .share-note { margin-top: 10px; font-size: 11px; color: #60716b; }
.share-panel [role="status"] { display: block; margin-top: 8px; font-size: 11px; }
#viewer-share { position: fixed; top: 12px; right: 12px; z-index: 20; }
#viewer-share .share-panel { left: auto; right: 0; }
#comparison-share .share-panel, #pcb-share .share-panel { left: auto; right: 0; }
#upload-share { margin-top: 12px; }
+31
مشاهده پرونده
@@ -0,0 +1,31 @@
/** Construct inert embed markup, retaining the read capability only in the URL fragment. */
export function viewerURL(value, token, base = location.origin) {
const url = new URL(value, base);
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('Invalid viewer URL');
if (url.username || url.password) throw new Error('Invalid viewer URL');
if (token) url.hash = new URLSearchParams({ token }).toString();
return url.href;
}
export function iframeCode(value, base = location.origin) {
const url = viewerURL(value, null, base);
const escaped = url.replace(/[&"<>']/g, c => ({'&':'&amp;','"':'&quot;','<':'&lt;','>':'&gt;',"'":'&#39;'}[c]));
return `<iframe\n src="${escaped}"\n width="100%"\n height="800"\n title="Altium design viewer"\n loading="lazy"\n referrerpolicy="no-referrer"\n allowfullscreen\n></iframe>`;
}
export function mountShare(host, value) {
const code = iframeCode(value), widget = document.createElement('div');widget.className = 'share-widget';
const toggle = document.createElement('button');toggle.type = 'button';toggle.className = 'share-toggle';toggle.textContent = 'Share';toggle.setAttribute('aria-expanded','false');
const panel = document.createElement('div');panel.className = 'share-panel';panel.hidden = true;
const caption = document.createElement('p');caption.textContent = 'Embed this viewer in Gitea or this service:';
const text = document.createElement('textarea');text.readOnly = true;text.value = code;text.rows = 9;text.setAttribute('aria-label','Viewer iframe code');
const note = document.createElement('p');note.className = 'share-note';
note.textContent = new URL(value,location.origin).hash.includes('token=') ? 'This code includes a read token. Anyone with the code can view this design.' : 'Embedding is allowed only on this service and https://git.sadentel.net.';
const copy = document.createElement('button');copy.type = 'button';copy.textContent = 'Copy iframe';
const status = document.createElement('span');status.setAttribute('role','status');
copy.addEventListener('click',async()=>{
try { await navigator.clipboard.writeText(code);status.textContent = 'Copied'; }
catch { text.focus();text.select();status.textContent = 'Select and copy the code manually.'; }
});
toggle.addEventListener('click',()=>{panel.hidden=!panel.hidden;toggle.setAttribute('aria-expanded',String(!panel.hidden));});
widget.addEventListener('keydown',event=>{if(event.key==='Escape'){panel.hidden=true;toggle.setAttribute('aria-expanded','false');toggle.focus();}});
panel.append(caption,text,note,copy,status);widget.append(toggle,panel);host.append(widget);
}
+46
مشاهده پرونده
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {iframeCode,viewerURL,mountShare} from './share.js';
const base='https://render.sadentel.net';
for(const path of ['/view/','/compare/','/view/pcb/','/compare/pcb/']){
test(`share ${path} preserves the private capability`,()=>{
const url=viewerURL(path+'a'.repeat(48),'read-token',base);
const code=iframeCode(url,base);
assert.ok(code.includes(`src="${base}${path}${'a'.repeat(48)}#token=read-token"`));
assert.ok(code.includes('width="100%"')&&code.includes('height="800"'));
assert.equal(new URL(url).search,'');
assert.ok(!code.includes('X-API-Key'));
});
}
test('unlisted iframe does not gain a token',()=>{
assert.ok(!iframeCode('/view/'+'a'.repeat(48),base).includes('#token='));
});
test('iframe escapes attributes and rejects executable URL schemes',()=>{
const code=iframeCode(base+'/view/test?x=1&y=2#token=" onload="bad',base);
assert.ok(!code.includes(' onload="bad'));
assert.ok(code.includes('%22') && code.includes('&amp;'));
for(const url of ['javascript:alert(1)','data:text/html,hello','https://user:secret@example.test/view/id'])assert.throws(()=>iframeCode(url,base));
});
test('Share reveals the iframe and copies its read token without sending a request',async t=>{
class Element {
constructor(){this.children=[];this.events={};this.attributes={};}
append(...items){this.children.push(...items);}
setAttribute(key,value){this.attributes[key]=value;}
addEventListener(name,callback){this.events[name]=callback;}
focus(){} select(){}
}
const saved=new Map(['document','location','navigator'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
let copied='';
Object.defineProperty(globalThis,'document',{configurable:true,value:{createElement:()=>new Element()}});
Object.defineProperty(globalThis,'location',{configurable:true,value:{origin:base}});
Object.defineProperty(globalThis,'navigator',{configurable:true,value:{clipboard:{writeText:async code=>{copied=code;}}}});
t.after(()=>{for(const [key,descriptor]of saved)if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];});
const host=new Element();mountShare(host,base+'/view/'+'a'.repeat(48)+'#token=private-read');
const [toggle,panel]=host.children[0].children;
assert.equal(panel.hidden,true);toggle.events.click();assert.equal(panel.hidden,false);
const textarea=panel.children[1],copy=panel.children[3];
assert.ok(textarea.value.includes('#token=private-read'));assert.equal(textarea.readOnly,true);
await copy.events.click();assert.equal(copied,textarea.value);
toggle.events.click();assert.equal(panel.hidden,true);
});
+4 -3
مشاهده پرونده
@@ -1,3 +1,4 @@
import { mountShare } from './share.js?v=1';
const operations = { const operations = {
schematic: { endpoint:'/api/v1/schematics/render', suffix:'.SchDoc', label:'Render schematic', fileLabel:'Schematic file', comparison:false }, 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 }, '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'); const details=document.querySelector('#response-details'), json=document.querySelector('#response-json');
let polling=null, uploading=false; let polling=null, uploading=false;
function reset(){ 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(){ function configure(){
if(uploading)return; if(uploading)return;
@@ -73,14 +74,14 @@ form.addEventListener('submit',async event=>{
} }
data.append('access',document.querySelector('#access').value); data.append('access',document.querySelector('#access').value);
const expiry=document.querySelector('#expires-in').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; 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…'; uploading=true;button.disabled=true;fields.disabled=true;result.textContent='Uploading…';
try{ try{
const created=await decode(await fetch(config.endpoint,{method:'POST',headers,body:data}));showResponse(created); 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); 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.'); 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.'; result.textContent='Queued. You can open the viewer while processing continues.';
const controller=new AbortController();polling=controller; 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.`;}); poll(created,controller).catch(error=>{if(error.name!=='AbortError'&&!controller.signal.aborted)result.textContent=`${error.message} The accepted upload is not retried automatically.`;});
+1 -1
مشاهده پرونده
@@ -1,2 +1,2 @@
<!doctype html> <!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"><title>Schematic</title><link rel="stylesheet" href="/fonts.css"><link rel="stylesheet" href="/assets/viewer.css"><script type="module" src="/assets/viewer.js"></script></head><body><main id="viewport" aria-label="Schematic viewer" tabindex="0"></main><output id="state" class="loading" role="status">Loading schematic…</output></body></html> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"><title>Schematic</title><link rel="stylesheet" href="/fonts.css"><link rel="stylesheet" href="/assets/viewer.css"><script type="module" src="/assets/viewer.js?v=expiry-share-1"></script><link rel="stylesheet" href="/assets/share.css?v=1"></head><body><main id="viewport" aria-label="Schematic viewer" tabindex="0"></main><div id="viewer-share"></div><output id="state" class="loading" role="status">Loading schematic…</output></body></html>
+3
مشاهده پرونده
@@ -1,3 +1,4 @@
import { mountShare, viewerURL } from './share.js?v=1';
import { Camera } from './camera.js'; import { Camera } from './camera.js';
const viewport = document.querySelector('#viewport'); const viewport = document.querySelector('#viewport');
const state = document.querySelector('#state'); const state = document.querySelector('#state');
@@ -87,7 +88,9 @@ async function start() {
camera.x = camera.width/2-center.x*camera.scale; camera.y = camera.height/2-center.y*camera.scale; paint(); camera.x = camera.width/2-center.x*camera.scale; camera.y = camera.height/2-center.y*camera.scale; paint();
}).observe(viewport); }).observe(viewport);
// Enforce configured expiry even when a viewer remains open. // Enforce configured expiry even when a viewer remains open.
mountShare(document.querySelector('#viewer-share'),viewerURL(location.pathname,token));
function expire() { function expire() {
if (result.expires_at === -1) return;
const remaining = result.expires_at*1000-Date.now(); const remaining = result.expires_at*1000-Date.now();
if (remaining <= 0) { viewport.replaceChildren(); state.textContent = 'This schematic link has expired'; } if (remaining <= 0) { viewport.replaceChildren(); state.textContent = 'This schematic link has expired'; }
else setTimeout(expire,Math.min(2147483647,remaining)); else setTimeout(expire,Math.min(2147483647,remaining));