Schedule posts to Bluesky with Cloudflare workers. skyscheduler.work
cf tool bsky-tool cloudflare bluesky schedule bsky service social-media cloudflare-workers

Threads

Closes #82

+2252 -825
+3 -2
README.md
··· 14 14 - **Multiple user/account handling**: Manage multiple users/bsky accounts easily 15 15 - **Bluesky Post Scheduling**: Schedule multiple posts to your Bluesky account 16 16 - **Hourly Time Slots**: Time selection is limited to hourly intervals to optimize worker execution and reduce unnecessary runs 17 + - **Post Threading**: Schedule entire post threads with full media support per post! 17 18 - **Simple Setup**: Fairly minimal setup and easy to use 18 19 - **Supports media posts**: Automatically handles content tagging and formatting your media so that it looks the best on BSky. Image transforms via Cloudflare Images 19 20 - **Handles Link Embeds**: Post your content with a link embed easily! ··· 27 28 28 29 - Node.js (v24.x or later) 29 30 - Package Manager 30 - - Cloudflare Pro Workers account (for CPU and Queues [can be disabled with `QUEUE_SETTINGS.enabled` set to false]) 31 + - Cloudflare Pro Workers account (for CPU) 31 32 32 33 ### Installation 33 34 ··· 53 54 - `TURNSTILE_PUBLIC_KEY` - the turnstile public key for captcha 54 55 - `TURNSTILE_SECRET_KEY` - the turnstile secret key for captcha 55 56 - `RESIZE_SECRET_HEADER` - a header value that will be included on requests while trying to resize images. Protects the resize bucket while still making it accessible to CF Images. 56 - 57 + 57 58 **Note**: When deploying, these variables should also be configured as secrets in your Cloudflare worker dashboard. You can also do this via `npx wrangler secret put <NAME_OF_SECRET>`. 58 59 59 60 4. Update your `wrangler.toml` with changes that reflect your account.
+1 -1
assets/css/dropzoneMods.css
··· 21 21 background-color: unset; 22 22 } 23 23 24 - .dz-filename { 24 + .dz-filename { 25 25 width: 65%; 26 26 float: right; 27 27 }
+24 -4
assets/css/stylesheet.css
··· 3 3 } 4 4 5 5 .hidden { 6 - display: none; 6 + display: none !important; 7 7 } 8 8 9 9 .sidebar-block { ··· 33 33 } 34 34 35 35 .btn-error, .pico .btn-error { 36 - border: var(--pico-border-width) solid red; 36 + border: var(--pico-border-width) solid red; 37 37 color: red; 38 38 } 39 39 40 40 .btn-error:hover, .pico .btn-error:hover { 41 41 --error-color-hover: rgb(252, 60, 60); 42 - border: var(--pico-border-width) solid var(--error-color-hover); 42 + border: var(--pico-border-width) solid var(--error-color-hover); 43 43 color: var(--error-color-hover); 44 44 } 45 45 ··· 49 49 } 50 50 51 51 .btn-success, .pico .btn-success { 52 - border: var(--pico-border-width) solid green; 52 + border: var(--pico-border-width) solid green; 53 53 color: green; 54 54 } 55 55 ··· 65 65 float: right; 66 66 svg, img { 67 67 margin-top: -7px; 68 + } 69 + } 70 + .btn-delete .thread-cancel { 71 + display: inline-block; 72 + margin-top: 3px; 73 + } 74 + 75 + .addThreadPost { 76 + svg, img { 77 + margin-top: -7px; 78 + margin-left: 1.3px; 68 79 } 69 80 } 70 81 ··· 236 247 237 248 .capitialize { 238 249 text-transform: capitalize; 250 + } 251 + 252 + .serverFunds { 253 + position: relative; 254 + top: -10px; 255 + } 256 + 257 + .highlight, .highlight div, .highlight header, .highlight footer { 258 + background-color: rgba(210, 166, 8, 0.891) !important; 239 259 } 240 260 241 261 [data-tooltip]:before, .pico [data-tooltip]:before {
+1
assets/icons/reply.svg
··· 1 + <svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g clip-path="url(#clip0_429_10970)"> <circle cx="12" cy="11.999" r="9" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></circle> <path d="M12 9V15" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></path> <path d="M9 12H15" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></path> </g> <defs> <clipPath id="clip0_429_10970"> <rect width="24" height="24" fill="white"></rect> </clipPath> </defs> </g></svg>
+66
assets/js/altTextHelper.js
··· 1 + function openAltText(file, altTextButton, loadCallback, saveCallback) { 2 + // A bunch of DOM elements 3 + const altTextModal = document.getElementById("altTextDialog"); 4 + const altTextField = document.getElementById("altTextField"); 5 + const altTextImgPreview = document.getElementById("altThumbImg"); 6 + const saveButton = document.getElementById("altTextSaveButton"); 7 + const cancelButton = document.getElementById("altTextCancelButton"); 8 + const isFileInstance = file instanceof File; 9 + const altTextPreviewImgURL = isFileInstance ? URL.createObjectURL(file) : `preview/file/${file}`; 10 + 11 + // Handle page reset 12 + if (altTextModal.hasAttribute("hasReset") === false) { 13 + document.addEventListener("resetPost", () => { 14 + altTextField.value = ""; 15 + altTextImgPreview.src = ""; 16 + resetCounter("altTextCount"); 17 + }); 18 + altTextModal.setAttribute("hasReset", true); 19 + } 20 + 21 + altTextField.value = loadCallback() || ""; 22 + altTextField.selectionStart = altTextField.value.length; 23 + recountCounter("altTextCount"); 24 + tributeToElement(altTextField); 25 + const handleSave = () => { 26 + const newAltTextData = altTextField.value; 27 + saveCallback(newAltTextData); 28 + if (newAltTextData === "") { 29 + altTextButton.classList.remove("btn-success"); 30 + } else { 31 + altTextButton.classList.add("btn-success"); 32 + } 33 + closeAltModal(); 34 + }; 35 + 36 + const unbindAltModal = () => { 37 + saveButton.replaceWith(saveButton.cloneNode(true)); 38 + cancelButton.replaceWith(cancelButton.cloneNode(true)); 39 + altTextModal.removeEventListener("close", unbindAltModal); 40 + altTextImgPreview.src = ""; 41 + if (isFileInstance) 42 + URL.revokeObjectURL(altTextPreviewImgURL); 43 + detachTribute(altTextField); 44 + } 45 + 46 + const closeAltModal = () => { 47 + unbindAltModal(); 48 + closeModal(altTextModal); 49 + }; 50 + 51 + altTextImgPreview.src = altTextPreviewImgURL; 52 + addClickKeyboardListener(saveButton, handleSave); 53 + addClickKeyboardListener(cancelButton, closeAltModal); 54 + altTextModal.addEventListener("close", unbindAltModal); 55 + openModal(altTextModal); 56 + altTextField.focus(); 57 + } 58 + 59 + function openPostAltEditor(file) { 60 + const editorLocation = document.querySelector(`div[alteditfor="${file}"]`); 61 + const editorDataLocation = editorLocation.querySelector("input[data-alt]"); 62 + openAltText(file, editorLocation.querySelector("a"), () => editorDataLocation.getAttribute("value"), 63 + (newAltValue) => { 64 + editorDataLocation.setAttribute("value", newAltValue); 65 + }); 66 + }
+160
assets/js/app.js
··· 1 + /* Functions that are mostly used on the application side of things */ 2 + function getPostListElement(itemID) { 3 + return document.getElementById(`postBase${itemID}`); 4 + } 5 + 6 + function formatDate(date) { 7 + return new Date(date).toLocaleString(undefined, { 8 + year: "numeric", 9 + month: "short", 10 + day: "numeric", 11 + hour: "numeric", 12 + }); 13 + } 14 + 15 + function updateAllTimes() { 16 + document.querySelectorAll(".timestamp").forEach(el => { 17 + if (el.hasAttribute("corrected")) 18 + return; 19 + 20 + el.textContent = formatDate(el.innerText); 21 + el.setAttribute("corrected", true); 22 + }); 23 + document.querySelectorAll(".repostTimesLeft").forEach(el => { 24 + if (el.hasAttribute("data-tooltip")) 25 + return; 26 + 27 + if (el.firstElementChild.className == "repostInfoData") { 28 + const repostInformation = el.firstElementChild.innerText; 29 + if (repostInformation.length > 0) 30 + el.setAttribute("data-tooltip", repostInformation); 31 + } 32 + }); 33 + } 34 + 35 + function scrollToPost(id) { 36 + const postElement = getPostListElement(id); 37 + if (postElement) { 38 + postElement.scrollIntoView({behavior: "smooth", container: "nearest", block: "nearest", inline: "start" }); 39 + } 40 + } 41 + 42 + function refreshPosts() { 43 + htmx.trigger("body", "refreshPosts"); 44 + } 45 + 46 + document.addEventListener("scrollTop", function() { 47 + scrollTop(); 48 + }); 49 + 50 + document.addEventListener("scrollListTop", function() { 51 + scrollToObject(document.getElementById("posts")); 52 + }); 53 + 54 + document.addEventListener("scrollListToPost", function(ev) { 55 + scrollToPost(ev.detail); 56 + }); 57 + 58 + document.addEventListener("postDeleted", function() { 59 + pushToast("Post deleted", true); 60 + }); 61 + 62 + document.addEventListener("postFailedDelete", function() { 63 + pushToast("Post failed to delete, try again", false); 64 + refreshPosts(); 65 + }); 66 + 67 + document.addEventListener("timeSidebar", function() { 68 + updateAllTimes(); 69 + document.querySelectorAll(".addThreadPost[listen=false]").forEach(el => { 70 + addClickKeyboardListener(el, () => { 71 + const threadEvent = new CustomEvent('replyThreadCreate', { 72 + detail: { 73 + target: el.parentElement 74 + }}); 75 + document.dispatchEvent(threadEvent); 76 + }); 77 + el.setAttribute("listen", true); 78 + }); 79 + }); 80 + 81 + document.addEventListener("postUpdatedNotice", function() { 82 + pushToast("Post updated successfully!", true); 83 + }) 84 + 85 + document.addEventListener("accountUpdated", function(ev) { 86 + closeModal(document.getElementById("changeInfo")); 87 + document.getElementById("settingsData").reset(); 88 + pushToast("Settings Updated!", true); 89 + }); 90 + 91 + function addCounter(textField, counter, maxLength) { 92 + const textEl = document.getElementById(textField); 93 + const counterEl = document.getElementById(counter); 94 + if (counterEl) { 95 + // escape out of adding a counter more than once 96 + if (counterEl.hasAttribute("counting")) 97 + return; 98 + 99 + const handleCount = (counter) => { 100 + counterEl.innerHTML = `${counter.all}/${maxLength}`; 101 + // Show red color if the text field is too long, this will not be super accurate on items containing links, but w/e 102 + if (counter.all > maxLength) { 103 + counterEl.classList.add('tooLong'); 104 + } else { 105 + counterEl.classList.remove('tooLong'); 106 + } 107 + }; 108 + 109 + Countable.on(textEl, handleCount); 110 + counterEl.setAttribute("counting", true); 111 + counterEl.addEventListener("reset", () => { 112 + counterEl.innerHTML = `0/${maxLength}`; 113 + counterEl.classList.remove('tooLong'); 114 + }); 115 + counterEl.addEventListener("recount", () => { 116 + Countable.count(textEl, handleCount); 117 + }); 118 + } 119 + } 120 + 121 + function recountCounter(counter) { 122 + const counterEl = document.getElementById(counter); 123 + counterEl.dispatchEvent(new Event("recount")); 124 + } 125 + 126 + function resetCounter(counter) { 127 + const counterEl = document.getElementById(counter); 128 + counterEl.dispatchEvent(new Event("reset")); 129 + } 130 + 131 + function addEasyModalOpen(buttonID, modalEl, closeButtonID) { 132 + addClickKeyboardListener(document.getElementById(buttonID), () => { 133 + clearSettingsData(); 134 + openModal(modalEl); 135 + }); 136 + addClickKeyboardListener(document.getElementById(closeButtonID), () => { 137 + closeModal(modalEl); 138 + }); 139 + } 140 + 141 + function setElementRequired(el, required) { 142 + if (required) 143 + el.setAttribute("required", true); 144 + else 145 + el.removeAttribute("required"); 146 + } 147 + 148 + function setElementVisible(el, shouldShow) { 149 + if (shouldShow) 150 + el.classList.remove("hidden"); 151 + else 152 + el.classList.add("hidden"); 153 + } 154 + 155 + function setElementDisabled(el, disabled) { 156 + if (disabled) 157 + el.setAttribute("disabled", true); 158 + else 159 + el.removeAttribute("disabled"); 160 + }
+21 -111
assets/js/main.js
··· 1 + /* Functions that can be used anywhere on the website. */ 1 2 function pushToast(msg, isSuccess) { 2 3 Toastify({ 3 4 text: msg, ··· 12 13 window.scrollTo({ top: 0, behavior: "smooth" }); 13 14 } 14 15 15 - function formatDate(date) { 16 - return new Date(date).toLocaleString(undefined, { 17 - year: "numeric", 18 - month: "short", 19 - day: "numeric", 20 - hour: "numeric", 21 - }); 22 - } 23 - 24 - function updateAllTimes() { 25 - document.querySelectorAll(".timestamp").forEach(el => { 26 - if (el.hasAttribute("corrected")) 27 - return; 28 - 29 - el.textContent = formatDate(el.innerText); 30 - el.setAttribute("corrected", true); 31 - }); 32 - document.querySelectorAll(".repostTimesLeft").forEach(el => { 33 - if (el.hasAttribute("data-tooltip")) 34 - return; 35 - 36 - if (el.firstElementChild.className == "repostInfoData") { 37 - const repostInformation = el.firstElementChild.innerText; 38 - if (repostInformation.length > 0) 39 - el.setAttribute("data-tooltip", repostInformation); 16 + function scrollToObject(el) { 17 + if (el) { 18 + el.scroll({top:0, behavior:'smooth'}); 19 + const tabInvalidate = el.querySelector(".invalidateTab"); 20 + if (tabInvalidate) { 21 + tabInvalidate.focus(); 22 + tabInvalidate.blur(); 40 23 } 41 - }); 24 + } 42 25 } 43 26 44 - function refreshPosts() { 45 - htmx.trigger("body", "refreshPosts"); 46 - } 47 - 48 - function addKeyboardListener(el, callback) { 27 + function addKeyboardListener(el, callback, keys=["Enter", " "], preventDefault=true) { 49 28 el.addEventListener("keydown", (ev) => { 50 - if (ev.key === "Enter" || ev.key === " ") { 51 - ev.preventDefault(); 29 + if (keys.includes(ev.key)) { 30 + if (preventDefault) 31 + ev.preventDefault(); 52 32 callback(ev); 53 33 } 54 34 }); 55 35 } 56 - 57 - document.addEventListener("postDeleted", function() { 58 - pushToast("Post deleted", true); 59 - }); 60 - 61 - document.addEventListener("postFailedDelete", function() { 62 - pushToast("Post failed to delete, try again", false); 63 - refreshPosts(); 64 - }); 65 - 66 - document.addEventListener("timeSidebar", function() { 67 - updateAllTimes(); 68 - }); 69 - 70 - document.addEventListener("scrollTop", function() { 71 - scrollTop(); 72 - }); 73 - 74 - document.addEventListener("postUpdatedNotice", function() { 75 - pushToast("Post updated successfully!", true); 76 - }) 77 - 78 - document.addEventListener("accountUpdated", function(ev) { 79 - closeModal(document.getElementById("changeInfo")); 80 - document.getElementById("settingsData").reset(); 81 - pushToast("Settings Updated!", true); 82 - }); 36 + function addClickKeyboardListener(el, callback, keys=["Enter", " "], preventDefault=true) { 37 + el.addEventListener("click", (ev) => { 38 + if (preventDefault) 39 + ev.preventDefault(); 40 + callback(); 41 + }); 42 + addKeyboardListener(el, callback, keys, preventDefault); 43 + } 83 44 84 45 function pushDeletedAccountToast() { 85 46 const urlParams = new URLSearchParams(window.location.search); ··· 130 91 }); 131 92 } 132 93 } 133 - function addCounter(textField, counter, maxLength) { 134 - const textEl = document.getElementById(textField); 135 - const counterEl = document.getElementById(counter); 136 - if (counterEl) { 137 - // escape out of adding a counter more than once 138 - if (counterEl.hasAttribute("counting")) 139 - return; 140 - 141 - const handleCount = (counter) => { 142 - counterEl.innerHTML = `${counter.all}/${maxLength}`; 143 - // Show red color if the text field is too long, this will not be super accurate on items containing links, but w/e 144 - if (counter.all > maxLength) { 145 - counterEl.classList.add('tooLong'); 146 - } else { 147 - counterEl.classList.remove('tooLong'); 148 - } 149 - }; 150 - 151 - Countable.on(textEl, handleCount); 152 - counterEl.setAttribute("counting", true); 153 - counterEl.addEventListener("reset", () => { 154 - counterEl.innerHTML = `0/${maxLength}`; 155 - counterEl.classList.remove('tooLong'); 156 - }); 157 - counterEl.addEventListener("recount", () => { 158 - Countable.count(textEl, handleCount); 159 - }); 160 - } 161 - } 162 - 163 - function recountCounter(counter) { 164 - const counterEl = document.getElementById(counter); 165 - counterEl.dispatchEvent(new Event("recount")); 166 - } 167 - 168 - function resetCounter(counter) { 169 - const counterEl = document.getElementById(counter); 170 - counterEl.dispatchEvent(new Event("reset")); 171 - } 172 94 173 95 function redirectAfterDelay(url, customDelay=0) { 174 96 setTimeout(function() { ··· 246 168 redirectAfterDelay(successLocation, customDelay); 247 169 }); 248 170 } 249 - 250 - function addEasyModalOpen(buttonID, modalEl, closeButtonID) { 251 - document.getElementById(buttonID).addEventListener("click", (ev) => { 252 - ev.preventDefault(); 253 - clearSettingsData(); 254 - openModal(modalEl); 255 - }); 256 - document.getElementById(closeButtonID).addEventListener("click", (ev) => { 257 - ev.preventDefault(); 258 - closeModal(modalEl); 259 - }); 260 - }
+111 -193
assets/js/postHelper.js
··· 1 1 const repostCheckbox = document.getElementById('makeReposts'); 2 2 const postNowCheckbox = document.getElementById('postNow'); 3 3 const scheduledDate = document.getElementById('scheduledDate'); 4 - const urlCardBox = document.getElementById('urlCard'); 4 + 5 5 const recordUrlBox = document.getElementById('recordBox'); 6 6 const content = document.getElementById('content'); 7 7 const postForm = document.getElementById('postForm'); 8 + const threadField = document.getElementById('threadInfo'); 9 + const cancelThreadBtn = document.getElementById('cancelThreadPost'); 10 + const postFormTitle = document.getElementById('postFormTitle') 8 11 let hasFileLimit = false; 9 12 let fileData = new Map(); 10 13 11 - const imageAttachmentSection = document.getElementById("imageAttachmentSection"); 12 - const linkAttachmentSection = document.getElementById("webLinkAttachmentSection"); 14 + /* Sections for handling UI changes and modifications */ 15 + const sectionRetweet = document.getElementById('section-retweet'); 16 + const sectionSchedule = document.getElementById('section-postSchedule'); 17 + const sectionImageAttach = document.getElementById("section-imageAttachment"); 18 + const sectionLinkAttach = document.getElementById("section-weblink"); 13 19 14 20 function addOnUnloadBlocker() { 15 21 window.onbeforeunload = function() { ··· 20 26 function clearOnUnloadBlocker() { 21 27 window.onbeforeunload = null; 22 28 } 23 - function setElementRequired(el, required) { 24 - if (required) 25 - el.setAttribute("required", true); 26 - else 27 - el.removeAttribute("required"); 28 - } 29 29 30 - function setElementVisible(el, shouldShow) { 31 - if (shouldShow) 32 - el.classList.remove("hidden"); 33 - else 34 - el.classList.add("hidden"); 35 - } 36 - 37 - function setElementDisabled(el, disabled) { 38 - if (disabled) 39 - el.setAttribute("disabled", true); 40 - else 41 - el.removeAttribute("disabled"); 42 - } 43 - 44 - urlCardBox.addEventListener("paste", () => { 45 - showContentLabeler(true); 46 - setElementVisible(imageAttachmentSection, false); 47 - }); 48 - 49 - urlCardBox.addEventListener("input", () => { 50 - const isNotEmpty = urlCardBox.value.length > 0; 51 - showContentLabeler(isNotEmpty); 52 - setElementVisible(imageAttachmentSection, !isNotEmpty); 53 - }); 54 - 55 - let fileDropzone = new Dropzone("#fileUploads", { 56 - url: "/post/upload", 30 + let fileDropzone = new Dropzone("#fileUploads", { 31 + url: "/post/upload", 57 32 autoProcessQueue: true, 58 33 /* We process this ourselves */ 59 34 addRemoveLinks: false, ··· 68 43 document.addEventListener("resetPost", () => { 69 44 postForm.reset(); 70 45 postForm.removeAttribute("disabled"); 71 - setElementVisible(imageAttachmentSection, true); 72 - setElementVisible(linkAttachmentSection, true); 46 + // reset sections 47 + setElementVisible(sectionImageAttach, true); 48 + setElementVisible(sectionLinkAttach, true); 49 + setElementVisible(sectionRetweet, true); 50 + setElementVisible(sectionSchedule, true); 51 + setElementVisible(cancelThreadBtn.parentElement, false); 52 + postFormTitle.innerText = "Schedule New Post"; 53 + // remove thread info data 54 + if (threadField.hasAttribute("parentpost")) { 55 + const postHighlight = getPostListElement(threadField.getAttribute("parentpost")); 56 + if (postHighlight) { 57 + postHighlight.classList.remove("highlight"); 58 + } 59 + } 60 + threadField.removeAttribute("rootpost"); 61 + threadField.removeAttribute("parentpost"); 62 + threadField.removeAttribute("postid"); 73 63 showContentLabeler(false); 74 64 setSelectDisable(repostCheckbox.parentElement, true); 75 65 setElementRequired(scheduledDate, true); ··· 78 68 repostCheckbox.checked = false; 79 69 postNowCheckbox.checked = false; 80 70 hasFileLimit = false; 81 - urlCardBox.value = ""; 71 + document.getElementById('urlCard').value = ""; 82 72 recordUrlBox.value = ""; 83 73 resetCounter("count"); 84 74 setTimeout(scrollTop, 400); ··· 93 83 hasFileLimit = false; 94 84 clearOnUnloadBlocker(); 95 85 showContentLabeler(false); 96 - setElementVisible(linkAttachmentSection, true); 86 + setElementVisible(sectionLinkAttach, true); 97 87 }); 98 88 99 89 fileDropzone.on("addedfile", file => { ··· 102 92 pushToast("Maximum number of files reached", false); 103 93 return; 104 94 } 105 - setElementVisible(linkAttachmentSection, false); 95 + setElementVisible(sectionLinkAttach, false); 106 96 const buttonHolder = Dropzone.createElement("<fieldset role='group' class='imgbtn'></fieldset>"); 107 97 const removeButton = Dropzone.createElement("<button class='fileDel outline btn-error' disabled><small>Remove file</small></button>"); 108 98 const addAltText = Dropzone.createElement("<button class='outline' disabled><small>Add Alt Text</small></button><br />"); 109 - 99 + 110 100 addAltText.addEventListener("click", function(e) { 111 101 e.preventDefault(); 112 102 e.stopPropagation(); ··· 128 118 // Remove the file 129 119 fetch('/post/upload', { 130 120 method: 'DELETE', 131 - keepalive: true, 121 + keepalive: true, 132 122 body: JSON.stringify({"content": fileData.get(file.name).content }) 133 123 }).then(async response => { 134 124 const data = await response.json(); ··· 142 132 pushToast(`Deleted file ${file.name}`, true); 143 133 } 144 134 if (fileData.length == 0) { 145 - setElementVisible(linkAttachmentSection, true); 135 + setElementVisible(sectionLinkAttach, true); 146 136 } 147 137 }); 148 138 }); ··· 181 171 pushToast(`${file.name} is too long for bsky by ${(videoDuration - MAX_VIDEO_LENGTH).toFixed(2)} seconds`, false); 182 172 deleteFileOnError(); 183 173 } else { 184 - fileData.set(file.name, {content: response.data, type: 3, 174 + fileData.set(file.name, {content: response.data, type: 3, 185 175 height: videoTag.videoHeight, width: videoTag.videoWidth, duration: videoDuration }); 186 176 hasFileLimit = true; 187 177 } ··· 206 196 if (uint8[i] == 0x21 207 197 && uint8[i + 1] == 0xF9 208 198 && uint8[i + 2] == 0x04 209 - && uint8[i + 7] == 0x00) 199 + && uint8[i + 7] == 0x00) 210 200 { 211 201 const delay = (uint8[i + 5] << 8) | (uint8[i + 4] & 0xFF) 212 202 duration += delay < 2 ? 10 : delay ··· 247 237 } else { 248 238 fileData.set(file.name, {content: response.data, type: 1}); 249 239 } 250 - 240 + 251 241 // Make the buttons pressable 252 242 file.previewElement.querySelectorAll("button").forEach(el => setElementDisabled(el, false)); 253 243 ··· 286 276 console.error(`file error was ${msg}`); 287 277 pushToast(`Error: ${file.name} had an unexpected error`, false); 288 278 } 289 - 279 + 290 280 fileDropzone.removeFile(file); 291 281 if (fileData.length == 0) { 292 - setElementVisible(linkAttachmentSection, true); 282 + setElementVisible(sectionLinkAttach, true); 293 283 } 294 284 }); 295 285 ··· 312 302 const contentVal = content.value; 313 303 const postNow = postNowCheckbox.checked; 314 304 const scheduledDateVal = scheduledDate.value; 305 + const isThreadPost = threadField.hasAttribute("rootpost") && threadField.hasAttribute("parentpost"); 315 306 // Handle conversion of date time to make sure that it is correct. 316 307 let dateTime; 317 308 try { 318 - dateTime = postNow ? new Date().toISOString() : new Date(scheduledDateVal).toISOString(); 309 + dateTime = isThreadPost || postNow ? new Date().toISOString() : new Date(scheduledDateVal).toISOString(); 319 310 } catch(dateErr) { 320 311 pushToast("Invalid date", false); 321 312 showPostProgress(false); ··· 328 319 content: contentVal, 329 320 scheduledDate: dateTime, 330 321 makePostNow: postNow, 331 - repostData: undefined 322 + repostData: undefined, 323 + rootPost: undefined, 324 + parentPost: undefined, 332 325 }; 333 326 334 327 // Add repost data if we should be making reposts ··· 340 333 }; 341 334 } 342 335 336 + // Add thread data if it exists 337 + if (isThreadPost) { 338 + postObject.parentPost = threadField.getAttribute("parentpost"); 339 + postObject.rootPost = threadField.getAttribute("rootpost"); 340 + postObject.makePostNow = false; 341 + postObject.repostData = undefined; 342 + } 343 + 343 344 const hasFiles = fileData.size > 0; 344 - const linkCardURL = urlCardBox.value; 345 + const linkCardURL = document.getElementById('urlCard').value; 345 346 const recordURL = recordUrlBox.value; 346 347 const hasWebEmbed = linkCardURL.length > 0; 347 348 const hasRecord = recordURL.length > 0; ··· 398 399 body: payload 399 400 }); 400 401 const data = await response.json(); 401 - 402 + 402 403 if (response.ok) { 403 404 pushToast(data.message, true); 404 405 document.dispatchEvent(new Event("resetPost")); 405 406 refreshPosts(); 407 + if (data.id) { 408 + // TODO: this really should wait for refreshPosts to end 409 + setTimeout(function(){scrollToPost(data.id)}, 1600); 410 + } 406 411 } else { 407 412 // For postnow, we try again, immediate failures still add to the DB 408 413 if (response.status === 406 && postNow) { ··· 433 438 function showContentLabeler(shouldShow) { 434 439 const contentLabelSelector = document.getElementById("content-label-selector"); 435 440 const contentLabelSelect = document.getElementById("contentLabels"); 441 + const urlEmbedBox = document.getElementById('urlCard'); 436 442 437 - if (!shouldShow && (fileData.length > 0 || urlCardBox.value.length > 0)) 443 + if (!shouldShow && (fileData.length > 0 || urlEmbedBox.value.length > 0)) 438 444 return; 439 445 440 446 setElementVisible(contentLabelSelector, shouldShow); ··· 448 454 el.setAttribute("aria-busy", shouldShow); 449 455 setElementDisabled(el, shouldShow); 450 456 setElementDisabled(postForm, shouldShow); 457 + setElementDisabled(cancelThreadBtn, shouldShow); 451 458 if (shouldShow) { 452 459 el.textContent = "Making Post..."; 453 460 } else { ··· 455 462 } 456 463 } 457 464 458 - function openAltText(file, altTextButton, loadCallback, saveCallback) { 459 - // A bunch of DOM elements 460 - const altTextModal = document.getElementById("altTextDialog"); 461 - const altTextField = document.getElementById("altTextField"); 462 - const altTextImgPreview = document.getElementById("altThumbImg"); 463 - const saveButton = document.getElementById("altTextSaveButton"); 464 - const cancelButton = document.getElementById("altTextCancelButton"); 465 - const isFileInstance = file instanceof File; 466 - const altTextPreviewImgURL = isFileInstance ? URL.createObjectURL(file) : `preview/file/${file}`; 467 - 468 - // Handle page reset 469 - if (altTextModal.hasAttribute("hasReset") === false) { 470 - document.addEventListener("resetPost", () => { 471 - altTextField.value = ""; 472 - altTextImgPreview.src = ""; 473 - resetCounter("altTextCount"); 474 - }); 475 - altTextModal.setAttribute("hasReset", true); 476 - } 477 - 478 - altTextField.value = loadCallback() || ""; 479 - altTextField.selectionStart = altTextField.value.length; 480 - recountCounter("altTextCount"); 481 - tributeToElement(altTextField); 482 - const handleSave = (ev) => { 483 - ev.preventDefault(); 484 - const newAltTextData = altTextField.value; 485 - saveCallback(newAltTextData); 486 - if (newAltTextData === "") { 487 - altTextButton.classList.remove("btn-success"); 488 - } else { 489 - altTextButton.classList.add("btn-success"); 490 - } 491 - closeAltModal(); 492 - }; 493 - 494 - const unbindAltModal = () => { 495 - saveButton.removeEventListener("click", handleSave); 496 - cancelButton.removeEventListener("click", closeAltModal); 497 - altTextModal.removeEventListener("close", unbindAltModal); 498 - altTextImgPreview.src = ""; 499 - if (isFileInstance) 500 - URL.revokeObjectURL(altTextPreviewImgURL); 501 - detachTribute(altTextField); 502 - } 503 - 504 - const closeAltModal = () => { 505 - unbindAltModal(); 506 - closeModal(altTextModal); 507 - }; 508 - 509 - altTextImgPreview.src = altTextPreviewImgURL; 510 - saveButton.addEventListener("click", handleSave); 511 - cancelButton.addEventListener("click", closeAltModal); 512 - altTextModal.addEventListener("close", unbindAltModal); 513 - openModal(altTextModal); 514 - altTextField.focus(); 515 - } 516 - 517 - function searchBSkyMentions(query, callback) { 518 - const xhr = new XMLHttpRequest(); 519 - xhr.open("GET", `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${query}&limit=${MAX_AUTO_COMPLETE_NAMES}`); 520 - xhr.onreadystatechange = () => { 521 - if (xhr.readyState === XMLHttpRequest.DONE) { 522 - // Request good 523 - if (xhr.status === 200) { 524 - try { 525 - const returnData = JSON.parse(xhr.responseText); 526 - callback(returnData.actors); 527 - return; 528 - } catch(err) { 529 - console.error(`failed to parse bsky mention list ${err}`) 530 - } 531 - } 532 - console.error(`fetching bluesky mentionlist returned ${xhr.status}`); 533 - callback([]); 534 - } 535 - } 536 - xhr.send(); 537 - } 538 - 539 - function tributeToElement(el) { 540 - const mentionTribute = new Tribute({ 541 - menuItemTemplate: function(item) { 542 - const avatarStr = item.original.avatar !== undefined ? `<img src="${item.original.avatar}">` : ""; 543 - return `${avatarStr}<span><code>${item.original.displayName}</code><br /> <small>@${item.original.handle}</small></span>`; 544 - }, 545 - values: function(text, cb) { 546 - searchBSkyMentions(text, item => cb(item)); 547 - }, 548 - noMatchTemplate: () => '<span class="acBskyHandle">No Match Found</span>', 549 - lookup: 'handle', 550 - fillAttr: 'handle', 551 - spaceSelectsMatch: true, 552 - menuItemLimit: MAX_AUTO_COMPLETE_NAMES, 553 - menuShowMinLength: MIN_CHAR_AUTO_COMPLETE_NAMES, 554 - menuContainer: el.parentNode 555 - }); 556 - 557 - el.addEventListener("detach", () => { 558 - mentionTribute.detach(el); 559 - }); 560 - mentionTribute.attach(el); 561 - } 562 - 563 - function detachTribute(el) { 564 - el.dispatchEvent(new Event("detach")); 565 - } 566 - 567 - function openPostAltEditor(file) { 568 - const editorLocation = document.querySelector(`div[alteditfor="${file}"]`); 569 - const editorDataLocation = editorLocation.querySelector("input[data-alt]"); 570 - openAltText(file, editorLocation.querySelector("a"), () => editorDataLocation.getAttribute("value"), 571 - (newAltValue) => { 572 - editorDataLocation.setAttribute("value", newAltValue); 573 - }); 574 - } 575 - 576 465 // HTMX will call this 577 466 document.addEventListener("editPost", function(event) { 578 467 const postid = event.detail.value; 579 468 const editField = document.getElementById(`edit${postid}`); 580 469 const editForm = document.getElementById(`editPost${postid}`); 581 470 const cancelButton = editForm.querySelector(".cancelEditButton"); 582 - 471 + 583 472 addCounter(`edit${postid}`, `editCount${postid}`, MAX_LENGTH); 584 473 tributeToElement(editField); 585 474 ··· 596 485 editField.addEventListener("tribute-active-false", () => { 597 486 editField.addEventListener("keydown", cancelEditField); 598 487 }); 599 - 600 - const addEventListeners = (el, callback) => { 601 - el.addEventListener("click", (ev) => { 602 - ev.preventDefault(); 603 - callback(); 604 - }); 605 - addKeyboardListener(el, (ev) => callback()); 606 - } 488 + 607 489 editField.addEventListener("keydown", cancelEditField); 608 490 editForm.querySelectorAll(".editPostAlt").forEach((altEl) => { 609 - addEventListeners(altEl, () => { 491 + addClickKeyboardListener(altEl, () => { 610 492 openPostAltEditor(encodeURIComponent(altEl.getAttribute("data-file")) || ""); 611 493 }); 612 494 }); 613 495 614 - addKeyboardListener(cancelButton, (ev) => cancelButton.click()); 496 + addKeyboardListener(cancelButton, () => cancelButton.click()); 615 497 616 498 editField.selectionStart = editField.value.length; 617 499 editField.focus(); 618 500 }); 619 501 620 - document.addEventListener("scrollListTop", function() { 621 - const postsList = document.getElementById("posts"); 622 - if (postsList) { 623 - postsList.scroll({top:0, behavior:'smooth'}); 624 - const tabInvalidate = postsList.querySelector(".invalidateTab"); 625 - if (tabInvalidate) { 626 - tabInvalidate.focus(); 627 - tabInvalidate.blur(); 628 - } 502 + document.addEventListener("replyThreadCreate", function(ev) { 503 + const postDOM = ev.detail.target; 504 + // check attributes 505 + if (!postDOM.hasAttribute("data-root")) { 506 + pushToast("Invalid operation occurred", false); 507 + return; 629 508 } 509 + 510 + const rootID = postDOM.getAttribute("data-root"); 511 + if (threadField.hasAttribute("rootpost")) { 512 + const currentEdit = threadField.getAttribute("rootpost"); 513 + if (rootID != currentEdit) 514 + pushToast("You are already threading a post, please cancel/submit it before continuing", false); 515 + return; 516 + } 517 + 518 + threadField.setAttribute("rootpost", rootID); 519 + const parentID = postDOM.hasAttribute("data-item") ? postDOM.getAttribute("data-item") : rootID; 520 + threadField.setAttribute("parentpost", parentID); 521 + const postHighlight = getPostListElement(parentID); 522 + if (postHighlight) { 523 + postHighlight.classList.add("highlight"); 524 + } 525 + 526 + setElementVisible(cancelThreadBtn.parentElement, true); 527 + setElementVisible(sectionRetweet, false); 528 + setElementVisible(sectionSchedule, false); 529 + 530 + postFormTitle.innerText = "Schedule New Thread Reply"; 630 531 }); 631 532 632 533 function runPageReactors() { ··· 659 560 } 660 561 }); 661 562 563 + const urlCardBox = document.getElementById('urlCard'); 564 + urlCardBox.addEventListener("paste", () => { 565 + showContentLabeler(true); 566 + setElementVisible(sectionImageAttach, false); 567 + }); 568 + 569 + urlCardBox.addEventListener("input", (ev) => { 570 + const isNotEmpty = ev.target.value.length > 0; 571 + showContentLabeler(isNotEmpty); 572 + setElementVisible(sectionImageAttach, !isNotEmpty); 573 + }); 574 + 662 575 // Handle character counting 663 576 addCounter("content", "count", MAX_LENGTH); 664 577 addCounter("altTextField", "altTextCount", MAX_ALT_LENGTH); 665 578 // Add mentions 666 579 tributeToElement(content); 580 + // add event for the cancel button 581 + if (cancelThreadBtn) { 582 + addClickKeyboardListener(cancelThreadBtn, () => 583 + {document.dispatchEvent(new Event("resetPost")) }); 584 + } 667 585 document.dispatchEvent(new Event("timeSidebar")); 668 586 document.dispatchEvent(new Event("resetPost")); 669 587 } 670 588 671 - document.addEventListener("DOMContentLoaded", () => { 589 + document.addEventListener("DOMContentLoaded", () => { 672 590 runPageReactors(); 673 591 new PicoTabs('[role="tablist"]'); 674 592 });
+1 -1
assets/js/repostHelper.js
··· 89 89 body: payload 90 90 }); 91 91 const data = await response.json(); 92 - 92 + 93 93 if (response.ok) { 94 94 pushToast(data.message, true); 95 95 document.dispatchEvent(new Event("resetRepost"));
+49
assets/js/tributeHelper.js
··· 1 + function searchBSkyMentions(query, callback) { 2 + const xhr = new XMLHttpRequest(); 3 + xhr.open("GET", `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${query}&limit=${MAX_AUTO_COMPLETE_NAMES}`); 4 + xhr.onreadystatechange = () => { 5 + if (xhr.readyState === XMLHttpRequest.DONE) { 6 + // Request good 7 + if (xhr.status === 200) { 8 + try { 9 + const returnData = JSON.parse(xhr.responseText); 10 + callback(returnData.actors); 11 + return; 12 + } catch(err) { 13 + console.error(`failed to parse bsky mention list ${err}`) 14 + } 15 + } 16 + console.error(`fetching bluesky mentionlist returned ${xhr.status}`); 17 + callback([]); 18 + } 19 + } 20 + xhr.send(); 21 + } 22 + 23 + function tributeToElement(el) { 24 + const mentionTribute = new Tribute({ 25 + menuItemTemplate: function(item) { 26 + const avatarStr = item.original.avatar !== undefined ? `<img src="${item.original.avatar}">` : ""; 27 + return `${avatarStr}<span><code>${item.original.displayName}</code><br /> <small>@${item.original.handle}</small></span>`; 28 + }, 29 + values: function(text, cb) { 30 + searchBSkyMentions(text, item => cb(item)); 31 + }, 32 + noMatchTemplate: () => '<span class="acBskyHandle">No Match Found</span>', 33 + lookup: 'handle', 34 + fillAttr: 'handle', 35 + spaceSelectsMatch: true, 36 + menuItemLimit: MAX_AUTO_COMPLETE_NAMES, 37 + menuShowMinLength: MIN_CHAR_AUTO_COMPLETE_NAMES, 38 + menuContainer: el.parentNode 39 + }); 40 + 41 + el.addEventListener("detach", () => { 42 + mentionTribute.detach(el); 43 + }); 44 + mentionTribute.attach(el); 45 + } 46 + 47 + function detachTribute(el) { 48 + el.dispatchEvent(new Event("detach")); 49 + }
+4 -4
assets/sitemap.xml
··· 1 1 <?xml version="1.0" encoding="UTF-8"?> 2 - <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" 3 - xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 4 - xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 2 + <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" 3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 4 + xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 5 5 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> 6 6 <url> 7 7 <loc>https://skyscheduler.work/</loc> 8 - <lastmod>2025-12-30T12:10:19.142Z</lastmod> 8 + <lastmod>2026-02-13T22:23:19.142Z</lastmod> 9 9 <priority>1.00</priority> 10 10 </url> 11 11 <url>
+1 -1
drizzle.config.ts
··· 1 1 import type { Config } from "drizzle-kit"; 2 - 2 + 3 3 export default { 4 4 schema: "./src/db/index.ts", 5 5 out: "./migrations",
+5
migrations/0025_gifted_dust.sql
··· 1 + ALTER TABLE `posts` ADD `rootPost` text;--> statement-breakpoint 2 + ALTER TABLE `posts` ADD `parentPost` text;--> statement-breakpoint 3 + ALTER TABLE `posts` ADD `threadOrder` integer DEFAULT -1;--> statement-breakpoint 4 + CREATE INDEX `generalThread_idx` ON `posts` (`parentPost`,`rootPost`) WHERE parentPost is not NULL;--> statement-breakpoint 5 + CREATE INDEX `threadOrder_idx` ON `posts` (`rootPost`,`threadOrder`) WHERE threadOrder >= 0;
+949
migrations/meta/0025_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "b19e8727-067c-4073-9390-f543f182da33", 5 + "prevId": "78d39811-af21-4af8-aaf3-e388148bfa95", 6 + "tables": { 7 + "accounts": { 8 + "name": "accounts", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "account_id": { 18 + "name": "account_id", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "provider_id": { 25 + "name": "provider_id", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "user_id": { 32 + "name": "user_id", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + }, 38 + "access_token": { 39 + "name": "access_token", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "refresh_token": { 46 + "name": "refresh_token", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": false, 50 + "autoincrement": false 51 + }, 52 + "id_token": { 53 + "name": "id_token", 54 + "type": "text", 55 + "primaryKey": false, 56 + "notNull": false, 57 + "autoincrement": false 58 + }, 59 + "access_token_expires_at": { 60 + "name": "access_token_expires_at", 61 + "type": "integer", 62 + "primaryKey": false, 63 + "notNull": false, 64 + "autoincrement": false 65 + }, 66 + "refresh_token_expires_at": { 67 + "name": "refresh_token_expires_at", 68 + "type": "integer", 69 + "primaryKey": false, 70 + "notNull": false, 71 + "autoincrement": false 72 + }, 73 + "scope": { 74 + "name": "scope", 75 + "type": "text", 76 + "primaryKey": false, 77 + "notNull": false, 78 + "autoincrement": false 79 + }, 80 + "password": { 81 + "name": "password", 82 + "type": "text", 83 + "primaryKey": false, 84 + "notNull": false, 85 + "autoincrement": false 86 + }, 87 + "created_at": { 88 + "name": "created_at", 89 + "type": "integer", 90 + "primaryKey": false, 91 + "notNull": true, 92 + "autoincrement": false, 93 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 94 + }, 95 + "updated_at": { 96 + "name": "updated_at", 97 + "type": "integer", 98 + "primaryKey": false, 99 + "notNull": true, 100 + "autoincrement": false 101 + } 102 + }, 103 + "indexes": { 104 + "accounts_userId_idx": { 105 + "name": "accounts_userId_idx", 106 + "columns": [ 107 + "user_id" 108 + ], 109 + "isUnique": false 110 + } 111 + }, 112 + "foreignKeys": { 113 + "accounts_user_id_users_id_fk": { 114 + "name": "accounts_user_id_users_id_fk", 115 + "tableFrom": "accounts", 116 + "tableTo": "users", 117 + "columnsFrom": [ 118 + "user_id" 119 + ], 120 + "columnsTo": [ 121 + "id" 122 + ], 123 + "onDelete": "cascade", 124 + "onUpdate": "no action" 125 + } 126 + }, 127 + "compositePrimaryKeys": {}, 128 + "uniqueConstraints": {}, 129 + "checkConstraints": {} 130 + }, 131 + "sessions": { 132 + "name": "sessions", 133 + "columns": { 134 + "id": { 135 + "name": "id", 136 + "type": "text", 137 + "primaryKey": true, 138 + "notNull": true, 139 + "autoincrement": false 140 + }, 141 + "expires_at": { 142 + "name": "expires_at", 143 + "type": "integer", 144 + "primaryKey": false, 145 + "notNull": true, 146 + "autoincrement": false 147 + }, 148 + "token": { 149 + "name": "token", 150 + "type": "text", 151 + "primaryKey": false, 152 + "notNull": true, 153 + "autoincrement": false 154 + }, 155 + "created_at": { 156 + "name": "created_at", 157 + "type": "integer", 158 + "primaryKey": false, 159 + "notNull": true, 160 + "autoincrement": false, 161 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 162 + }, 163 + "updated_at": { 164 + "name": "updated_at", 165 + "type": "integer", 166 + "primaryKey": false, 167 + "notNull": true, 168 + "autoincrement": false 169 + }, 170 + "ip_address": { 171 + "name": "ip_address", 172 + "type": "text", 173 + "primaryKey": false, 174 + "notNull": false, 175 + "autoincrement": false 176 + }, 177 + "user_agent": { 178 + "name": "user_agent", 179 + "type": "text", 180 + "primaryKey": false, 181 + "notNull": false, 182 + "autoincrement": false 183 + }, 184 + "user_id": { 185 + "name": "user_id", 186 + "type": "text", 187 + "primaryKey": false, 188 + "notNull": true, 189 + "autoincrement": false 190 + } 191 + }, 192 + "indexes": { 193 + "sessions_token_unique": { 194 + "name": "sessions_token_unique", 195 + "columns": [ 196 + "token" 197 + ], 198 + "isUnique": true 199 + }, 200 + "sessions_userId_idx": { 201 + "name": "sessions_userId_idx", 202 + "columns": [ 203 + "user_id" 204 + ], 205 + "isUnique": false 206 + } 207 + }, 208 + "foreignKeys": { 209 + "sessions_user_id_users_id_fk": { 210 + "name": "sessions_user_id_users_id_fk", 211 + "tableFrom": "sessions", 212 + "tableTo": "users", 213 + "columnsFrom": [ 214 + "user_id" 215 + ], 216 + "columnsTo": [ 217 + "id" 218 + ], 219 + "onDelete": "cascade", 220 + "onUpdate": "no action" 221 + } 222 + }, 223 + "compositePrimaryKeys": {}, 224 + "uniqueConstraints": {}, 225 + "checkConstraints": {} 226 + }, 227 + "users": { 228 + "name": "users", 229 + "columns": { 230 + "id": { 231 + "name": "id", 232 + "type": "text", 233 + "primaryKey": true, 234 + "notNull": true, 235 + "autoincrement": false 236 + }, 237 + "name": { 238 + "name": "name", 239 + "type": "text", 240 + "primaryKey": false, 241 + "notNull": true, 242 + "autoincrement": false 243 + }, 244 + "email": { 245 + "name": "email", 246 + "type": "text", 247 + "primaryKey": false, 248 + "notNull": true, 249 + "autoincrement": false 250 + }, 251 + "email_verified": { 252 + "name": "email_verified", 253 + "type": "integer", 254 + "primaryKey": false, 255 + "notNull": true, 256 + "autoincrement": false 257 + }, 258 + "image": { 259 + "name": "image", 260 + "type": "text", 261 + "primaryKey": false, 262 + "notNull": false, 263 + "autoincrement": false 264 + }, 265 + "created_at": { 266 + "name": "created_at", 267 + "type": "integer", 268 + "primaryKey": false, 269 + "notNull": true, 270 + "autoincrement": false 271 + }, 272 + "updated_at": { 273 + "name": "updated_at", 274 + "type": "integer", 275 + "primaryKey": false, 276 + "notNull": true, 277 + "autoincrement": false 278 + }, 279 + "username": { 280 + "name": "username", 281 + "type": "text", 282 + "primaryKey": false, 283 + "notNull": false, 284 + "autoincrement": false 285 + }, 286 + "display_username": { 287 + "name": "display_username", 288 + "type": "text", 289 + "primaryKey": false, 290 + "notNull": false, 291 + "autoincrement": false 292 + }, 293 + "bsky_app_pass": { 294 + "name": "bsky_app_pass", 295 + "type": "text", 296 + "primaryKey": false, 297 + "notNull": true, 298 + "autoincrement": false 299 + }, 300 + "pds": { 301 + "name": "pds", 302 + "type": "text", 303 + "primaryKey": false, 304 + "notNull": true, 305 + "autoincrement": false, 306 + "default": "'https://bsky.social'" 307 + } 308 + }, 309 + "indexes": { 310 + "users_email_unique": { 311 + "name": "users_email_unique", 312 + "columns": [ 313 + "email" 314 + ], 315 + "isUnique": true 316 + }, 317 + "users_username_unique": { 318 + "name": "users_username_unique", 319 + "columns": [ 320 + "username" 321 + ], 322 + "isUnique": true 323 + } 324 + }, 325 + "foreignKeys": {}, 326 + "compositePrimaryKeys": {}, 327 + "uniqueConstraints": {}, 328 + "checkConstraints": {} 329 + }, 330 + "verifications": { 331 + "name": "verifications", 332 + "columns": { 333 + "id": { 334 + "name": "id", 335 + "type": "text", 336 + "primaryKey": true, 337 + "notNull": true, 338 + "autoincrement": false 339 + }, 340 + "identifier": { 341 + "name": "identifier", 342 + "type": "text", 343 + "primaryKey": false, 344 + "notNull": true, 345 + "autoincrement": false 346 + }, 347 + "value": { 348 + "name": "value", 349 + "type": "text", 350 + "primaryKey": false, 351 + "notNull": true, 352 + "autoincrement": false 353 + }, 354 + "expires_at": { 355 + "name": "expires_at", 356 + "type": "integer", 357 + "primaryKey": false, 358 + "notNull": true, 359 + "autoincrement": false 360 + }, 361 + "created_at": { 362 + "name": "created_at", 363 + "type": "integer", 364 + "primaryKey": false, 365 + "notNull": true, 366 + "autoincrement": false, 367 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 368 + }, 369 + "updated_at": { 370 + "name": "updated_at", 371 + "type": "integer", 372 + "primaryKey": false, 373 + "notNull": true, 374 + "autoincrement": false, 375 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 376 + } 377 + }, 378 + "indexes": { 379 + "verifications_identifier_idx": { 380 + "name": "verifications_identifier_idx", 381 + "columns": [ 382 + "identifier" 383 + ], 384 + "isUnique": false 385 + } 386 + }, 387 + "foreignKeys": {}, 388 + "compositePrimaryKeys": {}, 389 + "uniqueConstraints": {}, 390 + "checkConstraints": {} 391 + }, 392 + "bans": { 393 + "name": "bans", 394 + "columns": { 395 + "account_did": { 396 + "name": "account_did", 397 + "type": "text", 398 + "primaryKey": true, 399 + "notNull": true, 400 + "autoincrement": false 401 + }, 402 + "banReason": { 403 + "name": "banReason", 404 + "type": "text", 405 + "primaryKey": false, 406 + "notNull": true, 407 + "autoincrement": false, 408 + "default": "''" 409 + }, 410 + "created_at": { 411 + "name": "created_at", 412 + "type": "integer", 413 + "primaryKey": false, 414 + "notNull": true, 415 + "autoincrement": false, 416 + "default": "CURRENT_TIMESTAMP" 417 + } 418 + }, 419 + "indexes": {}, 420 + "foreignKeys": {}, 421 + "compositePrimaryKeys": {}, 422 + "uniqueConstraints": {}, 423 + "checkConstraints": {} 424 + }, 425 + "media": { 426 + "name": "media", 427 + "columns": { 428 + "file": { 429 + "name": "file", 430 + "type": "text", 431 + "primaryKey": true, 432 + "notNull": true, 433 + "autoincrement": false 434 + }, 435 + "hasPost": { 436 + "name": "hasPost", 437 + "type": "integer", 438 + "primaryKey": false, 439 + "notNull": false, 440 + "autoincrement": false, 441 + "default": false 442 + }, 443 + "user": { 444 + "name": "user", 445 + "type": "text", 446 + "primaryKey": false, 447 + "notNull": false, 448 + "autoincrement": false 449 + }, 450 + "created_at": { 451 + "name": "created_at", 452 + "type": "integer", 453 + "primaryKey": false, 454 + "notNull": true, 455 + "autoincrement": false 456 + } 457 + }, 458 + "indexes": { 459 + "media_oldWithNoPost_idx": { 460 + "name": "media_oldWithNoPost_idx", 461 + "columns": [ 462 + "hasPost", 463 + "created_at" 464 + ], 465 + "isUnique": false, 466 + "where": "hasPost = 0" 467 + }, 468 + "media_userid_idx": { 469 + "name": "media_userid_idx", 470 + "columns": [ 471 + "user" 472 + ], 473 + "isUnique": false 474 + } 475 + }, 476 + "foreignKeys": { 477 + "media_user_users_id_fk": { 478 + "name": "media_user_users_id_fk", 479 + "tableFrom": "media", 480 + "tableTo": "users", 481 + "columnsFrom": [ 482 + "user" 483 + ], 484 + "columnsTo": [ 485 + "id" 486 + ], 487 + "onDelete": "cascade", 488 + "onUpdate": "no action" 489 + } 490 + }, 491 + "compositePrimaryKeys": {}, 492 + "uniqueConstraints": {}, 493 + "checkConstraints": {} 494 + }, 495 + "posts": { 496 + "name": "posts", 497 + "columns": { 498 + "uuid": { 499 + "name": "uuid", 500 + "type": "text", 501 + "primaryKey": true, 502 + "notNull": true, 503 + "autoincrement": false 504 + }, 505 + "content": { 506 + "name": "content", 507 + "type": "text", 508 + "primaryKey": false, 509 + "notNull": true, 510 + "autoincrement": false 511 + }, 512 + "scheduled_date": { 513 + "name": "scheduled_date", 514 + "type": "integer", 515 + "primaryKey": false, 516 + "notNull": true, 517 + "autoincrement": false 518 + }, 519 + "posted": { 520 + "name": "posted", 521 + "type": "integer", 522 + "primaryKey": false, 523 + "notNull": false, 524 + "autoincrement": false, 525 + "default": false 526 + }, 527 + "postNow": { 528 + "name": "postNow", 529 + "type": "integer", 530 + "primaryKey": false, 531 + "notNull": false, 532 + "autoincrement": false, 533 + "default": false 534 + }, 535 + "embedContent": { 536 + "name": "embedContent", 537 + "type": "text", 538 + "primaryKey": false, 539 + "notNull": true, 540 + "autoincrement": false, 541 + "default": "(json_array())" 542 + }, 543 + "repostInfo": { 544 + "name": "repostInfo", 545 + "type": "text", 546 + "primaryKey": false, 547 + "notNull": false, 548 + "autoincrement": false 549 + }, 550 + "uri": { 551 + "name": "uri", 552 + "type": "text", 553 + "primaryKey": false, 554 + "notNull": false, 555 + "autoincrement": false 556 + }, 557 + "cid": { 558 + "name": "cid", 559 + "type": "text", 560 + "primaryKey": false, 561 + "notNull": false, 562 + "autoincrement": false 563 + }, 564 + "isRepost": { 565 + "name": "isRepost", 566 + "type": "integer", 567 + "primaryKey": false, 568 + "notNull": false, 569 + "autoincrement": false, 570 + "default": false 571 + }, 572 + "rootPost": { 573 + "name": "rootPost", 574 + "type": "text", 575 + "primaryKey": false, 576 + "notNull": false, 577 + "autoincrement": false 578 + }, 579 + "parentPost": { 580 + "name": "parentPost", 581 + "type": "text", 582 + "primaryKey": false, 583 + "notNull": false, 584 + "autoincrement": false 585 + }, 586 + "threadOrder": { 587 + "name": "threadOrder", 588 + "type": "integer", 589 + "primaryKey": false, 590 + "notNull": false, 591 + "autoincrement": false, 592 + "default": -1 593 + }, 594 + "contentLabel": { 595 + "name": "contentLabel", 596 + "type": "text", 597 + "primaryKey": false, 598 + "notNull": true, 599 + "autoincrement": false, 600 + "default": "'None'" 601 + }, 602 + "created_at": { 603 + "name": "created_at", 604 + "type": "integer", 605 + "primaryKey": false, 606 + "notNull": true, 607 + "autoincrement": false, 608 + "default": "CURRENT_TIMESTAMP" 609 + }, 610 + "updated_at": { 611 + "name": "updated_at", 612 + "type": "integer", 613 + "primaryKey": false, 614 + "notNull": false, 615 + "autoincrement": false 616 + }, 617 + "user": { 618 + "name": "user", 619 + "type": "text", 620 + "primaryKey": false, 621 + "notNull": true, 622 + "autoincrement": false 623 + } 624 + }, 625 + "indexes": { 626 + "user_idx": { 627 + "name": "user_idx", 628 + "columns": [ 629 + "user" 630 + ], 631 + "isUnique": false 632 + }, 633 + "postedUpdate_idx": { 634 + "name": "postedUpdate_idx", 635 + "columns": [ 636 + "updated_at", 637 + "posted" 638 + ], 639 + "isUnique": false, 640 + "where": "posted = 1" 641 + }, 642 + "repostOnlyUser_idx": { 643 + "name": "repostOnlyUser_idx", 644 + "columns": [ 645 + "user", 646 + "isRepost" 647 + ], 648 + "isUnique": false, 649 + "where": "isRepost = 1" 650 + }, 651 + "postedUUID_idx": { 652 + "name": "postedUUID_idx", 653 + "columns": [ 654 + "uuid", 655 + "posted" 656 + ], 657 + "isUnique": false 658 + }, 659 + "generalThread_idx": { 660 + "name": "generalThread_idx", 661 + "columns": [ 662 + "parentPost", 663 + "rootPost" 664 + ], 665 + "isUnique": false, 666 + "where": "parentPost is not NULL" 667 + }, 668 + "threadOrder_idx": { 669 + "name": "threadOrder_idx", 670 + "columns": [ 671 + "rootPost", 672 + "threadOrder" 673 + ], 674 + "isUnique": false, 675 + "where": "threadOrder >= 0" 676 + }, 677 + "postNowScheduledDatePosted_idx": { 678 + "name": "postNowScheduledDatePosted_idx", 679 + "columns": [ 680 + "posted", 681 + "scheduled_date", 682 + "postNow" 683 + ], 684 + "isUnique": false, 685 + "where": "posted = 0 and postNow <> 1" 686 + }, 687 + "repostAddOn_idx": { 688 + "name": "repostAddOn_idx", 689 + "columns": [ 690 + "user", 691 + "cid" 692 + ], 693 + "isUnique": false 694 + } 695 + }, 696 + "foreignKeys": { 697 + "posts_user_users_id_fk": { 698 + "name": "posts_user_users_id_fk", 699 + "tableFrom": "posts", 700 + "tableTo": "users", 701 + "columnsFrom": [ 702 + "user" 703 + ], 704 + "columnsTo": [ 705 + "id" 706 + ], 707 + "onDelete": "cascade", 708 + "onUpdate": "no action" 709 + } 710 + }, 711 + "compositePrimaryKeys": {}, 712 + "uniqueConstraints": {}, 713 + "checkConstraints": {} 714 + }, 715 + "repostCounts": { 716 + "name": "repostCounts", 717 + "columns": { 718 + "post_uuid": { 719 + "name": "post_uuid", 720 + "type": "text", 721 + "primaryKey": true, 722 + "notNull": true, 723 + "autoincrement": false 724 + }, 725 + "count": { 726 + "name": "count", 727 + "type": "integer", 728 + "primaryKey": false, 729 + "notNull": true, 730 + "autoincrement": false, 731 + "default": 0 732 + } 733 + }, 734 + "indexes": {}, 735 + "foreignKeys": { 736 + "repostCounts_post_uuid_posts_uuid_fk": { 737 + "name": "repostCounts_post_uuid_posts_uuid_fk", 738 + "tableFrom": "repostCounts", 739 + "tableTo": "posts", 740 + "columnsFrom": [ 741 + "post_uuid" 742 + ], 743 + "columnsTo": [ 744 + "uuid" 745 + ], 746 + "onDelete": "cascade", 747 + "onUpdate": "no action" 748 + } 749 + }, 750 + "compositePrimaryKeys": {}, 751 + "uniqueConstraints": {}, 752 + "checkConstraints": {} 753 + }, 754 + "reposts": { 755 + "name": "reposts", 756 + "columns": { 757 + "id": { 758 + "name": "id", 759 + "type": "integer", 760 + "primaryKey": true, 761 + "notNull": true, 762 + "autoincrement": true 763 + }, 764 + "post_uuid": { 765 + "name": "post_uuid", 766 + "type": "text", 767 + "primaryKey": false, 768 + "notNull": true, 769 + "autoincrement": false 770 + }, 771 + "scheduled_date": { 772 + "name": "scheduled_date", 773 + "type": "integer", 774 + "primaryKey": false, 775 + "notNull": true, 776 + "autoincrement": false 777 + }, 778 + "schedule_guid": { 779 + "name": "schedule_guid", 780 + "type": "text", 781 + "primaryKey": false, 782 + "notNull": false, 783 + "autoincrement": false 784 + } 785 + }, 786 + "indexes": { 787 + "repost_scheduledDate_idx": { 788 + "name": "repost_scheduledDate_idx", 789 + "columns": [ 790 + "scheduled_date" 791 + ], 792 + "isUnique": false 793 + }, 794 + "repost_postid_idx": { 795 + "name": "repost_postid_idx", 796 + "columns": [ 797 + "post_uuid" 798 + ], 799 + "isUnique": false 800 + }, 801 + "repost_scheduleGuid_idx": { 802 + "name": "repost_scheduleGuid_idx", 803 + "columns": [ 804 + "schedule_guid", 805 + "post_uuid" 806 + ], 807 + "isUnique": false 808 + }, 809 + "repost_noduplicates_idx": { 810 + "name": "repost_noduplicates_idx", 811 + "columns": [ 812 + "post_uuid", 813 + "scheduled_date" 814 + ], 815 + "isUnique": true 816 + } 817 + }, 818 + "foreignKeys": { 819 + "reposts_post_uuid_posts_uuid_fk": { 820 + "name": "reposts_post_uuid_posts_uuid_fk", 821 + "tableFrom": "reposts", 822 + "tableTo": "posts", 823 + "columnsFrom": [ 824 + "post_uuid" 825 + ], 826 + "columnsTo": [ 827 + "uuid" 828 + ], 829 + "onDelete": "cascade", 830 + "onUpdate": "no action" 831 + } 832 + }, 833 + "compositePrimaryKeys": {}, 834 + "uniqueConstraints": {}, 835 + "checkConstraints": {} 836 + }, 837 + "violations": { 838 + "name": "violations", 839 + "columns": { 840 + "id": { 841 + "name": "id", 842 + "type": "integer", 843 + "primaryKey": true, 844 + "notNull": true, 845 + "autoincrement": true 846 + }, 847 + "user": { 848 + "name": "user", 849 + "type": "text", 850 + "primaryKey": false, 851 + "notNull": true, 852 + "autoincrement": false 853 + }, 854 + "tosViolation": { 855 + "name": "tosViolation", 856 + "type": "integer", 857 + "primaryKey": false, 858 + "notNull": false, 859 + "autoincrement": false, 860 + "default": false 861 + }, 862 + "userPassInvalid": { 863 + "name": "userPassInvalid", 864 + "type": "integer", 865 + "primaryKey": false, 866 + "notNull": false, 867 + "autoincrement": false, 868 + "default": false 869 + }, 870 + "accountSuspended": { 871 + "name": "accountSuspended", 872 + "type": "integer", 873 + "primaryKey": false, 874 + "notNull": false, 875 + "autoincrement": false, 876 + "default": false 877 + }, 878 + "accountGone": { 879 + "name": "accountGone", 880 + "type": "integer", 881 + "primaryKey": false, 882 + "notNull": false, 883 + "autoincrement": false, 884 + "default": false 885 + }, 886 + "mediaTooBig": { 887 + "name": "mediaTooBig", 888 + "type": "integer", 889 + "primaryKey": false, 890 + "notNull": false, 891 + "autoincrement": false, 892 + "default": false 893 + }, 894 + "created_at": { 895 + "name": "created_at", 896 + "type": "integer", 897 + "primaryKey": false, 898 + "notNull": true, 899 + "autoincrement": false, 900 + "default": "CURRENT_TIMESTAMP" 901 + } 902 + }, 903 + "indexes": { 904 + "violations_user_unique": { 905 + "name": "violations_user_unique", 906 + "columns": [ 907 + "user" 908 + ], 909 + "isUnique": true 910 + }, 911 + "violations_user_idx": { 912 + "name": "violations_user_idx", 913 + "columns": [ 914 + "user" 915 + ], 916 + "isUnique": false 917 + } 918 + }, 919 + "foreignKeys": { 920 + "violations_user_users_id_fk": { 921 + "name": "violations_user_users_id_fk", 922 + "tableFrom": "violations", 923 + "tableTo": "users", 924 + "columnsFrom": [ 925 + "user" 926 + ], 927 + "columnsTo": [ 928 + "id" 929 + ], 930 + "onDelete": "cascade", 931 + "onUpdate": "no action" 932 + } 933 + }, 934 + "compositePrimaryKeys": {}, 935 + "uniqueConstraints": {}, 936 + "checkConstraints": {} 937 + } 938 + }, 939 + "views": {}, 940 + "enums": {}, 941 + "_meta": { 942 + "schemas": {}, 943 + "tables": {}, 944 + "columns": {} 945 + }, 946 + "internal": { 947 + "indexes": {} 948 + } 949 + }
+7
migrations/meta/_journal.json
··· 176 176 "when": 1770686362039, 177 177 "tag": "0024_cloudy_vapor", 178 178 "breakpoints": true 179 + }, 180 + { 181 + "idx": 25, 182 + "version": "6", 183 + "when": 1771043534550, 184 + "tag": "0025_gifted_dust", 185 + "breakpoints": true 179 186 } 180 187 ] 181 188 }
+163 -163
package-lock.json
··· 6 6 "": { 7 7 "name": "skyscheduler", 8 8 "dependencies": { 9 - "@atproto/api": "^0.18.20", 9 + "@atproto/api": "^0.18.21", 10 10 "better-auth": "^1.4.18", 11 11 "better-auth-cloudflare": "^0.2.9", 12 12 "date-fns": "^4.1.0", 13 13 "drizzle-orm": "^0.45.1", 14 - "hono": "^4.11.8", 14 + "hono": "^4.11.9", 15 15 "human-id": "^4.1.3", 16 16 "image-dimensions": "^2.5.0", 17 17 "just-flatten-it": "^5.2.0", ··· 26 26 "zod": "^4.3.6" 27 27 }, 28 28 "devDependencies": { 29 - "@types/node": "^24.10.12", 30 - "drizzle-kit": "^0.31.8", 29 + "@types/node": "^24.10.13", 30 + "drizzle-kit": "^0.31.9", 31 31 "minify": "^14.1.0", 32 32 "npm-run-all": "^4.1.5", 33 33 "prettier": "^3.8.1", 34 - "wrangler": "^4.63.0" 34 + "wrangler": "^4.65.0" 35 35 }, 36 36 "engines": { 37 37 "node": ">=24.11.1" 38 38 } 39 39 }, 40 40 "node_modules/@atproto/api": { 41 - "version": "0.18.20", 42 - "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.18.20.tgz", 43 - "integrity": "sha512-BZYZkh2VJIFCXEnc/vzKwAwWjAQQTgbNJ8FBxpBK+z+KYh99O0uPCsRYKoCQsRrnkgrhzdU9+g2G+7zanTIGbw==", 41 + "version": "0.18.21", 42 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.18.21.tgz", 43 + "integrity": "sha512-s35MIJerGT/pKe2xJtKKswqlIr/ola2r2iURBKBL0Mk1OKe6jP4YvTMh1N2d2PEANFzNNTbKoDaLfJPo2Uvc/w==", 44 44 "license": "MIT", 45 45 "dependencies": { 46 - "@atproto/common-web": "^0.4.15", 46 + "@atproto/common-web": "^0.4.16", 47 47 "@atproto/lexicon": "^0.6.1", 48 48 "@atproto/syntax": "^0.4.3", 49 49 "@atproto/xrpc": "^0.7.7", ··· 206 206 } 207 207 }, 208 208 "node_modules/@cloudflare/unenv-preset": { 209 - "version": "2.12.0", 210 - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.12.0.tgz", 211 - "integrity": "sha512-NK4vN+2Z/GbfGS4BamtbbVk1rcu5RmqaYGiyHJQrA09AoxdZPHDF3W/EhgI0YSK8p3vRo/VNCtbSJFPON7FWMQ==", 209 + "version": "2.12.1", 210 + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.12.1.tgz", 211 + "integrity": "sha512-tP/Wi+40aBJovonSNJSsS7aFJY0xjuckKplmzDs2Xat06BJ68B6iG7YDUWXJL8gNn0gqW7YC5WhlYhO3QbugQA==", 212 212 "dev": true, 213 213 "license": "MIT OR Apache-2.0", 214 214 "peerDependencies": { ··· 222 222 } 223 223 }, 224 224 "node_modules/@cloudflare/workerd-darwin-64": { 225 - "version": "1.20260205.0", 226 - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260205.0.tgz", 227 - "integrity": "sha512-ToOItqcirmWPwR+PtT+Q4bdjTn/63ZxhJKEfW4FNn7FxMTS1Tw5dml0T0mieOZbCpcvY8BdvPKFCSlJuI8IVHQ==", 225 + "version": "1.20260212.0", 226 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260212.0.tgz", 227 + "integrity": "sha512-kLxuYutk88Wlo7edp8mlkN68TgZZ9237SUnuX9kNaD5jcOdblUqiBctMRZeRcPsuoX/3g2t0vS4ga02NBEVRNg==", 228 228 "cpu": [ 229 229 "x64" 230 230 ], ··· 239 239 } 240 240 }, 241 241 "node_modules/@cloudflare/workerd-darwin-arm64": { 242 - "version": "1.20260205.0", 243 - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260205.0.tgz", 244 - "integrity": "sha512-402ZqLz+LrG0NDXp7Hn7IZbI0DyhjNfjAlVenb0K3yod9KCuux0u3NksNBvqJx0mIGHvVR4K05h+jfT5BTHqGA==", 242 + "version": "1.20260212.0", 243 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260212.0.tgz", 244 + "integrity": "sha512-fqoqQWMA1D0ZzDOD8sp0allREM2M8GHdpxMXQ8EdZpZ70z5bJbJ9Vr4qe35++FNIZJspsDHfTw3Xm/M4ELm/dQ==", 245 245 "cpu": [ 246 246 "arm64" 247 247 ], ··· 256 256 } 257 257 }, 258 258 "node_modules/@cloudflare/workerd-linux-64": { 259 - "version": "1.20260205.0", 260 - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260205.0.tgz", 261 - "integrity": "sha512-rz9jBzazIA18RHY+osa19hvsPfr0LZI1AJzIjC6UqkKKphcTpHBEQ25Xt8cIA34ivMIqeENpYnnmpDFesLkfcQ==", 259 + "version": "1.20260212.0", 260 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260212.0.tgz", 261 + "integrity": "sha512-bCSQoZzDzV5MSh4ueWo1DgmOn4Hf3QBu4Yo3eQFXA2llYFIu/sZgRtkEehw1X2/SY5Sn6O0EMCqxJYRf82Wdeg==", 262 262 "cpu": [ 263 263 "x64" 264 264 ], ··· 273 273 } 274 274 }, 275 275 "node_modules/@cloudflare/workerd-linux-arm64": { 276 - "version": "1.20260205.0", 277 - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260205.0.tgz", 278 - "integrity": "sha512-jr6cKpMM/DBEbL+ATJ9rYue758CKp0SfA/nXt5vR32iINVJrb396ye9iat2y9Moa/PgPKnTrFgmT6urUmG3IUg==", 276 + "version": "1.20260212.0", 277 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260212.0.tgz", 278 + "integrity": "sha512-GPvp1iiKQodtbUDi6OmR5I0vD75lawB54tdYGtmypuHC7ZOI2WhBmhb3wCxgnQNOG1z7mhCQrzRCoqrKwYbVWQ==", 279 279 "cpu": [ 280 280 "arm64" 281 281 ], ··· 290 290 } 291 291 }, 292 292 "node_modules/@cloudflare/workerd-windows-64": { 293 - "version": "1.20260205.0", 294 - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260205.0.tgz", 295 - "integrity": "sha512-SMPW5jCZYOG7XFIglSlsgN8ivcl0pCrSAYxCwxtWvZ88whhcDB/aISNtiQiDZujPH8tIo2hE5dEkxW7tGEwc3A==", 293 + "version": "1.20260212.0", 294 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260212.0.tgz", 295 + "integrity": "sha512-wHRI218Xn4ndgWJCUHH4Zx0YlU5q/o6OmcxXkcw95tJOsQn4lDrhppioPh4eScxJZALf2X+ODeZcyQTCq5exGw==", 296 296 "cpu": [ 297 297 "x64" 298 298 ], ··· 2047 2047 } 2048 2048 }, 2049 2049 "node_modules/@types/node": { 2050 - "version": "24.10.12", 2051 - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz", 2052 - "integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==", 2050 + "version": "24.10.13", 2051 + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", 2052 + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", 2053 2053 "dev": true, 2054 2054 "license": "MIT", 2055 2055 "dependencies": { ··· 2801 2801 } 2802 2802 }, 2803 2803 "node_modules/drizzle-kit": { 2804 - "version": "0.31.8", 2805 - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz", 2806 - "integrity": "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==", 2804 + "version": "0.31.9", 2805 + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz", 2806 + "integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==", 2807 2807 "devOptional": true, 2808 2808 "license": "MIT", 2809 2809 "dependencies": { ··· 3503 3503 } 3504 3504 }, 3505 3505 "node_modules/hono": { 3506 - "version": "4.11.8", 3507 - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz", 3508 - "integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==", 3506 + "version": "4.11.9", 3507 + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", 3508 + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", 3509 3509 "license": "MIT", 3510 3510 "engines": { 3511 3511 "node": ">=16.9.0" ··· 4389 4389 } 4390 4390 }, 4391 4391 "node_modules/miniflare": { 4392 - "version": "4.20260205.0", 4393 - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260205.0.tgz", 4394 - "integrity": "sha512-jG1TknEDeFqcq/z5gsOm1rKeg4cNG7ruWxEuiPxl3pnQumavxo8kFpeQC6XKVpAhh2PI9ODGyIYlgd77sTHl5g==", 4392 + "version": "4.20260212.0", 4393 + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260212.0.tgz", 4394 + "integrity": "sha512-Lgxq83EuR2q/0/DAVOSGXhXS1V7GDB04HVggoPsenQng8sqEDR3hO4FigIw5ZI2Sv2X7kIc30NCzGHJlCFIYWg==", 4395 4395 "dev": true, 4396 4396 "license": "MIT", 4397 4397 "dependencies": { 4398 4398 "@cspotcode/source-map-support": "0.8.1", 4399 4399 "sharp": "^0.34.5", 4400 4400 "undici": "7.18.2", 4401 - "workerd": "1.20260205.0", 4401 + "workerd": "1.20260212.0", 4402 4402 "ws": "8.18.0", 4403 4403 "youch": "4.1.0-beta.10" 4404 4404 }, ··· 5780 5780 } 5781 5781 }, 5782 5782 "node_modules/workerd": { 5783 - "version": "1.20260205.0", 5784 - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260205.0.tgz", 5785 - "integrity": "sha512-CcMH5clHwrH8VlY7yWS9C/G/C8g9czIz1yU3akMSP9Z3CkEMFSoC3GGdj5G7Alw/PHEeez1+1IrlYger4pwu+w==", 5783 + "version": "1.20260212.0", 5784 + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260212.0.tgz", 5785 + "integrity": "sha512-4B9BoZUzKSRv3pVZGEPh7OX+Q817hpUqAUtz5O0TxJVqo4OsYJAUA/sY177Q5ha/twjT9KaJt2DtQzE+oyCOzw==", 5786 5786 "dev": true, 5787 5787 "hasInstallScript": true, 5788 5788 "license": "Apache-2.0", ··· 5793 5793 "node": ">=16" 5794 5794 }, 5795 5795 "optionalDependencies": { 5796 - "@cloudflare/workerd-darwin-64": "1.20260205.0", 5797 - "@cloudflare/workerd-darwin-arm64": "1.20260205.0", 5798 - "@cloudflare/workerd-linux-64": "1.20260205.0", 5799 - "@cloudflare/workerd-linux-arm64": "1.20260205.0", 5800 - "@cloudflare/workerd-windows-64": "1.20260205.0" 5796 + "@cloudflare/workerd-darwin-64": "1.20260212.0", 5797 + "@cloudflare/workerd-darwin-arm64": "1.20260212.0", 5798 + "@cloudflare/workerd-linux-64": "1.20260212.0", 5799 + "@cloudflare/workerd-linux-arm64": "1.20260212.0", 5800 + "@cloudflare/workerd-windows-64": "1.20260212.0" 5801 5801 } 5802 5802 }, 5803 5803 "node_modules/wrangler": { 5804 - "version": "4.63.0", 5805 - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.63.0.tgz", 5806 - "integrity": "sha512-+R04jF7Eb8K3KRMSgoXpcIdLb8GC62eoSGusYh1pyrSMm/10E0hbKkd7phMJO4HxXc6R7mOHC5SSoX9eof30Uw==", 5804 + "version": "4.65.0", 5805 + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.65.0.tgz", 5806 + "integrity": "sha512-R+n3o3tlGzLK9I4fGocPReOuvcnjhtOL2aCVKkHMeuEwt9pPbOO4FxJtx/ec5cIUG/otRyJnfQGCAr9DplBVng==", 5807 5807 "dev": true, 5808 5808 "license": "MIT OR Apache-2.0", 5809 5809 "dependencies": { 5810 5810 "@cloudflare/kv-asset-handler": "0.4.2", 5811 - "@cloudflare/unenv-preset": "2.12.0", 5811 + "@cloudflare/unenv-preset": "2.12.1", 5812 5812 "blake3-wasm": "2.1.5", 5813 - "esbuild": "0.27.0", 5814 - "miniflare": "4.20260205.0", 5813 + "esbuild": "0.27.3", 5814 + "miniflare": "4.20260212.0", 5815 5815 "path-to-regexp": "6.3.0", 5816 5816 "unenv": "2.0.0-rc.24", 5817 - "workerd": "1.20260205.0" 5817 + "workerd": "1.20260212.0" 5818 5818 }, 5819 5819 "bin": { 5820 5820 "wrangler": "bin/wrangler.js", ··· 5827 5827 "fsevents": "~2.3.2" 5828 5828 }, 5829 5829 "peerDependencies": { 5830 - "@cloudflare/workers-types": "^4.20260205.0" 5830 + "@cloudflare/workers-types": "^4.20260212.0" 5831 5831 }, 5832 5832 "peerDependenciesMeta": { 5833 5833 "@cloudflare/workers-types": { ··· 5836 5836 } 5837 5837 }, 5838 5838 "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { 5839 - "version": "0.27.0", 5840 - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", 5841 - "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", 5839 + "version": "0.27.3", 5840 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", 5841 + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", 5842 5842 "cpu": [ 5843 5843 "ppc64" 5844 5844 ], ··· 5853 5853 } 5854 5854 }, 5855 5855 "node_modules/wrangler/node_modules/@esbuild/android-arm": { 5856 - "version": "0.27.0", 5857 - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", 5858 - "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", 5856 + "version": "0.27.3", 5857 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", 5858 + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", 5859 5859 "cpu": [ 5860 5860 "arm" 5861 5861 ], ··· 5870 5870 } 5871 5871 }, 5872 5872 "node_modules/wrangler/node_modules/@esbuild/android-arm64": { 5873 - "version": "0.27.0", 5874 - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", 5875 - "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", 5873 + "version": "0.27.3", 5874 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", 5875 + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", 5876 5876 "cpu": [ 5877 5877 "arm64" 5878 5878 ], ··· 5887 5887 } 5888 5888 }, 5889 5889 "node_modules/wrangler/node_modules/@esbuild/android-x64": { 5890 - "version": "0.27.0", 5891 - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", 5892 - "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", 5890 + "version": "0.27.3", 5891 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", 5892 + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", 5893 5893 "cpu": [ 5894 5894 "x64" 5895 5895 ], ··· 5904 5904 } 5905 5905 }, 5906 5906 "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { 5907 - "version": "0.27.0", 5908 - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", 5909 - "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", 5907 + "version": "0.27.3", 5908 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", 5909 + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", 5910 5910 "cpu": [ 5911 5911 "arm64" 5912 5912 ], ··· 5921 5921 } 5922 5922 }, 5923 5923 "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { 5924 - "version": "0.27.0", 5925 - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", 5926 - "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", 5924 + "version": "0.27.3", 5925 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", 5926 + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", 5927 5927 "cpu": [ 5928 5928 "x64" 5929 5929 ], ··· 5938 5938 } 5939 5939 }, 5940 5940 "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { 5941 - "version": "0.27.0", 5942 - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", 5943 - "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", 5941 + "version": "0.27.3", 5942 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", 5943 + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", 5944 5944 "cpu": [ 5945 5945 "arm64" 5946 5946 ], ··· 5955 5955 } 5956 5956 }, 5957 5957 "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { 5958 - "version": "0.27.0", 5959 - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", 5960 - "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", 5958 + "version": "0.27.3", 5959 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", 5960 + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", 5961 5961 "cpu": [ 5962 5962 "x64" 5963 5963 ], ··· 5972 5972 } 5973 5973 }, 5974 5974 "node_modules/wrangler/node_modules/@esbuild/linux-arm": { 5975 - "version": "0.27.0", 5976 - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", 5977 - "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", 5975 + "version": "0.27.3", 5976 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", 5977 + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", 5978 5978 "cpu": [ 5979 5979 "arm" 5980 5980 ], ··· 5989 5989 } 5990 5990 }, 5991 5991 "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { 5992 - "version": "0.27.0", 5993 - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", 5994 - "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", 5992 + "version": "0.27.3", 5993 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", 5994 + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", 5995 5995 "cpu": [ 5996 5996 "arm64" 5997 5997 ], ··· 6006 6006 } 6007 6007 }, 6008 6008 "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { 6009 - "version": "0.27.0", 6010 - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", 6011 - "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", 6009 + "version": "0.27.3", 6010 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", 6011 + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", 6012 6012 "cpu": [ 6013 6013 "ia32" 6014 6014 ], ··· 6023 6023 } 6024 6024 }, 6025 6025 "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { 6026 - "version": "0.27.0", 6027 - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", 6028 - "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", 6026 + "version": "0.27.3", 6027 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", 6028 + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", 6029 6029 "cpu": [ 6030 6030 "loong64" 6031 6031 ], ··· 6040 6040 } 6041 6041 }, 6042 6042 "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { 6043 - "version": "0.27.0", 6044 - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", 6045 - "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", 6043 + "version": "0.27.3", 6044 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", 6045 + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", 6046 6046 "cpu": [ 6047 6047 "mips64el" 6048 6048 ], ··· 6057 6057 } 6058 6058 }, 6059 6059 "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { 6060 - "version": "0.27.0", 6061 - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", 6062 - "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", 6060 + "version": "0.27.3", 6061 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", 6062 + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", 6063 6063 "cpu": [ 6064 6064 "ppc64" 6065 6065 ], ··· 6074 6074 } 6075 6075 }, 6076 6076 "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { 6077 - "version": "0.27.0", 6078 - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", 6079 - "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", 6077 + "version": "0.27.3", 6078 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", 6079 + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", 6080 6080 "cpu": [ 6081 6081 "riscv64" 6082 6082 ], ··· 6091 6091 } 6092 6092 }, 6093 6093 "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { 6094 - "version": "0.27.0", 6095 - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", 6096 - "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", 6094 + "version": "0.27.3", 6095 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", 6096 + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", 6097 6097 "cpu": [ 6098 6098 "s390x" 6099 6099 ], ··· 6108 6108 } 6109 6109 }, 6110 6110 "node_modules/wrangler/node_modules/@esbuild/linux-x64": { 6111 - "version": "0.27.0", 6112 - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", 6113 - "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", 6111 + "version": "0.27.3", 6112 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", 6113 + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", 6114 6114 "cpu": [ 6115 6115 "x64" 6116 6116 ], ··· 6125 6125 } 6126 6126 }, 6127 6127 "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { 6128 - "version": "0.27.0", 6129 - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", 6130 - "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", 6128 + "version": "0.27.3", 6129 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", 6130 + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", 6131 6131 "cpu": [ 6132 6132 "arm64" 6133 6133 ], ··· 6142 6142 } 6143 6143 }, 6144 6144 "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { 6145 - "version": "0.27.0", 6146 - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", 6147 - "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", 6145 + "version": "0.27.3", 6146 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", 6147 + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", 6148 6148 "cpu": [ 6149 6149 "x64" 6150 6150 ], ··· 6159 6159 } 6160 6160 }, 6161 6161 "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { 6162 - "version": "0.27.0", 6163 - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", 6164 - "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", 6162 + "version": "0.27.3", 6163 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", 6164 + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", 6165 6165 "cpu": [ 6166 6166 "arm64" 6167 6167 ], ··· 6176 6176 } 6177 6177 }, 6178 6178 "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { 6179 - "version": "0.27.0", 6180 - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", 6181 - "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", 6179 + "version": "0.27.3", 6180 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", 6181 + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", 6182 6182 "cpu": [ 6183 6183 "x64" 6184 6184 ], ··· 6193 6193 } 6194 6194 }, 6195 6195 "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { 6196 - "version": "0.27.0", 6197 - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", 6198 - "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", 6196 + "version": "0.27.3", 6197 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", 6198 + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", 6199 6199 "cpu": [ 6200 6200 "arm64" 6201 6201 ], ··· 6210 6210 } 6211 6211 }, 6212 6212 "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { 6213 - "version": "0.27.0", 6214 - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", 6215 - "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", 6213 + "version": "0.27.3", 6214 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", 6215 + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", 6216 6216 "cpu": [ 6217 6217 "x64" 6218 6218 ], ··· 6227 6227 } 6228 6228 }, 6229 6229 "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { 6230 - "version": "0.27.0", 6231 - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", 6232 - "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", 6230 + "version": "0.27.3", 6231 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", 6232 + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", 6233 6233 "cpu": [ 6234 6234 "arm64" 6235 6235 ], ··· 6244 6244 } 6245 6245 }, 6246 6246 "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { 6247 - "version": "0.27.0", 6248 - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", 6249 - "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", 6247 + "version": "0.27.3", 6248 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", 6249 + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", 6250 6250 "cpu": [ 6251 6251 "ia32" 6252 6252 ], ··· 6261 6261 } 6262 6262 }, 6263 6263 "node_modules/wrangler/node_modules/@esbuild/win32-x64": { 6264 - "version": "0.27.0", 6265 - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", 6266 - "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", 6264 + "version": "0.27.3", 6265 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", 6266 + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", 6267 6267 "cpu": [ 6268 6268 "x64" 6269 6269 ], ··· 6278 6278 } 6279 6279 }, 6280 6280 "node_modules/wrangler/node_modules/esbuild": { 6281 - "version": "0.27.0", 6282 - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", 6283 - "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", 6281 + "version": "0.27.3", 6282 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", 6283 + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", 6284 6284 "dev": true, 6285 6285 "hasInstallScript": true, 6286 6286 "license": "MIT", ··· 6291 6291 "node": ">=18" 6292 6292 }, 6293 6293 "optionalDependencies": { 6294 - "@esbuild/aix-ppc64": "0.27.0", 6295 - "@esbuild/android-arm": "0.27.0", 6296 - "@esbuild/android-arm64": "0.27.0", 6297 - "@esbuild/android-x64": "0.27.0", 6298 - "@esbuild/darwin-arm64": "0.27.0", 6299 - "@esbuild/darwin-x64": "0.27.0", 6300 - "@esbuild/freebsd-arm64": "0.27.0", 6301 - "@esbuild/freebsd-x64": "0.27.0", 6302 - "@esbuild/linux-arm": "0.27.0", 6303 - "@esbuild/linux-arm64": "0.27.0", 6304 - "@esbuild/linux-ia32": "0.27.0", 6305 - "@esbuild/linux-loong64": "0.27.0", 6306 - "@esbuild/linux-mips64el": "0.27.0", 6307 - "@esbuild/linux-ppc64": "0.27.0", 6308 - "@esbuild/linux-riscv64": "0.27.0", 6309 - "@esbuild/linux-s390x": "0.27.0", 6310 - "@esbuild/linux-x64": "0.27.0", 6311 - "@esbuild/netbsd-arm64": "0.27.0", 6312 - "@esbuild/netbsd-x64": "0.27.0", 6313 - "@esbuild/openbsd-arm64": "0.27.0", 6314 - "@esbuild/openbsd-x64": "0.27.0", 6315 - "@esbuild/openharmony-arm64": "0.27.0", 6316 - "@esbuild/sunos-x64": "0.27.0", 6317 - "@esbuild/win32-arm64": "0.27.0", 6318 - "@esbuild/win32-ia32": "0.27.0", 6319 - "@esbuild/win32-x64": "0.27.0" 6294 + "@esbuild/aix-ppc64": "0.27.3", 6295 + "@esbuild/android-arm": "0.27.3", 6296 + "@esbuild/android-arm64": "0.27.3", 6297 + "@esbuild/android-x64": "0.27.3", 6298 + "@esbuild/darwin-arm64": "0.27.3", 6299 + "@esbuild/darwin-x64": "0.27.3", 6300 + "@esbuild/freebsd-arm64": "0.27.3", 6301 + "@esbuild/freebsd-x64": "0.27.3", 6302 + "@esbuild/linux-arm": "0.27.3", 6303 + "@esbuild/linux-arm64": "0.27.3", 6304 + "@esbuild/linux-ia32": "0.27.3", 6305 + "@esbuild/linux-loong64": "0.27.3", 6306 + "@esbuild/linux-mips64el": "0.27.3", 6307 + "@esbuild/linux-ppc64": "0.27.3", 6308 + "@esbuild/linux-riscv64": "0.27.3", 6309 + "@esbuild/linux-s390x": "0.27.3", 6310 + "@esbuild/linux-x64": "0.27.3", 6311 + "@esbuild/netbsd-arm64": "0.27.3", 6312 + "@esbuild/netbsd-x64": "0.27.3", 6313 + "@esbuild/openbsd-arm64": "0.27.3", 6314 + "@esbuild/openbsd-x64": "0.27.3", 6315 + "@esbuild/openharmony-arm64": "0.27.3", 6316 + "@esbuild/sunos-x64": "0.27.3", 6317 + "@esbuild/win32-arm64": "0.27.3", 6318 + "@esbuild/win32-ia32": "0.27.3", 6319 + "@esbuild/win32-x64": "0.27.3" 6320 6320 } 6321 6321 }, 6322 6322 "node_modules/ws": {
+8 -5
package.json
··· 19 19 "migrate:all": "npm run migrate:local && npm run migrate:prod", 20 20 "minify:repost": "minify assets/js/repostHelper.js > assets/js/repostHelper.min.js", 21 21 "minify:post": "minify assets/js/postHelper.js > assets/js/postHelper.min.js", 22 + "minify:app": "minify assets/js/app.js > assets/js/app.min.js", 23 + "minify:alt": "minify assets/js/altTextHelper.js > assets/js/altTextHelper.min.js", 24 + "minify:tribute": "minify assets/js/tributeHelper.js > assets/js/tributeHelper.min.js", 22 25 "minify:main": "minify assets/js/main.js > assets/js/main.min.js", 23 26 "minify:settings": "minify assets/js/settings.js > assets/js/settings.min.js", 24 27 "minify": "run-p minify:*", 25 28 "types": "wrangler types src/wrangler.d.ts" 26 29 }, 27 30 "dependencies": { 28 - "@atproto/api": "^0.18.20", 31 + "@atproto/api": "^0.18.21", 29 32 "better-auth": "^1.4.18", 30 33 "better-auth-cloudflare": "^0.2.9", 31 34 "date-fns": "^4.1.0", 32 35 "drizzle-orm": "^0.45.1", 33 - "hono": "^4.11.8", 36 + "hono": "^4.11.9", 34 37 "human-id": "^4.1.3", 35 38 "image-dimensions": "^2.5.0", 36 39 "just-flatten-it": "^5.2.0", ··· 45 48 "zod": "^4.3.6" 46 49 }, 47 50 "devDependencies": { 48 - "@types/node": "^24.10.12", 49 - "drizzle-kit": "^0.31.8", 51 + "@types/node": "^24.10.13", 52 + "drizzle-kit": "^0.31.9", 50 53 "minify": "^14.1.0", 51 54 "npm-run-all": "^4.1.5", 52 55 "prettier": "^3.8.1", 53 - "wrangler": "^4.63.0" 56 + "wrangler": "^4.65.0" 54 57 }, 55 58 "engines": { 56 59 "node": ">=24.11.1"
+3 -3
src/auth/index.ts
··· 10 10 import { createDMWithUser } from "../utils/bskyMsg"; 11 11 12 12 function createPasswordResetMessage(url: string) { 13 - return `Your SkyScheduler password reset url is: 14 - ${url} 13 + return `Your SkyScheduler password reset url is: 14 + ${url} 15 15 16 16 This URL will expire in about an hour. 17 17 ··· 134 134 enabled: false 135 135 }, 136 136 }, 137 - telemetry: { 137 + telemetry: { 138 138 enabled: false 139 139 }, 140 140 logger: {
+12 -6
src/db/app.schema.ts
··· 18 18 cid: text('cid'), 19 19 // if this post is a pseudo post (i.e. a repost of anything) 20 20 isRepost: integer('isRepost', { mode: 'boolean' }).default(false), 21 - // if this post has a post chain to it, this should only ever apply to the root post 22 - //isThread: integer('isThread', {mode: 'boolean'}).default(false), 21 + rootPost: text('rootPost'), 22 + parentPost: text('parentPost'), 23 + threadOrder: integer('threadOrder').default(-1), 23 24 // bsky content labels 24 25 contentLabel: text('contentLabel', {mode: 'text'}).$type<PostLabel>().default(PostLabel.None).notNull(), 25 26 // metadata timestamps ··· 45 46 .where(sql`isRepost = 1`), 46 47 // for db pruning and parity with the PDS 47 48 index("postedUUID_idx").on(table.uuid, table.posted), 48 - // for checking for post chains 49 - /*index("threadUUID_idx") 50 - .on(table.uuid, table.isThread, table.rootPost) 51 - .where(sql`isThread = 1`),*/ 49 + // Querying children 50 + index("generalThread_idx") 51 + .on(table.parentPost, table.rootPost) 52 + .where(sql`parentPost is not NULL`), 53 + // Updating thread orders 54 + index("threadOrder_idx") 55 + .on(table.rootPost, table.threadOrder) 56 + .where(sql`threadOrder >= 0`), 52 57 // cron job 53 58 index("postNowScheduledDatePosted_idx") 54 59 .on(table.posted, table.scheduledDate, table.postNow) ··· 115 120 .notNull(), 116 121 }); 117 122 123 + // helper bookkeeping to make sure we don't have a ton of abandoned files in R2 118 124 export const mediaFiles = sqliteTable('media', { 119 125 fileName: text('file', {mode: 'text'}).primaryKey(), 120 126 hasPost: integer('hasPost', { mode: 'boolean' }).default(false),
+6 -6
src/endpoints/account.tsx
··· 30 30 try { 31 31 const errorMsgs = JSON.parse(errorJson); 32 32 return c.html(<div class="validation-error btn-error"> 33 - <b>Failed Validation</b>: 33 + <b>Failed Validation</b>: 34 34 <ul> 35 35 {errorMsgs.map((el: { message: string; }) => { 36 36 return <li>{el.message}</li>; ··· 169 169 170 170 // Grab the user's pds as well 171 171 const userPDS: string = await lookupBskyPDS(profileDID); 172 - 172 + 173 173 // grab our auth object 174 174 const auth = c.get("auth"); 175 175 if (!auth) { 176 176 return c.json({ok: false, message: "invalid operation occurred, please retry again"}, 501); 177 177 } 178 - 178 + 179 179 console.log(`attempting to create an account for ${username} with pds ${userPDS}`); 180 180 // create the user 181 181 const createUser = await auth.api.signUpEmail({ ··· 261 261 if (!auth) { 262 262 return c.json({ok: false, message: "invalid operation occurred, please retry again"}, 501); 263 263 } 264 - 264 + 265 265 const { data, error } = await auth.api.resetPassword({body: { 266 266 newPassword: password, 267 267 token: resetToken, ··· 275 275 account.post("/delete", authMiddleware, async (c) => { 276 276 const body = await c.req.parseBody(); 277 277 const validation = AccountDeleteSchema.safeParse(body); 278 - 278 + 279 279 if (!validation.success) { 280 280 return serverParseValidationErr(c, validation.error.message); 281 281 } ··· 308 308 .then((media) => deleteFromR2(c, media)) 309 309 .then(() => authCtx.internalAdapter.deleteSessions(userId)) 310 310 .then(() => authCtx.internalAdapter.deleteUser(userId))); 311 - 311 + 312 312 c.header("HX-Redirect", "/?deleted"); 313 313 return c.html(<></>); 314 314 } else {
+20 -13
src/endpoints/post.tsx
··· 9 9 import { corsHelperMiddleware } from "../middleware/corsHelper"; 10 10 import { 11 11 Bindings, CreateObjectResponse, CreatePostQueryResponse, 12 + DeleteResponse, 12 13 EmbedDataType, LooseObj, Post 13 14 } from "../types.d"; 14 15 import { makePost } from "../utils/bskyApi"; ··· 17 18 createPost, createRepost, deletePost, getPostById, getPostByIdWithReposts, 18 19 updatePostForUser 19 20 } from "../utils/dbQuery"; 20 - import { enqueuePost, isQueueEnabled, shouldPostNowQueue } from "../utils/queuePublisher"; 21 + import { enqueuePost, shouldPostNowQueue } from "../utils/queuePublisher"; 21 22 import { deleteFromR2, uploadFileR2 } from "../utils/r2Query"; 22 23 import { FileDeleteSchema } from "../validation/mediaSchema"; 23 24 import { EditSchema } from "../validation/postSchema"; ··· 64 65 const postInfo: Post|null = await getPostById(c, response.postId); 65 66 if (!isEmpty(postInfo)) { 66 67 const env: Bindings = c.env; 67 - if (isQueueEnabled(env) && shouldPostNowQueue(env)) { 68 + if (shouldPostNowQueue(env)) { 68 69 try 69 70 { 70 71 await enqueuePost(env, postInfo!); ··· 74 75 } 75 76 } else { 76 77 if (!await makePost(c, postInfo)) 77 - return c.json({message: `Failed to post content, will try again soon.\n\nIf it doesn't post, send a message with this code:\n${postInfo!.postid}`}, 406); 78 + return c.json({message: `Failed to post content, will try again soon.\n\n 79 + If it doesn't post, send a message with this code:\n${postInfo!.postid}`}, 406); 78 80 } 79 - return c.json({message: "Created Post!" }); 81 + return c.json({message: "Created Post!", id: response.postId}); 80 82 } else { 81 83 return c.json({message: "Unable to get post content, post may have been lost"}, 401); 82 84 } 83 85 } 84 - return c.json({ message: "Post scheduled successfully!" }); 86 + return c.json({ message: "Post scheduled successfully!", id: response.postId}); 85 87 }); 86 88 87 89 // Create repost ··· 91 93 if (!response.ok) { 92 94 return c.json({message: response.msg}, 400); 93 95 } 94 - return c.json({ message: "Repost scheduled successfully!"}); 96 + return c.json({ message: "Repost scheduled successfully!", id: response.postId}); 95 97 }); 96 98 97 99 // Get all posts ··· 119 121 post.post("/edit/:id", authMiddleware, async (c: Context) => { 120 122 const { id } = c.req.param(); 121 123 const swapErrEvents: string = "refreshPosts, scrollTop, scrollListTop"; 122 - const swapSuccessEvents: string = "postUpdatedNotice, timeSidebar, scrollTop, scrollListTop"; 123 124 if (!isValid(id)) { 124 125 c.header("HX-Trigger-After-Swap", swapErrEvents); 125 126 return c.html(<b class="btn-error">Post was invalid</b>, 400); ··· 180 181 // push embedContent as editable yes. 181 182 if (hasEmbedEdits) 182 183 payload.embedContent = originalPost.embeds; 183 - 184 + 184 185 if (await updatePostForUser(c, id, payload)) { 185 186 originalPost.text = content; 186 187 const username = await getUsernameForUser(c); 187 - c.header("HX-Trigger-After-Swap", swapSuccessEvents); 188 + c.header("HX-Trigger-After-Settle", `{"scrollListToPost": "${id}"}`); 189 + c.header("HX-Trigger-After-Swap", "postUpdatedNotice, timeSidebar, scrollTop"); 188 190 return c.html(<ScheduledPost post={originalPost} user={username} dynamic={true} />); 189 191 } 190 192 ··· 197 199 if (!isValid(id)) 198 200 return c.html(<></>, 400); 199 201 200 - c.header("HX-Trigger-After-Swap", "timeSidebar, scrollListTop, scrollTop"); 201 - 202 202 const postInfo = await getPostByIdWithReposts(c, id); 203 203 // Get the original post to replace with 204 204 if (postInfo !== null) { 205 + c.header("HX-Trigger-After-Swap", "timeSidebar, scrollListTop, scrollTop"); 205 206 const username = await getUsernameForUser(c); 206 207 return c.html(<ScheduledPost post={postInfo} user={username} dynamic={true} />); 207 208 } ··· 215 216 post.delete("/delete/:id", authMiddleware, async (c: Context) => { 216 217 const { id } = c.req.param(); 217 218 if (isValid(id)) { 218 - if (await deletePost(c, id) === true) { 219 - c.header("HX-Trigger-After-Swap", "postDeleted, accountViolations"); 219 + const response: DeleteResponse = await deletePost(c, id); 220 + if (response.success === true) { 221 + let postRefreshEvent = ""; 222 + if (response.needsRefresh) { 223 + postRefreshEvent = ", refreshPosts, timeSidebar, scrollTop"; 224 + } 225 + const triggerEvents = `postDeleted, accountViolations${postRefreshEvent}`; 226 + c.header("HX-Trigger-After-Swap", triggerEvents); 220 227 return c.html(<></>); 221 228 } 222 229 }
+2 -2
src/endpoints/preview.tsx
··· 24 24 if (fetchedFile === null) { 25 25 return c.redirect("/thumbs/missing.png"); 26 26 } 27 - 27 + 28 28 const contentType = fetchedFile.httpMetadata?.contentType || fetchedFile.customMetadata["type"]; 29 29 if (BSKY_IMG_MIME_TYPES.includes(contentType) === false) { 30 30 return c.redirect("/thumbs/missing.png"); ··· 34 34 if (isEmpty(uploaderId) || c.get("userId") !== uploaderId) { 35 35 return c.redirect("/thumbs/image.png"); 36 36 } 37 - 37 + 38 38 return c.body(await fetchedFile.blob(), 200, { 39 39 "Content-Type": contentType 40 40 })
+6 -5
src/index.tsx
··· 153 153 break; 154 154 } 155 155 }, 156 - async queue(batch: MessageBatch<QueueTaskData>, environment: Env, ctx: ExecutionContext) { 156 + async queue(batch: MessageBatch<QueueTaskData>, environment: Bindings, ctx: ExecutionContext) { 157 157 const runtimeWrapper: ScheduledContext = { 158 158 executionCtx: ctx, 159 - env: environment as Bindings 159 + env: environment 160 160 }; 161 161 162 - let wasSuccess = false; 162 + const delay: number = environment.QUEUE_SETTINGS.delay_val; 163 + let wasSuccess: boolean = false; 163 164 for (const message of batch.messages) { 164 165 switch (message.body.type) { 165 166 case QueueTaskType.Post: 166 - wasSuccess = await handlePostTask(runtimeWrapper, message.body.post!, null, true); 167 + wasSuccess = await handlePostTask(runtimeWrapper, message.body.post!, null); 167 168 break; 168 169 case QueueTaskType.Repost: 169 170 wasSuccess = await handleRepostTask(runtimeWrapper, message.body.repost!, null); ··· 176 177 } 177 178 // Handle queue acknowledgement on success/failure 178 179 if (!wasSuccess) { 179 - message.retry({delaySeconds: 3*(message.attempts+1)}); 180 + message.retry({delaySeconds: delay*(message.attempts+1)}); 180 181 } else { 181 182 message.ack(); 182 183 }
+11
src/layout/depTags.tsx
··· 4 4 scripts?: PreloadRules[] 5 5 }; 6 6 7 + type ScriptTagsType = { 8 + scripts: string[]; 9 + } 10 + 7 11 export function DependencyTags({scripts}: DepTagsType) { 8 12 if (scripts === undefined) { 9 13 return (<></>); ··· 16 20 case "style": 17 21 return (<link href={itm.href} rel="stylesheet" type="text/css" />); 18 22 } 23 + }); 24 + return (<>{html}</>); 25 + } 26 + 27 + export function ScriptTags({scripts}: ScriptTagsType) { 28 + const html = scripts.map((itm) => { 29 + return (<script type="text/javascript" src={itm}></script>); 19 30 }); 20 31 return (<>{html}</>); 21 32 }
+2 -2
src/layout/editPost.tsx
··· 20 20 <input type="hidden" name={`altEdits.${num}.content`} value={embedData.content} /> 21 21 <input type="hidden" data-alt={true} name={`altEdits.${num}.alt`} value={embedData.alt} /> 22 22 {/* Accessible handlers will be added in via the htmx header */} 23 - <a tabindex={0} role="button" data-file={embedData.content} 23 + <a tabindex={0} role="button" data-file={embedData.content} 24 24 class="editPostAlt secondary outline">Edit Alt</a> 25 25 </center> 26 26 </div> ··· 65 65 <center class="postControls"> 66 66 <div id={editResponse}> 67 67 </div> 68 - <button tabindex={0}>Update Post</button> 68 + <button tabindex={0}>Update Post</button> 69 69 <a tabindex={0} role="button" class="secondary cancelEditButton" hx-swap="innerHTML swap:0.2s" hx-get={`/post/edit/${post.postid}/cancel`} 70 70 hx-confirm="Are you sure you want to cancel editing?">Cancel</a> 71 71 </center>
+12 -4
src/layout/footer.tsx
··· 1 + import { PROGRESS_MADE, PROGRESS_TOTAL } from "../progress"; 2 + 1 3 // Helper footer for various pages 2 4 type FooterCopyrightProps = { 3 5 inNewWindow?: boolean; 4 6 showHomepage?: boolean; 7 + showProgressBar?: boolean; 5 8 } 6 9 7 10 export default function FooterCopyright(props: FooterCopyrightProps) { 8 11 const newWinAttr = props.inNewWindow ? {"target": '_blank'} : {}; 9 12 const projectURL = (<a class="secondary" target="_blank" title="Project source on GitHub" href="https://github.com/SocksTheWolf/SkyScheduler">SkyScheduler</a>); 10 13 const homepageURL = (<a class="secondary" title="Homepage" href="/">SkyScheduler</a>); 14 + const progressBarTooltip = `$${PROGRESS_MADE}/$${PROGRESS_TOTAL} for this month`; 11 15 return ( 12 - <center><small> 13 - {props.showHomepage ? homepageURL : projectURL} &copy; {new Date().getFullYear()} 16 + <center><small> 17 + {props.showProgressBar ? <div class="serverFunds"><span data-tooltip={progressBarTooltip}>Current Server Costs:</span> 18 + <progress value={PROGRESS_MADE} max={PROGRESS_TOTAL} /></div> : null} 19 + {props.showHomepage ? homepageURL : projectURL} &copy; {new Date().getFullYear()} 14 20 <span class="credits"> 15 21 <a rel="author" target="_blank" title="Project author" href="https://socksthewolf.com">SocksTheWolf</a><br /> 16 22 <small> 17 - <a class="secondary" target="_blank" title="Tip the project maintainer" href="/tip">Tip</a> - 18 - <a class="secondary" {...newWinAttr} href="/tos" title="Terms of Service">Terms</a> - 23 + <a class="secondary" target="_blank" 24 + data-tooltip="Tips are not required, the service is free, but if you like this service, tips would help with the costs <3" 25 + title="Tip the dev" href="/tip">Tip</a> - 26 + <a class="secondary" {...newWinAttr} href="/tos" title="Terms of Service">Terms</a> - 19 27 <a class="secondary" {...newWinAttr} href="/privacy" title="Privacy Policy">Privacy</a> 20 28 </small> 21 29 </span>
+13 -9
src/layout/makePost.tsx
··· 18 18 19 19 export const PreloadPostCreation: PreloadRules[] = [ 20 20 ...ConstScriptPreload, 21 - {type: "script", href: "/dep/dropzone.min.js"}, 22 - {type: "style", href: "/dep/dropzone.min.css"}, 21 + {type: "script", href: "/dep/dropzone.min.js"}, 22 + {type: "style", href: "/dep/dropzone.min.css"}, 23 23 {type: "style", href: "/css/dropzoneMods.css"}, 24 24 {type: "style", href: "/dep/tribute.css"}, 25 25 {type: "script", href: "/dep/tribute.min.js"} ··· 33 33 <article> 34 34 <form id="postForm" novalidate> 35 35 <header> 36 - <h4>Schedule New Post</h4> 36 + <h4 id="postFormTitle"></h4> 37 + <small class="btn-delete thread-cancel" data-tooltip="Cancel making post in thread"> 38 + <a id="cancelThreadPost" tabindex={0} class="ghost secondary" role="button">Cancel Thread Post</a> 39 + </small> 37 40 </header> 38 41 <div> 39 42 <article> ··· 46 49 47 50 <details> 48 51 <summary role="button" title="click to toggle section" class="secondary outline">Attach Media/Link</summary> 49 - <section id="imageAttachmentSection"> 52 + <section id="section-imageAttachment"> 50 53 <article> 51 54 <header>Files</header> 52 55 <div> ··· 56 59 </div> 57 60 <footer> 58 61 <div class="uploadGuidelines"><small><b>Note</b>: <ul> 59 - <li><span data-tooltip={BSKY_IMG_FILE_EXTS}>Images</span>: 62 + <li><span data-tooltip={BSKY_IMG_FILE_EXTS}>Images</span>: 60 63 <ul> 61 64 <li>must be less than {CF_IMAGES_MAX_DIMENSION}x{CF_IMAGES_MAX_DIMENSION} pixels</li> 62 65 <li>must have a file size smaller than {CF_IMAGES_FILE_SIZE_LIMIT_IN_MB}MB (SkyScheduler will attempt to compress images to fit <span data-tooltip={bskyImageLimits}>BlueSky's requirements</span>)</li> 63 66 <li>thumbnails will only be shown here for images that are smaller than {MAX_THUMBNAIL_SIZE}MB</li> 64 67 <li>don't upload and fail, it's recommended to use a lower resolution file instead</li> 65 68 </ul></li> 66 - <li><span data-tooltip={BSKY_VIDEO_FILE_EXTS}>Videos</span>: 69 + <li><span data-tooltip={BSKY_VIDEO_FILE_EXTS}>Videos</span>: 67 70 <ul> 68 71 <li>must be shorter than {BSKY_VIDEO_MAX_DURATION} minutes</li> 69 72 <li>must be smaller than {R2_FILE_SIZE_LIMIT_IN_MB}MB</li> ··· 73 76 </footer> 74 77 </article> 75 78 </section> 76 - <section id="webLinkAttachmentSection"> 79 + <section id="section-weblink"> 77 80 <article> 78 81 <header>Link Embed</header> 79 82 <input type="text" id="urlCard" placeholder="https://" value="" /> ··· 96 99 </article> 97 100 </section> 98 101 </details> 99 - <details open> 102 + <details id="section-postSchedule" open> 100 103 <summary title="click to toggle section" role="button" class="outline secondary">Post Scheduling</summary> 101 104 <ScheduleOptions allowNow={true} timeID="scheduledDate" checkboxID="postNow" type="post" /> 102 105 </details> 103 106 104 - <details> 107 + <details id="section-retweet"> 105 108 <summary role="button" title="click to toggle section" class="secondary outline">Auto-Retweet</summary> 106 109 <RetweetOptions id="makeReposts" /> 107 110 </details> 111 + <input type="hidden" id="threadInfo" /> 108 112 </div> 109 113 <footer> 110 114 <button id="makingPostRequest" type="submit" class="w-full primary">
+3 -3
src/layout/passwordFields.tsx
··· 15 15 export function BSkyAppPasswordField(props: PasswordFieldSettings) { 16 16 const requiredAttr:string = props.required ? "required" : ""; 17 17 return html`<input type="password" name="bskyAppPassword" title="Bluesky account's App Password" 18 - maxlength=${BSKY_MAX_APP_PASSWORD_LENGTH} placeholder="" ${requiredAttr} 18 + maxlength=${BSKY_MAX_APP_PASSWORD_LENGTH} placeholder="" ${requiredAttr} 19 19 data-1p-ignore data-bwignore data-lpignore="true" 20 - data-protonpass-ignore="true" 20 + data-protonpass-ignore="true" 21 21 autocomplete="off"></input>`; 22 22 } 23 23 ··· 36 36 autocompleteSetting = "new-password"; 37 37 break; 38 38 } 39 - return html`<input id="password" type="password" name="password" minlength=${MIN_DASHBOARD_PASS} 39 + return html`<input id="password" type="password" name="password" minlength=${MIN_DASHBOARD_PASS} 40 40 maxlength=${MAX_DASHBOARD_PASS} ${requiredAttr} autocomplete=${autocompleteSetting} />`; 41 41 }
+30 -19
src/layout/postList.tsx
··· 2 2 import { html, raw } from "hono/html"; 3 3 import isEmpty from "just-is-empty"; 4 4 import { Post } from "../types.d"; 5 - import { getPostsForUser } from "../utils/dbQuery"; 6 5 import { getUsernameForUser } from "../utils/db/userinfo"; 6 + import { getPostsForUser } from "../utils/dbQuery"; 7 7 8 8 type PostContentObjectProps = { 9 9 text: string; ··· 17 17 post: Post; 18 18 user: string|null; 19 19 // if the object should be dynamically replaced. 20 + // usually in edit/cancel edit settings. 20 21 dynamic?: boolean; 21 22 } 22 23 ··· 29 30 30 31 const postType = content.isRepost ? "repost" : "post"; 31 32 const postOnText = content.isRepost ? "Repost on" : "Posted on"; 32 - const editAttributes = hasBeenPosted ? '' : raw(`title="Click to edit post content" hx-get="/post/edit/${content.postid}" 33 + const deleteReplace = `hx-target="${content.isChildPost ? 'blockquote:has(' : ''}#postBase${content.postid}${content.isChildPost ? ')' :''}"`; 34 + const editAttributes = hasBeenPosted ? '' : raw(`title="Click to edit post content" hx-get="/post/edit/${content.postid}" 33 35 hx-trigger="click once" hx-target="#post${content.postid}" hx-swap="innerHTML show:#editPost${content.postid}:top"`); 34 - const deletePostElement = raw(`<button type="submit" hx-delete="/post/delete/${content.postid}" 35 - hx-confirm="Are you sure you want to delete this ${postType}?" title="Click to delete this ${postType}" 36 - data-placement="left" data-tooltip="Delete this ${postType}" hx-target="#postBase${content.postid}" 36 + const deletePostElement = raw(`<button type="submit" hx-delete="/post/delete/${content.postid}" 37 + hx-confirm="Are you sure you want to delete this ${postType}?" title="Click to delete this ${postType}" 38 + data-placement="left" data-tooltip="Delete this ${postType}" ${raw(deleteReplace)} 37 39 hx-swap="outerHTML" hx-trigger="click" class="btn-sm btn-error outline btn-delete"> 38 - <img src="/icons/trash.svg" alt="trash icon" width="20px" height="20px" /> 40 + <img src="/icons/trash.svg" alt="trash icon" width="20px" height="20px" /> 39 41 </button>`); 40 - const editPostElement = raw(`<button class="editPostKeyboard btn-sm primary outline" listening="false" 42 + const editPostElement = raw(`<button class="editPostKeyboard btn-sm primary outline" 41 43 data-tooltip="Edit this post" data-placement="right" ${editAttributes}> 42 44 <img src="/icons/edit.svg" alt="edit icon" width="20px" height="20px" /> 43 45 </button>`); 44 - 46 + const threadItemElement = raw(`<button class="addThreadPost btn-sm primary outline" data-tooltip="Create a post in thread" 47 + data-placement="right" listen="false"> 48 + <img src="/icons/reply.svg" alt="threaded post icon" width="20px" height="20px" /> 49 + </button>`); 50 + 45 51 let repostInfoStr:string = ""; 46 52 if (!isEmpty(content.repostInfo)) { 47 53 for (const repostItem of content.repostInfo!) { ··· 55 61 } 56 62 } 57 63 } 58 - const repostCountElement = content.repostCount ? 64 + const repostCountElement = content.repostCount ? 59 65 (<> | <span class="repostTimesLeft" tabindex={0} data-placement="left"> 60 66 <span class="repostInfoData" hidden={true}>{raw(repostInfoStr)}</span>Reposts Left: {content.repostCount}</span></>) : ""; 61 67 68 + // This is only really good for debugging, this attribute isn't used anywhere else. 69 + const parentMetaAttr = (content.isChildPost) ? `data-parent="${content.parentPost}"` : ""; 70 + 62 71 const postHTML = html` 63 - <article data-root="${content.rootPost || ''}" data-parent="${content.parentPost || ''}" 72 + <article 64 73 id="postBase${content.postid}" ${oobSwapStr}> 65 - <header class="postItemHeader" ${hasBeenPosted && !content.isRepost ? raw('hidden>') : raw(`>`)} 66 - ${!hasBeenPosted ? editPostElement : null} 67 - ${!hasBeenPosted || (content.isRepost && content.repostCount! > 0) ? deletePostElement : null} 74 + <header class="postItemHeader" data-item="${content.postid}" data-root="${content.rootPost || content.postid}" ${raw(parentMetaAttr)} 75 + ${hasBeenPosted && !content.isRepost ? raw('hidden>') : raw(`>`)} 76 + ${!hasBeenPosted ? editPostElement : null} 77 + ${!hasBeenPosted ? threadItemElement : null} 78 + ${!hasBeenPosted || (content.isRepost && content.repostCount! > 0) ? deletePostElement : null} 68 79 </header> 69 80 <div id="post${content.postid}"> 70 81 ${<PostContentObject text={content.text}/>} 71 82 </div> 72 83 <footer> 73 84 <small> 74 - ${hasBeenPosted ? 75 - raw(`<a class="secondary" data-uri="${content.uri}" href="https://bsky.app/profile/${postURIID}" 76 - target="_blank" title="link to post">${postOnText}</a>:`) : 77 - 'Scheduled for:' } 85 + ${hasBeenPosted ? 86 + raw(`<a class="secondary" data-uri="${content.uri}" href="https://bsky.app/profile/${postURIID}" 87 + target="_blank" title="link to post">${postOnText}</a>:`) : 88 + 'Scheduled for:' } 78 89 <span class="timestamp">${content.scheduledDate}</span> 79 - ${!isEmpty(content.embeds) ? ' | Embeds: ' + content.embeds?.length : ''} 90 + ${!isEmpty(content.embeds) ? ' | Embeds: ' + content.embeds?.length : ''} 80 91 ${repostCountElement} 81 92 </small> 82 93 </footer> 83 94 </article>`; 84 95 // if this is a thread, chain it nicely 85 - if (content.parentPost !== undefined) 96 + if (content.isChildPost) 86 97 return html`<blockquote>${postHTML}</blockquote>`; 87 98 88 99 return postHTML;
+2 -2
src/layout/retweetOptions.tsx
··· 18 18 <input class="autoRepostBox" type="checkbox" id={props.id} hidden={props.hidden} startchecked={props.checked} /> 19 19 <label hidden={props.hidden} class="noselect" for={props.id}>{checkboxLabel}</label> 20 20 <center class="repostScheduleSimple"> 21 - Automatically repost this {props.contentType || "content"} every 21 + Automatically repost this {props.contentType || "content"} every 22 22 <select disabled> 23 23 {[...Array(MAX_REPOST_IN_HOURS)].map((x, i) => { 24 24 if (i == 0) return; 25 25 const dayStr = i % 24 === 0 ? ` (${i/24} day)` : ''; 26 26 return (<option value={i}>{i}{dayStr}</option>); 27 27 })} 28 - </select> hours 28 + </select> hours 29 29 <select disabled> 30 30 {[...Array(MAX_REPOST_INTERVAL_LIMIT)].map((x, i) => { 31 31 if (i == 0) return;
+1 -1
src/layout/scheduleOptions.tsx
··· 12 12 const hasHeader = !isEmpty(props.header); 13 13 const headerText = hasHeader ? props.header : ""; 14 14 15 - const postNowHTML = (props.allowNow) ? 15 + const postNowHTML = (props.allowNow) ? 16 16 (<> 17 17 <input class="postNow" type="checkbox" id={props.checkboxID} /> 18 18 <label class="noselect capitialize" for={props.checkboxID}>Make {props.type} Now?</label>
+5 -5
src/layout/settings.tsx
··· 22 22 </p> 23 23 <br /> 24 24 <section> 25 - <form id="settingsData" name="settingsData" hx-post="/account/update" hx-target="#accountResponse" 25 + <form id="settingsData" name="settingsData" hx-post="/account/update" hx-target="#accountResponse" 26 26 hx-swap="innerHTML swap:1s" hx-indicator="#spinner" hx-disabled-elt="#settingsButtons button, find input" novalidate> 27 27 28 28 <UsernameField required={false} title="BlueSky Handle:" hintText="Only change this if you have recently changed your Bluesky handle" /> 29 29 30 30 <label> 31 - Dashboard Pass: 31 + Dashboard Pass: 32 32 <DashboardPasswordField autocomplete={PWAutoCompleteSettings.CurrentPass} /> 33 33 <small>The password to access the SkyScheduler Dashboard</small> 34 34 </label> 35 35 <label> 36 - BSky App Password: 36 + BSky App Password: 37 37 <BSkyAppPasswordField /> 38 38 <small>If you need to change your bsky application password, you can <a href="https://bsky.app/settings/app-passwords" target="_blank">get a new one here</a>.</small> 39 39 </label> 40 40 <label> 41 - BSky PDS: 41 + BSky PDS: 42 42 <input type="text" name="bskyUserPDS" placeholder={placeholderPDS} /> 43 43 <small>If you have not changed your PDS (or do not know what that means), you should leave this blank!</small> 44 44 </label> ··· 59 59 <header>Delete Account</header> 60 60 <p>To delete your SkyScheduler account, please type your password below.<br /> 61 61 All pending, scheduled posts + all unposted media will be deleted from this service. 62 - 62 + 63 63 <center><strong>NOTE</strong>: THIS ACTION IS <u>PERMANENT</u>.</center> 64 64 </p> 65 65 <form id="delAccountForm" name="delAccountForm" hx-post="/account/delete" hx-target="#accountDeleteResponse" hx-disabled-elt="#accountDeleteButtons button, find input"
+2 -2
src/layout/violationsBar.tsx
··· 20 20 errorStr = "You currently have media that's too large for Bluesky (like a video), please delete those posts"; 21 21 } 22 22 return ( 23 - <div id="violationBar" class="warning-box" hx-trigger="accountViolations from:body" 23 + <div id="violationBar" class="warning-box" hx-trigger="accountViolations from:body" 24 24 hx-swap="outerHTML" hx-get="/account/violations" hx-target="this"> 25 25 <span class="warning"><b>WARNING</b>: Account error found! {errorStr}</span> 26 26 </div> 27 27 ); 28 28 } 29 - return (<div hx-trigger="accountViolations from:body" hidden id="hiddenViolations" 29 + return (<div hx-trigger="accountViolations from:body" hidden id="hiddenViolations" 30 30 hx-get="/account/violations" hx-swap="outerHTML" hx-target="this"></div>); 31 31 };
+5 -3
src/limits.ts
··· 2 2 3 3 /** APPLICATION CONFIGURATIONS **/ 4 4 // minimum length of a post 5 - export const MIN_LENGTH: number = 1; 5 + export const MIN_LENGTH: number = 1; 6 6 // max amount of times something can be reposted 7 7 export const MAX_REPOST_INTERVAL: number = 15; 8 8 // max amount of time something can be reposted over ··· 13 13 export const MAX_GIF_LENGTH: number = 1; 14 14 // if gifs should be allowed to upload 15 15 export const GIF_UPLOAD_ALLOWED: boolean = false; 16 + // max posts per thread 17 + export const MAX_POSTS_PER_THREAD: number = 10; 16 18 17 19 // This is the length of how much we keep in the DB after a post has been made 18 20 export const MAX_POSTED_LENGTH: number = 50; ··· 51 53 // https://github.com/bluesky-social/social-app/blob/b38013a12ff22a3ebd3075baa0d98bc96302a316/src/lib/constants.ts#L63 52 54 export const MAX_LENGTH: number = 300; 53 55 54 - // Alt text limit via 56 + // Alt text limit via 55 57 // https://github.com/bluesky-social/social-app/blob/b38013a12ff22a3ebd3075baa0d98bc96302a316/src/lib/constants.ts#L69 56 58 export const MAX_ALT_TEXT: number = 2000; 57 59 58 - // Image limit values via 60 + // Image limit values via 59 61 // https://github.com/bluesky-social/social-app/blob/b38013a12ff22a3ebd3075baa0d98bc96302a316/src/lib/constants.ts#L97 60 62 export const BSKY_IMG_MAX_WIDTH: number = 2000; 61 63 export const BSKY_IMG_MAX_HEIGHT: number = 2000;
+1 -1
src/middleware/auth.ts
··· 32 32 } 33 33 await next(); 34 34 } 35 - export async function requireAuth(c: Context, next: any) { 35 + export async function requireAuth(c: Context, next: any) { 36 36 if (c.get("session") === null || c.get("userId") === null) { 37 37 return c.json({ error: "Unauthorized" }, 401); 38 38 }
+1 -1
src/middleware/redirectDash.ts
··· 2 2 import { every } from "hono/combine"; 3 3 import { pullAuthData } from "./auth"; 4 4 5 - export async function goDashIfLogin(c: Context, next: any) { 5 + export async function goDashIfLogin(c: Context, next: any) { 6 6 if (c.get("userId") !== null) { 7 7 return c.redirect("/dashboard"); 8 8 }
+18 -11
src/pages/dashboard.tsx
··· 1 1 import { Context } from "hono"; 2 2 import { AltTextDialog } from "../layout/altTextModal"; 3 - import { DependencyTags } from "../layout/depTags"; 3 + import { DependencyTags, ScriptTags } from "../layout/depTags"; 4 4 import FooterCopyright from "../layout/footer"; 5 5 import { BaseLayout } from "../layout/main"; 6 6 import { PostCreation, PreloadPostCreation } from "../layout/makePost"; ··· 9 9 import { Settings, SettingsButton } from "../layout/settings"; 10 10 import { ViolationNoticeBar } from "../layout/violationsBar"; 11 11 import { PreloadRules } from "../types.d"; 12 - import { appScriptStrs, postHelperScriptStr, repostHelperScriptStr } from "../utils/appScripts"; 12 + import { altTextScriptStr, appScriptStr, appScriptStrs, postHelperScriptStr, repostHelperScriptStr, tributeScriptStr } from "../utils/appScripts"; 13 + import { SHOW_PROGRESS_BAR } from "../progress"; 13 14 14 15 export default function Dashboard(props:any) { 15 16 const ctx: Context = props.c; ··· 20 21 {href: "/dep/modal.js", type: "script"}, 21 22 {href: "/dep/tabs.js", type: "script"} 22 23 ]; 24 + const bottomScripts:string[] = [ 25 + appScriptStr, 26 + altTextScriptStr, 27 + tributeScriptStr, 28 + postHelperScriptStr, 29 + repostHelperScriptStr 30 + ]; 23 31 24 32 // Our own homebrew js files 25 33 const dashboardScripts: PreloadRules[] = appScriptStrs.map((itm) => { 26 34 return {href: itm, type: "script"}; 27 35 }); 28 36 return ( 29 - <BaseLayout title="SkyScheduler - Dashboard" mainClass="dashboard" 37 + <BaseLayout title="SkyScheduler - Dashboard" mainClass="dashboard" 30 38 preloads={[...PreloadPostCreation, ...defaultDashboardPreloads, ...dashboardScripts]}> 31 39 <DependencyTags scripts={defaultDashboardPreloads} /> 32 40 <div class="row-fluid"> ··· 36 44 <h4>SkyScheduler Dashboard</h4> 37 45 <div class="sidebar-block"> 38 46 <small><i>Schedule Bluesky posts effortlessly</i>.</small><br /> 39 - <small>Account: <b class="truncate" id="currentUser" hx-get="/account/username" 47 + <small>Account: <b class="truncate" id="currentUser" hx-get="/account/username" 40 48 hx-trigger="accountUpdated from:body, load once" hx-target="this"></b></small> 41 49 </div> 42 50 <center class="postControls"> 43 - <button id="refresh-posts" hx-get="/post/all" hx-target="#posts" 44 - hx-trigger="refreshPosts from:body, accountUpdated from:body, click throttle:3s" 45 - hx-on-htmx-before-request="this.classList.add('svgAnim');" 51 + <button id="refresh-posts" hx-get="/post/all" hx-target="#posts" 52 + hx-trigger="refreshPosts from:body, accountUpdated from:body, click throttle:3s" 53 + hx-on-htmx-before-request="this.classList.add('svgAnim');" 46 54 hx-on-htmx-after-request="setTimeout(() => {this.classList.remove('svgAnim')}, 3000)"> 47 55 <span>Refresh Posts</span> 48 56 <img src="/icons/refresh.svg" alt="refresh icon" /> ··· 57 65 </div> 58 66 <footer> 59 67 <div> 60 - <button class="outline w-full btn-error logout" hx-post="/account/logout" 68 + <button class="outline w-full btn-error logout" hx-post="/account/logout" 61 69 hx-target="body" hx-confirm="Are you sure you want to logout?"> 62 70 Logout 63 71 </button> 64 72 </div> 65 73 <hr /> 66 - <FooterCopyright inNewWindow={true} showHomepage={true} /> 74 + <FooterCopyright inNewWindow={true} showHomepage={true} showProgressBar={SHOW_PROGRESS_BAR} /> 67 75 </footer> 68 76 </article> 69 77 </section> ··· 82 90 </div> 83 91 </div> 84 92 <AltTextDialog /> 85 - <script type="text/javascript" src={postHelperScriptStr}></script> 86 - <script type="text/javascript" src={repostHelperScriptStr}></script> 93 + <ScriptTags scripts={bottomScripts} /> 87 94 <Settings pds={ctx.get("pds")} /> 88 95 </BaseLayout> 89 96 );
+4 -4
src/pages/forgot.tsx
··· 10 10 const ctx: Context = props.c; 11 11 const botAccountURL:string = `https://bsky.app/profile/${ctx.env.RESET_BOT_USERNAME}`; 12 12 return ( 13 - <BaseLayout title="SkyScheduler - Forgot Password" 13 + <BaseLayout title="SkyScheduler - Forgot Password" 14 14 preloads={[...TurnstileCaptchaPreloads(ctx)]}> 15 15 <NavTags /> 16 - <AccountHandler title="Forgot Password Reset" 16 + <AccountHandler title="Forgot Password Reset" 17 17 submitText="Request Password Reset" 18 - loadingText="Requesting Password Reset..." endpoint="/account/forgot" 19 - successText="Attempted to send DM. If you do not have it, please make sure you are following the account." 18 + loadingText="Requesting Password Reset..." endpoint="/account/forgot" 19 + successText="Attempted to send DM. If you do not have it, please make sure you are following the account." 20 20 redirect="/login" 21 21 customRedirectDelay={2000} 22 22 footerHTML={<FooterCopyright />}>
+6 -2
src/pages/homepage.tsx
··· 1 1 import FooterCopyright from "../layout/footer"; 2 2 import { BaseLayout } from "../layout/main"; 3 3 import NavTags from "../layout/navTags"; 4 - import { MAX_REPOST_DAYS, MAX_REPOST_IN_HOURS, MAX_REPOST_INTERVAL, R2_FILE_SIZE_LIMIT_IN_MB } from "../limits"; 4 + import { 5 + MAX_POSTS_PER_THREAD, MAX_REPOST_DAYS, MAX_REPOST_IN_HOURS, 6 + MAX_REPOST_INTERVAL, R2_FILE_SIZE_LIMIT_IN_MB 7 + } from "../limits"; 5 8 6 9 export default function Home() { 7 10 return ( ··· 11 14 <article> 12 15 <noscript><header>Javascript is required to use this website</header></noscript> 13 16 <p> 14 - <strong>SkyScheduler</strong> is a free, <a href="https://github.com/socksthewolf/skyscheduler" rel="nofollow" target="_blank">open source</a> service that 17 + <strong>SkyScheduler</strong> is a free, <a href="https://github.com/socksthewolf/skyscheduler" rel="nofollow" target="_blank">open source</a> service that 15 18 lets you schedule and automatically repost your content on Bluesky!<br /> 16 19 Boost engagement and reach more people no matter what time of day!<br /> 17 20 <center> ··· 34 37 <li>Schedule your posts any time in the future (to the nearest hour)</li> 35 38 <li>Supports embeds, quote posts, links, tagging, mentions</li> 36 39 <li>Post <span data-tooltip={`images and video (up to ${R2_FILE_SIZE_LIMIT_IN_MB} MB)`}>media</span> with content labels and full support for alt text</li> 40 + <li>Schedule entire threads with support of up to {MAX_POSTS_PER_THREAD} posts per thread!</li> 37 41 <li>Automatically retweet your content at an interval of your choosing, up to {MAX_REPOST_INTERVAL} times every {MAX_REPOST_IN_HOURS-1} hours (or {MAX_REPOST_DAYS} days)</li> 38 42 <li>Edit the content of posts and alt text before they are posted</li> 39 43 </ul>
+4 -4
src/pages/login.tsx
··· 10 10 return ( 11 11 <BaseLayout title="SkyScheduler - Login"> 12 12 <NavTags /> 13 - <AccountHandler title="Login" 14 - loadingText="Logging in..." 13 + <AccountHandler title="Login" 14 + loadingText="Logging in..." 15 15 footerLinks={links} 16 - endpoint="/account/login" 17 - successText="Success! Redirecting to dashboard..." 16 + endpoint="/account/login" 17 + successText="Success! Redirecting to dashboard..." 18 18 redirect="/dashboard"> 19 19 20 20 <UsernameField />
+5 -5
src/pages/reset.tsx
··· 8 8 return ( 9 9 <BaseLayout title="SkyScheduler - Reset Password"> 10 10 <NavTags /> 11 - <AccountHandler title="Reset Password" 12 - loadingText="Resetting Password..." 13 - endpoint="/account/reset" 14 - successText="Success! Redirecting to login..." 15 - redirect="/login" 11 + <AccountHandler title="Reset Password" 12 + loadingText="Resetting Password..." 13 + endpoint="/account/reset" 14 + successText="Success! Redirecting to login..." 15 + redirect="/login" 16 16 footerLinks={links}> 17 17 18 18 <input type="hidden" name="resetToken" id="resetToken" hx-history="false" />
+7 -7
src/pages/signup.tsx
··· 12 12 13 13 export default function Signup(props:any) { 14 14 const ctx: Context = props.c; 15 - const linkToInvites = isUsingInviteKeys(ctx) ? 16 - (<a href={getInviteThread(ctx)} target="_blank">Invite codes are routinely posted in this thread, grab one here</a>) : 15 + const linkToInvites = isUsingInviteKeys(ctx) ? 16 + (<a href={getInviteThread(ctx)} target="_blank">Invite codes are routinely posted in this thread, grab one here</a>) : 17 17 "You can ask for the maintainer for it"; 18 18 19 19 return ( 20 - <BaseLayout title="SkyScheduler - Signup" 20 + <BaseLayout title="SkyScheduler - Signup" 21 21 preloads={[...TurnstileCaptchaPreloads(ctx)]}> 22 22 <NavTags /> 23 - <AccountHandler title="Create an Account" 23 + <AccountHandler title="Create an Account" 24 24 submitText="Sign Up!" 25 - loadingText="Signing up..." 26 - endpoint="/account/signup" 27 - successText="Success! Redirecting to login..." 25 + loadingText="Signing up..." 26 + endpoint="/account/signup" 27 + successText="Success! Redirecting to login..." 28 28 redirect="/login" 29 29 footerHTML={<FooterCopyright />}> 30 30
+3 -3
src/pages/tos.tsx
··· 32 32 Deletions may take up to 30 days to fully cycle out of backups. 33 33 </p> 34 34 <h4>Disclaimer/Limitations</h4> 35 - <p>SkyScheduler IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 36 - IN NO EVENT SHALL THE AUTHORS, HOSTS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 35 + <p>SkyScheduler IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 36 + IN NO EVENT SHALL THE AUTHORS, HOSTS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 37 37 TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p> 38 38 <h4>Ammendum</h4> 39 - <p>The terms of service may be revised at any time without prior notification. 39 + <p>The terms of service may be revised at any time without prior notification. 40 40 Continued use of the website means that you agree to be bound by the current version of this document.</p> 41 41 <footer> 42 42 <FooterCopyright />
+7
src/progress.ts
··· 1 + // for the progress bar, this is an easily editable file for updating the bar 2 + // maybe we'll support webhooks in the future, but w/e 3 + export const PROGRESS_TOTAL: number = 10; 4 + export const PROGRESS_MADE: number = 0; 5 + 6 + // if the support bar should be shown or not. Currently is only visible on the dashboard page 7 + export const SHOW_PROGRESS_BAR: boolean = false;
+23 -6
src/types.d.ts
··· 26 26 27 27 type QueueConfigSettings = { 28 28 enabled: boolean; 29 + repostsEnabled: boolean; 30 + threadEnabled: boolean; 29 31 postNowEnabled?: boolean; 32 + delay_val: number; 30 33 post_queues: string[]; 31 34 repost_queues: string[]; 32 35 } ··· 43 46 KV: KVNamespace; 44 47 IMAGES: ImagesBinding; 45 48 POST_QUEUE1: Queue; 46 - REPOST_QUEUE: Queue; 47 49 QUEUE_SETTINGS: QueueConfigSettings; 48 50 INVITE_POOL: KVNamespace; 49 51 IMAGE_SETTINGS: ImageConfigSettings; ··· 124 126 cid?: string; 125 127 uri?: string; 126 128 // thread data 127 - isThread: boolean; 129 + isThreadRoot: boolean; 130 + isChildPost: boolean; 131 + threadOrder: number; 128 132 rootPost?: string; 129 133 parentPost?: string; 130 134 }; ··· 165 169 }; 166 170 167 171 export type PostRecordResponse = PostResponseObject & { 168 - postID: string; 172 + postID: string|null; 169 173 content: string; 170 - embeds?: EmbedData[]; 174 + embeds?: EmbedData[]; 175 + }; 176 + 177 + export type PostStatus = { 178 + records: PostRecordResponse[]; 179 + // number of expected successes 180 + expected: number; 181 + // number of successes we got 182 + got: number; 171 183 }; 184 + 185 + export type DeleteResponse = { 186 + success: boolean; 187 + needsRefresh?: boolean; 188 + } 172 189 173 190 export interface LooseObj { 174 191 [key: string]: any; ··· 216 233 export type CreateObjectResponse = { 217 234 ok: boolean; 218 235 msg: string; 236 + postId?: string; 219 237 }; 220 238 221 239 export type CreatePostQueryResponse = CreateObjectResponse & { 222 240 postNow?: boolean; 223 - postId?: string; 224 241 }; 225 242 226 243 export type BskyAPILoginCreds = { ··· 232 249 233 250 // Used for the pruning and database operations 234 251 export type GetAllPostedBatch = { 235 - id: string; 252 + id: string; 236 253 uri: string|null; 237 254 }; 238 255
+6 -2
src/utils/appScripts.ts
··· 1 1 // Change this value to break out of any caching that might be happening 2 2 // for the runtime scripts (ex: main.js & postHelper.js) 3 - export const CURRENT_SCRIPT_VERSION: string = "1.4.3"; 3 + export const CURRENT_SCRIPT_VERSION: string = "1.4.5"; 4 4 5 5 export const getAppScriptStr = (scriptName: string) => `/js/${scriptName}.min.js?v=${CURRENT_SCRIPT_VERSION}`; 6 6 7 7 // Eventually make this automatically generated. 8 8 export const postHelperScriptStr: string = getAppScriptStr("postHelper"); 9 9 export const repostHelperScriptStr: string = getAppScriptStr("repostHelper"); 10 + export const appScriptStr: string = getAppScriptStr("app"); 11 + export const altTextScriptStr: string = getAppScriptStr("altTextHelper"); 12 + export const tributeScriptStr: string = getAppScriptStr("tributeHelper"); 10 13 export const mainScriptStr: string = getAppScriptStr("main"); 11 14 export const settingsScriptStr: string = getAppScriptStr("settings"); 12 15 13 - export const appScriptStrs = [postHelperScriptStr, repostHelperScriptStr, mainScriptStr, settingsScriptStr]; 16 + export const appScriptStrs = [mainScriptStr, appScriptStr, altTextScriptStr, tributeScriptStr, 17 + postHelperScriptStr, repostHelperScriptStr, settingsScriptStr];
+142 -64
src/utils/bskyApi.ts
··· 5 5 import has from 'just-has'; 6 6 import isEmpty from "just-is-empty"; 7 7 import truncate from "just-truncate"; 8 - import { BSKY_IMG_SIZE_LIMIT, MAX_ALT_TEXT, MAX_EMBEDS_PER_POST, MAX_POSTED_LENGTH } from '../limits'; 8 + import { BSKY_IMG_SIZE_LIMIT, MAX_ALT_TEXT, MAX_EMBEDS_PER_POST } from '../limits'; 9 9 import { 10 10 Bindings, BskyEmbedWrapper, BskyRecordWrapper, EmbedData, EmbedDataType, 11 - LooseObj, AccountStatus, Post, PostLabel, 12 - PostResponseObject, Repost, ScheduledContext 11 + LooseObj, Post, PostLabel, AccountStatus, 12 + PostRecordResponse, PostStatus, Repost, ScheduledContext 13 13 } from '../types.d'; 14 14 import { atpRecordURI } from '../validation/regexCases'; 15 + import { bulkUpdatePostedData, getChildPostsOfThread, isPostAlreadyPosted, setPostNowOffForPost } from './db/data'; 15 16 import { getBskyUserPassForId, getUsernameForUserId } from './db/userinfo'; 16 17 import { createViolationForUser } from './db/violations'; 17 18 import { deleteEmbedsFromR2 } from './r2Query'; 18 - import { isPostAlreadyPosted, setPostNowOffForPost, updatePostData } from './db/data'; 19 19 20 20 export const doesHandleExist = async (user: string) => { 21 21 try { ··· 124 124 return agent; 125 125 } 126 126 127 - export const makePost = async (c: Context|ScheduledContext, content: Post|null, isQueued: boolean=false, usingAgent: AtpAgent|null=null) => { 128 - if (content === null) 127 + export const makePost = async (c: Context|ScheduledContext, content: Post|null, usingAgent: AtpAgent|null=null) => { 128 + if (content === null) { 129 + console.warn("Dropping invocation of makePost, content was null"); 129 130 return false; 130 - 131 + } 132 + 131 133 const env = c.env; 132 134 // make a check to see if the post has already been posted onto bsky 133 - if (await isPostAlreadyPosted(env, content.postid)) { 134 - console.log(`Dropped handling make post for post ${content.postid}, already posted.`) 135 + // skip over this check if we are a threaded post, as we could have had a child post that didn't make it. 136 + if (!content.isThreadRoot && await isPostAlreadyPosted(env, content.postid)) { 137 + console.log(`Dropped handling make post for post ${content.postid}, already posted.`); 135 138 return true; 136 139 } 137 140 ··· 140 143 console.warn(`could not make agent for post ${content.postid}`); 141 144 return false; 142 145 } 143 - const newPost: PostResponseObject|null = await makePostRaw(env, content, agent); 144 - if (newPost !== null) { 145 - // update post data in the d1 146 - const postDataUpdate: Promise<boolean> = updatePostData(env, content.postid, { posted: true, uri: newPost.uri, cid: newPost.cid, 147 - content: truncate(content.text, MAX_POSTED_LENGTH), embedContent: [] }); 148 - if (isQueued) 149 - await postDataUpdate; 150 - else 151 - c.executionCtx.waitUntil(postDataUpdate); 146 + 147 + const newPostRecords: PostStatus|null = await makePostRaw(env, content, agent); 148 + if (newPostRecords !== null) { 149 + await bulkUpdatePostedData(env, newPostRecords.records, newPostRecords.expected == newPostRecords.got); 152 150 153 151 // Delete any embeds if they exist. 154 - await deleteEmbedsFromR2(c, content.embeds, isQueued); 155 - return true; 152 + for (const record of newPostRecords.records) { 153 + if (record.postID === null) 154 + continue; 155 + 156 + c.executionCtx.waitUntil(deleteEmbedsFromR2(c, record.embeds, true)); 157 + } 158 + 159 + // if we had a total success, return true. 160 + return newPostRecords.expected == newPostRecords.got; 161 + } else if (!content.postNow) { 162 + console.warn(`Post records for ${content.postid} was null, the schedule post failed`); 156 163 } 157 - 164 + 158 165 // Turn off the post now flag if we failed. 159 166 if (content.postNow) { 160 - if (isQueued) 161 - await setPostNowOffForPost(env, content.postid); 162 - else 163 - c.executionCtx.waitUntil(setPostNowOffForPost(env, content.postid)); 167 + c.executionCtx.waitUntil(setPostNowOffForPost(env, content.postid)); 164 168 } 165 169 return false; 166 170 } ··· 181 185 // the only thing that actually matters is the object below. 182 186 //console.warn(`failed to unrepost post ${content.uri} with err ${err}`); 183 187 } 184 - 188 + 185 189 try { 186 190 await agent.repost(content.uri, content.cid); 187 191 } catch(err) { ··· 192 196 return bWasSuccess; 193 197 }; 194 198 195 - export const makePostRaw = async (env: Bindings, content: Post, agent: AtpAgent) => { 199 + export const makePostRaw = async (env: Bindings, content: Post, agent: AtpAgent): Promise<PostStatus|null> => { 196 200 const username = await getUsernameForUserId(env, content.user); 197 201 // incredibly unlikely but we'll handle it 198 202 if (username === null) { ··· 200 204 return null; 201 205 } 202 206 203 - const rt = new RichText({ 204 - text: content.text, 205 - }); 207 + // Easy lookup map for reply mapping for this post chain 208 + const postMap = new Map(); 206 209 207 - await rt.detectFacets(agent); 210 + // Lambda that handles making a post record and submitting it to bsky 211 + const postSegment = async (postData: Post) => { 212 + let currentEmbedIndex = 0; 208 213 209 - // This used to be so that we could handle posts in threads, but it turns out that threading is more annoying 210 - // As if anything fails, you have to roll back pretty hard. 211 - // So threading is dropped. But here's the code if we wanted to bring it back in the future. 212 - let currentEmbedIndex = 0; 213 - const posts: PostResponseObject[] = []; 214 + const rt = new RichText({ 215 + text: postData.text, 216 + }); 214 217 215 - const postSegment = async (data: string) => { 218 + await rt.detectFacets(agent); 216 219 let postRecord: AppBskyFeedPost.Record = { 217 220 $type: 'app.bsky.feed.post', 218 - text: data, 221 + text: rt.text, 219 222 facets: rt.facets, 220 223 createdAt: new Date().toISOString(), 221 224 }; 222 - if (content.label !== undefined && content.label !== PostLabel.None) { 225 + if (postData.label !== undefined && postData.label !== PostLabel.None) { 223 226 let contentValues = []; 224 - switch (content.label) { 227 + switch (postData.label) { 225 228 case PostLabel.Adult: 226 229 contentValues.push({"val": "porn"}); 227 230 break; ··· 246 249 } 247 250 248 251 // Upload any embeds to this post 249 - if (content.embeds?.length) { 252 + if (postData.embeds?.length) { 250 253 let mediaEmbeds: BskyEmbedWrapper = { type: EmbedDataType.None }; 251 254 let imagesArray = []; 252 255 let bskyRecordInfo: BskyRecordWrapper = {}; 253 256 let embedsProcessed: number = 0; 254 257 const isRecordViolation = (attemptToWrite: EmbedDataType) => { 255 - return mediaEmbeds.type != EmbedDataType.None && mediaEmbeds.type != attemptToWrite 258 + return mediaEmbeds.type != EmbedDataType.None && mediaEmbeds.type != attemptToWrite 256 259 && mediaEmbeds.type != EmbedDataType.Record && attemptToWrite != EmbedDataType.Record; 257 260 } 258 261 // go until we run out of embeds or have hit the amount of embeds per post (+1 because there could be a record with media) 259 - for (; embedsProcessed < MAX_EMBEDS_PER_POST + 1 && currentEmbedIndex < content.embeds.length; ++currentEmbedIndex, ++embedsProcessed) { 260 - const currentEmbed: EmbedData = content.embeds[currentEmbedIndex]; 262 + for (; embedsProcessed < MAX_EMBEDS_PER_POST + 1 && currentEmbedIndex < postData.embeds.length; ++currentEmbedIndex, ++embedsProcessed) { 263 + const currentEmbed: EmbedData = postData.embeds[currentEmbedIndex]; 261 264 const currentEmbedType: EmbedDataType = currentEmbed.type; 262 265 263 266 // If we never saw any record info, and the current type is not record itself, then we're on an overflow and need to back out. ··· 267 270 268 271 // If we have encountered a record violation (illegal mixed media types), then we should stop processing further. 269 272 if (isRecordViolation(currentEmbedType)) { 270 - console.error(`${content.postid} had a mixed media types of ${mediaEmbeds.type} trying to write ${currentEmbedType}`); 273 + console.error(`${postData.postid} had a mixed media types of ${mediaEmbeds.type} trying to write ${currentEmbedType}`); 271 274 break; 272 275 } 273 276 ··· 287 290 let imageBlob = await thumbnail.blob(); 288 291 let thumbEncode = thumbnail.headers.get("content-type") || "image/png"; 289 292 if (imageBlob.size > BSKY_IMG_SIZE_LIMIT) { 290 - // Resize the thumbnail because while the blob service will accept 293 + // Resize the thumbnail because while the blob service will accept 291 294 // embed thumbnails of any size 292 - // it will fail when you try to make the post record, saying the 295 + // it will fail when you try to make the post record, saying the 293 296 // post record is invalid. 294 297 const imgTransform = (await env.IMAGES.input(imageBlob.stream()) 295 298 .transform({width: 1280, height: 720, fit: "scale-down"}) ··· 314 317 continue; 315 318 } else if (currentEmbedType == EmbedDataType.Record) { 316 319 let changedRecord = false; 317 - // Write the record type if we don't have one set already 320 + // Write the record type if we don't have one set already 318 321 // (others can override this and the post will become a record with media instead) 319 322 if (mediaEmbeds.type == EmbedDataType.None) { 320 323 mediaEmbeds.type = EmbedDataType.Record; ··· 322 325 } 323 326 324 327 if (!isEmpty(bskyRecordInfo)) { 325 - console.warn(`${content.postid} attempted to write two record info objects`); 328 + console.warn(`${postData.postid} attempted to write two record info objects`); 326 329 continue; 327 330 } 328 331 ··· 374 377 const uriResolve: string[] = [ uri ]; 375 378 const resolvePost = await getAgentPostRecords(agent, uriResolve); 376 379 if (resolvePost === null) { 377 - console.error(`Unable to resolve record information for ${content.postid} with ${uri}`); 380 + console.error(`Unable to resolve record information for ${postData.postid} with ${uri}`); 378 381 // Change the record back. 379 382 if (changedRecord) 380 383 mediaEmbeds.type = EmbedDataType.None; 381 384 continue; 382 - } 385 + } 383 386 if (resolvePost.length !== 0) 384 387 cid = resolvePost[0].cid; 385 388 } ··· 439 442 } catch (err) { 440 443 if (err instanceof XRPCError) { 441 444 if (err.status === ResponseType.InternalServerError) { 442 - console.warn(`Encountered internal server error on ${currentEmbed.content} for post ${content.postid}`); 445 + console.warn(`Encountered internal server error on ${currentEmbed.content} for post ${postData.postid}`); 443 446 return false; 444 447 } 445 448 } 446 449 // Give violation mediaTooBig if the file is too large. 447 - await createViolationForUser(env, content.user, AccountStatus.MediaTooBig); 448 - console.warn(`Unable to upload ${currentEmbed.content} for post ${content.postid} with err ${err}`); 450 + await createViolationForUser(env, postData.user, AccountStatus.MediaTooBig); 451 + console.warn(`Unable to upload ${currentEmbed.content} for post ${postData.postid} with err ${err}`); 449 452 return false; 450 453 } 451 454 ··· 453 456 if (!uploadFile.success) { 454 457 console.warn(`failed to upload ${currentEmbed.content} to blob service`); 455 458 return false; 456 - } 459 + } 457 460 458 461 // Handle images 459 462 if (currentEmbedType == EmbedDataType.Image) { ··· 464 467 }; 465 468 // Attempt to get the width and height of the image file. 466 469 const sizeResult = await imageDimensionsFromStream(await fileBlob.stream()); 467 - // If we were able to parse the width and height of the image, 470 + // If we were able to parse the width and height of the image, 468 471 // then append the "aspect ratio" into the image record. 469 472 if (sizeResult) { 470 473 bskyMetadata.aspectRatio = { ··· 540 543 } 541 544 } 542 545 546 + // set up the thread chain 547 + if (postData.isChildPost) { 548 + const rootPostRecord: PostRecordResponse = postMap.get(postData.rootPost!); 549 + const parentPostRecord: PostRecordResponse = postMap.get(postData.parentPost!); 550 + if (!isEmpty(rootPostRecord) && !isEmpty(parentPostRecord)) { 551 + (postRecord as any).reply = { 552 + "root": { 553 + "uri": rootPostRecord.uri, 554 + "cid": rootPostRecord.cid 555 + }, 556 + "parent": { 557 + "uri": parentPostRecord.uri, 558 + "cid": parentPostRecord.cid 559 + } 560 + } 561 + } 562 + } 563 + 543 564 try { 544 565 const response = await agent.post(postRecord); 545 - posts.push(response); 566 + postMap.set(postData.postid, 567 + { ...response, 568 + embeds: postData.embeds, 569 + postID: postData.postid 570 + } as PostRecordResponse); 571 + console.log(`Posted to Bluesky: ${response.uri}`); 546 572 return true; 547 573 } catch(err) { 548 574 // This will try again in the future, next roundabout. 549 - console.error(`encountered error while trying to push post ${content.postid} up to bsky ${err}`); 575 + console.error(`encountered error while trying to push post ${postData.postid} up to bsky ${err}`); 550 576 } 551 577 return false; 552 578 }; 553 579 580 + let successThisRound = 0; 554 581 // Attempt to make the post 555 - if (await postSegment(rt.text) === false) { 556 - return null; 582 + if (!content.posted) { 583 + if (await postSegment(content) === false) 584 + return null; 585 + else 586 + successThisRound = 1; 587 + } else if (content.isThreadRoot) { 588 + // Thread posts with children that fail to be posted will be marked with 589 + // posted: false in the database, but the cid will be populated. 590 + // 591 + // However, our helper code will translate the post object and return 592 + // that it's actually posted: true 593 + // 594 + // Do not recreate the thread root in this scenario 595 + // push the existing data into the post map 596 + // so it can be referred to by other child posts. 597 + // 598 + // Only do this for thread roots, no one else. 599 + postMap.set(content.postid, 600 + { uri: content.uri, 601 + cid: content.cid, 602 + postID: content.postid 603 + } as PostRecordResponse); 604 + } 605 + 606 + // Assume that we succeeded here (failure returns null) 607 + let successes = 1; 608 + let expected = 1; 609 + 610 + // If this is a post thread root 611 + if (content.isThreadRoot) { 612 + const childPosts = await getChildPostsOfThread(env, content.postid) || []; 613 + expected += childPosts.length; 614 + // get the thread children. 615 + for (const child of childPosts) { 616 + // If this post is already posted, we might be trying to restore from a failed state 617 + if (child.posted) { 618 + postMap.set(child.postid, {postID: null, uri: child.uri!, cid: child.cid!}); 619 + successes += 1; 620 + continue; 621 + } 622 + // This is the first child post we haven't handled yet, oof. 623 + const childSuccess = await postSegment(child); 624 + if (childSuccess === false) { 625 + console.error(`We encountered errors attempting to post child ${child.postid}, returning what did get posted`); 626 + break; 627 + } 628 + successes += 1; 629 + successThisRound += 1; 630 + } 557 631 } 558 632 559 - // Make a note that we posted this to BSky 560 - console.log(`Posted to Bluesky: ${posts.map(p => p.uri)}`); 633 + // Return a nice array for the folks at home 634 + const returnObj: PostStatus = { 635 + records: Array.from(postMap.values()).filter((post) => { return post.postID !== null;}), 636 + expected: expected, 637 + got: successes 638 + } 639 + console.log(`posted ${successes}/${expected}, did ${successThisRound} work units`); 561 640 562 - // store the first uri/cid 563 - return posts[0]; 641 + return returnObj; 564 642 } 565 643 566 644 export const getPostRecords = async (records:string[]) => {
+1 -1
src/utils/bskyMsg.ts
··· 50 50 } catch (delerr) { 51 51 console.error(`failed to delete reset message for self, got error ${delerr}`); 52 52 } 53 - // Message has been sent. 53 + // Message has been sent. 54 54 return true; 55 55 } else { 56 56 console.error(`Unable to send the message to ${user}, could not sendMessage call`);
+1 -1
src/utils/bskyPrune.ts
··· 63 63 } 64 64 } 65 65 return removePostIds; 66 - } 66 + }
+1 -1
src/utils/constScriptGen.ts
··· 22 22 }; 23 23 24 24 export const ConstScriptPreload: PreloadRules[] = [ 25 - {type: "script", href: `/js/consts.js?v=${CONST_SCRIPT_VERSION}`}, 25 + {type: "script", href: `/js/consts.js?v=${CONST_SCRIPT_VERSION}`}, 26 26 ]; 27 27 28 28 export function makeConstScript() {
+64 -19
src/utils/db/data.ts
··· 1 - import { 2 - and, eq, inArray, asc, 3 - lte, ne, notInArray, sql 4 - } from "drizzle-orm"; 1 + import { and, asc, desc, eq, inArray, isNotNull, lte, ne, notInArray, sql } from "drizzle-orm"; 5 2 import { BatchItem } from "drizzle-orm/batch"; 6 3 import { drizzle, DrizzleD1Database } from "drizzle-orm/d1"; 7 - import { Context } from "hono"; 8 4 import isEmpty from "just-is-empty"; 9 5 import { validate as uuidValid } from 'uuid'; 10 6 import { posts, repostCounts, reposts, violations } from "../../db/app.schema"; 11 - import { MAX_HOLD_DAYS_BEFORE_PURGE } from "../../limits"; 7 + import { MAX_HOLD_DAYS_BEFORE_PURGE, MAX_POSTED_LENGTH } from "../../limits"; 12 8 import { 13 9 BatchQuery, 14 10 Bindings, 15 11 GetAllPostedBatch, 16 12 Post, 17 - Repost, 18 - ScheduledContext 13 + PostRecordResponse, 14 + Repost 19 15 } from "../../types.d"; 20 16 import { createPostObject, createRepostObject, floorCurrentTime } from "../helpers"; 21 17 ··· 28 24 return result.length >= 1; 29 25 }; 30 26 31 - export const getAllPostsForCurrentTime = async (env: Bindings): Promise<Post[]> => { 27 + export const getAllPostsForCurrentTime = async (env: Bindings, removeThreads: boolean = false): Promise<Post[]> => { 32 28 // Get all scheduled posts for current time 33 29 const db: DrizzleD1Database = drizzle(env.DB); 34 30 const currentTime: Date = floorCurrentTime(); ··· 38 34 .where( 39 35 and( 40 36 and( 41 - eq(posts.posted, false), 42 - ne(posts.postNow, true) // Ignore any posts that are marked for post now 37 + and( 38 + eq(posts.posted, false), 39 + ne(posts.postNow, true) // Ignore any posts that are marked for post now 40 + ), 41 + lte(posts.scheduledDate, currentTime) 43 42 ), 44 - lte(posts.scheduledDate, currentTime) 43 + // ignore threads, we'll create this one later. 44 + removeThreads ? eq(posts.threadOrder, -1) : lte(posts.threadOrder, 0) 45 45 ) 46 46 )); 47 47 const results = await db.with(postsToMake).select().from(postsToMake) ··· 72 72 const currentTime = floorCurrentTime(); 73 73 const deletedPosts = await db.delete(reposts).where(lte(reposts.scheduledDate, currentTime)) 74 74 .returning({id: reposts.uuid, scheduleGuid: reposts.scheduleGuid}); 75 - 75 + 76 76 // This is really stupid and I hate it, but someone has to update repost counts once posted 77 77 if (deletedPosts.length > 0) { 78 - let batchedQueries:BatchItem<"sqlite">[] = []; 78 + let batchedQueries:BatchItem<"sqlite">[] = []; 79 79 for (const deleted of deletedPosts) { 80 80 // Update counts 81 81 const newCount = db.$count(reposts, eq(reposts.uuid, deleted.id)); ··· 89 89 // if there are none, this schedule should get removed from the repostInfo array 90 90 const stillHasSchedule = await db.select().from(reposts) 91 91 .where(and( 92 - eq(reposts.scheduleGuid, deleted.scheduleGuid!), 92 + eq(reposts.scheduleGuid, deleted.scheduleGuid!), 93 93 eq(reposts.uuid, deleted.id))) 94 94 .limit(1).all(); 95 - 95 + 96 96 // if this is empty, then we need to update the repost info. 97 97 if (isEmpty(stillHasSchedule)) { 98 98 // get the existing repost info to filter out this old data ··· 125 125 return success; 126 126 }; 127 127 128 + export const bulkUpdatePostedData = async (env: Bindings, records: PostRecordResponse[], allPosted: boolean) => { 129 + const db: DrizzleD1Database = drizzle(env.DB); 130 + let dbOperations: BatchItem<"sqlite">[] = []; 131 + 132 + for (let i = 0; i < records.length; ++i) { 133 + const record = records[i]; 134 + // skip over invalid records 135 + if (record.postID === null) 136 + continue; 137 + 138 + let wasPosted = (i == 0 && !allPosted) ? false : true; 139 + dbOperations.push(db.update(posts).set( 140 + {content: sql`substr(posts.content, 0, ${MAX_POSTED_LENGTH})`, posted: wasPosted, uri: record.uri, cid: record.cid, embedContent: []}) 141 + .where(eq(posts.uuid, record.postID))); 142 + } 143 + 144 + if (dbOperations.length > 0) 145 + await db.batch(dbOperations as BatchQuery); 146 + } 147 + 128 148 export const setPostNowOffForPost = async (env: Bindings, id: string) => { 129 149 const didUpdate = await updatePostData(env, id, {postNow: false}); 130 150 if (!didUpdate) 131 151 console.error(`Unable to set PostNow to off for post ${id}`); 132 152 }; 133 153 134 - export const updatePostForGivenUser = async (c: Context|ScheduledContext, userId: string, id: string, newData: Object) => { 154 + export const updatePostForGivenUser = async (env: Bindings, userId: string, id: string, newData: Object) => { 135 155 if (isEmpty(userId) || !uuidValid(id)) 136 156 return false; 137 157 138 - const db: DrizzleD1Database = drizzle(c.env.DB); 158 + const db: DrizzleD1Database = drizzle(env.DB); 139 159 const {success} = await db.update(posts).set(newData).where(and(eq(posts.uuid, id), eq(posts.userId, userId))); 140 160 return success; 141 161 }; ··· 171 191 return true; 172 192 } 173 193 return query[0].posted; 194 + }; 195 + 196 + export const getChildPostsOfThread = async (env: Bindings, rootId: string): Promise<Post[]|null> => { 197 + if (!uuidValid(rootId)) 198 + return null; 199 + 200 + const db: DrizzleD1Database = drizzle(env.DB); 201 + const query = await db.select().from(posts) 202 + .where(and(isNotNull(posts.parentPost), eq(posts.rootPost, rootId))) 203 + .orderBy(asc(posts.threadOrder), desc(posts.createdAt)).all(); 204 + if (query.length > 0) { 205 + return query.map((child) => createPostObject(child)); 206 + } 207 + return null; 208 + }; 209 + 210 + export const getPostThreadCount = async (env: Bindings, userId: string, rootId: string): Promise<number> => { 211 + if (!uuidValid(rootId)) 212 + return 0; 213 + 214 + const db: DrizzleD1Database = drizzle(env.DB); 215 + return await db.$count(posts, and( 216 + eq(posts.rootPost, rootId), 217 + eq(posts.userId, userId))); 174 218 } 175 219 176 220 // deletes multiple posted posts from a database. ··· 200 244 and( 201 245 eq(posts.posted, true), lte(posts.updatedAt, sql`${dateString}`) 202 246 ), 203 - lte(repostCounts.count, 0) 247 + // skip child posts objects 248 + and(lte(posts.threadOrder, 0), lte(repostCounts.count, 0)) 204 249 ) 205 250 ).all(); 206 251 const postsToDelete = dbQuery.map((item) => { return item.data });
+2 -2
src/utils/db/file.ts
··· 34 34 .where( 35 35 and(eq(mediaFiles.hasPost, false), lte(mediaFiles.createdAt, numDaysAgo)) 36 36 ).all(); 37 - 37 + 38 38 return results.map((item) => item.fileName); 39 39 }; 40 40 ··· 42 42 const db: DrizzleD1Database = drizzle(env.DB); 43 43 const mediaList = await db.select({embeds: posts.embedContent}).from(posts) 44 44 .where(and(eq(posts.posted, false), eq(posts.userId, userId))).all(); 45 - 45 + 46 46 let messyArray: string[][] = []; 47 47 mediaList.forEach(obj => { 48 48 const postMedia = obj.embeds;
+5 -7
src/utils/db/maintain.ts
··· 35 35 // Post truncation 36 36 if (postTruncation.length > 0) { 37 37 console.log(`Attempting to clean up post truncation for ${postTruncation.length} posts`); 38 - // it would be nicer to do bulking of this, but the method to do so in drizzle leaves me uneasy (and totally not about to sql inject myself) 39 - // so we do each query uniquely instead. 40 - postTruncation.forEach(async item => { 38 + for (const item of postTruncation) { 41 39 console.log(`Updating post ${item.id}`); 42 - await db.update(posts).set({ content: truncate(item.content, MAX_POSTED_LENGTH) }).where(eq(posts.uuid, item.id)); 43 - }); 40 + await db.update(posts).set({ content: sql`substr(posts.content, 0, ${MAX_POSTED_LENGTH})`}).where(eq(posts.uuid, item.id)); 41 + } 44 42 } 45 43 46 44 // push timestamps ··· 56 54 console.error(`Adding file listings got error ${err}`); 57 55 } 58 56 59 - let batchedQueries:BatchItem<"sqlite">[] = []; 57 + let batchedQueries:BatchItem<"sqlite">[] = []; 60 58 // Flag if the media file has embed data 61 59 const allUsers = await db.select({id: users.id}).from(users).all(); 62 60 for (const user of allUsers) { ··· 68 66 const allPosts = await db.select({id: posts.uuid}).from(posts); 69 67 for (const post of allPosts) { 70 68 const count = db.$count(reposts, eq(reposts.uuid, post.id)); 71 - batchedQueries.push(db.insert(repostCounts).values({uuid: post.id, 69 + batchedQueries.push(db.insert(repostCounts).values({uuid: post.id, 72 70 count: count}).onConflictDoNothing()); 73 71 } 74 72 await db.batch(batchedQueries as BatchQuery);
+3 -3
src/utils/db/violations.ts
··· 58 58 } 59 59 60 60 export const createViolationForUser = async(env: Bindings, userId: string, violationType: AccountStatus): Promise<boolean> => { 61 - const NoHandleState: AccountStatus[] = [AccountStatus.Ok, AccountStatus.PlatformOutage, 61 + const NoHandleState: AccountStatus[] = [AccountStatus.Ok, AccountStatus.PlatformOutage, 62 62 AccountStatus.None, AccountStatus.UnhandledError]; 63 63 // Don't do anything in these cases 64 64 if (violationType in NoHandleState) { ··· 83 83 }; 84 84 85 85 export const getViolationDeleteQueryForUser = (db: DrizzleD1Database, userId: string) => { 86 - return db.delete(violations).where(and(eq(violations.userId, userId), 86 + return db.delete(violations).where(and(eq(violations.userId, userId), 87 87 and(ne(violations.tosViolation, true), ne(violations.accountGone, true)) 88 88 )); 89 89 }; ··· 103 103 const valuesUpdate:LooseObj = createObjForValuesChange(violationType, false); 104 104 await db.update(violations).set({...valuesUpdate}).where(eq(violations.userId, userId)); 105 105 // Delete the record if the user has no other violations 106 - await db.delete(violations).where(and(eq(violations.userId, userId), 106 + await db.delete(violations).where(and(eq(violations.userId, userId), 107 107 and( 108 108 and( 109 109 and(ne(violations.accountSuspended, true), ne(violations.accountGone, true),
+164 -53
src/utils/dbQuery.ts
··· 1 1 import { addHours, isAfter, isEqual } from "date-fns"; 2 - import { and, desc, eq, getTableColumns } from "drizzle-orm"; 2 + import { and, asc, desc, eq, getTableColumns, gt, gte, sql } from "drizzle-orm"; 3 3 import { BatchItem } from "drizzle-orm/batch"; 4 4 import { drizzle, DrizzleD1Database } from "drizzle-orm/d1"; 5 5 import { Context } from "hono"; ··· 8 8 import { v4 as uuidv4, validate as uuidValid } from 'uuid'; 9 9 import { mediaFiles, posts, repostCounts, reposts } from "../db/app.schema"; 10 10 import { accounts, users } from "../db/auth.schema"; 11 - import { MAX_REPOST_POSTS, MAX_REPOST_RULES_PER_POST } from "../limits"; 11 + import { MAX_POSTS_PER_THREAD, MAX_REPOST_POSTS, MAX_REPOST_RULES_PER_POST } from "../limits"; 12 12 import { 13 + AccountStatus, 13 14 BatchQuery, 14 15 CreateObjectResponse, CreatePostQueryResponse, 16 + DeleteResponse, 15 17 EmbedDataType, 16 - AccountStatus, 17 18 Post, PostLabel, 18 19 RepostInfo 19 20 } from "../types.d"; 20 21 import { PostSchema } from "../validation/postSchema"; 21 22 import { RepostSchema } from "../validation/repostSchema"; 22 - import { doesPostExist, updatePostForGivenUser } from "./db/data"; 23 + import { getChildPostsOfThread, getPostThreadCount, updatePostForGivenUser } from "./db/data"; 23 24 import { getViolationsForUser, removeViolation, removeViolations, userHasViolations } from "./db/violations"; 24 25 import { createPostObject, createRepostInfo, floorGivenTime } from "./helpers"; 25 26 import { deleteEmbedsFromR2 } from "./r2Query"; ··· 29 30 const userId = c.get("userId"); 30 31 if (userId) { 31 32 const db: DrizzleD1Database = drizzle(c.env.DB); 32 - const results = await db.select({...getTableColumns(posts), repostCount: repostCounts.count}) 33 + const results = await db.select({ 34 + ...getTableColumns(posts), 35 + repostCount: repostCounts.count 36 + }) 33 37 .from(posts).where(eq(posts.userId, userId)) 34 38 .leftJoin(repostCounts, eq(posts.uuid, repostCounts.uuid)) 35 - .orderBy(desc(posts.scheduledDate), desc(posts.createdAt)).all(); 36 - 39 + .orderBy(desc(posts.scheduledDate), asc(posts.threadOrder), desc(posts.createdAt)).all(); 40 + 37 41 if (isEmpty(results)) 38 42 return null; 39 43 40 44 return results.map((itm) => createPostObject(itm)); 41 45 } 42 46 } catch(err) { 43 - console.error(`Failed to get posts for user, session could not be fetched ${err}`); 47 + console.error(`Failed to get posts for user, session could not be fetched ${err}`); 44 48 } 45 49 return null; 46 50 }; ··· 87 91 return false; 88 92 }; 89 93 90 - export const deletePost = async (c: Context, id: string): Promise<boolean> => { 94 + export const deletePost = async (c: Context, id: string): Promise<DeleteResponse> => { 91 95 const userId = c.get("userId"); 96 + const returnObj: DeleteResponse = {success: false}; 92 97 if (!userId) { 93 - return false; 98 + return returnObj; 94 99 } 95 100 96 101 const db: DrizzleD1Database = drizzle(c.env.DB); 97 - const postQuery = await db.select().from(posts).where(and(eq(posts.uuid, id), eq(posts.userId, userId))).all(); 98 - if (postQuery.length !== 0) { 102 + const postObj = await getPostById(c, id); 103 + if (postObj !== null) { 104 + let queriesToExecute:BatchItem<"sqlite">[] = []; 99 105 // If the post has not been posted, that means we still have files for it, so 100 106 // delete the files from R2 101 - if (!postQuery[0].posted) { 102 - await deleteEmbedsFromR2(c, createPostObject(postQuery[0]).embeds); 107 + if (!postObj.posted) { 108 + await deleteEmbedsFromR2(c, postObj.embeds); 103 109 if (await userHasViolations(db, userId)) { 104 110 // Remove the media too big violation if it's been given 105 111 await removeViolation(c.env, userId, AccountStatus.MediaTooBig); 106 112 } 107 113 } 108 114 109 - c.executionCtx.waitUntil(db.delete(posts).where(eq(posts.uuid, id))); 110 - return true; 115 + // If the parent post is not null, then attempt to find and update the post chain 116 + const parentPost = postObj.parentPost; 117 + if (parentPost !== undefined) { 118 + // set anyone who had this as their parent to this post chain 119 + queriesToExecute.push(db.update(posts).set({parentPost: parentPost, threadOrder: postObj.threadOrder}) 120 + .where(and(eq(posts.parentPost, postObj.postid), eq(posts.rootPost, postObj.rootPost!)))); 121 + 122 + // Update the post order past here 123 + queriesToExecute.push(db.update(posts).set({threadOrder: sql`threadOrder - 1`}) 124 + .where( 125 + and(eq(posts.rootPost, postObj.rootPost!), gt(posts.threadOrder, postObj.threadOrder) 126 + ))); 127 + } 128 + 129 + // We'll need to delete all of the child embeds then, a costly, annoying experience. 130 + if (postObj.isThreadRoot) { 131 + const childPosts = await getChildPostsOfThread(c.env, postObj.postid); 132 + if (childPosts !== null) { 133 + for (const childPost of childPosts) { 134 + c.executionCtx.waitUntil(deleteEmbedsFromR2(c, childPost.embeds)); 135 + queriesToExecute.push(db.delete(posts).where(eq(posts.uuid, childPost.postid))); 136 + } 137 + } else { 138 + console.warn(`could not get child posts of thread ${postObj.postid} during delete`); 139 + } 140 + } else if (postObj.isChildPost) { 141 + // this is not a thread root, so we should figure out how many children are left. 142 + const childPostCount = (await getPostThreadCount(c.env, postObj.user, postObj.rootPost!)) - 1; 143 + if (childPostCount <= 0) { 144 + queriesToExecute.push(db.update(posts).set({threadOrder: -1}).where(eq(posts.uuid, postObj.rootPost!))); 145 + } 146 + } 147 + 148 + // delete post 149 + queriesToExecute.push(db.delete(posts).where(eq(posts.uuid, id))); 150 + await c.executionCtx.waitUntil(db.batch(queriesToExecute as BatchQuery)); 151 + returnObj.success = true; 152 + returnObj.needsRefresh = postObj.isThreadRoot; 111 153 } 112 - return false; 154 + return returnObj; 113 155 }; 114 156 115 157 export const createPost = async (c: Context, body: any): Promise<CreatePostQueryResponse> => { ··· 128 170 const scheduleDate = floorGivenTime((makePostNow) ? new Date() : new Date(scheduledDate)); 129 171 130 172 // Ensure scheduled date is in the future 131 - if (!isAfter(scheduleDate, new Date()) && !makePostNow) { 173 + // 174 + // Do not do this check if you are doing a threaded post 175 + // or you have marked that you are posting right now. 176 + if (!isAfter(scheduleDate, new Date()) && 177 + (!makePostNow && (isEmpty(rootPost) && isEmpty(parentPost)))) { 132 178 return { ok: false, msg: "Scheduled date must be in the future" }; 133 179 } 134 180 ··· 146 192 let rootPostID:string|undefined = undefined; 147 193 let parentPostID:string|undefined = undefined; 148 194 let rootPostData: Post|null = null; 195 + let parentPostOrder: number = 0; 149 196 if (uuidValid(rootPost)) { 150 197 // returns null if the post doesn't appear on this account 151 198 rootPostData = await getPostById(c, rootPost!); ··· 153 200 if (rootPostData.posted) { 154 201 return { ok: false, msg: "You cannot make threads off already posted posts"}; 155 202 } 156 - rootPostID = rootPostData.rootPost!; 203 + if (rootPostData.isChildPost) { 204 + return { ok: false, msg: "Subthreads of threads are not allowed." }; 205 + } 206 + if (rootPostData.isRepost) { 207 + return {ok: false, msg: "Threads cannot be made of repost actions"}; 208 + } 209 + rootPostID = rootPostData.rootPost || rootPostData.postid; 157 210 // If this isn't a direct reply, check directly underneath it 158 211 if (rootPost !== parentPost) { 159 212 if (uuidValid(parentPost)) { 160 - if (await doesPostExist(c.env, userId, parentPost!)) { 213 + const parentPostData = await getPostById(c, parentPost!); 214 + if (parentPostData !== null) { 161 215 parentPostID = parentPost!; 216 + parentPostOrder = parentPostData.threadOrder + 1; 162 217 } else { 163 218 return { ok: false, msg: "The given parent post cannot be found on your account"}; 164 219 } 220 + } else { 221 + return { ok: false, msg: "The given parent post is invalid"}; 165 222 } 166 223 } else { 167 - parentPostID = rootPost!; 224 + parentPostID = rootPostData.postid; 225 + parentPostOrder = 1; // Root will always be 0, so if this is root, go 1 up. 168 226 } 169 227 } else { 170 228 return { ok: false, msg: "The given root post cannot be found on your account"}; 171 229 } 172 230 } 173 - const isThreadedPost:boolean = (rootPostID !== undefined && parentPostID !== undefined); 231 + 232 + const isThreadedPost: boolean = (rootPostID !== undefined && parentPostID !== undefined); 233 + if (isThreadedPost) { 234 + const threadCount: number = await getPostThreadCount(c.env, userId, rootPostID!); 235 + if (threadCount >= MAX_POSTS_PER_THREAD) { 236 + return { ok: false, msg: `this thread has hit the limit of ${MAX_POSTS_PER_THREAD} posts per thread`}; 237 + } 238 + } 174 239 175 240 // Create repost metadata 176 241 const scheduleGUID = (!isThreadedPost) ? uuidv4() : undefined; 177 - const repostInfo = (!isThreadedPost) ? createRepostInfo(scheduleGUID!, scheduleDate, false, repostData) : undefined; 178 - 242 + const repostInfo = (!isThreadedPost) ? 243 + createRepostInfo(scheduleGUID!, scheduleDate, false, repostData) : undefined; 244 + 179 245 // Create the posts 180 246 const postUUID = uuidv4(); 181 - let dbOperations: BatchItem<"sqlite">[] = [ 182 - db.insert(posts).values({ 247 + let dbOperations: BatchItem<"sqlite">[] = []; 248 + 249 + // if we're threaded, insert our post before the given parent 250 + if (isThreadedPost) { 251 + // Update the parent to our new post 252 + dbOperations.push(db.update(posts).set({parentPost: postUUID }) 253 + .where(and(eq(posts.parentPost, parentPostID!), eq(posts.rootPost, rootPostID!)))); 254 + 255 + // update all posts past this one to also update their order (we will take their id) 256 + dbOperations.push(db.update(posts).set({threadOrder: sql`threadOrder + 1`}) 257 + .where( 258 + and(eq(posts.rootPost, rootPostID!), gte(posts.threadOrder, parentPostOrder) 259 + ))); 260 + 261 + // Update the root post so that it has the correct flags set on it as well. 262 + if (rootPostData!.isThreadRoot == false) { 263 + dbOperations.push(db.update(posts).set({threadOrder: 0, rootPost: rootPostData!.postid}) 264 + .where(eq(posts.uuid, rootPostData!.postid))); 265 + } 266 + } else { 267 + rootPostID = postUUID; 268 + } 269 + 270 + // Add the post to the DB 271 + dbOperations.push(db.insert(posts).values({ 183 272 content, 184 273 uuid: postUUID, 185 274 postNow: makePostNow, 186 - scheduledDate: (!isThreadedPost) ? scheduleDate : new Date(rootPostData?.scheduledDate!), 187 - /*isThread: isThreadedPost, 275 + scheduledDate: (!isThreadedPost) ? scheduleDate : new Date(rootPostData!.scheduledDate!), 188 276 rootPost: rootPostID, 189 - parentPost: parentPostID,*/ 277 + parentPost: parentPostID, 190 278 repostInfo: (!isThreadedPost) ? [repostInfo!] : [], 279 + threadOrder: (!isThreadedPost) ? undefined : parentPostOrder, 191 280 embedContent: embeds, 192 281 contentLabel: label || PostLabel.None, 193 282 userId: userId 194 - }) 195 - ]; 283 + })); 196 284 197 285 if (!isEmpty(embeds)) { 198 286 // Loop through all data within an embed blob so we can mark it as posted ··· 238 326 const { url, uri, cid, scheduledDate, repostData } = validation.data; 239 327 const scheduleDate = floorGivenTime(new Date(scheduledDate)); 240 328 const timeNow = new Date(); 241 - 329 + 242 330 // Ensure scheduled date is in the future 243 331 if (!isAfter(scheduleDate, timeNow)) { 244 332 return { ok: false, msg: "Scheduled date must be in the future" }; ··· 260 348 261 349 // Check to see if the post already exists 262 350 // (check also against the userId here as well to avoid cross account data collisions) 263 - const existingPost = await db.select({id: posts.uuid, date: posts.scheduledDate, curRepostInfo: posts.repostInfo}) 264 - .from(posts).where(and( 265 - eq(posts.userId, userId), eq(posts.cid, cid))) 266 - .limit(1).all(); 267 - 268 - const hasExistingPost:boolean = existingPost.length >= 1; 269 - if (hasExistingPost) { 270 - postUUID = existingPost[0].id; 271 - const existingPostDate = existingPost[0].date; 351 + const existingPost = await getPostByCID(c, cid); 352 + if (existingPost !== null) { 353 + postUUID = existingPost.postid; 354 + const existingPostDate = existingPost.scheduledDate!; 272 355 // Ensure the date asked for is after what the post's schedule date is 273 356 if (!isAfter(scheduleDate, existingPostDate) && !isEqual(scheduledDate, existingPostDate)) { 274 357 return { ok: false, msg: "Scheduled date must be after the initial post's date" }; 275 358 } 359 + // Make sure this isn't a thread post. 360 + // We could probably work around this but I don't think it's worth the effort. 361 + if (existingPost.isChildPost) { 362 + return {ok: false, msg: "Repost posts cannot be created from child thread posts"}; 363 + } 364 + 276 365 // Add repost info object to existing array 277 - let newRepostInfo:RepostInfo[] = isEmpty(existingPost[0].curRepostInfo) ? [] : existingPost[0].curRepostInfo!; 366 + let newRepostInfo:RepostInfo[] = isEmpty(existingPost.repostInfo) ? [] : existingPost.repostInfo!; 278 367 if (newRepostInfo.length >= MAX_REPOST_RULES_PER_POST) { 279 368 return {ok: false, msg: `Num of reposts rules for this post has exceeded the limit of ${MAX_REPOST_RULES_PER_POST} rules`}; 280 369 } ··· 287 376 // Limit of post reposts on the user's account. 288 377 const accountCurrentReposts = await db.$count(posts, and(eq(posts.userId, userId), eq(posts.isRepost, true))); 289 378 if (MAX_REPOST_POSTS > 0 && accountCurrentReposts >= MAX_REPOST_POSTS) { 290 - return {ok: false, msg: 379 + return {ok: false, msg: 291 380 `You've cannot create any more repost posts at this time. Using: (${accountCurrentReposts}/${MAX_REPOST_POSTS}) repost posts`}; 292 381 } 293 382 ··· 313 402 scheduleGuid: scheduleGUID, 314 403 scheduledDate: scheduleDate 315 404 }).onConflictDoNothing()); 316 - 405 + 317 406 // Push other repost times if we have them 318 407 if (repostData) { 319 408 for (var i = 1; i <= repostData.times; ++i) { ··· 326 415 totalRepostCount += repostData.times; 327 416 } 328 417 // Update repost counts 329 - if (hasExistingPost) { 418 + if (existingPost !== null) { 330 419 const newCount = db.$count(reposts, eq(reposts.uuid, postUUID)); 331 420 dbOperations.push(db.update(repostCounts) 332 421 .set({count: newCount}) ··· 337 426 338 427 const batchResponse = await db.batch(dbOperations as BatchQuery); 339 428 const success = batchResponse.every((el) => el.success); 340 - return { ok: success, msg: success ? "success" : "fail" }; 429 + return { ok: success, msg: success ? "success" : "fail", postId: postUUID }; 341 430 }; 342 431 343 432 export const updatePostForUser = async (c: Context, id: string, newData: Object) => { 344 433 const userId = c.get("userId"); 345 - return await updatePostForGivenUser(c, userId, id, newData); 434 + return await updatePostForGivenUser(c.env, userId, id, newData); 346 435 }; 347 436 348 437 export const getPostById = async(c: Context, id: string): Promise<Post|null> => { ··· 352 441 353 442 const env = c.env; 354 443 const db: DrizzleD1Database = drizzle(env.DB); 355 - const result = await db.select().from(posts).where(and(eq(posts.uuid, id), eq(posts.userId, userId))).limit(1).all(); 444 + const result = await db.select().from(posts) 445 + .where(and(eq(posts.uuid, id), eq(posts.userId, userId))) 446 + .limit(1).all(); 447 + 356 448 if (!isEmpty(result)) 357 449 return createPostObject(result[0]); 358 450 return null; 359 451 }; 360 452 453 + export const getPostByCID = async(c: Context, cid: string): Promise<Post|null> => { 454 + const userId = c.get("userId"); 455 + if (!userId) 456 + return null; 457 + 458 + const env = c.env; 459 + const db: DrizzleD1Database = drizzle(env.DB); 460 + const result = await db.select().from(posts) 461 + .where(and(eq(posts.userId, userId), eq(posts.cid, cid))) 462 + .limit(1).all(); 463 + 464 + if (!isEmpty(result)) 465 + return createPostObject(result[0]); 466 + return null; 467 + } 468 + 361 469 // used for post editing, acts very similar to getPostsForUser 362 470 export const getPostByIdWithReposts = async(c: Context, id: string): Promise<Post|null> => { 363 471 const userId = c.get("userId"); ··· 366 474 367 475 const env = c.env; 368 476 const db: DrizzleD1Database = drizzle(env.DB); 369 - const result = await db.select({...getTableColumns(posts), repostCount: repostCounts.count}).from(posts) 370 - .where(and(eq(posts.uuid, id), eq(posts.userId, userId))) 371 - .leftJoin(repostCounts, eq(posts.uuid, repostCounts.uuid)) 372 - .limit(1).all(); 477 + const result = await db.select({ 478 + ...getTableColumns(posts), 479 + repostCount: repostCounts.count, 480 + }).from(posts) 481 + .where(and(eq(posts.uuid, id), eq(posts.userId, userId))) 482 + .leftJoin(repostCounts, eq(posts.uuid, repostCounts.uuid)) 483 + .limit(1).all(); 373 484 374 485 if (!isEmpty(result)) 375 486 return createPostObject(result[0]); 376 - return null; 487 + return null; 377 488 };
+25 -11
src/utils/helpers.ts
··· 11 11 postData.label = data.contentLabel; 12 12 postData.text = data.content; 13 13 postData.postNow = data.postNow; 14 - if (data.repostCount) 14 + postData.threadOrder = data.threadOrder; 15 + 16 + if (has(data, "repostCount")) 15 17 postData.repostCount = data.repostCount; 16 18 17 - if (data.posted) 18 - postData.posted = data.posted; 19 19 if (data.scheduledDate) 20 20 postData.scheduledDate = data.scheduledDate; 21 - 22 - if (data.isRepost) 23 - postData.isRepost = data.isRepost; 24 21 25 22 if (data.repostInfo) 26 23 postData.repostInfo = data.repostInfo; 27 24 28 - if (data.rootPost && data.parentPost) { 25 + if (data.rootPost) 26 + postData.rootPost = data.rootPost; 27 + 28 + if (data.parentPost) { 29 29 postData.parentPost = data.parentPost; 30 - postData.rootPost = data.rootPost; 31 - postData.isThread = true; 30 + postData.isChildPost = true; 32 31 } else { 33 - postData.isThread = false; 34 - } 32 + postData.isChildPost = false; 33 + } 34 + 35 + if (data.threadOrder == 0) 36 + postData.isThreadRoot = true; 37 + else 38 + postData.isThreadRoot = false; 35 39 36 40 // ATProto data 37 41 if (data.uri) 38 42 postData.uri = data.uri; 39 43 if (data.cid) 40 44 postData.cid = data.cid; 45 + 46 + if (has(data, "isRepost")) 47 + postData.isRepost = data.isRepost; 48 + 49 + if (has(data, "posted")) 50 + postData.posted = data.posted; 51 + 52 + // if a cid flag appears for the object and it's a thread root, then the post (if marked not posted) is posted. 53 + if (postData.posted == false && !isEmpty(data.cid) && postData.isThreadRoot) 54 + postData.posted = true; 41 55 42 56 return postData; 43 57 }
+1 -1
src/utils/inviteKeys.ts
··· 48 48 } 49 49 // check the amount we have 50 50 const amount: number = parseInt(value); 51 - 51 + 52 52 // handle NaN 53 53 if (isNaN(amount)) { 54 54 console.warn(`${inviteKey} has the value of ${value} which triggers NaN.`);
+11 -5
src/utils/queuePublisher.ts
··· 16 16 return get(env, queueName, null); 17 17 }; 18 18 19 - export const isQueueEnabled = (env: Bindings) => env.QUEUE_SETTINGS.enabled; 20 - export const shouldPostNowQueue = (env: Bindings) => env.QUEUE_SETTINGS.postNowEnabled || false; 19 + const hasPostQueue = (env: Bindings) => !isEmpty(env.QUEUE_SETTINGS.post_queues) && env.IN_DEV == false; 20 + const hasRepostQueue = (env: Bindings) => !isEmpty(env.QUEUE_SETTINGS.repost_queues) && env.IN_DEV == false; 21 + export const isQueueEnabled = (env: Bindings) => env.QUEUE_SETTINGS.enabled && hasPostQueue(env); 22 + export const isRepostQueueEnabled = (env: Bindings) => env.QUEUE_SETTINGS.repostsEnabled && hasRepostQueue(env); 23 + export const shouldPostNowQueue = (env: Bindings) => env.QUEUE_SETTINGS.postNowEnabled && isQueueEnabled(env); 24 + export const shouldPostThreadQueue = (env: Bindings) => env.QUEUE_SETTINGS.threadEnabled && (hasPostQueue(env) || isQueueEnabled(env)); 21 25 22 26 export async function enqueuePost(env: Bindings, post: Post) { 23 - if (!isQueueEnabled(env)) 27 + if (post.isThreadRoot && !shouldPostThreadQueue(env)) 28 + return; 29 + else if (!isQueueEnabled(env)) 24 30 return; 25 31 26 32 // Pick a random consumer to handle this post ··· 30 36 } 31 37 32 38 export async function enqueueRepost(env: Bindings, post: Repost) { 33 - if (!isQueueEnabled(env)) 39 + if (!isRepostQueueEnabled(env)) 34 40 return; 35 - 41 + 36 42 // Pick a random consumer to handle this repost 37 43 const queueConsumer: Queue|null = getRandomQueue(env, "repost_queues"); 38 44 if (queueConsumer !== null)
+6 -6
src/utils/r2Query.ts
··· 70 70 }); 71 71 if (R2UploadRes) { 72 72 await addFileListing(env, fileName, metaData.user); 73 - return {"success": true, "data": R2UploadRes.key, 74 - "originalName": metaData.name, "fileSize": metaData.size, 73 + return {"success": true, "data": R2UploadRes.key, 74 + "originalName": metaData.name, "fileSize": metaData.size, 75 75 "qualityLevel": metaData.qualityLevel}; 76 76 } else { 77 77 return {"success": false, "error": "unable to push to file storage"}; ··· 140 140 const returnType = response.headers.get("Content-Type") || ""; 141 141 const transformFileSize: number = Number(response.headers.get("Content-Length")) || 0; 142 142 const resizeHadError = resizedHeader === null || resizedHeader.indexOf("err=") !== -1; 143 - 143 + 144 144 if (!resizeHadError && BSKY_IMG_MIME_TYPES.includes(returnType)) { 145 145 console.log(`Attempting quality level ${qualityLevel}% for ${originalName}, size: ${transformFileSize}`); 146 - 147 - // If we make the file size less than the actual limit 146 + 147 + // If we make the file size less than the actual limit 148 148 if (transformFileSize < BSKY_IMG_SIZE_LIMIT && transformFileSize !== 0) { 149 149 console.log(`${originalName}: Quality level ${qualityLevel}% processed, fits correctly with size.`); 150 150 failedToResize = false; ··· 196 196 // Technically this will never hit because it is greater than our own internal limits 197 197 if (file.size > BSKY_VIDEO_SIZE_LIMIT) { 198 198 return {"success": false, "error": `max video size is ${BSKY_VIDEO_SIZE_LIMIT}MB`}; 199 - } 199 + } 200 200 201 201 const fileMetaData: FileMetaData = { 202 202 name: file.name,
+17 -17
src/utils/scheduler.ts
··· 1 + import AtpAgent from '@atproto/api'; 1 2 import isEmpty from 'just-is-empty'; 2 3 import { Bindings, Post, Repost, ScheduledContext } from '../types.d'; 3 4 import { makeAgentForUser, makePost, makeRepost } from './bskyApi'; 4 5 import { pruneBskyPosts } from './bskyPrune'; 6 + import { deleteAllRepostsBeforeCurrentTime, deletePosts, getAllPostsForCurrentTime, getAllRepostsForCurrentTime, purgePostedPosts } from './db/data'; 5 7 import { getAllAbandonedMedia } from './db/file'; 6 - import { enqueuePost, enqueueRepost, isQueueEnabled } from './queuePublisher'; 8 + import { enqueuePost, enqueueRepost, isQueueEnabled, isRepostQueueEnabled, shouldPostThreadQueue } from './queuePublisher'; 7 9 import { deleteFromR2 } from './r2Query'; 8 - import { getAllPostsForCurrentTime, getAllRepostsForCurrentTime, deleteAllRepostsBeforeCurrentTime, purgePostedPosts, deletePosts } from './db/data'; 9 - import AtpAgent from '@atproto/api'; 10 10 11 - export const handlePostTask = async(runtime: ScheduledContext, postData: Post, agent: AtpAgent|null, isQueued: boolean = false) => { 12 - const madePost = await makePost(runtime, postData, isQueued, agent); 11 + export const handlePostTask = async(runtime: ScheduledContext, postData: Post, agent: AtpAgent|null) => { 12 + const madePost = await makePost(runtime, postData, agent); 13 13 if (madePost) { 14 14 console.log(`Made post ${postData.postid} successfully`); 15 15 } else { ··· 31 31 const scheduledPosts: Post[] = await getAllPostsForCurrentTime(env); 32 32 const scheduledReposts: Repost[] = await getAllRepostsForCurrentTime(env); 33 33 const queueEnabled: boolean = isQueueEnabled(env); 34 + const repostQueueEnabled: boolean = isRepostQueueEnabled(env); 35 + const threadQueueEnabled: boolean = shouldPostThreadQueue(env); 34 36 35 37 const runtimeWrapper: ScheduledContext = { 36 38 executionCtx: ctx, ··· 44 46 // TODO: bunching as a part of queues, literally just throw an agent at a queue with instructions and go. 45 47 // this requires queueing to be working properly. 46 48 const AgentList = new Map(); 47 - const usesAgentMap = (env.SITE_SETTINGS.use_agent_map); 48 - 49 + const usesAgentMap: boolean = (env.SITE_SETTINGS.use_agent_map) || false; 50 + 49 51 // Push any posts 50 52 if (!isEmpty(scheduledPosts)) { 51 53 console.log(`handling ${scheduledPosts.length} posts...`); 52 - scheduledPosts.forEach(async (post) => { 53 - if (!queueEnabled) { 54 + for (const post of scheduledPosts) { 55 + if (queueEnabled || (post.isThreadRoot && threadQueueEnabled)) { 56 + await enqueuePost(env, post); 57 + } else { 54 58 let agent = (usesAgentMap) ? AgentList.get(post.user) || null : null; 55 59 if (agent === null) { 56 60 agent = await makeAgentForUser(env, post.user); ··· 58 62 AgentList.set(post.user, agent); 59 63 } 60 64 ctx.waitUntil(handlePostTask(runtimeWrapper, post, agent)); 61 - } else { 62 - await enqueuePost(env, post); 63 65 } 64 - 65 - }); 66 + } 66 67 } else { 67 68 console.log("no posts scheduled for this time"); 68 69 } ··· 70 71 // Push any reposts 71 72 if (!isEmpty(scheduledReposts)) { 72 73 console.log(`handling ${scheduledReposts.length} reposts`); 73 - scheduledReposts.forEach(async (repost) => { 74 - if (!queueEnabled) { 74 + for (const repost of scheduledReposts) { 75 + if (!repostQueueEnabled) { 75 76 let agent = (usesAgentMap) ? AgentList.get(repost.userId) || null : null; 76 77 if (agent === null) { 77 78 agent = await makeAgentForUser(env, repost.userId); ··· 82 83 } else { 83 84 await enqueueRepost(env, repost); 84 85 } 85 - 86 - }); 86 + }; 87 87 ctx.waitUntil(deleteAllRepostsBeforeCurrentTime(env)); 88 88 } else { 89 89 console.log("no reposts scheduled for this time");
+3 -3
src/validation/embedSchema.ts
··· 29 29 30 30 export const LinkEmbedSchema = z.object({ 31 31 /* content is the thumbnail */ 32 - content: z.string().trim().prefault("").refine((value) => { 32 + content: z.string().trim().prefault("").refine((value) => { 33 33 if (isEmpty(value)) 34 34 return true; 35 35 // So the idea here is to try to encode the string into an URL object, and if that fails ··· 46 46 }), 47 47 type: z.literal(EmbedDataType.WebLink), 48 48 title: z.string().trim().default(""), 49 - /* NOTE: uri is the link to the website here, 49 + /* NOTE: uri is the link to the website here, 50 50 content is used as the thumbnail */ 51 51 uri: z.url({ 52 - normalize: true, 52 + normalize: true, 53 53 protocol: /^https?$/, 54 54 hostname: z.regexes.domain, 55 55 error: "provided link is not an URL, please check URL and try again"
+8 -7
src/wrangler.d.ts
··· 1 1 /* eslint-disable */ 2 - // Generated by Wrangler by running `wrangler types src/wrangler.d.ts` (hash: 229f20d528db23afec5b16bd78599e7c) 3 - // Runtime types generated with workerd@1.20260205.0 2024-12-13 nodejs_compat 2 + // Generated by Wrangler by running `wrangler types src/wrangler.d.ts` (hash: 3f068bddc4c3f62a89ba111fafb7b28e) 3 + // Runtime types generated with workerd@1.20260212.0 2024-12-13 nodejs_compat 4 4 declare namespace Cloudflare { 5 5 interface GlobalProps { 6 6 mainModule: typeof import("./index"); ··· 12 12 R2RESIZE: R2Bucket; 13 13 DB: D1Database; 14 14 POST_QUEUE1: Queue; 15 - REPOST_QUEUE: Queue; 16 15 IMAGES: ImagesBinding; 17 16 IMAGE_SETTINGS: {"enabled":true,"steps":[95,85,75],"bucket_url":"https://resize.skyscheduler.work/"}; 18 17 SIGNUP_SETTINGS: {"use_captcha":true,"invite_only":false,"invite_thread":"https://bsky.app/profile/skyscheduler.work/post/3ltsfnzdmkk2l"}; 19 - QUEUE_SETTINGS: {"enabled":false,"postNowEnabled":false,"post_queues":["POST_QUEUE1"],"repost_queues":["REPOST_QUEUE"]}; 18 + QUEUE_SETTINGS: {"enabled":false,"repostsEnabled":false,"postNowEnabled":false,"threadEnabled":true,"delay_val":100,"post_queues":["POST_QUEUE1"],"repost_queues":[]}; 20 19 REDIRECTS: {"contact":"https://bsky.app/profile/skyscheduler.work","tip":"https://ko-fi.com/socksthewolf/tip"}; 21 20 R2_SETTINGS: {"auto_prune":true,"prune_days":3}; 22 21 SITE_SETTINGS: {"use_agent_map":false}; 23 - BETTER_AUTH_SECRET: string; 24 - BETTER_AUTH_URL: string; 25 22 DEFAULT_ADMIN_USER: string; 26 23 DEFAULT_ADMIN_PASS: string; 27 24 DEFAULT_ADMIN_BSKY_PASS: string; 25 + BETTER_AUTH_SECRET: string; 26 + BETTER_AUTH_URL: string; 28 27 TURNSTILE_PUBLIC_KEY: string; 29 28 TURNSTILE_SECRET_KEY: string; 30 29 RESET_BOT_USERNAME: string; 31 30 RESET_BOT_APP_PASS: string; 32 31 RESIZE_SECRET_HEADER: string; 33 - ENCRYPTED_PASS_KEY: string; 34 32 IN_DEV: string; 35 33 } 36 34 } ··· 10308 10306 blob: Blob; 10309 10307 }; 10310 10308 type ConversionResponse = { 10309 + id: string; 10311 10310 name: string; 10312 10311 mimeType: string; 10313 10312 format: 'markdown'; 10314 10313 tokens: number; 10315 10314 data: string; 10316 10315 } | { 10316 + id: string; 10317 10317 name: string; 10318 10318 mimeType: string; 10319 10319 format: 'error'; ··· 10331 10331 images?: EmbeddedImageConversionOptions & { 10332 10332 convertOGImage?: boolean; 10333 10333 }; 10334 + hostname?: string; 10334 10335 }; 10335 10336 docx?: { 10336 10337 images?: EmbeddedImageConversionOptions;
+4 -8
wrangler.toml
··· 29 29 30 30 # temporary storage to get CF Images working, this bucket should be WAF'd so no one but the worker can access it. 31 31 [[r2_buckets]] 32 - binding = "R2RESIZE" 32 + binding = "R2RESIZE" 33 33 bucket_name = "skyscheduler-resize" 34 34 35 35 [triggers] ··· 51 51 [[queues.producers]] 52 52 queue = "skyscheduler-post-queue" 53 53 binding = "POST_QUEUE1" 54 - [[queues.producers]] 55 - queue = "skyscheduler-repost-queue" 56 - binding = "REPOST_QUEUE" 57 54 58 55 [[queues.consumers]] 59 56 queue = "skyscheduler-post-queue" 60 - max_batch_size = 2 61 - [[queues.consumers]] 62 - queue = "skyscheduler-repost-queue" 57 + max_batch_size = 5 58 + max_batch_timeout = 10 63 59 64 60 [images] 65 61 binding = "IMAGES" ··· 78 74 SIGNUP_SETTINGS = {use_captcha=true, invite_only=false, invite_thread="https://bsky.app/profile/skyscheduler.work/post/3ltsfnzdmkk2l"} 79 75 80 76 # queue handling, pushing information. This is experimental. 81 - QUEUE_SETTINGS = {enabled=false, postNowEnabled=false, post_queues=["POST_QUEUE1"], repost_queues=["REPOST_QUEUE"]} 77 + QUEUE_SETTINGS = {enabled=false, repostsEnabled=false, postNowEnabled=false, threadEnabled=true, delay_val=100, post_queues=["POST_QUEUE1"], repost_queues=[]} 82 78 83 79 # redirect links 84 80 REDIRECTS = {contact="https://bsky.app/profile/skyscheduler.work", tip="https://ko-fi.com/socksthewolf/tip"}