This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / 5pkgs / timeful / self-host.patch
32 kB 898 lines
1diff --git a/frontend/public/index.html b/frontend/public/index.html 2index a300a8a4..48cb0e61 100644 3--- a/frontend/public/index.html 4+++ b/frontend/public/index.html 5@@ -1,21 +1,6 @@ 6 <!DOCTYPE html> 7 <html lang="en"> 8 <head> 9- <!-- Google Tag Manager --> 10- <script> 11- ;(function (w, d, s, l, i) { 12- w[l] = w[l] || [] 13- w[l].push({ "gtm.start": new Date().getTime(), event: "gtm.js" }) 14- var f = d.getElementsByTagName(s)[0], 15- j = d.createElement(s), 16- dl = l != "dataLayer" ? "&l=" + l : "" 17- j.async = true 18- j.src = "https://www.googletagmanager.com/gtm.js?id=" + i + dl 19- f.parentNode.insertBefore(j, f) 20- })(window, document, "script", "dataLayer", "GTM-M677X6V") 21- </script> 22- <!-- End Google Tag Manager --> 23- <script async src="https://cdn.fuseplatform.net/publift/tags/2/4241/fuse.js"></script> 24 25 <meta charset="utf-8" /> 26 <meta http-equiv="X-UA-Compatible" content="IE=edge" /> 27@@ -59,16 +44,6 @@ 28 /> 29 </head> 30 <body> 31- <!-- Google Tag Manager (noscript) --> 32- <noscript 33- ><iframe 34- src="https://www.googletagmanager.com/ns.html?id=GTM-M677X6V" 35- height="0" 36- width="0" 37- style="display: none; visibility: hidden" 38- ></iframe 39- ></noscript> 40- <!-- End Google Tag Manager (noscript) --> 41 <noscript> 42 <strong 43 >We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work 44diff --git a/frontend/src/App.vue b/frontend/src/App.vue 45index 176178b5..01469b17 100644 46--- a/frontend/src/App.vue 47+++ b/frontend/src/App.vue 48@@ -357,7 +357,10 @@ export default { 49 this.webviewDialog = true 50 return 51 } 52- this.$router.push({ name: "sign-in" }) 53+ this.$router.push({ 54+ name: "sign-in", 55+ query: { next: this.$route.fullPath }, 56+ }) 57 // this.signInDialog = true 58 } else { 59 this.$router.push({ name: "sign-in" }) 60diff --git a/frontend/src/components/Footer.vue b/frontend/src/components/Footer.vue 61index 35d9a6d5..7e8815d4 100644 62--- a/frontend/src/components/Footer.vue 63+++ b/frontend/src/components/Footer.vue 64@@ -30,7 +30,7 @@ 65 </svg> 66 </a> 67 <a 68- href="https://github.com/schej-it/timeful.app" 69+ href="https://github.com/Lassulus/superconfig/tree/master/5pkgs/timeful" 70 aria-label="GitHub" 71 target="_blank" 72 ><v-icon class="tw-text-white/80 hover:tw-text-white" 73diff --git a/frontend/src/components/home/Dashboard.vue b/frontend/src/components/home/Dashboard.vue 74index c56eebff..af75f247 100644 75--- a/frontend/src/components/home/Dashboard.vue 76+++ b/frontend/src/components/home/Dashboard.vue 77@@ -8,7 +8,7 @@ 78 Dashboard 79 </div> 80 <div 81- v-if="!isPremiumUser" 82+ v-if="enablePaywall && !isPremiumUser" 83 class="tw-flex tw-items-baseline tw-gap-2 tw-text-sm tw-font-normal tw-text-very-dark-gray" 84 > 85 <div> 86@@ -240,7 +240,13 @@ export default { 87 }, 88 computed: { 89 ...mapGetters(["isPremiumUser"]), 90- ...mapState(["authUser", "events", "groupsEnabled", "folders"]), 91+ ...mapState([ 92+ "authUser", 93+ "events", 94+ "groupsEnabled", 95+ "folders", 96+ "enablePaywall", 97+ ]), 98 orderedFolders() { 99 return this.folders.sort((a, b) => { 100 return a.name.localeCompare(b.name) 101diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 102index 1e028082..d7377fa3 100644 103--- a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 104+++ b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 105@@ -1048,6 +1048,7 @@ import { 106 isPremiumUser, 107 prefersStartOnMonday, 108 } from "@/utils" 109+import { buildIcs, downloadIcs } from "@/utils/ics_utils" 110 import { 111 availabilityTypes, 112 calendarOptionsDefaults, 113@@ -1250,11 +1251,7 @@ export default { 114 ...mapState(["authUser", "overlayAvailabilitiesEnabled"]), 115 ...mapGetters(["isPremiumUser"]), 116 showAds() { 117- return ( 118- !this.ownerIsPremium && 119- !this.isPremiumUser && 120- this.state !== this.states.SET_SPECIFIC_TIMES 121- ) 122+ return false 123 }, 124 /** Returns the width of the right side of the calendar */ 125 rightSideWidth() { 126@@ -2076,7 +2073,7 @@ export default { 127 } 128 return `Tap and drag to add your "available" ${daysOrTimes} in green.` 129 case this.states.SCHEDULE_EVENT: 130- return "Tap and drag on the calendar to schedule a Google Calendar event during those times." 131+ return "Tap and drag on the calendar to schedule an event during those times." 132 default: 133 return "" 134 } 135@@ -2092,7 +2089,7 @@ export default { 136 } 137 return `Click and drag to add your "available" ${daysOrTimes} in green.` 138 case this.states.SCHEDULE_EVENT: 139- return "Click and drag on the calendar to schedule a Google Calendar event during those times." 140+ return "Click and drag on the calendar to schedule an event during those times." 141 default: 142 return "" 143 } 144@@ -3563,8 +3560,11 @@ export default { 145 this.state = this.defaultState 146 }, 147 148- /** Redirect user to Google Calendar to finish the creation of the event */ 149- confirmScheduleEvent(googleCalendar = true) { 150+ /** 151+ * Finish scheduling the dragged-out slot: open Google Calendar / Outlook 152+ * prefilled, or download an .ics file. target: "google" | "outlook" | "ics" 153+ */ 154+ confirmScheduleEvent(target = "google") { 155 if (!this.curScheduledEvent) return 156 // if (!isPremiumUser(this.authUser)) { 157 // this.showUpgradeDialog({ 158@@ -3615,9 +3615,27 @@ export default { 159 const emailsString = encodeURIComponent(emails.filter(Boolean).join(",")) 160 161 const eventId = this.event.shortId ?? this.event._id 162+ const eventUrl = `${window.location.origin}/e/${eventId}` 163+ const description = `\n\nThis event was scheduled with Timeful: ${eventUrl}` 164+ 165+ if (target === "ics") { 166+ const ics = buildIcs({ 167+ uid: `${this.event._id}-${startDate.getTime()}@${window.location.host}`, 168+ start: startDate, 169+ end: endDate, 170+ summary: this.event.name, 171+ description: description.trim(), 172+ location: this.event.location, 173+ url: eventUrl, 174+ }) 175+ const filename = `${this.event.name.replace(/[^\w-]+/g, "_") || "event"}.ics` 176+ downloadIcs(filename, ics) 177+ this.state = this.defaultState 178+ return 179+ } 180 181 let url = "" 182- if (googleCalendar) { 183+ if (target === "google") { 184 // Format start and end date to be in the format required by gcal (remove -, :, and .000) 185 const start = startDate.toISOString().replace(/([-:]|\.000)/g, "") 186 const end = endDate.toISOString().replace(/([-:]|\.000)/g, "") 187@@ -3626,14 +3644,13 @@ export default { 188 url = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent( 189 this.event.name 190 )}&dates=${start}/${end}&details=${encodeURIComponent( 191- "\n\nThis event was scheduled with Timeful: https://timeful.app/e/" 192- )}${eventId}&ctz=${this.curTimezone.value}&add=${emailsString}` 193+ description 194+ )}&ctz=${this.curTimezone.value}&add=${emailsString}` 195 } else { 196 url = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent( 197 this.event.name 198 )}&body=${encodeURIComponent( 199- "\n\nThis event was scheduled with Timeful: https://timeful.app/e/" + 200- eventId 201+ description 202 )}&startdt=${startDate.toISOString()}&enddt=${endDate.toISOString()}&location=${encodeURIComponent( 203 this.event.location || "" 204 )}&path=/calendar/action/compose&timezone=${this.curTimezone.value}` 205diff --git a/frontend/src/components/schedule_overlap/ToolRow.vue b/frontend/src/components/schedule_overlap/ToolRow.vue 206index d8aaaddd..5822dd28 100644 207--- a/frontend/src/components/schedule_overlap/ToolRow.vue 208+++ b/frontend/src/components/schedule_overlap/ToolRow.vue 209@@ -118,7 +118,9 @@ 210 </v-btn> 211 </template> 212 <v-list dense> 213- <v-list-item @click="(e) => $emit('confirmScheduleEvent', true)"> 214+ <v-list-item 215+ @click="(e) => $emit('confirmScheduleEvent', 'google')" 216+ > 217 <v-img 218 src="@/assets/gcal_logo.png" 219 class="tw-mr-2 tw-flex-none" 220@@ -129,7 +131,9 @@ 221 <v-list-item-title>Google Calendar</v-list-item-title> 222 </v-list-item-content> 223 </v-list-item> 224- <v-list-item @click="(e) => $emit('confirmScheduleEvent', false)"> 225+ <v-list-item 226+ @click="(e) => $emit('confirmScheduleEvent', 'outlook')" 227+ > 228 <v-img 229 src="@/assets/outlook_logo.svg" 230 class="tw-mr-2 tw-flex-none" 231@@ -140,6 +144,14 @@ 232 <v-list-item-title>Outlook</v-list-item-title> 233 </v-list-item-content> 234 </v-list-item> 235+ <v-list-item @click="(e) => $emit('confirmScheduleEvent', 'ics')"> 236+ <v-icon class="tw-mr-2 tw-flex-none" size="20" 237+ >mdi-calendar-export</v-icon 238+ > 239+ <v-list-item-content> 240+ <v-list-item-title>Download .ics</v-list-item-title> 241+ </v-list-item-content> 242+ </v-list-item> 243 </v-list> 244 </v-menu> 245 </template> 246diff --git a/frontend/src/main.js b/frontend/src/main.js 247index 2d370f7a..9dfa971a 100644 248--- a/frontend/src/main.js 249+++ b/frontend/src/main.js 250@@ -5,19 +5,12 @@ import router from "./router" 251 import store from "./store" 252 import vuetify from "./plugins/vuetify" 253 import posthogPlugin from "./plugins/posthog" 254-import VueGtm from "@gtm-support/vue2-gtm" 255 import VueMeta from "vue-meta" 256 import "./index.css" 257 258 // Posthog 259 Vue.use(posthogPlugin) 260 261-// Google Analytics 262-Vue.use(VueGtm, { 263- id: "GTM-M677X6V", 264- vueRouter: router, 265-}) 266- 267 // Site Metadata 268 Vue.use(VueMeta) 269 270diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js 271index a0770841..b9460a8f 100644 272--- a/frontend/src/store/index.js 273+++ b/frontend/src/store/index.js 274@@ -29,7 +29,7 @@ export default new Vuex.Store({ 275 signUpFormEnabled: false, 276 daysOnlyEnabled: true, 277 overlayAvailabilitiesEnabled: true, 278- enablePaywall: true, 279+ enablePaywall: false, 280 281 // Experiments 282 pricingPageConversion: "control", 283diff --git a/frontend/src/utils/ics_utils.js b/frontend/src/utils/ics_utils.js 284new file mode 100644 285index 00000000..c285bd1b 286--- /dev/null 287+++ b/frontend/src/utils/ics_utils.js 288@@ -0,0 +1,69 @@ 289+/** Builds and downloads a single-event iCalendar (RFC 5545) file. */ 290+ 291+const utf8 = new TextEncoder() 292+ 293+/** Escapes a TEXT property value (RFC 5545 §3.3.11) */ 294+const escapeText = (text) => 295+ text 296+ .replace(/\\/g, "\\\\") 297+ .replace(/;/g, "\\;") 298+ .replace(/,/g, "\\,") 299+ .replace(/\r?\n/g, "\\n") 300+ 301+/** Formats a Date as a UTC DATE-TIME, e.g. 20261020T070000Z */ 302+const formatDateTime = (date) => 303+ date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "") 304+ 305+/** Folds a content line so no physical line exceeds 75 octets (RFC 5545 §3.1) */ 306+const foldLine = (line) => { 307+ const parts = [] 308+ let current = "" 309+ let currentBytes = 0 310+ for (const char of line) { 311+ const charBytes = utf8.encode(char).length 312+ // continuation lines start with a space, which counts toward the limit 313+ const limit = parts.length === 0 ? 75 : 74 314+ if (currentBytes + charBytes > limit) { 315+ parts.push(current) 316+ current = "" 317+ currentBytes = 0 318+ } 319+ current += char 320+ currentBytes += charBytes 321+ } 322+ parts.push(current) 323+ return parts.join("\r\n ") 324+} 325+ 326+export const buildIcs = ({ uid, start, end, summary, description, location, url }) => { 327+ const lines = [ 328+ "BEGIN:VCALENDAR", 329+ "VERSION:2.0", 330+ "PRODID:-//timeful//self-hosted//EN", 331+ "CALSCALE:GREGORIAN", 332+ "METHOD:PUBLISH", 333+ "BEGIN:VEVENT", 334+ `UID:${uid}`, 335+ `DTSTAMP:${formatDateTime(new Date())}`, 336+ `DTSTART:${formatDateTime(start)}`, 337+ `DTEND:${formatDateTime(end)}`, 338+ `SUMMARY:${escapeText(summary)}`, 339+ ] 340+ if (description) lines.push(`DESCRIPTION:${escapeText(description)}`) 341+ if (location) lines.push(`LOCATION:${escapeText(location)}`) 342+ if (url) lines.push(`URL:${url}`) 343+ lines.push("END:VEVENT", "END:VCALENDAR") 344+ return lines.map(foldLine).join("\r\n") + "\r\n" 345+} 346+ 347+export const downloadIcs = (filename, icsText) => { 348+ const blob = new Blob([icsText], { type: "text/calendar;charset=utf-8" }) 349+ const href = URL.createObjectURL(blob) 350+ const a = document.createElement("a") 351+ a.href = href 352+ a.download = filename 353+ document.body.appendChild(a) 354+ a.click() 355+ a.remove() 356+ URL.revokeObjectURL(href) 357+} 358diff --git a/frontend/src/views/Event.vue b/frontend/src/views/Event.vue 359index 81887280..87dba8d1 100644 360--- a/frontend/src/views/Event.vue 361+++ b/frontend/src/views/Event.vue 362@@ -463,13 +463,29 @@ 363 Cancel 364 </v-btn> 365 <v-spacer /> 366- <v-btn 367- :disabled="!allowScheduleEvent" 368- class="tw-bg-white tw-text-blue" 369- @click="confirmScheduleEvent" 370- > 371- Schedule 372- </v-btn> 373+ <v-menu top offset-y> 374+ <template v-slot:activator="{ on, attrs }"> 375+ <v-btn 376+ :disabled="!allowScheduleEvent" 377+ class="tw-bg-white tw-text-blue" 378+ v-bind="attrs" 379+ v-on="on" 380+ > 381+ Schedule 382+ </v-btn> 383+ </template> 384+ <v-list dense> 385+ <v-list-item @click="confirmScheduleEvent('google')"> 386+ <v-list-item-title>Google Calendar</v-list-item-title> 387+ </v-list-item> 388+ <v-list-item @click="confirmScheduleEvent('outlook')"> 389+ <v-list-item-title>Outlook</v-list-item-title> 390+ </v-list-item> 391+ <v-list-item @click="confirmScheduleEvent('ics')"> 392+ <v-list-item-title>Download .ics</v-list-item-title> 393+ </v-list-item> 394+ </v-list> 395+ </v-menu> 396 </template> 397 </div> 398 <PubliftAd 399@@ -638,11 +654,7 @@ export default { 400 ...mapState(["authUser", "events"]), 401 ...mapGetters(["isPremiumUser"]), 402 showAds() { 403- return ( 404- !this.ownerIsPremium && 405- !this.isPremiumUser && 406- !this.isSettingSpecificTimes 407- ) 408+ return false 409 }, 410 allowScheduleEvent() { 411 return this.scheduleOverlapComponent?.allowScheduleEvent 412@@ -998,8 +1010,8 @@ export default { 413 cancelScheduleEvent() { 414 this.scheduleOverlapComponent?.cancelScheduleEvent() 415 }, 416- confirmScheduleEvent() { 417- this.scheduleOverlapComponent?.confirmScheduleEvent() 418+ confirmScheduleEvent(target) { 419+ this.scheduleOverlapComponent?.confirmScheduleEvent(target) 420 }, 421 422 highlightAvailabilityBtn() { 423diff --git a/frontend/src/views/Landing.vue b/frontend/src/views/Landing.vue 424index b8a50d8f..41d7521c 100644 425--- a/frontend/src/views/Landing.vue 426+++ b/frontend/src/views/Landing.vue 427@@ -31,13 +31,10 @@ 428 class="tw-mb-4 tw-flex tw-select-none tw-items-center tw-rounded-full tw-border tw-border-light-gray-stroke tw-bg-white/70 tw-px-2.5 tw-py-1.5 tw-text-sm tw-text-dark-gray" 429 > 430 We're open source! 431- <github-button 432- v-once 433- class="-tw-mb-1 tw-ml-2" 434- href="https://github.com/schej-it/timeful.app" 435- data-show-count="true" 436- aria-label="Star timeful.app on GitHub" 437- >Star</github-button 438+ <a 439+ class="tw-ml-2 tw-text-blue" 440+ href="https://github.com/Lassulus/superconfig/tree/master/5pkgs/timeful" 441+ >Source</a 442 > 443 </div> 444 <div 445@@ -111,20 +108,6 @@ 446 transition="fade-transition" 447 contain 448 /> 449- <vue-vimeo-player 450- video-url="https://player.vimeo.com/video/1083205305?h=d58bef862a" 451- :player-width="800" 452- :player-height="800" 453- :options="{ 454- muted: true, 455- playsinline: true, 456- responsive: true, 457- }" 458- :controls="false" 459- :autoplay="true" 460- :loop="true" 461- @play="onPlay" 462- /> 463 </div> 464 </div> 465 </div> 466@@ -168,25 +151,6 @@ 467 /> 468 </div> 469 470- <!-- Video --> 471- <div 472- class="tw-flex tw-justify-center tw-bg-green tw-px-4 tw-pb-12 tw-pt-24 md:tw-pb-16" 473- > 474- <div 475- class="tw-h-[300px] tw-max-w-3xl tw-flex-1 sm:tw-h-[400px] md:tw-h-[450px]" 476- > 477- <iframe 478- class="tw-h-full tw-w-full" 479- src="https://www.youtube.com/embed/vFkBC8BrkOk?si=pF64JAIyDhom_1do" 480- title="Timeful demo" 481- frameborder="0" 482- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 483- referrerpolicy="strict-origin-when-cross-origin" 484- allowfullscreen 485- ></iframe> 486- </div> 487- </div> 488- 489 <!-- Reddit Testimonials --> 490 <div class="tw-flex tw-justify-center tw-bg-light-gray tw-py-12"> 491 <div class="tw-mx-4 tw-max-w-3xl tw-flex-1 sm:tw-mx-16"> 492@@ -299,11 +263,9 @@ import NewEvent from "@/components/NewEvent.vue" 493 import NewDialog from "@/components/NewDialog.vue" 494 import LandingPageHeader from "@/components/landing/LandingPageHeader.vue" 495 import Logo from "@/components/Logo.vue" 496-import GithubButton from "vue-github-button" 497 import SignInDialog from "@/components/SignInDialog.vue" 498 import { calendarTypes } from "@/constants" 499 import HowItWorksDialog from "@/components/HowItWorksDialog.vue" 500-import { vueVimeoPlayer } from "vue-vimeo-player" 501 import Footer from "@/components/Footer.vue" 502 import PronunciationMenu from "@/components/PronunciationMenu.vue" 503 import { mapState, mapMutations } from "vuex" 504@@ -325,11 +287,9 @@ export default { 505 NewEvent, 506 NewDialog, 507 LandingPageHeader, 508- GithubButton, 509 Logo, 510 SignInDialog, 511 HowItWorksDialog, 512- vueVimeoPlayer, 513 Footer, 514 PronunciationMenu, 515 AuthUserMenu, 516diff --git a/frontend/src/views/SignIn.vue b/frontend/src/views/SignIn.vue 517index d667be55..6be32f29 100644 518--- a/frontend/src/views/SignIn.vue 519+++ b/frontend/src/views/SignIn.vue 520@@ -32,70 +32,20 @@ 521 </v-card-title> 522 <v-card-text class="tw-flex tw-flex-col tw-items-center tw-pt-6"> 523 <div class="tw-mb-4 tw-flex tw-w-full tw-flex-col tw-gap-y-2"> 524- <v-btn 525- block 526- @click="signIn(calendarTypes.GOOGLE)" 527- class="tw-bg-white" 528- > 529+ <v-btn block @click="signInGithub" class="tw-bg-white"> 530 <div class="tw-flex tw-w-full tw-items-center tw-gap-2"> 531- <v-img 532- class="tw-flex-initial" 533- width="20" 534- height="20" 535- src="@/assets/google_logo.svg" 536- /> 537+ <v-icon class="tw-flex-initial" size="20">mdi-github</v-icon> 538 <v-spacer /> 539- {{ isSignUp ? "Sign up with" : "Continue with" }} Google 540+ {{ isSignUp ? "Sign up with" : "Continue with" }} GitHub 541 <v-spacer /> 542 </div> 543 </v-btn> 544- <v-btn 545- block 546- @click="signIn(calendarTypes.OUTLOOK)" 547- class="tw-bg-white" 548+ <div 549+ v-if="$route.query.error === 'github'" 550+ class="tw-text-center tw-text-sm tw-text-red" 551 > 552- <div class="tw-flex tw-w-full tw-items-center tw-gap-2"> 553- <v-img 554- class="tw-flex-initial" 555- width="20" 556- height="20" 557- src="@/assets/outlook_logo.svg" 558- /> 559- <v-spacer /> 560- {{ isSignUp ? "Sign up with" : "Continue with" }} Outlook 561- <v-spacer /> 562- </div> 563- </v-btn> 564- 565- <div class="tw-my-2 tw-flex tw-items-center tw-gap-3"> 566- <v-divider /> 567- <span class="tw-text-xs tw-text-gray">or</span> 568- <v-divider /> 569- </div> 570- 571- <div> 572- <div class="tw-mb-1 tw-text-sm tw-font-medium"> 573- Email address 574- </div> 575- <v-text-field 576- v-model="email" 577- class="tw-mb-2" 578- placeholder="Enter your email..." 579- type="email" 580- solo 581- hide-details="auto" 582- :error-messages="emailError" 583- @keydown.enter="submitEmail" 584- /> 585- <v-btn 586- block 587- color="primary" 588- :loading="sending" 589- :disabled="sending" 590- @click="submitEmail" 591- > 592- {{ isSignUp ? "Sign up with" : "Continue with" }} Email 593- </v-btn> 594+ GitHub sign-in failed. Make sure your GitHub account has a 595+ verified email address and try again. 596 </div> 597 </div> 598 <div class="tw-text-center tw-text-xs"> 599@@ -251,7 +201,7 @@ 600 601 <script> 602 import { authTypes, calendarTypes } from "@/constants" 603-import { post, signInGoogle, signInOutlook } from "@/utils" 604+import { getEventsCreated, post, signInGoogle, signInOutlook } from "@/utils" 605 import { mapMutations } from "vuex" 606 import Logo from "@/components/Logo.vue" 607 608@@ -299,6 +249,15 @@ export default { 609 610 methods: { 611 ...mapMutations(["setAuthUser"]), 612+ signInGithub() { 613+ const params = new URLSearchParams({ 614+ timezoneOffset: new Date().getTimezoneOffset(), 615+ eventsToLink: getEventsCreated().join(","), 616+ }) 617+ const next = this.$route.query.next 618+ if (typeof next === "string") params.set("redirect", next) 619+ window.location.href = `/api/auth/github?${params}` 620+ }, 621 signIn(provider) { 622 const state = this.upgradeRedirect 623 ? { type: authTypes.UPGRADE, upgradeParams: this.$route.query.upgradeParams } 624diff --git a/server/main.go b/server/main.go 625index b038861e..1678b7df 100644 626--- a/server/main.go 627+++ b/server/main.go 628@@ -175,7 +175,9 @@ func main() { 629 router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) 630 631 // Run server 632- if os.Getenv("NODE_ENV") == "staging" { 633+ if listenAddr := os.Getenv("LISTEN_ADDR"); listenAddr != "" { 634+ router.Run(listenAddr) 635+ } else if os.Getenv("NODE_ENV") == "staging" { 636 router.Run(":3003") 637 } else { 638 router.Run(":3002") 639diff --git a/server/routes/auth.go b/server/routes/auth.go 640index 6444e714..2869116d 100644 641--- a/server/routes/auth.go 642+++ b/server/routes/auth.go 643@@ -41,6 +41,8 @@ func InitAuth(router *gin.RouterGroup) { 644 authRouter.POST("/otp/check-email", checkEmail) 645 authRouter.POST("/otp/send", sendOtp) 646 authRouter.POST("/otp/verify", verifyOtp) 647+ 648+ initGithubAuth(authRouter) 649 } 650 651 // @Summary Signs user in 652diff --git a/server/routes/github_auth.go b/server/routes/github_auth.go 653new file mode 100644 654index 00000000..40ef9206 655--- /dev/null 656+++ b/server/routes/github_auth.go 657@@ -0,0 +1,241 @@ 658+/* GitHub OAuth sign-in (self-host patch). Accounts are keyed by the GitHub 659+ * account's primary verified email, like the email OTP flow, and carry no 660+ * calendar access. */ 661+package routes 662+ 663+import ( 664+ "context" 665+ "crypto/rand" 666+ "encoding/hex" 667+ "encoding/json" 668+ "fmt" 669+ "net/http" 670+ "net/url" 671+ "os" 672+ "strconv" 673+ "strings" 674+ "time" 675+ 676+ "github.com/gin-contrib/sessions" 677+ "github.com/gin-gonic/gin" 678+ "go.mongodb.org/mongo-driver/bson" 679+ "go.mongodb.org/mongo-driver/bson/primitive" 680+ "schej.it/server/db" 681+ "schej.it/server/logger" 682+ "schej.it/server/models" 683+ "schej.it/server/utils" 684+) 685+ 686+var githubHttpClient = &http.Client{Timeout: 10 * time.Second} 687+ 688+// Session keys holding the in-flight sign-in between the redirect to GitHub 689+// and the callback. 690+const ( 691+ githubStateKey = "githubState" 692+ githubRedirectKey = "githubRedirect" 693+ githubTzKey = "githubTimezoneOffset" 694+ githubLinkKey = "githubEventsToLink" 695+) 696+ 697+func initGithubAuth(authRouter *gin.RouterGroup) { 698+ authRouter.GET("/github", githubSignIn) 699+ authRouter.GET("/github/callback", githubCallback) 700+} 701+ 702+// @Summary Starts GitHub sign-in 703+// @Description Redirects to GitHub's OAuth consent page. Query: redirect (relative path to return to), timezoneOffset, eventsToLink (comma-separated event ids created while signed out) 704+// @Tags auth 705+// @Router /auth/github [get] 706+func githubSignIn(c *gin.Context) { 707+ clientId := os.Getenv("GITHUB_CLIENT_ID") 708+ if clientId == "" { 709+ c.String(http.StatusNotFound, "GitHub sign-in is not configured") 710+ return 711+ } 712+ 713+ stateBytes := make([]byte, 32) 714+ if _, err := rand.Read(stateBytes); err != nil { 715+ logger.StdErr.Panicln(err) 716+ } 717+ state := hex.EncodeToString(stateBytes) 718+ 719+ session := sessions.Default(c) 720+ session.Set(githubStateKey, state) 721+ session.Set(githubRedirectKey, safeRedirectPath(c.Query("redirect"))) 722+ session.Set(githubTzKey, c.Query("timezoneOffset")) 723+ session.Set(githubLinkKey, c.Query("eventsToLink")) 724+ session.Save() 725+ 726+ params := url.Values{} 727+ params.Set("client_id", clientId) 728+ params.Set("scope", "read:user user:email") 729+ params.Set("state", state) 730+ c.Redirect(http.StatusFound, "https://github.com/login/oauth/authorize?"+params.Encode()) 731+} 732+ 733+// @Summary GitHub OAuth callback 734+// @Description Exchanges the code, finds or creates the user by primary verified email, sets the session and redirects back into the app 735+// @Tags auth 736+// @Router /auth/github/callback [get] 737+func githubCallback(c *gin.Context) { 738+ session := sessions.Default(c) 739+ expectedState, _ := session.Get(githubStateKey).(string) 740+ redirect, _ := session.Get(githubRedirectKey).(string) 741+ tzString, _ := session.Get(githubTzKey).(string) 742+ eventsToLink, _ := session.Get(githubLinkKey).(string) 743+ // The state is single-use: every exit path below saves the session with 744+ // these keys removed. Each Save emits a Set-Cookie, so save exactly once. 745+ session.Delete(githubStateKey) 746+ session.Delete(githubRedirectKey) 747+ session.Delete(githubTzKey) 748+ session.Delete(githubLinkKey) 749+ 750+ fail := func(reason string, err error) { 751+ logger.StdErr.Printf("GitHub sign-in failed: %s: %v", reason, err) 752+ session.Save() 753+ c.Redirect(http.StatusFound, "/sign-in?error=github") 754+ } 755+ 756+ if expectedState == "" || c.Query("state") != expectedState { 757+ fail("state mismatch", nil) 758+ return 759+ } 760+ if c.Query("error") != "" { 761+ fail("denied", fmt.Errorf("%s", c.Query("error"))) 762+ return 763+ } 764+ 765+ accessToken, err := githubExchangeCode(c.Query("code")) 766+ if err != nil { 767+ fail("code exchange", err) 768+ return 769+ } 770+ 771+ var ghUser struct { 772+ Login string `json:"login"` 773+ Name string `json:"name"` 774+ AvatarUrl string `json:"avatar_url"` 775+ } 776+ if err := githubGet(accessToken, "/user", &ghUser); err != nil { 777+ fail("fetch user", err) 778+ return 779+ } 780+ 781+ var ghEmails []struct { 782+ Email string `json:"email"` 783+ Primary bool `json:"primary"` 784+ Verified bool `json:"verified"` 785+ } 786+ if err := githubGet(accessToken, "/user/emails", &ghEmails); err != nil { 787+ fail("fetch emails", err) 788+ return 789+ } 790+ email := "" 791+ for _, e := range ghEmails { 792+ if e.Verified && (e.Primary || email == "") { 793+ email = e.Email 794+ } 795+ } 796+ if email == "" { 797+ fail("no verified email", nil) 798+ return 799+ } 800+ email = utils.NormalizeEmail(email) 801+ 802+ var userId primitive.ObjectID 803+ if existing := db.GetUserByEmail(email); existing != nil { 804+ userId = existing.Id 805+ } else { 806+ firstName, lastName := ghUser.Login, "" 807+ if name := strings.TrimSpace(ghUser.Name); name != "" { 808+ firstName, lastName, _ = strings.Cut(name, " ") 809+ } 810+ timezoneOffset, _ := strconv.Atoi(tzString) 811+ 812+ res, err := db.UsersCollection.InsertOne(context.Background(), models.User{ 813+ Email: email, 814+ FirstName: firstName, 815+ LastName: strings.TrimSpace(lastName), 816+ Picture: ghUser.AvatarUrl, 817+ TimezoneOffset: timezoneOffset, 818+ TokenOrigin: models.WEB, 819+ }) 820+ if err != nil { 821+ logger.StdErr.Panicln(err) 822+ } 823+ userId = res.InsertedID.(primitive.ObjectID) 824+ } 825+ 826+ // Claim events this browser created while signed out (same as /auth/sign-in) 827+ for _, eventIdString := range strings.Split(eventsToLink, ",") { 828+ eventId, err := primitive.ObjectIDFromHex(eventIdString) 829+ if err == nil { 830+ db.EventsCollection.UpdateOne(context.Background(), bson.M{"_id": eventId, "ownerId": nil}, bson.M{"$set": bson.M{"ownerId": userId}}) 831+ } 832+ } 833+ 834+ session.Set("userId", userId.Hex()) 835+ session.Save() 836+ 837+ c.Redirect(http.StatusFound, redirect) 838+} 839+ 840+// safeRedirectPath only allows same-origin absolute paths, so the callback 841+// cannot be turned into an open redirect. 842+func safeRedirectPath(path string) string { 843+ if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.HasPrefix(path, "/\\") { 844+ return "/home" 845+ } 846+ return path 847+} 848+ 849+func githubExchangeCode(code string) (string, error) { 850+ form := url.Values{} 851+ form.Set("client_id", os.Getenv("GITHUB_CLIENT_ID")) 852+ form.Set("client_secret", os.Getenv("GITHUB_CLIENT_SECRET")) 853+ form.Set("code", code) 854+ 855+ req, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token", strings.NewReader(form.Encode())) 856+ if err != nil { 857+ return "", err 858+ } 859+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 860+ req.Header.Set("Accept", "application/json") 861+ 862+ resp, err := githubHttpClient.Do(req) 863+ if err != nil { 864+ return "", err 865+ } 866+ defer resp.Body.Close() 867+ 868+ var body struct { 869+ AccessToken string `json:"access_token"` 870+ Error string `json:"error"` 871+ } 872+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 873+ return "", err 874+ } 875+ if body.AccessToken == "" { 876+ return "", fmt.Errorf("no access token (status %d, error %q)", resp.StatusCode, body.Error) 877+ } 878+ return body.AccessToken, nil 879+} 880+ 881+func githubGet(accessToken string, path string, out interface{}) error { 882+ req, err := http.NewRequest(http.MethodGet, "https://api.github.com"+path, nil) 883+ if err != nil { 884+ return err 885+ } 886+ req.Header.Set("Authorization", "Bearer "+accessToken) 887+ req.Header.Set("Accept", "application/vnd.github+json") 888+ 889+ resp, err := githubHttpClient.Do(req) 890+ if err != nil { 891+ return err 892+ } 893+ defer resp.Body.Close() 894+ if resp.StatusCode != http.StatusOK { 895+ return fmt.Errorf("GET %s: status %d", path, resp.StatusCode) 896+ } 897+ return json.NewDecoder(resp.Body).Decode(out) 898+}