mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Merge branch 'maziggy:main' into feature/accurate-usage-tracking
This commit is contained in:
@@ -79,6 +79,7 @@ jobs:
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
severity: 'CRITICAL,HIGH,MEDIUM'
|
||||
version: 'v0.69.1'
|
||||
|
||||
- name: Upload Trivy results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
@@ -95,6 +96,7 @@ jobs:
|
||||
format: 'sarif'
|
||||
output: 'trivy-config-results.sarif'
|
||||
severity: 'CRITICAL,HIGH,MEDIUM'
|
||||
version: 'v0.69.1'
|
||||
|
||||
- name: Upload Trivy config results
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
|
||||
+43
-1
@@ -3,7 +3,24 @@
|
||||
All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
|
||||
## [0.1.8b] - Not released
|
||||
## [0.1.8] - Not released
|
||||
|
||||
### Security
|
||||
- **XML External Entity (XXE) Prevention**:
|
||||
- Replaced `xml.etree.ElementTree` with `defusedxml` across all 3MF parsing code
|
||||
- Prevents XXE attacks through malicious 3MF files
|
||||
- Detected by Bandit B314 security scanner
|
||||
- **Path Injection Vulnerabilities Fixed**:
|
||||
- Added path traversal validation to project attachment endpoints
|
||||
- Strengthened filename sanitization in timelapse processing
|
||||
- Prevents directory traversal attacks via `../` sequences
|
||||
- Detected by CodeQL security scanner
|
||||
- **Security Scanning in CI/CD**:
|
||||
- Added Bandit (Python security analyzer) with SARIF upload to GitHub Security
|
||||
- Added Trivy (container/IaC scanner) for Docker image and Dockerfile analysis
|
||||
- Added pip-audit and npm-audit for dependency vulnerability scanning
|
||||
- Automatic GitHub issue creation for detected vulnerabilities
|
||||
- Security scan results visible in GitHub Security tab
|
||||
|
||||
### Enhanced
|
||||
- **3D Model Viewer Improvements** (PR #262):
|
||||
@@ -53,6 +70,31 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- Previously used `find()` which always returned the first match regardless of color
|
||||
- Fixed in both backend (print_scheduler.py) and frontend (useFilamentMapping.ts)
|
||||
- Resolves wrong tray selection (e.g., A4 instead of B1) when multiple AMS units have same filament type
|
||||
- **A1/A1 Mini FTP Upload Failures** (Issue #271):
|
||||
- Fixed FTP uploads hanging/timing out on A1 and A1 Mini printers
|
||||
- Replaced `storbinary()` with manual chunked transfer using `transfercmd()`
|
||||
- A1's FTP server has issues with Python's `storbinary()` waiting for completion response
|
||||
- Uses 1MB chunks with explicit 120s socket timeout for reliable transfers
|
||||
- Works for all printer models (X1C, P1S, P1P, A1, A1 Mini)
|
||||
- **P1S/P1P FTP Upload Failures**:
|
||||
- Fixed FTP uploads failing with EOFError on P1S and P1P printers
|
||||
- These printers use vsFTPd which requires SSL session reuse on data channel
|
||||
- Removed P1S/P1P from skip-session-reuse list (they were incorrectly added)
|
||||
- **FTP Auto-Detection for A1 Printers**:
|
||||
- Automatically detects working FTP mode (prot_p vs prot_c) for A1/A1 Mini
|
||||
- Tries encrypted data channel first, falls back to clear if needed
|
||||
- Caches working mode per printer IP to avoid repeated detection
|
||||
- **Safari Camera Stream Failing**:
|
||||
- Fixed camera streams not loading in Safari due to Service Worker error
|
||||
- Safari has stricter Service Worker scope requirements
|
||||
- **Queue Print Time for Multi-Plate Files** (PR #274):
|
||||
- Fixed print time showing total for all plates instead of selected plate
|
||||
- Now extracts per-plate print time from 3MF slice_info.config
|
||||
- Contributed by MisterBeardy
|
||||
- **Docker Permissions**:
|
||||
- Added user directive to docker-compose.yml using PUID/PGID environment variables
|
||||
- Allows container to run as host user, fixing permission issues with bind-mounted volumes
|
||||
- Usage: `PUID=$(id -u) PGID=$(id -g) docker compose up -d`
|
||||
|
||||
### Added
|
||||
- **Windows Portable Launcher** (contributed by nmori):
|
||||
|
||||
@@ -1590,7 +1590,10 @@ async def process_timelapse(
|
||||
raise HTTPException(400, "Audio must be .mp3, .wav, .m4a, .aac, or .ogg")
|
||||
|
||||
audio_content = await audio.read()
|
||||
suffix = Path(audio.filename).suffix
|
||||
# Extract and validate suffix to prevent path injection
|
||||
suffix = Path(audio.filename).suffix.lower()
|
||||
if suffix not in (".mp3", ".wav", ".m4a", ".aac", ".ogg"):
|
||||
raise HTTPException(400, "Invalid audio file extension")
|
||||
audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}{suffix}"
|
||||
audio_temp_path.write_bytes(audio_content)
|
||||
|
||||
@@ -1605,8 +1608,11 @@ async def process_timelapse(
|
||||
else:
|
||||
# Save as new file alongside original
|
||||
filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
|
||||
# Sanitize filename
|
||||
# Sanitize filename - remove path separators and traversal sequences
|
||||
filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
|
||||
# Prevent path traversal
|
||||
if ".." in filename or not filename or filename.startswith("."):
|
||||
filename = f"timelapse_{archive_id}_edited"
|
||||
if not filename.endswith(".mp4"):
|
||||
filename += ".mp4"
|
||||
output_path = archive_dir / filename
|
||||
@@ -1833,7 +1839,8 @@ async def get_archive_capabilities(
|
||||
):
|
||||
"""Check what viewing capabilities are available for this 3MF file."""
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import defusedxml.ElementTree as ET
|
||||
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
@@ -2113,7 +2120,7 @@ async def get_plate_preview(
|
||||
plate_num = 1
|
||||
if "Metadata/slice_info.config" in names:
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
import defusedxml.ElementTree as ET
|
||||
|
||||
slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
|
||||
root = ET.fromstring(slice_content)
|
||||
@@ -2254,7 +2261,8 @@ async def get_archive_plates(
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import defusedxml.ElementTree as ET
|
||||
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
@@ -2560,7 +2568,7 @@ async def get_filament_requirements(
|
||||
archive_id: The archive ID
|
||||
plate_id: Optional plate index to filter filaments for (for multi-plate files)
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
import defusedxml.ElementTree as ET
|
||||
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
|
||||
@@ -1312,9 +1312,10 @@ async def get_library_file_plates(
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
|
||||
import defusedxml.ElementTree as ET
|
||||
|
||||
# Get the library file
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
lib_file = result.scalar_one_or_none()
|
||||
@@ -1613,9 +1614,10 @@ async def get_library_file_filament_requirements(
|
||||
file_id: The library file ID
|
||||
plate_id: Optional plate index to get filaments for a specific plate
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
|
||||
import defusedxml.ElementTree as ET
|
||||
|
||||
# Get the library file
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
lib_file = result.scalar_one_or_none()
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import defusedxml.ElementTree as ET
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -98,6 +98,58 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
|
||||
return sorted(types)
|
||||
|
||||
|
||||
def _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
|
||||
"""Extract print time (prediction) from a 3MF file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the 3MF file
|
||||
plate_id: Optional plate index to filter for (for multi-plate files)
|
||||
|
||||
Returns:
|
||||
Print time in seconds, or None if not found
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
if "Metadata/slice_info.config" not in zf.namelist():
|
||||
return None
|
||||
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
if plate_id is not None:
|
||||
for plate_elem in root.findall(".//plate"):
|
||||
plate_index = None
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
if meta.get("key") == "index":
|
||||
try:
|
||||
plate_index = int(meta.get("value", "0"))
|
||||
except ValueError:
|
||||
pass
|
||||
break
|
||||
|
||||
if plate_index == plate_id:
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
if meta.get("key") == "prediction":
|
||||
try:
|
||||
return int(meta.get("value", "0"))
|
||||
except ValueError:
|
||||
return None
|
||||
break
|
||||
else:
|
||||
plate_elem = root.find(".//plate")
|
||||
if plate_elem is not None:
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
if meta.get("key") == "prediction":
|
||||
try:
|
||||
return int(meta.get("value", "0"))
|
||||
except ValueError:
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract print time from {file_path}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
||||
"""Add nested archive/printer/library_file info to response."""
|
||||
# Parse ams_mapping from JSON string BEFORE model_validate
|
||||
@@ -153,6 +205,12 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
||||
response.archive_name = item.archive.print_name or item.archive.filename
|
||||
response.archive_thumbnail = item.archive.thumbnail_path
|
||||
response.print_time_seconds = item.archive.print_time_seconds
|
||||
if item.plate_id:
|
||||
archive_path = settings.base_dir / item.archive.file_path
|
||||
if archive_path.exists():
|
||||
plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
|
||||
if plate_time is not None:
|
||||
response.print_time_seconds = plate_time
|
||||
if item.library_file:
|
||||
response.library_file_name = (
|
||||
item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
|
||||
@@ -163,6 +221,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
||||
# Get print time from library file metadata if no archive
|
||||
if not item.archive and item.library_file.file_metadata:
|
||||
response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
|
||||
if item.plate_id:
|
||||
lib_path = Path(item.library_file.file_path)
|
||||
library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
|
||||
if library_file_path.exists():
|
||||
plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
|
||||
if plate_time is not None:
|
||||
response.print_time_seconds = plate_time
|
||||
if item.printer:
|
||||
response.printer_name = item.printer.name
|
||||
return response
|
||||
|
||||
@@ -852,9 +852,10 @@ async def get_printer_file_plates(
|
||||
"""Get available plates from a multi-plate 3MF file stored on a printer."""
|
||||
import io
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
|
||||
import defusedxml.ElementTree as ET
|
||||
|
||||
# Validate printer
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
|
||||
@@ -907,6 +907,10 @@ async def download_attachment(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""Download an attachment from a project."""
|
||||
# Validate filename to prevent path traversal
|
||||
if "/" in filename or "\\" in filename or ".." in filename or not filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
# Verify project exists
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
@@ -939,6 +943,10 @@ async def delete_attachment(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Delete an attachment from a project."""
|
||||
# Validate filename to prevent path traversal
|
||||
if "/" in filename or "\\" in filename or ".." in filename or not filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
# Verify project exists
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# Application version - single source of truth
|
||||
APP_VERSION = "0.1.8b"
|
||||
APP_VERSION = "0.1.8"
|
||||
GITHUB_REPO = "maziggy/bambuddy"
|
||||
|
||||
# App directory - where the application is installed (for static files)
|
||||
|
||||
@@ -6,8 +6,8 @@ import shutil
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from defusedxml import ElementTree as ET
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@@ -80,6 +80,9 @@ class BambuFTPClient:
|
||||
# Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
|
||||
# These models have varying FTP SSL behavior depending on firmware version
|
||||
A1_MODELS = ("A1", "A1 Mini")
|
||||
# Chunk size for manual upload transfer (1MB)
|
||||
# Larger chunks reduce overhead and work better with A1 printers
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
# Cache for working FTP modes per printer IP
|
||||
# Maps IP -> "prot_p" or "prot_c"
|
||||
@@ -363,15 +366,37 @@ class BambuFTPClient:
|
||||
|
||||
uploaded = 0
|
||||
|
||||
def on_block(block: bytes):
|
||||
nonlocal uploaded
|
||||
uploaded += len(block)
|
||||
if progress_callback:
|
||||
progress_callback(uploaded, file_size)
|
||||
|
||||
# Use manual transfer instead of storbinary() for A1 compatibility
|
||||
# A1 printers have issues with storbinary's voidresp() hanging after transfer
|
||||
with open(local_path, "rb") as f:
|
||||
logger.debug(f"FTP STOR command starting for {remote_path}")
|
||||
self._ftp.storbinary(f"STOR {remote_path}", f, callback=on_block)
|
||||
conn = self._ftp.transfercmd(f"STOR {remote_path}")
|
||||
|
||||
# Set explicit socket options for reliable transfer
|
||||
conn.setblocking(True)
|
||||
conn.settimeout(120) # 2 minute timeout per chunk
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk = f.read(self.CHUNK_SIZE)
|
||||
if not chunk:
|
||||
logger.debug("FTP upload: final chunk reached")
|
||||
break
|
||||
|
||||
conn.sendall(chunk)
|
||||
uploaded += len(chunk)
|
||||
logger.debug(f"FTP upload progress: {uploaded}/{file_size} bytes")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(uploaded, file_size)
|
||||
|
||||
except OSError as e:
|
||||
logger.error(f"FTP connection lost during upload: {e}")
|
||||
conn.close()
|
||||
raise
|
||||
|
||||
conn.close()
|
||||
|
||||
logger.info(f"FTP upload complete: {remote_path}")
|
||||
return True
|
||||
except ftplib.error_perm as e:
|
||||
@@ -399,8 +424,24 @@ class BambuFTPClient:
|
||||
return False
|
||||
|
||||
try:
|
||||
buffer = BytesIO(data)
|
||||
self._ftp.storbinary(f"STOR {remote_path}", buffer)
|
||||
# Use manual transfer instead of storbinary() for A1 compatibility
|
||||
conn = self._ftp.transfercmd(f"STOR {remote_path}")
|
||||
conn.setblocking(True)
|
||||
conn.settimeout(120)
|
||||
|
||||
try:
|
||||
# Send data in chunks
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
chunk = data[offset : offset + self.CHUNK_SIZE]
|
||||
conn.sendall(chunk)
|
||||
offset += len(chunk)
|
||||
except OSError as e:
|
||||
logger.error(f"FTP connection lost during upload_bytes: {e}")
|
||||
conn.close()
|
||||
raise
|
||||
|
||||
conn.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import defusedxml.ElementTree as ET
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ class TestPrintableObjectsExtraction:
|
||||
|
||||
def test_extract_printable_objects_from_slice_info(self):
|
||||
"""Test parsing printable objects from slice_info.config XML."""
|
||||
from xml.etree import ElementTree as ET
|
||||
from defusedxml import ElementTree as ET
|
||||
|
||||
# Example slice_info.config content with 4 objects
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -250,7 +250,7 @@ class TestPrintableObjectsExtraction:
|
||||
|
||||
def test_extract_printable_objects_empty_plate(self):
|
||||
"""Test handling plate with no objects."""
|
||||
from xml.etree import ElementTree as ET
|
||||
from defusedxml import ElementTree as ET
|
||||
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
@@ -272,7 +272,7 @@ class TestPrintableObjectsExtraction:
|
||||
|
||||
def test_extract_printable_objects_all_skipped(self):
|
||||
"""Test handling plate where all objects are skipped."""
|
||||
from xml.etree import ElementTree as ET
|
||||
from defusedxml import ElementTree as ET
|
||||
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
@@ -299,7 +299,7 @@ class TestThreeMFPlateIndexExtraction:
|
||||
|
||||
def test_extract_plate_index_from_slice_info(self):
|
||||
"""Test parsing plate index from slice_info.config metadata."""
|
||||
from xml.etree import ElementTree as ET
|
||||
from defusedxml import ElementTree as ET
|
||||
|
||||
# Single-plate export from plate 5 of a multi-plate project
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -325,7 +325,7 @@ class TestThreeMFPlateIndexExtraction:
|
||||
|
||||
def test_extract_plate_index_plate_1(self):
|
||||
"""Test parsing plate index when it's plate 1."""
|
||||
from xml.etree import ElementTree as ET
|
||||
from defusedxml import ElementTree as ET
|
||||
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
@@ -402,7 +402,7 @@ class TestThreeMFPlateIndexExtraction:
|
||||
|
||||
def test_high_plate_number_extraction(self):
|
||||
"""Test extracting high plate numbers (e.g., plate 28)."""
|
||||
from xml.etree import ElementTree as ET
|
||||
from defusedxml import ElementTree as ET
|
||||
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
@@ -433,7 +433,7 @@ class TestMultiPlate3MFParsing:
|
||||
|
||||
def test_parse_multiple_plates_from_slice_info(self):
|
||||
"""Test parsing multiple plates from slice_info.config."""
|
||||
from xml.etree import ElementTree as ET
|
||||
from defusedxml import ElementTree as ET
|
||||
|
||||
# Multi-plate 3MF with 3 plates
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -510,7 +510,7 @@ class TestMultiPlate3MFParsing:
|
||||
|
||||
def test_filter_filaments_by_plate_id(self):
|
||||
"""Test filtering filaments for a specific plate."""
|
||||
from xml.etree import ElementTree as ET
|
||||
from defusedxml import ElementTree as ET
|
||||
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Unit tests for plate object extraction from 3MF model_settings.config."""
|
||||
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import pytest
|
||||
from defusedxml import ElementTree as ET
|
||||
|
||||
|
||||
class TestPlateObjectExtraction:
|
||||
|
||||
@@ -20,6 +20,7 @@ pyftpdlib>=2.0.0
|
||||
cryptography>=41.0.0
|
||||
|
||||
# 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
|
||||
defusedxml>=0.7.0 # Safe XML parsing (prevents XXE attacks)
|
||||
|
||||
# Excel Export
|
||||
openpyxl>=3.1.0
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-Dc108DF_.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CwIN-fI6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CosC5iN4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user