forked from
lass.pds.lassul.us/superconfig
This repository has no description
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+}