Skip to content

Instantly share code, notes, and snippets.

@koeppelmann
Created June 22, 2026 12:23
Show Gist options
  • Select an option

  • Save koeppelmann/be0f7452141b289fad851f11ac3d3d49 to your computer and use it in GitHub Desktop.

Select an option

Save koeppelmann/be0f7452141b289fad851f11ac3d3d49 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swarm File Upload</title>
<script src="https://unpkg.com/@ethersphere/bee-js/dist/index.browser.min.js"></script>
<!-- Helia for IPFS in browser -->
<script type="module">
import { createHelia } from 'https://esm.sh/helia@4';
import { unixfs } from 'https://esm.sh/@helia/unixfs@3';
import { CID } from 'https://esm.sh/multiformats/cid';
// Make available globally
window.heliaModules = { createHelia, unixfs, CID };
</script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
padding: 2rem;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 0.5rem;
font-size: 2rem;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 2rem;
}
.config-section {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.config-section h2 {
font-size: 1rem;
margin-bottom: 1rem;
color: #f90;
}
.input-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.input-group label {
flex: 1;
min-width: 250px;
}
.input-group label span {
display: block;
font-size: 0.85rem;
color: #aaa;
margin-bottom: 0.5rem;
}
.input-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a2e;
color: #fff;
font-size: 0.9rem;
}
.input-group input:focus {
outline: none;
border-color: #f90;
}
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.5rem 1rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
font-size: 0.9rem;
}
.connection-status .status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #888;
transition: background 0.3s;
}
.connection-status.connected .status-dot {
background: #4caf50;
}
.connection-status.gateway .status-dot {
background: #ff9800;
}
.connection-status.disconnected .status-dot {
background: #f44336;
}
.connection-status .status-text {
color: #ccc;
}
.drop-zone {
border: 2px dashed #444;
border-radius: 16px;
padding: 4rem 2rem;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
background: rgba(255, 255, 255, 0.02);
}
.drop-zone:hover,
.drop-zone.drag-over {
border-color: #f90;
background: rgba(255, 153, 0, 0.05);
}
.drop-zone-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.drop-zone-text {
font-size: 1.2rem;
margin-bottom: 0.5rem;
}
.drop-zone-hint {
color: #666;
font-size: 0.9rem;
}
.file-input {
display: none;
}
.upload-list {
margin-top: 1.5rem;
}
.upload-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1rem 1.5rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 1rem;
}
.upload-item-icon {
font-size: 1.5rem;
}
.upload-item-info {
flex: 1;
min-width: 0;
}
.upload-item-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upload-item-size {
font-size: 0.85rem;
color: #888;
}
.upload-item-status {
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
white-space: nowrap;
}
.status-pending {
background: #333;
color: #888;
}
.status-uploading {
background: rgba(255, 153, 0, 0.2);
color: #f90;
}
.status-success {
background: rgba(0, 200, 83, 0.2);
color: #00c853;
}
.status-error {
background: rgba(255, 82, 82, 0.2);
color: #ff5252;
}
.upload-item-link {
margin-top: 0.5rem;
}
.upload-item-link a {
color: #f90;
text-decoration: none;
font-size: 0.85rem;
word-break: break-all;
}
.upload-item-link a:hover {
text-decoration: underline;
}
.copy-btn {
background: transparent;
border: 1px solid #444;
color: #888;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
margin-left: 0.5rem;
}
.copy-btn:hover {
border-color: #f90;
color: #f90;
}
.error-message {
background: rgba(255, 82, 82, 0.1);
border: 1px solid #ff5252;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
color: #ff5252;
}
.info-box {
background: rgba(255, 153, 0, 0.1);
border: 1px solid #f90;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
line-height: 1.5;
}
.info-box a {
color: #f90;
}
.debug-section {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1.5rem;
margin-top: 2rem;
border: 1px solid #333;
}
.debug-section h2 {
font-size: 1rem;
margin-bottom: 1rem;
color: #f90;
display: flex;
align-items: center;
gap: 0.5rem;
}
.debug-input-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.debug-input-row input {
flex: 1;
padding: 0.75rem;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a2e;
color: #fff;
font-size: 0.9rem;
font-family: monospace;
}
.debug-input-row input:focus {
outline: none;
border-color: #f90;
}
.debug-btn {
background: #f90;
border: none;
color: #000;
padding: 0.75rem 1.5rem;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
}
.debug-btn:hover {
background: #ffaa33;
}
.debug-btn:disabled {
background: #666;
cursor: not-allowed;
}
.debug-results {
background: #0d0d1a;
border-radius: 8px;
padding: 1rem;
font-family: monospace;
font-size: 0.85rem;
max-height: 500px;
overflow-y: auto;
}
.debug-results:empty {
display: none;
}
.debug-item {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #333;
}
.debug-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.debug-item-title {
color: #f90;
font-weight: 600;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.debug-item-content {
color: #ccc;
line-height: 1.6;
}
.debug-status {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.debug-status.success {
background: rgba(0, 200, 83, 0.2);
color: #00c853;
}
.debug-status.error {
background: rgba(255, 82, 82, 0.2);
color: #ff5252;
}
.debug-status.warning {
background: rgba(255, 193, 7, 0.2);
color: #ffc107;
}
.debug-status.info {
background: rgba(33, 150, 243, 0.2);
color: #2196f3;
}
.debug-loading {
color: #888;
font-style: italic;
}
.debug-json {
background: rgba(0, 0, 0, 0.3);
padding: 0.5rem;
border-radius: 4px;
margin-top: 0.5rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.url-section {
background: rgba(33, 150, 243, 0.05);
border-radius: 12px;
padding: 1.5rem;
margin-top: 1.5rem;
border: 1px solid #2196f3;
}
.url-section h2 {
font-size: 1rem;
margin-bottom: 1rem;
color: #64b5f6;
display: flex;
align-items: center;
gap: 0.5rem;
}
.url-hint {
font-size: 0.85rem;
color: #888;
margin-bottom: 1rem;
line-height: 1.5;
}
.url-hint code {
background: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.8rem;
}
.url-input-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.url-input-row input {
flex: 1;
padding: 0.75rem;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a2e;
color: #fff;
font-size: 0.9rem;
}
.url-input-row input:focus {
outline: none;
border-color: #2196f3;
}
.url-btn {
background: #2196f3;
border: none;
color: #fff;
padding: 0.75rem 1.5rem;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
}
.url-btn:hover {
background: #42a5f5;
}
.url-btn:disabled {
background: #666;
cursor: not-allowed;
}
.url-status {
background: #0d0d1a;
border-radius: 8px;
padding: 1rem;
font-size: 0.9rem;
margin-top: 1rem;
}
.url-status:empty {
display: none;
}
.url-progress {
margin-top: 0.5rem;
}
.url-progress-bar {
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
margin-top: 0.5rem;
}
.url-progress-fill {
height: 100%;
background: linear-gradient(90deg, #2196f3, #64b5f6);
border-radius: 4px;
transition: width 0.3s ease;
}
.ipfs-section {
background: rgba(0, 188, 212, 0.05);
border-radius: 12px;
padding: 1.5rem;
margin-top: 1.5rem;
border: 1px solid #00bcd4;
}
.ipfs-section h2 {
font-size: 1rem;
margin-bottom: 1rem;
color: #4dd0e1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.ipfs-hint {
font-size: 0.85rem;
color: #888;
margin-bottom: 1rem;
line-height: 1.5;
}
.ipfs-hint code {
background: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.8rem;
}
.ipfs-input-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.ipfs-input-row input {
flex: 1;
padding: 0.75rem;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a2e;
color: #fff;
font-size: 0.9rem;
font-family: monospace;
}
.ipfs-input-row input:focus {
outline: none;
border-color: #00bcd4;
}
.ipfs-btn {
background: #00bcd4;
border: none;
color: #000;
padding: 0.75rem 1.5rem;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
}
.ipfs-btn:hover {
background: #4dd0e1;
}
.ipfs-btn:disabled {
background: #666;
cursor: not-allowed;
color: #999;
}
.ipfs-status {
background: #0d0d1a;
border-radius: 8px;
padding: 1rem;
font-size: 0.9rem;
margin-top: 1rem;
}
.ipfs-status:empty {
display: none;
}
.ipfs-progress {
margin-top: 0.5rem;
}
.ipfs-progress-bar {
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
margin-top: 0.5rem;
}
.ipfs-progress-fill {
height: 100%;
background: linear-gradient(90deg, #00bcd4, #4dd0e1);
border-radius: 4px;
transition: width 0.3s ease;
}
.ipfs-gateway-note {
font-size: 0.8rem;
color: #666;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #333;
}
.ipfs-gateway-note a {
color: #4dd0e1;
}
.helia-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
border-radius: 12px;
background: rgba(0, 0, 0, 0.3);
}
.helia-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.helia-status.connecting .dot {
background: #ffc107;
animation: pulse 1s infinite;
}
.helia-status.connected .dot {
background: #00c853;
}
.helia-status.error .dot {
background: #ff5252;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Client-Side Stamping Section */
.client-stamp-section {
background: rgba(76, 175, 80, 0.05);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid #4caf50;
}
.client-stamp-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.client-stamp-header h2 {
font-size: 1rem;
color: #81c784;
margin: 0;
}
.client-stamp-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.85rem;
color: #a5d6a7;
}
.client-stamp-toggle input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #4caf50;
}
.client-stamp-hint {
font-size: 0.85rem;
color: #888;
margin-bottom: 1rem;
line-height: 1.5;
}
.client-stamp-config {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 1rem;
}
.client-stamp-config .input-group label span {
color: #a5d6a7;
}
.client-stamp-config input {
background: #1a1a2e;
border: 1px solid #4caf50;
color: #fff;
}
.client-stamp-config input:focus {
border-color: #81c784;
outline: none;
}
.client-stamp-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.client-stamp-btn {
padding: 0.6rem 1.2rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
background: #4caf50;
color: #fff;
transition: background 0.2s;
}
.client-stamp-btn:hover {
background: #66bb6a;
}
.client-stamp-btn.secondary {
background: transparent;
border: 1px solid #4caf50;
color: #a5d6a7;
}
.client-stamp-btn.secondary:hover {
background: rgba(76, 175, 80, 0.15);
}
.client-stamp-btn:disabled {
background: #666;
cursor: not-allowed;
}
.client-stamp-status {
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
}
.client-stamp-status:empty {
display: none;
}
.client-stamp-status.success {
background: rgba(76, 175, 80, 0.2);
color: #a5d6a7;
}
.client-stamp-status.error {
background: rgba(244, 67, 54, 0.2);
color: #ef9a9a;
}
.client-stamp-status.info {
background: rgba(33, 150, 243, 0.2);
color: #90caf9;
}
.client-stamp-info {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(76, 175, 80, 0.3);
}
.stamp-info-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.85rem;
}
.stamp-info-label {
color: #888;
min-width: 120px;
}
.stamp-info-value {
color: #a5d6a7;
font-family: monospace;
word-break: break-all;
}
.stamps-section {
background: rgba(156, 39, 176, 0.05);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid #9c27b0;
}
.stamps-section h2 {
font-size: 1rem;
margin-bottom: 1rem;
color: #ce93d8;
display: flex;
align-items: center;
gap: 0.5rem;
}
.stamps-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.stamps-refresh-btn {
background: transparent;
border: 1px solid #9c27b0;
color: #ce93d8;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.stamps-refresh-btn:hover {
background: rgba(156, 39, 176, 0.1);
}
.stamps-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.stamp-card {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 1rem;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
}
.stamp-card:hover {
border-color: #9c27b0;
}
.stamp-card.selected {
border-color: #ce93d8;
background: rgba(156, 39, 176, 0.15);
}
.stamp-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.stamp-label {
font-weight: 600;
color: #ce93d8;
}
.stamp-status {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.stamp-status.usable {
background: rgba(0, 200, 83, 0.2);
color: #00c853;
}
.stamp-status.unusable {
background: rgba(255, 82, 82, 0.2);
color: #ff5252;
}
.stamp-id {
font-family: monospace;
font-size: 0.75rem;
color: #888;
word-break: break-all;
margin-bottom: 0.5rem;
}
.stamp-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.5rem;
font-size: 0.85rem;
}
.stamp-detail {
display: flex;
flex-direction: column;
}
.stamp-detail-label {
color: #888;
font-size: 0.75rem;
}
.stamp-detail-value {
color: #fff;
font-weight: 500;
}
.stamp-utilization-bar {
height: 4px;
background: #333;
border-radius: 2px;
margin-top: 0.75rem;
overflow: hidden;
}
.stamp-utilization-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.stamps-loading {
color: #888;
font-style: italic;
padding: 1rem;
text-align: center;
}
.stamps-empty {
color: #888;
padding: 1rem;
text-align: center;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.stamp-expanded {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #333;
}
.stamp-expanded-details {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem 1rem;
font-size: 0.8rem;
}
.stamp-expanded-details .stamp-detail.full-width {
grid-column: 1 / -1;
}
.topup-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #333;
}
.topup-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid #9c27b0;
border-radius: 6px;
background: #1a1a2e;
color: #fff;
font-size: 0.85rem;
}
.topup-input:focus {
outline: none;
border-color: #ce93d8;
}
.topup-btn {
background: #9c27b0;
border: none;
color: #fff;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
white-space: nowrap;
}
.topup-btn:hover {
background: #ab47bc;
}
.topup-btn:disabled {
background: #666;
cursor: not-allowed;
}
.topup-status {
font-size: 0.8rem;
margin-top: 0.5rem;
}
.topup-status.success {
color: #00c853;
}
.topup-status.error {
color: #ff5252;
}
.topup-hint {
font-size: 0.75rem;
color: #888;
margin-top: 0.25rem;
}
.topup-cost {
font-size: 0.85rem;
color: #ce93d8;
margin-top: 0.5rem;
min-height: 1.2em;
}
.topup-cost .bzz-amount {
font-weight: 600;
color: #fff;
}
.pins-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #333;
}
.pins-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.pins-title {
font-size: 0.85rem;
color: #ce93d8;
font-weight: 600;
}
.pins-load-btn {
background: transparent;
border: 1px solid #9c27b0;
color: #ce93d8;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.pins-load-btn:hover {
background: rgba(156, 39, 176, 0.1);
}
.pins-list {
max-height: 200px;
overflow-y: auto;
font-size: 0.8rem;
}
.pin-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.pin-item:last-child {
border-bottom: none;
}
.pin-hash {
font-family: monospace;
font-size: 0.7rem;
color: #aaa;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pin-actions {
display: flex;
gap: 0.25rem;
}
.pin-actions a, .pin-actions button {
background: transparent;
border: 1px solid #444;
color: #888;
padding: 0.15rem 0.4rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.7rem;
text-decoration: none;
}
.pin-actions a:hover, .pin-actions button:hover {
border-color: #ce93d8;
color: #ce93d8;
}
.pins-empty {
color: #666;
font-style: italic;
padding: 0.5rem 0;
}
.pins-note {
font-size: 0.7rem;
color: #666;
margin-top: 0.5rem;
font-style: italic;
}
.tooltip {
position: relative;
cursor: help;
border-bottom: 1px dotted #888;
}
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: #222;
color: #fff;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 400;
white-space: nowrap;
max-width: 250px;
white-space: normal;
text-align: left;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
pointer-events: none;
}
.tooltip::before {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: #222;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 1000;
}
.tooltip:hover::after,
.tooltip:hover::before {
opacity: 1;
visibility: visible;
}
.stamp-detail-label.tooltip {
display: inline-block;
}
/* Message Board Section */
.board-section {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1.5rem;
margin-top: 1.5rem;
border: 1px solid #2a5a4a;
}
.board-section h2 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
color: #4caf50;
}
.board-hint {
font-size: 0.85rem;
color: #888;
margin-bottom: 1rem;
}
.board-key-section {
background: rgba(76, 175, 80, 0.1);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.board-key-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
align-items: center;
}
.board-key-row input {
flex: 1;
padding: 0.6rem;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #fff;
font-family: monospace;
font-size: 0.85rem;
}
.board-key-row input:focus {
outline: none;
border-color: #4caf50;
}
.board-btn {
padding: 0.6rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
font-size: 0.85rem;
transition: all 0.2s;
}
.board-btn-primary {
background: #4caf50;
color: #fff;
}
.board-btn-primary:hover {
background: #66bb6a;
}
.board-btn-secondary {
background: transparent;
border: 1px solid #4caf50;
color: #4caf50;
}
.board-btn-secondary:hover {
background: rgba(76, 175, 80, 0.1);
}
.board-info {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
align-items: center;
font-size: 0.8rem;
margin-top: 0.75rem;
padding: 0.75rem;
background: rgba(0,0,0,0.2);
border-radius: 6px;
}
.board-info-label {
color: #888;
}
.board-info-value {
font-family: monospace;
color: #a5d6a7;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-info-copy {
background: transparent;
border: 1px solid #444;
color: #888;
padding: 0.2rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.7rem;
}
.board-info-copy:hover {
border-color: #4caf50;
color: #4caf50;
}
.board-share-links {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.board-share-btn {
flex: 1;
padding: 0.5rem;
background: rgba(76, 175, 80, 0.15);
border: 1px solid #4caf50;
color: #a5d6a7;
border-radius: 6px;
cursor: pointer;
font-size: 0.75rem;
text-align: center;
}
.board-share-btn:hover {
background: rgba(76, 175, 80, 0.25);
}
.board-compose {
background: rgba(76, 175, 80, 0.1);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.board-compose-title {
font-size: 0.9rem;
color: #a5d6a7;
margin-bottom: 0.75rem;
font-weight: 600;
}
.board-compose textarea {
width: 100%;
min-height: 80px;
padding: 0.75rem;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #fff;
font-size: 0.9rem;
resize: vertical;
margin-bottom: 0.75rem;
}
.board-compose textarea:focus {
outline: none;
border-color: #4caf50;
}
.board-compose-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.board-compose-actions input[type="file"] {
display: none;
}
.board-image-preview {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.board-image-preview img {
max-height: 60px;
border-radius: 4px;
border: 1px solid #333;
}
.board-image-preview .remove-image {
position: relative;
}
.board-image-preview .remove-image::after {
content: '×';
position: absolute;
top: -5px;
right: -5px;
background: #f44336;
color: #fff;
width: 16px;
height: 16px;
border-radius: 50%;
font-size: 12px;
line-height: 16px;
text-align: center;
cursor: pointer;
}
.board-messages {
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 1rem;
max-height: 500px;
overflow-y: auto;
}
.board-messages-title {
font-size: 0.9rem;
color: #888;
margin-bottom: 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.board-message {
background: rgba(255,255,255,0.03);
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
border-left: 3px solid #4caf50;
}
.board-message:last-child {
margin-bottom: 0;
}
.board-message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.75rem;
color: #888;
}
.board-message-author {
font-family: monospace;
color: #a5d6a7;
}
.board-message-time {
color: #666;
}
.board-message-text {
font-size: 0.9rem;
line-height: 1.5;
color: #ddd;
word-break: break-word;
}
.board-message-images {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.board-message-images img {
max-width: 200px;
max-height: 150px;
border-radius: 6px;
cursor: pointer;
transition: transform 0.2s;
}
.board-message-images img:hover {
transform: scale(1.02);
}
.board-empty {
text-align: center;
color: #666;
padding: 2rem;
font-style: italic;
}
.board-loading {
text-align: center;
color: #888;
padding: 1rem;
}
.board-status {
margin-top: 0.75rem;
padding: 0.5rem;
border-radius: 6px;
font-size: 0.85rem;
}
.board-status.success {
background: rgba(76, 175, 80, 0.2);
color: #a5d6a7;
}
.board-status.error {
background: rgba(244, 67, 54, 0.2);
color: #ef9a9a;
}
.board-status.info {
background: rgba(33, 150, 243, 0.2);
color: #90caf9;
}
.board-readonly-notice {
background: rgba(255, 152, 0, 0.15);
border: 1px solid #ff9800;
color: #ffcc80;
padding: 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
margin-bottom: 1rem;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<h1>Swarm File Upload</h1>
<p class="subtitle">Drag and drop files to upload to Swarm decentralized storage</p>
<div class="info-box">
<strong>Upload Options:</strong><br>
1. <strong>Local Bee Node:</strong> Run a <a href="https://docs.ethswarm.org/docs/bee/installation/quick-start" target="_blank">Bee full node</a> with a postage stamp.<br>
2. <strong>Gateway (Sponsored):</strong> Use a public gateway with free sponsored uploads (no node needed).<br>
3. <strong>Client-Side Stamping:</strong> Use your own stamp via any gateway - sign chunks locally without sharing your key.
</div>
<div class="config-section">
<h2>Configuration</h2>
<div class="connection-status" id="connectionStatus">
<span class="status-dot"></span>
<span class="status-text">Checking connection...</span>
</div>
<div class="input-group">
<label>
<span>Bee Node URL (local)</span>
<input type="text" id="beeUrl" value="http://localhost:1633" placeholder="http://localhost:1633">
</label>
<label>
<span>Gateway URL (fallback)</span>
<input type="text" id="gatewayUrl" value="https://api.gateway.ethswarm.org" placeholder="https://api.gateway.ethswarm.org">
</label>
</div>
<div class="input-group" style="margin-top: 1rem;">
<label style="flex: 2;">
<span>Postage Batch ID <span id="stampRequirement" style="color: #888; font-size: 0.8em;">(required for uploads)</span></span>
<input type="text" id="batchId" placeholder="Select a stamp below or enter ID manually">
</label>
</div>
</div>
<div class="client-stamp-section" id="clientStampSection">
<div class="client-stamp-header">
<h2>Client-Side Stamping (Bring Your Own Stamp)</h2>
<label class="client-stamp-toggle">
<input type="checkbox" id="clientStampEnabled" onchange="toggleClientStamping()">
<span>Enable</span>
</label>
</div>
<div class="client-stamp-hint">
Sign and stamp chunks locally with your own key and batch. Upload to any gateway without sharing your private key.
Requires a batch you own (purchased with your Ethereum address).
</div>
<div class="client-stamp-config" id="clientStampConfig" style="display: none;">
<div class="input-group">
<label>
<span>Signer Private Key <span style="color: #ff9800; font-size: 0.8em;">(kept local, never sent)</span></span>
<input type="password" id="clientSignerKey" placeholder="64-char hex private key (owner of batch)">
</label>
</div>
<div class="input-group" style="margin-top: 0.75rem;">
<label>
<span>Batch ID (owned by signer)</span>
<input type="text" id="clientBatchId" placeholder="Your postage batch ID">
</label>
<label style="max-width: 120px;">
<span>Batch Depth</span>
<input type="number" id="clientBatchDepth" placeholder="20" value="20" min="17" max="32">
</label>
</div>
<div class="client-stamp-actions">
<button class="client-stamp-btn" onclick="initClientStamper()">Initialize Stamper</button>
<button class="client-stamp-btn secondary" onclick="testClientStamping()">Test Upload</button>
</div>
<div class="client-stamp-status" id="clientStampStatus"></div>
<div class="client-stamp-info" id="clientStampInfo" style="display: none;">
<div class="stamp-info-row">
<span class="stamp-info-label">Signer Address:</span>
<span class="stamp-info-value" id="clientSignerAddress">-</span>
</div>
<div class="stamp-info-row">
<span class="stamp-info-label">Stamper State:</span>
<span class="stamp-info-value" id="clientStamperState">Not initialized</span>
</div>
</div>
</div>
</div>
<div class="stamps-section">
<div class="stamps-header">
<h2>Postage Stamps (Local Node)</h2>
<button class="stamps-refresh-btn" onclick="loadStamps()">Refresh</button>
</div>
<div class="stamps-list" id="stampsList">
<div class="stamps-loading">Loading stamps...</div>
</div>
</div>
<div id="errorContainer"></div>
<div class="drop-zone" id="dropZone">
<div class="drop-zone-icon">📁</div>
<div class="drop-zone-text">Drop files here or click to browse</div>
<div class="drop-zone-hint">Supports any file type</div>
</div>
<input type="file" id="fileInput" class="file-input" multiple>
<div class="upload-list" id="uploadList"></div>
<div class="url-section">
<h2>URL to Swarm</h2>
<div class="url-hint">
Fetch any public URL and upload to Swarm. Works with direct file links (images, PDFs, etc).
For YouTube, use: <code>yt-dlp "URL" -o video.mp4</code> then drag the file here.
</div>
<div class="url-input-row">
<input type="text" id="fetchUrl" placeholder="Paste any public URL (e.g., https://example.com/image.png)">
<button class="url-btn" id="fetchBtn" onclick="fetchAndUploadUrl()">Fetch & Upload</button>
</div>
<div class="url-status" id="urlStatus"></div>
</div>
<div class="ipfs-section">
<h2>
IPFS to Swarm
<span class="helia-status" id="heliaStatus">
<span class="dot"></span>
<span class="status-text">Loading...</span>
</span>
</h2>
<div class="ipfs-hint">
Migrate content from IPFS to Swarm. Supports <strong>single files</strong> and <strong>entire websites/directories</strong>.
Enter an IPFS CID like <code>QmXoypiz...</code> or <code>bafybeig...</code>
</div>
<div class="ipfs-input-row">
<input type="text" id="ipfsCid" placeholder="Enter IPFS CID (e.g., bafybeidtudmi6qajjcsfwnieepuuu6buz2lo7gip3no2iugut6f75vjmd4)">
<button class="ipfs-btn" id="ipfsBtn" onclick="fetchIpfsAndUpload()">Migrate to Swarm</button>
</div>
<div class="ipfs-status" id="ipfsStatus"></div>
<div class="ipfs-gateway-note">
<strong>Note:</strong> Large websites may take several minutes. The tool uses IPFS gateways to fetch content.
Progress is shown for each file during directory migration.
</div>
</div>
<div class="debug-section">
<h2>Hash Debug Tool</h2>
<div class="debug-input-row">
<input type="text" id="debugHash" placeholder="Paste a Swarm hash to inspect...">
<button class="debug-btn" id="debugBtn" onclick="debugHash()">Inspect</button>
</div>
<div class="debug-results" id="debugResults"></div>
</div>
<div class="board-section">
<h2>Message Board (Swarm Feed)</h2>
<div class="board-hint">
Create or load a decentralized message board using Swarm Feeds. Messages are stored as a linked list on Swarm.
</div>
<div class="board-key-section">
<div class="board-key-row">
<input type="text" id="boardPrivateKey" placeholder="Private key (hex) or feed address for read-only">
<button class="board-btn board-btn-secondary" onclick="boardGenerateKey()">Generate New</button>
<button class="board-btn board-btn-primary" onclick="boardLoadKey()">Load</button>
</div>
<div id="boardKeyInfo" style="display: none;">
<div class="board-info">
<span class="board-info-label">Owner:</span>
<span class="board-info-value" id="boardOwnerAddress"></span>
<button class="board-info-copy" onclick="copyToClipboard(document.getElementById('boardOwnerAddress').textContent)">Copy</button>
<span class="board-info-label">Feed:</span>
<span class="board-info-value" id="boardFeedAddress"></span>
<button class="board-info-copy" onclick="copyToClipboard(document.getElementById('boardFeedAddress').textContent)">Copy</button>
</div>
<div class="board-share-links">
<button class="board-share-btn" onclick="boardCopyWriteLink()">Copy Write Link (with key)</button>
<button class="board-share-btn" onclick="boardCopyReadLink()">Copy Read-Only Link</button>
</div>
</div>
<div id="boardStatus"></div>
</div>
<div id="boardReadonlyNotice" class="board-readonly-notice" style="display: none;">
Read-only mode. To post messages, load with a private key.
</div>
<div id="boardCompose" class="board-compose" style="display: none;">
<div class="board-compose-title">New Message</div>
<div class="board-image-preview" id="boardImagePreview"></div>
<textarea id="boardMessageText" placeholder="Write your message..."></textarea>
<div class="board-compose-actions">
<button class="board-btn board-btn-primary" onclick="boardPostMessage()">Post Message</button>
<button class="board-btn board-btn-secondary" onclick="document.getElementById('boardImageInput').click()">Add Image</button>
<input type="file" id="boardImageInput" accept="image/*" multiple onchange="boardHandleImageSelect(event)">
</div>
</div>
<div class="board-messages">
<div class="board-messages-title">
<span>Messages</span>
<button class="board-btn board-btn-secondary" onclick="boardLoadMessages()" style="padding: 0.3rem 0.6rem; font-size: 0.75rem;">Refresh</button>
</div>
<div id="boardMessagesList">
<div class="board-empty">Load a feed to see messages</div>
</div>
</div>
</div>
</div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const uploadList = document.getElementById('uploadList');
const errorContainer = document.getElementById('errorContainer');
const beeUrlInput = document.getElementById('beeUrl');
const gatewayUrlInput = document.getElementById('gatewayUrl');
const batchIdInput = document.getElementById('batchId');
const connectionStatus = document.getElementById('connectionStatus');
// Connection state
let isLocalNodeAvailable = false;
let isGatewayAvailable = false;
// Client-side stamping state
let clientStampingEnabled = false;
let clientStamper = null;
let clientSignerKey = null;
let clientSignerAddress = null;
// Load saved configuration
const savedBeeUrl = localStorage.getItem('swarm-bee-url');
const savedGatewayUrl = localStorage.getItem('swarm-gateway-url');
const savedBatchId = localStorage.getItem('swarm-batch-id');
if (savedBeeUrl) beeUrlInput.value = savedBeeUrl;
if (savedGatewayUrl) gatewayUrlInput.value = savedGatewayUrl;
if (savedBatchId) batchIdInput.value = savedBatchId;
// Load saved client-side stamping settings (not the private key for security)
const savedClientBatchId = localStorage.getItem('swarm-client-batch-id');
const savedClientBatchDepth = localStorage.getItem('swarm-client-batch-depth');
if (savedClientBatchId) {
document.getElementById('clientBatchId').value = savedClientBatchId;
}
if (savedClientBatchDepth) {
document.getElementById('clientBatchDepth').value = savedClientBatchDepth;
}
// Get the active Bee URL (local if available, gateway otherwise)
function getActiveUrl() {
if (isLocalNodeAvailable) {
return beeUrlInput.value.trim();
}
if (isGatewayAvailable) {
return gatewayUrlInput.value.trim();
}
return beeUrlInput.value.trim(); // fallback
}
// Check if we can upload (local node with stamps)
function canUpload() {
return isLocalNodeAvailable && batchIdInput.value.trim();
}
// Update connection status UI
function updateConnectionStatus(status, message) {
connectionStatus.className = 'connection-status ' + status;
connectionStatus.querySelector('.status-text').textContent = message;
}
// Check connection to local node and gateway
async function checkConnections() {
updateConnectionStatus('', 'Checking connections...');
// Check local node
const localUrl = beeUrlInput.value.trim();
const gatewayUrl = gatewayUrlInput.value.trim();
try {
const localResp = await fetch(`${localUrl}/health`, {
method: 'GET',
signal: AbortSignal.timeout(3000)
});
isLocalNodeAvailable = localResp.ok;
} catch (e) {
isLocalNodeAvailable = false;
}
// Check gateway using bee-js (handles CORS better)
if (!isLocalNodeAvailable && gatewayUrl) {
try {
const bee = new BeeJs.Bee(gatewayUrl);
// Try to download a zero hash - will fail with 404 but proves connectivity
await bee.downloadData('0000000000000000000000000000000000000000000000000000000000000000');
isGatewayAvailable = true;
} catch (e) {
// 404 means gateway is reachable but hash not found - that's fine
if (e.message.includes('404') || e.message.includes('Not Found') || e.status === 404) {
isGatewayAvailable = true;
} else {
isGatewayAvailable = false;
}
}
}
// Update UI based on connection status
if (isLocalNodeAvailable) {
updateConnectionStatus('connected', `Connected to local Bee node (${localUrl})`);
loadStamps();
} else if (isGatewayAvailable) {
updateConnectionStatus('gateway', `Gateway mode: ${gatewayUrl} (limited uploads via sponsored batch)`);
document.getElementById('stampsList').innerHTML = '<div class="stamps-empty">Gateway mode - using sponsored batch for uploads. Start a local Bee node for full control.</div>';
} else {
updateConnectionStatus('disconnected', 'No connection - start a local Bee node or check gateway URL');
document.getElementById('stampsList').innerHTML = '<div class="stamps-empty">Not connected to any Swarm node.</div>';
}
}
// Save configuration on change
beeUrlInput.addEventListener('change', () => {
localStorage.setItem('swarm-bee-url', beeUrlInput.value);
checkConnections();
});
gatewayUrlInput.addEventListener('change', () => {
localStorage.setItem('swarm-gateway-url', gatewayUrlInput.value);
checkConnections();
});
batchIdInput.addEventListener('change', () => {
localStorage.setItem('swarm-batch-id', batchIdInput.value);
updateSelectedStamp();
});
// Postage Stamps functionality
let stampsData = [];
let batchesData = {};
let nodeAddress = '';
async function loadStamps() {
const beeUrl = beeUrlInput.value.trim();
const stampsList = document.getElementById('stampsList');
if (!beeUrl) {
stampsList.innerHTML = '<div class="stamps-empty">Configure Bee Node URL first</div>';
return;
}
stampsList.innerHTML = '<div class="stamps-loading">Loading stamps...</div>';
try {
// Fetch stamps, batches (for owner info), and node address in parallel
const [stampsResp, batchesResp, addressResp] = await Promise.all([
fetch(`${beeUrl}/stamps`),
fetch(`${beeUrl}/batches`).catch(() => null),
fetch(`${beeUrl}/addresses`).catch(() => null)
]);
if (!stampsResp.ok) {
throw new Error(`HTTP ${stampsResp.status}`);
}
const stampsJson = await stampsResp.json();
stampsData = stampsJson.stamps || [];
// Build a map of batchID -> owner from batches endpoint
if (batchesResp && batchesResp.ok) {
const batchesJson = await batchesResp.json();
batchesData = {};
(batchesJson.batches || []).forEach(b => {
batchesData[b.batchID] = b.owner;
});
}
// Get node's ethereum address
if (addressResp && addressResp.ok) {
const addressJson = await addressResp.json();
nodeAddress = addressJson.ethereum || addressJson.chain_address || '';
}
if (stampsData.length === 0) {
stampsList.innerHTML = '<div class="stamps-empty">No postage stamps found. Buy a stamp in Swarm Desktop or via the API.</div>';
return;
}
renderStamps();
} catch (error) {
stampsList.innerHTML = `<div class="stamps-empty" style="color: #ff5252;">Failed to load stamps: ${error.message}</div>`;
}
}
function renderStamps() {
const stampsList = document.getElementById('stampsList');
const currentBatchId = batchIdInput.value.trim();
stampsList.innerHTML = stampsData.map(stamp => {
const isSelected = stamp.batchID === currentBatchId;
const utilizationPercent = Math.min(100, (stamp.utilization / Math.pow(2, stamp.depth - stamp.bucketDepth)) * 100);
const ttlHours = Math.floor(stamp.batchTTL / 3600);
const ttlDays = Math.floor(ttlHours / 24);
const ttlDisplay = ttlDays > 0 ? `${ttlDays}d ${ttlHours % 24}h` : `${ttlHours}h`;
// Calculate capacity
const capacityBytes = Math.pow(2, stamp.depth) * 4096; // 4KB chunks
const capacityDisplay = formatFileSize(capacityBytes);
// Calculate used capacity
const usedBytes = (stamp.utilization / Math.pow(2, stamp.depth - stamp.bucketDepth)) * capacityBytes;
const usedDisplay = formatFileSize(usedBytes);
// Utilization bar color
let utilizationColor = '#00c853'; // green
if (utilizationPercent > 80) utilizationColor = '#ff5252'; // red
else if (utilizationPercent > 50) utilizationColor = '#ffc107'; // yellow
return `
<div class="stamp-card ${isSelected ? 'selected' : ''}" onclick="selectStamp('${stamp.batchID}')">
<div class="stamp-card-header">
<span class="stamp-label">${stamp.label || 'Unnamed Stamp'}</span>
<span class="stamp-status ${stamp.usable ? 'usable' : 'unusable'}">${stamp.usable ? 'USABLE' : 'UNUSABLE'}</span>
</div>
<div class="stamp-id">${stamp.batchID}</div>
<div class="stamp-details">
<div class="stamp-detail">
<span class="stamp-detail-label tooltip" data-tooltip="Time To Live - How long until this stamp expires and data may be garbage collected">TTL</span>
<span class="stamp-detail-value">${ttlDisplay}</span>
</div>
<div class="stamp-detail">
<span class="stamp-detail-label tooltip" data-tooltip="Maximum storage capacity of this stamp (2^depth × 4KB chunks)">Capacity</span>
<span class="stamp-detail-value">${capacityDisplay}</span>
</div>
<div class="stamp-detail">
<span class="stamp-detail-label tooltip" data-tooltip="Amount of stamp capacity currently used by uploaded data">Used</span>
<span class="stamp-detail-value">${usedDisplay} (${utilizationPercent.toFixed(1)}%)</span>
</div>
<div class="stamp-detail">
<span class="stamp-detail-label tooltip" data-tooltip="Determines stamp capacity. Each +1 depth doubles the capacity (and cost)">Depth</span>
<span class="stamp-detail-value">${stamp.depth}</span>
</div>
</div>
<div class="stamp-utilization-bar">
<div class="stamp-utilization-fill" style="width: ${utilizationPercent}%; background: ${utilizationColor};"></div>
</div>
${isSelected ? (() => {
const owner = batchesData[stamp.batchID] || '';
const ownerDisplay = owner ? '0x' + owner : 'Unknown';
const isOwnStamp = owner && nodeAddress && nodeAddress.toLowerCase().endsWith(owner.toLowerCase());
return `
<div class="stamp-expanded">
<div class="stamp-expanded-details">
<div class="stamp-detail" style="grid-column: 1 / -1;">
<span class="stamp-detail-label tooltip" data-tooltip="Ethereum address that owns this stamp batch on Gnosis Chain">Owner</span>
<span class="stamp-detail-value" style="font-family: monospace; font-size: 0.75rem;">
${ownerDisplay}
${isOwnStamp ? '<span style="color: #00c853; margin-left: 0.5rem;">(Your node)</span>' : ''}
</span>
</div>
<div class="stamp-detail">
<span class="stamp-detail-label tooltip" data-tooltip="PLUR = smallest unit of BZZ token (1 BZZ = 10^16 PLUR). Amount paid for this stamp's storage duration.">Amount (PLUR)</span>
<span class="stamp-detail-value">${Number(stamp.amount).toLocaleString()}</span>
</div>
<div class="stamp-detail">
<span class="stamp-detail-label tooltip" data-tooltip="Gnosis Chain block number when this stamp was purchased">Block Number</span>
<span class="stamp-detail-value">${stamp.blockNumber.toLocaleString()}</span>
</div>
<div class="stamp-detail">
<span class="stamp-detail-label tooltip" data-tooltip="Number of collision buckets (2^bucketDepth). Higher = more even distribution of chunks">Bucket Depth</span>
<span class="stamp-detail-value">${stamp.bucketDepth}</span>
</div>
<div class="stamp-detail">
<span class="stamp-detail-label tooltip" data-tooltip="If true, uploaded content cannot be overwritten or updated">Immutable</span>
<span class="stamp-detail-value">${stamp.immutableFlag ? 'Yes' : 'No'}</span>
</div>
<div class="stamp-detail">
<span class="stamp-detail-label tooltip" data-tooltip="Raw utilization counter - number of chunks stored across all buckets">Utilization</span>
<span class="stamp-detail-value">${stamp.utilization}</span>
</div>
<div class="stamp-detail">
<span class="stamp-detail-label tooltip" data-tooltip="Whether this stamp batch exists on the blockchain">Exists</span>
<span class="stamp-detail-value">${stamp.exists ? 'Yes' : 'No'}</span>
</div>
</div>
<div class="topup-row" onclick="event.stopPropagation()">
<input type="number" class="topup-input" id="topup-days-${stamp.batchID}"
placeholder="Days to add" min="1" step="1"
oninput="updateTopupCost('${stamp.batchID}', ${stamp.batchTTL}, '${stamp.amount}', ${stamp.depth})" />
<button class="topup-btn" onclick="topUpStamp('${stamp.batchID}', ${stamp.batchTTL}, '${stamp.amount}', ${stamp.depth})">Top Up TTL</button>
</div>
<div class="topup-cost" id="topup-cost-${stamp.batchID}"></div>
<div class="topup-status" id="topup-status-${stamp.batchID}"></div>
<div class="pins-section" onclick="event.stopPropagation()">
<div class="pins-header">
<span class="pins-title">Pinned Content on Node</span>
<button class="pins-load-btn" onclick="loadPins('${stamp.batchID}')">Load Pins</button>
</div>
<div class="pins-list" id="pins-list-${stamp.batchID}">
<div class="pins-empty">Click "Load Pins" to see pinned content</div>
</div>
<div class="pins-note">Note: Pins are node-wide, not stamp-specific. The Swarm API doesn't track which stamp was used for each upload.</div>
</div>
</div>
`;})() : ''}
</div>
`;
}).join('');
}
function selectStamp(batchId) {
batchIdInput.value = batchId;
localStorage.setItem('swarm-batch-id', batchId);
renderStamps();
}
function updateSelectedStamp() {
renderStamps();
}
async function loadPins(batchId) {
const beeUrl = beeUrlInput.value.trim();
const pinsListDiv = document.getElementById(`pins-list-${batchId}`);
if (!beeUrl) {
pinsListDiv.innerHTML = '<div class="pins-empty" style="color: #ff5252;">Configure Bee Node URL first</div>';
return;
}
pinsListDiv.innerHTML = '<div class="pins-empty">Loading pins...</div>';
try {
const response = await fetch(`${beeUrl}/pins`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const pins = data.references || [];
if (pins.length === 0) {
pinsListDiv.innerHTML = '<div class="pins-empty">No pinned content found on this node</div>';
return;
}
pinsListDiv.innerHTML = pins.map(hash => `
<div class="pin-item">
<span class="pin-hash" title="${hash}">${hash}</span>
<div class="pin-actions">
<a href="${beeUrl}/bzz/${hash}/" target="_blank">Open</a>
<a href="https://gateway.ethswarm.org/bzz/${hash}/" target="_blank">Gateway</a>
<button onclick="copyToClipboard('${hash}'); event.stopPropagation();">Copy</button>
<button onclick="document.getElementById('debugHash').value='${hash}'; debugHash(); event.stopPropagation();">Debug</button>
</div>
</div>
`).join('');
} catch (error) {
pinsListDiv.innerHTML = `<div class="pins-empty" style="color: #ff5252;">Failed to load pins: ${error.message}</div>`;
}
}
function updateTopupCost(batchId, currentTTL, currentAmount, depth) {
const daysInput = document.getElementById(`topup-days-${batchId}`);
const costDiv = document.getElementById(`topup-cost-${batchId}`);
const days = parseFloat(daysInput.value.trim());
if (!days || days <= 0) {
costDiv.innerHTML = '';
return;
}
// Calculate PLUR amount needed
// The amount in stamp data is per-chunk, total cost = amount * 2^depth
// To add time: we need (amount_per_chunk / TTL_seconds) * seconds_to_add * 2^depth
const secondsToAdd = days * 86400;
const chunks = BigInt(2) ** BigInt(depth);
const plurPerSecondPerChunk = BigInt(currentAmount) / BigInt(currentTTL);
const plurToAdd = plurPerSecondPerChunk * BigInt(Math.ceil(secondsToAdd)) * chunks;
// Convert to BZZ (1 BZZ = 10^16 PLUR)
// Use string manipulation for BigInt to avoid precision loss
const plurStr = plurToAdd.toString().padStart(17, '0');
const wholePart = plurStr.slice(0, -16) || '0';
const fracPart = plurStr.slice(-16);
const bzzDisplay = `${wholePart}.${fracPart.slice(0, 6)}`;
costDiv.innerHTML = `Cost: <span class="bzz-amount">~${bzzDisplay} BZZ</span>`;
}
async function topUpStamp(batchId, currentTTL, currentAmount, depth) {
const beeUrl = beeUrlInput.value.trim();
const daysInput = document.getElementById(`topup-days-${batchId}`);
const statusDiv = document.getElementById(`topup-status-${batchId}`);
const days = parseFloat(daysInput.value.trim());
if (!days || days <= 0) {
statusDiv.innerHTML = '<span class="error">Please enter a valid number of days</span>';
statusDiv.className = 'topup-status error';
return;
}
// Calculate PLUR amount based on current TTL and amount
// The API expects the per-chunk amount to add, not the total
// amount is per-chunk, so we calculate per-chunk amount to add for the days
const secondsToAdd = days * 86400; // 86400 seconds per day
const plurPerSecondPerChunk = BigInt(currentAmount) / BigInt(currentTTL);
const plurToAdd = plurPerSecondPerChunk * BigInt(Math.ceil(secondsToAdd));
// Calculate total BZZ cost for display (per-chunk * 2^depth)
const chunks = BigInt(2) ** BigInt(depth);
const totalPlur = plurToAdd * chunks;
const plurStr = totalPlur.toString().padStart(17, '0');
const bzzDisplay = `${plurStr.slice(0, -16) || '0'}.${plurStr.slice(-16).slice(0, 4)}`;
// Find the button and disable it
const btn = daysInput.nextElementSibling;
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Processing...';
statusDiv.innerHTML = `Sending top-up transaction for ${days} day(s) (~${bzzDisplay} BZZ)...`;
statusDiv.className = 'topup-status';
try {
const response = await fetch(`${beeUrl}/stamps/topup/${batchId}/${plurToAdd.toString()}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
let errorMsg = `HTTP ${response.status}`;
try {
const errorData = await response.json();
errorMsg = errorData.message || errorData.error || errorMsg;
} catch (e) {}
throw new Error(errorMsg);
}
const result = await response.json();
statusDiv.innerHTML = `Top-up successful! Added ${days} days. Batch ID: <code style="font-size: 0.7rem; word-break: break-all;">${result.batchID || batchId}</code>`;
statusDiv.className = 'topup-status success';
daysInput.value = '';
// Reload stamps to show updated TTL
setTimeout(() => {
loadStamps();
}, 2000);
} catch (error) {
console.error('Top-up error:', error);
statusDiv.innerHTML = `Top-up failed: ${error.message}`;
statusDiv.className = 'topup-status error';
}
btn.disabled = false;
btn.textContent = originalText;
}
// Check connections and load stamps on page load
setTimeout(checkConnections, 500);
// ===== CLIENT-SIDE STAMPING FUNCTIONS =====
function toggleClientStamping() {
const enabled = document.getElementById('clientStampEnabled').checked;
const configDiv = document.getElementById('clientStampConfig');
clientStampingEnabled = enabled;
configDiv.style.display = enabled ? 'block' : 'none';
if (!enabled) {
// Reset state when disabled
clientStamper = null;
updateClientStampStatus('', '');
}
}
function updateClientStampStatus(message, type) {
const statusDiv = document.getElementById('clientStampStatus');
if (!message) {
statusDiv.innerHTML = '';
statusDiv.className = 'client-stamp-status';
return;
}
statusDiv.innerHTML = message;
statusDiv.className = 'client-stamp-status ' + type;
}
async function initClientStamper() {
const signerKeyInput = document.getElementById('clientSignerKey');
const batchIdInput = document.getElementById('clientBatchId');
const depthInput = document.getElementById('clientBatchDepth');
const signerKey = signerKeyInput.value.trim().replace(/^0x/, '');
const batchId = batchIdInput.value.trim().replace(/^0x/, '');
const depth = parseInt(depthInput.value) || 20;
if (!signerKey || signerKey.length !== 64) {
updateClientStampStatus('Please enter a valid 64-character hex private key', 'error');
return;
}
if (!batchId || batchId.length !== 64) {
updateClientStampStatus('Please enter a valid 64-character batch ID', 'error');
return;
}
updateClientStampStatus('Initializing stamper...', 'info');
try {
// Check if bee-js has the Stamper class
if (!BeeJs.Stamper) {
throw new Error('Stamper class not found in bee-js. Make sure you have the latest version (v10+).');
}
// Derive address from private key
let address;
try {
if (BeeJs.PrivateKey) {
const pk = new BeeJs.PrivateKey(signerKey);
address = pk.publicKey?.address?.toHex?.() || pk.address?.toHex?.();
if (!address) {
// Try alternative methods
const pubKey = pk.toPublicKey?.() || pk.publicKey;
address = pubKey?.toAddress?.()?.toHex?.() || pubKey?.address?.toHex?.();
}
}
} catch (e) {
console.log('PrivateKey method failed:', e.message);
}
// Fallback: derive address using feed writer
if (!address) {
try {
const tempBee = new BeeJs.Bee(getActiveUrl() || 'http://localhost:1633');
const tempTopic = '0000000000000000000000000000000000000000000000000000000000000000';
const writer = tempBee.makeFeedWriter(tempTopic, signerKey);
if (writer.owner) {
address = typeof writer.owner === 'string'
? writer.owner.replace(/^0x/, '')
: (writer.owner.toHex?.() || bytesToHex(writer.owner)).replace(/^0x/, '');
}
} catch (e) {
console.log('FeedWriter method failed:', e.message);
}
}
if (!address) {
throw new Error('Could not derive address from private key');
}
// Create the stamper
// Stamper.fromBlank(signer, batchId, depth)
clientStamper = BeeJs.Stamper.fromBlank(signerKey, batchId, depth);
clientSignerKey = signerKey;
clientSignerAddress = address;
// Update UI
document.getElementById('clientSignerAddress').textContent = '0x' + address;
document.getElementById('clientStamperState').textContent = 'Ready';
document.getElementById('clientStampInfo').style.display = 'block';
updateClientStampStatus('Stamper initialized successfully! Uploads will now use client-side stamping.', 'success');
// Save to localStorage (not the private key, just that it's enabled)
localStorage.setItem('swarm-client-stamping-enabled', 'true');
localStorage.setItem('swarm-client-batch-id', batchId);
localStorage.setItem('swarm-client-batch-depth', depth.toString());
} catch (e) {
console.error('Error initializing stamper:', e);
updateClientStampStatus('Error: ' + e.message, 'error');
clientStamper = null;
}
}
async function testClientStamping() {
if (!clientStamper) {
updateClientStampStatus('Please initialize the stamper first', 'error');
return;
}
const gatewayUrl = gatewayUrlInput.value.trim();
if (!gatewayUrl) {
updateClientStampStatus('Please configure a gateway URL', 'error');
return;
}
updateClientStampStatus('Testing client-side stamped upload...', 'info');
try {
const bee = new BeeJs.Bee(gatewayUrl);
const testData = new TextEncoder().encode('Hello from client-side stamping! ' + Date.now());
// Check if bee-js supports uploadChunk with envelope
// The flow is: create chunk -> stamp it -> upload stamped chunk
// For now, test with uploadData and see if we can pass the stamper
// The latest bee-js might have uploadData accept a stamper option
// Try using the stamper directly with the Bee instance
if (typeof bee.uploadData === 'function') {
// Check if uploadData accepts stamper or envelope options
const result = await bee.uploadData(clientStamper, testData);
const reference = typeof result.reference === 'string'
? result.reference
: (result.reference.toHex?.() || bytesToHex(result.reference));
updateClientStampStatus(
`Test upload successful!<br>Reference: <code style="font-size: 0.8em;">${reference}</code><br>` +
`<a href="${gatewayUrl}/bytes/${reference}" target="_blank" style="color: #81c784;">View on gateway</a>`,
'success'
);
document.getElementById('clientStamperState').textContent = 'Active - Last upload: ' + new Date().toLocaleTimeString();
} else {
throw new Error('uploadData method not found on Bee instance');
}
} catch (e) {
console.error('Test upload error:', e);
updateClientStampStatus('Test upload failed: ' + e.message, 'error');
}
}
// Check if client-side stamping can be used for an upload
function canUseClientStamping() {
return clientStampingEnabled && clientStamper !== null;
}
// Get the stamper or batch ID for uploads
function getStampForUpload() {
if (canUseClientStamping()) {
return clientStamper;
}
if (isLocalNodeAvailable) {
return batchIdInput.value.trim();
}
if (isGatewayAvailable) {
return '0000000000000000000000000000000000000000000000000000000000000000';
}
return null;
}
// ===== END CLIENT-SIDE STAMPING FUNCTIONS =====
// Drag and drop handlers
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', () => {
handleFiles(fileInput.files);
fileInput.value = '';
});
function showError(message) {
errorContainer.innerHTML = `<div class="error-message">${message}</div>`;
setTimeout(() => {
errorContainer.innerHTML = '';
}, 5000);
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function getFileIcon(type) {
if (type.startsWith('image/')) return '🖼️';
if (type.startsWith('video/')) return '🎬';
if (type.startsWith('audio/')) return '🎵';
if (type.includes('pdf')) return '📄';
if (type.includes('zip') || type.includes('tar') || type.includes('rar')) return '📦';
if (type.includes('text') || type.includes('javascript') || type.includes('json')) return '📝';
return '📁';
}
async function handleFiles(files) {
let beeUrl, stamp;
// Check if client-side stamping is enabled
if (canUseClientStamping()) {
beeUrl = gatewayUrlInput.value.trim();
stamp = clientStamper;
if (!beeUrl) {
showError('Please configure a gateway URL for client-side stamping');
return;
}
} else if (isLocalNodeAvailable) {
beeUrl = beeUrlInput.value.trim();
stamp = batchIdInput.value.trim();
if (!beeUrl || !stamp) {
showError('Please configure Bee URL and select a postage stamp');
return;
}
} else if (isGatewayAvailable) {
beeUrl = gatewayUrlInput.value.trim();
stamp = '0000000000000000000000000000000000000000000000000000000000000000';
} else {
showError('No connection available. Start a local Bee node or check gateway URL.');
return;
}
for (const file of files) {
await uploadFile(file, beeUrl, stamp);
}
}
async function uploadFile(file, beeUrl, stamp) {
const itemId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
const isClientStamped = stamp && typeof stamp === 'object' && stamp.stamp; // Check if it's a Stamper
const itemHtml = `
<div class="upload-item" id="item-${itemId}">
<div class="upload-item-icon">${getFileIcon(file.type)}</div>
<div class="upload-item-info">
<div class="upload-item-name">${file.name}</div>
<div class="upload-item-size">${formatFileSize(file.size)}${isClientStamped ? ' (client-stamped)' : ''}</div>
<div class="upload-item-link" id="link-${itemId}"></div>
</div>
<div class="upload-item-status status-uploading" id="status-${itemId}">Uploading...</div>
</div>
`;
uploadList.insertAdjacentHTML('afterbegin', itemHtml);
try {
const bee = new BeeJs.Bee(beeUrl);
const result = await bee.uploadFile(stamp, file, file.name, {
contentType: file.type,
deferred: false // Upload immediately to the network
});
const reference = result.reference;
const swarmUrl = `${beeUrl}/bzz/${reference}/`;
const gatewayUrl = `https://gateway.ethswarm.org/bzz/${reference}/`;
document.getElementById(`status-${itemId}`).className = 'upload-item-status status-success';
document.getElementById(`status-${itemId}`).textContent = 'Success';
document.getElementById(`link-${itemId}`).innerHTML = `
<a href="${gatewayUrl}" target="_blank">${reference}</a>
<button class="copy-btn" onclick="copyToClipboard('${reference}')">Copy Hash</button>
<button class="copy-btn" onclick="copyToClipboard('${gatewayUrl}')">Copy URL</button>
`;
} catch (error) {
console.error('Upload error:', error);
document.getElementById(`status-${itemId}`).className = 'upload-item-status status-error';
document.getElementById(`status-${itemId}`).textContent = 'Failed';
let errorMsg = error.message || 'Unknown error';
if (error.message && error.message.includes('Failed to fetch')) {
errorMsg = 'Cannot connect to Bee node. Check if it\'s running and CORS is enabled.';
}
document.getElementById(`link-${itemId}`).innerHTML = `<span style="color: #ff5252; font-size: 0.85rem;">${errorMsg}</span>`;
}
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => {
btn.textContent = originalText;
}, 1500);
});
}
// Debug functionality
async function debugHash() {
const hash = document.getElementById('debugHash').value.trim();
const beeUrl = getActiveUrl();
const resultsDiv = document.getElementById('debugResults');
const debugBtn = document.getElementById('debugBtn');
if (!hash) {
resultsDiv.innerHTML = '<div class="debug-item"><div class="debug-item-content" style="color: #ff5252;">Please enter a hash to inspect</div></div>';
return;
}
if (!beeUrl) {
resultsDiv.innerHTML = '<div class="debug-item"><div class="debug-item-content" style="color: #ff5252;">No Swarm connection available</div></div>';
return;
}
debugBtn.disabled = true;
debugBtn.textContent = 'Inspecting...';
resultsDiv.innerHTML = '<div class="debug-loading">Gathering information from your Bee node...</div>';
const results = [];
// Helper function to make API calls
async function apiCall(endpoint, description) {
try {
const response = await fetch(`${beeUrl}${endpoint}`);
const status = response.status;
let data = null;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
data = await response.json();
}
return { success: status >= 200 && status < 300, status, data, error: null };
} catch (error) {
return { success: false, status: null, data: null, error: error.message };
}
}
// 1. Check if chunk exists locally
try {
const chunkResp = await fetch(`${beeUrl}/chunks/${hash}`, { method: 'HEAD' });
results.push({
title: 'Local Chunk Status',
status: chunkResp.status === 200 ? 'success' : 'error',
statusText: chunkResp.status === 200 ? 'EXISTS' : 'NOT FOUND',
content: chunkResp.status === 200
? 'The root chunk exists on your local node.'
: 'The root chunk was not found on your local node.'
});
} catch (e) {
results.push({
title: 'Local Chunk Status',
status: 'error',
statusText: 'ERROR',
content: `Failed to check: ${e.message}`
});
}
// 2. Check pin status
const pinResult = await apiCall(`/pins/${hash}`, 'Pin Status');
if (pinResult.success && pinResult.data) {
results.push({
title: 'Pin Status',
status: 'success',
statusText: 'PINNED',
content: 'This content is pinned on your node.',
json: pinResult.data
});
} else {
results.push({
title: 'Pin Status',
status: 'warning',
statusText: 'NOT PINNED',
content: 'This content is not pinned on your node.'
});
}
// 3. Check stewardship / retrievability (this can take time)
results.push({
title: 'Network Retrievability',
status: 'info',
statusText: 'CHECKING...',
content: 'Checking if content is retrievable from the network...',
id: 'stewardship-result'
});
// Render current results while stewardship check runs
renderDebugResults(results);
// Run stewardship check (can be slow)
try {
const stewardshipResp = await fetch(`${beeUrl}/stewardship/${hash}`, {
signal: AbortSignal.timeout(30000)
});
const stewardshipIdx = results.findIndex(r => r.id === 'stewardship-result');
if (stewardshipResp.status === 200) {
const stewardshipData = await stewardshipResp.json();
results[stewardshipIdx] = {
title: 'Network Retrievability',
status: stewardshipData.isRetrievable ? 'success' : 'error',
statusText: stewardshipData.isRetrievable ? 'RETRIEVABLE' : 'NOT RETRIEVABLE',
content: stewardshipData.isRetrievable
? 'Content can be retrieved from the Swarm network.'
: 'Content cannot currently be retrieved from the network. It may not have propagated yet.',
json: stewardshipData
};
} else {
results[stewardshipIdx] = {
title: 'Network Retrievability',
status: 'error',
statusText: 'UNKNOWN',
content: `Stewardship check returned status ${stewardshipResp.status}`
};
}
} catch (e) {
const stewardshipIdx = results.findIndex(r => r.id === 'stewardship-result');
results[stewardshipIdx] = {
title: 'Network Retrievability',
status: 'warning',
statusText: 'TIMEOUT',
content: `Stewardship check timed out or failed: ${e.message}. This is common for content not yet on the network.`
};
}
// 4. Try to get file info via bzz endpoint
try {
const bzzResp = await fetch(`${beeUrl}/bzz/${hash}/`, { method: 'HEAD' });
const contentType = bzzResp.headers.get('content-type');
const contentLength = bzzResp.headers.get('content-length');
const swarmTag = bzzResp.headers.get('swarm-tag');
if (bzzResp.status === 200) {
results.push({
title: 'File Information',
status: 'success',
statusText: 'AVAILABLE',
content: `Content-Type: ${contentType || 'unknown'}\nContent-Length: ${contentLength ? formatFileSize(parseInt(contentLength)) : 'unknown'}${swarmTag ? `\nSwarm-Tag: ${swarmTag}` : ''}`
});
} else {
results.push({
title: 'File Information',
status: 'warning',
statusText: `HTTP ${bzzResp.status}`,
content: 'Could not retrieve file metadata from bzz endpoint.'
});
}
} catch (e) {
results.push({
title: 'File Information',
status: 'error',
statusText: 'ERROR',
content: `Failed to get file info: ${e.message}`
});
}
// 5. Get node status for context
const statusResult = await apiCall('/status', 'Node Status');
if (statusResult.success && statusResult.data) {
const s = statusResult.data;
results.push({
title: 'Your Node Status',
status: 'info',
statusText: s.beeMode.toUpperCase(),
content: `Mode: ${s.beeMode}\nConnected Peers: ${s.connectedPeers}\nNeighborhood Size: ${s.neighborhoodSize}\nStorage Radius: ${s.storageRadius}\nReachable: ${s.isReachable ? 'Yes' : 'No'}`,
json: s
});
}
// 6. Check topology for relevant peers
const topoResult = await apiCall('/topology', 'Topology');
if (topoResult.success && topoResult.data) {
const t = topoResult.data;
// Calculate which bin the hash would fall into
const hashPrefix = hash.substring(0, 2);
const nodePrefix = t.baseAddr.substring(0, 2);
results.push({
title: 'Network Topology',
status: 'info',
statusText: `${t.connected} PEERS`,
content: `Total Known Peers: ${t.population}\nConnected Peers: ${t.connected}\nNetwork Depth: ${t.depth}\nReachability: ${t.reachability}\nNetwork: ${t.networkAvailability}\n\nYour node address: ${t.baseAddr.substring(0, 16)}...\nTarget hash prefix: ${hashPrefix}...`
});
}
// 7. Gateway check
results.push({
title: 'Public Gateway',
status: 'info',
statusText: 'LINK',
content: `Gateway URL: https://gateway.ethswarm.org/bzz/${hash}/\n\nNote: Public gateway access depends on network propagation.`,
link: `https://gateway.ethswarm.org/bzz/${hash}/`
});
renderDebugResults(results);
debugBtn.disabled = false;
debugBtn.textContent = 'Inspect';
}
function renderDebugResults(results) {
const resultsDiv = document.getElementById('debugResults');
resultsDiv.innerHTML = results.map(r => `
<div class="debug-item">
<div class="debug-item-title">
${r.title}
<span class="debug-status ${r.status}">${r.statusText}</span>
</div>
<div class="debug-item-content">
<pre style="margin: 0; white-space: pre-wrap;">${r.content}</pre>
${r.json ? `<div class="debug-json">${JSON.stringify(r.json, null, 2)}</div>` : ''}
${r.link ? `<a href="${r.link}" target="_blank" style="color: #f90;">Open in Gateway</a>` : ''}
</div>
</div>
`).join('');
}
// Allow Enter key to trigger debug
document.getElementById('debugHash').addEventListener('keypress', (e) => {
if (e.key === 'Enter') debugHash();
});
// URL to Swarm functionality
async function fetchAndUploadUrl() {
const url = document.getElementById('fetchUrl').value.trim();
const statusDiv = document.getElementById('urlStatus');
const fetchBtn = document.getElementById('fetchBtn');
if (!url) {
statusDiv.innerHTML = '<span style="color: #ff5252;">Please enter a URL</span>';
return;
}
let beeUrl, stamp;
// Check if client-side stamping is enabled
if (canUseClientStamping()) {
beeUrl = gatewayUrlInput.value.trim();
stamp = clientStamper;
if (!beeUrl) {
statusDiv.innerHTML = '<span style="color: #ff5252;">Please configure a gateway URL for client-side stamping</span>';
return;
}
} else if (isLocalNodeAvailable) {
beeUrl = beeUrlInput.value.trim();
stamp = batchIdInput.value.trim();
if (!beeUrl || !stamp) {
statusDiv.innerHTML = '<span style="color: #ff5252;">Please configure Bee URL and select a postage stamp</span>';
return;
}
} else if (isGatewayAvailable) {
beeUrl = gatewayUrlInput.value.trim();
stamp = '0000000000000000000000000000000000000000000000000000000000000000';
} else {
statusDiv.innerHTML = '<span style="color: #ff5252;">No connection available</span>';
return;
}
// Basic URL validation
try {
new URL(url);
} catch {
statusDiv.innerHTML = '<span style="color: #ff5252;">Invalid URL format</span>';
return;
}
fetchBtn.disabled = true;
fetchBtn.textContent = 'Fetching...';
statusDiv.innerHTML = `
<div>Step 1/2: Fetching content...</div>
<div class="url-progress">
<div class="url-progress-bar">
<div class="url-progress-fill" style="width: 20%"></div>
</div>
</div>
`;
try {
// Fetch the URL
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const blob = await response.blob();
const contentType = response.headers.get('content-type') || 'application/octet-stream';
// Extract filename from URL
let filename = url.split('/').pop().split('?')[0] || 'downloaded-file';
// Remove any invalid characters
filename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
if (!filename.includes('.')) {
// Add extension based on content type
const ext = contentType.split('/')[1]?.split(';')[0] || 'bin';
filename += '.' + ext;
}
statusDiv.innerHTML = `
<div>Step 2/2: Uploading to Swarm...</div>
<div style="font-size: 0.85rem; color: #888; margin-top: 0.25rem;">
File: ${filename} (${formatFileSize(blob.size)})
</div>
<div class="url-progress">
<div class="url-progress-bar">
<div class="url-progress-fill" style="width: 60%"></div>
</div>
</div>
`;
// Create File object and upload to Swarm
const file = new File([blob], filename, { type: contentType });
const bee = new BeeJs.Bee(beeUrl);
const result = await bee.uploadFile(stamp, file, filename, {
contentType: contentType,
deferred: false
});
const reference = result.reference;
const gatewayUrl = `https://gateway.ethswarm.org/bzz/${reference}/`;
statusDiv.innerHTML = `
<div style="color: #00c853; font-weight: 600;">Upload successful!</div>
<div style="font-size: 0.85rem; margin-top: 0.5rem;">
<strong>Swarm Hash:</strong><br>
<code style="color: #f90; word-break: break-all;">${reference}</code>
</div>
<div style="margin-top: 0.75rem;">
<button class="copy-btn" onclick="copyToClipboard('${reference}')" style="margin: 0;">Copy Hash</button>
<button class="copy-btn" onclick="copyToClipboard('${gatewayUrl}')">Copy Gateway URL</button>
<a href="${beeUrl}/bzz/${reference}/" target="_blank" class="copy-btn" style="text-decoration: none; display: inline-block;">Open Local</a>
<a href="${gatewayUrl}" target="_blank" class="copy-btn" style="text-decoration: none; display: inline-block;">Open Gateway</a>
</div>
<div class="url-progress" style="margin-top: 0.5rem;">
<div class="url-progress-bar">
<div class="url-progress-fill" style="width: 100%; background: linear-gradient(90deg, #00c853, #69f0ae);"></div>
</div>
</div>
`;
// Also add to the upload list
const itemId = Date.now() + '-url-' + Math.random().toString(36).substr(2, 9);
const itemHtml = `
<div class="upload-item" id="item-${itemId}">
<div class="upload-item-icon">${getFileIcon(contentType)}</div>
<div class="upload-item-info">
<div class="upload-item-name">${filename}</div>
<div class="upload-item-size">${formatFileSize(blob.size)} (from URL)</div>
<div class="upload-item-link">
<a href="${gatewayUrl}" target="_blank">${reference}</a>
<button class="copy-btn" onclick="copyToClipboard('${reference}')">Copy Hash</button>
<button class="copy-btn" onclick="copyToClipboard('${gatewayUrl}')">Copy URL</button>
</div>
</div>
<div class="upload-item-status status-success">Success</div>
</div>
`;
uploadList.insertAdjacentHTML('afterbegin', itemHtml);
} catch (error) {
console.error('URL fetch error:', error);
let errorMessage = error.message;
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError') || error.name === 'TypeError') {
errorMessage = `
<strong>CORS Error</strong><br>
This URL doesn't allow cross-origin requests from browsers.<br><br>
<strong>Alternatives:</strong><br>
• Download the file manually and drag it here<br>
• Use <code>curl "${url}" -o file</code> in terminal
`;
}
statusDiv.innerHTML = `
<div style="color: #ff5252;">
<strong>Error:</strong><br>
${errorMessage}
</div>
`;
}
fetchBtn.disabled = false;
fetchBtn.textContent = 'Fetch & Upload';
}
// Allow Enter key to trigger URL fetch
document.getElementById('fetchUrl').addEventListener('keypress', (e) => {
if (e.key === 'Enter') fetchAndUploadUrl();
});
// IPFS to Swarm functionality - supports files and directories
let ipfsMigrationAborted = false;
const IPFS_GATEWAYS = [
'https://dweb.link',
'https://ipfs.io',
'https://cloudflare-ipfs.com',
'https://gateway.pinata.cloud'
];
function updateHeliaStatus(status, text) {
const statusEl = document.getElementById('heliaStatus');
const textEl = statusEl.querySelector('.status-text');
statusEl.className = 'helia-status ' + status;
textEl.textContent = text;
}
// Set gateway mode immediately
setTimeout(() => updateHeliaStatus('connected', 'Gateway mode'), 100);
// Fetch a single file from IPFS gateway
async function fetchIpfsFile(cid, path = '') {
const fullPath = path ? `${cid}/${path}` : cid;
for (const gateway of IPFS_GATEWAYS) {
try {
const url = `${gateway}/ipfs/${fullPath}`;
const response = await fetch(url, {
signal: AbortSignal.timeout(30000)
});
if (response.ok) {
return {
blob: await response.blob(),
contentType: response.headers.get('content-type') || 'application/octet-stream',
gateway
};
}
} catch (e) {
console.log(`Gateway ${gateway} failed for ${fullPath}:`, e.message);
}
}
throw new Error(`Failed to fetch ${fullPath} from all gateways`);
}
// List directory contents using IPFS gateway with dag-json format
async function listIpfsDirectory(cid) {
// Try subdomain gateway with dag-json format (most reliable)
const subdomainGateways = [
`https://${cid}.ipfs.dweb.link`,
`https://${cid}.ipfs.cf-ipfs.com`,
`https://${cid}.ipfs.w3s.link`
];
for (const baseUrl of subdomainGateways) {
try {
const url = `${baseUrl}/?format=dag-json`;
console.log('Trying dag-json:', url);
const response = await fetch(url, {
signal: AbortSignal.timeout(30000)
});
if (response.ok) {
const data = await response.json();
if (data.Links && Array.isArray(data.Links)) {
console.log('Found', data.Links.length, 'entries via dag-json');
return data.Links.map(link => ({
name: link.Name,
cid: link.Hash ? (link.Hash['/'] || link.Hash) : null,
size: link.Tsize || 0,
type: link.Name && !link.Name.includes('.') ? 'directory' : 'file'
}));
}
}
} catch (e) {
console.log(`dag-json failed on subdomain gateway:`, e.message);
}
}
// Fallback: try path-based gateways with dag-json
for (const gateway of IPFS_GATEWAYS) {
try {
const url = `${gateway}/ipfs/${cid}?format=dag-json`;
console.log('Trying path dag-json:', url);
const response = await fetch(url, {
signal: AbortSignal.timeout(30000)
});
if (response.ok) {
const data = await response.json();
if (data.Links && Array.isArray(data.Links)) {
console.log('Found', data.Links.length, 'entries via path dag-json');
return data.Links.map(link => ({
name: link.Name,
cid: link.Hash ? (link.Hash['/'] || link.Hash) : null,
size: link.Tsize || 0,
type: link.Name && !link.Name.includes('.') ? 'directory' : 'file'
}));
}
}
} catch (e) {
console.log(`dag-json failed on ${gateway}:`, e.message);
}
}
return null; // Not a directory or couldn't list
}
// Recursively collect all files from IPFS directory
async function collectIpfsFiles(cid, basePath = '', statusDiv, stats) {
if (ipfsMigrationAborted) throw new Error('Migration aborted');
const entries = await listIpfsDirectory(cid);
if (!entries) {
// It's a file, not a directory
return null;
}
const files = [];
for (const entry of entries) {
if (ipfsMigrationAborted) throw new Error('Migration aborted');
const fullPath = basePath ? `${basePath}/${entry.name}` : entry.name;
if (entry.type === 'directory') {
// Recursively process subdirectory
stats.dirsScanned++;
statusDiv.innerHTML = `
<div>Scanning IPFS directory...</div>
<div style="font-size: 0.85rem; color: #888; margin-top: 0.25rem;">
Found: ${stats.filesFound} files in ${stats.dirsScanned} directories<br>
Current: ${fullPath.substring(0, 60)}${fullPath.length > 60 ? '...' : ''}
</div>
<div class="ipfs-progress">
<div class="ipfs-progress-bar">
<div class="ipfs-progress-fill" style="width: 15%"></div>
</div>
</div>
<button class="copy-btn" style="margin-top: 0.5rem; background: #ff5252; border-color: #ff5252; color: #fff;" onclick="ipfsMigrationAborted = true;">Cancel</button>
`;
const subFiles = await collectIpfsFiles(entry.cid || cid, fullPath, statusDiv, stats);
if (subFiles) {
files.push(...subFiles);
}
} else {
// It's a file
stats.filesFound++;
files.push({
path: fullPath,
name: entry.name,
entryCid: entry.cid
});
}
}
return files;
}
// Create a tar file from an array of files
// TAR format: 512-byte header + file content padded to 512 bytes, ends with 1024 zero bytes
function createTar(files) {
const encoder = new TextEncoder();
const chunks = [];
for (const file of files) {
// Create 512-byte header
const header = new Uint8Array(512);
// File name (100 bytes) - use path
const nameBytes = encoder.encode(file.path);
header.set(nameBytes.slice(0, 100), 0);
// File mode (8 bytes) - octal string (match bee-js: 0000777)
const mode = encoder.encode('0000777\0');
header.set(mode, 100);
// Owner UID (8 bytes) - match bee-js: 0001750
const uid = encoder.encode('0001750\0');
header.set(uid, 108);
// Owner GID (8 bytes) - match bee-js: 0001750
const gid = encoder.encode('0001750\0');
header.set(gid, 116);
// File size in octal (12 bytes)
const size = file.data.length;
const sizeOctal = size.toString(8).padStart(11, '0') + '\0';
header.set(encoder.encode(sizeOctal), 124);
// Modification time (12 bytes)
const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0') + '\0';
header.set(encoder.encode(mtime), 136);
// Checksum placeholder (8 spaces)
header.set(encoder.encode(' '), 148);
// Type flag - '0' for regular file
header[156] = 48; // ASCII '0'
// Link name (100 bytes) - empty
// USTAR magic at offset 257: 'ustar\0' then version '00' at 263
header.set(encoder.encode('ustar'), 257);
header[262] = 0; // null after ustar
header.set(encoder.encode('00'), 263); // version
// Calculate checksum
let checksum = 0;
for (let i = 0; i < 512; i++) {
checksum += header[i];
}
const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 ';
header.set(encoder.encode(checksumOctal), 148);
chunks.push(header);
// File content
chunks.push(file.data);
// Pad to 512-byte boundary
const padding = (512 - (size % 512)) % 512;
if (padding > 0) {
chunks.push(new Uint8Array(padding));
}
}
// End of archive: two 512-byte blocks of zeros
chunks.push(new Uint8Array(1024));
// Combine all chunks
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const tarData = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
tarData.set(chunk, offset);
offset += chunk.length;
}
return tarData;
}
// Determine content type from filename
function getContentTypeFromPath(path) {
const ext = path.split('.').pop().toLowerCase();
const types = {
'html': 'text/html',
'htm': 'text/html',
'css': 'text/css',
'js': 'application/javascript',
'json': 'application/json',
'xml': 'application/xml',
'txt': 'text/plain',
'md': 'text/markdown',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'webp': 'image/webp',
'ico': 'image/x-icon',
'mp4': 'video/mp4',
'webm': 'video/webm',
'mp3': 'audio/mpeg',
'ogg': 'audio/ogg',
'wav': 'audio/wav',
'pdf': 'application/pdf',
'woff': 'font/woff',
'woff2': 'font/woff2',
'ttf': 'font/ttf',
'eot': 'application/vnd.ms-fontobject'
};
return types[ext] || 'application/octet-stream';
}
async function fetchIpfsAndUpload() {
const cidInput = document.getElementById('ipfsCid').value.trim();
const statusDiv = document.getElementById('ipfsStatus');
const ipfsBtn = document.getElementById('ipfsBtn');
ipfsMigrationAborted = false;
// Clean up CID input - handle various formats
let cid = cidInput;
if (cid.startsWith('ipfs://')) cid = cid.slice(7);
if (cid.startsWith('/ipfs/')) cid = cid.slice(6);
const gatewayMatch = cid.match(/\/ipfs\/([a-zA-Z0-9]+)/);
if (gatewayMatch) cid = gatewayMatch[1];
cid = cid.split('/')[0].split('?')[0];
if (!cid) {
statusDiv.innerHTML = '<span style="color: #ff5252;">Please enter an IPFS CID</span>';
return;
}
let beeUrl, stamp;
// Check if client-side stamping is enabled
if (canUseClientStamping()) {
beeUrl = gatewayUrlInput.value.trim();
stamp = clientStamper;
if (!beeUrl) {
statusDiv.innerHTML = '<span style="color: #ff5252;">Please configure a gateway URL for client-side stamping</span>';
return;
}
} else if (isLocalNodeAvailable) {
beeUrl = beeUrlInput.value.trim();
stamp = batchIdInput.value.trim();
if (!beeUrl || !stamp) {
statusDiv.innerHTML = '<span style="color: #ff5252;">Please configure Bee URL and select a postage stamp</span>';
return;
}
} else if (isGatewayAvailable) {
beeUrl = gatewayUrlInput.value.trim();
stamp = '0000000000000000000000000000000000000000000000000000000000000000';
} else {
statusDiv.innerHTML = '<span style="color: #ff5252;">No connection available</span>';
return;
}
if (!cid.match(/^(Qm[a-zA-Z0-9]{44}|bafy[a-zA-Z0-9]+|bafk[a-zA-Z0-9]+|bafyb[a-zA-Z0-9]+)$/)) {
statusDiv.innerHTML = '<span style="color: #ff5252;">Invalid CID format</span>';
return;
}
ipfsBtn.disabled = true;
ipfsBtn.textContent = 'Migrating...';
try {
// First, check if it's a directory
statusDiv.innerHTML = `
<div>Checking if CID is a file or directory...</div>
<div class="ipfs-progress">
<div class="ipfs-progress-bar">
<div class="ipfs-progress-fill" style="width: 5%"></div>
</div>
</div>
`;
const dirEntries = await listIpfsDirectory(cid);
if (dirEntries && dirEntries.length > 0) {
// It's a directory - migrate entire website
await migrateIpfsDirectory(cid, dirEntries, beeUrl, stamp, statusDiv);
} else {
// It's a single file
await migrateIpfsSingleFile(cid, beeUrl, stamp, statusDiv);
}
} catch (error) {
console.error('IPFS migration error:', error);
if (error.message === 'Migration aborted') {
statusDiv.innerHTML = `
<div style="color: #ffc107;">
<strong>Migration cancelled</strong><br>
The migration was stopped by user request.
</div>
`;
} else {
statusDiv.innerHTML = `
<div style="color: #ff5252;">
<strong>Error:</strong> ${error.message}<br><br>
<strong>Troubleshooting:</strong><br>
• Verify the CID is correct<br>
• Try fetching from <a href="https://dweb.link/ipfs/${cid}" target="_blank" style="color: #4dd0e1;">dweb.link</a> to verify availability
</div>
`;
}
}
ipfsBtn.disabled = false;
ipfsBtn.textContent = 'Migrate to Swarm';
}
async function migrateIpfsSingleFile(cid, beeUrl, stamp, statusDiv) {
statusDiv.innerHTML = `
<div>Fetching file from IPFS...</div>
<div class="ipfs-progress">
<div class="ipfs-progress-bar">
<div class="ipfs-progress-fill" style="width: 30%"></div>
</div>
</div>
`;
const fetchResult = await fetchIpfsFile(cid);
const { blob, contentType } = fetchResult;
const filename = `ipfs-${cid.substring(0, 12)}`;
statusDiv.innerHTML = `
<div>Uploading to Swarm...</div>
<div style="font-size: 0.85rem; color: #888;">Size: ${formatFileSize(blob.size)}</div>
<div class="ipfs-progress">
<div class="ipfs-progress-bar">
<div class="ipfs-progress-fill" style="width: 60%"></div>
</div>
</div>
`;
const file = new File([blob], filename, { type: contentType });
const bee = new BeeJs.Bee(beeUrl);
const result = await bee.uploadFile(stamp, file, filename, {
contentType: contentType,
deferred: false
});
const reference = result.reference;
const swarmGatewayUrl = `https://gateway.ethswarm.org/bzz/${reference}/`;
statusDiv.innerHTML = `
<div style="color: #00c853; font-weight: 600;">Successfully migrated file to Swarm!</div>
<div style="font-size: 0.85rem; margin-top: 0.5rem;">
<strong>IPFS CID:</strong> <code style="color: #4dd0e1;">${cid}</code><br>
<strong>Swarm Hash:</strong> <code style="color: #f90; word-break: break-all;">${reference}</code><br>
<strong>Size:</strong> ${formatFileSize(blob.size)}
</div>
<div style="margin-top: 0.75rem;">
<button class="copy-btn" onclick="copyToClipboard('${reference}')" style="margin: 0;">Copy Swarm Hash</button>
<a href="${swarmGatewayUrl}" target="_blank" class="copy-btn" style="text-decoration: none; display: inline-block;">Open Gateway</a>
</div>
`;
}
async function migrateIpfsDirectory(cid, initialEntries, beeUrl, stamp, statusDiv) {
const stats = { filesFound: 0, dirsScanned: 1 };
statusDiv.innerHTML = `
<div>Scanning IPFS directory structure...</div>
<div style="font-size: 0.85rem; color: #888;">This may take a while for large websites...</div>
<div class="ipfs-progress">
<div class="ipfs-progress-bar">
<div class="ipfs-progress-fill" style="width: 10%"></div>
</div>
</div>
<button class="copy-btn" style="margin-top: 0.5rem; background: #ff5252; border-color: #ff5252; color: #fff;" onclick="ipfsMigrationAborted = true;">Cancel</button>
`;
// Collect all files recursively
const allFiles = [];
for (const entry of initialEntries) {
if (ipfsMigrationAborted) throw new Error('Migration aborted');
if (entry.type === 'directory') {
stats.dirsScanned++;
const subFiles = await collectIpfsFiles(entry.cid || cid, entry.name, statusDiv, stats);
if (subFiles) allFiles.push(...subFiles);
} else {
stats.filesFound++;
allFiles.push({
path: entry.name,
name: entry.name,
entryCid: entry.cid
});
}
}
if (allFiles.length === 0) {
throw new Error('No files found in directory');
}
statusDiv.innerHTML = `
<div>Found ${allFiles.length} files. Starting download...</div>
<div class="ipfs-progress">
<div class="ipfs-progress-bar">
<div class="ipfs-progress-fill" style="width: 20%"></div>
</div>
</div>
<button class="copy-btn" style="margin-top: 0.5rem; background: #ff5252; border-color: #ff5252; color: #fff;" onclick="ipfsMigrationAborted = true;">Cancel</button>
`;
// Download all files
const downloadedFiles = [];
let totalSize = 0;
let downloadedCount = 0;
let failedCount = 0;
const failedFiles = [];
for (const fileInfo of allFiles) {
if (ipfsMigrationAborted) throw new Error('Migration aborted');
downloadedCount++;
const progress = 20 + (downloadedCount / allFiles.length) * 50;
statusDiv.innerHTML = `
<div>Downloading files from IPFS...</div>
<div style="font-size: 0.85rem; color: #888; margin-top: 0.25rem;">
Progress: ${downloadedCount}/${allFiles.length} files (${failedCount} failed)<br>
Downloaded: ${formatFileSize(totalSize)}<br>
Current: ${fileInfo.path.substring(0, 50)}${fileInfo.path.length > 50 ? '...' : ''}
</div>
<div class="ipfs-progress">
<div class="ipfs-progress-bar">
<div class="ipfs-progress-fill" style="width: ${progress}%"></div>
</div>
</div>
<button class="copy-btn" style="margin-top: 0.5rem; background: #ff5252; border-color: #ff5252; color: #fff;" onclick="ipfsMigrationAborted = true;">Cancel</button>
`;
try {
const result = await fetchIpfsFile(cid, fileInfo.path);
const contentType = getContentTypeFromPath(fileInfo.path);
downloadedFiles.push({
path: fileInfo.path,
data: new Uint8Array(await result.blob.arrayBuffer()),
contentType: contentType
});
totalSize += result.blob.size;
} catch (e) {
console.error(`Failed to download ${fileInfo.path}:`, e.message);
failedCount++;
failedFiles.push(fileInfo.path);
}
}
if (downloadedFiles.length === 0) {
throw new Error('Failed to download any files');
}
// Upload to Swarm as a collection
statusDiv.innerHTML = `
<div>Uploading ${downloadedFiles.length} files to Swarm...</div>
<div style="font-size: 0.85rem; color: #888; margin-top: 0.25rem;">
Total size: ${formatFileSize(totalSize)}<br>
${failedCount > 0 ? `<span style="color: #ffc107;">${failedCount} files could not be downloaded</span>` : ''}
</div>
<div class="ipfs-progress">
<div class="ipfs-progress-bar">
<div class="ipfs-progress-fill" style="width: 75%"></div>
</div>
</div>
`;
// Find index document
const indexPath = downloadedFiles.find(f =>
f.path === 'index.html' || f.path.endsWith('/index.html')
)?.path || 'index.html';
const indexDocument = indexPath.split('/').pop() || 'index.html';
console.log('Uploading collection with', downloadedFiles.length, 'files');
console.log('Index document:', indexDocument);
console.log('First 10 file paths:', downloadedFiles.slice(0, 10).map(f => f.path));
console.log('First 10 file sizes:', downloadedFiles.slice(0, 10).map(f => f.data.length));
// Verify files have valid data
const validFiles = downloadedFiles.filter(f => f.path && f.data && f.data.length > 0);
console.log('Valid files:', validFiles.length, 'of', downloadedFiles.length);
if (validFiles.length === 0) {
throw new Error('No valid files to upload');
}
// Use tar upload - more reliable for preserving directory structure
const tarData = createTar(validFiles);
console.log('Created tar with', validFiles.length, 'files, size:', tarData.length);
// Debug: show first 200 bytes of tar as hex
const hexBytes = Array.from(tarData.slice(0, 200)).map(b => b.toString(16).padStart(2, '0')).join(' ');
console.log('Tar header (first 200 bytes):', hexBytes);
// Upload tar directly to bzz endpoint
// Extract batch ID string from stamp (which could be a Stamper object or string)
const batchIdStr = typeof stamp === 'string'
? stamp
: (stamp.batchId?.toHex?.() || stamp.batchId?.toString?.() || stamp.batchId);
const response = await fetch(`${beeUrl}/bzz`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-tar',
'Swarm-Collection': 'true',
'Swarm-Index-Document': indexDocument,
'Swarm-Postage-Batch-Id': batchIdStr
},
body: tarData
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Upload failed: ${response.status} ${errorText}`);
}
const result = await response.json();
const reference = result.reference;
const swarmGatewayUrl = `https://gateway.ethswarm.org/bzz/${reference}/`;
const localUrl = `${beeUrl}/bzz/${reference}/`;
statusDiv.innerHTML = `
<div style="color: #00c853; font-weight: 600;">Successfully migrated website to Swarm!</div>
<div style="font-size: 0.85rem; margin-top: 0.5rem;">
<strong>IPFS CID:</strong> <code style="color: #4dd0e1;">${cid}</code><br>
<strong>Swarm Hash:</strong> <code style="color: #f90; word-break: break-all;">${reference}</code><br>
<strong>Files:</strong> ${downloadedFiles.length} (${failedCount} failed)<br>
<strong>Total Size:</strong> ${formatFileSize(totalSize)}
</div>
${failedFiles.length > 0 ? `
<details style="margin-top: 0.5rem; font-size: 0.8rem;">
<summary style="color: #ffc107; cursor: pointer;">Show ${failedFiles.length} failed files</summary>
<div style="max-height: 100px; overflow-y: auto; margin-top: 0.25rem; color: #888;">
${failedFiles.map(f => `• ${f}`).join('<br>')}
</div>
</details>
` : ''}
<div style="margin-top: 0.75rem;">
<button class="copy-btn" onclick="copyToClipboard('${reference}')" style="margin: 0;">Copy Swarm Hash</button>
<button class="copy-btn" onclick="copyToClipboard('${swarmGatewayUrl}')">Copy Gateway URL</button>
<a href="${localUrl}" target="_blank" class="copy-btn" style="text-decoration: none; display: inline-block;">Open Local</a>
<a href="${swarmGatewayUrl}" target="_blank" class="copy-btn" style="text-decoration: none; display: inline-block;">Open Gateway</a>
</div>
<div class="ipfs-progress" style="margin-top: 0.5rem;">
<div class="ipfs-progress-bar">
<div class="ipfs-progress-fill" style="width: 100%; background: linear-gradient(90deg, #00c853, #69f0ae);"></div>
</div>
</div>
`;
// Add to upload list
const itemId = Date.now() + '-ipfs-dir-' + Math.random().toString(36).substr(2, 9);
const itemHtml = `
<div class="upload-item" id="item-${itemId}">
<div class="upload-item-icon">🌐</div>
<div class="upload-item-info">
<div class="upload-item-name">Website from IPFS</div>
<div class="upload-item-size">${downloadedFiles.length} files, ${formatFileSize(totalSize)} (from IPFS: ${cid.substring(0, 12)}...)</div>
<div class="upload-item-link">
<a href="${swarmGatewayUrl}" target="_blank">${reference}</a>
<button class="copy-btn" onclick="copyToClipboard('${reference}')">Copy Hash</button>
<button class="copy-btn" onclick="copyToClipboard('${swarmGatewayUrl}')">Copy URL</button>
</div>
</div>
<div class="upload-item-status status-success">Migrated</div>
</div>
`;
uploadList.insertAdjacentHTML('afterbegin', itemHtml);
}
// Allow Enter key to trigger IPFS fetch
document.getElementById('ipfsCid').addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !document.getElementById('ipfsBtn').disabled) {
fetchIpfsAndUpload();
}
});
// ==========================================
// Message Board (Swarm Feed) Functionality
// ==========================================
// Topic must be 32 bytes (64 hex chars) - this is "swarm-message-board-v1" padded with zeros
const BOARD_TOPIC_NAME = 'swarm-message-board-v1';
// Convert to hex and pad to 32 bytes (44 hex chars + 20 zeros = 64)
const BOARD_TOPIC = '737761726d2d6d6573736167652d626f6172642d763100000000000000000000';
let boardPrivateKey = null;
let boardOwnerAddress = null;
let boardFeedAddress = null;
let boardIsReadOnly = false;
let boardPendingImages = [];
// Generate random bytes for private key
function generateRandomBytes(length) {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('');
}
// Convert hex string to Uint8Array
function hexToBytes(hex) {
hex = hex.replace(/^0x/, '');
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
// Convert Uint8Array to hex string
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
// Derive Ethereum address from private key
async function privateKeyToAddress(privateKeyHex) {
// Ensure hex format with 0x prefix for bee-js
const keyWithPrefix = privateKeyHex.startsWith('0x') ? privateKeyHex : '0x' + privateKeyHex;
const keyWithoutPrefix = privateKeyHex.replace(/^0x/, '');
// Validate it's 64 hex characters
if (!/^[a-fA-F0-9]{64}$/.test(keyWithoutPrefix)) {
throw new Error('Invalid private key format');
}
try {
// Method 1: Try using BeeJs.PrivateKey if available (newer bee-js versions)
if (typeof BeeJs.PrivateKey === 'function') {
const pk = new BeeJs.PrivateKey(keyWithPrefix);
const addr = pk.publicKey().address();
if (addr.toHex) return addr.toHex().replace(/^0x/, '');
if (addr instanceof Uint8Array) return bytesToHex(addr);
return String(addr).replace(/^0x/, '');
}
} catch (e) {
console.log('PrivateKey method failed:', e.message);
}
try {
// Method 2: Try Utils.Eth or similar
if (BeeJs.Utils?.Eth?.makePrivateKeySigner) {
const signer = BeeJs.Utils.Eth.makePrivateKeySigner(hexToBytes(keyWithoutPrefix));
return bytesToHex(signer.address);
}
} catch (e) {
console.log('Utils.Eth method failed:', e.message);
}
try {
// Method 3: Create a feed writer and extract owner from it
const beeUrl = getActiveUrl() || 'http://localhost:1633';
const bee = new BeeJs.Bee(beeUrl);
const writer = bee.makeFeedWriter(BOARD_TOPIC, keyWithPrefix);
// The writer should have an owner property
if (writer.owner) {
const owner = writer.owner;
if (typeof owner === 'string') return owner.replace(/^0x/, '');
if (owner instanceof Uint8Array) return bytesToHex(owner);
if (owner.toHex) return owner.toHex().replace(/^0x/, '');
}
} catch (e) {
console.log('FeedWriter method failed:', e.message);
}
// Log what's available for debugging
console.log('BeeJs exports:', Object.keys(BeeJs));
if (BeeJs.Utils) console.log('BeeJs.Utils:', Object.keys(BeeJs.Utils));
throw new Error('Cannot derive address from private key');
}
// Compute feed address from owner + topic
function computeFeedAddress(ownerAddress, topic) {
// Feed address = keccak256(topic + owner)
// We'll use a simplified approach and let bee-js handle it
// For display, we can construct a predictable identifier
return `${ownerAddress.substring(0, 16)}...${topic.substring(0, 8)}`;
}
// Generate new private key
function boardGenerateKey() {
const privateKey = generateRandomBytes(32);
document.getElementById('boardPrivateKey').value = privateKey;
boardShowStatus('New key generated. Click "Load" to activate.', 'info');
}
// Load key or feed address
async function boardLoadKey() {
const input = document.getElementById('boardPrivateKey').value.trim();
if (!input) {
boardShowStatus('Please enter a private key or feed address', 'error');
return;
}
const beeUrl = getActiveUrl();
if (!beeUrl) {
boardShowStatus('No Swarm connection available', 'error');
return;
}
try {
// Check if input looks like a private key (64 hex chars) or an address (40 hex chars)
const cleanInput = input.replace(/^0x/, '');
if (cleanInput.length === 64) {
// It's a private key - full write access
boardPrivateKey = cleanInput;
boardOwnerAddress = await privateKeyToAddress(cleanInput);
boardIsReadOnly = false;
document.getElementById('boardOwnerAddress').textContent = '0x' + boardOwnerAddress;
document.getElementById('boardFeedAddress').textContent = BOARD_TOPIC_NAME;
document.getElementById('boardKeyInfo').style.display = 'block';
document.getElementById('boardCompose').style.display = 'block';
document.getElementById('boardReadonlyNotice').style.display = 'none';
boardShowStatus('Feed loaded with write access', 'success');
} else if (cleanInput.length === 40 || cleanInput.length === 42) {
// It's an address - read only
boardPrivateKey = null;
boardOwnerAddress = cleanInput.replace(/^0x/, '');
boardIsReadOnly = true;
document.getElementById('boardOwnerAddress').textContent = '0x' + boardOwnerAddress;
document.getElementById('boardFeedAddress').textContent = BOARD_TOPIC_NAME;
document.getElementById('boardKeyInfo').style.display = 'block';
document.getElementById('boardCompose').style.display = 'none';
document.getElementById('boardReadonlyNotice').style.display = 'block';
boardShowStatus('Feed loaded in read-only mode', 'info');
} else {
boardShowStatus('Invalid input. Enter a 64-char private key or 40-char address', 'error');
return;
}
// Load messages
await boardLoadMessages();
// Check URL params for auto-load
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.has('board')) {
// Save to localStorage for convenience
if (!boardIsReadOnly) {
localStorage.setItem('swarm-board-key', boardPrivateKey);
}
}
} catch (e) {
console.error('Error loading key:', e);
boardShowStatus('Error: ' + e.message, 'error');
}
}
// Show status message
function boardShowStatus(message, type) {
const statusDiv = document.getElementById('boardStatus');
statusDiv.innerHTML = `<div class="board-status ${type}">${message}</div>`;
if (type === 'success' || type === 'info') {
setTimeout(() => {
statusDiv.innerHTML = '';
}, 5000);
}
}
// Handle image selection
function boardHandleImageSelect(event) {
const files = event.target.files;
const previewDiv = document.getElementById('boardImagePreview');
for (const file of files) {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
boardPendingImages.push({
data: e.target.result,
type: file.type,
name: file.name
});
const imgContainer = document.createElement('div');
imgContainer.className = 'remove-image';
imgContainer.innerHTML = `<img src="${e.target.result}" alt="${file.name}">`;
imgContainer.onclick = () => {
const index = boardPendingImages.findIndex(img => img.data === e.target.result);
if (index > -1) boardPendingImages.splice(index, 1);
imgContainer.remove();
};
previewDiv.appendChild(imgContainer);
};
reader.readAsDataURL(file);
}
}
// Reset input
event.target.value = '';
}
// Post a new message
async function boardPostMessage() {
if (boardIsReadOnly || !boardPrivateKey) {
boardShowStatus('Cannot post in read-only mode', 'error');
return;
}
const text = document.getElementById('boardMessageText').value.trim();
if (!text && boardPendingImages.length === 0) {
boardShowStatus('Please enter a message or add an image', 'error');
return;
}
// Determine which URL and stamp to use
let beeUrl, stamp;
// Check if client-side stamping is enabled
if (canUseClientStamping()) {
beeUrl = gatewayUrlInput.value.trim();
stamp = clientStamper;
if (!beeUrl) {
boardShowStatus('Please configure a gateway URL for client-side stamping', 'error');
return;
}
boardShowStatus('Uploading via gateway (client-side stamped)...', 'info');
} else if (isLocalNodeAvailable) {
beeUrl = beeUrlInput.value.trim();
stamp = batchIdInput.value.trim();
if (!beeUrl || !stamp) {
boardShowStatus('Please configure Bee URL and select a postage stamp', 'error');
return;
}
} else if (isGatewayAvailable) {
// Use gateway with zero batch (sponsored uploads)
beeUrl = gatewayUrlInput.value.trim();
stamp = '0000000000000000000000000000000000000000000000000000000000000000';
boardShowStatus('Uploading via gateway (sponsored)...', 'info');
} else {
boardShowStatus('No connection available', 'error');
return;
}
try {
if (isLocalNodeAvailable && !canUseClientStamping()) {
boardShowStatus('Uploading message...', 'info');
}
const bee = new BeeJs.Bee(beeUrl);
// Upload images first if any
const imageRefs = [];
for (const img of boardPendingImages) {
// Convert data URL to Uint8Array
const response = await fetch(img.data);
const arrayBuffer = await response.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const result = await bee.uploadFile(stamp, uint8Array, img.name, {
contentType: img.type
});
// Convert reference to hex string
let refHex;
if (result.reference.toHex) {
refHex = result.reference.toHex();
} else if (result.reference.bytes) {
refHex = bytesToHex(new Uint8Array(Object.values(result.reference.bytes)));
} else {
refHex = String(result.reference);
}
imageRefs.push({
reference: refHex,
type: img.type,
name: img.name
});
}
// Get current feed to find previous message reference
let previousRef = null;
try {
const feedReader = bee.makeFeedReader(BOARD_TOPIC, '0x' + boardOwnerAddress);
const feedResult = await feedReader.downloadReference();
// Extract reference bytes and convert to hex
const refBytes = feedResult.reference.bytes
? new Uint8Array(Object.values(feedResult.reference.bytes))
: new Uint8Array(feedResult.reference);
previousRef = bytesToHex(refBytes);
} catch (e) {
// No previous messages, this is the first one
console.log('No previous messages found, starting new feed');
}
// Create message object
const message = {
text: text,
images: imageRefs,
timestamp: Date.now(),
author: '0x' + boardOwnerAddress.substring(0, 8),
previous: previousRef
};
// Upload message data
const messageJson = JSON.stringify(message);
const messageResult = await bee.uploadData(stamp, messageJson);
// Store the reference in the message for next iteration
message._ref = messageResult.reference;
// Update feed to point to new message
const feedWriter = bee.makeFeedWriter(BOARD_TOPIC, boardPrivateKey);
await feedWriter.upload(stamp, messageResult.reference);
// Clear form
document.getElementById('boardMessageText').value = '';
document.getElementById('boardImagePreview').innerHTML = '';
boardPendingImages = [];
boardShowStatus('Message posted successfully!', 'success');
// Reload messages
await boardLoadMessages();
} catch (e) {
console.error('Error posting message:', e);
boardShowStatus('Error posting: ' + e.message, 'error');
}
}
// Load messages from feed
async function boardLoadMessages() {
if (!boardOwnerAddress) {
return;
}
const beeUrl = getActiveUrl();
if (!beeUrl) {
boardShowStatus('No Swarm connection available', 'error');
return;
}
const messagesListDiv = document.getElementById('boardMessagesList');
messagesListDiv.innerHTML = '<div class="board-loading">Loading messages...</div>';
try {
const bee = new BeeJs.Bee(beeUrl);
const feedReader = bee.makeFeedReader(BOARD_TOPIC, '0x' + boardOwnerAddress);
// Get latest feed update reference
let latestRef;
try {
const feedResult = await feedReader.downloadReference();
// Extract reference bytes and convert to hex
const refBytes = feedResult.reference.bytes
? new Uint8Array(Object.values(feedResult.reference.bytes))
: new Uint8Array(feedResult.reference);
latestRef = bytesToHex(refBytes);
} catch (e) {
console.log('Feed empty or not found:', e.message);
messagesListDiv.innerHTML = '<div class="board-empty">No messages yet. Be the first to post!</div>';
return;
}
// Traverse linked list of messages
const messages = [];
let currentRef = latestRef;
let maxMessages = 50; // Limit to prevent infinite loops
while (currentRef && maxMessages > 0) {
try {
const data = await bee.downloadData(currentRef);
// Handle bee-js returning object with bytes property
let bytes;
if (data.bytes) {
bytes = new Uint8Array(Object.values(data.bytes));
} else if (data instanceof Uint8Array) {
bytes = data;
} else {
bytes = new Uint8Array(data);
}
const message = JSON.parse(new TextDecoder().decode(bytes));
message._ref = currentRef;
messages.push(message);
currentRef = message.previous;
maxMessages--;
} catch (e) {
console.log('Error loading message:', e.message);
break;
}
}
if (messages.length === 0) {
messagesListDiv.innerHTML = '<div class="board-empty">No messages yet. Be the first to post!</div>';
return;
}
// Render messages (newest first - they're already in that order)
messagesListDiv.innerHTML = messages.map(msg => {
const date = new Date(msg.timestamp);
const timeStr = date.toLocaleString();
let imagesHtml = '';
if (msg.images && msg.images.length > 0) {
imagesHtml = `
<div class="board-message-images">
${msg.images.map(img => `
<img src="${beeUrl}/bzz/${img.reference}/"
alt="${img.name || 'image'}"
onclick="window.open('${beeUrl}/bzz/${img.reference}/', '_blank')">
`).join('')}
</div>
`;
}
return `
<div class="board-message">
<div class="board-message-header">
<span class="board-message-author">${msg.author || 'Anonymous'}</span>
<span class="board-message-time">${timeStr}</span>
</div>
<div class="board-message-text">${escapeHtml(msg.text || '')}</div>
${imagesHtml}
</div>
`;
}).join('');
} catch (e) {
console.error('Error loading messages:', e);
messagesListDiv.innerHTML = `<div class="board-empty">Error loading messages: ${e.message}</div>`;
}
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Copy write link (includes private key)
function boardCopyWriteLink() {
if (!boardPrivateKey) {
boardShowStatus('No private key loaded', 'error');
return;
}
const url = `${window.location.origin}${window.location.pathname}?board=${boardPrivateKey}`;
copyToClipboard(url);
boardShowStatus('Write link copied! Keep this private.', 'success');
}
// Copy read-only link (just address)
function boardCopyReadLink() {
if (!boardOwnerAddress) {
boardShowStatus('No feed loaded', 'error');
return;
}
const url = `${window.location.origin}${window.location.pathname}?board=${boardOwnerAddress}`;
copyToClipboard(url);
boardShowStatus('Read-only link copied!', 'success');
}
// Check URL params on load
function boardCheckUrlParams() {
const urlParams = new URLSearchParams(window.location.search);
const boardParam = urlParams.get('board');
if (boardParam) {
document.getElementById('boardPrivateKey').value = boardParam;
// Auto-load after a short delay to let bee-js initialize
setTimeout(() => boardLoadKey(), 500);
} else {
// Check localStorage
const savedKey = localStorage.getItem('swarm-board-key');
if (savedKey) {
document.getElementById('boardPrivateKey').value = savedKey;
}
}
}
// Initialize board on page load
boardCheckUrlParams();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment