This repository has no description
0

Configure Feed

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

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