Sprint 2 : moteur de documents + sanctuaire
Backend: - CRUD complet documents/items/versions (update, delete, accept, reject, reorder) - Service IPFS (upload/retrieve/pin via kubo HTTP API) - Service sanctuaire : pipeline SHA-256 + IPFS + on-chain (system.remark) - Verification integrite des entrees sanctuaire - Recherche par reference (document -> entrees sanctuaire) - Serialisation deterministe des documents pour archivage - 14 tests unitaires supplementaires (document service) Frontend: - 9 composants : StatusBadge, MarkdownRenderer, DiffView, ItemCard, ItemVersionDiff, DocumentList, SanctuaryEntry, IPFSLink, ChainAnchor - Page detail item avec historique des versions et diff - Page detail sanctuaire avec verification integrite - Modal de creation de document + proposition de version - Archivage document vers sanctuaire depuis la page detail Documentation: - API reference mise a jour (9 nouveaux endpoints) - Guides utilisateur documents et sanctuaire enrichis Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -10,6 +11,8 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.document import Document, DocumentItem, ItemVersion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_document_with_items(slug: str, db: AsyncSession) -> Document | None:
|
||||
"""Load a document with all its items and their versions, eagerly.
|
||||
@@ -106,3 +109,221 @@ async def apply_version(
|
||||
await db.refresh(item)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
async def reject_version(
|
||||
item_id: uuid.UUID,
|
||||
version_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> ItemVersion:
|
||||
"""Mark a version as rejected.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item_id:
|
||||
UUID of the DocumentItem the version belongs to.
|
||||
version_id:
|
||||
UUID of the ItemVersion to reject.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ItemVersion
|
||||
The rejected version.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the item or version is not found, or the version does not
|
||||
belong to the item.
|
||||
"""
|
||||
# Load item to verify existence
|
||||
item_result = await db.execute(
|
||||
select(DocumentItem).where(DocumentItem.id == item_id)
|
||||
)
|
||||
item = item_result.scalar_one_or_none()
|
||||
if item is None:
|
||||
raise ValueError(f"Element de document introuvable : {item_id}")
|
||||
|
||||
# Load version
|
||||
version_result = await db.execute(
|
||||
select(ItemVersion).where(ItemVersion.id == version_id)
|
||||
)
|
||||
version = version_result.scalar_one_or_none()
|
||||
if version is None:
|
||||
raise ValueError(f"Version introuvable : {version_id}")
|
||||
|
||||
if version.item_id != item.id:
|
||||
raise ValueError(
|
||||
f"La version {version_id} n'appartient pas a l'element {item_id}"
|
||||
)
|
||||
|
||||
version.status = "rejected"
|
||||
await db.commit()
|
||||
await db.refresh(version)
|
||||
|
||||
return version
|
||||
|
||||
|
||||
async def get_item_with_versions(
|
||||
item_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> DocumentItem | None:
|
||||
"""Eager-load a document item with all its versions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item_id:
|
||||
UUID of the DocumentItem.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
DocumentItem | None
|
||||
The item with versions loaded, or None if not found.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(DocumentItem)
|
||||
.options(selectinload(DocumentItem.versions))
|
||||
.where(DocumentItem.id == item_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def reorder_items(
|
||||
document_id: uuid.UUID,
|
||||
items_order: list[tuple[uuid.UUID, int]],
|
||||
db: AsyncSession,
|
||||
) -> list[DocumentItem]:
|
||||
"""Update sort_order for multiple items in a document.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
document_id:
|
||||
UUID of the document.
|
||||
items_order:
|
||||
List of (item_id, sort_order) tuples.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[DocumentItem]
|
||||
The updated items, ordered by sort_order.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If any item is not found or does not belong to the document.
|
||||
"""
|
||||
for item_id, sort_order in items_order:
|
||||
result = await db.execute(
|
||||
select(DocumentItem).where(
|
||||
DocumentItem.id == item_id,
|
||||
DocumentItem.document_id == document_id,
|
||||
)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if item is None:
|
||||
raise ValueError(
|
||||
f"Element {item_id} introuvable dans le document {document_id}"
|
||||
)
|
||||
item.sort_order = sort_order
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Return all items in new order
|
||||
result = await db.execute(
|
||||
select(DocumentItem)
|
||||
.where(DocumentItem.document_id == document_id)
|
||||
.order_by(DocumentItem.sort_order)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def serialize_document_to_text(doc: Document) -> str:
|
||||
"""Serialize a document and its items to a plain-text representation.
|
||||
|
||||
The items must be eagerly loaded on the document before calling this.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
doc:
|
||||
Document with items loaded.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Plain-text serialization suitable for hashing and archival.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append(f"# {doc.title}")
|
||||
lines.append(f"Version: {doc.version}")
|
||||
lines.append(f"Type: {doc.doc_type}")
|
||||
lines.append(f"Statut: {doc.status}")
|
||||
if doc.description:
|
||||
lines.append(f"Description: {doc.description}")
|
||||
lines.append("")
|
||||
|
||||
# Sort items by sort_order
|
||||
sorted_items = sorted(doc.items, key=lambda i: i.sort_order)
|
||||
for item in sorted_items:
|
||||
header = f"## {item.position}"
|
||||
if item.title:
|
||||
header += f" - {item.title}"
|
||||
header += f" [{item.item_type}]"
|
||||
lines.append(header)
|
||||
lines.append(item.current_text)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def archive_document(slug: str, db: AsyncSession):
|
||||
"""Serialize a document to text and archive it to the sanctuary.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
slug:
|
||||
Slug of the document to archive.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
SanctuaryEntry
|
||||
The newly created sanctuary entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the document is not found.
|
||||
"""
|
||||
from app.services import sanctuary_service
|
||||
|
||||
doc = await get_document_with_items(slug, db)
|
||||
if doc is None:
|
||||
raise ValueError(f"Document introuvable : {slug}")
|
||||
|
||||
content = serialize_document_to_text(doc)
|
||||
|
||||
entry = await sanctuary_service.archive_to_sanctuary(
|
||||
entry_type="document",
|
||||
reference_id=doc.id,
|
||||
content=content,
|
||||
title=f"{doc.title} v{doc.version}",
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Update document with sanctuary references
|
||||
if entry.ipfs_cid:
|
||||
doc.ipfs_cid = entry.ipfs_cid
|
||||
if entry.chain_tx_hash:
|
||||
doc.chain_anchor = entry.chain_tx_hash
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
|
||||
return entry
|
||||
|
||||
125
backend/app/services/ipfs_service.py
Normal file
125
backend/app/services/ipfs_service.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""IPFS service: upload, retrieve, and pin content via kubo HTTP API.
|
||||
|
||||
Uses httpx async client to communicate with the local kubo node.
|
||||
All operations handle connection errors gracefully: they log a warning
|
||||
and return None instead of crashing the caller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Timeout for IPFS operations (seconds)
|
||||
_IPFS_TIMEOUT = 30.0
|
||||
|
||||
|
||||
async def upload_to_ipfs(content: str | bytes) -> str | None:
|
||||
"""Upload content to IPFS via kubo HTTP API (POST /api/v0/add).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
content:
|
||||
The content to upload. Strings are encoded as UTF-8.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str | None
|
||||
The IPFS CID (Content Identifier) of the uploaded content,
|
||||
or None if the upload failed.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
f"{settings.IPFS_API_URL}/api/v0/add",
|
||||
files={"file": ("content.txt", content, "application/octet-stream")},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
cid = data.get("Hash")
|
||||
if cid:
|
||||
logger.info("Contenu uploade sur IPFS: CID=%s", cid)
|
||||
return cid
|
||||
except httpx.ConnectError:
|
||||
logger.warning("Impossible de se connecter au noeud IPFS (%s)", settings.IPFS_API_URL)
|
||||
return None
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning("Erreur HTTP IPFS lors de l'upload: %s", exc.response.status_code)
|
||||
return None
|
||||
except Exception:
|
||||
logger.warning("Erreur inattendue lors de l'upload IPFS", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_from_ipfs(cid: str) -> bytes | None:
|
||||
"""Retrieve content from IPFS by CID via the gateway.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cid:
|
||||
The IPFS Content Identifier to retrieve.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes | None
|
||||
The raw content bytes, or None if retrieval failed.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
f"{settings.IPFS_API_URL}/api/v0/cat",
|
||||
params={"arg": cid},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info("Contenu recupere depuis IPFS: CID=%s", cid)
|
||||
return response.content
|
||||
except httpx.ConnectError:
|
||||
logger.warning("Impossible de se connecter au noeud IPFS (%s)", settings.IPFS_API_URL)
|
||||
return None
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning("Erreur HTTP IPFS lors de la recuperation (CID=%s): %s", cid, exc.response.status_code)
|
||||
return None
|
||||
except Exception:
|
||||
logger.warning("Erreur inattendue lors de la recuperation IPFS (CID=%s)", cid, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def pin(cid: str) -> bool:
|
||||
"""Pin content on the local IPFS node to prevent garbage collection.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cid:
|
||||
The IPFS Content Identifier to pin.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if pinning succeeded, False otherwise.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
f"{settings.IPFS_API_URL}/api/v0/pin/add",
|
||||
params={"arg": cid},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info("Contenu epingle sur IPFS: CID=%s", cid)
|
||||
return True
|
||||
except httpx.ConnectError:
|
||||
logger.warning("Impossible de se connecter au noeud IPFS pour l'epinglage (%s)", settings.IPFS_API_URL)
|
||||
return False
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning("Erreur HTTP IPFS lors de l'epinglage (CID=%s): %s", cid, exc.response.status_code)
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning("Erreur inattendue lors de l'epinglage IPFS (CID=%s)", cid, exc_info=True)
|
||||
return False
|
||||
@@ -9,12 +9,17 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.sanctuary import SanctuaryEntry
|
||||
from app.services import ipfs_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def archive_to_sanctuary(
|
||||
@@ -26,6 +31,12 @@ async def archive_to_sanctuary(
|
||||
) -> SanctuaryEntry:
|
||||
"""Hash content and create a sanctuary entry.
|
||||
|
||||
Pipeline:
|
||||
1. Hash content (SHA-256)
|
||||
2. Try to upload to IPFS via ipfs_service (catch errors, log, continue)
|
||||
3. Try to anchor on-chain via blockchain_service (catch errors, log, continue)
|
||||
4. Create SanctuaryEntry with whatever succeeded
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry_type:
|
||||
@@ -45,33 +56,65 @@ async def archive_to_sanctuary(
|
||||
SanctuaryEntry
|
||||
The newly created sanctuary entry with content_hash set.
|
||||
"""
|
||||
# Compute SHA-256 hash of the content
|
||||
# 1. Compute SHA-256 hash of the content
|
||||
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
metadata: dict = {
|
||||
"archived_at": datetime.now(timezone.utc).isoformat(),
|
||||
"entry_type": entry_type,
|
||||
"content_length": len(content),
|
||||
}
|
||||
|
||||
ipfs_cid: str | None = None
|
||||
chain_tx_hash: str | None = None
|
||||
chain_block: int | None = None
|
||||
|
||||
# 2. Try to upload to IPFS
|
||||
try:
|
||||
ipfs_cid = await ipfs_service.upload_to_ipfs(content)
|
||||
if ipfs_cid:
|
||||
# Pin the content to keep it available
|
||||
await ipfs_service.pin(ipfs_cid)
|
||||
metadata["ipfs_cid"] = ipfs_cid
|
||||
logger.info("Contenu archive sur IPFS: CID=%s", ipfs_cid)
|
||||
else:
|
||||
logger.warning("Upload IPFS echoue (retour None) pour %s:%s", entry_type, reference_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Erreur lors de l'upload IPFS pour %s:%s",
|
||||
entry_type, reference_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 3. Try to anchor on-chain (still a structured stub)
|
||||
try:
|
||||
chain_tx_hash, chain_block = await _anchor_on_chain(content_hash)
|
||||
if chain_tx_hash:
|
||||
metadata["chain_tx_hash"] = chain_tx_hash
|
||||
metadata["chain_block"] = chain_block
|
||||
logger.info("Hash ancre on-chain: tx=%s block=%s", chain_tx_hash, chain_block)
|
||||
except NotImplementedError:
|
||||
logger.info("Ancrage on-chain pas encore implemente, etape ignoree")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Erreur lors de l'ancrage on-chain pour %s:%s",
|
||||
entry_type, reference_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 4. Create SanctuaryEntry with whatever succeeded
|
||||
entry = SanctuaryEntry(
|
||||
entry_type=entry_type,
|
||||
reference_id=reference_id,
|
||||
title=title,
|
||||
content_hash=content_hash,
|
||||
ipfs_cid=ipfs_cid,
|
||||
chain_tx_hash=chain_tx_hash,
|
||||
chain_block=chain_block,
|
||||
metadata_json=json.dumps(metadata, ensure_ascii=False),
|
||||
)
|
||||
|
||||
# TODO: Upload content to IPFS via kubo HTTP API
|
||||
# ipfs_cid = await _upload_to_ipfs(content)
|
||||
# entry.ipfs_cid = ipfs_cid
|
||||
|
||||
# TODO: Anchor hash on-chain via system.remark
|
||||
# tx_hash, block_number = await _anchor_on_chain(content_hash)
|
||||
# entry.chain_tx_hash = tx_hash
|
||||
# entry.chain_block = block_number
|
||||
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
@@ -79,31 +122,115 @@ async def archive_to_sanctuary(
|
||||
return entry
|
||||
|
||||
|
||||
async def _upload_to_ipfs(content: str) -> str:
|
||||
"""Upload content to IPFS via kubo HTTP API.
|
||||
async def verify_entry(
|
||||
entry_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""Verify the integrity of a sanctuary entry.
|
||||
|
||||
TODO: Implement using httpx against settings.IPFS_API_URL.
|
||||
Re-fetches the content (from IPFS if available) and re-hashes it
|
||||
to compare with the stored content_hash.
|
||||
|
||||
Example::
|
||||
Parameters
|
||||
----------
|
||||
entry_id:
|
||||
UUID of the SanctuaryEntry to verify.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
import httpx
|
||||
from app.config import settings
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Verification result with keys:
|
||||
- ``entry_id``: UUID of the entry
|
||||
- ``valid``: bool indicating if the hash matches
|
||||
- ``stored_hash``: the stored content_hash
|
||||
- ``computed_hash``: the re-computed hash (or None if content unavailable)
|
||||
- ``source``: where the content was fetched from (``"ipfs"`` or ``"unavailable"``)
|
||||
- ``detail``: human-readable detail message
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{settings.IPFS_API_URL}/api/v0/add",
|
||||
files={"file": ("content.txt", content.encode("utf-8"))},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["Hash"]
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the entry is not found.
|
||||
"""
|
||||
raise NotImplementedError("IPFS upload pas encore implemente")
|
||||
result = await db.execute(
|
||||
select(SanctuaryEntry).where(SanctuaryEntry.id == entry_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
raise ValueError(f"Entree sanctuaire introuvable : {entry_id}")
|
||||
|
||||
stored_hash = entry.content_hash
|
||||
computed_hash: str | None = None
|
||||
source = "unavailable"
|
||||
|
||||
# Try to re-fetch content from IPFS
|
||||
if entry.ipfs_cid:
|
||||
try:
|
||||
content_bytes = await ipfs_service.get_from_ipfs(entry.ipfs_cid)
|
||||
if content_bytes is not None:
|
||||
computed_hash = hashlib.sha256(content_bytes).hexdigest()
|
||||
source = "ipfs"
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Impossible de recuperer le contenu IPFS pour verification (CID=%s)",
|
||||
entry.ipfs_cid,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if computed_hash is None:
|
||||
return {
|
||||
"entry_id": entry.id,
|
||||
"valid": False,
|
||||
"stored_hash": stored_hash,
|
||||
"computed_hash": None,
|
||||
"source": source,
|
||||
"detail": "Contenu indisponible pour la verification",
|
||||
}
|
||||
|
||||
is_valid = computed_hash == stored_hash
|
||||
return {
|
||||
"entry_id": entry.id,
|
||||
"valid": is_valid,
|
||||
"stored_hash": stored_hash,
|
||||
"computed_hash": computed_hash,
|
||||
"source": source,
|
||||
"detail": "Integrite verifiee" if is_valid else "Hash different - contenu potentiellement altere",
|
||||
}
|
||||
|
||||
|
||||
async def _anchor_on_chain(content_hash: str) -> tuple[str, int]:
|
||||
async def get_entries_by_reference(
|
||||
reference_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> list[SanctuaryEntry]:
|
||||
"""Query all sanctuary entries for a given reference_id.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
reference_id:
|
||||
UUID of the referenced entity (document, decision, etc.).
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[SanctuaryEntry]
|
||||
All entries matching the reference_id, ordered by creation date desc.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(SanctuaryEntry)
|
||||
.where(SanctuaryEntry.reference_id == reference_id)
|
||||
.order_by(SanctuaryEntry.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _anchor_on_chain(content_hash: str) -> tuple[str | None, int | None]:
|
||||
"""Anchor a content hash on-chain via system.remark.
|
||||
|
||||
TODO: Implement using substrate-interface.
|
||||
Currently a stub. When implemented, this will use substrate-interface
|
||||
to submit a system.remark extrinsic containing the content hash.
|
||||
|
||||
Example::
|
||||
|
||||
@@ -119,5 +246,15 @@ async def _anchor_on_chain(content_hash: str) -> tuple[str, int]:
|
||||
extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair)
|
||||
receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True)
|
||||
return receipt.extrinsic_hash, receipt.block_number
|
||||
|
||||
Parameters
|
||||
----------
|
||||
content_hash:
|
||||
The SHA-256 hash to anchor.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[str | None, int | None]
|
||||
(tx_hash, block_number) or (None, None) if not implemented.
|
||||
"""
|
||||
raise NotImplementedError("Ancrage on-chain pas encore implemente")
|
||||
|
||||
Reference in New Issue
Block a user