From 2255bbed94e1459788203a92b5f0eec5370abcab Mon Sep 17 00:00:00 2001 From: Bobby Date: Wed, 16 Jul 2025 20:07:20 +0530 Subject: upload ui for images; registered users can upload --- controllers/posts.go | 70 ++++++++- processors/preferences.go | 2 +- router/routes.go | 1 + static/css/main.css | 183 +++++++++++++++++++-- static/scripts/errorControls.js | 11 ++ static/scripts/preferences.js | 12 -- static/scripts/upload.js | 340 ++++++++++++++++++++++++++++++++++++++++ templates/layouts/main.django | 6 +- templates/posts/new.django | 22 ++- templates/preferences.django | 1 + utils/format/format.go | 7 +- utils/validators/links.go | 12 ++ 12 files changed, 639 insertions(+), 28 deletions(-) create mode 100644 static/scripts/errorControls.js create mode 100644 static/scripts/upload.js create mode 100644 utils/validators/links.go diff --git a/controllers/posts.go b/controllers/posts.go index c14d793..6a2d01a 100644 --- a/controllers/posts.go +++ b/controllers/posts.go @@ -4,7 +4,12 @@ import ( "imageboard/config" "imageboard/database" "imageboard/utils/auth" + "imageboard/utils/format" "imageboard/utils/shortcuts" + "imageboard/utils/validators" + "io" + "net/http" + "strings" "github.com/gofiber/fiber/v2" ) @@ -57,5 +62,68 @@ func PostsUploadPageController(ctx *fiber.Ctx) error { return nil } - return shortcuts.Render(ctx, config.TEMPLATE_POST_NEW, nil) + allowedTypes := []string{} + for t := range strings.SplitSeq(config.Upload.AllowedTypes, ",") { + if idx := strings.Index(t, "/"); idx != -1 && idx+1 < len(t) { + subtype := t[idx+1:] + if subtype != "" { + allowedTypes = append(allowedTypes, "."+subtype) + } + } + } + + return shortcuts.Render(ctx, config.TEMPLATE_POST_NEW, fiber.Map{ + "AllowedTypes": allowedTypes, + "MaxSize": format.FileSize(int64(config.Upload.MaxSize)), + }) +} + +func PostsUploadImageLinkProxyController(ctx *fiber.Ctx) error { + maxSize := int64(config.Upload.MaxSize) + if !auth.IsAuthenticated(ctx) { + return fiber.NewError(fiber.StatusForbidden, "Forbidden") + } + + url := ctx.Query("url") + if url == "" { + return fiber.NewError(fiber.StatusBadRequest, "Missing url parameter") + } + + client := &http.Client{} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid URL") + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36") + + referer := validators.GetRefererForURL(url) + if referer != "" { + req.Header.Set("Referer", referer) + } + + resp, err := client.Do(req) + if err != nil { + return fiber.NewError(fiber.StatusBadGateway, "Failed to fetch image") + } + if resp.StatusCode != 200 { + return fiber.NewError(fiber.StatusBadGateway, "Failed to fetch image") + } + defer resp.Body.Close() + + contentType := resp.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + return fiber.NewError(fiber.StatusBadRequest, "URL does not point to an image") + } + + ctx.Set("Content-Type", contentType) + ctx.Set("Cache-Control", "no-store") + buf, err := io.ReadAll(resp.Body) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to read image data") + } + if int64(len(buf)) > maxSize { + return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Image exceeds maximum allowed size of "+format.FileSize(maxSize)) + } + return ctx.Send(buf) } diff --git a/processors/preferences.go b/processors/preferences.go index a74d1e5..21b7c16 100644 --- a/processors/preferences.go +++ b/processors/preferences.go @@ -10,7 +10,7 @@ import ( func PreferencesContextProcessor(context *fiber.Ctx) error { defaultPreferences := config.SitePreferences{ - SidebarWidth: "180px", + SidebarWidth: "220px", MainContentWidth: "1200px", H1FontSize: "16px", BodyFontSize: "13px", diff --git a/router/routes.go b/router/routes.go index b074103..8c97318 100644 --- a/router/routes.go +++ b/router/routes.go @@ -13,6 +13,7 @@ func Initialize(router *fiber.App) { posts := router.Group("/posts") posts.Get("/", controllers.PostsPageController) posts.Get("/new", controllers.PostsUploadPageController) + posts.Get("/new/ilinkfetch", controllers.PostsUploadImageLinkProxyController) login := router.Group("/login") login.Get("/", controllers.LoginPageController) diff --git a/static/css/main.css b/static/css/main.css index 51d2206..f9e3b4e 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -134,6 +134,11 @@ main { padding: 10px; } +.content-main h1 { + color: #ff99cc; + margin-bottom: 8px; +} + .centered-main { display: flex; flex-direction: column; @@ -143,17 +148,21 @@ main { min-height: 580px; } -.centered-main h1 { - color: #ffccff; - margin: 8px 0px; +.centered-horizontal { + display: flex; + justify-content: center; + align-items: center; + width: 100%; } -.content-main h1 { - color: #ff99cc; - margin-bottom: 8px; +.centered-main h1, +.centered-horizontal h1 { + color: #ffccff; + margin: 8px 0px; } -.centered-main p { +.centered-main p, +.centered-horizontal p { color: #99ffcc; } @@ -187,7 +196,8 @@ main { input[type="text"], input[type="email"], input[type="password"], -input[type="number"] { +input[type="number"], +input[type="url"] { background-color: #1a0033; border: 1px solid #9999ff; color: #ccccff; @@ -197,13 +207,15 @@ input[type="number"] { input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus, -input[type="number"]:focus { +input[type="number"]:focus, +input[type="url"]:focus { border-color: #ff99cc; background-color: #260040; outline: none; } input[type="button"], +button[type="button"], input[type="submit"] { background-color: #330066; border: 1px solid #9999ff; @@ -213,6 +225,7 @@ input[type="submit"] { } input[type="button"]:hover, +button[type="button"]:hover, input[type="submit"]:hover { background-color: #ff99cc; color: #1a001a; @@ -379,4 +392,156 @@ footer::before { padding: 8px; margin-bottom: 16px; text-align: center; +} + +.upload-drag-box { + border: 2px dashed #9999ff; + background-color: #1a0033; + padding-top: 20px; + text-align: center; + cursor: pointer; + width: 768px; + margin: 24px 0px 12px 0px; +} + +.upload-drag-box.dragover { + border-color: #ff99cc; + background-color: #260040; + transition: background 0.2s, border-color 0.2s, box-shadow 0.2s; +} + +.upload-drag-box h1 { + margin: 0; +} + +.upload-drag-box p { + margin: 8px 0 12px 0; +} + +.upload-drag-box small { + display: block; + border-top: 1px dashed #9999ff; + margin-top: 20px; + padding-top: 10px; + padding-bottom: 10px; +} + +.upload-area { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.upload-via-link>form { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + width: 768px; +} + +.upload-via-link>form input[type="url"] { + flex: 1; + width: 100%; +} + +.upload-previews { + margin-top: 16px; + width: 768px; +} + +.preview-area { + display: flex; + flex-direction: row; + gap: 8px; + margin-bottom: 16px; +} + +.preview-image { + width: 96px; + height: 96px; + object-fit: cover; + border: 1px solid #9999ff; + flex-shrink: 0; +} + +.preview-details { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1 1 0; + min-width: 0; +} + +.preview-link { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + min-width: 0; +} + +.preview-rating-form { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; +} + +.preview-remove-btn { + display: inline-block; + width: auto; + align-self: flex-start; + max-width: 120px; +} + + + +.upload-area { + position: relative; +} + +.upload-drag-box { + position: relative; +} + +.upload-loading-indicator { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + justify-content: center; + background: rgba(26, 0, 51, 0.85); + width: 100%; + height: 100%; + border-radius: 8px; + z-index: 20; + pointer-events: none; +} + +.upload-loading-icon { + font-size: 2.5em; + color: #ff99cc; + filter: drop-shadow(0 0 8px #ff99cc88); + animation: uploadspin 1.2s linear infinite; +} + +.upload-loading-icon { + font-size: 3em; + color: #ff99cc; + filter: drop-shadow(0 0 8px #ff99cc88); + animation: uploadspin 1.2s linear infinite; +} + +@keyframes uploadspin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } } \ No newline at end of file diff --git a/static/scripts/errorControls.js b/static/scripts/errorControls.js new file mode 100644 index 0000000..8a00756 --- /dev/null +++ b/static/scripts/errorControls.js @@ -0,0 +1,11 @@ +function showError(message) { + const errorMessage = document.getElementById('error-message'); + errorMessage.textContent = message; + errorMessage.style.display = 'block'; +} + +function hideError() { + const errorMessage = document.getElementById('error-message'); + errorMessage.textContent = ''; + errorMessage.style.display = 'none'; +} \ No newline at end of file diff --git a/static/scripts/preferences.js b/static/scripts/preferences.js index 61db579..66a7bb7 100644 --- a/static/scripts/preferences.js +++ b/static/scripts/preferences.js @@ -8,18 +8,6 @@ function validateCSSFontSize(value) { return validFontSizePattern.test(value); } -function showError(message) { - const errorMessage = document.getElementById('error-message'); - errorMessage.textContent = message; - errorMessage.style.display = 'block'; -} - -function hideError() { - const errorMessage = document.getElementById('error-message'); - errorMessage.textContent = ''; - errorMessage.style.display = 'none'; -} - function setPreferences() { const preferences = { sidebar_width: document.getElementById('sidebar-width').value, diff --git a/static/scripts/upload.js b/static/scripts/upload.js new file mode 100644 index 0000000..5d0f3ec --- /dev/null +++ b/static/scripts/upload.js @@ -0,0 +1,340 @@ +/** + * Tracks all images to be uploaded (local files and link-fetched images). + * Key is either the file name (for local) or URL (for link). + * @type {Map} + */ +const imageBlobMapping = new Map(); + +/** + * Shows or hides the Upload All button in the .upload-area container based on imageBlobMapping size. + * The button is created once and appended/removed as needed. + * @function + */ +let uploadAllBtn = null; +function updateUploadAllBtn() { + const uploadArea = document.querySelector('.upload-area'); + if (!uploadArea) return; + if (!uploadAllBtn) { + uploadAllBtn = document.createElement('button'); + uploadAllBtn.type = 'button'; + uploadAllBtn.textContent = 'Upload All'; + uploadAllBtn.className = 'upload-all-btn'; + /** + * TODO: Implement actual upload logic here + */ + uploadAllBtn.onclick = function () { + alert('Upload All clicked!'); + }; + } + if (imageBlobMapping.size > 0) { + if (!uploadArea.contains(uploadAllBtn)) { + uploadArea.appendChild(uploadAllBtn); + } + } else { + if (uploadArea.contains(uploadAllBtn)) { + uploadArea.removeChild(uploadAllBtn); + } + } +} + +/** + * Creates a preview element for an image (local or link). + * @param {string} key - The key for the image (filename or URL) + * @param {Blob} blob - The image blob + * @param {'local'|'link'} type - Type of image + * @param {string} nameOrUrl - Filename (local) or URL (link) + * @returns {HTMLDivElement} + */ +function createPreviewElement(key, blob, type, nameOrUrl) { + const previewElement = document.createElement('div'); + previewElement.className = 'preview-area'; + + const previewImage = document.createElement('img'); + previewImage.className = 'preview-image'; + previewImage.src = URL.createObjectURL(blob); + previewElement.appendChild(previewImage); + + const previewDetailsArea = document.createElement('div'); + previewDetailsArea.className = 'preview-details'; + previewElement.appendChild(previewDetailsArea); + + if (type === 'link') { + const previewLink = document.createElement('a'); + previewLink.className = 'preview-link'; + previewLink.target = '_blank'; + previewLink.href = nameOrUrl; + previewLink.textContent = nameOrUrl; + previewDetailsArea.appendChild(previewLink); + } else { + const previewFile = document.createElement('span'); + previewFile.className = 'preview-link'; + previewFile.textContent = nameOrUrl; + previewDetailsArea.appendChild(previewFile); + } + + const previewRatingForm = document.createElement('form'); + previewRatingForm.className = 'preview-rating-form'; + ['Safe', 'Questionable', 'Sensitive', 'Explicit'].forEach((rating, idx) => { + const inputId = `rating-${rating.toLowerCase()}-${key}`; + const input = document.createElement('input'); + input.type = 'radio'; + input.name = `rating-${key}`; + input.value = rating.toLowerCase(); + input.checked = idx === 0; + input.id = inputId; + const label = document.createElement('label'); + label.textContent = rating; + label.setAttribute('for', inputId); + previewRatingForm.appendChild(input); + previewRatingForm.appendChild(label); + }); + previewDetailsArea.appendChild(previewRatingForm); + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Remove'; + removeBtn.className = 'preview-remove-btn'; + removeBtn.onclick = () => { + previewElement.remove(); + imageBlobMapping.delete(key); + updateUploadAllBtn(); + }; + previewDetailsArea.appendChild(removeBtn); + + return previewElement; +} + +window.onbeforeunload = function (_event) { + if (imageBlobMapping.size > 0) { + return 'Are you sure you want to leave? Data you have entered may not be saved.'; + } +}; + +/** + * Shows or hides a retro loading indicator in the upload area. + * @param {boolean} show + */ +function setUploadLoadingIndicator(show) { + let indicator = document.getElementById('_uploadLoadingIndicator'); + const dragBox = document.querySelector('.upload-drag-box'); + if (!dragBox) return; + dragBox.style.position = 'relative'; + if (!indicator) { + indicator = document.createElement('div'); + indicator.id = '_uploadLoadingIndicator'; + indicator.className = 'upload-loading-indicator'; + indicator.innerHTML = ''; + dragBox.appendChild(indicator); + } + indicator.style.display = show ? 'flex' : 'none'; +} + +/** + * Handles uploading an image via link using the backend proxy. + * Optimized and uses async/await. + * @returns {Promise} + */ +async function uploadViaLink() { + const uploadViaLinkInputBox = document.getElementById('_uploadViaLink_InputBox'); + const uploadViaLinkUploadPreviewsArea = document.getElementById('_uploadViaLink_UploadPreviewsArea'); + const link = uploadViaLinkInputBox.value.trim(); + hideError(); + + if (!link) { + showError('Please enter a valid image URL.'); + return; + } + if (imageBlobMapping.has(link)) { + showError('This image has already been added.'); + return; + } + setUploadLoadingIndicator(true); + try { + const proxyUrl = `/posts/new/ilinkfetch?url=${encodeURIComponent(link)}`; + const response = await fetch(proxyUrl); + if (!response.ok) { + let errorMsg = 'Failed to fetch the image from the provided URL.'; + try { + const text = await response.text(); + if (text && text !== 'Failed to fetch image') errorMsg = text; + } catch { + errorMsg = 'An error occurred while fetching the image.'; + } + showError(errorMsg || 'An error occurred while fetching the image.'); + return; + } + const contentType = response.headers.get('content-type') || ''; + if (!contentType.startsWith('image/')) { + showError('The URL does not point to a valid image.'); + return; + } + const blob = await response.blob(); + if (!blob) { + showError('No image data received from the URL.'); + return; + } + const previewElement = createPreviewElement(link, blob, 'link', link); + uploadViaLinkUploadPreviewsArea.appendChild(previewElement); + imageBlobMapping.set(link, { blob, rating: 'safe', previewElement, type: 'link', nameOrUrl: link }); + updateUploadAllBtn(); + } catch (error) { + console.error('Error fetching image:', error); + showError('An error occurred while fetching the image.'); + } finally { + setUploadLoadingIndicator(false); + uploadViaLinkInputBox.value = ''; + } +} + +/** + * Handles drag-and-drop and click-to-select for local image files. + */ +function setupLocalImageUpload() { + const dragBox = document.querySelector('.upload-drag-box'); + const previewsArea = document.getElementById('_uploadViaLink_UploadPreviewsArea'); + const dragHeading = dragBox ? dragBox.querySelector('h1') : null; + if (!dragBox || !previewsArea || !dragHeading) return; + + dragBox.addEventListener('dragover', function (e) { + e.preventDefault(); + dragBox.classList.add('dragover'); + dragHeading.textContent = 'Release to upload!'; + }); + dragBox.addEventListener('dragleave', function (e) { + e.preventDefault(); + dragBox.classList.remove('dragover'); + dragHeading.textContent = 'Drop files here or just click this box!'; + }); + dragBox.addEventListener('drop', async function (e) { + e.preventDefault(); + dragBox.classList.remove('dragover'); + dragHeading.textContent = 'Drop files here or just click this box!'; + const files = e.dataTransfer.files; + if (files && files.length > 0) { + handleFiles(files); + } else { + // Try to get a URL from the drop + let url = ''; + if (e.dataTransfer.items) { + for (let i = 0; i < e.dataTransfer.items.length; i++) { + const item = e.dataTransfer.items[i]; + if (item.kind === 'string' && (item.type === 'text/uri-list' || item.type === 'text/plain')) { + item.getAsString(function (s) { + if (s && s.match(/^https?:\/\/.+/)) { + handleDroppedUrl(s); + } + }); + return; + } + } + } + // Fallback for some browsers + url = e.dataTransfer.getData('text/uri-list') || e.dataTransfer.getData('text/plain'); + if (url && url.match(/^https?:\/\/.+/)) { + handleDroppedUrl(url); + } + } + }); + dragBox.addEventListener('click', function () { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.multiple = true; + input.onchange = function () { + handleFiles(input.files); + }; + input.click(); + }); +} + +/** + * Handles dropped URLs, tries to upload if it's an image or fetch if not. + * @param {string} url + */ +function handleDroppedUrl(url) { + // Accept direct image URLs or blob/data URLs + const imageExt = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i; + if (imageExt.test(url) || url.startsWith('blob:') || url.startsWith('data:image/')) { + uploadViaLinkDirect(url); + } else { + // Try to fetch and see if it's an image + fetch(url, { method: 'HEAD' }) + .then(resp => { + const type = resp.headers.get('content-type') || ''; + if (type.startsWith('image/')) { + uploadViaLinkDirect(url); + } else { + showError('Dropped URL is not a direct image. Try dragging the image itself, not the page.'); + } + }) + .catch(() => { + showError('Could not fetch dropped URL.'); + }); + } +} + +/** + * Directly uploads an image via link (used for drag-drop URLs) + * @param {string} url + */ +async function uploadViaLinkDirect(url) { + setUploadLoadingIndicator(true); + const uploadViaLinkUploadPreviewsArea = document.getElementById('_uploadViaLink_UploadPreviewsArea'); + if (imageBlobMapping.has(url)) { + showError('This image has already been added.'); + return; + } + try { + const proxyUrl = `/posts/new/ilinkfetch?url=${encodeURIComponent(url)}`; + const response = await fetch(proxyUrl); + if (!response.ok) { + let errorMsg = 'Failed to fetch the image from the provided URL.'; + try { + const text = await response.text(); + if (text && text !== 'Failed to fetch image') errorMsg = text; + } catch { + errorMsg = 'An error occurred while fetching the image.'; + } + showError(errorMsg || 'An error occurred while fetching the image.'); + return; + } + const contentType = response.headers.get('content-type') || ''; + if (!contentType.startsWith('image/')) { + showError('The URL does not point to a valid image.'); + return; + } + const blob = await response.blob(); + if (!blob) { + showError('No image data received from the URL.'); + return; + } + const previewElement = createPreviewElement(url, blob, 'link', url); + uploadViaLinkUploadPreviewsArea.appendChild(previewElement); + imageBlobMapping.set(url, { blob, rating: 'safe', previewElement, type: 'link', nameOrUrl: url }); + updateUploadAllBtn(); + } catch (error) { + console.error('Error fetching image:', error); + showError('An error occurred while fetching the image.'); + } finally { + setUploadLoadingIndicator(false); + } +} + +/** + * Handles adding local files to the preview and mapping. + * @param {FileList} files + */ +function handleFiles(files) { + const previewsArea = document.getElementById('_uploadViaLink_UploadPreviewsArea'); + for (const file of files) { + if (!file.type.startsWith('image/')) continue; + if (imageBlobMapping.has(file.name)) continue; + const previewElement = createPreviewElement(file.name, file, 'local', file.name); + previewsArea.appendChild(previewElement); + imageBlobMapping.set(file.name, { blob: file, rating: 'safe', previewElement, type: 'local', nameOrUrl: file.name }); + } + updateUploadAllBtn(); +} + +setupLocalImageUpload(); + diff --git a/templates/layouts/main.django b/templates/layouts/main.django index 98bb733..8673b21 100644 --- a/templates/layouts/main.django +++ b/templates/layouts/main.django @@ -5,9 +5,9 @@ {{ Title }} - {{ Appname }} - - - + + + {{ PreferencesCSS|safe }} diff --git a/templates/posts/new.django b/templates/posts/new.django index d3a2e5e..15dae77 100644 --- a/templates/posts/new.django +++ b/templates/posts/new.django @@ -1,4 +1,24 @@ {% extends 'layouts/main.django' %} {% block content %} - Upload a new post here +
+
+
+

Drop files here or just click this box!

+

Supported formats: {{ AllowedTypes|join:', ' }}

+ Max size: {{ MaxSize }} +
+ + + +
+
+{% endblock %} +{% block scripts %} + + {% endblock %} diff --git a/templates/preferences.django b/templates/preferences.django index d8a153e..691cbdf 100644 --- a/templates/preferences.django +++ b/templates/preferences.django @@ -68,5 +68,6 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/utils/format/format.go b/utils/format/format.go index 53c813e..e57bc1f 100644 --- a/utils/format/format.go +++ b/utils/format/format.go @@ -13,7 +13,12 @@ func FileSize(size int64) string { exp++ } - return fmt.Sprintf("%.2f %sB", float64(size)/float64(div), "KMGTPE"[exp:exp+1]) + val := float64(size) / float64(div) + unitStr := "KMGTPE"[exp : exp+1] + if val == float64(int64(val)) { + return fmt.Sprintf("%d %sB", int64(val), unitStr) + } + return fmt.Sprintf("%.2f %sB", val, unitStr) } func Count(count int64) string { diff --git a/utils/validators/links.go b/utils/validators/links.go new file mode 100644 index 0000000..cc9dd9b --- /dev/null +++ b/utils/validators/links.go @@ -0,0 +1,12 @@ +package validators + +import "strings" + +func GetRefererForURL(url string) string { + switch { + case strings.Contains(url, "i.pximg.net") || strings.Contains(url, "pixiv.net"): + return "https://www.pixiv.net/" + default: + return "" + } +} -- cgit v1.2.3