implement signature requests over ws

This commit is contained in:
timurgordon
2025-05-19 14:48:40 +03:00
parent 2fd74defab
commit 83dde53555
27 changed files with 10791 additions and 0 deletions

2491
sigsocket/examples/web_app/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
[package]
name = "sigsocket-web-example"
version = "0.1.0"
edition = "2021"
[dependencies]
sigsocket = { path = "../.." }
actix-web = "4.3.1"
actix-rt = "2.8.0"
actix-files = "0.6.2"
actix-web-actors = "4.2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
env_logger = "0.10.0"
log = "0.4"
tera = "1.19.0"
tokio = { version = "1.28.0", features = ["full"] }
dotenv = "0.15.0"
hex = "0.4.3"
base64 = "0.13.0"
uuid = { version = "1.0", features = ["v4"] }

View File

@@ -0,0 +1,439 @@
use actix_files as fs;
use actix_web::{web, App, HttpServer, Responder, HttpResponse, Result};
use actix_web_actors::ws;
use serde::{Deserialize, Serialize};
use tera::{Tera, Context};
use std::sync::{Arc, Mutex};
use sigsocket::service::SigSocketService;
use sigsocket::registry::ConnectionRegistry;
use std::sync::RwLock;
use log::{info, error};
use hex;
use base64;
use std::collections::HashMap;
use uuid::Uuid;
use std::time::{Duration, Instant};
use tokio::task;
use serde_json::json;
// Status enum to represent the current state of a signature request
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub enum SignatureStatus {
Pending, // Request is created but not yet sent to the client
Processing, // Request is sent to the client for signing
Success, // Signature received and verified successfully
Error, // An error occurred during signing
Timeout, // Request timed out waiting for signature
}
// Shared state for the application
struct AppState {
templates: Tera,
sigsocket_service: Arc<SigSocketService>,
// Store all pending signature requests with their status
signature_requests: Arc<Mutex<HashMap<String, PendingSignature>>>,
}
// Structure for incoming sign requests
#[derive(Deserialize)]
struct SignRequest {
public_key: String,
message: String,
}
// Result structure for API responses
#[derive(Serialize, Clone)]
struct SignResult {
id: String, // Unique ID for this signature request
public_key: String, // Public key of the signer
message: String, // Original message that was signed
status: SignatureStatus, // Current status of the request
signature: Option<String>, // Signature if available
error: Option<String>, // Error message if any
created_at: String, // When the request was created (human readable)
updated_at: String, // When the request was last updated (human readable)
}
// Structure to track pending signatures
#[derive(Clone)]
struct PendingSignature {
id: String, // Unique ID for this request
public_key: String, // Public key that should sign
message: String, // Message to be signed
message_bytes: Vec<u8>, // Raw message bytes
status: SignatureStatus, // Current status
error: Option<String>, // Error message if any
signature: Option<String>, // Signature if available
created_at: Instant, // When the request was created
updated_at: Instant, // When the request was last updated
timeout_duration: Duration // How long to wait before timing out
}
impl PendingSignature {
fn new(id: String, public_key: String, message: String, message_bytes: Vec<u8>) -> Self {
let now = Instant::now();
PendingSignature {
id,
public_key,
message,
message_bytes,
status: SignatureStatus::Pending,
signature: None,
error: None,
created_at: now,
updated_at: now,
timeout_duration: Duration::from_secs(60), // Default 60-second timeout
}
}
fn to_result(&self) -> SignResult {
SignResult {
id: self.id.clone(),
public_key: self.public_key.clone(),
message: self.message.clone(),
status: self.status.clone(),
signature: self.signature.clone(),
error: self.error.clone(),
created_at: format!("{}s ago", self.created_at.elapsed().as_secs()),
updated_at: format!("{}s ago", self.updated_at.elapsed().as_secs()),
}
}
fn update_status(&mut self, status: SignatureStatus) {
self.status = status;
self.updated_at = Instant::now();
}
fn set_success(&mut self, signature: String) {
self.signature = Some(signature);
self.update_status(SignatureStatus::Success);
}
fn set_error(&mut self, error: String) {
self.error = Some(error);
self.update_status(SignatureStatus::Error);
}
fn is_timed_out(&self) -> bool {
self.created_at.elapsed() > self.timeout_duration
}
}
// Controller for the index page
async fn index(data: web::Data<AppState>) -> Result<HttpResponse> {
let mut context = Context::new();
// Add all signature requests to the context
let signature_requests = data.signature_requests.lock().unwrap();
// Convert the pending signatures to results for the template
let mut pending_sigs: Vec<&PendingSignature> = signature_requests.values().collect();
// Sort by created_at date (newest first)
pending_sigs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
// Convert to results after sorting
let results: Vec<SignResult> = pending_sigs.iter()
.map(|sig| sig.to_result())
.collect();
context.insert("signature_requests", &results);
context.insert("has_requests", &!results.is_empty());
let rendered = data.templates.render("index.html", &context)
.map_err(|e| {
eprintln!("Template error: {}", e);
actix_web::error::ErrorInternalServerError("Template error")
})?;
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
}
// Controller for the sign endpoint
async fn sign(
data: web::Data<AppState>,
form: web::Form<SignRequest>,
) -> impl Responder {
let message = form.message.clone();
let public_key = form.public_key.clone();
info!("Received sign request for public key: {}", &public_key);
info!("Message to sign: {}", &message);
// Generate a unique ID for this signature request
let request_id = Uuid::new_v4().to_string();
// Log the message bytes
let message_bytes = message.as_bytes().to_vec();
info!("Message bytes: {:?}", message_bytes);
info!("Message hex: {}", hex::encode(&message_bytes));
// Create a new pending signature request
let pending = PendingSignature::new(
request_id.clone(),
public_key.clone(),
message.clone(),
message_bytes.clone()
);
// Add the pending request to our state
{
let mut signature_requests = data.signature_requests.lock().unwrap();
signature_requests.insert(request_id.clone(), pending);
info!("Added new pending signature request: {}", request_id);
}
// Clone what we need for the async task
let request_id_clone = request_id.clone();
let service = data.sigsocket_service.clone();
let signature_requests = data.signature_requests.clone();
// Spawn an async task to handle the signature request
task::spawn(async move {
info!("Starting async signature task for request: {}", request_id_clone);
// Update status to Processing
{
let mut requests = signature_requests.lock().unwrap();
if let Some(request) = requests.get_mut(&request_id_clone) {
request.update_status(SignatureStatus::Processing);
}
}
// Send the message to be signed via SigSocket
info!("Sending message to SigSocket service for signing...");
match service.send_to_sign(&public_key, &message_bytes).await {
Ok((response_bytes, signature)) => {
// Successfully received a signature
let signature_base64 = base64::encode(&signature);
let message_base64 = base64::encode(&message_bytes);
// Format in the expected dot-separated format: base64_message.base64_signature
let full_signature = format!("{}.{}", message_base64, signature_base64);
info!("Successfully received signature response for request: {}", request_id_clone);
info!("Message base64: {}", message_base64);
info!("Signature base64: {}", signature_base64);
info!("Full signature (dot format): {}", full_signature);
// Update the signature request with the successful result
let mut requests = signature_requests.lock().unwrap();
if let Some(request) = requests.get_mut(&request_id_clone) {
request.set_success(signature_base64);
}
},
Err(err) => {
// Error occurred
error!("Error during signature process for request {}: {:?}", request_id_clone, err);
// Update the signature request with the error
let mut requests = signature_requests.lock().unwrap();
if let Some(request) = requests.get_mut(&request_id_clone) {
request.set_error(format!("Error: {:?}", err));
}
}
}
});
// Return JSON response if it's an AJAX request, otherwise redirect
if is_ajax_request(&form) {
// Return JSON response for AJAX requests
HttpResponse::Ok()
.content_type("application/json")
.json(json!({
"status": "pending",
"requestId": request_id,
"message": "Signature request added to queue"
}))
} else {
// Redirect back to the index page
HttpResponse::SeeOther()
.append_header(("Location", "/"))
.finish()
}
}
// Helper function to check if this is an AJAX request
fn is_ajax_request(_form: &web::Form<SignRequest>) -> bool {
// For simplicity, we'll always return false for now
// In a real application, you would check headers like X-Requested-With
false
}
// WebSocket handler for SigSocket connections
async fn websocket_handler(
req: actix_web::HttpRequest,
stream: actix_web::web::Payload,
service: web::Data<Arc<SigSocketService>>,
) -> Result<HttpResponse> {
// Create a new SigSocket handler
let handler = service.create_websocket_handler();
// Start WebSocket connection
ws::start(handler, &req, stream)
}
// Status endpoint for SigSocket server
async fn status_endpoint(service: web::Data<Arc<SigSocketService>>) -> impl Responder {
// Get the connection count
match service.connection_count() {
Ok(count) => {
// Return JSON response with status info
web::Json(json!({
"status": "online",
"active_connections": count,
"version": env!("CARGO_PKG_VERSION"),
}))
},
Err(e) => {
error!("Error getting connection count: {:?}", e);
// Return error status
web::Json(json!({
"status": "error",
"error": format!("{:?}", e),
}))
}
}
}
// Get status of a specific signature request or all requests
async fn signature_status(
data: web::Data<AppState>,
path: web::Path<(String,)>,
) -> impl Responder {
let request_id = &path.0;
// If the request_id is "all", return all requests
if request_id == "all" {
let signature_requests = data.signature_requests.lock().unwrap();
// Convert the pending signatures to results for the API
let results: Vec<SignResult> = signature_requests.values()
.map(|sig| sig.to_result())
.collect();
return web::Json(json!({
"status": "success",
"count": results.len(),
"requests": results
}));
}
// Otherwise, find the specific request
let signature_requests = data.signature_requests.lock().unwrap();
if let Some(request) = signature_requests.get(request_id) {
web::Json(json!({
"status": "success",
"request": request.to_result()
}))
} else {
web::Json(json!({
"status": "error",
"message": format!("No signature request found with ID: {}", request_id)
}))
}
}
// Delete a signature request
async fn delete_signature(
data: web::Data<AppState>,
path: web::Path<(String,)>,
) -> impl Responder {
let request_id = &path.0;
let mut signature_requests = data.signature_requests.lock().unwrap();
if let Some(_) = signature_requests.remove(request_id) {
web::Json(json!({
"status": "success",
"message": format!("Signature request {} deleted", request_id)
}))
} else {
web::Json(json!({
"status": "error",
"message": format!("No signature request found with ID: {}", request_id)
}))
}
}
// Task to check for timed-out signature requests
async fn check_timeouts(signature_requests: Arc<Mutex<HashMap<String, PendingSignature>>>) {
loop {
tokio::time::sleep(Duration::from_secs(5)).await;
// Check for timed-out requests
let mut requests = signature_requests.lock().unwrap();
let timed_out: Vec<String> = requests.iter()
.filter(|(_, req)| req.status == SignatureStatus::Pending || req.status == SignatureStatus::Processing)
.filter(|(_, req)| req.is_timed_out())
.map(|(id, _)| id.clone())
.collect();
// Update timed-out requests
for id in timed_out {
if let Some(req) = requests.get_mut(&id) {
req.error = Some("Request timed out waiting for signature".to_string());
req.update_status(SignatureStatus::Timeout);
info!("Signature request {} timed out", id);
}
}
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Setup logger
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
// Initialize templates
let mut tera = Tera::default();
tera.add_raw_templates(vec![
("index.html", include_str!("../templates/index.html")),
]).unwrap();
// Initialize SigSocket registry and service
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
let sigsocket_service = Arc::new(SigSocketService::new(registry.clone()));
// Initialize signature requests tracking
let signature_requests = Arc::new(Mutex::new(HashMap::new()));
// Start the timeout checking task
let timeout_checker_requests = signature_requests.clone();
tokio::spawn(async move {
check_timeouts(timeout_checker_requests).await;
});
// Shared application state
let app_state = web::Data::new(AppState {
templates: tera,
sigsocket_service: sigsocket_service.clone(),
signature_requests: signature_requests.clone(),
});
info!("Web App server starting on http://127.0.0.1:8080");
info!("SigSocket WebSocket endpoint available at ws://127.0.0.1:8080/ws");
// Start the web server with both our regular routes and the SigSocket WebSocket handler
HttpServer::new(move || {
App::new()
.app_data(app_state.clone())
.app_data(web::Data::new(sigsocket_service.clone()))
// Regular web app routes
.route("/", web::get().to(index))
.route("/sign", web::post().to(sign))
// SigSocket WebSocket handler
.route("/ws", web::get().to(websocket_handler))
// Status endpoints
.route("/sigsocket/status", web::get().to(status_endpoint))
// Signature API endpoints
.route("/api/signatures/{id}", web::get().to(signature_status))
.route("/api/signatures/{id}", web::delete().to(delete_signature))
// Static files
.service(fs::Files::new("/static", "./static"))
})
.bind("127.0.0.1:8080")?
.run()
.await
}

View File

@@ -0,0 +1,462 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SigSocket Demo App</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
}
.container {
display: flex;
justify-content: space-between;
}
.panel {
flex: 1;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
margin: 0 10px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"],
textarea {
width: 100%;
padding: 8px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
textarea {
min-height: 150px;
resize: vertical;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background-color: #45a049;
}
.result {
background-color: #f9f9f9;
padding: 15px;
border-radius: 4px;
margin-top: 20px;
}
.success {
color: #4CAF50;
font-weight: bold;
}
.error {
color: #f44336;
font-weight: bold;
}
</style>
</head>
<body>
<h1>SigSocket Demo Application</h1>
<div class="container">
<!-- Left Panel - Message Input Form -->
<div class="panel">
<h2>Sign Message</h2>
<form action="/sign" method="post">
<div>
<label for="public_key">Public Key:</label>
<input type="text" id="public_key" name="public_key" placeholder="Enter the client's public key" required>
</div>
<div>
<label for="message">Message to Sign:</label>
<textarea id="message" name="message" placeholder="Enter the message to be signed" required></textarea>
</div>
<button type="submit">Sign with SigSocket</button>
</form>
</div>
<!-- Right Panel - Signature Results -->
<div class="panel">
<h2>Pending Signatures</h2>
<div id="signature-list">
{% if has_requests %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Message</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for req in signature_requests %}
<tr id="signature-row-{{ req.id }}" class="{% if req.status == 'Success' %}table-success{% elif req.status == 'Error' or req.status == 'Timeout' %}table-danger{% elif req.status == 'Processing' %}table-warning{% else %}table-light{% endif %}">
<td>{{ req.id | truncate(length=8) }}</td>
<td>{{ req.message | truncate(length=20, end="...") }}</td>
<td>
<span class="badge rounded-pill {% if req.status == 'Success' %}bg-success{% elif req.status == 'Error' or req.status == 'Timeout' %}bg-danger{% elif req.status == 'Processing' %}bg-warning{% else %}bg-secondary{% endif %}">
{{ req.status }}
</span>
</td>
<td>{{ req.created_at }}</td>
<td>
<button class="btn btn-sm btn-info" onclick="viewSignature('{{ req.id }}')">
View
</button>
<button class="btn btn-sm btn-danger" onclick="deleteSignature('{{ req.id }}')">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No pending signatures. Submit a request using the form on the left.</p>
{% endif %}
</div>
<!-- Signature details modal -->
<div class="modal fade" id="signatureDetailsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Signature Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="signature-details-content">
<!-- Content will be loaded dynamically -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div style="text-align: center; margin-top: 30px;">
<p>
This demo uses the SigSocket WebSocket-based signing service.
Make sure a SigSocket client is connected with the matching public key.
</p>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed bottom-0 start-0 p-3" style="z-index: 11; width: 100%;">
<!-- Toasts will be added here dynamically -->
</div>
<script>
// Auto-refresh signature list every 2 seconds
let refreshTimer;
let signatureDetailsModal;
document.addEventListener('DOMContentLoaded', function() {
// Initialize the signature details modal
signatureDetailsModal = new bootstrap.Modal(document.getElementById('signatureDetailsModal'));
// Start auto-refresh
startAutoRefresh();
});
function startAutoRefresh() {
// Clear any existing timer
if (refreshTimer) {
clearInterval(refreshTimer);
}
// Setup timer to refresh signatures every 2 seconds
refreshTimer = setInterval(refreshSignatures, 2000);
console.log('Auto-refresh started');
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
console.log('Auto-refresh stopped');
}
}
function refreshSignatures() {
fetch('/api/signatures/all')
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
updateSignatureTable(data.requests);
}
})
.catch(err => {
console.error('Error refreshing signatures: ' + err);
stopAutoRefresh(); // Stop on error
});
}
function updateSignatureTable(signatures) {
const tableBody = document.querySelector('#signature-list table tbody');
if (!tableBody && signatures.length > 0) {
// No table exists but we have signatures - reload the page
window.location.reload();
return;
} else if (!tableBody) {
return; // No table and no signatures - nothing to do
}
if (signatures.length === 0) {
document.getElementById('signature-list').innerHTML = '<p>No pending signatures. Submit a request using the form on the left.</p>';
return;
}
// Update existing rows and add new ones
let existingIds = Array.from(tableBody.querySelectorAll('tr')).map(row => row.id.replace('signature-row-', ''));
signatures.forEach(sig => {
const rowId = 'signature-row-' + sig.id;
let row = document.getElementById(rowId);
if (row) {
// Update existing row
updateSignatureRow(row, sig);
// Remove from existingIds
existingIds = existingIds.filter(id => id !== sig.id);
} else {
// Create new row
row = document.createElement('tr');
row.id = rowId;
updateSignatureRow(row, sig);
tableBody.appendChild(row);
}
});
// Remove rows that no longer exist
existingIds.forEach(id => {
const row = document.getElementById('signature-row-' + id);
if (row) row.remove();
});
}
function updateSignatureRow(row, sig) {
// Set row class based on status
row.className = '';
if (sig.status === 'Success') {
row.className = 'table-success';
} else if (sig.status === 'Error' || sig.status === 'Timeout') {
row.className = 'table-danger';
} else if (sig.status === 'Processing') {
row.className = 'table-warning';
} else {
row.className = 'table-light';
}
// Update row content
row.innerHTML = `
<td>${sig.id.substring(0, 8)}</td>
<td>${sig.message.length > 20 ? sig.message.substring(0, 20) + '...' : sig.message}</td>
<td>
<span class="badge rounded-pill ${getBadgeClass(sig.status)}">
${sig.status}
</span>
</td>
<td>${sig.created_at}</td>
<td>
<button class="btn btn-sm btn-info" onclick="viewSignature('${sig.id}')">
View
</button>
<button class="btn btn-sm btn-danger" onclick="deleteSignature('${sig.id}')">
Delete
</button>
</td>
`;
}
function getBadgeClass(status) {
switch(status) {
case 'Success': return 'bg-success';
case 'Error': case 'Timeout': return 'bg-danger';
case 'Processing': return 'bg-warning';
default: return 'bg-secondary';
}
}
function viewSignature(id) {
fetch(`/api/signatures/${id}`)
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
displaySignatureDetails(data.request);
signatureDetailsModal.show();
} else {
showToast('Error: ' + data.message, 'danger');
}
})
.catch(err => {
showToast('Error loading signature details: ' + err, 'danger');
});
}
function displaySignatureDetails(signature) {
const content = document.getElementById('signature-details-content');
let statusClass = '';
if (signature.status === 'Success') statusClass = 'text-success';
else if (signature.status === 'Error' || signature.status === 'Timeout') statusClass = 'text-danger';
else if (signature.status === 'Processing') statusClass = 'text-warning';
content.innerHTML = `
<div class="card mb-3">
<div class="card-header d-flex justify-content-between">
<h5>Request ID: ${signature.id}</h5>
<h5 class="${statusClass}">Status: ${signature.status}</h5>
</div>
<div class="card-body">
<div class="mb-3">
<h6>Public Key:</h6>
<pre class="bg-light p-2">${signature.public_key || 'N/A'}</pre>
</div>
<div class="mb-3">
<h6>Message:</h6>
<pre class="bg-light p-2">${signature.message}</pre>
</div>
${signature.signature ? `
<div class="mb-3">
<h6>Signature (base64):</h6>
<pre class="bg-light p-2">${signature.signature}</pre>
</div>` : ''}
${signature.error ? `
<div class="mb-3">
<h6 class="text-danger">Error:</h6>
<pre class="bg-light p-2">${signature.error}</pre>
</div>` : ''}
<div class="row">
<div class="col">
<p><strong>Created:</strong> ${signature.created_at}</p>
</div>
<div class="col">
<p><strong>Last Updated:</strong> ${signature.updated_at}</p>
</div>
</div>
</div>
</div>
`;
}
function deleteSignature(id) {
if (confirm('Are you sure you want to delete this signature request?')) {
fetch(`/api/signatures/${id}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showToast(data.message, 'info');
refreshSignatures(); // Refresh immediately
} else {
showToast('Error: ' + data.message, 'danger');
}
})
.catch(err => {
showToast('Error deleting signature: ' + err, 'danger');
});
}
}
// Override console.log to show toast messages
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
console.log = function(message) {
// Call the original console.log
originalConsoleLog.apply(console, arguments);
// Show toast with the message
showToast(message, 'info');
};
console.error = function(message) {
// Call the original console.error
originalConsoleError.apply(console, arguments);
// Show toast with the error message
showToast(message, 'danger');
};
function showToast(message, type = 'info') {
// Create toast element
const toastId = 'toast-' + Date.now();
const toastElement = document.createElement('div');
toastElement.id = toastId;
toastElement.className = 'toast w-100';
toastElement.setAttribute('role', 'alert');
toastElement.setAttribute('aria-live', 'assertive');
toastElement.setAttribute('aria-atomic', 'true');
// Set toast content
toastElement.innerHTML = `
<div class="toast-header bg-${type} text-white">
<strong class="me-auto">${type === 'danger' ? 'Error' : 'Info'}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
${message}
</div>
`;
// Append to container
document.querySelector('.toast-container').appendChild(toastElement);
// Initialize and show the toast
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Remove toast after it's hidden
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
// Test toast
console.log('Web app loaded successfully!');
</script>
</body>
</html>