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

Optimize routing

closes #90 and #89

+130 -82
+59
src/endpoints/admin.tsx
··· 1 + import { Hono } from "hono"; 2 + import { secureHeaders } from "hono/secure-headers"; 3 + import { ContextVariables } from "../auth"; 4 + import { authAdminOnlyMiddleware } from "../middleware/adminOnly"; 5 + import { Bindings } from "../types.d"; 6 + import { getAllAbandonedMedia } from "../utils/db/file"; 7 + import { runMaintenanceUpdates } from "../utils/db/maintain"; 8 + import { makeInviteKey } from "../utils/inviteKeys"; 9 + import { corsHelperMiddleware } from "../middleware/corsHelper"; 10 + import { cleanupAbandonedFiles, cleanUpPostsTask, schedulePostTask } from "../utils/scheduler"; 11 + 12 + export const admin = new Hono<{ Bindings: Bindings, Variables: ContextVariables }>(); 13 + 14 + admin.use(secureHeaders()); 15 + admin.use(corsHelperMiddleware); 16 + 17 + // Generate invites route 18 + admin.get("/invite", authAdminOnlyMiddleware, (c) => { 19 + const newKey = makeInviteKey(c); 20 + if (newKey !== null) 21 + return c.text(`${newKey} is good for ${c.env.SIGNUP_SETTINGS.invite_uses} uses`); 22 + else 23 + return c.text("Invite keys are disabled."); 24 + }); 25 + 26 + // Admin Maintenance Cleanup 27 + admin.get("/cron", authAdminOnlyMiddleware, async (c) => { 28 + await schedulePostTask(c); 29 + return c.text("ran"); 30 + }); 31 + 32 + admin.get("/cron-clean", authAdminOnlyMiddleware, (c) => { 33 + c.executionCtx.waitUntil(cleanUpPostsTask(c)); 34 + return c.text("ran"); 35 + }); 36 + 37 + admin.get("/db-update", authAdminOnlyMiddleware, (c) => { 38 + c.executionCtx.waitUntil(runMaintenanceUpdates(c)); 39 + return c.text("ran"); 40 + }); 41 + 42 + admin.get("/abandoned", authAdminOnlyMiddleware, async (c) => { 43 + let returnHTML = ""; 44 + const abandonedFiles: string[] = await getAllAbandonedMedia(c); 45 + // print out all abandoned files 46 + for (const file of abandonedFiles) { 47 + returnHTML += `${file}\n`; 48 + } 49 + if (c.env.R2_SETTINGS.auto_prune == true) { 50 + console.log("pruning abandoned files..."); 51 + await cleanupAbandonedFiles(c); 52 + } 53 + 54 + if (returnHTML.length == 0) { 55 + returnHTML = "no files abandoned"; 56 + } 57 + 58 + return c.text(returnHTML); 59 + });
+28 -69
src/index.tsx
··· 2 2 import { Env, Hono } from "hono"; 3 3 import { ContextVariables, createAuth } from "./auth"; 4 4 import { account } from "./endpoints/account"; 5 + import { admin } from "./endpoints/admin"; 5 6 import { post } from "./endpoints/post"; 6 7 import { preview } from "./endpoints/preview"; 7 - import { authAdminOnlyMiddleware } from "./middleware/adminOnly"; 8 8 import { authMiddleware } from "./middleware/auth"; 9 9 import { corsHelperMiddleware } from "./middleware/corsHelper"; 10 10 import { redirectToDashIfLogin } from "./middleware/redirectDash"; ··· 19 19 import { Bindings, QueueTaskData, ScheduledContext, TaskType } from "./types.d"; 20 20 import { AgentMap } from "./utils/bskyAgents"; 21 21 import { makeConstScript } from "./utils/constScriptGen"; 22 - import { getAllAbandonedMedia } from "./utils/db/file"; 23 - import { runMaintenanceUpdates } from "./utils/db/maintain"; 24 - import { makeInviteKey } from "./utils/inviteKeys"; 25 22 import { 26 - cleanupAbandonedFiles, cleanUpPostsTask, handlePostTask, 23 + cleanUpPostsTask, handlePostTask, 27 24 handleRepostTask, schedulePostTask 28 25 } from "./utils/scheduler"; 29 26 import { setupAccounts } from "./utils/setup"; 30 27 31 28 const app = new Hono<{ Bindings: Bindings, Variables: ContextVariables }>(); 32 29 30 + ///// Static Pages ///// 31 + 32 + // Root route 33 + app.all("/", (c) => c.html(<Home />)); 34 + 35 + // JS injection of const variables 36 + app.get("/js/consts.js", (c) => { 37 + const constScript = makeConstScript(); 38 + return c.body(constScript, 200, { 39 + 'Content-Type': 'text/javascript', 40 + 'Cache-Control': 'max-age=604800' 41 + }); 42 + }); 43 + 44 + // Add redirects 45 + app.all("/contact", (c) => c.redirect(c.env.REDIRECTS.contact)); 46 + app.all("/tip", (c) => c.redirect(c.env.REDIRECTS.tip)); 47 + 48 + // Legal linkies 49 + app.get("/tos", (c) => c.html(<TermsOfService />)); 50 + app.get("/privacy", (c) => c.html(<PrivacyPolicy />)); 51 + 33 52 ///// Inline Middleware ///// 34 53 // CORS configuration for auth routes 35 54 app.use("/api/auth/**", corsHelperMiddleware); ··· 56 75 app.use("/post/**", corsHelperMiddleware); 57 76 app.route("/post", post); 58 77 78 + // Admin endpoints 79 + app.use("/admin/**", corsHelperMiddleware); 80 + app.route("/admin", admin); 81 + 59 82 // Image preview endpoint 60 83 app.use("/preview/**", corsHelperMiddleware); 61 84 app.route("/preview", preview); 62 85 63 - // Root route 64 - app.all("/", (c) => c.html(<Home />)); 65 - 66 - // JS injection of const variables 67 - app.get("/js/consts.js", (c) => { 68 - const constScript = makeConstScript(); 69 - return c.body(constScript, 200, { 70 - 'Content-Type': 'text/javascript', 71 - 'Cache-Control': 'max-age=604800' 72 - }); 73 - }); 74 - 75 - // Add redirects 76 - app.all("/contact", (c) => c.redirect(c.env.REDIRECTS.contact)); 77 - app.all("/tip", (c) => c.redirect(c.env.REDIRECTS.tip)); 78 - 79 - // Legal linkies 80 - app.get("/tos", (c) => c.html(<TermsOfService />)); 81 - app.get("/privacy", (c) => c.html(<PrivacyPolicy />)); 82 - 83 86 // Dashboard route 84 87 app.get("/dashboard", authMiddleware, (c) => c.html(<Dashboard c={c} />)); 85 88 ··· 94 97 95 98 // Reset Password route 96 99 app.get("/reset", redirectToDashIfLogin, (c) => c.html(<ResetPassword />)); 97 - 98 - // Generate invites route 99 - app.get("/invite", authAdminOnlyMiddleware, (c) => { 100 - const newKey = makeInviteKey(c); 101 - if (newKey !== null) 102 - return c.text(`${newKey} is good for 10 uses`); 103 - else 104 - return c.text("Invite keys are disabled."); 105 - }); 106 - 107 - // Admin Maintenance Cleanup 108 - app.get("/cron", authAdminOnlyMiddleware, async (c) => { 109 - await schedulePostTask(c); 110 - return c.text("ran"); 111 - }); 112 - 113 - app.get("/cron-clean", authAdminOnlyMiddleware, (c) => { 114 - c.executionCtx.waitUntil(cleanUpPostsTask(c)); 115 - return c.text("ran"); 116 - }); 117 - 118 - app.get("/db-update", authAdminOnlyMiddleware, (c) => { 119 - c.executionCtx.waitUntil(runMaintenanceUpdates(c)); 120 - return c.text("ran"); 121 - }); 122 - 123 - app.get("/abandoned", authAdminOnlyMiddleware, async (c) => { 124 - let returnHTML = ""; 125 - const abandonedFiles: string[] = await getAllAbandonedMedia(c); 126 - // print out all abandoned files 127 - for (const file of abandonedFiles) { 128 - returnHTML += `${file}\n`; 129 - } 130 - if (c.env.R2_SETTINGS.auto_prune == true) { 131 - console.log("pruning abandoned files..."); 132 - await cleanupAbandonedFiles(c); 133 - } 134 - 135 - if (returnHTML.length == 0) { 136 - returnHTML = "no files abandoned"; 137 - } 138 - 139 - return c.text(returnHTML); 140 - }); 141 100 142 101 // Startup Application 143 102 app.get("/start", (c) => c.redirect('/setup'));
+1
src/types.d.ts
··· 13 13 use_captcha: boolean; 14 14 invite_only: boolean; 15 15 invite_thread?: string; 16 + invite_uses: number; 16 17 } 17 18 18 19 type RedirectConfigSettings = {
+5 -1
src/utils/inviteKeys.ts
··· 76 76 return null; 77 77 } 78 78 79 + const usages: number = c.env.SIGNUP_SETTINGS.invite_uses; 80 + if (usages === undefined) 81 + return null; 82 + 79 83 const newKey: string = humanId({ 80 84 separator: '-', 81 85 capitalize: false, 82 86 }); 83 - c.executionCtx.waitUntil(c.env.INVITE_POOL!.put(newKey, "10")); 87 + c.executionCtx.waitUntil(c.env.INVITE_POOL!.put(newKey, usages)); 84 88 return newKey; 85 89 }
+34 -9
src/wrangler.d.ts
··· 1 1 /* eslint-disable */ 2 - // Generated by Wrangler by running `wrangler types src/wrangler.d.ts` (hash: 55b4346bed1553dbff2b8c9d511b4b56) 3 - // Runtime types generated with workerd@1.20260212.0 2024-12-13 nodejs_compat 2 + // Generated by Wrangler by running `wrangler types src/wrangler.d.ts` (hash: ef02624a1e49d0409702d04e46795d86) 3 + // Runtime types generated with workerd@1.20260212.0 2025-11-18 disable_ctx_exports,disable_nodejs_http_server_modules,nodejs_compat,nodejs_compat_do_not_populate_process_env 4 4 declare namespace Cloudflare { 5 5 interface GlobalProps { 6 6 mainModule: typeof import("./index"); ··· 11 11 DB: D1Database; 12 12 IMAGES: ImagesBinding; 13 13 IMAGE_SETTINGS: {"enabled":false}; 14 - SIGNUP_SETTINGS: {"use_captcha":false,"invite_only":false,"invite_thread":""}; 14 + SIGNUP_SETTINGS: {"use_captcha":false,"invite_only":false,"invite_thread":"","invite_uses":10}; 15 15 QUEUE_SETTINGS: {"enabled":false,"repostsEnabled":false,"postNowEnabled":false,"threadEnabled":true,"delay_val":100,"post_queues":["POST_QUEUE"],"repost_queues":[]}; 16 16 REDIRECTS: {"contact":"https://bsky.app/profile/skyscheduler.work","tip":"https://ko-fi.com/socksthewolf/tip"}; 17 17 R2_SETTINGS: {"auto_prune":false,"prune_days":3}; 18 - TASK_SETTINGS: {"use_posts":false,"use_reposts":false}; 18 + TASK_SETTINGS: {"use_posts":true,"use_reposts":true}; 19 19 BETTER_AUTH_SECRET: string; 20 20 BETTER_AUTH_URL: string; 21 21 DEFAULT_ADMIN_USER: string; ··· 45 45 DB: D1Database; 46 46 IMAGES: ImagesBinding; 47 47 IMAGE_SETTINGS: {"enabled":false} | {"enabled":true,"steps":[95,85,75],"bucket_url":"https://resize.skyscheduler.work/"}; 48 - SIGNUP_SETTINGS: {"use_captcha":false,"invite_only":false,"invite_thread":""} | {"use_captcha":true,"invite_only":false,"invite_thread":""}; 48 + SIGNUP_SETTINGS: {"use_captcha":false,"invite_only":false,"invite_thread":"","invite_uses":10} | {"use_captcha":true,"invite_only":false,"invite_thread":"","invite_uses":10}; 49 49 QUEUE_SETTINGS: {"enabled":false,"repostsEnabled":false,"postNowEnabled":false,"threadEnabled":true,"delay_val":100,"post_queues":["POST_QUEUE"],"repost_queues":[]} | {"enabled":true,"repostsEnabled":true,"postNowEnabled":false,"threadEnabled":true,"delay_val":100,"post_queues":["POST_QUEUE"],"repost_queues":["REPOST_QUEUE"]}; 50 50 REDIRECTS: {"contact":"https://bsky.app/profile/skyscheduler.work","tip":"https://ko-fi.com/socksthewolf/tip"}; 51 51 R2_SETTINGS: {"auto_prune":false,"prune_days":3} | {"auto_prune":true,"prune_days":3}; 52 - TASK_SETTINGS: {"use_posts":false,"use_reposts":false}; 52 + TASK_SETTINGS: {"use_posts":true,"use_reposts":true}; 53 53 INVITE_POOL?: KVNamespace; 54 54 R2RESIZE?: R2Bucket; 55 55 DBStaging?: D1Database; ··· 375 375 ByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy; 376 376 CountQueuingStrategy: typeof CountQueuingStrategy; 377 377 ErrorEvent: typeof ErrorEvent; 378 + MessageChannel: typeof MessageChannel; 379 + MessagePort: typeof MessagePort; 378 380 EventSource: typeof EventSource; 379 381 ReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest; 380 382 ReadableStreamDefaultController: typeof ReadableStreamDefaultController; ··· 503 505 sendBeacon(url: string, body?: BodyInit): boolean; 504 506 readonly userAgent: string; 505 507 readonly hardwareConcurrency: number; 508 + readonly language: string; 509 + readonly languages: string[]; 506 510 } 507 511 interface AlarmInvocationInfo { 508 512 readonly isRetry: boolean; ··· 1822 1826 * 1823 1827 * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache) 1824 1828 */ 1825 - cache?: "no-store"; 1829 + cache?: "no-store" | "no-cache"; 1826 1830 } 1827 1831 interface RequestInit<Cf = CfProperties> { 1828 1832 /* A string to set request's method. */ ··· 1836 1840 fetcher?: (Fetcher | null); 1837 1841 cf?: Cf; 1838 1842 /* A string indicating how the request will interact with the browser's cache to set request's cache. */ 1839 - cache?: "no-store"; 1843 + cache?: "no-store" | "no-cache"; 1840 1844 /* A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */ 1841 1845 integrity?: string; 1842 1846 /* An AbortSignal to set request's signal. */ ··· 2994 2998 get pathname(): string; 2995 2999 get search(): string; 2996 3000 get hash(): string; 3001 + get hasRegExpGroups(): boolean; 2997 3002 test(input?: (string | URLPatternInit), baseURL?: string): boolean; 2998 3003 exec(input?: (string | URLPatternInit), baseURL?: string): URLPatternResult | null; 2999 3004 } ··· 3255 3260 * 3256 3261 * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort) 3257 3262 */ 3258 - interface MessagePort extends EventTarget { 3263 + declare abstract class MessagePort extends EventTarget { 3259 3264 /** 3260 3265 * The **`postMessage()`** method of the transfers ownership of objects to other browsing contexts. 3261 3266 * ··· 3276 3281 start(): void; 3277 3282 get onmessage(): any | null; 3278 3283 set onmessage(value: any | null); 3284 + } 3285 + /** 3286 + * The **`MessageChannel`** interface of the Channel Messaging API allows us to create a new message channel and send data through it via its two MessagePort properties. 3287 + * 3288 + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel) 3289 + */ 3290 + declare class MessageChannel { 3291 + constructor(); 3292 + /** 3293 + * The **`port1`** read-only property of the the port attached to the context that originated the channel. 3294 + * 3295 + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port1) 3296 + */ 3297 + readonly port1: MessagePort; 3298 + /** 3299 + * The **`port2`** read-only property of the the port attached to the context at the other end of the channel, which the message is initially sent to. 3300 + * 3301 + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port2) 3302 + */ 3303 + readonly port2: MessagePort; 3279 3304 } 3280 3305 interface MessagePortPostMessageOptions { 3281 3306 transfer?: any[];
+3 -3
wrangler.toml
··· 71 71 72 72 [[queues.consumers]] 73 73 queue = "skyscheduler-post-queue" 74 - max_batch_size = 5 74 + max_batch_size = 3 75 75 max_batch_timeout = 5 76 76 max_retries = 3 77 77 ··· 93 93 IMAGE_SETTINGS={enabled=true, steps=[95, 85, 75], bucket_url="https://resize.skyscheduler.work/"} 94 94 95 95 # Signup options and if keys should be used 96 - SIGNUP_SETTINGS = {use_captcha=true, invite_only=false, invite_thread=""} 96 + SIGNUP_SETTINGS = {use_captcha=true, invite_only=false, invite_thread="", invite_uses=10} 97 97 98 98 # queue handling, pushing information. 99 99 QUEUE_SETTINGS = {enabled=true, repostsEnabled=true, postNowEnabled=false, threadEnabled=true, delay_val=100, post_queues=["POST_QUEUE"], repost_queues=["REPOST_QUEUE"]} ··· 116 116 [env.staging.vars] 117 117 BETTER_AUTH_URL="*" 118 118 IMAGE_SETTINGS={enabled=false} 119 - SIGNUP_SETTINGS = {use_captcha=false, invite_only=false, invite_thread=""} 119 + SIGNUP_SETTINGS = {use_captcha=false, invite_only=false, invite_thread="", invite_uses=10} 120 120 QUEUE_SETTINGS = {enabled=false, repostsEnabled=false, postNowEnabled=false, threadEnabled=true, delay_val=100, post_queues=["POST_QUEUE"], repost_queues=[]} 121 121 REDIRECTS = {contact="https://bsky.app/profile/skyscheduler.work", tip="https://ko-fi.com/socksthewolf/tip"} 122 122 R2_SETTINGS={auto_prune=false, prune_days=3}