Add connect to Wifi and File Manager Webserver (#41)
## Summary
- **What is the goal of this PR?**
Implements wireless EPUB file management via a built-in web server,
enabling users to upload, browse, organize, and delete EPUB files from
any device on the same WiFi network without needing a computer cable
connection.
- **What changes are included?**
- **New Web Server**
([`CrossPointWebServer.cpp`](src/CrossPointWebServer.cpp),
[`CrossPointWebServer.h`](src/CrossPointWebServer.h)):
- HTTP server on port 80 with a responsive HTML/CSS interface
- Home page showing device status (version, IP, free memory)
- File Manager with folder navigation and breadcrumb support
- EPUB file upload with progress tracking
- Folder creation and file/folder deletion
- XSS protection via HTML escaping
- Hidden system folders (`.` prefixed, "System Volume Information",
"XTCache")
- **WiFi Screen** ([`WifiScreen.cpp`](src/screens/WifiScreen.cpp),
[`WifiScreen.h`](src/screens/WifiScreen.h)):
- Network scanning with signal strength indicators
- Visual indicators for encrypted (`*`) and saved (`+`) networks
- State machine managing: scanning, network selection, password entry,
connecting, save/forget prompts
- 15-second connection timeout handling
- Integration with web server (starts on connect, stops on exit)
- **WiFi Credential Storage**
([`WifiCredentialStore.cpp`](src/WifiCredentialStore.cpp),
[`WifiCredentialStore.h`](src/WifiCredentialStore.h)):
- Persistent storage in `/sd/.crosspoint/wifi.bin`
- XOR obfuscation for stored passwords (basic protection against casual
reading)
- Up to 8 saved networks with add/remove/update operations
- **On-Screen Keyboard**
([`OnScreenKeyboard.cpp`](src/screens/OnScreenKeyboard.cpp),
[`OnScreenKeyboard.h`](src/screens/OnScreenKeyboard.h)):
- Reusable QWERTY keyboard component with shift support
- Special keys: Shift, Space, Backspace, Done
- Support for password masking mode
- **Settings Screen Integration**
([`SettingsScreen.h`](src/screens/SettingsScreen.h)):
- Added WiFi action to navigate to the new WiFi screen
- **Documentation** ([`docs/webserver.md`](docs/webserver.md)):
- Comprehensive user guide covering WiFi setup, web interface usage,
file management, troubleshooting, and security notes
- See this for more screenshots!
- Working "displays the right way in GitHub" on my repo:
https://github.com/olearycrew/crosspoint-reader/blob/feature/connect-to-wifi/docs/webserver.md
**Video demo**
https://github.com/user-attachments/assets/283e32dc-2d9f-4ae2-848e-01f41166a731
## Additional Context
- **Security considerations**: The web server has no
authentication—anyone on the same WiFi network can access files. This is
documented as a limitation, recommending use only on trusted private
networks. Password obfuscation in the credential store is XOR-based, not
cryptographically secure.
- **Memory implications**: The web server and WiFi stack consume
significant memory. The implementation properly cleans up (stops server,
disconnects WiFi, sets `WIFI_OFF` mode) when exiting the WiFi screen to
free resources.
- **Async operations**: Network scanning and connection use async
patterns with FreeRTOS tasks to prevent blocking the UI. The display
task handles rendering on a dedicated thread with mutex protection.
- **Browser compatibility**: The web interface uses standard
HTML5/CSS3/JavaScript and is tested to work with all modern browsers on
desktop and mobile.
---------
Co-authored-by: Dave Allie <dave@daveallie.com>
This commit is contained in:
251
src/html/FilesPageFooter.html
Normal file
251
src/html/FilesPageFooter.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<div class="card">
|
||||
<p style="text-align: center; color: #95a5a6; margin: 0;">
|
||||
CrossPoint E-Reader • Open Source
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal-overlay" id="uploadModal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeUploadModal()">×</button>
|
||||
<h3>📤 Upload eBook</h3>
|
||||
<div class="upload-form">
|
||||
<p class="file-info">Select an .epub file to upload to <strong id="uploadPathDisplay"></strong></p>
|
||||
<input type="file" id="fileInput" accept=".epub" onchange="validateFile()">
|
||||
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
|
||||
<div id="progress-container">
|
||||
<div id="progress-bar"><div id="progress-fill"></div></div>
|
||||
<div id="progress-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Folder Modal -->
|
||||
<div class="modal-overlay" id="folderModal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeFolderModal()">×</button>
|
||||
<h3>📁 New Folder</h3>
|
||||
<div class="folder-form">
|
||||
<p class="file-info">Create a new folder in <strong id="folderPathDisplay"></strong></p>
|
||||
<input type="text" id="folderName" class="folder-input" placeholder="Folder name...">
|
||||
<button class="folder-btn" onclick="createFolder()">Create Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal-overlay" id="deleteModal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeDeleteModal()">×</button>
|
||||
<h3>🗑️ Delete Item</h3>
|
||||
<div class="folder-form">
|
||||
<p class="delete-warning">⚠️ This action cannot be undone!</p>
|
||||
<p class="file-info">Are you sure you want to delete:</p>
|
||||
<p class="delete-item-name" id="deleteItemName"></p>
|
||||
<input type="hidden" id="deleteItemPath">
|
||||
<input type="hidden" id="deleteItemType">
|
||||
<button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button>
|
||||
<button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Modal functions
|
||||
function openUploadModal() {
|
||||
const currentPath = document.getElementById('currentPath').value;
|
||||
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
|
||||
document.getElementById('uploadModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeUploadModal() {
|
||||
document.getElementById('uploadModal').classList.remove('open');
|
||||
document.getElementById('fileInput').value = '';
|
||||
document.getElementById('uploadBtn').disabled = true;
|
||||
document.getElementById('progress-container').style.display = 'none';
|
||||
document.getElementById('progress-fill').style.width = '0%';
|
||||
document.getElementById('progress-fill').style.backgroundColor = '#27ae60';
|
||||
}
|
||||
|
||||
function openFolderModal() {
|
||||
const currentPath = document.getElementById('currentPath').value;
|
||||
document.getElementById('folderPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
|
||||
document.getElementById('folderModal').classList.add('open');
|
||||
document.getElementById('folderName').value = '';
|
||||
}
|
||||
|
||||
function closeFolderModal() {
|
||||
document.getElementById('folderModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// Close modals when clicking overlay
|
||||
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) {
|
||||
overlay.classList.remove('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function validateFile() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (file) {
|
||||
const fileName = file.name.toLowerCase();
|
||||
if (!fileName.endsWith('.epub')) {
|
||||
alert('Only .epub files are allowed!');
|
||||
fileInput.value = '';
|
||||
uploadBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
uploadBtn.disabled = false;
|
||||
} else {
|
||||
uploadBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function uploadFile() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const file = fileInput.files[0];
|
||||
const currentPath = document.getElementById('currentPath').value;
|
||||
|
||||
if (!file) {
|
||||
alert('Please select a file first!');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = file.name.toLowerCase();
|
||||
if (!fileName.endsWith('.epub')) {
|
||||
alert('Only .epub files are allowed!');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
const progressFill = document.getElementById('progress-fill');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
|
||||
progressContainer.style.display = 'block';
|
||||
uploadBtn.disabled = true;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
// Include path as query parameter since multipart form data doesn't make
|
||||
// form fields available until after file upload completes
|
||||
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
||||
|
||||
xhr.upload.onprogress = function(e) {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
progressFill.style.width = percent + '%';
|
||||
progressText.textContent = 'Uploading: ' + percent + '%';
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
progressText.textContent = 'Upload complete!';
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
progressText.textContent = 'Upload failed: ' + xhr.responseText;
|
||||
progressFill.style.backgroundColor = '#e74c3c';
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
progressText.textContent = 'Upload failed - network error';
|
||||
progressFill.style.backgroundColor = '#e74c3c';
|
||||
uploadBtn.disabled = false;
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
function createFolder() {
|
||||
const folderName = document.getElementById('folderName').value.trim();
|
||||
const currentPath = document.getElementById('currentPath').value;
|
||||
|
||||
if (!folderName) {
|
||||
alert('Please enter a folder name!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate folder name (no special characters except underscore and hyphen)
|
||||
const validName = /^[a-zA-Z0-9_\-]+$/.test(folderName);
|
||||
if (!validName) {
|
||||
alert('Folder name can only contain letters, numbers, underscores, and hyphens.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('name', folderName);
|
||||
formData.append('path', currentPath);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/mkdir', true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to create folder: ' + xhr.responseText);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
alert('Failed to create folder - network error');
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Delete functions
|
||||
function openDeleteModal(name, path, isFolder) {
|
||||
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
|
||||
document.getElementById('deleteItemPath').value = path;
|
||||
document.getElementById('deleteItemType').value = isFolder ? 'folder' : 'file';
|
||||
document.getElementById('deleteModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
const path = document.getElementById('deleteItemPath').value;
|
||||
const itemType = document.getElementById('deleteItemType').value;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('path', path);
|
||||
formData.append('type', itemType);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/delete', true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to delete: ' + xhr.responseText);
|
||||
closeDeleteModal();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
alert('Failed to delete - network error');
|
||||
closeDeleteModal();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user