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

Improve Drizzle DB performance

Closes #86

+454 -241
+7 -3
package.json
··· 3 "scripts": { 4 "dev": "wrangler dev --live-reload", 5 "dev:queue": "wrangler dev -c wrangler.toml --persist-to .wrangler/state --live-reload", 6 - "deploy": "wrangler deploy --minify", 7 "auth": "npm run auth:generate && npm run auth:format", 8 "auth:generate": "npx @better-auth/cli@latest generate --config src/auth/index.ts --output src/db/auth.schema.ts -y", 9 "auth:format": "prettier --write src/db/auth.schema.ts", ··· 14 "migrate:prod:db": "wrangler d1 migrations apply skyposts --remote", 15 "migrate:prod:pragma": "wrangler d1 execute skyposts --command \"PRAGMA optimize\" --remote -y", 16 "migrate:prod": "run-s migrate:prod:*", 17 - "migrate:remote": "npm run migrate:prod", 18 - "migrate:optimize": "npm run migrate:prod:pragma && npm run migrate:local:pragma", 19 "migrate:all": "npm run migrate:local && npm run migrate:prod", 20 "minify:repost": "minify assets/js/repostHelper.js > assets/js/repostHelper.min.js", 21 "minify:post": "minify assets/js/postHelper.js > assets/js/postHelper.min.js",
··· 3 "scripts": { 4 "dev": "wrangler dev --live-reload", 5 "dev:queue": "wrangler dev -c wrangler.toml --persist-to .wrangler/state --live-reload", 6 + "deploy": "wrangler deploy --minify --env=''", 7 + "staging": "wrangler deploy --env staging --minify", 8 "auth": "npm run auth:generate && npm run auth:format", 9 "auth:generate": "npx @better-auth/cli@latest generate --config src/auth/index.ts --output src/db/auth.schema.ts -y", 10 "auth:format": "prettier --write src/db/auth.schema.ts", ··· 15 "migrate:prod:db": "wrangler d1 migrations apply skyposts --remote", 16 "migrate:prod:pragma": "wrangler d1 execute skyposts --command \"PRAGMA optimize\" --remote -y", 17 "migrate:prod": "run-s migrate:prod:*", 18 + "migrate:staging:db": "wrangler d1 migrations apply skyposts-dev --remote", 19 + "migrate:staging:pragma": "wrangler d1 execute skyposts-dev --command \"PRAGMA optimize\" --remote -y", 20 + "migrate:staging": "run-s migrate:staging:*", 21 + "migrate:remote": "npm run migrate:prod && npm run migrate:staging", 22 + "migrate:optimize": "npm run migrate:prod:pragma && npm run migrate:staging:pragma && npm run migrate:local:pragma", 23 "migrate:all": "npm run migrate:local && npm run migrate:prod", 24 "minify:repost": "minify assets/js/repostHelper.js > assets/js/repostHelper.min.js", 25 "minify:post": "minify assets/js/postHelper.js > assets/js/postHelper.min.js",
+2 -1
src/auth/index.ts
··· 2 import { withCloudflare } from "better-auth-cloudflare"; 3 import { drizzleAdapter } from "better-auth/adapters/drizzle"; 4 import { username } from "better-auth/plugins"; 5 - import { drizzle } from "drizzle-orm/d1"; 6 import { schema } from "../db"; 7 import { BSKY_MAX_USERNAME_LENGTH, BSKY_MIN_USERNAME_LENGTH } from "../limits"; 8 import { Bindings } from "../types.d"; ··· 160 userId: string; 161 isAdmin: boolean; 162 session: Session; 163 pds: string; 164 }; 165
··· 2 import { withCloudflare } from "better-auth-cloudflare"; 3 import { drizzleAdapter } from "better-auth/adapters/drizzle"; 4 import { username } from "better-auth/plugins"; 5 + import { drizzle, DrizzleD1Database } from "drizzle-orm/d1"; 6 import { schema } from "../db"; 7 import { BSKY_MAX_USERNAME_LENGTH, BSKY_MIN_USERNAME_LENGTH } from "../limits"; 8 import { Bindings } from "../types.d"; ··· 160 userId: string; 161 isAdmin: boolean; 162 session: Session; 163 + db: DrizzleD1Database; 164 pds: string; 165 }; 166
+3 -3
src/endpoints/account.tsx
··· 163 } 164 165 // Check if the user has violated TOS. 166 - if (await userHasBan(c.env, profileDID)) { 167 return c.json({ok: false, message: "your account has been forbidden from using this service"}, 400); 168 } 169 ··· 214 return c.json({ok: false, message: "user doesn't exist"}, 401); 215 } 216 217 - const userEmail = await getUserEmailForHandle(c.env, username); 218 if (isEmpty(userEmail)) { 219 return c.json({ok: false, message: "user data is missing"}, 401); 220 } ··· 305 password: password 306 }); 307 if (verify) { 308 - c.executionCtx.waitUntil(getAllMediaOfUser(c.env, userId) 309 .then((media) => deleteFromR2(c, media)) 310 .then(() => authCtx.internalAdapter.deleteSessions(userId)) 311 .then(() => authCtx.internalAdapter.deleteUser(userId)));
··· 163 } 164 165 // Check if the user has violated TOS. 166 + if (await userHasBan(c, profileDID)) { 167 return c.json({ok: false, message: "your account has been forbidden from using this service"}, 400); 168 } 169 ··· 214 return c.json({ok: false, message: "user doesn't exist"}, 401); 215 } 216 217 + const userEmail = await getUserEmailForHandle(c, username); 218 if (isEmpty(userEmail)) { 219 return c.json({ok: false, message: "user data is missing"}, 401); 220 } ··· 305 password: password 306 }); 307 if (verify) { 308 + c.executionCtx.waitUntil(getAllMediaOfUser(c, userId) 309 .then((media) => deleteFromR2(c, media)) 310 .then(() => authCtx.internalAdapter.deleteSessions(userId)) 311 .then(() => authCtx.internalAdapter.deleteUser(userId)));
+13 -14
src/index.tsx
··· 1 import { Env, Hono } from "hono"; 2 import { ContextVariables, createAuth } from "./auth"; 3 import { account } from "./endpoints/account"; ··· 36 app.use("*", async (c, next) => { 37 const auth = createAuth(c.env, (c.req.raw as any).cf || {}); 38 c.set("auth", auth); 39 await next(); 40 }); 41 ··· 103 104 // Admin Maintenance Cleanup 105 app.get("/cron", authAdminOnlyMiddleware, async (c) => { 106 - await schedulePostTask(c.env, c.executionCtx); 107 return c.text("ran"); 108 }); 109 110 app.get("/cron-clean", authAdminOnlyMiddleware, (c) => { 111 - c.executionCtx.waitUntil(cleanUpPostsTask(c.env, c.executionCtx)); 112 return c.text("ran"); 113 }); 114 115 app.get("/db-update", authAdminOnlyMiddleware, (c) => { 116 - c.executionCtx.waitUntil(runMaintenanceUpdates(c.env)); 117 return c.text("ran"); 118 }); 119 120 app.get("/abandoned", authAdminOnlyMiddleware, async (c) => { 121 let returnHTML = ""; 122 - const abandonedFiles: string[] = await getAllAbandonedMedia(c.env); 123 // print out all abandoned files 124 for (const file of abandonedFiles) { 125 returnHTML += `${file}\n`; 126 } 127 if (c.env.R2_SETTINGS.auto_prune == true) { 128 console.log("pruning abandoned files..."); 129 - await cleanupAbandonedFiles(c.env, c.executionCtx); 130 } 131 132 if (returnHTML.length == 0) { ··· 143 144 export default { 145 scheduled(event: ScheduledEvent, env: Bindings, ctx: ExecutionContext) { 146 switch (event.cron) { 147 case "30 17 * * sun": 148 - ctx.waitUntil(cleanUpPostsTask(env, ctx)); 149 break; 150 default: 151 case "0 * * * *": 152 - ctx.waitUntil(schedulePostTask(env, ctx)); 153 break; 154 } 155 }, 156 - async queue(batch: MessageBatch<QueueTaskData>, environment: Bindings, ctx: ExecutionContext) { 157 - const runtimeWrapper: ScheduledContext = { 158 - executionCtx: ctx, 159 - env: environment 160 - }; 161 - 162 - const delay: number = environment.QUEUE_SETTINGS.delay_val; 163 let wasSuccess: boolean = false; 164 for (const message of batch.messages) { 165 switch (message.body.type) {
··· 1 + import { drizzle } from "drizzle-orm/d1"; 2 import { Env, Hono } from "hono"; 3 import { ContextVariables, createAuth } from "./auth"; 4 import { account } from "./endpoints/account"; ··· 37 app.use("*", async (c, next) => { 38 const auth = createAuth(c.env, (c.req.raw as any).cf || {}); 39 c.set("auth", auth); 40 + c.set("db", drizzle(c.env.DB)); 41 await next(); 42 }); 43 ··· 105 106 // Admin Maintenance Cleanup 107 app.get("/cron", authAdminOnlyMiddleware, async (c) => { 108 + await schedulePostTask(c); 109 return c.text("ran"); 110 }); 111 112 app.get("/cron-clean", authAdminOnlyMiddleware, (c) => { 113 + c.executionCtx.waitUntil(cleanUpPostsTask(c)); 114 return c.text("ran"); 115 }); 116 117 app.get("/db-update", authAdminOnlyMiddleware, (c) => { 118 + c.executionCtx.waitUntil(runMaintenanceUpdates(c)); 119 return c.text("ran"); 120 }); 121 122 app.get("/abandoned", authAdminOnlyMiddleware, async (c) => { 123 let returnHTML = ""; 124 + const abandonedFiles: string[] = await getAllAbandonedMedia(c); 125 // print out all abandoned files 126 for (const file of abandonedFiles) { 127 returnHTML += `${file}\n`; 128 } 129 if (c.env.R2_SETTINGS.auto_prune == true) { 130 console.log("pruning abandoned files..."); 131 + await cleanupAbandonedFiles(c); 132 } 133 134 if (returnHTML.length == 0) { ··· 145 146 export default { 147 scheduled(event: ScheduledEvent, env: Bindings, ctx: ExecutionContext) { 148 + const runtimeWrapper = new ScheduledContext(env, ctx); 149 switch (event.cron) { 150 case "30 17 * * sun": 151 + ctx.waitUntil(cleanUpPostsTask(runtimeWrapper)); 152 break; 153 default: 154 case "0 * * * *": 155 + ctx.waitUntil(schedulePostTask(runtimeWrapper)); 156 break; 157 } 158 }, 159 + async queue(batch: MessageBatch<QueueTaskData>, env: Bindings, ctx: ExecutionContext) { 160 + const runtimeWrapper = new ScheduledContext(env, ctx); 161 + const delay: number = env.QUEUE_SETTINGS.delay_val; 162 let wasSuccess: boolean = false; 163 for (const message of batch.messages) { 164 switch (message.body.type) {
+5 -1
src/layout/violationsBar.tsx
··· 1 import { Context } from "hono"; 2 import { getViolationsForCurrentUser } from "../utils/db/violations"; 3 4 - export async function ViolationNoticeBar(props: any) { 5 const ctx: Context = props.ctx; 6 const violationData = await getViolationsForCurrentUser(ctx); 7 if (violationData !== null) {
··· 1 import { Context } from "hono"; 2 import { getViolationsForCurrentUser } from "../utils/db/violations"; 3 4 + type ViolationNoticeProps = { 5 + ctx: Context; 6 + } 7 + 8 + export async function ViolationNoticeBar(props: ViolationNoticeProps) { 9 const ctx: Context = props.ctx; 10 const violationData = await getViolationsForCurrentUser(ctx); 11 if (violationData !== null) {
+6 -3
src/pages/dashboard.tsx
··· 1 import { Context } from "hono"; 2 import { AltTextDialog } from "../layout/altTextModal"; 3 - import { IncludeDependencyTags, ScriptTags } from "../layout/helpers/includesTags"; 4 import FooterCopyright from "../layout/helpers/footer"; 5 import { BaseLayout } from "../layout/main"; 6 import { PostCreation, PreloadPostCreation } from "../layout/makePost"; 7 import { MakeRetweet } from "../layout/makeRetweet"; 8 import { ScheduledPostList } from "../layout/postList"; 9 import { Settings, SettingsButton } from "../layout/settings"; 10 import { ViolationNoticeBar } from "../layout/violationsBar"; 11 import { PreloadRules } from "../types.d"; 12 - import { altTextScriptStr, appScriptStr, appScriptStrs, postHelperScriptStr, repostHelperScriptStr, tributeScriptStr } from "../utils/appScripts"; 13 - import { SHOW_PROGRESS_BAR } from "../progress"; 14 15 export default function Dashboard(props:any) { 16 const ctx: Context = props.c;
··· 1 import { Context } from "hono"; 2 import { AltTextDialog } from "../layout/altTextModal"; 3 import FooterCopyright from "../layout/helpers/footer"; 4 + import { IncludeDependencyTags, ScriptTags } from "../layout/helpers/includesTags"; 5 import { BaseLayout } from "../layout/main"; 6 import { PostCreation, PreloadPostCreation } from "../layout/makePost"; 7 import { MakeRetweet } from "../layout/makeRetweet"; 8 import { ScheduledPostList } from "../layout/postList"; 9 import { Settings, SettingsButton } from "../layout/settings"; 10 import { ViolationNoticeBar } from "../layout/violationsBar"; 11 + import { SHOW_PROGRESS_BAR } from "../progress"; 12 import { PreloadRules } from "../types.d"; 13 + import { 14 + altTextScriptStr, appScriptStr, appScriptStrs, 15 + postHelperScriptStr, repostHelperScriptStr, tributeScriptStr 16 + } from "../utils/appScripts"; 17 18 export default function Dashboard(props:any) { 19 const ctx: Context = props.c;
+21 -4
src/types.d.ts
··· 4 /*** Settings config wrappers for bindings ***/ 5 type ImageConfigSettings = { 6 enabled: boolean; 7 - steps: number[]; 8 - bucket_url: string; 9 }; 10 11 type SignupConfigSettings = { ··· 47 IMAGES: ImagesBinding; 48 POST_QUEUE1: Queue; 49 QUEUE_SETTINGS: QueueConfigSettings; 50 - INVITE_POOL: KVNamespace; 51 IMAGE_SETTINGS: ImageConfigSettings; 52 SIGNUP_SETTINGS: SignupConfigSettings; 53 SITE_SETTINGS: SiteConfigSettings; ··· 215 href: string; 216 }; 217 218 - export type ScheduledContext = { 219 executionCtx: ExecutionContext; 220 env: Bindings; 221 }; 222 223 export type BskyEmbedWrapper = { 224 type: EmbedDataType;
··· 4 /*** Settings config wrappers for bindings ***/ 5 type ImageConfigSettings = { 6 enabled: boolean; 7 + steps?: number[]; 8 + bucket_url?: string; 9 }; 10 11 type SignupConfigSettings = { ··· 47 IMAGES: ImagesBinding; 48 POST_QUEUE1: Queue; 49 QUEUE_SETTINGS: QueueConfigSettings; 50 + INVITE_POOL?: KVNamespace; 51 IMAGE_SETTINGS: ImageConfigSettings; 52 SIGNUP_SETTINGS: SignupConfigSettings; 53 SITE_SETTINGS: SiteConfigSettings; ··· 215 href: string; 216 }; 217 218 + export class ScheduledContext { 219 executionCtx: ExecutionContext; 220 env: Bindings; 221 + #map: Map<string, any>; 222 + constructor(env: Bindings, executionCtx: ExecutionContext) { 223 + this.#map = new Map<string, any>(); 224 + this.env = env; 225 + this.executionCtx = executionCtx; 226 + this.set("db", drizzle(env.DB)); 227 + } 228 + get(name: string) { 229 + if (this.#map.has(name)) 230 + return this.#map.get(name); 231 + return null; 232 + } 233 + set(name: string, value: any) { 234 + this.#map.set(name, value); 235 + } 236 }; 237 + 238 + export type AllContext = Context|ScheduledContext; 239 240 export type BskyEmbedWrapper = { 241 type: EmbedDataType;
+19 -20
src/utils/bskyApi.ts
··· 9 import { 10 Bindings, BskyEmbedWrapper, BskyRecordWrapper, EmbedData, EmbedDataType, 11 LooseObj, Post, PostLabel, AccountStatus, 12 - PostRecordResponse, PostStatus, Repost, ScheduledContext 13 } from '../types.d'; 14 import { atpRecordURI } from '../validation/regexCases'; 15 import { bulkUpdatePostedData, getChildPostsOfThread, isPostAlreadyPosted, setPostNowOffForPost } from './db/data'; ··· 104 return AccountStatus.UnhandledError; 105 } 106 107 - export const makeAgentForUser = async (env: Bindings, userId: string) => { 108 - const loginCreds = await getBskyUserPassForId(env, userId); 109 if (loginCreds.valid === false) { 110 console.error(`credentials for user ${userId} were invalid`); 111 return null; ··· 116 117 const loginResponse: AccountStatus = await loginToBsky(agent, username, password); 118 if (loginResponse != AccountStatus.Ok) { 119 - const addViolation: boolean = await createViolationForUser(env, userId, loginResponse); 120 if (addViolation) 121 console.error(`Unable to login to ${userId} with violation ${loginResponse}`); 122 return null; ··· 124 return agent; 125 } 126 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"); 130 return false; 131 } 132 133 - const env = c.env; 134 // make a check to see if the post has already been posted onto bsky 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.`); 138 return true; 139 } 140 141 - const agent: AtpAgent|null = (usingAgent === null) ? await makeAgentForUser(env, content.user) : usingAgent; 142 if (agent === null) { 143 console.warn(`could not make agent for post ${content.postid}`); 144 return false; 145 } 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); 150 151 // Delete any embeds if they exist. 152 for (const record of newPostRecords.records) { ··· 164 165 // Turn off the post now flag if we failed. 166 if (content.postNow) { 167 - c.executionCtx.waitUntil(setPostNowOffForPost(env, content.postid)); 168 } 169 return false; 170 } 171 172 - export const makeRepost = async (c: Context|ScheduledContext, content: Repost, usingAgent: AtpAgent|null=null) => { 173 - const env = c.env; 174 let bWasSuccess = true; 175 - const agent: AtpAgent|null = (usingAgent === null) ? await makeAgentForUser(env, content.userId) : usingAgent; 176 if (agent === null) { 177 console.warn(`could not make agent for repost ${content.postid}`); 178 return false; ··· 196 return bWasSuccess; 197 }; 198 199 - export const makePostRaw = async (env: Bindings, content: Post, agent: AtpAgent): Promise<PostStatus|null> => { 200 - const username = await getUsernameForUserId(env, content.user); 201 // incredibly unlikely but we'll handle it 202 if (username === null) { 203 console.warn(`username for post ${content.postid} was invalid`); ··· 294 // embed thumbnails of any size 295 // it will fail when you try to make the post record, saying the 296 // post record is invalid. 297 - const imgTransform = (await env.IMAGES.input(imageBlob.stream()) 298 .transform({width: 1280, height: 720, fit: "scale-down"}) 299 .output({ format: "image/jpeg", quality: 85 })).response(); 300 if (imgTransform.ok) { ··· 429 } 430 431 // Otherwise pull files from storage 432 - const file = await env.R2.get(currentEmbed.content); 433 if (!file) { 434 console.warn(`Could not get the file ${currentEmbed.content} from R2 for post!`); 435 return false; ··· 447 } 448 } 449 // Give violation mediaTooBig if the file is too large. 450 - await createViolationForUser(env, postData.user, AccountStatus.MediaTooBig); 451 console.warn(`Unable to upload ${currentEmbed.content} for post ${postData.postid} with err ${err}`); 452 return false; 453 } ··· 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) {
··· 9 import { 10 Bindings, BskyEmbedWrapper, BskyRecordWrapper, EmbedData, EmbedDataType, 11 LooseObj, Post, PostLabel, AccountStatus, 12 + PostRecordResponse, PostStatus, Repost, ScheduledContext, 13 + AllContext 14 } from '../types.d'; 15 import { atpRecordURI } from '../validation/regexCases'; 16 import { bulkUpdatePostedData, getChildPostsOfThread, isPostAlreadyPosted, setPostNowOffForPost } from './db/data'; ··· 105 return AccountStatus.UnhandledError; 106 } 107 108 + export const makeAgentForUser = async (c: AllContext, userId: string) => { 109 + const loginCreds = await getBskyUserPassForId(c, userId); 110 if (loginCreds.valid === false) { 111 console.error(`credentials for user ${userId} were invalid`); 112 return null; ··· 117 118 const loginResponse: AccountStatus = await loginToBsky(agent, username, password); 119 if (loginResponse != AccountStatus.Ok) { 120 + const addViolation: boolean = await createViolationForUser(c, userId, loginResponse); 121 if (addViolation) 122 console.error(`Unable to login to ${userId} with violation ${loginResponse}`); 123 return null; ··· 125 return agent; 126 } 127 128 + export const makePost = async (c: AllContext, content: Post|null, usingAgent: AtpAgent|null=null) => { 129 if (content === null) { 130 console.warn("Dropping invocation of makePost, content was null"); 131 return false; 132 } 133 134 // make a check to see if the post has already been posted onto bsky 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(c, content.postid)) { 137 console.log(`Dropped handling make post for post ${content.postid}, already posted.`); 138 return true; 139 } 140 141 + const agent: AtpAgent|null = (usingAgent === null) ? await makeAgentForUser(c, content.user) : usingAgent; 142 if (agent === null) { 143 console.warn(`could not make agent for post ${content.postid}`); 144 return false; 145 } 146 147 + const newPostRecords: PostStatus|null = await makePostRaw(c, content, agent); 148 if (newPostRecords !== null) { 149 + await bulkUpdatePostedData(c, newPostRecords.records, newPostRecords.expected == newPostRecords.got); 150 151 // Delete any embeds if they exist. 152 for (const record of newPostRecords.records) { ··· 164 165 // Turn off the post now flag if we failed. 166 if (content.postNow) { 167 + c.executionCtx.waitUntil(setPostNowOffForPost(c, content.postid)); 168 } 169 return false; 170 } 171 172 + export const makeRepost = async (c: AllContext, content: Repost, usingAgent: AtpAgent|null=null) => { 173 let bWasSuccess = true; 174 + const agent: AtpAgent|null = (usingAgent === null) ? await makeAgentForUser(c, content.userId) : usingAgent; 175 if (agent === null) { 176 console.warn(`could not make agent for repost ${content.postid}`); 177 return false; ··· 195 return bWasSuccess; 196 }; 197 198 + export const makePostRaw = async (c: AllContext, content: Post, agent: AtpAgent): Promise<PostStatus|null> => { 199 + const username = await getUsernameForUserId(c, content.user); 200 // incredibly unlikely but we'll handle it 201 if (username === null) { 202 console.warn(`username for post ${content.postid} was invalid`); ··· 293 // embed thumbnails of any size 294 // it will fail when you try to make the post record, saying the 295 // post record is invalid. 296 + const imgTransform = (await c.env.IMAGES.input(imageBlob.stream()) 297 .transform({width: 1280, height: 720, fit: "scale-down"}) 298 .output({ format: "image/jpeg", quality: 85 })).response(); 299 if (imgTransform.ok) { ··· 428 } 429 430 // Otherwise pull files from storage 431 + const file = await c.env.R2.get(currentEmbed.content); 432 if (!file) { 433 console.warn(`Could not get the file ${currentEmbed.content} from R2 for post!`); 434 return false; ··· 446 } 447 } 448 // Give violation mediaTooBig if the file is too large. 449 + await createViolationForUser(c, postData.user, AccountStatus.MediaTooBig); 450 console.warn(`Unable to upload ${currentEmbed.content} for post ${postData.postid} with err ${err}`); 451 return false; 452 } ··· 608 609 // If this is a post thread root 610 if (content.isThreadRoot) { 611 + const childPosts = await getChildPostsOfThread(c, content.postid) || []; 612 expected += childPosts.length; 613 // get the thread children. 614 for (const child of childPosts) {
+3 -3
src/utils/bskyPrune.ts
··· 1 import isEmpty from 'just-is-empty'; 2 import split from 'just-split'; 3 - import { Bindings } from '../types.d'; 4 import { getPostRecords } from './bskyApi'; 5 import { getAllPostedPosts, getAllPostedPostsOfUser } from './db/data'; 6 ··· 8 // are still on the network or not. If they are not, then this prunes the posts from 9 // the database. This call is quite expensive and should only be ran on a weekly 10 // cron job. 11 - export const pruneBskyPosts = async (env: Bindings, userId?: string) => { 12 - const allPostedPosts = (userId !== undefined) ? await getAllPostedPostsOfUser(env, userId) : await getAllPostedPosts(env); 13 let removePostIds: string[] = []; 14 let postedGroups = split(allPostedPosts, 25); 15 while (!isEmpty(postedGroups)) {
··· 1 import isEmpty from 'just-is-empty'; 2 import split from 'just-split'; 3 + import { AllContext } from '../types.d'; 4 import { getPostRecords } from './bskyApi'; 5 import { getAllPostedPosts, getAllPostedPostsOfUser } from './db/data'; 6 ··· 8 // are still on the network or not. If they are not, then this prunes the posts from 9 // the database. This call is quite expensive and should only be ran on a weekly 10 // cron job. 11 + export const pruneBskyPosts = async (c: AllContext, userId?: string) => { 12 + const allPostedPosts = (userId !== undefined) ? await getAllPostedPostsOfUser(c, userId) : await getAllPostedPosts(c); 13 let removePostIds: string[] = []; 14 let postedGroups = split(allPostedPosts, 25); 15 while (!isEmpty(postedGroups)) {
+82 -29
src/utils/db/data.ts
··· 1 import { and, asc, desc, eq, inArray, isNotNull, lte, ne, notInArray, sql } from "drizzle-orm"; 2 import { BatchItem } from "drizzle-orm/batch"; 3 - import { drizzle, DrizzleD1Database } from "drizzle-orm/d1"; 4 import isEmpty from "just-is-empty"; 5 import { validate as uuidValid } from 'uuid'; 6 import { posts, repostCounts, reposts } from "../../db/app.schema"; 7 import { violations } from "../../db/enforcement.schema"; 8 import { MAX_HOLD_DAYS_BEFORE_PURGE, MAX_POSTED_LENGTH } from "../../limits"; 9 import { 10 BatchQuery, 11 - Bindings, 12 GetAllPostedBatch, 13 Post, 14 PostRecordResponse, ··· 16 } from "../../types.d"; 17 import { createPostObject, createRepostObject, floorCurrentTime } from "../helpers"; 18 19 - export const getAllPostsForCurrentTime = async (env: Bindings, removeThreads: boolean = false): Promise<Post[]> => { 20 // Get all scheduled posts for current time 21 - const db: DrizzleD1Database = drizzle(env.DB); 22 const currentTime: Date = floorCurrentTime(); 23 24 const violationUsers = db.select({violators: violations.userId}).from(violations); ··· 41 return results.map((item) => createPostObject(item)); 42 }; 43 44 - export const getAllRepostsForGivenTime = async (env: Bindings, givenDate: Date): Promise<Repost[]> => { 45 // Get all scheduled posts for the given time 46 - const db: DrizzleD1Database = drizzle(env.DB); 47 const query = db.select({uuid: reposts.uuid}).from(reposts) 48 .where(lte(reposts.scheduledDate, givenDate)); 49 const violationsQuery = db.select({data: violations.userId}).from(violations); ··· 55 return results.map((item) => createRepostObject(item)); 56 }; 57 58 - export const getAllRepostsForCurrentTime = async (env: Bindings): Promise<Repost[]> => { 59 - return await getAllRepostsForGivenTime(env, floorCurrentTime()); 60 }; 61 62 - export const deleteAllRepostsBeforeCurrentTime = async (env: Bindings) => { 63 - const db: DrizzleD1Database = drizzle(env.DB); 64 const currentTime = floorCurrentTime(); 65 const deletedPosts = await db.delete(reposts).where(lte(reposts.scheduledDate, currentTime)) 66 .returning({id: reposts.uuid, scheduleGuid: reposts.scheduleGuid}); ··· 107 } 108 }; 109 110 - export const bulkUpdatePostedData = async (env: Bindings, records: PostRecordResponse[], allPosted: boolean) => { 111 - const db: DrizzleD1Database = drizzle(env.DB); 112 let dbOperations: BatchItem<"sqlite">[] = []; 113 114 for (let i = 0; i < records.length; ++i) { ··· 128 await db.batch(dbOperations as BatchQuery); 129 } 130 131 - export const setPostNowOffForPost = async (env: Bindings, id: string) => { 132 if (!uuidValid(id)) 133 return false; 134 135 - const db: DrizzleD1Database = drizzle(env.DB); 136 const {success} = await db.update(posts).set({postNow: false}).where(eq(posts.uuid, id)); 137 if (!success) 138 console.error(`Unable to set PostNow to off for post ${id}`); 139 }; 140 141 - export const updatePostForGivenUser = async (env: Bindings, userId: string, id: string, newData: Object) => { 142 if (isEmpty(userId) || !uuidValid(id)) 143 return false; 144 145 - const db: DrizzleD1Database = drizzle(env.DB); 146 const {success} = await db.update(posts).set(newData).where( 147 and(eq(posts.uuid, id), eq(posts.userId, userId))); 148 return success; 149 }; 150 151 - export const getAllPostedPostsOfUser = async(env: Bindings, userId: string): Promise<GetAllPostedBatch[]> => { 152 if (isEmpty(userId)) 153 return []; 154 155 - const db: DrizzleD1Database = drizzle(env.DB); 156 return await db.select({id: posts.uuid, uri: posts.uri}) 157 .from(posts) 158 .where(and(eq(posts.userId, userId), eq(posts.posted, true))) 159 .all(); 160 }; 161 162 - export const getAllPostedPosts = async (env: Bindings): Promise<GetAllPostedBatch[]> => { 163 - const db: DrizzleD1Database = drizzle(env.DB); 164 return await db.select({id: posts.uuid, uri: posts.uri}) 165 .from(posts) 166 .where(eq(posts.posted, true)) 167 .all(); 168 }; 169 170 - export const isPostAlreadyPosted = async (env: Bindings, postId: string): Promise<boolean> => { 171 if (!uuidValid(postId)) 172 return true; 173 174 - const db: DrizzleD1Database = drizzle(env.DB); 175 const query = await db.select({posted: posts.posted}).from(posts).where(eq(posts.uuid, postId)).all(); 176 if (isEmpty(query) || query[0].posted === null) { 177 // if the post does not exist, return true anyways ··· 180 return query[0].posted; 181 }; 182 183 - export const getChildPostsOfThread = async (env: Bindings, rootId: string): Promise<Post[]|null> => { 184 if (!uuidValid(rootId)) 185 return null; 186 187 - const db: DrizzleD1Database = drizzle(env.DB); 188 const query = await db.select().from(posts) 189 .where(and(isNotNull(posts.parentPost), eq(posts.rootPost, rootId))) 190 .orderBy(asc(posts.threadOrder), desc(posts.createdAt)).all(); ··· 204 } 205 206 // deletes multiple posted posts from a database. 207 - export const deletePosts = async (env: Bindings, postsToDelete: string[]): Promise<number> => { 208 // Don't do anything on empty arrays. 209 if (isEmpty(postsToDelete)) 210 return 0; 211 212 - const db: DrizzleD1Database = drizzle(env.DB); 213 let deleteQueries: BatchItem<"sqlite">[] = []; 214 postsToDelete.forEach((itm) => { 215 deleteQueries.push(db.delete(posts).where(and(eq(posts.uuid, itm), eq(posts.posted, true)))); ··· 224 return 0; 225 }; 226 227 - export const purgePostedPosts = async (env: Bindings): Promise<number> => { 228 - const db: DrizzleD1Database = drizzle(env.DB); 229 const dateString = `datetime('now', '-${MAX_HOLD_DAYS_BEFORE_PURGE} days')`; 230 const dbQuery = await db.select({ data: posts.uuid }).from(posts) 231 .leftJoin(repostCounts, eq(posts.uuid, repostCounts.uuid)) ··· 242 if (isEmpty(postsToDelete)) 243 return 0; 244 245 - return await deletePosts(env, postsToDelete); 246 }; 247 248 export const getPostByCID = async(db: DrizzleD1Database, userId: string, cid: string): Promise<Post|null> => {
··· 1 import { and, asc, desc, eq, inArray, isNotNull, lte, ne, notInArray, sql } from "drizzle-orm"; 2 import { BatchItem } from "drizzle-orm/batch"; 3 + import { DrizzleD1Database } from "drizzle-orm/d1"; 4 import isEmpty from "just-is-empty"; 5 import { validate as uuidValid } from 'uuid'; 6 import { posts, repostCounts, reposts } from "../../db/app.schema"; 7 import { violations } from "../../db/enforcement.schema"; 8 import { MAX_HOLD_DAYS_BEFORE_PURGE, MAX_POSTED_LENGTH } from "../../limits"; 9 import { 10 + AllContext, 11 BatchQuery, 12 GetAllPostedBatch, 13 Post, 14 PostRecordResponse, ··· 16 } from "../../types.d"; 17 import { createPostObject, createRepostObject, floorCurrentTime } from "../helpers"; 18 19 + export const getAllPostsForCurrentTime = async (c: AllContext, removeThreads: boolean = false): Promise<Post[]> => { 20 // Get all scheduled posts for current time 21 + const db: DrizzleD1Database = c.get("db"); 22 + if (!db) { 23 + console.error("Could not get all posts for current time, db was null"); 24 + return []; 25 + } 26 const currentTime: Date = floorCurrentTime(); 27 28 const violationUsers = db.select({violators: violations.userId}).from(violations); ··· 45 return results.map((item) => createPostObject(item)); 46 }; 47 48 + export const getAllRepostsForGivenTime = async (c: AllContext, givenDate: Date): Promise<Repost[]> => { 49 // Get all scheduled posts for the given time 50 + const db: DrizzleD1Database = c.get("db"); 51 + if (!db) { 52 + console.error("could not get all reposts for given timeframe, db was null"); 53 + return []; 54 + } 55 const query = db.select({uuid: reposts.uuid}).from(reposts) 56 .where(lte(reposts.scheduledDate, givenDate)); 57 const violationsQuery = db.select({data: violations.userId}).from(violations); ··· 63 return results.map((item) => createRepostObject(item)); 64 }; 65 66 + export const getAllRepostsForCurrentTime = async (c: AllContext): Promise<Repost[]> => { 67 + return await getAllRepostsForGivenTime(c, floorCurrentTime()); 68 }; 69 70 + export const deleteAllRepostsBeforeCurrentTime = async (c: AllContext) => { 71 + const db: DrizzleD1Database = c.get("db"); 72 + if (!db) { 73 + console.error("unable to delete all reposts before current time, db was null"); 74 + return; 75 + } 76 const currentTime = floorCurrentTime(); 77 const deletedPosts = await db.delete(reposts).where(lte(reposts.scheduledDate, currentTime)) 78 .returning({id: reposts.uuid, scheduleGuid: reposts.scheduleGuid}); ··· 119 } 120 }; 121 122 + export const bulkUpdatePostedData = async (c: AllContext, records: PostRecordResponse[], allPosted: boolean) => { 123 + const db: DrizzleD1Database = c.get("db"); 124 + if (!db) { 125 + console.error("unable to bulk update posted data, db was null"); 126 + return; 127 + } 128 let dbOperations: BatchItem<"sqlite">[] = []; 129 130 for (let i = 0; i < records.length; ++i) { ··· 144 await db.batch(dbOperations as BatchQuery); 145 } 146 147 + export const setPostNowOffForPost = async (c: AllContext, id: string) => { 148 + const db: DrizzleD1Database = c.get("db"); 149 if (!uuidValid(id)) 150 return false; 151 152 + if (!db) { 153 + console.warn(`cannot set off post now for post ${id}`); 154 + return false; 155 + } 156 + 157 const {success} = await db.update(posts).set({postNow: false}).where(eq(posts.uuid, id)); 158 if (!success) 159 console.error(`Unable to set PostNow to off for post ${id}`); 160 }; 161 162 + export const updatePostForGivenUser = async (c: AllContext, userId: string, id: string, newData: Object) => { 163 + const db: DrizzleD1Database = c.get("db"); 164 if (isEmpty(userId) || !uuidValid(id)) 165 return false; 166 167 + if (!db) { 168 + console.error(`unable to update post ${id} for user ${userId}, db was null`); 169 + return false; 170 + } 171 + 172 const {success} = await db.update(posts).set(newData).where( 173 and(eq(posts.uuid, id), eq(posts.userId, userId))); 174 return success; 175 }; 176 177 + export const getAllPostedPostsOfUser = async(c: AllContext, userId: string): Promise<GetAllPostedBatch[]> => { 178 + const db: DrizzleD1Database = c.get("db"); 179 if (isEmpty(userId)) 180 return []; 181 182 + if (!db) { 183 + console.error(`unable to get all posted posts of user ${userId}, db was null`); 184 + return []; 185 + } 186 + 187 return await db.select({id: posts.uuid, uri: posts.uri}) 188 .from(posts) 189 .where(and(eq(posts.userId, userId), eq(posts.posted, true))) 190 .all(); 191 }; 192 193 + export const getAllPostedPosts = async (c: AllContext): Promise<GetAllPostedBatch[]> => { 194 + const db: DrizzleD1Database = c.get("db"); 195 + if (!db) { 196 + console.error("unable to get all posted posts, db was null"); 197 + return []; 198 + } 199 return await db.select({id: posts.uuid, uri: posts.uri}) 200 .from(posts) 201 .where(eq(posts.posted, true)) 202 .all(); 203 }; 204 205 + export const isPostAlreadyPosted = async (c: AllContext, postId: string): Promise<boolean> => { 206 + const db: DrizzleD1Database = c.get("db"); 207 if (!uuidValid(postId)) 208 return true; 209 210 + if (!db) { 211 + console.error(`unable to get database to tell if ${postId} has been posted`); 212 + return true; 213 + } 214 + 215 const query = await db.select({posted: posts.posted}).from(posts).where(eq(posts.uuid, postId)).all(); 216 if (isEmpty(query) || query[0].posted === null) { 217 // if the post does not exist, return true anyways ··· 220 return query[0].posted; 221 }; 222 223 + export const getChildPostsOfThread = async (c: AllContext, rootId: string): Promise<Post[]|null> => { 224 + const db: DrizzleD1Database = c.get("db"); 225 if (!uuidValid(rootId)) 226 return null; 227 228 + if (!db) { 229 + console.error(`unable to get child posts of root ${rootId}, db was null`); 230 + return null; 231 + } 232 + 233 const query = await db.select().from(posts) 234 .where(and(isNotNull(posts.parentPost), eq(posts.rootPost, rootId))) 235 .orderBy(asc(posts.threadOrder), desc(posts.createdAt)).all(); ··· 249 } 250 251 // deletes multiple posted posts from a database. 252 + export const deletePosts = async (c: AllContext, postsToDelete: string[]): Promise<number> => { 253 // Don't do anything on empty arrays. 254 if (isEmpty(postsToDelete)) 255 return 0; 256 257 + const db: DrizzleD1Database = c.get("db"); 258 + if (!db) { 259 + console.error(`could not delete posts ${postsToDelete}, db was null`); 260 + return 0; 261 + } 262 let deleteQueries: BatchItem<"sqlite">[] = []; 263 postsToDelete.forEach((itm) => { 264 deleteQueries.push(db.delete(posts).where(and(eq(posts.uuid, itm), eq(posts.posted, true)))); ··· 273 return 0; 274 }; 275 276 + export const purgePostedPosts = async (c: AllContext): Promise<number> => { 277 + const db: DrizzleD1Database = c.get("db"); 278 + if (!db) { 279 + console.error("could not purge posted posts, got error"); 280 + return 0; 281 + } 282 const dateString = `datetime('now', '-${MAX_HOLD_DAYS_BEFORE_PURGE} days')`; 283 const dbQuery = await db.select({ data: posts.uuid }).from(posts) 284 .leftJoin(repostCounts, eq(posts.uuid, repostCounts.uuid)) ··· 295 if (isEmpty(postsToDelete)) 296 return 0; 297 298 + return await deletePosts(c, postsToDelete); 299 }; 300 301 export const getPostByCID = async(db: DrizzleD1Database, userId: string, cid: string): Promise<Post|null> => {
+27 -11
src/utils/db/file.ts
··· 1 import { and, eq, inArray, lte } from "drizzle-orm"; 2 - import { drizzle, DrizzleD1Database } from "drizzle-orm/d1"; 3 import flatten from "just-flatten-it"; 4 import { mediaFiles, posts } from "../../db/app.schema"; 5 - import { Bindings, EmbedDataType, LooseObj } from "../../types.d"; 6 import { daysAgo } from "../helpers"; 7 8 - export const addFileListing = async (env: Bindings, file: string, user: string|null, createDate: Date|null=null) => { 9 - const db: DrizzleD1Database = drizzle(env.DB); 10 let insertData:LooseObj = {}; 11 if (createDate !== null) { 12 insertData.createdAt = createDate; ··· 18 .onConflictDoNothing({target: mediaFiles.fileName}); 19 }; 20 21 - export const deleteFileListings = async (env: Bindings, files: string|string[]) => { 22 - const db: DrizzleD1Database = drizzle(env.DB); 23 let filesToDelete = []; 24 filesToDelete.push(files); 25 const filesToWorkOn = flatten(filesToDelete); 26 await db.delete(mediaFiles).where(inArray(mediaFiles.fileName, filesToWorkOn)); 27 }; 28 29 - export const getAllAbandonedMedia = async(env: Bindings) => { 30 - const db: DrizzleD1Database = drizzle(env.DB); 31 - const numDaysAgo = daysAgo(env.R2_SETTINGS.prune_days); 32 33 const results = await db.select().from(mediaFiles) 34 .where( ··· 38 return results.map((item) => item.fileName); 39 }; 40 41 - export const getAllMediaOfUser = async (env: Bindings, userId: string): Promise<string[]> => { 42 - const db: DrizzleD1Database = drizzle(env.DB); 43 const mediaList = await db.select({embeds: posts.embedContent}).from(posts) 44 .where(and(eq(posts.posted, false), eq(posts.userId, userId))).all(); 45
··· 1 import { and, eq, inArray, lte } from "drizzle-orm"; 2 + import { DrizzleD1Database } from "drizzle-orm/d1"; 3 import flatten from "just-flatten-it"; 4 import { mediaFiles, posts } from "../../db/app.schema"; 5 + import { AllContext, EmbedDataType, LooseObj } from "../../types.d"; 6 import { daysAgo } from "../helpers"; 7 8 + export const addFileListing = async (c: AllContext, file: string, user: string|null, createDate: Date|null=null) => { 9 + const db: DrizzleD1Database = c.get("db"); 10 + if (!db) { 11 + console.error(`unable to create file listing for file ${file}, db was null`); 12 + return; 13 + } 14 let insertData:LooseObj = {}; 15 if (createDate !== null) { 16 insertData.createdAt = createDate; ··· 22 .onConflictDoNothing({target: mediaFiles.fileName}); 23 }; 24 25 + export const deleteFileListings = async (c: AllContext, files: string|string[]) => { 26 + const db: DrizzleD1Database = c.get("db"); 27 + if (!db) { 28 + console.error(`unable to delete file listings ${files}, db was null`); 29 + return; 30 + } 31 let filesToDelete = []; 32 filesToDelete.push(files); 33 const filesToWorkOn = flatten(filesToDelete); 34 await db.delete(mediaFiles).where(inArray(mediaFiles.fileName, filesToWorkOn)); 35 }; 36 37 + export const getAllAbandonedMedia = async(c: AllContext): Promise<string[]> => { 38 + const db: DrizzleD1Database = c.get("db"); 39 + if (!db) { 40 + console.error("could not get all abandoned media, db was null"); 41 + return []; 42 + } 43 + const numDaysAgo = daysAgo(c.env.R2_SETTINGS.prune_days); 44 45 const results = await db.select().from(mediaFiles) 46 .where( ··· 50 return results.map((item) => item.fileName); 51 }; 52 53 + export const getAllMediaOfUser = async (c: AllContext, userId: string): Promise<string[]> => { 54 + const db: DrizzleD1Database = c.get("db"); 55 + if (!db) { 56 + console.warn(`could not get all media of user ${userId}, db was null`); 57 + return []; 58 + } 59 const mediaList = await db.select({embeds: posts.embedContent}).from(posts) 60 .where(and(eq(posts.posted, false), eq(posts.userId, userId))).all(); 61
+11 -7
src/utils/db/maintain.ts
··· 1 import { eq, getTableColumns, gt, inArray, isNull, sql } from "drizzle-orm"; 2 import { BatchItem } from "drizzle-orm/batch"; 3 - import { drizzle, DrizzleD1Database } from "drizzle-orm/d1"; 4 import flatten from "just-flatten-it"; 5 import { mediaFiles, posts, repostCounts, reposts } from "../../db/app.schema"; 6 import { users } from "../../db/auth.schema"; 7 import { MAX_POSTED_LENGTH } from "../../limits"; 8 - import { BatchQuery, Bindings, R2BucketObject } from "../../types.d"; 9 import { getAllFilesList } from "../r2Query"; 10 import { addFileListing, getAllMediaOfUser } from "./file"; 11 12 /** Maintenance operations **/ 13 - export const runMaintenanceUpdates = async (env: Bindings) => { 14 - const db: DrizzleD1Database = drizzle(env.DB); 15 // Create a posted query that also checks for valid json and content length 16 const postedQuery = db.select({ 17 ...getTableColumns(posts), ··· 44 await db.update(posts).set({updatedAt: sql`CURRENT_TIMESTAMP`}).where(isNull(posts.updatedAt)); 45 46 // populate existing media table with post data 47 - const allBucketFiles:R2BucketObject[] = await getAllFilesList(env); 48 try { 49 for (const bucketFile of allBucketFiles) { 50 - await addFileListing(env, bucketFile.name, bucketFile.user, bucketFile.date); 51 } 52 } catch(err) { 53 console.error(`Adding file listings got error ${err}`); ··· 57 // Flag if the media file has embed data 58 const allUsers = await db.select({id: users.id}).from(users).all(); 59 for (const user of allUsers) { 60 - const userMedia = await getAllMediaOfUser(env, user.id); 61 batchedQueries.push(db.update(mediaFiles).set({hasPost: true}) 62 .where(inArray(mediaFiles.fileName, flatten(userMedia)))); 63 }
··· 1 import { eq, getTableColumns, gt, inArray, isNull, sql } from "drizzle-orm"; 2 import { BatchItem } from "drizzle-orm/batch"; 3 + import { DrizzleD1Database } from "drizzle-orm/d1"; 4 import flatten from "just-flatten-it"; 5 import { mediaFiles, posts, repostCounts, reposts } from "../../db/app.schema"; 6 import { users } from "../../db/auth.schema"; 7 import { MAX_POSTED_LENGTH } from "../../limits"; 8 + import { AllContext, BatchQuery, Bindings, R2BucketObject } from "../../types.d"; 9 import { getAllFilesList } from "../r2Query"; 10 import { addFileListing, getAllMediaOfUser } from "./file"; 11 12 /** Maintenance operations **/ 13 + export const runMaintenanceUpdates = async (c: AllContext) => { 14 + const db: DrizzleD1Database = c.get("db"); 15 + if (!db) { 16 + console.error("unable to get database to run maintenance"); 17 + return; 18 + } 19 // Create a posted query that also checks for valid json and content length 20 const postedQuery = db.select({ 21 ...getTableColumns(posts), ··· 48 await db.update(posts).set({updatedAt: sql`CURRENT_TIMESTAMP`}).where(isNull(posts.updatedAt)); 49 50 // populate existing media table with post data 51 + const allBucketFiles:R2BucketObject[] = await getAllFilesList(c); 52 try { 53 for (const bucketFile of allBucketFiles) { 54 + await addFileListing(c, bucketFile.name, bucketFile.user, bucketFile.date); 55 } 56 } catch(err) { 57 console.error(`Adding file listings got error ${err}`); ··· 61 // Flag if the media file has embed data 62 const allUsers = await db.select({id: users.id}).from(users).all(); 63 for (const user of allUsers) { 64 + const userMedia = await getAllMediaOfUser(c, user.id); 65 batchedQueries.push(db.update(mediaFiles).set({hasPost: true}) 66 .where(inArray(mediaFiles.fileName, flatten(userMedia)))); 67 }
+33 -16
src/utils/db/userinfo.ts
··· 1 import { eq } from "drizzle-orm"; 2 - import { drizzle, DrizzleD1Database } from "drizzle-orm/d1"; 3 - import { Context } from "hono"; 4 import isEmpty from "just-is-empty"; 5 import { users } from "../../db/auth.schema"; 6 - import { Bindings, BskyAPILoginCreds } from "../../types.d"; 7 import { createLoginCredsObj } from "../helpers"; 8 9 - export const doesUserExist = async (c: Context, username: string) => { 10 - const db: DrizzleD1Database = drizzle(c.env.DB); 11 const result = await db.select().from(users) 12 .where(eq(users.username, username)) 13 .limit(1).all(); 14 return result.length > 0; 15 }; 16 17 - export const doesAdminExist = async (c: Context) => { 18 - const db: DrizzleD1Database = drizzle(c.env.DB); 19 const result = await db.select().from(users) 20 .where(eq(users.name, "admin")) 21 .limit(1).all(); 22 return result.length > 0; 23 }; 24 25 - export const getBskyUserPassForId = async (env: Bindings, userid: string): Promise<BskyAPILoginCreds> => { 26 - const db: DrizzleD1Database = drizzle(env.DB); 27 const response = await db.select({user: users.username, pass: users.bskyAppPass, pds: users.pds}) 28 .from(users) 29 .where(eq(users.id, userid)) 30 .limit(1).all(); 31 - return createLoginCredsObj(env, response[0] || null); 32 }; 33 34 - export const getUsernameForUserId = async (env: Bindings, userId: string): Promise<string|null> => { 35 - const db: DrizzleD1Database = drizzle(env.DB); 36 const result = await db.select({username: users.username}).from(users) 37 .where(eq(users.id, userId)).limit(1); 38 if (result !== null && result.length > 0) ··· 40 return null; 41 }; 42 43 - export const getUsernameForUser = async (c: Context): Promise<string|null> => { 44 const userId = c.get("userId"); 45 if (!userId) 46 return null; 47 48 - return await getUsernameForUserId(c.env, userId); 49 }; 50 51 // This is a super dumb query that's needed to get around better auth's forgot password system 52 // because you cannot make the call with just an username, you need to also have the email 53 // but we never update the email past the original time you first signed up, so instead 54 // we use big brain tactics to spoof the email 55 - export const getUserEmailForHandle = async (env: Bindings, userhandle: string): Promise<string|null> => { 56 - const db: DrizzleD1Database = drizzle(env.DB); 57 const result = await db.select({email: users.email}).from(users).where(eq(users.username, userhandle)).limit(1); 58 if (!isEmpty(result)) 59 return result[0].email;
··· 1 import { eq } from "drizzle-orm"; 2 + import { DrizzleD1Database } from "drizzle-orm/d1"; 3 import isEmpty from "just-is-empty"; 4 import { users } from "../../db/auth.schema"; 5 + import { AllContext, BskyAPILoginCreds } from "../../types.d"; 6 import { createLoginCredsObj } from "../helpers"; 7 8 + export const doesUserExist = async (c: AllContext, username: string): Promise<boolean> => { 9 + const db: DrizzleD1Database = c.get("db"); 10 + if (!db) { 11 + console.error("Unable to check database for user existence"); 12 + return true; 13 + } 14 const result = await db.select().from(users) 15 .where(eq(users.username, username)) 16 .limit(1).all(); 17 return result.length > 0; 18 }; 19 20 + export const doesAdminExist = async (c: AllContext) => { 21 + const db: DrizzleD1Database = c.get("db"); 22 + if (!db) { 23 + console.error("unable to check database for admin account"); 24 + return false; 25 + } 26 + 27 const result = await db.select().from(users) 28 .where(eq(users.name, "admin")) 29 .limit(1).all(); 30 return result.length > 0; 31 }; 32 33 + export const getBskyUserPassForId = async (c: AllContext, userid: string): Promise<BskyAPILoginCreds> => { 34 + const db: DrizzleD1Database = c.get("db"); 35 + if (!db) 36 + return createLoginCredsObj(null); 37 + 38 const response = await db.select({user: users.username, pass: users.bskyAppPass, pds: users.pds}) 39 .from(users) 40 .where(eq(users.id, userid)) 41 .limit(1).all(); 42 + return createLoginCredsObj(response[0] || null); 43 }; 44 45 + export const getUsernameForUserId = async (c: AllContext, userId: string): Promise<string|null> => { 46 + const db: DrizzleD1Database = c.get("db"); 47 + if (!db) 48 + return null; 49 + 50 const result = await db.select({username: users.username}).from(users) 51 .where(eq(users.id, userId)).limit(1); 52 if (result !== null && result.length > 0) ··· 54 return null; 55 }; 56 57 + export const getUsernameForUser = async (c: AllContext): Promise<string|null> => { 58 const userId = c.get("userId"); 59 if (!userId) 60 return null; 61 62 + return await getUsernameForUserId(c, userId); 63 }; 64 65 // This is a super dumb query that's needed to get around better auth's forgot password system 66 // because you cannot make the call with just an username, you need to also have the email 67 // but we never update the email past the original time you first signed up, so instead 68 // we use big brain tactics to spoof the email 69 + export const getUserEmailForHandle = async (c: AllContext, userhandle: string): Promise<string|null> => { 70 + const db: DrizzleD1Database = c.get("db"); 71 + if (!db) 72 + return null; 73 + 74 const result = await db.select({email: users.email}).from(users).where(eq(users.username, userhandle)).limit(1); 75 if (!isEmpty(result)) 76 return result[0].email;
+33 -20
src/utils/db/violations.ts
··· 1 import { and, eq, ne } from "drizzle-orm"; 2 - import { drizzle, DrizzleD1Database } from "drizzle-orm/d1"; 3 - import { Context } from "hono"; 4 import { bannedUsers, violations } from "../../db/enforcement.schema"; 5 - import { Bindings, LooseObj, AccountStatus, Violation } from "../../types.d"; 6 import { lookupBskyHandle } from "../bskyApi"; 7 import { getUsernameForUserId } from "./userinfo"; 8 ··· 18 } 19 } 20 21 - export const userHasBan = async (env: Bindings, userDid: string): Promise<boolean> => { 22 - const db: DrizzleD1Database = drizzle(env.DB); 23 - return (await db.select().from(bannedUsers).where(eq(bannedUsers.did, userDid)).limit(1).all()).length > 0; 24 }; 25 26 - export const userHandleHasBan = async (env: Bindings, userName: string) => { 27 if (userName !== null) { 28 const didHandle = await lookupBskyHandle(userName); 29 if (didHandle !== null) 30 - return await userHasBan(env, didHandle); 31 } 32 return false; 33 }; ··· 56 return valuesUpdate; 57 } 58 59 - export const createViolationForUser = async(env: Bindings, userId: string, violationType: AccountStatus): Promise<boolean> => { 60 const NoHandleState: AccountStatus[] = [AccountStatus.Ok, AccountStatus.PlatformOutage, 61 AccountStatus.None, AccountStatus.UnhandledError]; 62 // Don't do anything in these cases ··· 65 return false; 66 } 67 68 - const db: DrizzleD1Database = drizzle(env.DB); 69 const valuesUpdate:LooseObj = createObjForValuesChange([violationType], true); 70 if (violationType === AccountStatus.TOSViolation) { 71 - const bskyUsername = await getUsernameForUserId(env, userId); 72 if (bskyUsername !== null) { 73 await createBanForUser(db, bskyUsername, "tos violation"); 74 } else { ··· 87 )); 88 }; 89 90 - export const removeViolation = async(env: Bindings, userId: string, violationType: AccountStatus) => { 91 - await removeViolations(env, userId, [violationType]); 92 }; 93 94 - export const removeViolations = async(env: Bindings, userId: string, violationType: AccountStatus[]) => { 95 - const db: DrizzleD1Database = drizzle(env.DB); 96 // Check if they have a violation first 97 if ((await userHasViolations(db, userId)) == false) { 98 return; ··· 113 } 114 115 export const getViolationsForUser = async(db: DrizzleD1Database, userId: string) => { 116 - const {results} = await db.select().from(violations).where(eq(violations.userId, userId)).limit(1).run(); 117 if (results.length > 0) 118 return (results[0] as Violation); 119 return null; 120 }; 121 122 - export const getViolationsForCurrentUser = async(c: Context): Promise<Violation|null> => { 123 const userId = c.get("userId"); 124 - if (userId) { 125 - const db: DrizzleD1Database = drizzle(c.env.DB); 126 return await getViolationsForUser(db, userId); 127 } 128 return null; 129 - };
··· 1 import { and, eq, ne } from "drizzle-orm"; 2 + import { DrizzleD1Database } from "drizzle-orm/d1"; 3 import { bannedUsers, violations } from "../../db/enforcement.schema"; 4 + import { AccountStatus, AllContext, LooseObj, Violation } from "../../types.d"; 5 import { lookupBskyHandle } from "../bskyApi"; 6 import { getUsernameForUserId } from "./userinfo"; 7 ··· 17 } 18 } 19 20 + export const userHasBan = async (c: AllContext, userDid: string): Promise<boolean> => { 21 + const db: DrizzleD1Database = c.get("db"); 22 + if (!db) { 23 + console.error("unable to check if user has ban, db was null"); 24 + return false; 25 + } 26 + const usersBanned = await db.$count(bannedUsers, eq(bannedUsers.did, userDid)); 27 + return (usersBanned > 0); 28 }; 29 30 + export const userHandleHasBan = async (c: AllContext, userName: string) => { 31 if (userName !== null) { 32 const didHandle = await lookupBskyHandle(userName); 33 if (didHandle !== null) 34 + return await userHasBan(c, didHandle); 35 } 36 return false; 37 }; ··· 60 return valuesUpdate; 61 } 62 63 + export const createViolationForUser = async(c: AllContext, userId: string, violationType: AccountStatus): Promise<boolean> => { 64 const NoHandleState: AccountStatus[] = [AccountStatus.Ok, AccountStatus.PlatformOutage, 65 AccountStatus.None, AccountStatus.UnhandledError]; 66 // Don't do anything in these cases ··· 69 return false; 70 } 71 72 + const db: DrizzleD1Database = c.get("db"); 73 + if (!db) { 74 + console.error("unable to get database to create violations for"); 75 + return false; 76 + } 77 const valuesUpdate:LooseObj = createObjForValuesChange([violationType], true); 78 if (violationType === AccountStatus.TOSViolation) { 79 + const bskyUsername = await getUsernameForUserId(c, userId); 80 if (bskyUsername !== null) { 81 await createBanForUser(db, bskyUsername, "tos violation"); 82 } else { ··· 95 )); 96 }; 97 98 + export const removeViolation = async(c: AllContext, userId: string, violationType: AccountStatus) => { 99 + await removeViolations(c, userId, [violationType]); 100 }; 101 102 + export const removeViolations = async(c: AllContext, userId: string, violationType: AccountStatus[]) => { 103 + const db: DrizzleD1Database = c.get("db"); 104 + if (!db) { 105 + console.warn(`unable to remove violations for user ${userId}, db was null`); 106 + return; 107 + } 108 // Check if they have a violation first 109 if ((await userHasViolations(db, userId)) == false) { 110 return; ··· 125 } 126 127 export const getViolationsForUser = async(db: DrizzleD1Database, userId: string) => { 128 + const {results} = await db.select().from(violations) 129 + .where(eq(violations.userId, userId)).limit(1).run(); 130 if (results.length > 0) 131 return (results[0] as Violation); 132 return null; 133 }; 134 135 + export const getViolationsForCurrentUser = async(c: AllContext): Promise<Violation|null> => { 136 const userId = c.get("userId"); 137 + const db: DrizzleD1Database = c.get("db"); 138 + if (userId && db) { 139 return await getViolationsForUser(db, userId); 140 } 141 return null; 142 + };
+51 -25
src/utils/dbQuery.ts
··· 1 import { addHours, isAfter, isEqual } from "date-fns"; 2 import { and, asc, desc, eq, getTableColumns, gt, gte, sql } from "drizzle-orm"; 3 import { BatchItem } from "drizzle-orm/batch"; 4 - import { drizzle, DrizzleD1Database } from "drizzle-orm/d1"; 5 - import { Context } from "hono"; 6 import has from "just-has"; 7 import isEmpty from "just-is-empty"; 8 import { v4 as uuidv4, validate as uuidValid } from 'uuid'; ··· 11 import { MAX_POSTS_PER_THREAD, MAX_REPOST_POSTS, MAX_REPOST_RULES_PER_POST } from "../limits"; 12 import { 13 AccountStatus, 14 BatchQuery, 15 CreateObjectResponse, CreatePostQueryResponse, 16 DeleteResponse, ··· 25 import { createPostObject, createRepostInfo, floorGivenTime } from "./helpers"; 26 import { deleteEmbedsFromR2 } from "./r2Query"; 27 28 - export const getPostsForUser = async (c: Context): Promise<Post[]|null> => { 29 try { 30 const userId = c.get("userId"); 31 - if (userId) { 32 - const db: DrizzleD1Database = drizzle(c.env.DB); 33 const results = await db.select({ 34 ...getTableColumns(posts), 35 repostCount: repostCounts.count ··· 49 return null; 50 }; 51 52 - export const updateUserData = async (c: Context, newData: any): Promise<boolean> => { 53 const userId = c.get("userId"); 54 try { 55 if (userId) { 56 - const db: DrizzleD1Database = drizzle(c.env.DB); 57 let queriesToExecute:BatchItem<"sqlite">[] = []; 58 59 if (has(newData, "password")) { ··· 73 // check if the user has violations 74 if (await userHasViolations(db, userId)) { 75 // they do, so clear them out 76 - await removeViolations(c.env, userId, [AccountStatus.InvalidAccount, AccountStatus.Deactivated]); 77 } 78 } 79 ··· 92 return false; 93 }; 94 95 - export const deletePost = async (c: Context, id: string): Promise<DeleteResponse> => { 96 const userId = c.get("userId"); 97 const returnObj: DeleteResponse = {success: false}; 98 if (!userId) { 99 return returnObj; 100 } 101 102 - const db: DrizzleD1Database = drizzle(c.env.DB); 103 const postObj = await getPostById(c, id); 104 if (postObj !== null) { 105 let queriesToExecute:BatchItem<"sqlite">[] = []; ··· 109 await deleteEmbedsFromR2(c, postObj.embeds); 110 if (await userHasViolations(db, userId)) { 111 // Remove the media too big violation if it's been given 112 - await removeViolation(c.env, userId, AccountStatus.MediaTooBig); 113 } 114 } 115 ··· 129 130 // We'll need to delete all of the child embeds then, a costly, annoying experience. 131 if (postObj.isThreadRoot) { 132 - const childPosts = await getChildPostsOfThread(c.env, postObj.postid); 133 if (childPosts !== null) { 134 for (const childPost of childPosts) { 135 c.executionCtx.waitUntil(deleteEmbedsFromR2(c, childPost.embeds)); ··· 155 return returnObj; 156 }; 157 158 - export const createPost = async (c: Context, body: any): Promise<CreatePostQueryResponse> => { 159 - const db: DrizzleD1Database = drizzle(c.env.DB); 160 - 161 const userId = c.get("userId"); 162 if (!userId) 163 return { ok: false, msg: "Your user session has expired, please login again"}; 164 165 const validation = PostSchema.safeParse(body); 166 if (!validation.success) { 167 return { ok: false, msg: validation.error.toString() }; ··· 313 return { ok: success, postNow: makePostNow, postId: postUUID, msg: success ? "success" : "fail" }; 314 }; 315 316 - export const createRepost = async (c: Context, body: any): Promise<CreateObjectResponse> => { 317 - const db: DrizzleD1Database = drizzle(c.env.DB); 318 319 const userId = c.get("userId"); 320 if (!userId) 321 return { ok: false, msg: "Your user session has expired, please login again"}; 322 323 const validation = RepostSchema.safeParse(body); 324 if (!validation.success) { ··· 430 return { ok: success, msg: success ? "success" : "fail", postId: postUUID }; 431 }; 432 433 - export const updatePostForUser = async (c: Context, id: string, newData: Object): Promise<boolean> => { 434 const userId = c.get("userId"); 435 - return await updatePostForGivenUser(c.env, userId, id, newData); 436 }; 437 438 - export const getPostById = async(c: Context, id: string): Promise<Post|null> => { 439 const userId = c.get("userId"); 440 if (!userId || !uuidValid(id)) 441 return null; 442 443 - const env = c.env; 444 - const db: DrizzleD1Database = drizzle(env.DB); 445 const result = await db.select().from(posts) 446 .where(and(eq(posts.uuid, id), eq(posts.userId, userId))) 447 .limit(1).all(); ··· 452 }; 453 454 // used for post editing, acts very similar to getPostsForUser 455 - export const getPostByIdWithReposts = async(c: Context, id: string): Promise<Post|null> => { 456 const userId = c.get("userId"); 457 if (!userId || !uuidValid(id)) 458 return null; 459 460 - const env = c.env; 461 - const db: DrizzleD1Database = drizzle(env.DB); 462 const result = await db.select({ 463 ...getTableColumns(posts), 464 repostCount: repostCounts.count,
··· 1 import { addHours, isAfter, isEqual } from "date-fns"; 2 import { and, asc, desc, eq, getTableColumns, gt, gte, sql } from "drizzle-orm"; 3 import { BatchItem } from "drizzle-orm/batch"; 4 + import { DrizzleD1Database } from "drizzle-orm/d1"; 5 import has from "just-has"; 6 import isEmpty from "just-is-empty"; 7 import { v4 as uuidv4, validate as uuidValid } from 'uuid'; ··· 10 import { MAX_POSTS_PER_THREAD, MAX_REPOST_POSTS, MAX_REPOST_RULES_PER_POST } from "../limits"; 11 import { 12 AccountStatus, 13 + AllContext, 14 BatchQuery, 15 CreateObjectResponse, CreatePostQueryResponse, 16 DeleteResponse, ··· 25 import { createPostObject, createRepostInfo, floorGivenTime } from "./helpers"; 26 import { deleteEmbedsFromR2 } from "./r2Query"; 27 28 + export const getPostsForUser = async (c: AllContext): Promise<Post[]|null> => { 29 try { 30 const userId = c.get("userId"); 31 + const db: DrizzleD1Database = c.get("db"); 32 + if (userId && db) { 33 const results = await db.select({ 34 ...getTableColumns(posts), 35 repostCount: repostCounts.count ··· 49 return null; 50 }; 51 52 + export const updateUserData = async (c: AllContext, newData: any): Promise<boolean> => { 53 const userId = c.get("userId"); 54 + const db: DrizzleD1Database = c.get("db"); 55 try { 56 + if (!db) { 57 + console.error("Unable to update user data, no database object"); 58 + return false; 59 + } 60 if (userId) { 61 let queriesToExecute:BatchItem<"sqlite">[] = []; 62 63 if (has(newData, "password")) { ··· 77 // check if the user has violations 78 if (await userHasViolations(db, userId)) { 79 // they do, so clear them out 80 + await removeViolations(c, userId, [AccountStatus.InvalidAccount, AccountStatus.Deactivated]); 81 } 82 } 83 ··· 96 return false; 97 }; 98 99 + export const deletePost = async (c: AllContext, id: string): Promise<DeleteResponse> => { 100 const userId = c.get("userId"); 101 const returnObj: DeleteResponse = {success: false}; 102 if (!userId) { 103 return returnObj; 104 } 105 106 + const db: DrizzleD1Database = c.get("db"); 107 + if (!db) { 108 + console.error(`unable to delete post ${id}, db was null`); 109 + return returnObj; 110 + } 111 + 112 const postObj = await getPostById(c, id); 113 if (postObj !== null) { 114 let queriesToExecute:BatchItem<"sqlite">[] = []; ··· 118 await deleteEmbedsFromR2(c, postObj.embeds); 119 if (await userHasViolations(db, userId)) { 120 // Remove the media too big violation if it's been given 121 + await removeViolation(c, userId, AccountStatus.MediaTooBig); 122 } 123 } 124 ··· 138 139 // We'll need to delete all of the child embeds then, a costly, annoying experience. 140 if (postObj.isThreadRoot) { 141 + const childPosts = await getChildPostsOfThread(c, postObj.postid); 142 if (childPosts !== null) { 143 for (const childPost of childPosts) { 144 c.executionCtx.waitUntil(deleteEmbedsFromR2(c, childPost.embeds)); ··· 164 return returnObj; 165 }; 166 167 + export const createPost = async (c: AllContext, body: any): Promise<CreatePostQueryResponse> => { 168 + const db: DrizzleD1Database = c.get("db"); 169 const userId = c.get("userId"); 170 if (!userId) 171 return { ok: false, msg: "Your user session has expired, please login again"}; 172 173 + if (!db) { 174 + console.error("unable to create post, db became null"); 175 + return { ok: false, msg: "An application error has occurred please refresh" }; 176 + } 177 + 178 const validation = PostSchema.safeParse(body); 179 if (!validation.success) { 180 return { ok: false, msg: validation.error.toString() }; ··· 326 return { ok: success, postNow: makePostNow, postId: postUUID, msg: success ? "success" : "fail" }; 327 }; 328 329 + export const createRepost = async (c: AllContext, body: any): Promise<CreateObjectResponse> => { 330 + const db: DrizzleD1Database = c.get("db"); 331 332 const userId = c.get("userId"); 333 if (!userId) 334 return { ok: false, msg: "Your user session has expired, please login again"}; 335 + 336 + if (!db) { 337 + console.error("unable to create repost db became null"); 338 + return {ok: false, msg: "Invalid server operation occurred, please refresh"}; 339 + } 340 341 const validation = RepostSchema.safeParse(body); 342 if (!validation.success) { ··· 448 return { ok: success, msg: success ? "success" : "fail", postId: postUUID }; 449 }; 450 451 + export const updatePostForUser = async (c: AllContext, id: string, newData: Object): Promise<boolean> => { 452 const userId = c.get("userId"); 453 + return await updatePostForGivenUser(c, userId, id, newData); 454 }; 455 456 + export const getPostById = async(c: AllContext, id: string): Promise<Post|null> => { 457 const userId = c.get("userId"); 458 if (!userId || !uuidValid(id)) 459 return null; 460 461 + const db: DrizzleD1Database = c.get("db"); 462 + if (!db) { 463 + console.error(`unable to get post ${id}, db was null`); 464 + return null; 465 + } 466 + 467 const result = await db.select().from(posts) 468 .where(and(eq(posts.uuid, id), eq(posts.userId, userId))) 469 .limit(1).all(); ··· 474 }; 475 476 // used for post editing, acts very similar to getPostsForUser 477 + export const getPostByIdWithReposts = async(c: AllContext, id: string): Promise<Post|null> => { 478 const userId = c.get("userId"); 479 if (!userId || !uuidValid(id)) 480 return null; 481 482 + const db: DrizzleD1Database = c.get("db"); 483 + if (!db) { 484 + console.error(`unable to get post ${id} with reposts, db was null`); 485 + return null; 486 + } 487 + 488 const result = await db.select({ 489 ...getTableColumns(posts), 490 repostCount: repostCounts.count,
+3 -3
src/utils/helpers.ts
··· 1 import { startOfHour, subDays } from "date-fns"; 2 import has from "just-has"; 3 import isEmpty from "just-is-empty"; 4 - import { Bindings, BskyAPILoginCreds, Post, Repost, RepostInfo } from "../types.d"; 5 - import { Context } from "hono"; 6 7 export function createPostObject(data: any) { 8 const postData: Post = (new Object() as Post); ··· 82 return repostObj; 83 } 84 85 - export function createLoginCredsObj(env: Bindings, data: any) { 86 const loginCreds: BskyAPILoginCreds = (new Object() as BskyAPILoginCreds); 87 if (isEmpty(data)) { 88 loginCreds.password = loginCreds.username = loginCreds.pds = "";
··· 1 import { startOfHour, subDays } from "date-fns"; 2 + import { Context } from "hono"; 3 import has from "just-has"; 4 import isEmpty from "just-is-empty"; 5 + import { BskyAPILoginCreds, Post, Repost, RepostInfo } from "../types.d"; 6 7 export function createPostObject(data: any) { 8 const postData: Post = (new Object() as Post); ··· 82 return repostObj; 83 } 84 85 + export function createLoginCredsObj(data: any) { 86 const loginCreds: BskyAPILoginCreds = (new Object() as BskyAPILoginCreds); 87 if (isEmpty(data)) { 88 loginCreds.password = loginCreds.username = loginCreds.pds = "";
+5 -5
src/utils/inviteKeys.ts
··· 15 if (inviteKey === undefined) 16 return false; 17 18 - const value = await c.env.INVITE_POOL.get(inviteKey); 19 // Key does not exist 20 if (value === null) 21 return false; ··· 41 if (inviteKey === undefined) 42 return; 43 44 - const value = await c.env.INVITE_POOL.get(inviteKey); 45 if (value === null) { 46 console.error(`attempted to use invite key ${inviteKey} but is invalid`); 47 return; ··· 62 let newValue: number = amount - 1; 63 // Delete any keys that fall to 0, they should be removed from the db 64 if (newValue <= 0) { 65 - await c.env.INVITE_POOL.delete(inviteKey); 66 return; 67 } 68 69 // put the new value on the stack 70 - await c.env.INVITE_POOL.put(inviteKey, newValue.toString()); 71 } 72 } 73 ··· 80 separator: '-', 81 capitalize: false, 82 }); 83 - c.executionCtx.waitUntil(c.env.INVITE_POOL.put(newKey, "10")); 84 return newKey; 85 }
··· 15 if (inviteKey === undefined) 16 return false; 17 18 + const value = await c.env.INVITE_POOL!.get(inviteKey); 19 // Key does not exist 20 if (value === null) 21 return false; ··· 41 if (inviteKey === undefined) 42 return; 43 44 + const value = await c.env.INVITE_POOL!.get(inviteKey); 45 if (value === null) { 46 console.error(`attempted to use invite key ${inviteKey} but is invalid`); 47 return; ··· 62 let newValue: number = amount - 1; 63 // Delete any keys that fall to 0, they should be removed from the db 64 if (newValue <= 0) { 65 + await c.env.INVITE_POOL!.delete(inviteKey); 66 return; 67 } 68 69 // put the new value on the stack 70 + await c.env.INVITE_POOL!.put(inviteKey, newValue.toString()); 71 } 72 } 73 ··· 80 separator: '-', 81 capitalize: false, 82 }); 83 + c.executionCtx.waitUntil(c.env.INVITE_POOL!.put(newKey, "10")); 84 return newKey; 85 }
+21 -22
src/utils/r2Query.ts
··· 15 R2_FILE_SIZE_LIMIT, 16 R2_FILE_SIZE_LIMIT_IN_MB 17 } from "../limits"; 18 - import { Bindings, EmbedData, EmbedDataType, R2BucketObject, ScheduledContext } from '../types.d'; 19 import { addFileListing, deleteFileListings } from './db/file'; 20 21 type FileMetaData = { ··· 26 qualityLevel?: number; 27 }; 28 29 - export const deleteEmbedsFromR2 = async (c: Context|ScheduledContext, embeds: EmbedData[]|undefined, isQueued: boolean=false) => { 30 let itemsToDelete:string[] = []; 31 32 if (embeds !== undefined && embeds.length > 0) { ··· 42 return itemsToDelete; 43 }; 44 45 - export const deleteFromR2 = async (c: Context|ScheduledContext, embeds: string[]|string, isQueued: boolean=false) => { 46 if (embeds.length <= 0) 47 return; 48 49 console.log(`Deleting ${embeds}`); 50 const killFilesPromise = c.env.R2.delete(embeds); 51 - const deleteFileListingPromise = deleteFileListings(c.env, embeds); 52 if (isQueued) { 53 await killFilesPromise; 54 await deleteFileListingPromise; ··· 58 } 59 }; 60 61 - const rawUploadToR2 = async (env: Bindings, buffer: ArrayBuffer|ReadableStream, metaData: FileMetaData) => { 62 const fileExt:string|undefined = metaData.name.split(".").pop(); 63 if (fileExt === undefined) { 64 return {"success": false, "error": "unable to upload, file name is invalid"}; 65 } 66 67 const fileName = `${uuidv4()}.${fileExt.toLowerCase()}`; 68 - const R2UploadRes = await env.R2.put(fileName, buffer, { 69 customMetadata: {"user": metaData.user, "type": metaData.type } 70 }); 71 if (R2UploadRes) { 72 - await addFileListing(env, fileName, metaData.user); 73 return {"success": true, "data": R2UploadRes.key, 74 "originalName": metaData.name, "fileSize": metaData.size, 75 "qualityLevel": metaData.qualityLevel}; ··· 80 81 const uploadImageToR2 = async(c: Context, file: File, userId: string) => { 82 const originalName = file.name; 83 - const env: Bindings = c.env; 84 // The maximum size of CF Image transforms. 85 if (file.size > CF_IMAGES_FILE_SIZE_LIMIT) { 86 return {"success": false, "error": `An image has a maximum file size of ${CF_IMAGES_FILE_SIZE_LIMIT_IN_MB}MB`}; ··· 108 if (file.size > BSKY_IMG_SIZE_LIMIT) { 109 let failedToResize = true; 110 111 - if (env.IMAGE_SETTINGS.enabled) { 112 const resizeFilename = uuidv4(); 113 - const resizeBucketPush = await env.R2RESIZE.put(resizeFilename, await file.bytes(), { 114 customMetadata: {"user": userId }, 115 httpMetadata: { contentType: file.type } 116 }); ··· 121 } 122 123 // TODO: use the image wrangler binding 124 - for (var i = 0; i < env.IMAGE_SETTINGS.steps.length; ++i) { 125 - const qualityLevel = env.IMAGE_SETTINGS.steps[i]; 126 - const response = await fetch(new URL(resizeFilename, env.IMAGE_SETTINGS.bucket_url), { 127 headers: { 128 - "x-skyscheduler-helper": env.RESIZE_SECRET_HEADER 129 }, 130 cf: { 131 image: { ··· 170 } 171 } 172 // Delete the file from the resize bucket. 173 - c.executionCtx.waitUntil(env.R2RESIZE.delete(resizeFilename)); 174 } 175 176 if (failedToResize) { ··· 189 190 if (fileToProcess === null) 191 fileToProcess = await file.arrayBuffer(); 192 - return await rawUploadToR2(env, fileToProcess, fileMetaData); 193 }; 194 195 - const uploadVideoToR2 = async (env: Bindings, file: File, userId: string) => { 196 // Technically this will never hit because it is greater than our own internal limits 197 if (file.size > BSKY_VIDEO_SIZE_LIMIT) { 198 return {"success": false, "error": `max video size is ${BSKY_VIDEO_SIZE_LIMIT}MB`}; ··· 204 type: file.type, 205 user: userId 206 }; 207 - return await rawUploadToR2(env, await file.stream(), fileMetaData); 208 }; 209 210 export const uploadFileR2 = async (c: Context, file: File|string, userId: string) => { ··· 227 if (BSKY_IMG_MIME_TYPES.includes(fileType)) { 228 return await uploadImageToR2(c, file, userId); 229 } else if (BSKY_VIDEO_MIME_TYPES.includes(fileType)) { 230 - return await uploadVideoToR2(c.env, file, userId); 231 } else if (GIF_UPLOAD_ALLOWED && BSKY_GIF_MIME_TYPES.includes(fileType)) { 232 // TODO: modify this in the future to transform the image to a webm 233 // then push to uploadVideo 234 - return await uploadVideoToR2(c.env, file, userId); 235 } 236 return {"success": false, "error": "unable to push to R2"}; 237 }; 238 239 - export const getAllFilesList = async (env: Bindings) => { 240 let options: R2ListOptions = { 241 limit: 1000, 242 include: ["customMetadata"] ··· 244 let values:R2BucketObject[] = []; 245 246 while (true) { 247 - const response = await env.R2.list(options); 248 for (const file of response.objects) { 249 values.push({ 250 name: file.key,
··· 15 R2_FILE_SIZE_LIMIT, 16 R2_FILE_SIZE_LIMIT_IN_MB 17 } from "../limits"; 18 + import { AllContext, EmbedData, EmbedDataType, R2BucketObject } from '../types.d'; 19 import { addFileListing, deleteFileListings } from './db/file'; 20 21 type FileMetaData = { ··· 26 qualityLevel?: number; 27 }; 28 29 + export const deleteEmbedsFromR2 = async (c: AllContext, embeds: EmbedData[]|undefined, isQueued: boolean=false) => { 30 let itemsToDelete:string[] = []; 31 32 if (embeds !== undefined && embeds.length > 0) { ··· 42 return itemsToDelete; 43 }; 44 45 + export const deleteFromR2 = async (c: AllContext, embeds: string[]|string, isQueued: boolean=false) => { 46 if (embeds.length <= 0) 47 return; 48 49 console.log(`Deleting ${embeds}`); 50 const killFilesPromise = c.env.R2.delete(embeds); 51 + const deleteFileListingPromise = deleteFileListings(c, embeds); 52 if (isQueued) { 53 await killFilesPromise; 54 await deleteFileListingPromise; ··· 58 } 59 }; 60 61 + const rawUploadToR2 = async (c: AllContext, buffer: ArrayBuffer|ReadableStream, metaData: FileMetaData) => { 62 const fileExt:string|undefined = metaData.name.split(".").pop(); 63 if (fileExt === undefined) { 64 return {"success": false, "error": "unable to upload, file name is invalid"}; 65 } 66 67 const fileName = `${uuidv4()}.${fileExt.toLowerCase()}`; 68 + const R2UploadRes = await c.env.R2.put(fileName, buffer, { 69 customMetadata: {"user": metaData.user, "type": metaData.type } 70 }); 71 if (R2UploadRes) { 72 + await addFileListing(c, fileName, metaData.user); 73 return {"success": true, "data": R2UploadRes.key, 74 "originalName": metaData.name, "fileSize": metaData.size, 75 "qualityLevel": metaData.qualityLevel}; ··· 80 81 const uploadImageToR2 = async(c: Context, file: File, userId: string) => { 82 const originalName = file.name; 83 // The maximum size of CF Image transforms. 84 if (file.size > CF_IMAGES_FILE_SIZE_LIMIT) { 85 return {"success": false, "error": `An image has a maximum file size of ${CF_IMAGES_FILE_SIZE_LIMIT_IN_MB}MB`}; ··· 107 if (file.size > BSKY_IMG_SIZE_LIMIT) { 108 let failedToResize = true; 109 110 + if (c.env.IMAGE_SETTINGS.enabled) { 111 const resizeFilename = uuidv4(); 112 + const resizeBucketPush = await c.env.R2RESIZE.put(resizeFilename, await file.bytes(), { 113 customMetadata: {"user": userId }, 114 httpMetadata: { contentType: file.type } 115 }); ··· 120 } 121 122 // TODO: use the image wrangler binding 123 + for (var i = 0; i < c.env.IMAGE_SETTINGS.steps.length; ++i) { 124 + const qualityLevel = c.env.IMAGE_SETTINGS.steps[i]; 125 + const response = await fetch(new URL(resizeFilename, c.env.IMAGE_SETTINGS.bucket_url!), { 126 headers: { 127 + "x-skyscheduler-helper": c.env.RESIZE_SECRET_HEADER 128 }, 129 cf: { 130 image: { ··· 169 } 170 } 171 // Delete the file from the resize bucket. 172 + c.executionCtx.waitUntil(c.env.R2RESIZE.delete(resizeFilename)); 173 } 174 175 if (failedToResize) { ··· 188 189 if (fileToProcess === null) 190 fileToProcess = await file.arrayBuffer(); 191 + return await rawUploadToR2(c, fileToProcess, fileMetaData); 192 }; 193 194 + const uploadVideoToR2 = async (c: Context, file: File, userId: string) => { 195 // Technically this will never hit because it is greater than our own internal limits 196 if (file.size > BSKY_VIDEO_SIZE_LIMIT) { 197 return {"success": false, "error": `max video size is ${BSKY_VIDEO_SIZE_LIMIT}MB`}; ··· 203 type: file.type, 204 user: userId 205 }; 206 + return await rawUploadToR2(c, await file.stream(), fileMetaData); 207 }; 208 209 export const uploadFileR2 = async (c: Context, file: File|string, userId: string) => { ··· 226 if (BSKY_IMG_MIME_TYPES.includes(fileType)) { 227 return await uploadImageToR2(c, file, userId); 228 } else if (BSKY_VIDEO_MIME_TYPES.includes(fileType)) { 229 + return await uploadVideoToR2(c, file, userId); 230 } else if (GIF_UPLOAD_ALLOWED && BSKY_GIF_MIME_TYPES.includes(fileType)) { 231 // TODO: modify this in the future to transform the image to a webm 232 // then push to uploadVideo 233 + return await uploadVideoToR2(c, file, userId); 234 } 235 return {"success": false, "error": "unable to push to R2"}; 236 }; 237 238 + export const getAllFilesList = async (c: AllContext) => { 239 let options: R2ListOptions = { 240 limit: 1000, 241 include: ["customMetadata"] ··· 243 let values:R2BucketObject[] = []; 244 245 while (true) { 246 + const response = await c.env.R2.list(options); 247 for (const file of response.objects) { 248 values.push({ 249 name: file.key,
+27 -37
src/utils/scheduler.ts
··· 1 import AtpAgent from '@atproto/api'; 2 import isEmpty from 'just-is-empty'; 3 - import { Bindings, Post, Repost, ScheduledContext } from '../types.d'; 4 import { makeAgentForUser, makePost, makeRepost } from './bskyApi'; 5 import { pruneBskyPosts } from './bskyPrune'; 6 import { ··· 11 import { enqueuePost, enqueueRepost, isQueueEnabled, isRepostQueueEnabled, shouldPostThreadQueue } from './queuePublisher'; 12 import { deleteFromR2 } from './r2Query'; 13 14 - export const handlePostTask = async(runtime: ScheduledContext, postData: Post, agent: AtpAgent|null) => { 15 const madePost = await makePost(runtime, postData, agent); 16 if (madePost) { 17 console.log(`Made post ${postData.postid} successfully`); ··· 20 } 21 return madePost; 22 } 23 - export const handleRepostTask = async(runtime: ScheduledContext, postData: Repost, agent: AtpAgent|null) => { 24 - const madeRepost = await makeRepost(runtime, postData, agent); 25 if (madeRepost) { 26 console.log(`Reposted ${postData.uri} successfully!`); 27 } else { ··· 30 return madeRepost; 31 }; 32 33 - export const schedulePostTask = async (env: Bindings, ctx: ExecutionContext) => { 34 - const scheduledPosts: Post[] = await getAllPostsForCurrentTime(env); 35 - const scheduledReposts: Repost[] = await getAllRepostsForCurrentTime(env); 36 - const queueEnabled: boolean = isQueueEnabled(env); 37 - const repostQueueEnabled: boolean = isRepostQueueEnabled(env); 38 - const threadQueueEnabled: boolean = shouldPostThreadQueue(env); 39 - 40 - const runtimeWrapper: ScheduledContext = { 41 - executionCtx: ctx, 42 - env: env 43 - }; 44 - 45 // Temporary cache of agents to make handling actions much better and easier. 46 // The only potential downside is if we run hot on RAM with a lot of users. Before, the agents would 47 // get freed up as a part of exiting their cycle, but this would make that worse... ··· 49 // TODO: bunching as a part of queues, literally just throw an agent at a queue with instructions and go. 50 // this requires queueing to be working properly. 51 const AgentList = new Map(); 52 - const usesAgentMap: boolean = (env.SITE_SETTINGS.use_agent_map) || false; 53 54 // Push any posts 55 if (!isEmpty(scheduledPosts)) { 56 console.log(`handling ${scheduledPosts.length} posts...`); 57 for (const post of scheduledPosts) { 58 if (queueEnabled || (post.isThreadRoot && threadQueueEnabled)) { 59 - await enqueuePost(env, post); 60 } else { 61 let agent = (usesAgentMap) ? AgentList.get(post.user) || null : null; 62 if (agent === null) { 63 - agent = await makeAgentForUser(env, post.user); 64 if (usesAgentMap) 65 AgentList.set(post.user, agent); 66 } 67 - ctx.waitUntil(handlePostTask(runtimeWrapper, post, agent)); 68 } 69 } 70 } else { ··· 78 if (!repostQueueEnabled) { 79 let agent = (usesAgentMap) ? AgentList.get(repost.userId) || null : null; 80 if (agent === null) { 81 - agent = await makeAgentForUser(env, repost.userId); 82 if (usesAgentMap) 83 AgentList.set(repost.userId, agent); 84 } 85 - ctx.waitUntil(handleRepostTask(runtimeWrapper, repost, agent)); 86 } else { 87 - await enqueueRepost(env, repost); 88 } 89 }; 90 - ctx.waitUntil(deleteAllRepostsBeforeCurrentTime(env)); 91 } else { 92 console.log("no reposts scheduled for this time"); 93 } 94 }; 95 96 - export const cleanUpPostsTask = async(env: Bindings, ctx: ExecutionContext) => { 97 - const purgedPosts: number = await purgePostedPosts(env); 98 console.log(`Purged ${purgedPosts} old posts from the database`); 99 100 - const removedIds: string[] = await pruneBskyPosts(env); 101 if (!isEmpty(removedIds)) { 102 - const deletedItems: number = await deletePosts(env, removedIds); 103 console.log(`Deleted ${deletedItems} missing posts from the db`); 104 } 105 - if (env.R2_SETTINGS.auto_prune === true) 106 - await cleanupAbandonedFiles(env, ctx); 107 }; 108 109 - export const cleanupAbandonedFiles = async(env: Bindings, ctx: ExecutionContext) => { 110 - const abandonedFiles: string[] = await getAllAbandonedMedia(env); 111 - const runtimeWrapper: ScheduledContext = { 112 - executionCtx: ctx, 113 - env: env 114 - }; 115 if (!isEmpty(abandonedFiles)) { 116 - await deleteFromR2(runtimeWrapper, abandonedFiles); 117 } 118 };
··· 1 import AtpAgent from '@atproto/api'; 2 import isEmpty from 'just-is-empty'; 3 + import { AllContext, Post, Repost } from '../types.d'; 4 import { makeAgentForUser, makePost, makeRepost } from './bskyApi'; 5 import { pruneBskyPosts } from './bskyPrune'; 6 import { ··· 11 import { enqueuePost, enqueueRepost, isQueueEnabled, isRepostQueueEnabled, shouldPostThreadQueue } from './queuePublisher'; 12 import { deleteFromR2 } from './r2Query'; 13 14 + export const handlePostTask = async(runtime: AllContext, postData: Post, agent: AtpAgent|null) => { 15 const madePost = await makePost(runtime, postData, agent); 16 if (madePost) { 17 console.log(`Made post ${postData.postid} successfully`); ··· 20 } 21 return madePost; 22 } 23 + export const handleRepostTask = async(c: AllContext, postData: Repost, agent: AtpAgent|null) => { 24 + const madeRepost = await makeRepost(c, postData, agent); 25 if (madeRepost) { 26 console.log(`Reposted ${postData.uri} successfully!`); 27 } else { ··· 30 return madeRepost; 31 }; 32 33 + export const schedulePostTask = async (c: AllContext) => { 34 + const scheduledPosts: Post[] = await getAllPostsForCurrentTime(c); 35 + const scheduledReposts: Repost[] = await getAllRepostsForCurrentTime(c); 36 + const queueEnabled: boolean = isQueueEnabled(c.env); 37 + const repostQueueEnabled: boolean = isRepostQueueEnabled(c.env); 38 + const threadQueueEnabled: boolean = shouldPostThreadQueue(c.env); 39 // Temporary cache of agents to make handling actions much better and easier. 40 // The only potential downside is if we run hot on RAM with a lot of users. Before, the agents would 41 // get freed up as a part of exiting their cycle, but this would make that worse... ··· 43 // TODO: bunching as a part of queues, literally just throw an agent at a queue with instructions and go. 44 // this requires queueing to be working properly. 45 const AgentList = new Map(); 46 + const usesAgentMap: boolean = (c.env.SITE_SETTINGS.use_agent_map) || false; 47 48 // Push any posts 49 if (!isEmpty(scheduledPosts)) { 50 console.log(`handling ${scheduledPosts.length} posts...`); 51 for (const post of scheduledPosts) { 52 if (queueEnabled || (post.isThreadRoot && threadQueueEnabled)) { 53 + await enqueuePost(c, post); 54 } else { 55 let agent = (usesAgentMap) ? AgentList.get(post.user) || null : null; 56 if (agent === null) { 57 + agent = await makeAgentForUser(c, post.user); 58 if (usesAgentMap) 59 AgentList.set(post.user, agent); 60 } 61 + c.ctx.waitUntil(handlePostTask(c, post, agent)); 62 } 63 } 64 } else { ··· 72 if (!repostQueueEnabled) { 73 let agent = (usesAgentMap) ? AgentList.get(repost.userId) || null : null; 74 if (agent === null) { 75 + agent = await makeAgentForUser(c, repost.userId); 76 if (usesAgentMap) 77 AgentList.set(repost.userId, agent); 78 } 79 + c.ctx.waitUntil(handleRepostTask(c, repost, agent)); 80 } else { 81 + await enqueueRepost(c, repost); 82 } 83 }; 84 + c.ctx.waitUntil(deleteAllRepostsBeforeCurrentTime(c)); 85 } else { 86 console.log("no reposts scheduled for this time"); 87 } 88 }; 89 90 + export const cleanUpPostsTask = async(c: AllContext) => { 91 + const purgedPosts: number = await purgePostedPosts(c); 92 console.log(`Purged ${purgedPosts} old posts from the database`); 93 94 + const removedIds: string[] = await pruneBskyPosts(c); 95 if (!isEmpty(removedIds)) { 96 + const deletedItems: number = await deletePosts(c, removedIds); 97 console.log(`Deleted ${deletedItems} missing posts from the db`); 98 } 99 + if (c.env.R2_SETTINGS.auto_prune === true) 100 + await cleanupAbandonedFiles(c); 101 }; 102 103 + export const cleanupAbandonedFiles = async(c: AllContext) => { 104 + const abandonedFiles: string[] = await getAllAbandonedMedia(c); 105 if (!isEmpty(abandonedFiles)) { 106 + await deleteFromR2(c, abandonedFiles); 107 } 108 };
+32 -8
src/wrangler.d.ts
··· 1 /* eslint-disable */ 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 declare namespace Cloudflare { 5 interface GlobalProps { 6 mainModule: typeof import("./index"); 7 } 8 - interface Env { 9 KV: KVNamespace; 10 - INVITE_POOL: KVNamespace; 11 R2: R2Bucket; 12 - R2RESIZE: R2Bucket; 13 DB: D1Database; 14 - POST_QUEUE1: Queue; 15 IMAGES: ImagesBinding; 16 - IMAGE_SETTINGS: {"enabled":true,"steps":[95,85,75],"bucket_url":"https://resize.skyscheduler.work/"}; 17 - SIGNUP_SETTINGS: {"use_captcha":true,"invite_only":false,"invite_thread":"https://bsky.app/profile/skyscheduler.work/post/3ltsfnzdmkk2l"}; 18 QUEUE_SETTINGS: {"enabled":false,"repostsEnabled":false,"postNowEnabled":false,"threadEnabled":true,"delay_val":100,"post_queues":["POST_QUEUE1"],"repost_queues":[]}; 19 REDIRECTS: {"contact":"https://bsky.app/profile/skyscheduler.work","tip":"https://ko-fi.com/socksthewolf/tip"}; 20 - R2_SETTINGS: {"auto_prune":true,"prune_days":3}; 21 SITE_SETTINGS: {"use_agent_map":false}; 22 DEFAULT_ADMIN_USER: string; 23 DEFAULT_ADMIN_PASS: string; 24 DEFAULT_ADMIN_BSKY_PASS: string; 25 BETTER_AUTH_SECRET: string; 26 BETTER_AUTH_URL: string; 27 TURNSTILE_PUBLIC_KEY: string; 28 TURNSTILE_SECRET_KEY: string; 29 RESET_BOT_USERNAME: string; 30 RESET_BOT_APP_PASS: string; 31 RESIZE_SECRET_HEADER: string; 32 IN_DEV: string; 33 } 34 } 35 interface Env extends Cloudflare.Env {}
··· 1 /* eslint-disable */ 2 + // Generated by Wrangler by running `wrangler types src/wrangler.d.ts` (hash: d6fc3c7066eb7e2adc3a6e0ae96bae05) 3 // Runtime types generated with workerd@1.20260212.0 2024-12-13 nodejs_compat 4 declare namespace Cloudflare { 5 interface GlobalProps { 6 mainModule: typeof import("./index"); 7 } 8 + interface StagingEnv { 9 KV: KVNamespace; 10 R2: R2Bucket; 11 DB: D1Database; 12 IMAGES: ImagesBinding; 13 + IMAGE_SETTINGS: {"enabled":false}; 14 + SIGNUP_SETTINGS: {"use_captcha":false,"invite_only":false,"invite_thread":""}; 15 QUEUE_SETTINGS: {"enabled":false,"repostsEnabled":false,"postNowEnabled":false,"threadEnabled":true,"delay_val":100,"post_queues":["POST_QUEUE1"],"repost_queues":[]}; 16 REDIRECTS: {"contact":"https://bsky.app/profile/skyscheduler.work","tip":"https://ko-fi.com/socksthewolf/tip"}; 17 + R2_SETTINGS: {"auto_prune":false,"prune_days":3}; 18 SITE_SETTINGS: {"use_agent_map":false}; 19 + BETTER_AUTH_SECRET: string; 20 + BETTER_AUTH_URL: string; 21 DEFAULT_ADMIN_USER: string; 22 DEFAULT_ADMIN_PASS: string; 23 DEFAULT_ADMIN_BSKY_PASS: string; 24 + TURNSTILE_PUBLIC_KEY: string; 25 + TURNSTILE_SECRET_KEY: string; 26 + RESET_BOT_USERNAME: string; 27 + RESET_BOT_APP_PASS: string; 28 + RESIZE_SECRET_HEADER: string; 29 + IN_DEV: string; 30 + } 31 + interface Env { 32 BETTER_AUTH_SECRET: string; 33 BETTER_AUTH_URL: string; 34 + DEFAULT_ADMIN_USER: string; 35 + DEFAULT_ADMIN_PASS: string; 36 + DEFAULT_ADMIN_BSKY_PASS: string; 37 TURNSTILE_PUBLIC_KEY: string; 38 TURNSTILE_SECRET_KEY: string; 39 RESET_BOT_USERNAME: string; 40 RESET_BOT_APP_PASS: string; 41 RESIZE_SECRET_HEADER: string; 42 IN_DEV: string; 43 + KV: KVNamespace; 44 + R2: R2Bucket; 45 + DB: D1Database; 46 + IMAGES: ImagesBinding; 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":""}; 49 + QUEUE_SETTINGS: {"enabled":false,"repostsEnabled":false,"postNowEnabled":false,"threadEnabled":true,"delay_val":100,"post_queues":["POST_QUEUE1"],"repost_queues":[]}; 50 + REDIRECTS: {"contact":"https://bsky.app/profile/skyscheduler.work","tip":"https://ko-fi.com/socksthewolf/tip"}; 51 + R2_SETTINGS: {"auto_prune":false,"prune_days":3} | {"auto_prune":true,"prune_days":3}; 52 + SITE_SETTINGS: {"use_agent_map":false}; 53 + INVITE_POOL?: KVNamespace; 54 + R2RESIZE?: R2Bucket; 55 + DBStaging?: D1Database; 56 + POST_QUEUE1?: Queue; 57 } 58 } 59 interface Env extends Cloudflare.Env {}
+50 -6
wrangler.toml
··· 1 name = "skyscheduler" 2 main = "src/index.tsx" 3 compatibility_date = "2024-12-13" ··· 10 assets = { run_worker_first = false, directory = "./assets/", not_found_handling = "single-page-application" } 11 routes = [{ pattern = "skyscheduler.work", custom_domain = true }] 12 13 - kv_namespaces = [ 14 - { binding = "KV", id = "0ecd892014ac47eea38c72be94586e7b" }, # Redis for sessions 15 - { binding = "INVITE_POOL", id = "45e1cff45cad45c28b22c4fbcd30db00" } # Invite key pool 16 - ] 17 18 [[d1_databases]] 19 binding = "DB" ··· 22 migrations_table = "migrations" 23 migrations_dir = "migrations" 24 25 # media storage 26 [[r2_buckets]] 27 binding = "R2" ··· 71 IMAGE_SETTINGS={enabled=true, steps=[95, 85, 75], bucket_url="https://resize.skyscheduler.work/"} 72 73 # Signup options and if keys should be used 74 - SIGNUP_SETTINGS = {use_captcha=true, invite_only=false, invite_thread="https://bsky.app/profile/skyscheduler.work/post/3ltsfnzdmkk2l"} 75 76 # queue handling, pushing information. This is experimental. 77 QUEUE_SETTINGS = {enabled=false, repostsEnabled=false, postNowEnabled=false, threadEnabled=true, delay_val=100, post_queues=["POST_QUEUE1"], repost_queues=[]} ··· 86 SITE_SETTINGS={use_agent_map=false} 87 88 # set this to true in your .dev.vars to turn off turnstile 89 - IN_DEV=false
··· 1 + "$schema" = "./node_modules/wrangler/config-schema.json" 2 name = "skyscheduler" 3 main = "src/index.tsx" 4 compatibility_date = "2024-12-13" ··· 11 assets = { run_worker_first = false, directory = "./assets/", not_found_handling = "single-page-application" } 12 routes = [{ pattern = "skyscheduler.work", custom_domain = true }] 13 14 + # Redis for sessions 15 + [[kv_namespaces]] 16 + binding = "KV" 17 + id = "0ecd892014ac47eea38c72be94586e7b" 18 + 19 + # Invite key pool 20 + [[kv_namespaces]] 21 + binding = "INVITE_POOL" 22 + id = "45e1cff45cad45c28b22c4fbcd30db00" 23 24 [[d1_databases]] 25 binding = "DB" ··· 28 migrations_table = "migrations" 29 migrations_dir = "migrations" 30 31 + [[d1_databases]] 32 + binding = "DBStaging" 33 + database_name = "skyposts-dev" 34 + database_id = "9e9e3275-c12e-4209-9b8c-a1ee57f7bb8d" 35 + migrations_table = "migrations" 36 + migrations_dir = "migrations" 37 + 38 # media storage 39 [[r2_buckets]] 40 binding = "R2" ··· 84 IMAGE_SETTINGS={enabled=true, steps=[95, 85, 75], bucket_url="https://resize.skyscheduler.work/"} 85 86 # Signup options and if keys should be used 87 + SIGNUP_SETTINGS = {use_captcha=true, invite_only=false, invite_thread=""} 88 89 # queue handling, pushing information. This is experimental. 90 QUEUE_SETTINGS = {enabled=false, repostsEnabled=false, postNowEnabled=false, threadEnabled=true, delay_val=100, post_queues=["POST_QUEUE1"], repost_queues=[]} ··· 99 SITE_SETTINGS={use_agent_map=false} 100 101 # set this to true in your .dev.vars to turn off turnstile 102 + IN_DEV=false 103 + 104 + [env.staging] 105 + workers_dev = true 106 + 107 + [env.staging.vars] 108 + BETTER_AUTH_URL="*" 109 + IMAGE_SETTINGS={enabled=false} 110 + SIGNUP_SETTINGS = {use_captcha=false, invite_only=false, invite_thread=""} 111 + QUEUE_SETTINGS = {enabled=false, repostsEnabled=false, postNowEnabled=false, threadEnabled=true, delay_val=100, post_queues=["POST_QUEUE1"], repost_queues=[]} 112 + REDIRECTS = {contact="https://bsky.app/profile/skyscheduler.work", tip="https://ko-fi.com/socksthewolf/tip"} 113 + R2_SETTINGS={auto_prune=false, prune_days=3} 114 + SITE_SETTINGS={use_agent_map=false} 115 + IN_DEV=true 116 + 117 + [[env.staging.d1_databases]] 118 + binding = "DB" 119 + database_name = "skyposts-dev" 120 + database_id = "9e9e3275-c12e-4209-9b8c-a1ee57f7bb8d" 121 + migrations_table = "migrations" 122 + migrations_dir = "migrations" 123 + 124 + [[env.staging.kv_namespaces]] 125 + binding = "KV" 126 + id = "9b9c6cdfd40e405aa3df63544a553b08" 127 + 128 + [env.staging.images] 129 + binding = "IMAGES" 130 + 131 + [[env.staging.r2_buckets]] 132 + binding = "R2" 133 + bucket_name = "skyembeds-dev"