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

add violations handling + other updates

closes #47

This also helps fix some browser hinting for password fields, and standardizes some elements

+875 -49
+11
migrations/0003_redundant_wasp.sql
···
··· 1 + CREATE TABLE `violations` ( 2 + `user` text PRIMARY KEY NOT NULL, 3 + `tos` integer DEFAULT false, 4 + `invalidPW` integer DEFAULT false, 5 + `accountSuspended` integer DEFAULT false, 6 + `accountGone` integer DEFAULT false, 7 + `created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL, 8 + FOREIGN KEY (`user`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade 9 + ); 10 + --> statement-breakpoint 11 + CREATE INDEX `violations_user_idx` ON `violations` (`user`);
+648
migrations/meta/0003_snapshot.json
···
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "cb043f9c-8b17-4030-8f55-24bb87a5b00e", 5 + "prevId": "8a5035e9-0abf-4c14-aa99-82d8c2569a6f", 6 + "tables": { 7 + "accounts": { 8 + "name": "accounts", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "account_id": { 18 + "name": "account_id", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "provider_id": { 25 + "name": "provider_id", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "user_id": { 32 + "name": "user_id", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + }, 38 + "access_token": { 39 + "name": "access_token", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "refresh_token": { 46 + "name": "refresh_token", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": false, 50 + "autoincrement": false 51 + }, 52 + "id_token": { 53 + "name": "id_token", 54 + "type": "text", 55 + "primaryKey": false, 56 + "notNull": false, 57 + "autoincrement": false 58 + }, 59 + "access_token_expires_at": { 60 + "name": "access_token_expires_at", 61 + "type": "integer", 62 + "primaryKey": false, 63 + "notNull": false, 64 + "autoincrement": false 65 + }, 66 + "refresh_token_expires_at": { 67 + "name": "refresh_token_expires_at", 68 + "type": "integer", 69 + "primaryKey": false, 70 + "notNull": false, 71 + "autoincrement": false 72 + }, 73 + "scope": { 74 + "name": "scope", 75 + "type": "text", 76 + "primaryKey": false, 77 + "notNull": false, 78 + "autoincrement": false 79 + }, 80 + "password": { 81 + "name": "password", 82 + "type": "text", 83 + "primaryKey": false, 84 + "notNull": false, 85 + "autoincrement": false 86 + }, 87 + "created_at": { 88 + "name": "created_at", 89 + "type": "integer", 90 + "primaryKey": false, 91 + "notNull": true, 92 + "autoincrement": false, 93 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 94 + }, 95 + "updated_at": { 96 + "name": "updated_at", 97 + "type": "integer", 98 + "primaryKey": false, 99 + "notNull": true, 100 + "autoincrement": false 101 + } 102 + }, 103 + "indexes": { 104 + "accounts_userId_idx": { 105 + "name": "accounts_userId_idx", 106 + "columns": [ 107 + "user_id" 108 + ], 109 + "isUnique": false 110 + } 111 + }, 112 + "foreignKeys": { 113 + "accounts_user_id_users_id_fk": { 114 + "name": "accounts_user_id_users_id_fk", 115 + "tableFrom": "accounts", 116 + "tableTo": "users", 117 + "columnsFrom": [ 118 + "user_id" 119 + ], 120 + "columnsTo": [ 121 + "id" 122 + ], 123 + "onDelete": "cascade", 124 + "onUpdate": "no action" 125 + } 126 + }, 127 + "compositePrimaryKeys": {}, 128 + "uniqueConstraints": {}, 129 + "checkConstraints": {} 130 + }, 131 + "sessions": { 132 + "name": "sessions", 133 + "columns": { 134 + "id": { 135 + "name": "id", 136 + "type": "text", 137 + "primaryKey": true, 138 + "notNull": true, 139 + "autoincrement": false 140 + }, 141 + "expires_at": { 142 + "name": "expires_at", 143 + "type": "integer", 144 + "primaryKey": false, 145 + "notNull": true, 146 + "autoincrement": false 147 + }, 148 + "token": { 149 + "name": "token", 150 + "type": "text", 151 + "primaryKey": false, 152 + "notNull": true, 153 + "autoincrement": false 154 + }, 155 + "created_at": { 156 + "name": "created_at", 157 + "type": "integer", 158 + "primaryKey": false, 159 + "notNull": true, 160 + "autoincrement": false, 161 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 162 + }, 163 + "updated_at": { 164 + "name": "updated_at", 165 + "type": "integer", 166 + "primaryKey": false, 167 + "notNull": true, 168 + "autoincrement": false 169 + }, 170 + "ip_address": { 171 + "name": "ip_address", 172 + "type": "text", 173 + "primaryKey": false, 174 + "notNull": false, 175 + "autoincrement": false 176 + }, 177 + "user_agent": { 178 + "name": "user_agent", 179 + "type": "text", 180 + "primaryKey": false, 181 + "notNull": false, 182 + "autoincrement": false 183 + }, 184 + "user_id": { 185 + "name": "user_id", 186 + "type": "text", 187 + "primaryKey": false, 188 + "notNull": true, 189 + "autoincrement": false 190 + } 191 + }, 192 + "indexes": { 193 + "sessions_token_unique": { 194 + "name": "sessions_token_unique", 195 + "columns": [ 196 + "token" 197 + ], 198 + "isUnique": true 199 + }, 200 + "sessions_userId_idx": { 201 + "name": "sessions_userId_idx", 202 + "columns": [ 203 + "user_id" 204 + ], 205 + "isUnique": false 206 + } 207 + }, 208 + "foreignKeys": { 209 + "sessions_user_id_users_id_fk": { 210 + "name": "sessions_user_id_users_id_fk", 211 + "tableFrom": "sessions", 212 + "tableTo": "users", 213 + "columnsFrom": [ 214 + "user_id" 215 + ], 216 + "columnsTo": [ 217 + "id" 218 + ], 219 + "onDelete": "cascade", 220 + "onUpdate": "no action" 221 + } 222 + }, 223 + "compositePrimaryKeys": {}, 224 + "uniqueConstraints": {}, 225 + "checkConstraints": {} 226 + }, 227 + "users": { 228 + "name": "users", 229 + "columns": { 230 + "id": { 231 + "name": "id", 232 + "type": "text", 233 + "primaryKey": true, 234 + "notNull": true, 235 + "autoincrement": false 236 + }, 237 + "name": { 238 + "name": "name", 239 + "type": "text", 240 + "primaryKey": false, 241 + "notNull": true, 242 + "autoincrement": false 243 + }, 244 + "email": { 245 + "name": "email", 246 + "type": "text", 247 + "primaryKey": false, 248 + "notNull": true, 249 + "autoincrement": false 250 + }, 251 + "email_verified": { 252 + "name": "email_verified", 253 + "type": "integer", 254 + "primaryKey": false, 255 + "notNull": true, 256 + "autoincrement": false 257 + }, 258 + "image": { 259 + "name": "image", 260 + "type": "text", 261 + "primaryKey": false, 262 + "notNull": false, 263 + "autoincrement": false 264 + }, 265 + "created_at": { 266 + "name": "created_at", 267 + "type": "integer", 268 + "primaryKey": false, 269 + "notNull": true, 270 + "autoincrement": false 271 + }, 272 + "updated_at": { 273 + "name": "updated_at", 274 + "type": "integer", 275 + "primaryKey": false, 276 + "notNull": true, 277 + "autoincrement": false 278 + }, 279 + "username": { 280 + "name": "username", 281 + "type": "text", 282 + "primaryKey": false, 283 + "notNull": false, 284 + "autoincrement": false 285 + }, 286 + "display_username": { 287 + "name": "display_username", 288 + "type": "text", 289 + "primaryKey": false, 290 + "notNull": false, 291 + "autoincrement": false 292 + }, 293 + "bsky_app_pass": { 294 + "name": "bsky_app_pass", 295 + "type": "text", 296 + "primaryKey": false, 297 + "notNull": true, 298 + "autoincrement": false 299 + }, 300 + "pds": { 301 + "name": "pds", 302 + "type": "text", 303 + "primaryKey": false, 304 + "notNull": true, 305 + "autoincrement": false, 306 + "default": "'https://bsky.social'" 307 + } 308 + }, 309 + "indexes": { 310 + "users_email_unique": { 311 + "name": "users_email_unique", 312 + "columns": [ 313 + "email" 314 + ], 315 + "isUnique": true 316 + }, 317 + "users_username_unique": { 318 + "name": "users_username_unique", 319 + "columns": [ 320 + "username" 321 + ], 322 + "isUnique": true 323 + } 324 + }, 325 + "foreignKeys": {}, 326 + "compositePrimaryKeys": {}, 327 + "uniqueConstraints": {}, 328 + "checkConstraints": {} 329 + }, 330 + "verifications": { 331 + "name": "verifications", 332 + "columns": { 333 + "id": { 334 + "name": "id", 335 + "type": "text", 336 + "primaryKey": true, 337 + "notNull": true, 338 + "autoincrement": false 339 + }, 340 + "identifier": { 341 + "name": "identifier", 342 + "type": "text", 343 + "primaryKey": false, 344 + "notNull": true, 345 + "autoincrement": false 346 + }, 347 + "value": { 348 + "name": "value", 349 + "type": "text", 350 + "primaryKey": false, 351 + "notNull": true, 352 + "autoincrement": false 353 + }, 354 + "expires_at": { 355 + "name": "expires_at", 356 + "type": "integer", 357 + "primaryKey": false, 358 + "notNull": true, 359 + "autoincrement": false 360 + }, 361 + "created_at": { 362 + "name": "created_at", 363 + "type": "integer", 364 + "primaryKey": false, 365 + "notNull": true, 366 + "autoincrement": false, 367 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 368 + }, 369 + "updated_at": { 370 + "name": "updated_at", 371 + "type": "integer", 372 + "primaryKey": false, 373 + "notNull": true, 374 + "autoincrement": false, 375 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 376 + } 377 + }, 378 + "indexes": { 379 + "verifications_identifier_idx": { 380 + "name": "verifications_identifier_idx", 381 + "columns": [ 382 + "identifier" 383 + ], 384 + "isUnique": false 385 + } 386 + }, 387 + "foreignKeys": {}, 388 + "compositePrimaryKeys": {}, 389 + "uniqueConstraints": {}, 390 + "checkConstraints": {} 391 + }, 392 + "posts": { 393 + "name": "posts", 394 + "columns": { 395 + "uuid": { 396 + "name": "uuid", 397 + "type": "text", 398 + "primaryKey": true, 399 + "notNull": true, 400 + "autoincrement": false 401 + }, 402 + "content": { 403 + "name": "content", 404 + "type": "text", 405 + "primaryKey": false, 406 + "notNull": true, 407 + "autoincrement": false 408 + }, 409 + "scheduled_date": { 410 + "name": "scheduled_date", 411 + "type": "integer", 412 + "primaryKey": false, 413 + "notNull": true, 414 + "autoincrement": false 415 + }, 416 + "posted": { 417 + "name": "posted", 418 + "type": "integer", 419 + "primaryKey": false, 420 + "notNull": false, 421 + "autoincrement": false, 422 + "default": false 423 + }, 424 + "embedContent": { 425 + "name": "embedContent", 426 + "type": "text", 427 + "primaryKey": false, 428 + "notNull": true, 429 + "autoincrement": false, 430 + "default": "(json_array())" 431 + }, 432 + "uri": { 433 + "name": "uri", 434 + "type": "text", 435 + "primaryKey": false, 436 + "notNull": false, 437 + "autoincrement": false 438 + }, 439 + "cid": { 440 + "name": "cid", 441 + "type": "text", 442 + "primaryKey": false, 443 + "notNull": false, 444 + "autoincrement": false 445 + }, 446 + "contentLabel": { 447 + "name": "contentLabel", 448 + "type": "text", 449 + "primaryKey": false, 450 + "notNull": true, 451 + "autoincrement": false, 452 + "default": "'None'" 453 + }, 454 + "created_at": { 455 + "name": "created_at", 456 + "type": "integer", 457 + "primaryKey": false, 458 + "notNull": true, 459 + "autoincrement": false, 460 + "default": "CURRENT_TIMESTAMP" 461 + }, 462 + "user": { 463 + "name": "user", 464 + "type": "text", 465 + "primaryKey": false, 466 + "notNull": true, 467 + "autoincrement": false 468 + } 469 + }, 470 + "indexes": { 471 + "scheduledDate_idx": { 472 + "name": "scheduledDate_idx", 473 + "columns": [ 474 + "scheduled_date" 475 + ], 476 + "isUnique": false 477 + }, 478 + "user_idx": { 479 + "name": "user_idx", 480 + "columns": [ 481 + "user" 482 + ], 483 + "isUnique": false 484 + } 485 + }, 486 + "foreignKeys": { 487 + "posts_user_users_id_fk": { 488 + "name": "posts_user_users_id_fk", 489 + "tableFrom": "posts", 490 + "tableTo": "users", 491 + "columnsFrom": [ 492 + "user" 493 + ], 494 + "columnsTo": [ 495 + "id" 496 + ], 497 + "onDelete": "cascade", 498 + "onUpdate": "no action" 499 + } 500 + }, 501 + "compositePrimaryKeys": {}, 502 + "uniqueConstraints": {}, 503 + "checkConstraints": {} 504 + }, 505 + "reposts": { 506 + "name": "reposts", 507 + "columns": { 508 + "id": { 509 + "name": "id", 510 + "type": "integer", 511 + "primaryKey": true, 512 + "notNull": true, 513 + "autoincrement": true 514 + }, 515 + "post_uuid": { 516 + "name": "post_uuid", 517 + "type": "text", 518 + "primaryKey": false, 519 + "notNull": true, 520 + "autoincrement": false 521 + }, 522 + "scheduled_date": { 523 + "name": "scheduled_date", 524 + "type": "integer", 525 + "primaryKey": false, 526 + "notNull": true, 527 + "autoincrement": false 528 + } 529 + }, 530 + "indexes": { 531 + "repost_scheduledDate_idx": { 532 + "name": "repost_scheduledDate_idx", 533 + "columns": [ 534 + "scheduled_date" 535 + ], 536 + "isUnique": false 537 + } 538 + }, 539 + "foreignKeys": { 540 + "reposts_post_uuid_posts_uuid_fk": { 541 + "name": "reposts_post_uuid_posts_uuid_fk", 542 + "tableFrom": "reposts", 543 + "tableTo": "posts", 544 + "columnsFrom": [ 545 + "post_uuid" 546 + ], 547 + "columnsTo": [ 548 + "uuid" 549 + ], 550 + "onDelete": "cascade", 551 + "onUpdate": "no action" 552 + } 553 + }, 554 + "compositePrimaryKeys": {}, 555 + "uniqueConstraints": {}, 556 + "checkConstraints": {} 557 + }, 558 + "violations": { 559 + "name": "violations", 560 + "columns": { 561 + "user": { 562 + "name": "user", 563 + "type": "text", 564 + "primaryKey": true, 565 + "notNull": true, 566 + "autoincrement": false 567 + }, 568 + "tos": { 569 + "name": "tos", 570 + "type": "integer", 571 + "primaryKey": false, 572 + "notNull": false, 573 + "autoincrement": false, 574 + "default": false 575 + }, 576 + "invalidPW": { 577 + "name": "invalidPW", 578 + "type": "integer", 579 + "primaryKey": false, 580 + "notNull": false, 581 + "autoincrement": false, 582 + "default": false 583 + }, 584 + "accountSuspended": { 585 + "name": "accountSuspended", 586 + "type": "integer", 587 + "primaryKey": false, 588 + "notNull": false, 589 + "autoincrement": false, 590 + "default": false 591 + }, 592 + "accountGone": { 593 + "name": "accountGone", 594 + "type": "integer", 595 + "primaryKey": false, 596 + "notNull": false, 597 + "autoincrement": false, 598 + "default": false 599 + }, 600 + "created_at": { 601 + "name": "created_at", 602 + "type": "integer", 603 + "primaryKey": false, 604 + "notNull": true, 605 + "autoincrement": false, 606 + "default": "CURRENT_TIMESTAMP" 607 + } 608 + }, 609 + "indexes": { 610 + "violations_user_idx": { 611 + "name": "violations_user_idx", 612 + "columns": [ 613 + "user" 614 + ], 615 + "isUnique": false 616 + } 617 + }, 618 + "foreignKeys": { 619 + "violations_user_users_id_fk": { 620 + "name": "violations_user_users_id_fk", 621 + "tableFrom": "violations", 622 + "tableTo": "users", 623 + "columnsFrom": [ 624 + "user" 625 + ], 626 + "columnsTo": [ 627 + "id" 628 + ], 629 + "onDelete": "cascade", 630 + "onUpdate": "no action" 631 + } 632 + }, 633 + "compositePrimaryKeys": {}, 634 + "uniqueConstraints": {}, 635 + "checkConstraints": {} 636 + } 637 + }, 638 + "views": {}, 639 + "enums": {}, 640 + "_meta": { 641 + "schemas": {}, 642 + "tables": {}, 643 + "columns": {} 644 + }, 645 + "internal": { 646 + "indexes": {} 647 + } 648 + }
+7
migrations/meta/_journal.json
··· 22 "when": 1766647841371, 23 "tag": "0002_nice_sunset_bain", 24 "breakpoints": true 25 } 26 ] 27 }
··· 22 "when": 1766647841371, 23 "tag": "0002_nice_sunset_bain", 24 "breakpoints": true 25 + }, 26 + { 27 + "idx": 3, 28 + "version": "6", 29 + "when": 1766702339895, 30 + "tag": "0003_redundant_wasp", 31 + "breakpoints": true 32 } 33 ] 34 }
+15
src/db/app.schema.ts
··· 32 }, (table) => [ 33 index("repost_scheduledDate_idx").on(table.scheduledDate), 34 ]);
··· 32 }, (table) => [ 33 index("repost_scheduledDate_idx").on(table.scheduledDate), 34 ]); 35 + 36 + export const violations = sqliteTable('violations', { 37 + userId: text("user") 38 + .notNull() 39 + .references(() => users.id, { onDelete: "cascade" }).primaryKey(), 40 + tosViolation: integer('tos', { mode: 'boolean' }).default(false), 41 + userPassInvalid: integer('invalidPW', { mode: 'boolean' }).default(false), 42 + accountSuspended: integer('accountSuspended', { mode: 'boolean' }).default(false), 43 + accountGone: integer('accountGone', { mode: 'boolean' }).default(false), 44 + createdAt: integer('created_at', { mode: 'timestamp_ms' }) 45 + .default(sql`CURRENT_TIMESTAMP`) 46 + .notNull(), 47 + }, (table) => [ 48 + index("violations_user_idx").on(table.userId) 49 + ]);
+37
src/layout/passwordFields.tsx
···
··· 1 + import { html } from "hono/html"; 2 + import { BSKY_MAX_APP_PASSWORD_LENGTH, MAX_DASHBOARD_PASS, MIN_DASHBOARD_PASS } from "../limits.d"; 3 + import { PWAutoCompleteSettings } from "../types.d"; 4 + 5 + type PasswordFieldSettings = { 6 + required?: boolean 7 + } 8 + 9 + type DashboardPasswordFieldSettings = { 10 + required?: boolean 11 + autocomplete: PWAutoCompleteSettings 12 + } 13 + 14 + export function BSkyAppPasswordField(props: PasswordFieldSettings) { 15 + const requiredAttr:string = props.required ? "required" : ""; 16 + return html`<input type="password" name="bskyAppPassword" maxlength=${BSKY_MAX_APP_PASSWORD_LENGTH} placeholder="" ${requiredAttr} 17 + data-1p-ignore data-bwignore data-lpignore="true" data-protonpass-ignore="true" autocomplete="off" />`; 18 + } 19 + 20 + export function DashboardPasswordField(props: DashboardPasswordFieldSettings) { 21 + const requiredAttr:string = props.required ? "required" : ""; 22 + let autocompleteSetting:string = ""; 23 + switch (props.autocomplete) { 24 + default: 25 + case PWAutoCompleteSettings.Off: 26 + autocompleteSetting = "off"; 27 + break; 28 + case PWAutoCompleteSettings.CurrentPass: 29 + autocompleteSetting = "current-password"; 30 + break; 31 + case PWAutoCompleteSettings.NewPass: 32 + autocompleteSetting = "new-password"; 33 + break; 34 + } 35 + return html`<input id="password" type="password" name="password" minlength=${MIN_DASHBOARD_PASS} 36 + maxlength=${MAX_DASHBOARD_PASS} ${requiredAttr} autocomplete=${autocompleteSetting} />`; 37 + }
+9 -13
src/layout/settings.tsx
··· 1 import { html } from "hono/html"; 2 - import { BSKY_MAX_APP_PASSWORD_LENGTH, MAX_DASHBOARD_PASS, MIN_DASHBOARD_PASS } from "../limits.d"; 3 - import UsernameField from "./usernameField"; 4 5 export function Settings() { 6 return ( ··· 22 23 <label> 24 Dashboard Pass: 25 - <input type="password" name="password" minlength={MIN_DASHBOARD_PASS} maxlength={MAX_DASHBOARD_PASS} /> 26 <small>The password to access this website</small> 27 </label> 28 <label> 29 BSky App Password: 30 - <input type="password" name="bskyAppPassword" maxlength={BSKY_MAX_APP_PASSWORD_LENGTH} /> 31 - <small>If you need to change your application password for whatever reason</small> 32 </label> 33 <label> 34 BSky PDS: ··· 36 <small>If you have not changed your PDS (or do not know what that means), you should leave this blank!</small> 37 </label> 38 </form> 39 - <br /> 40 <progress id="spinner" class="htmx-indicator" /> 41 - <center> 42 - <div id="accountResponse"> 43 - </div> 44 - </center> 45 </section> 46 <footer> 47 <button id="deleteAccountButton" class="btn-error" style="float: left;">Delete</button> ··· 63 <small>The password to access this website</small> 64 </label> 65 </form> 66 - <br /> 67 <progress id="delSpinner" class="htmx-indicator" /> 68 - <center> 69 <div id="accountDelete"> 70 </div> 71 - </center> 72 <footer> 73 <button class="btn-error" form="delAccountForm">Delete</button> 74 <button class="secondary" onclick='closeDeleteModal();'>Cancel</button>
··· 1 import { html } from "hono/html"; 2 + import { MAX_DASHBOARD_PASS, MIN_DASHBOARD_PASS } from "../limits.d"; 3 + import { UsernameField } from "./usernameField"; 4 + import { BSkyAppPasswordField, DashboardPasswordField } from "./passwordFields"; 5 + import { PWAutoCompleteSettings } from "../types.d"; 6 7 export function Settings() { 8 return ( ··· 24 25 <label> 26 Dashboard Pass: 27 + <DashboardPasswordField autocomplete={PWAutoCompleteSettings.CurrentPass} /> 28 <small>The password to access this website</small> 29 </label> 30 <label> 31 BSky App Password: 32 + <BSkyAppPasswordField /> 33 + <small>If you need to change your application password, you can <a href="https://bsky.app/settings/app-passwords" target="_blank">get a new one here</a></small> 34 </label> 35 <label> 36 BSky PDS: ··· 38 <small>If you have not changed your PDS (or do not know what that means), you should leave this blank!</small> 39 </label> 40 </form> 41 <progress id="spinner" class="htmx-indicator" /> 42 + <div id="accountResponse"> 43 + </div> 44 </section> 45 <footer> 46 <button id="deleteAccountButton" class="btn-error" style="float: left;">Delete</button> ··· 62 <small>The password to access this website</small> 63 </label> 64 </form> 65 <progress id="delSpinner" class="htmx-indicator" /> 66 <div id="accountDelete"> 67 </div> 68 <footer> 69 <button class="btn-error" form="delAccountForm">Delete</button> 70 <button class="secondary" onclick='closeDeleteModal();'>Cancel</button>
+3 -2
src/layout/usernameField.tsx
··· 8 required?: boolean; 9 }; 10 11 - export default function UsernameField(props?: UsernameFieldProps) { 12 - const hintText = props?.hintText ? raw(props.hintText) : raw("This is your Bluesky username, in the format of a custom domain or like <code>USERNAME.bsky.social</code>."); 13 // default required true. 14 const inputRequired = (props) ? (props?.required || false) : true; 15 return (
··· 8 required?: boolean; 9 }; 10 11 + export function UsernameField(props?: UsernameFieldProps) { 12 + const hintText = props?.hintText ? raw(props.hintText) : 13 + raw("This is your Bluesky username, in the format of a custom domain or like <code>USERNAME.bsky.social</code>."); 14 // default required true. 15 const inputRequired = (props) ? (props?.required || false) : true; 16 return (
+29
src/layout/violationsBar.tsx
···
··· 1 + import { Context } from "hono"; 2 + import { getViolationsForCurrentUser } from "../utils/dbQuery"; 3 + import { Violation } from "../types.d"; 4 + 5 + export async function ViolationNoticeBar(props: any) { 6 + const ctx:Context = props.ctx; 7 + const {success, results} = await getViolationsForCurrentUser(ctx); 8 + if (success && results.length > 0) { 9 + let errorStr = ""; 10 + const violationData:Violation = (results[0] as Violation) 11 + if (violationData.tosViolation) { 12 + errorStr = "Your account is in violation of SkyScheduler usage."; 13 + } else if(violationData.userPassInvalid) { 14 + errorStr = "Your Bluesky handle or application password is invalid. Please update these in the settings."; 15 + } else if (violationData.accountSuspended) { 16 + errorStr = "Your account has been suspended by Bluesky. Some features may not work at this time"; 17 + } else if (violationData.accountGone) { 18 + errorStr = "Unable to find your account, update your Bluesky handle in the settings"; 19 + } 20 + return ( 21 + <div class="warning-box"> 22 + <span class="warning"><b>WARNING</b>: Account error found! {errorStr}</span> 23 + </div> 24 + ); 25 + } 26 + return ( 27 + <></> 28 + ); 29 + };
+2
src/pages/dashboard.tsx
··· 3 import { BaseLayout } from "../layout/main"; 4 import { ScheduledPostList } from "../layout/postList"; 5 import { Settings, SettingsButton } from "../layout/settings"; 6 7 export default function Dashboard(props:any) { 8 const ctx: Context = props.c; ··· 44 </article> 45 </section> 46 <div class="container-fluid mainContent"> 47 <PostCreation /> 48 </div> 49 </div>
··· 3 import { BaseLayout } from "../layout/main"; 4 import { ScheduledPostList } from "../layout/postList"; 5 import { Settings, SettingsButton } from "../layout/settings"; 6 + import { ViolationNoticeBar } from "../layout/violationsBar"; 7 8 export default function Dashboard(props:any) { 9 const ctx: Context = props.c; ··· 45 </article> 46 </section> 47 <div class="container-fluid mainContent"> 48 + <ViolationNoticeBar ctx={ctx} /> 49 <PostCreation /> 50 </div> 51 </div>
+1 -1
src/pages/forgot.tsx
··· 2 import { BaseLayout } from "../layout/main"; 3 import NavTags from "../layout/navTags"; 4 import AccountHandler from "../layout/account"; 5 - import UsernameField from "../layout/usernameField"; 6 import TurnstileCaptcha from "../layout/turnstile"; 7 8 export default function ForgotPassword(props:any) {
··· 2 import { BaseLayout } from "../layout/main"; 3 import NavTags from "../layout/navTags"; 4 import AccountHandler from "../layout/account"; 5 + import { UsernameField } from "../layout/usernameField"; 6 import TurnstileCaptcha from "../layout/turnstile"; 7 8 export default function ForgotPassword(props:any) {
+4 -2
src/pages/login.tsx
··· 1 import { BaseLayout } from "../layout/main"; 2 import NavTags from "../layout/navTags"; 3 import AccountHandler from "../layout/account"; 4 - import UsernameField from "../layout/usernameField"; 5 6 export default function Login() { 7 const links = [{title: "Sign Up", url: "/signup"}, {title: "Forgot Password", url: "/forgot"}]; ··· 18 19 <label> 20 Dashboard Password 21 - <input type="password" name="password" id="password" required /> 22 <small><b>NOTE</b>: This password is not related to your bluesky account!</small> 23 </label> 24 </AccountHandler>
··· 1 import { BaseLayout } from "../layout/main"; 2 import NavTags from "../layout/navTags"; 3 import AccountHandler from "../layout/account"; 4 + import { UsernameField } from "../layout/usernameField"; 5 + import { DashboardPasswordField } from "../layout/passwordFields"; 6 + import { PWAutoCompleteSettings } from "../types.d"; 7 8 export default function Login() { 9 const links = [{title: "Sign Up", url: "/signup"}, {title: "Forgot Password", url: "/forgot"}]; ··· 20 21 <label> 22 Dashboard Password 23 + <DashboardPasswordField autocomplete={PWAutoCompleteSettings.CurrentPass} required={true} /> 24 <small><b>NOTE</b>: This password is not related to your bluesky account!</small> 25 </label> 26 </AccountHandler>
+6 -6
src/pages/signup.tsx
··· 1 import { Context } from "hono"; 2 import { BaseLayout } from "../layout/main"; 3 import { isUsingInviteKeys } from "../utils/inviteKeys"; 4 - import { BSKY_MAX_APP_PASSWORD_LENGTH, MAX_DASHBOARD_PASS, MIN_DASHBOARD_PASS } from "../limits.d"; 5 import NavTags from "../layout/navTags"; 6 import isEmpty from "just-is-empty"; 7 import AccountHandler from "../layout/account"; 8 - import UsernameField from "../layout/usernameField"; 9 import TurnstileCaptcha from "../layout/turnstile"; 10 import FooterCopyright from "../layout/footer"; 11 12 export default function Signup(props:any) { 13 const ctx: Context = props.c; ··· 30 31 <label> 32 Dashboard Password 33 - <input type="password" name="password" minlength={MIN_DASHBOARD_PASS} maxlength={MAX_DASHBOARD_PASS} required 34 - autocomplete="new-password" /> 35 <small>Create a new password to use to login to this website. Passwords should be {MIN_DASHBOARD_PASS} to {MAX_DASHBOARD_PASS} characters long.</small> 36 </label> 37 38 <label> 39 Bluesky App Password 40 - <input type="password" name="bskyAppPassword" maxlength={BSKY_MAX_APP_PASSWORD_LENGTH} placeholder="" required 41 - data-1p-ignore data-bwignore data-lpignore="true" data-protonpass-ignore="true" autocomplete="off" /> 42 <small> 43 If you need a bluesky app password for your account, <a target="_blank" href="https://bsky.app/settings/app-passwords">you can get one here</a>. 44 </small>
··· 1 import { Context } from "hono"; 2 import { BaseLayout } from "../layout/main"; 3 import { isUsingInviteKeys } from "../utils/inviteKeys"; 4 + import { MAX_DASHBOARD_PASS, MIN_DASHBOARD_PASS } from "../limits.d"; 5 import NavTags from "../layout/navTags"; 6 import isEmpty from "just-is-empty"; 7 import AccountHandler from "../layout/account"; 8 + import { UsernameField } from "../layout/usernameField"; 9 import TurnstileCaptcha from "../layout/turnstile"; 10 import FooterCopyright from "../layout/footer"; 11 + import { BSkyAppPasswordField, DashboardPasswordField } from "../layout/passwordFields"; 12 + import { PWAutoCompleteSettings } from "../types.d"; 13 14 export default function Signup(props:any) { 15 const ctx: Context = props.c; ··· 32 33 <label> 34 Dashboard Password 35 + <DashboardPasswordField autocomplete={PWAutoCompleteSettings.NewPass} required={true} /> 36 <small>Create a new password to use to login to this website. Passwords should be {MIN_DASHBOARD_PASS} to {MAX_DASHBOARD_PASS} characters long.</small> 37 </label> 38 39 <label> 40 Bluesky App Password 41 + <BSkyAppPasswordField required={true} /> 42 <small> 43 If you need a bluesky app password for your account, <a target="_blank" href="https://bsky.app/settings/app-passwords">you can get one here</a>. 44 </small>
+27 -13
src/utils/bskyApi.ts
··· 1 import { type AppBskyFeedPost, AtpAgent, RichText } from '@atproto/api'; 2 - import { Bindings, Post, Repost, PostLabel, EmbedData, PostResponseObject, LooseObj } from '../types.d'; 3 import { MAX_ALT_TEXT, MAX_EMBEDS, MAX_LENGTH, MAX_POSTED_LENGTH } from '../limits.d'; 4 - import { updatePostData, getBskyUserPassForId } from './dbQuery'; 5 import { deleteEmbedsFromR2 } from './r2Query'; 6 import {imageDimensionsFromStream} from 'image-dimensions'; 7 import truncate from "just-truncate"; ··· 22 password: pass, 23 }); 24 if (!loginResponse.success) { 25 - console.warn(`could not login as user ${user}`); 26 - return false; 27 } 28 - return true; 29 } catch (err) { 30 console.error(`encountered exception on login for user ${user}, err ${err}`); 31 } 32 - return false; 33 } 34 35 export const makeRepost = async (env: Bindings, content: Repost) => { ··· 45 return false; 46 } 47 48 - const loginResponse = await loginToBsky(agent, user, pass); 49 - if (!loginResponse) { 50 - // TODO: Probably should handle failure better here. 51 return false; 52 } 53 ··· 82 return null; 83 } 84 85 - const loginResponse = await loginToBsky(agent, user, pass); 86 - if (!loginResponse) { 87 - // TODO: Probably should handle failure better here. 88 return null; 89 } 90 ··· 103 const posts:PostResponseObject[] = []; 104 105 const postSegment = async (data: string) => { 106 - let postRecord:AppBskyFeedPost.Record = { 107 $type: 'app.bsky.feed.post', 108 text: data, 109 facets: rt.facets,
··· 1 import { type AppBskyFeedPost, AtpAgent, RichText } from '@atproto/api'; 2 + import { Bindings, Post, Repost, PostLabel, EmbedData, PostResponseObject, LooseObj, PlatformLoginResponse } from '../types.d'; 3 import { MAX_ALT_TEXT, MAX_EMBEDS, MAX_LENGTH, MAX_POSTED_LENGTH } from '../limits.d'; 4 + import { updatePostData, getBskyUserPassForId, createViolationForUser } from './dbQuery'; 5 import { deleteEmbedsFromR2 } from './r2Query'; 6 import {imageDimensionsFromStream} from 'image-dimensions'; 7 import truncate from "just-truncate"; ··· 22 password: pass, 23 }); 24 if (!loginResponse.success) { 25 + if (loginResponse.data.active == false) { 26 + switch (loginResponse.data.status) { 27 + case "deactivated": 28 + return PlatformLoginResponse.Deactivated; 29 + case "suspended": 30 + return PlatformLoginResponse.Suspended; 31 + case "takendown": 32 + return PlatformLoginResponse.TakenDown; 33 + } 34 + return PlatformLoginResponse.InvalidAccount; 35 + } 36 + return PlatformLoginResponse.PlatformOutage; 37 } 38 + return PlatformLoginResponse.Ok; 39 } catch (err) { 40 console.error(`encountered exception on login for user ${user}, err ${err}`); 41 } 42 + return PlatformLoginResponse.UnhandledError; 43 } 44 45 export const makeRepost = async (env: Bindings, content: Repost) => { ··· 55 return false; 56 } 57 58 + const loginResponse:PlatformLoginResponse = await loginToBsky(agent, user, pass); 59 + if (loginResponse != PlatformLoginResponse.Ok) { 60 + const addViolation:boolean = await createViolationForUser(env, content.userId, loginResponse); 61 + if (addViolation) 62 + console.error(`Unable to login to make repost from user ${content.userId} with violation ${loginResponse}`); 63 return false; 64 } 65 ··· 94 return null; 95 } 96 97 + const loginResponse:PlatformLoginResponse = await loginToBsky(agent, user, pass); 98 + if (loginResponse != PlatformLoginResponse.Ok) { 99 + const addViolation:boolean = await createViolationForUser(env, content.user, loginResponse); 100 + if (addViolation) 101 + console.error(`Unable to login to make post ${content.postid} with violation ${loginResponse}`); 102 return null; 103 } 104 ··· 117 const posts:PostResponseObject[] = []; 118 119 const postSegment = async (data: string) => { 120 + let postRecord:AppBskyFeedPost.Record = { 121 $type: 'app.bsky.feed.post', 122 text: data, 123 facets: rt.facets,
+2 -2
src/utils/bskyMsg.ts
··· 1 import { AtpAgent, RichText } from '@atproto/api'; 2 import { loginToBsky } from './bskyApi'; 3 - import { Bindings } from '../types'; 4 5 export const createDMWithUser = async (env: Bindings, user: string, msg: string) => { 6 const agent = new AtpAgent({ ··· 8 }); 9 10 const loginResponse = await loginToBsky(agent, env.RESET_BOT_USERNAME, env.RESET_BOT_APP_PASS); 11 - if (!loginResponse) { 12 console.error("Unable to login to the bot to send reset password messages"); 13 return false; 14 }
··· 1 import { AtpAgent, RichText } from '@atproto/api'; 2 import { loginToBsky } from './bskyApi'; 3 + import { Bindings, PlatformLoginResponse } from '../types.d'; 4 5 export const createDMWithUser = async (env: Bindings, user: string, msg: string) => { 6 const agent = new AtpAgent({ ··· 8 }); 9 10 const loginResponse = await loginToBsky(agent, env.RESET_BOT_USERNAME, env.RESET_BOT_APP_PASS); 11 + if (loginResponse != PlatformLoginResponse.Ok) { 12 console.error("Unable to login to the bot to send reset password messages"); 13 return false; 14 }
+74 -10
src/utils/dbQuery.ts
··· 1 import { Context } from "hono"; 2 import { DrizzleD1Database, drizzle } from "drizzle-orm/d1"; 3 - import { sql, and, or, gt, eq, lte, inArray, desc, count, getTableColumns } from "drizzle-orm"; 4 import { BatchItem } from "drizzle-orm/batch"; 5 - import { posts, reposts } from "../db/app.schema"; 6 import { accounts, users } from "../db/auth.schema"; 7 import { PostSchema } from "../validation/postSchema"; 8 - import { Bindings } from "../types"; 9 import { MAX_POSTED_LENGTH } from "../limits.d"; 10 import { createPostObject, floorCurrentTime, floorGivenTime } from "./helpers"; 11 import { deleteEmbedsFromR2 } from "./r2Query"; ··· 67 if (userData) { 68 const db: DrizzleD1Database = drizzle(c.env.DB); 69 let queriesToExecute:BatchItem<"sqlite">[] = []; 70 71 - if (has(newData, "password")) { 72 // cache out the new hash 73 const newPassword = newData.password; 74 // remove it from the original object ··· 80 .where(eq(accounts.userId, userData.id))); 81 } 82 83 if (!isEmpty(newData)) { 84 queriesToExecute.push(db.update(users).set(newData) 85 .where(eq(users.id, userData.id))); ··· 136 return { ok: false, msg: "Scheduled date must be in the future" }; 137 } 138 139 const postUUID = uuidv4(); 140 let dbOperations:BatchItem<"sqlite">[] = [ 141 db.insert(posts).values({ ··· 168 const db: DrizzleD1Database = drizzle(env.DB); 169 const currentTime: Date = floorCurrentTime(); 170 171 return await db.select().from(posts) 172 - .where(and(lte(posts.scheduledDate, currentTime), 173 - eq(posts.posted, false))) 174 - .all(); 175 }; 176 177 export const getAllRepostsForGivenTime = async (env: Bindings, givenDate: Date) => { ··· 179 const db: DrizzleD1Database = drizzle(env.DB); 180 const query = db.select({uuid: reposts.uuid}).from(reposts) 181 .where(lte(reposts.scheduledDate, givenDate)); 182 return await db.select({uri: posts.uri, cid: posts.cid, userId: posts.userId }) 183 .from(posts) 184 - .where(inArray(posts.uuid, query)) 185 .all(); 186 }; 187 ··· 206 return false; 207 208 const db: DrizzleD1Database = drizzle(c.env.DB); 209 - const result = await db.update(posts).set(newData).where(and(eq(posts.uuid, id), eq(posts.userId, userData.id))); 210 - return result.success; 211 }; 212 213 export const getPostById = async(c: Context, id: string) => { ··· 287 postTruncation.forEach(async item => { 288 await db.update(posts).set({ content: truncate(item.content, MAX_POSTED_LENGTH) }).where(eq(posts.uuid, item.id)); 289 }); 290 } 291 };
··· 1 import { Context } from "hono"; 2 import { DrizzleD1Database, drizzle } from "drizzle-orm/d1"; 3 + import { sql, and, gt, eq, lte, inArray, desc, count, getTableColumns, notInArray, ne } from "drizzle-orm"; 4 import { BatchItem } from "drizzle-orm/batch"; 5 + import { posts, reposts, violations } from "../db/app.schema"; 6 import { accounts, users } from "../db/auth.schema"; 7 import { PostSchema } from "../validation/postSchema"; 8 + import { Bindings, LooseObj, PlatformLoginResponse } from "../types.d"; 9 import { MAX_POSTED_LENGTH } from "../limits.d"; 10 import { createPostObject, floorCurrentTime, floorGivenTime } from "./helpers"; 11 import { deleteEmbedsFromR2 } from "./r2Query"; ··· 67 if (userData) { 68 const db: DrizzleD1Database = drizzle(c.env.DB); 69 let queriesToExecute:BatchItem<"sqlite">[] = []; 70 + const updatedPassword = has(newData, "password"); 71 72 + if (updatedPassword) { 73 // cache out the new hash 74 const newPassword = newData.password; 75 // remove it from the original object ··· 81 .where(eq(accounts.userId, userData.id))); 82 } 83 84 + // If we have new data about the username, pds, or password, then clear account invalid violations 85 + if (updatedPassword || has(newData, "username") || has(newData, "pds")) { 86 + queriesToExecute.push(getViolationDeleteQueryForUser(db, userData.id)); 87 + } 88 + 89 if (!isEmpty(newData)) { 90 queriesToExecute.push(db.update(users).set(newData) 91 .where(eq(users.id, userData.id))); ··· 142 return { ok: false, msg: "Scheduled date must be in the future" }; 143 } 144 145 + // TODO: prevent anything from happening if you are currently in violations table 146 + 147 const postUUID = uuidv4(); 148 let dbOperations:BatchItem<"sqlite">[] = [ 149 db.insert(posts).values({ ··· 176 const db: DrizzleD1Database = drizzle(env.DB); 177 const currentTime: Date = floorCurrentTime(); 178 179 + const violationUsers = db.select({data: violations.userId}).from(violations); 180 return await db.select().from(posts) 181 + .where(and(and( 182 + lte(posts.scheduledDate, currentTime), eq(posts.posted, false)), 183 + notInArray(posts.userId, violationUsers)) 184 + ).all(); 185 }; 186 187 export const getAllRepostsForGivenTime = async (env: Bindings, givenDate: Date) => { ··· 189 const db: DrizzleD1Database = drizzle(env.DB); 190 const query = db.select({uuid: reposts.uuid}).from(reposts) 191 .where(lte(reposts.scheduledDate, givenDate)); 192 + const violationsQuery = db.select({data: violations.userId}).from(violations); 193 return await db.select({uri: posts.uri, cid: posts.cid, userId: posts.userId }) 194 .from(posts) 195 + .where(and(inArray(posts.uuid, query), notInArray(posts.userId, violationsQuery))) 196 .all(); 197 }; 198 ··· 217 return false; 218 219 const db: DrizzleD1Database = drizzle(c.env.DB); 220 + const {success} = await db.update(posts).set(newData).where(and(eq(posts.uuid, id), eq(posts.userId, userData.id))); 221 + return success; 222 }; 223 224 export const getPostById = async(c: Context, id: string) => { ··· 298 postTruncation.forEach(async item => { 299 await db.update(posts).set({ content: truncate(item.content, MAX_POSTED_LENGTH) }).where(eq(posts.uuid, item.id)); 300 }); 301 + } 302 + }; 303 + 304 + export const createViolationForUser = async(env: Bindings, userId: string, violationType: PlatformLoginResponse) => { 305 + const NoHandleState:PlatformLoginResponse[] = [PlatformLoginResponse.Ok, PlatformLoginResponse.PlatformOutage, PlatformLoginResponse.None, PlatformLoginResponse.UnhandledError]; 306 + // Don't do anything in these cases 307 + if (violationType in NoHandleState) { 308 + console.warn(`createViolationForUser got a not valid add request for user ${userId} with violation ${violationType}`); 309 + return false; 310 + } 311 + 312 + const db: DrizzleD1Database = drizzle(env.DB); 313 + let valuesUpdate:LooseObj = {}; 314 + switch (violationType) 315 + { 316 + case PlatformLoginResponse.InvalidAccount: 317 + case PlatformLoginResponse.InvalidCreds: 318 + valuesUpdate.userPassInvalid = true; 319 + break; 320 + case PlatformLoginResponse.Suspended: 321 + valuesUpdate.accountSuspended = true; 322 + break; 323 + case PlatformLoginResponse.TakenDown: 324 + case PlatformLoginResponse.Deactivated: 325 + valuesUpdate.accountGone = true; 326 + break; 327 + default: 328 + console.warn(`createViolationForUser was not properly handled for ${violationType}`); 329 + return false; 330 + } 331 + 332 + const {success} = await db.insert(violations).values({userId: userId, ...valuesUpdate}).onConflictDoUpdate({target: violations.userId, set: valuesUpdate}); 333 + return success; 334 + }; 335 + 336 + const getViolationDeleteQueryForUser = (db:DrizzleD1Database, userId: string) => { 337 + return db.delete(violations).where(and(eq(violations.userId, userId), 338 + and(ne(violations.tosViolation, true), ne(violations.accountGone, true)))); 339 + }; 340 + 341 + export const clearViolationForUser = async(env: Bindings, userId: string) => { 342 + const db: DrizzleD1Database = drizzle(env.DB); 343 + const {success} = await getViolationDeleteQueryForUser(db, userId); 344 + return success; 345 + }; 346 + 347 + export const getViolationsForCurrentUser = async(c: Context) => { 348 + const userData = c.get("user"); 349 + if (userData) { 350 + const db: DrizzleD1Database = drizzle(c.env.DB); 351 + return await db.select().from(violations).where(eq(violations.userId, userData.id)).limit(1).run(); 352 + } else { 353 + return {success: false, results: []}; 354 } 355 };