This repository has no description
0

Configure Feed

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

neoprism: host timeful on when.lassul.us

Availability polls (formerly schej), patched to drop ads/tracking/paywall
and to sign in via GitHub OAuth.

+845 -1
+118
2configs/timeful.nix
··· 1 + { 2 + config, 3 + lib, 4 + pkgs, 5 + self, 6 + ... 7 + }: 8 + let 9 + domain = "when.lassul.us"; 10 + port = 3002; 11 + timeful = self.packages.${pkgs.system}.timeful; 12 + in 13 + { 14 + # timeful (formerly schej): crab.fit/when2meet-style availability polls. 15 + # Creating a poll and painting availability work without an account; 16 + # signing in (GitHub OAuth, see 5pkgs/timeful/self-host.patch) keeps a 17 + # dashboard of your polls. 18 + services.mongodb = { 19 + enable = true; 20 + # prebuilt binary; the default `mongodb` builds from source for hours 21 + package = pkgs.mongodb-ce; 22 + }; 23 + 24 + clan.core.vars.generators.timeful = { 25 + files.env = { }; 26 + runtimeInputs = [ pkgs.openssl ]; 27 + # ENCRYPTION_KEY is fed raw to aes.NewCipher, so it must be exactly 28 + # 16/24/32 bytes (upstream's `openssl rand -base64 32` hint is 44 bytes). 29 + script = '' 30 + { 31 + echo "SESSION_SECRET=$(openssl rand -hex 32)" 32 + echo "ENCRYPTION_KEY=$(openssl rand -hex 16)" 33 + } > $out/env 34 + ''; 35 + }; 36 + 37 + # GitHub OAuth app (https://github.com/settings/developers), callback URL 38 + # https://when.lassul.us/api/auth/github/callback 39 + clan.core.vars.generators.timeful-github = { 40 + files.env = { }; 41 + prompts.client_id = { 42 + description = "GitHub OAuth app client ID for ${domain}"; 43 + persist = true; 44 + }; 45 + prompts.client_secret = { 46 + description = "GitHub OAuth app client secret for ${domain}"; 47 + type = "hidden"; 48 + persist = true; 49 + }; 50 + script = '' 51 + printf 'GITHUB_CLIENT_ID=%s\nGITHUB_CLIENT_SECRET=%s\n' \ 52 + "$(cat "$prompts"/client_id)" "$(cat "$prompts"/client_secret)" > "$out/env" 53 + ''; 54 + }; 55 + 56 + systemd.services.timeful = { 57 + description = "timeful availability polls"; 58 + wantedBy = [ "multi-user.target" ]; 59 + requires = [ "mongodb.service" ]; 60 + after = [ 61 + "network.target" 62 + "mongodb.service" 63 + ]; 64 + environment = { 65 + LISTEN_ADDR = "127.0.0.1:${toString port}"; 66 + MONGODB_URI = "mongodb://127.0.0.1:27017"; 67 + CORS_ORIGINS = "https://${domain}"; 68 + # no listmonk mailing list; skip its HTTP calls on sign-up 69 + LISTMONK_ENABLED = "false"; 70 + }; 71 + serviceConfig = { 72 + ExecStart = "${lib.getExe timeful} -release"; 73 + # The server unconditionally appends everything it prints to 74 + # ./logs.log, which would grow forever; the journal already has it. 75 + ExecStartPre = "${pkgs.coreutils}/bin/ln -sfn /dev/null logs.log"; 76 + EnvironmentFile = [ 77 + config.clan.core.vars.generators.timeful.files.env.path 78 + config.clan.core.vars.generators.timeful-github.files.env.path 79 + ]; 80 + DynamicUser = true; 81 + StateDirectory = "timeful"; 82 + WorkingDirectory = "/var/lib/timeful"; 83 + Restart = "on-failure"; 84 + CapabilityBoundingSet = ""; 85 + NoNewPrivileges = true; 86 + PrivateDevices = true; 87 + ProtectHome = true; 88 + ProtectKernelTunables = true; 89 + ProtectKernelModules = true; 90 + ProtectControlGroups = true; 91 + RestrictAddressFamilies = [ 92 + "AF_INET" 93 + "AF_INET6" 94 + "AF_UNIX" 95 + ]; 96 + RestrictNamespaces = true; 97 + LockPersonality = true; 98 + SystemCallArchitectures = "native"; 99 + }; 100 + }; 101 + 102 + services.nginx.virtualHosts.${domain} = { 103 + enableACME = true; 104 + forceSSL = true; 105 + locations."/" = { 106 + proxyPass = "http://127.0.0.1:${toString port}"; 107 + # gin trusts every proxy and takes the leftmost X-Forwarded-For entry 108 + # for its per-IP rate limiter, so overwrite instead of appending 109 + # (recommendedProxySettings appends a client-controlled value). 110 + extraConfig = '' 111 + proxy_set_header Host $host; 112 + proxy_set_header X-Real-IP $remote_addr; 113 + proxy_set_header X-Forwarded-For $remote_addr; 114 + proxy_set_header X-Forwarded-Proto $scheme; 115 + ''; 116 + }; 117 + }; 118 + }
+2 -1
2configs/dns/lassul.us.zone
··· 1 - @ 3600 IN SOA lassul.us. ns1.lassul.us. 2026092801 7200 3600 86400 3600 1 + @ 3600 IN SOA lassul.us. ns1.lassul.us. 2026100200 7200 3600 86400 3600 2 2 3 3 ;;@ 3600 IN NS ns1 4 4 @ 3600 IN NS ns1 ··· 60 60 omni IN CNAME neoprism 61 61 where.is IN CNAME neoprism 62 62 parley IN CNAME neoprism 63 + when IN CNAME neoprism 63 64 64 65 ;; Mail 65 66 @ IN MX 3 mail
+71
5pkgs/timeful/package.nix
··· 1 + { 2 + lib, 3 + applyPatches, 4 + buildGoModule, 5 + buildNpmPackage, 6 + fetchFromGitHub, 7 + makeWrapper, 8 + }: 9 + 10 + let 11 + version = "0-unstable-2026-10-01"; 12 + 13 + # self-host.patch (AGPL-3.0 §13: this directory is the corresponding source 14 + # linked from the footer and the landing page): 15 + # - strips Google Tag Manager, Publift/Primis ads, the paywall and the 16 + # landing page's YouTube/Vimeo embeds and GitHub star button 17 + # - replaces the Google/Outlook/email-OTP sign-in page with GitHub OAuth 18 + # (GET /api/auth/github, GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET) 19 + # - makes the listen address configurable via LISTEN_ADDR (upstream 20 + # hardcodes ":3002" on all interfaces) 21 + src = applyPatches { 22 + src = fetchFromGitHub { 23 + owner = "schej-it"; 24 + repo = "timeful.app"; 25 + rev = "23b7e7ead1d279ea5d8f431199a230c2dfbe0c1a"; 26 + hash = "sha256-Yp2dkz4+QZ9pqZXOM4srZsCiu/TGLQXGNZtsRd5Dx+U="; 27 + }; 28 + patches = [ ./self-host.patch ]; 29 + }; 30 + 31 + frontend = buildNpmPackage { 32 + pname = "timeful-frontend"; 33 + inherit version; 34 + src = "${src}/frontend"; 35 + 36 + npmDepsHash = "sha256-eHitoSJUPX2mhlOgGrECCUp0zhKFxgsfxkY7+BmeHak="; 37 + 38 + # No PostHog key and no Google/Microsoft OAuth client baked in; sign-in 39 + # goes through the server-side GitHub flow instead. 40 + installPhase = '' 41 + runHook preInstall 42 + cp -r dist $out 43 + runHook postInstall 44 + ''; 45 + }; 46 + in 47 + buildGoModule { 48 + pname = "timeful"; 49 + inherit version src; 50 + 51 + modRoot = "server"; 52 + subPackages = [ "." ]; 53 + vendorHash = "sha256-dl9JURh6pCB7P+jAWcjlPZwmkKPqV7EaOJZSwJYwcFQ="; 54 + 55 + nativeBuildInputs = [ makeWrapper ]; 56 + 57 + postInstall = '' 58 + mv $out/bin/server $out/bin/timeful 59 + wrapProgram $out/bin/timeful --set-default FRONTEND_DIST ${frontend} 60 + ''; 61 + 62 + passthru = { inherit frontend; }; 63 + 64 + meta = { 65 + description = "Availability poll to find the best time for a group to meet (formerly Schej)"; 66 + homepage = "https://github.com/schej-it/timeful.app"; 67 + license = lib.licenses.agpl3Only; 68 + mainProgram = "timeful"; 69 + platforms = lib.platforms.linux; 70 + }; 71 + }
+647
5pkgs/timeful/self-host.patch
··· 1 + diff --git a/frontend/public/index.html b/frontend/public/index.html 2 + index 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 44 + diff --git a/frontend/src/App.vue b/frontend/src/App.vue 45 + index 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" }) 60 + diff --git a/frontend/src/components/Footer.vue b/frontend/src/components/Footer.vue 61 + index 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" 73 + diff --git a/frontend/src/components/home/Dashboard.vue b/frontend/src/components/home/Dashboard.vue 74 + index 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) 101 + diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 102 + index 1e028082..eb523062 100644 103 + --- a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 104 + +++ b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 105 + @@ -1250,11 +1250,7 @@ export default { 106 + ...mapState(["authUser", "overlayAvailabilitiesEnabled"]), 107 + ...mapGetters(["isPremiumUser"]), 108 + showAds() { 109 + - return ( 110 + - !this.ownerIsPremium && 111 + - !this.isPremiumUser && 112 + - this.state !== this.states.SET_SPECIFIC_TIMES 113 + - ) 114 + + return false 115 + }, 116 + /** Returns the width of the right side of the calendar */ 117 + rightSideWidth() { 118 + diff --git a/frontend/src/main.js b/frontend/src/main.js 119 + index 2d370f7a..9dfa971a 100644 120 + --- a/frontend/src/main.js 121 + +++ b/frontend/src/main.js 122 + @@ -5,19 +5,12 @@ import router from "./router" 123 + import store from "./store" 124 + import vuetify from "./plugins/vuetify" 125 + import posthogPlugin from "./plugins/posthog" 126 + -import VueGtm from "@gtm-support/vue2-gtm" 127 + import VueMeta from "vue-meta" 128 + import "./index.css" 129 + 130 + // Posthog 131 + Vue.use(posthogPlugin) 132 + 133 + -// Google Analytics 134 + -Vue.use(VueGtm, { 135 + - id: "GTM-M677X6V", 136 + - vueRouter: router, 137 + -}) 138 + - 139 + // Site Metadata 140 + Vue.use(VueMeta) 141 + 142 + diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js 143 + index a0770841..b9460a8f 100644 144 + --- a/frontend/src/store/index.js 145 + +++ b/frontend/src/store/index.js 146 + @@ -29,7 +29,7 @@ export default new Vuex.Store({ 147 + signUpFormEnabled: false, 148 + daysOnlyEnabled: true, 149 + overlayAvailabilitiesEnabled: true, 150 + - enablePaywall: true, 151 + + enablePaywall: false, 152 + 153 + // Experiments 154 + pricingPageConversion: "control", 155 + diff --git a/frontend/src/views/Event.vue b/frontend/src/views/Event.vue 156 + index 81887280..79786ce3 100644 157 + --- a/frontend/src/views/Event.vue 158 + +++ b/frontend/src/views/Event.vue 159 + @@ -638,11 +638,7 @@ export default { 160 + ...mapState(["authUser", "events"]), 161 + ...mapGetters(["isPremiumUser"]), 162 + showAds() { 163 + - return ( 164 + - !this.ownerIsPremium && 165 + - !this.isPremiumUser && 166 + - !this.isSettingSpecificTimes 167 + - ) 168 + + return false 169 + }, 170 + allowScheduleEvent() { 171 + return this.scheduleOverlapComponent?.allowScheduleEvent 172 + diff --git a/frontend/src/views/Landing.vue b/frontend/src/views/Landing.vue 173 + index b8a50d8f..41d7521c 100644 174 + --- a/frontend/src/views/Landing.vue 175 + +++ b/frontend/src/views/Landing.vue 176 + @@ -31,13 +31,10 @@ 177 + 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" 178 + > 179 + We're open source! 180 + - <github-button 181 + - v-once 182 + - class="-tw-mb-1 tw-ml-2" 183 + - href="https://github.com/schej-it/timeful.app" 184 + - data-show-count="true" 185 + - aria-label="Star timeful.app on GitHub" 186 + - >Star</github-button 187 + + <a 188 + + class="tw-ml-2 tw-text-blue" 189 + + href="https://github.com/Lassulus/superconfig/tree/master/5pkgs/timeful" 190 + + >Source</a 191 + > 192 + </div> 193 + <div 194 + @@ -111,20 +108,6 @@ 195 + transition="fade-transition" 196 + contain 197 + /> 198 + - <vue-vimeo-player 199 + - video-url="https://player.vimeo.com/video/1083205305?h=d58bef862a" 200 + - :player-width="800" 201 + - :player-height="800" 202 + - :options="{ 203 + - muted: true, 204 + - playsinline: true, 205 + - responsive: true, 206 + - }" 207 + - :controls="false" 208 + - :autoplay="true" 209 + - :loop="true" 210 + - @play="onPlay" 211 + - /> 212 + </div> 213 + </div> 214 + </div> 215 + @@ -168,25 +151,6 @@ 216 + /> 217 + </div> 218 + 219 + - <!-- Video --> 220 + - <div 221 + - class="tw-flex tw-justify-center tw-bg-green tw-px-4 tw-pb-12 tw-pt-24 md:tw-pb-16" 222 + - > 223 + - <div 224 + - class="tw-h-[300px] tw-max-w-3xl tw-flex-1 sm:tw-h-[400px] md:tw-h-[450px]" 225 + - > 226 + - <iframe 227 + - class="tw-h-full tw-w-full" 228 + - src="https://www.youtube.com/embed/vFkBC8BrkOk?si=pF64JAIyDhom_1do" 229 + - title="Timeful demo" 230 + - frameborder="0" 231 + - allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 232 + - referrerpolicy="strict-origin-when-cross-origin" 233 + - allowfullscreen 234 + - ></iframe> 235 + - </div> 236 + - </div> 237 + - 238 + <!-- Reddit Testimonials --> 239 + <div class="tw-flex tw-justify-center tw-bg-light-gray tw-py-12"> 240 + <div class="tw-mx-4 tw-max-w-3xl tw-flex-1 sm:tw-mx-16"> 241 + @@ -299,11 +263,9 @@ import NewEvent from "@/components/NewEvent.vue" 242 + import NewDialog from "@/components/NewDialog.vue" 243 + import LandingPageHeader from "@/components/landing/LandingPageHeader.vue" 244 + import Logo from "@/components/Logo.vue" 245 + -import GithubButton from "vue-github-button" 246 + import SignInDialog from "@/components/SignInDialog.vue" 247 + import { calendarTypes } from "@/constants" 248 + import HowItWorksDialog from "@/components/HowItWorksDialog.vue" 249 + -import { vueVimeoPlayer } from "vue-vimeo-player" 250 + import Footer from "@/components/Footer.vue" 251 + import PronunciationMenu from "@/components/PronunciationMenu.vue" 252 + import { mapState, mapMutations } from "vuex" 253 + @@ -325,11 +287,9 @@ export default { 254 + NewEvent, 255 + NewDialog, 256 + LandingPageHeader, 257 + - GithubButton, 258 + Logo, 259 + SignInDialog, 260 + HowItWorksDialog, 261 + - vueVimeoPlayer, 262 + Footer, 263 + PronunciationMenu, 264 + AuthUserMenu, 265 + diff --git a/frontend/src/views/SignIn.vue b/frontend/src/views/SignIn.vue 266 + index d667be55..6be32f29 100644 267 + --- a/frontend/src/views/SignIn.vue 268 + +++ b/frontend/src/views/SignIn.vue 269 + @@ -32,70 +32,20 @@ 270 + </v-card-title> 271 + <v-card-text class="tw-flex tw-flex-col tw-items-center tw-pt-6"> 272 + <div class="tw-mb-4 tw-flex tw-w-full tw-flex-col tw-gap-y-2"> 273 + - <v-btn 274 + - block 275 + - @click="signIn(calendarTypes.GOOGLE)" 276 + - class="tw-bg-white" 277 + - > 278 + + <v-btn block @click="signInGithub" class="tw-bg-white"> 279 + <div class="tw-flex tw-w-full tw-items-center tw-gap-2"> 280 + - <v-img 281 + - class="tw-flex-initial" 282 + - width="20" 283 + - height="20" 284 + - src="@/assets/google_logo.svg" 285 + - /> 286 + + <v-icon class="tw-flex-initial" size="20">mdi-github</v-icon> 287 + <v-spacer /> 288 + - {{ isSignUp ? "Sign up with" : "Continue with" }} Google 289 + + {{ isSignUp ? "Sign up with" : "Continue with" }} GitHub 290 + <v-spacer /> 291 + </div> 292 + </v-btn> 293 + - <v-btn 294 + - block 295 + - @click="signIn(calendarTypes.OUTLOOK)" 296 + - class="tw-bg-white" 297 + + <div 298 + + v-if="$route.query.error === 'github'" 299 + + class="tw-text-center tw-text-sm tw-text-red" 300 + > 301 + - <div class="tw-flex tw-w-full tw-items-center tw-gap-2"> 302 + - <v-img 303 + - class="tw-flex-initial" 304 + - width="20" 305 + - height="20" 306 + - src="@/assets/outlook_logo.svg" 307 + - /> 308 + - <v-spacer /> 309 + - {{ isSignUp ? "Sign up with" : "Continue with" }} Outlook 310 + - <v-spacer /> 311 + - </div> 312 + - </v-btn> 313 + - 314 + - <div class="tw-my-2 tw-flex tw-items-center tw-gap-3"> 315 + - <v-divider /> 316 + - <span class="tw-text-xs tw-text-gray">or</span> 317 + - <v-divider /> 318 + - </div> 319 + - 320 + - <div> 321 + - <div class="tw-mb-1 tw-text-sm tw-font-medium"> 322 + - Email address 323 + - </div> 324 + - <v-text-field 325 + - v-model="email" 326 + - class="tw-mb-2" 327 + - placeholder="Enter your email..." 328 + - type="email" 329 + - solo 330 + - hide-details="auto" 331 + - :error-messages="emailError" 332 + - @keydown.enter="submitEmail" 333 + - /> 334 + - <v-btn 335 + - block 336 + - color="primary" 337 + - :loading="sending" 338 + - :disabled="sending" 339 + - @click="submitEmail" 340 + - > 341 + - {{ isSignUp ? "Sign up with" : "Continue with" }} Email 342 + - </v-btn> 343 + + GitHub sign-in failed. Make sure your GitHub account has a 344 + + verified email address and try again. 345 + </div> 346 + </div> 347 + <div class="tw-text-center tw-text-xs"> 348 + @@ -251,7 +201,7 @@ 349 + 350 + <script> 351 + import { authTypes, calendarTypes } from "@/constants" 352 + -import { post, signInGoogle, signInOutlook } from "@/utils" 353 + +import { getEventsCreated, post, signInGoogle, signInOutlook } from "@/utils" 354 + import { mapMutations } from "vuex" 355 + import Logo from "@/components/Logo.vue" 356 + 357 + @@ -299,6 +249,15 @@ export default { 358 + 359 + methods: { 360 + ...mapMutations(["setAuthUser"]), 361 + + signInGithub() { 362 + + const params = new URLSearchParams({ 363 + + timezoneOffset: new Date().getTimezoneOffset(), 364 + + eventsToLink: getEventsCreated().join(","), 365 + + }) 366 + + const next = this.$route.query.next 367 + + if (typeof next === "string") params.set("redirect", next) 368 + + window.location.href = `/api/auth/github?${params}` 369 + + }, 370 + signIn(provider) { 371 + const state = this.upgradeRedirect 372 + ? { type: authTypes.UPGRADE, upgradeParams: this.$route.query.upgradeParams } 373 + diff --git a/server/main.go b/server/main.go 374 + index b038861e..1678b7df 100644 375 + --- a/server/main.go 376 + +++ b/server/main.go 377 + @@ -175,7 +175,9 @@ func main() { 378 + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) 379 + 380 + // Run server 381 + - if os.Getenv("NODE_ENV") == "staging" { 382 + + if listenAddr := os.Getenv("LISTEN_ADDR"); listenAddr != "" { 383 + + router.Run(listenAddr) 384 + + } else if os.Getenv("NODE_ENV") == "staging" { 385 + router.Run(":3003") 386 + } else { 387 + router.Run(":3002") 388 + diff --git a/server/routes/auth.go b/server/routes/auth.go 389 + index 6444e714..2869116d 100644 390 + --- a/server/routes/auth.go 391 + +++ b/server/routes/auth.go 392 + @@ -41,6 +41,8 @@ func InitAuth(router *gin.RouterGroup) { 393 + authRouter.POST("/otp/check-email", checkEmail) 394 + authRouter.POST("/otp/send", sendOtp) 395 + authRouter.POST("/otp/verify", verifyOtp) 396 + + 397 + + initGithubAuth(authRouter) 398 + } 399 + 400 + // @Summary Signs user in 401 + diff --git a/server/routes/github_auth.go b/server/routes/github_auth.go 402 + new file mode 100644 403 + index 00000000..40ef9206 404 + --- /dev/null 405 + +++ b/server/routes/github_auth.go 406 + @@ -0,0 +1,241 @@ 407 + +/* GitHub OAuth sign-in (self-host patch). Accounts are keyed by the GitHub 408 + + * account's primary verified email, like the email OTP flow, and carry no 409 + + * calendar access. */ 410 + +package routes 411 + + 412 + +import ( 413 + + "context" 414 + + "crypto/rand" 415 + + "encoding/hex" 416 + + "encoding/json" 417 + + "fmt" 418 + + "net/http" 419 + + "net/url" 420 + + "os" 421 + + "strconv" 422 + + "strings" 423 + + "time" 424 + + 425 + + "github.com/gin-contrib/sessions" 426 + + "github.com/gin-gonic/gin" 427 + + "go.mongodb.org/mongo-driver/bson" 428 + + "go.mongodb.org/mongo-driver/bson/primitive" 429 + + "schej.it/server/db" 430 + + "schej.it/server/logger" 431 + + "schej.it/server/models" 432 + + "schej.it/server/utils" 433 + +) 434 + + 435 + +var githubHttpClient = &http.Client{Timeout: 10 * time.Second} 436 + + 437 + +// Session keys holding the in-flight sign-in between the redirect to GitHub 438 + +// and the callback. 439 + +const ( 440 + + githubStateKey = "githubState" 441 + + githubRedirectKey = "githubRedirect" 442 + + githubTzKey = "githubTimezoneOffset" 443 + + githubLinkKey = "githubEventsToLink" 444 + +) 445 + + 446 + +func initGithubAuth(authRouter *gin.RouterGroup) { 447 + + authRouter.GET("/github", githubSignIn) 448 + + authRouter.GET("/github/callback", githubCallback) 449 + +} 450 + + 451 + +// @Summary Starts GitHub sign-in 452 + +// @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) 453 + +// @Tags auth 454 + +// @Router /auth/github [get] 455 + +func githubSignIn(c *gin.Context) { 456 + + clientId := os.Getenv("GITHUB_CLIENT_ID") 457 + + if clientId == "" { 458 + + c.String(http.StatusNotFound, "GitHub sign-in is not configured") 459 + + return 460 + + } 461 + + 462 + + stateBytes := make([]byte, 32) 463 + + if _, err := rand.Read(stateBytes); err != nil { 464 + + logger.StdErr.Panicln(err) 465 + + } 466 + + state := hex.EncodeToString(stateBytes) 467 + + 468 + + session := sessions.Default(c) 469 + + session.Set(githubStateKey, state) 470 + + session.Set(githubRedirectKey, safeRedirectPath(c.Query("redirect"))) 471 + + session.Set(githubTzKey, c.Query("timezoneOffset")) 472 + + session.Set(githubLinkKey, c.Query("eventsToLink")) 473 + + session.Save() 474 + + 475 + + params := url.Values{} 476 + + params.Set("client_id", clientId) 477 + + params.Set("scope", "read:user user:email") 478 + + params.Set("state", state) 479 + + c.Redirect(http.StatusFound, "https://github.com/login/oauth/authorize?"+params.Encode()) 480 + +} 481 + + 482 + +// @Summary GitHub OAuth callback 483 + +// @Description Exchanges the code, finds or creates the user by primary verified email, sets the session and redirects back into the app 484 + +// @Tags auth 485 + +// @Router /auth/github/callback [get] 486 + +func githubCallback(c *gin.Context) { 487 + + session := sessions.Default(c) 488 + + expectedState, _ := session.Get(githubStateKey).(string) 489 + + redirect, _ := session.Get(githubRedirectKey).(string) 490 + + tzString, _ := session.Get(githubTzKey).(string) 491 + + eventsToLink, _ := session.Get(githubLinkKey).(string) 492 + + // The state is single-use: every exit path below saves the session with 493 + + // these keys removed. Each Save emits a Set-Cookie, so save exactly once. 494 + + session.Delete(githubStateKey) 495 + + session.Delete(githubRedirectKey) 496 + + session.Delete(githubTzKey) 497 + + session.Delete(githubLinkKey) 498 + + 499 + + fail := func(reason string, err error) { 500 + + logger.StdErr.Printf("GitHub sign-in failed: %s: %v", reason, err) 501 + + session.Save() 502 + + c.Redirect(http.StatusFound, "/sign-in?error=github") 503 + + } 504 + + 505 + + if expectedState == "" || c.Query("state") != expectedState { 506 + + fail("state mismatch", nil) 507 + + return 508 + + } 509 + + if c.Query("error") != "" { 510 + + fail("denied", fmt.Errorf("%s", c.Query("error"))) 511 + + return 512 + + } 513 + + 514 + + accessToken, err := githubExchangeCode(c.Query("code")) 515 + + if err != nil { 516 + + fail("code exchange", err) 517 + + return 518 + + } 519 + + 520 + + var ghUser struct { 521 + + Login string `json:"login"` 522 + + Name string `json:"name"` 523 + + AvatarUrl string `json:"avatar_url"` 524 + + } 525 + + if err := githubGet(accessToken, "/user", &ghUser); err != nil { 526 + + fail("fetch user", err) 527 + + return 528 + + } 529 + + 530 + + var ghEmails []struct { 531 + + Email string `json:"email"` 532 + + Primary bool `json:"primary"` 533 + + Verified bool `json:"verified"` 534 + + } 535 + + if err := githubGet(accessToken, "/user/emails", &ghEmails); err != nil { 536 + + fail("fetch emails", err) 537 + + return 538 + + } 539 + + email := "" 540 + + for _, e := range ghEmails { 541 + + if e.Verified && (e.Primary || email == "") { 542 + + email = e.Email 543 + + } 544 + + } 545 + + if email == "" { 546 + + fail("no verified email", nil) 547 + + return 548 + + } 549 + + email = utils.NormalizeEmail(email) 550 + + 551 + + var userId primitive.ObjectID 552 + + if existing := db.GetUserByEmail(email); existing != nil { 553 + + userId = existing.Id 554 + + } else { 555 + + firstName, lastName := ghUser.Login, "" 556 + + if name := strings.TrimSpace(ghUser.Name); name != "" { 557 + + firstName, lastName, _ = strings.Cut(name, " ") 558 + + } 559 + + timezoneOffset, _ := strconv.Atoi(tzString) 560 + + 561 + + res, err := db.UsersCollection.InsertOne(context.Background(), models.User{ 562 + + Email: email, 563 + + FirstName: firstName, 564 + + LastName: strings.TrimSpace(lastName), 565 + + Picture: ghUser.AvatarUrl, 566 + + TimezoneOffset: timezoneOffset, 567 + + TokenOrigin: models.WEB, 568 + + }) 569 + + if err != nil { 570 + + logger.StdErr.Panicln(err) 571 + + } 572 + + userId = res.InsertedID.(primitive.ObjectID) 573 + + } 574 + + 575 + + // Claim events this browser created while signed out (same as /auth/sign-in) 576 + + for _, eventIdString := range strings.Split(eventsToLink, ",") { 577 + + eventId, err := primitive.ObjectIDFromHex(eventIdString) 578 + + if err == nil { 579 + + db.EventsCollection.UpdateOne(context.Background(), bson.M{"_id": eventId, "ownerId": nil}, bson.M{"$set": bson.M{"ownerId": userId}}) 580 + + } 581 + + } 582 + + 583 + + session.Set("userId", userId.Hex()) 584 + + session.Save() 585 + + 586 + + c.Redirect(http.StatusFound, redirect) 587 + +} 588 + + 589 + +// safeRedirectPath only allows same-origin absolute paths, so the callback 590 + +// cannot be turned into an open redirect. 591 + +func safeRedirectPath(path string) string { 592 + + if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.HasPrefix(path, "/\\") { 593 + + return "/home" 594 + + } 595 + + return path 596 + +} 597 + + 598 + +func githubExchangeCode(code string) (string, error) { 599 + + form := url.Values{} 600 + + form.Set("client_id", os.Getenv("GITHUB_CLIENT_ID")) 601 + + form.Set("client_secret", os.Getenv("GITHUB_CLIENT_SECRET")) 602 + + form.Set("code", code) 603 + + 604 + + req, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token", strings.NewReader(form.Encode())) 605 + + if err != nil { 606 + + return "", err 607 + + } 608 + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 609 + + req.Header.Set("Accept", "application/json") 610 + + 611 + + resp, err := githubHttpClient.Do(req) 612 + + if err != nil { 613 + + return "", err 614 + + } 615 + + defer resp.Body.Close() 616 + + 617 + + var body struct { 618 + + AccessToken string `json:"access_token"` 619 + + Error string `json:"error"` 620 + + } 621 + + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 622 + + return "", err 623 + + } 624 + + if body.AccessToken == "" { 625 + + return "", fmt.Errorf("no access token (status %d, error %q)", resp.StatusCode, body.Error) 626 + + } 627 + + return body.AccessToken, nil 628 + +} 629 + + 630 + +func githubGet(accessToken string, path string, out interface{}) error { 631 + + req, err := http.NewRequest(http.MethodGet, "https://api.github.com"+path, nil) 632 + + if err != nil { 633 + + return err 634 + + } 635 + + req.Header.Set("Authorization", "Bearer "+accessToken) 636 + + req.Header.Set("Accept", "application/vnd.github+json") 637 + + 638 + + resp, err := githubHttpClient.Do(req) 639 + + if err != nil { 640 + + return err 641 + + } 642 + + defer resp.Body.Close() 643 + + if resp.StatusCode != http.StatusOK { 644 + + return fmt.Errorf("GET %s: status %d", path, resp.StatusCode) 645 + + } 646 + + return json.NewDecoder(resp.Body).Decode(out) 647 + +}
+4
machines/neoprism/backup.nix
··· 90 90 for db in parley history; do 91 91 ${pkgs.sqlite}/bin/sqlite3 "/var/lib/parley/$db.db" ".backup /var/backup/parley/$db.db" 92 92 done 93 + 94 + # Dump timeful polls (when.lassul.us) from MongoDB 95 + ${pkgs.mongodb-tools}/bin/mongodump --uri=mongodb://127.0.0.1:27017 \ 96 + --db=schej-it --gzip --archive=/var/backup/timeful.archive.gz 93 97 ''; 94 98 95 99 postHook = ''
+3
machines/neoprism/config.nix
··· 101 101 # kannix (kanban board) 102 102 ../../2configs/kannix.nix 103 103 104 + # timeful availability polls (when.lassul.us) 105 + ../../2configs/timeful.nix 106 + 104 107 # backups 105 108 ./backup.nix 106 109