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

add special resize operations

+101 -42
-1
assets/css/dashboard.css
··· 1 1 /** SIDEBAR **/ 2 - 3 2 .postItemHeader { 4 3 min-height: 50px; 5 4 button:has(+ button) {
+4
assets/css/stylesheet.css
··· 67 67 [data-tooltip]:before, .pico [data-tooltip]:before { 68 68 white-space: preserve-breaks !important; 69 69 } 70 + 71 + .toastify-avatar { 72 + margin-left: 0px; 73 + }
+1
assets/icons/success.svg
··· 1 + <svg fill="#ffffff" width="24px" height="24px" viewBox="-2.04 -2.04 24.48 24.48" xmlns="http://www.w3.org/2000/svg" class="cf-icon-svg" stroke="#ffffff" stroke-width="0.00020400000000000003" transform="rotate(0)matrix(1, 0, 0, 1, 0, 0)"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.040799999999999996"></g><g id="SVGRepo_iconCarrier"><path d="M16.417 10.283A7.917 7.917 0 1 1 8.5 2.366a7.916 7.916 0 0 1 7.917 7.917zm-4.105-4.498a.791.791 0 0 0-1.082.29l-3.828 6.63-1.733-2.08a.791.791 0 1 0-1.216 1.014l2.459 2.952a.792.792 0 0 0 .608.285.83.83 0 0 0 .068-.003.791.791 0 0 0 .618-.393L12.6 6.866a.791.791 0 0 0-.29-1.081z"></path></g></svg>
+8
assets/icons/warning.svg
··· 1 + <svg width="30px" height="30px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <g id="SVGRepo_bgCarrier" stroke-width="0"></g> 3 + <g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g> 4 + <g id="SVGRepo_iconCarrier"> 5 + <path d="M12 7V13M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> 6 + <circle cx="12" cy="16.5" r="1" fill="#ffffff"></circle> 7 + </g> 8 + </svg>
+12 -3
assets/js/main.js
··· 1 1 /* Functions that can be used anywhere on the website. */ 2 2 function pushToast(msg, isSuccess) { 3 - Toastify({ 3 + var newToast = Toastify({ 4 4 text: msg, 5 + stopOnFocus: false, 6 + ariaLive: true, 7 + avatar: !isSuccess ? "/icons/warning.svg" : "/icons/success.svg", 5 8 duration: !isSuccess ? 10000 : Toastify.defaults.duration, 6 9 style: { 10 + "padding-left": "12px", 7 11 background: isSuccess ? 'green' : 'red' 8 - } 9 - }).showToast(); 12 + }, 13 + close: false, 14 + onClick: () => { 15 + newToast.hideToast(); 16 + }, 17 + }); 18 + newToast.showToast(); 10 19 } 11 20 12 21 function scrollTop() {
+37 -19
assets/js/postHelper.js
··· 10 10 const postFormTitle = document.getElementById('postFormTitle') 11 11 let hasFileLimit = false; 12 12 let fileData = new Map(); 13 + let waitingFiles = 0; 13 14 14 15 /* Sections for handling UI changes and modifications */ 15 16 const sectionRetweet = document.getElementById('section-retweet'); ··· 20 21 function addOnUnloadBlocker() { 21 22 window.onbeforeunload = function() { 22 23 document.querySelectorAll(".fileDel").forEach((el) => {el.click();}); 24 + // only way to get the alert box to not show up 23 25 return undefined; 24 26 } 25 27 } 26 28 function clearOnUnloadBlocker() { 27 29 window.onbeforeunload = null; 30 + } 31 + 32 + function setFileData(fileName, data) { 33 + fileData.set(fileName, data); 34 + --waitingFiles; 28 35 } 29 36 30 37 let fileDropzone = new Dropzone("#fileUploads", { 31 38 url: "/post/upload", 32 39 autoProcessQueue: true, 33 - /* We process this ourselves */ 40 + /* We add remove links ourselves */ 34 41 addRemoveLinks: false, 35 42 maxFiles: FILE_DROP_MAX_FILES, 36 43 dictMaxFilesExceeded: "max files", ··· 80 87 fileDropzone.removeAllFiles(); 81 88 // Clear the file data map 82 89 fileData.clear(); 90 + waitingFiles = 0; 83 91 }); 84 92 85 93 fileDropzone.on("reset", () => { 86 94 hasFileLimit = false; 95 + waitingFiles = 0; 87 96 clearOnUnloadBlocker(); 88 97 showContentLabeler(false); 89 98 setElementVisible(sectionLinkAttach, true); ··· 95 104 pushToast("Maximum number of files reached", false); 96 105 return; 97 106 } 107 + // Increase the number of waiting to be processed files. 108 + ++waitingFiles; 98 109 setElementVisible(sectionLinkAttach, false); 99 110 const buttonHolder = Dropzone.createElement("<fieldset role='group' class='file-item-group'></fieldset>"); 100 111 const removeButton = Dropzone.createElement("<button class='fileDel outline btn-error' disabled><small>Remove file</small></button>"); ··· 148 159 fileDropzone.on("success", function(file, response) { 149 160 const deleteFileOnError = () => { 150 161 const delButton = file.previewElement.querySelectorAll(".fileDel")[0]; 162 + --waitingFiles; 151 163 delButton.setAttribute("bad", true); 152 164 delButton.click(); 153 165 }; 166 + const deleteFileIfLengthOver = (length, max) => { 167 + if (length > max) { 168 + pushToast(`${file.name} is over the max duration by ${(length - max).toFixed(2)} seconds`, false); 169 + deleteFileOnError(); 170 + return true; 171 + } 172 + return false; 173 + } 154 174 // show the labels 155 175 showContentLabeler(true); 156 176 const fileIsImage = imageTypes.includes(file.type); ··· 170 190 videoTag.setAttribute("src", videoObjectURL); 171 191 videoTag.addEventListener("loadeddata", () => { 172 192 const videoDuration = videoTag.duration; 173 - if (videoDuration > MAX_VIDEO_LENGTH) { 174 - pushToast(`${file.name} is too long for bsky by ${(videoDuration - MAX_VIDEO_LENGTH).toFixed(2)} seconds`, false); 175 - deleteFileOnError(); 176 - } else { 177 - fileData.set(file.name, {content: response.data, type: 3, 193 + if (!deleteFileIfLengthOver(videoDuration, MAX_VIDEO_LENGTH)) { 194 + setFileData(file.name, {content: response.data, type: 3, 178 195 height: videoTag.videoHeight, width: videoTag.videoWidth, duration: videoDuration }); 179 196 hasFileLimit = true; 180 197 } ··· 196 213 let duration = 0; 197 214 try { 198 215 for (let i = 0, len = uint8.length; i < len; i++) { 199 - if (uint8[i] == 0x21 200 - && uint8[i + 1] == 0xF9 201 - && uint8[i + 2] == 0x04 202 - && uint8[i + 7] == 0x00) 216 + if (uint8[i] == 0x21 && uint8[i + 1] == 0xF9 217 + && uint8[i + 2] == 0x04 && uint8[i + 7] == 0x00) 203 218 { 204 219 const delay = (uint8[i + 5] << 8) | (uint8[i + 4] & 0xFF) 205 220 duration += delay < 2 ? 10 : delay ··· 224 239 if (videoDuration === null) { 225 240 pushToast(`${file.name} duration could not be processed`, false); 226 241 deleteFileOnError(); 227 - } else if (videoDuration > MAX_VIDEO_LENGTH) { 228 - pushToast(`${file.name} is over the maximum video duration by ${(videoDuration - MAX_VIDEO_LENGTH).toFixed(2)} seconds`, false); 229 - deleteFileOnError(); 230 - } else if (videoDuration >= MAX_GIF_LENGTH) { 231 - pushToast(`${file.name} is over the maximum length for a gif by ${(videoDuration - MAX_GIF_LENGTH).toFixed(2)} seconds`, false); 232 - deleteFileOnError(); 242 + } else if (deleteFileIfLengthOver(videoDuration, MAX_GIF_LENGTH)) { 243 + return; 233 244 } else { 234 - fileData.set(file.name, { content: response.data, type: 3, height: imageHeight, width: imageWidth, duration: videoDuration }); 245 + setFileData(file.name, { content: response.data, type: 3, height: imageHeight, width: imageWidth, duration: videoDuration }); 235 246 hasFileLimit = true; 236 247 } 237 248 }; 238 249 // Force the file to load. 239 250 imgObj.src = gifImgURL; 240 251 } else { 241 - fileData.set(file.name, {content: response.data, type: 1}); 252 + setFileData(file.name, {content: response.data, type: 1}); 242 253 } 243 254 244 255 // Make the buttons pressable ··· 280 291 pushToast(`Error: ${file.name} had an unexpected error`, false); 281 292 } 282 293 294 + // file failed to upload, so drop the value here too 295 + --waitingFiles; 283 296 fileDropzone.removeFile(file); 284 297 if (fileData.length == 0) { 285 298 setElementVisible(sectionLinkAttach, true); ··· 288 301 289 302 fileDropzone.on("uploadprogress", function(file, progress, bytesSent) { 290 303 const progressObject = file.previewElement.querySelector(".dz-upload"); 291 - progressObject.innerHTML = `${progress}%`; 304 + progressObject.innerHTML = `${progress.toFixed(2)}%`; 292 305 if ((progress === 100 || bytesSent == file.size) && progressObject) { 293 306 progressObject.innerHTML = "Processing...please wait. This may take a bit!"; 294 307 } ··· 301 314 // Handle form submission 302 315 postForm.addEventListener('submit', async (e) => { 303 316 e.preventDefault(); 317 + // prevent submissions if we're currently uploading files. 318 + if (waitingFiles > 0) { 319 + pushToast("Files are currently pending upload, please wait...", false); 320 + return; 321 + } 304 322 showPostProgress(true); 305 323 const contentVal = content.value; 306 324 const postNow = postNowCheckbox.checked;
+12 -5
src/layout/makePost.tsx
··· 26 26 {type: "script", href: "/dep/tribute.min.js"} 27 27 ]; 28 28 29 - export function PostCreation() { 29 + export function PostCreation({ctx}: any) { 30 + const maxWidth: number|undefined = ctx.env.IMAGE_SETTINGS.max_width; 30 31 const bskyImageLimits = `Max file size of ${BSKY_IMG_SIZE_LIMIT_IN_MB}MB`; 31 32 return ( 32 33 <section> ··· 63 64 <li><span data-tooltip={BSKY_IMG_FILE_EXTS}>Images</span>: 64 65 <ul> 65 66 <li>must be less than {CF_IMAGES_MAX_DIMENSION}x{CF_IMAGES_MAX_DIMENSION} pixels</li> 66 - <li>must have a file size smaller than {CF_IMAGES_FILE_SIZE_LIMIT_IN_MB}MB ({APP_NAME} will attempt to compress images to fit <span data-tooltip={bskyImageLimits}>BlueSky's requirements</span>)</li> 67 + <li>must have a file size smaller than {CF_IMAGES_FILE_SIZE_LIMIT_IN_MB}MB ({APP_NAME} will attempt to compress images to fit <span data-tooltip={bskyImageLimits}>BlueSky's requirements</span>) 68 + {maxWidth ? 69 + <ol> 70 + <li>images over {BSKY_IMG_SIZE_LIMIT_IN_MB}MB with a width greater than <b>{maxWidth}px</b> will also <u data-tooltip="will preserve aspect ratio">be resized</u> in addition to being compressed</li> 71 + </ol> : null} 72 + </li> 73 + 67 74 <li>thumbnails will only be shown here for images that are smaller than {MAX_THUMBNAIL_SIZE}MB</li> 68 - <li>don't upload and fail, it's recommended to use a lower resolution file instead</li> 75 + <li>if an image fails to upload, you'll need to manually adjust the file to fit it properly</li> 69 76 </ul></li> 70 77 <li><span data-tooltip={BSKY_VIDEO_FILE_EXTS}>Videos</span>: 71 78 <ul> 72 79 <li>must be shorter than {BSKY_VIDEO_MAX_DURATION} minutes</li> 73 80 <li>must be smaller than {R2_FILE_SIZE_LIMIT_IN_MB}MB</li> 74 - <li>will be processed on BSky after they're posted. This may show a temporary "Video not Found"/black screen for a bit after posting.</li> 81 + <li>will be processed on your PDS after they're posted. This may show a temporary <i>"Video not Found"</i> message for a bit after posting.</li> 75 82 </ul></li> 76 83 </ul></small></div> 77 84 </footer> ··· 95 102 <section> 96 103 <article> 97 104 <header>Insert Post/Feed/List Link</header> 98 - <input id="recordBox" placeholder="https://" title="Must be a link to a ATProto powered record" /> 105 + <input id="recordBox" placeholder="https://" title="Must be a link to a ATProto based record" /> 99 106 <small>Posts must be quotable and all record types must exist upon the scheduled time. If it does not exist, it will not be attached to your post.</small> 100 107 </article> 101 108 </section>
+2 -2
src/layout/settings.tsx
··· 38 38 <label> 39 39 BSky App Password: 40 40 <BSkyAppPasswordField /> 41 - <small>If you need to change your bsky application password, you can 42 - <a href="https://bsky.app/settings/app-passwords" target="_blank">get a new one here</a>.</small> 41 + <small>If you need to change your bsky application password, you can&nbsp; 42 + <a href="https://bsky.app/settings/app-passwords" target="_blank">get a new one here</a>.</small> 43 43 </label> 44 44 <label> 45 45 BSky PDS:
+1 -1
src/pages/dashboard.tsx
··· 82 82 <button id="tabone" class="w-half" role="tab" aria-selected="true" aria-controls="postTab">New Post</button> 83 83 <button id="tabtwo" class="w-half" role="tab" aria-controls="repostTab">New Retweet</button> 84 84 <div id="postTab" role="tabpanel" aria-labelledby="tabone" > 85 - <PostCreation /> 85 + <PostCreation ctx={ctx} /> 86 86 </div> 87 87 <div id="repostTab" role="tabpanel" aria-labelledby="tabtwo" hidden> 88 88 <MakeRetweet />
+2 -2
src/types.ts
··· 7 7 enabled: boolean; 8 8 steps?: number[]; 9 9 bucket_url?: string; 10 + max_width?: number; 10 11 }; 11 12 12 13 type SignupConfigSettings = { ··· 169 170 href: string; 170 171 }; 171 172 172 - export type AllContext = Context|ScheduledContext; 173 - 174 173 export type BskyEmbedWrapper = { 175 174 type: EmbedDataType; 176 175 data?: any; ··· 203 202 date: Date 204 203 } 205 204 205 + export type AllContext = Context|ScheduledContext; 206 206 export type BatchQuery = [BatchItem<'sqlite'>, ...BatchItem<'sqlite'>[]];
+1 -1
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.5.3"; 3 + export const CURRENT_SCRIPT_VERSION: string = "1.5.4"; 4 4 5 5 export const getAppScriptStr = (scriptName: string) => `/js/${scriptName}.min.js?v=${CURRENT_SCRIPT_VERSION}`; 6 6
+19 -7
src/utils/r2Query.ts
··· 108 108 let failedToResize = true; 109 109 110 110 if (c.env.IMAGE_SETTINGS.enabled) { 111 + // Randomly generated id to be used during the resize process 111 112 const resizeFilename = uuidv4(); 112 113 const resizeBucketPush = await c.env.R2RESIZE.put(resizeFilename, await file.bytes(), { 113 114 customMetadata: {"user": userId }, ··· 116 117 117 118 if (!resizeBucketPush) { 118 119 console.error(`Failed to push ${file.name} to the resizing bucket`); 119 - return {"success": false, "error": "unable to handle image for resize, please make it smaller"} 120 + return {"success": false, "error": "resize process ran out of disk space, you'll need to resize the image or try again"}; 120 121 } 121 122 122 - // TODO: use the image wrangler binding 123 + // Default image rules for resizing an image 124 + const imageRules: RequestInitCfPropertiesImage = { 125 + fit: "scale-down", 126 + metadata: "copyright", 127 + format: "jpeg", 128 + }; 129 + 130 + // if the application is to also resize the width automatically, do so here. 131 + // this will preserve aspect ratio 132 + if (c.env.IMAGE_SETTINGS.max_width) { 133 + imageRules.width = c.env.IMAGE_SETTINGS.max_width; 134 + } 135 + 123 136 for (var i = 0; i < c.env.IMAGE_SETTINGS.steps.length; ++i) { 124 - const qualityLevel = c.env.IMAGE_SETTINGS.steps[i]; 137 + const qualityLevel: number = c.env.IMAGE_SETTINGS.steps[i]; 125 138 const response = await fetch(new URL(resizeFilename, c.env.IMAGE_SETTINGS.bucket_url!), { 126 139 headers: { 127 140 "x-skyscheduler-helper": c.env.RESIZE_SECRET_HEADER ··· 129 142 cf: { 130 143 image: { 131 144 quality: qualityLevel, 132 - metadata: "copyright", 133 - format: "jpeg" 145 + ...imageRules 134 146 } 135 147 } 136 148 }); ··· 138 150 const resizedHeader = response.headers.get("Cf-Resized"); 139 151 const returnType = response.headers.get("Content-Type") || ""; 140 152 const transformFileSize: number = Number(response.headers.get("Content-Length")) || 0; 141 - const resizeHadError = resizedHeader === null || resizedHeader.indexOf("err=") !== -1; 153 + const resizeHadError: boolean = (resizedHeader === null || resizedHeader.indexOf("err=") !== -1); 142 154 143 155 if (!resizeHadError && BSKY_IMG_MIME_TYPES.includes(returnType)) { 144 156 console.log(`Attempting quality level ${qualityLevel}% for ${originalName}, size: ${transformFileSize}`); ··· 174 186 175 187 if (failedToResize) { 176 188 const fileSizeOverAmount: string = ((file.size - BSKY_IMG_SIZE_LIMIT)/MB_TO_BYTES).toFixed(2); 177 - return {"success": false, "originalName": originalName, "error": `Image is too large for bsky, over by ${fileSizeOverAmount}MB`}; 189 + return {"success": false, "originalName": originalName, "error": `Image is too large for BSky, size is over by ${fileSizeOverAmount}MB`}; 178 190 } 179 191 } 180 192
+2 -1
wrangler.toml
··· 79 79 queue = "skyscheduler-repost-queue" 80 80 max_retries = 3 81 81 82 + # used for resizing the thumbnails for link posts 82 83 [images] 83 84 binding = "IMAGES" 84 85 ··· 90 91 BETTER_AUTH_URL="https://skyscheduler.work" 91 92 92 93 # If we should use cf image transforms & how much of a quality percentage should we try and the public location of the resize bucket. 93 - IMAGE_SETTINGS={enabled=true, steps=[95, 85, 75], bucket_url="https://resize.skyscheduler.work/"} 94 + IMAGE_SETTINGS={enabled=true, steps=[95, 85, 75], bucket_url="https://resize.skyscheduler.work/", max_width=3000} 94 95 95 96 # Signup options and if keys should be used 96 97 SIGNUP_SETTINGS = {use_captcha=true, invite_only=false, invite_thread="", invite_uses=10}