From 7c95f68c5be9655bde435850d9e4476b0fee5964 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Sat, 28 Mar 2026 19:48:32 +0800 Subject: [PATCH 1/2] refactor(ds): enforce web-only access token and slim atproto cookie session --- .env.example | 17 -- .gitignore | 4 +- README.md | 38 +++- app/(app)/app/page.tsx | 93 --------- app/(auth)/at-oauth/page.tsx | 5 - apps/ds/app/api/blob/route.ts | 76 ++++++++ apps/ds/app/api/migrate/cleanup/route.ts | 24 +++ apps/ds/app/api/migrate/export/route.ts | 19 ++ apps/ds/app/api/migrate/import/route.ts | 42 ++++ apps/ds/app/api/share/[shareId]/route.ts | 23 +++ apps/ds/app/api/share/route.ts | 64 +++++++ apps/ds/app/layout.tsx | 9 + apps/ds/app/page.tsx | 8 + apps/ds/lib/db.ts | 41 ++++ apps/ds/lib/signature.ts | 85 ++++++++ apps/ds/next.config.ts | 5 + apps/ds/package.json | 25 +++ apps/ds/tsconfig.json | 21 ++ apps/ds/types/pg.d.ts | 10 + .../app}/(app)/[handle]/[shareId]/page.tsx | 0 {app => apps/web/app}/(app)/about/page.tsx | 6 +- {app => apps/web/app}/(app)/app/layout.tsx | 0 apps/web/app/(app)/app/page.tsx | 181 ++++++++++++++++++ {app => apps/web/app}/(app)/privacy/page.tsx | 0 .../app/(app)/share/[did]/[shareId]/page.tsx | 59 ++++++ .../web/app}/(app)/share/[id]/layout.tsx | 0 .../web/app}/(app)/share/[id]/page.tsx | 0 {app => apps/web/app}/(app)/terms/page.tsx | 0 apps/web/app/(auth)/at-oauth/page.tsx | 33 ++++ .../web/app}/(auth)/reset-password/page.tsx | 0 {app => apps/web/app}/(auth)/sign-in/page.tsx | 0 .../app}/(auth)/sign-in/sso-callback/page.tsx | 0 {app => apps/web/app}/(auth)/sign-up/page.tsx | 0 .../app}/(auth)/sign-up/sso-callback/page.tsx | 0 {app => apps/web/app}/api/account/route.ts | 0 .../web/app}/api/atproto/callback/route.ts | 84 +++++--- .../web/app}/api/atproto/login/route.ts | 45 +++-- .../web/app}/api/atproto/logout/route.ts | 9 - .../app}/api/atproto/register-url/route.ts | 24 ++- .../web/app}/api/atproto/session/route.ts | 15 +- {app => apps/web/app}/api/blob/check/route.ts | 0 {app => apps/web/app}/api/blob/route.ts | 158 ++++++++++----- apps/web/app/api/ds/config/route.ts | 62 ++++++ apps/web/app/api/ds/migrate/route.ts | 98 ++++++++++ apps/web/app/api/ds/proxy/route.ts | 41 ++++ {app => apps/web/app}/api/share/list/route.ts | 0 .../web/app}/api/share/public/route.ts | 76 +++++++- {app => apps/web/app}/api/share/route.ts | 135 ++++++++++++- {app => apps/web/app}/api/verify/route.ts | 0 {app => apps/web/app}/globals.css | 2 + {app => apps/web/app}/icon.svg | 0 {app => apps/web/app}/layout.tsx | 0 {app => apps/web/app}/manifest.ts | 0 {app => apps/web/app}/not-found.tsx | 0 .../app/oauth-client-metadata.json/route.ts | 24 +++ {app => apps/web/app}/page.tsx | 0 {app => apps/web/app}/sitemap.ts | 0 components.json => apps/web/components.json | 0 .../app/analytics/analytics-view.tsx | 0 .../app/analytics/build-info-card.tsx | 0 .../app/analytics/events-calendar.tsx | 0 .../app/analytics/import-export.tsx | 0 .../app/analytics/share-management.tsx | 0 .../app/analytics/time-analytics.tsx | 0 .../components}/app/auth-waiting-loading.tsx | 0 .../web/components}/app/calendar.tsx | 0 .../components}/app/event/event-dialog.tsx | 0 .../components}/app/event/event-preview.tsx | 2 +- .../components}/app/profile/daily-toast.tsx | 0 .../web/components}/app/profile/settings.tsx | 135 +++++++++++++ .../components}/app/profile/shared-event.tsx | 0 .../app/profile/user-profile-button.tsx | 2 +- .../app/sidebar/bookmark-panel.tsx | 0 .../web/components}/app/sidebar/countdown.tsx | 0 .../app/sidebar/mini-calendar-sheet.tsx | 0 .../components}/app/sidebar/right-sidebar.tsx | 0 .../web/components}/app/sidebar/sidebar.tsx | 0 .../web/components}/app/views/day-view.tsx | 0 .../web/components}/app/views/month-view.tsx | 0 .../web/components}/app/views/week-view.tsx | 0 .../web/components}/app/views/year-view.tsx | 0 .../components}/auth/atproto-login-form.tsx | 0 .../web/components}/auth/auth-brand.tsx | 0 .../web/components}/auth/login-form.tsx | 0 .../web/components}/auth/reset-form.tsx | 0 .../web/components}/auth/sign-up-form.tsx | 0 .../web/components}/icons/clock-dashed.tsx | 0 .../web/components}/icons/share.tsx | 0 .../components}/landing/animated-sphere.tsx | 0 .../landing/animated-tetrahedron.tsx | 0 .../web/components}/landing/animated-wave.tsx | 0 .../web/components}/landing/cta-section.tsx | 0 .../landing/developers-section.tsx | 0 .../components}/landing/features-section.tsx | 0 .../components}/landing/footer-section.tsx | 0 .../web/components}/landing/hero-section.tsx | 0 .../landing/how-it-works-section.tsx | 0 .../web/components}/landing/index.ts | 0 .../landing/infrastructure-section.tsx | 0 .../landing/integrations-section.tsx | 0 .../components}/landing/metrics-section.tsx | 0 .../web/components}/landing/navigation.tsx | 0 .../components}/landing/pricing-section.tsx | 0 .../landing/testimonials-section.tsx | 0 .../providers/calendar-context.tsx | 0 .../components}/providers/pwa-provider.tsx | 0 .../components}/providers/theme-provider.tsx | 0 {hooks => apps/web/hooks}/use-toast.ts | 0 {hooks => apps/web/hooks}/useLocalStorage.ts | 0 {hooks => apps/web/hooks}/useMobile.ts | 0 {lib => apps/web/lib}/atproto-auth.ts | 15 +- apps/web/lib/atproto-feature.ts | 12 ++ {lib => apps/web/lib}/atproto-oauth-txn.ts | 0 {lib => apps/web/lib}/atproto.ts | 0 {lib => apps/web/lib}/crypto.ts | 0 {lib => apps/web/lib}/dpop.ts | 0 apps/web/lib/ds-client.ts | 19 ++ apps/web/lib/ds-signed-request.ts | 63 ++++++ {lib => apps/web/lib}/fetch-json.ts | 0 {lib => apps/web/lib}/gen-oauth-metadata.mjs | 0 {lib => apps/web/lib}/icsUtils.ts | 0 {lib => apps/web/lib}/notifications.ts | 0 {lib => apps/web/lib}/time-analytics.ts | 0 {lib => apps/web/lib}/utils.ts | 0 next.config.ts => apps/web/next.config.ts | 0 apps/web/package.json | 81 ++++++++ apps/web/postcss.config.mjs | 1 + proxy.ts => apps/web/proxy.ts | 0 {public => apps/web/public}/Banner-dark.jpg | Bin {public => apps/web/public}/Banner.jpg | Bin {public => apps/web/public}/Home.jpg | Bin {public => apps/web/public}/file.svg | 0 {public => apps/web/public}/globe.svg | 0 {public => apps/web/public}/icon.svg | 0 .../web/public}/oauth-client-metadata.json | 0 {public => apps/web/public}/og.png | Bin {public => apps/web/public}/sf.otf | Bin {public => apps/web/public}/sw.js | 0 {public => apps/web/public}/vercel.svg | 0 {public => apps/web/public}/window.svg | 0 apps/web/tailwind.config.ts | 1 + apps/web/tsconfig.json | 24 +++ vercel.json => apps/web/vercel.json | 0 lib/atproto-feature.ts | 17 -- package.json | 87 ++------- packages/config/package.json | 11 ++ .../config/postcss.config.mjs | 0 packages/config/tailwind.config.ts | 5 + .../config/tsconfig.base.json | 14 +- packages/i18n/package.json | 8 + .../i18n/scripts}/gen-locales.mjs | 10 +- {lib => packages/i18n/src}/i18n.ts | 2 +- .../i18n/src/locales}/bn.json | 0 .../i18n/src/locales}/de.json | 0 .../i18n/src/locales}/el.json | 0 .../i18n/src/locales}/en-GB.json | 0 .../i18n/src/locales}/en.json | 0 .../i18n/src/locales}/es.json | 0 .../i18n/src/locales}/fi.json | 0 .../i18n/src/locales}/fr.json | 0 .../i18n/src/locales}/hi.json | 0 .../i18n/src/locales}/is.json | 0 .../i18n/src/locales}/it.json | 0 .../i18n/src/locales}/ja.json | 0 .../i18n/src/locales}/ko.json | 0 .../i18n/src/locales}/lt.json | 0 .../i18n/src/locales}/lv.json | 0 .../i18n/src/locales}/mk.json | 0 .../i18n/src/locales}/nb.json | 0 .../i18n/src/locales}/nl.json | 0 .../i18n/src/locales}/pl.json | 0 .../i18n/src/locales}/pt.json | 0 .../i18n/src/locales}/ro.json | 0 .../i18n/src/locales}/ru.json | 0 .../i18n/src/locales}/sl.json | 0 .../i18n/src/locales}/sq.json | 0 .../i18n/src/locales}/sr.json | 0 .../i18n/src/locales}/sv.json | 0 .../i18n/src/locales}/sw.json | 0 .../i18n/src/locales}/th.json | 0 .../i18n/src/locales}/tr.json | 0 .../i18n/src/locales}/uk.json | 0 .../i18n/src/locales}/vi.json | 0 .../i18n/src/locales}/yue.json | 0 .../i18n/src/locales}/zh-CN.json | 0 .../i18n/src/locales}/zh-HK.json | 0 .../i18n/src/locales}/zh-TW.json | 0 packages/ui/package.json | 28 +++ .../ui => packages/ui/src}/accordion.tsx | 2 +- .../ui => packages/ui/src}/alert-dialog.tsx | 4 +- {components/ui => packages/ui/src}/alert.tsx | 2 +- {components/ui => packages/ui/src}/avatar.tsx | 2 +- {components/ui => packages/ui/src}/badge.tsx | 2 +- {components/ui => packages/ui/src}/button.tsx | 2 +- .../ui => packages/ui/src}/calendar.tsx | 4 +- {components/ui => packages/ui/src}/card.tsx | 2 +- .../ui => packages/ui/src}/checkbox.tsx | 2 +- .../ui => packages/ui/src}/collapsible.tsx | 0 .../ui => packages/ui/src}/context-menu.tsx | 2 +- {components/ui => packages/ui/src}/dialog.tsx | 4 +- .../ui => packages/ui/src}/dropdown-menu.tsx | 2 +- {components/ui => packages/ui/src}/empty.tsx | 2 +- {components/ui => packages/ui/src}/input.tsx | 2 +- {components/ui => packages/ui/src}/kbd.tsx | 2 +- {components/ui => packages/ui/src}/label.tsx | 2 +- .../ui => packages/ui/src}/popover.tsx | 2 +- .../ui => packages/ui/src}/scroll-area.tsx | 2 +- {components/ui => packages/ui/src}/select.tsx | 2 +- .../ui => packages/ui/src}/separator.tsx | 2 +- {components/ui => packages/ui/src}/sheet.tsx | 2 +- {components/ui => packages/ui/src}/sonner.tsx | 29 ++- .../ui => packages/ui/src}/spinner.tsx | 2 +- {components/ui => packages/ui/src}/switch.tsx | 2 +- {components/ui => packages/ui/src}/tabs.tsx | 2 +- .../ui => packages/ui/src}/textarea.tsx | 2 +- {components/ui => packages/ui/src}/toast.tsx | 2 +- .../ui => packages/ui/src}/toaster.tsx | 4 +- .../ui => packages/ui/src}/use-toast.tsx | 2 +- packages/ui/src/utils.ts | 6 + tailwind.config.ts | 3 - turbo.json | 40 ++++ 221 files changed, 1935 insertions(+), 405 deletions(-) delete mode 100644 .env.example delete mode 100644 app/(app)/app/page.tsx delete mode 100644 app/(auth)/at-oauth/page.tsx create mode 100644 apps/ds/app/api/blob/route.ts create mode 100644 apps/ds/app/api/migrate/cleanup/route.ts create mode 100644 apps/ds/app/api/migrate/export/route.ts create mode 100644 apps/ds/app/api/migrate/import/route.ts create mode 100644 apps/ds/app/api/share/[shareId]/route.ts create mode 100644 apps/ds/app/api/share/route.ts create mode 100644 apps/ds/app/layout.tsx create mode 100644 apps/ds/app/page.tsx create mode 100644 apps/ds/lib/db.ts create mode 100644 apps/ds/lib/signature.ts create mode 100644 apps/ds/next.config.ts create mode 100644 apps/ds/package.json create mode 100644 apps/ds/tsconfig.json create mode 100644 apps/ds/types/pg.d.ts rename {app => apps/web/app}/(app)/[handle]/[shareId]/page.tsx (100%) rename {app => apps/web/app}/(app)/about/page.tsx (97%) rename {app => apps/web/app}/(app)/app/layout.tsx (100%) create mode 100644 apps/web/app/(app)/app/page.tsx rename {app => apps/web/app}/(app)/privacy/page.tsx (100%) create mode 100644 apps/web/app/(app)/share/[did]/[shareId]/page.tsx rename {app => apps/web/app}/(app)/share/[id]/layout.tsx (100%) rename {app => apps/web/app}/(app)/share/[id]/page.tsx (100%) rename {app => apps/web/app}/(app)/terms/page.tsx (100%) create mode 100644 apps/web/app/(auth)/at-oauth/page.tsx rename {app => apps/web/app}/(auth)/reset-password/page.tsx (100%) rename {app => apps/web/app}/(auth)/sign-in/page.tsx (100%) rename {app => apps/web/app}/(auth)/sign-in/sso-callback/page.tsx (100%) rename {app => apps/web/app}/(auth)/sign-up/page.tsx (100%) rename {app => apps/web/app}/(auth)/sign-up/sso-callback/page.tsx (100%) rename {app => apps/web/app}/api/account/route.ts (100%) rename {app => apps/web/app}/api/atproto/callback/route.ts (73%) rename {app => apps/web/app}/api/atproto/login/route.ts (82%) rename {app => apps/web/app}/api/atproto/logout/route.ts (54%) rename {app => apps/web/app}/api/atproto/register-url/route.ts (80%) rename {app => apps/web/app}/api/atproto/session/route.ts (82%) rename {app => apps/web/app}/api/blob/check/route.ts (100%) rename {app => apps/web/app}/api/blob/route.ts (54%) create mode 100644 apps/web/app/api/ds/config/route.ts create mode 100644 apps/web/app/api/ds/migrate/route.ts create mode 100644 apps/web/app/api/ds/proxy/route.ts rename {app => apps/web/app}/api/share/list/route.ts (100%) rename {app => apps/web/app}/api/share/public/route.ts (69%) rename {app => apps/web/app}/api/share/route.ts (73%) rename {app => apps/web/app}/api/verify/route.ts (100%) rename {app => apps/web/app}/globals.css (99%) rename {app => apps/web/app}/icon.svg (100%) rename {app => apps/web/app}/layout.tsx (100%) rename {app => apps/web/app}/manifest.ts (100%) rename {app => apps/web/app}/not-found.tsx (100%) create mode 100644 apps/web/app/oauth-client-metadata.json/route.ts rename {app => apps/web/app}/page.tsx (100%) rename {app => apps/web/app}/sitemap.ts (100%) rename components.json => apps/web/components.json (100%) rename {components => apps/web/components}/app/analytics/analytics-view.tsx (100%) rename {components => apps/web/components}/app/analytics/build-info-card.tsx (100%) rename {components => apps/web/components}/app/analytics/events-calendar.tsx (100%) rename {components => apps/web/components}/app/analytics/import-export.tsx (100%) rename {components => apps/web/components}/app/analytics/share-management.tsx (100%) rename {components => apps/web/components}/app/analytics/time-analytics.tsx (100%) rename {components => apps/web/components}/app/auth-waiting-loading.tsx (100%) rename {components => apps/web/components}/app/calendar.tsx (100%) rename {components => apps/web/components}/app/event/event-dialog.tsx (100%) rename {components => apps/web/components}/app/event/event-preview.tsx (99%) rename {components => apps/web/components}/app/profile/daily-toast.tsx (100%) rename {components => apps/web/components}/app/profile/settings.tsx (70%) rename {components => apps/web/components}/app/profile/shared-event.tsx (100%) rename {components => apps/web/components}/app/profile/user-profile-button.tsx (99%) rename {components => apps/web/components}/app/sidebar/bookmark-panel.tsx (100%) rename {components => apps/web/components}/app/sidebar/countdown.tsx (100%) rename {components => apps/web/components}/app/sidebar/mini-calendar-sheet.tsx (100%) rename {components => apps/web/components}/app/sidebar/right-sidebar.tsx (100%) rename {components => apps/web/components}/app/sidebar/sidebar.tsx (100%) rename {components => apps/web/components}/app/views/day-view.tsx (100%) rename {components => apps/web/components}/app/views/month-view.tsx (100%) rename {components => apps/web/components}/app/views/week-view.tsx (100%) rename {components => apps/web/components}/app/views/year-view.tsx (100%) rename {components => apps/web/components}/auth/atproto-login-form.tsx (100%) rename {components => apps/web/components}/auth/auth-brand.tsx (100%) rename {components => apps/web/components}/auth/login-form.tsx (100%) rename {components => apps/web/components}/auth/reset-form.tsx (100%) rename {components => apps/web/components}/auth/sign-up-form.tsx (100%) rename {components => apps/web/components}/icons/clock-dashed.tsx (100%) rename {components => apps/web/components}/icons/share.tsx (100%) rename {components => apps/web/components}/landing/animated-sphere.tsx (100%) rename {components => apps/web/components}/landing/animated-tetrahedron.tsx (100%) rename {components => apps/web/components}/landing/animated-wave.tsx (100%) rename {components => apps/web/components}/landing/cta-section.tsx (100%) rename {components => apps/web/components}/landing/developers-section.tsx (100%) rename {components => apps/web/components}/landing/features-section.tsx (100%) rename {components => apps/web/components}/landing/footer-section.tsx (100%) rename {components => apps/web/components}/landing/hero-section.tsx (100%) rename {components => apps/web/components}/landing/how-it-works-section.tsx (100%) rename {components => apps/web/components}/landing/index.ts (100%) rename {components => apps/web/components}/landing/infrastructure-section.tsx (100%) rename {components => apps/web/components}/landing/integrations-section.tsx (100%) rename {components => apps/web/components}/landing/metrics-section.tsx (100%) rename {components => apps/web/components}/landing/navigation.tsx (100%) rename {components => apps/web/components}/landing/pricing-section.tsx (100%) rename {components => apps/web/components}/landing/testimonials-section.tsx (100%) rename {components => apps/web/components}/providers/calendar-context.tsx (100%) rename {components => apps/web/components}/providers/pwa-provider.tsx (100%) rename {components => apps/web/components}/providers/theme-provider.tsx (100%) rename {hooks => apps/web/hooks}/use-toast.ts (100%) rename {hooks => apps/web/hooks}/useLocalStorage.ts (100%) rename {hooks => apps/web/hooks}/useMobile.ts (100%) rename {lib => apps/web/lib}/atproto-auth.ts (91%) create mode 100644 apps/web/lib/atproto-feature.ts rename {lib => apps/web/lib}/atproto-oauth-txn.ts (100%) rename {lib => apps/web/lib}/atproto.ts (100%) rename {lib => apps/web/lib}/crypto.ts (100%) rename {lib => apps/web/lib}/dpop.ts (100%) create mode 100644 apps/web/lib/ds-client.ts create mode 100644 apps/web/lib/ds-signed-request.ts rename {lib => apps/web/lib}/fetch-json.ts (100%) rename {lib => apps/web/lib}/gen-oauth-metadata.mjs (100%) rename {lib => apps/web/lib}/icsUtils.ts (100%) rename {lib => apps/web/lib}/notifications.ts (100%) rename {lib => apps/web/lib}/time-analytics.ts (100%) rename {lib => apps/web/lib}/utils.ts (100%) rename next.config.ts => apps/web/next.config.ts (100%) create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs rename proxy.ts => apps/web/proxy.ts (100%) rename {public => apps/web/public}/Banner-dark.jpg (100%) rename {public => apps/web/public}/Banner.jpg (100%) rename {public => apps/web/public}/Home.jpg (100%) rename {public => apps/web/public}/file.svg (100%) rename {public => apps/web/public}/globe.svg (100%) rename {public => apps/web/public}/icon.svg (100%) rename {public => apps/web/public}/oauth-client-metadata.json (100%) rename {public => apps/web/public}/og.png (100%) rename {public => apps/web/public}/sf.otf (100%) rename {public => apps/web/public}/sw.js (100%) rename {public => apps/web/public}/vercel.svg (100%) rename {public => apps/web/public}/window.svg (100%) create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json rename vercel.json => apps/web/vercel.json (100%) delete mode 100644 lib/atproto-feature.ts create mode 100644 packages/config/package.json rename postcss.config.mjs => packages/config/postcss.config.mjs (100%) create mode 100644 packages/config/tailwind.config.ts rename tsconfig.json => packages/config/tsconfig.base.json (56%) create mode 100644 packages/i18n/package.json rename {lib => packages/i18n/scripts}/gen-locales.mjs (74%) rename {lib => packages/i18n/src}/i18n.ts (99%) rename {locales => packages/i18n/src/locales}/bn.json (100%) rename {locales => packages/i18n/src/locales}/de.json (100%) rename {locales => packages/i18n/src/locales}/el.json (100%) rename {locales => packages/i18n/src/locales}/en-GB.json (100%) rename {locales => packages/i18n/src/locales}/en.json (100%) rename {locales => packages/i18n/src/locales}/es.json (100%) rename {locales => packages/i18n/src/locales}/fi.json (100%) rename {locales => packages/i18n/src/locales}/fr.json (100%) rename {locales => packages/i18n/src/locales}/hi.json (100%) rename {locales => packages/i18n/src/locales}/is.json (100%) rename {locales => packages/i18n/src/locales}/it.json (100%) rename {locales => packages/i18n/src/locales}/ja.json (100%) rename {locales => packages/i18n/src/locales}/ko.json (100%) rename {locales => packages/i18n/src/locales}/lt.json (100%) rename {locales => packages/i18n/src/locales}/lv.json (100%) rename {locales => packages/i18n/src/locales}/mk.json (100%) rename {locales => packages/i18n/src/locales}/nb.json (100%) rename {locales => packages/i18n/src/locales}/nl.json (100%) rename {locales => packages/i18n/src/locales}/pl.json (100%) rename {locales => packages/i18n/src/locales}/pt.json (100%) rename {locales => packages/i18n/src/locales}/ro.json (100%) rename {locales => packages/i18n/src/locales}/ru.json (100%) rename {locales => packages/i18n/src/locales}/sl.json (100%) rename {locales => packages/i18n/src/locales}/sq.json (100%) rename {locales => packages/i18n/src/locales}/sr.json (100%) rename {locales => packages/i18n/src/locales}/sv.json (100%) rename {locales => packages/i18n/src/locales}/sw.json (100%) rename {locales => packages/i18n/src/locales}/th.json (100%) rename {locales => packages/i18n/src/locales}/tr.json (100%) rename {locales => packages/i18n/src/locales}/uk.json (100%) rename {locales => packages/i18n/src/locales}/vi.json (100%) rename {locales => packages/i18n/src/locales}/yue.json (100%) rename {locales => packages/i18n/src/locales}/zh-CN.json (100%) rename {locales => packages/i18n/src/locales}/zh-HK.json (100%) rename {locales => packages/i18n/src/locales}/zh-TW.json (100%) create mode 100644 packages/ui/package.json rename {components/ui => packages/ui/src}/accordion.tsx (98%) rename {components/ui => packages/ui/src}/alert-dialog.tsx (98%) rename {components/ui => packages/ui/src}/alert.tsx (97%) rename {components/ui => packages/ui/src}/avatar.tsx (96%) rename {components/ui => packages/ui/src}/badge.tsx (97%) rename {components/ui => packages/ui/src}/button.tsx (98%) rename {components/ui => packages/ui/src}/calendar.tsx (98%) rename {components/ui => packages/ui/src}/card.tsx (98%) rename {components/ui => packages/ui/src}/checkbox.tsx (97%) rename {components/ui => packages/ui/src}/collapsible.tsx (100%) rename {components/ui => packages/ui/src}/context-menu.tsx (99%) rename {components/ui => packages/ui/src}/dialog.tsx (98%) rename {components/ui => packages/ui/src}/dropdown-menu.tsx (99%) rename {components/ui => packages/ui/src}/empty.tsx (98%) rename {components/ui => packages/ui/src}/input.tsx (96%) rename {components/ui => packages/ui/src}/kbd.tsx (96%) rename {components/ui => packages/ui/src}/label.tsx (95%) rename {components/ui => packages/ui/src}/popover.tsx (98%) rename {components/ui => packages/ui/src}/scroll-area.tsx (98%) rename {components/ui => packages/ui/src}/select.tsx (99%) rename {components/ui => packages/ui/src}/separator.tsx (95%) rename {components/ui => packages/ui/src}/sheet.tsx (99%) rename {components/ui => packages/ui/src}/sonner.tsx (64%) rename {components/ui => packages/ui/src}/spinner.tsx (89%) rename {components/ui => packages/ui/src}/switch.tsx (97%) rename {components/ui => packages/ui/src}/tabs.tsx (98%) rename {components/ui => packages/ui/src}/textarea.tsx (96%) rename {components/ui => packages/ui/src}/toast.tsx (99%) rename {components/ui => packages/ui/src}/toaster.tsx (89%) rename {components/ui => packages/ui/src}/use-toast.tsx (98%) create mode 100644 packages/ui/src/utils.ts delete mode 100644 tailwind.config.ts create mode 100644 turbo.json diff --git a/.env.example b/.env.example deleted file mode 100644 index 3aafe5f3..00000000 --- a/.env.example +++ /dev/null @@ -1,17 +0,0 @@ -# Required -NEXT_PUBLIC_BASE_URL=http://localhost:3000 -SALT=Backup-Salt - -# Auth (Required) -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your-clerk-publishable-key -CLERK_SECRET_KEY=your-clerk-secret - -# ATProto / Atmosphere (Required if enabling ATProto login) -ATPROTO_SESSION_SECRET=replace-with-strong-random-secret - -# Optional, database -POSTGRES_URL=postgres://postgres:postgres@localhost:5432/onecalendar - -# Optional, Cloudflare turnstile captcha -NEXT_PUBLIC_TURNSTILE_SITE_KEY=site-key -TURNSTILE_SECRET_KEY=secret-key diff --git a/.gitignore b/.gitignore index 179f0fe7..d6596297 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ # next.js /.next/ +/apps/web/.next/ +/apps/ds/.next/ /out/ # production @@ -44,4 +46,4 @@ next-env.d.ts .turbo # generated locale map -/lib/locales.ts +/packages/i18n/src/locales.ts diff --git a/README.md b/README.md index afa45ae7..ea8ab11c 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ > A privacy-first, weekly-focused open-source calendar built for clarity and control. - - - Image + + + Image - [Live Product](https://calendar.xyehr.cn) @@ -96,6 +96,34 @@ This project is built for individuals and small teams who value clarity over com ## Getting Started +## Monorepo Structure + +This repository uses a standard **Turborepo** layout: + +- `apps/web`: Next.js application +- `apps/ds`: decentralized storage server (Next.js API + PostgreSQL) +- `packages/config`: shared ts/postcss/tailwind config +- `packages/ui`: shared shadcn ui components +- `packages/i18n`: locales + i18n runtime helpers +- `turbo.json`: task pipeline and cache settings + +Useful commands: + +```bash +# run the web app through Turbo +bun run dev + +# run decentralized storage server +bun run dev:ds + +# build all workspaces +bun run build + +# run a task only for web +bun run generate:locales +``` + + ### Prerequisites Required Versions: @@ -115,13 +143,15 @@ bun install # Start the app bun run dev +# or run directly in the web workspace +bun run --cwd apps/web dev ``` Then visit `http://localhost:3000` ### Environment Variables -Copy `.env.example` to `.env` and fill in. +Copy `apps/web/.env.example` to `apps/web/.env` and fill in. Key variables: diff --git a/app/(app)/app/page.tsx b/app/(app)/app/page.tsx deleted file mode 100644 index bd1472ee..00000000 --- a/app/(app)/app/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client" - -import Calendar from "@/components/app/calendar" -import AuthWaitingLoading from "@/components/app/auth-waiting-loading" -import { useUser } from "@clerk/nextjs" -import { useEffect, useMemo, useState } from "react" - -function hasClerkSessionCookie() { - if (typeof document === "undefined") return false - - return document.cookie - .split(";") - .some((cookie) => cookie.trim().startsWith("__session=")) -} - -export default function Home() { - const { isLoaded, isSignedIn } = useUser() - const [hasSessionCookie, setHasSessionCookie] = useState(hasClerkSessionCookie) - const [minimumWaitDone, setMinimumWaitDone] = useState(false) - const [atprotoLogoutDone, setAtprotoLogoutDone] = useState(false) - const [dbReady, setDbReady] = useState(false) - - useEffect(() => { - const waitTimer = window.setTimeout(() => { - setMinimumWaitDone(true) - }, 500) - - - const cookieCheckTimer = window.setInterval(() => { - if (hasClerkSessionCookie()) { - setHasSessionCookie(true) - } - }, 50) - - return () => { - window.clearTimeout(waitTimer) - window.clearInterval(cookieCheckTimer) - } - }, []) - - useEffect(() => { - if (!isLoaded || !isSignedIn || atprotoLogoutDone) return - fetch("/api/atproto/logout", { method: "POST" }) - .catch(() => undefined) - .finally(() => setAtprotoLogoutDone(true)) - }, [isLoaded, isSignedIn, atprotoLogoutDone]) - - - - useEffect(() => { - if (!isLoaded) return - - if (!isSignedIn) { - setDbReady(true) - return - } - - let active = true - const checkDbDataReady = async () => { - try { - const response = await fetch("/api/blob", { cache: "no-store" }) - if (!active) return - if (response.status === 200 || response.status === 404) { - setDbReady(true) - return - } - setDbReady(false) - } catch { - if (active) { - setDbReady(false) - } - } - } - - void checkDbDataReady() - return () => { - active = false - } - }, [isLoaded, isSignedIn]) - - const shouldShowAuthWait = useMemo(() => { - if (!minimumWaitDone) return true - if (hasSessionCookie && !isLoaded) return true - if (isSignedIn && !dbReady) return true - return false - }, [minimumWaitDone, hasSessionCookie, isLoaded, isSignedIn, dbReady]) - - if (shouldShowAuthWait) { - return - } - - return -} diff --git a/app/(auth)/at-oauth/page.tsx b/app/(auth)/at-oauth/page.tsx deleted file mode 100644 index 5c3ca7b8..00000000 --- a/app/(auth)/at-oauth/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function AtprotoLoginPage() { - redirect("/sign-in"); -} diff --git a/apps/ds/app/api/blob/route.ts b/apps/ds/app/api/blob/route.ts new file mode 100644 index 00000000..ef124e1a --- /dev/null +++ b/apps/ds/app/api/blob/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { ensureTables, pool } from "@/lib/db"; +import { requireSignedRequest } from "@/lib/signature"; + +function statusForError(error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + if ( + message.includes("Missing signature headers") || + message.includes("Expired timestamp") || + message.includes("Invalid signature") || + message.includes("Invalid app token") || + message.includes("Failed to resolve DID") || + message.includes("Missing DID public key") + ) { + return 401; + } + return 500; +} + +export async function GET(request: Request) { + try { + await ensureTables(); + const { did } = await requireSignedRequest(request); + const result = await pool.query( + "SELECT encrypted_data, iv, timestamp FROM calendar_backups WHERE user_id = $1 LIMIT 1", + [did], + ); + return NextResponse.json({ data: result.rows[0] ?? null }); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: statusForError(error) }, + ); + } +} + +export async function POST(request: Request) { + const raw = await request.text(); + try { + await ensureTables(); + const { did } = await requireSignedRequest(request, raw); + const payload = JSON.parse(raw) as { + encrypted_data: string; + iv: string; + timestamp?: number; + }; + + await pool.query( + `INSERT INTO calendar_backups (user_id, encrypted_data, iv, timestamp) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id) DO UPDATE SET encrypted_data = EXCLUDED.encrypted_data, iv = EXCLUDED.iv, timestamp = EXCLUDED.timestamp, updated_at = NOW()`, + [did, payload.encrypted_data, payload.iv, payload.timestamp ?? Date.now()], + ); + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: statusForError(error) }, + ); + } +} + +export async function DELETE(request: Request) { + try { + await ensureTables(); + const { did } = await requireSignedRequest(request); + await pool.query("DELETE FROM calendar_backups WHERE user_id = $1", [did]); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: statusForError(error) }, + ); + } +} diff --git a/apps/ds/app/api/migrate/cleanup/route.ts b/apps/ds/app/api/migrate/cleanup/route.ts new file mode 100644 index 00000000..d21f3123 --- /dev/null +++ b/apps/ds/app/api/migrate/cleanup/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { ensureTables, pool } from "@/lib/db"; +import { requireSignedRequest } from "@/lib/signature"; + +export async function POST(request: Request) { + const raw = await request.text(); + const client = await pool.connect(); + try { + await ensureTables(); + const { did } = await requireSignedRequest(request, raw); + + await client.query("BEGIN"); + await client.query("DELETE FROM shares WHERE user_id = $1", [did]); + await client.query("DELETE FROM calendar_backups WHERE user_id = $1", [did]); + await client.query("COMMIT"); + + return NextResponse.json({ success: true }); + } catch (error) { + await client.query("ROLLBACK"); + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } finally { + client.release(); + } +} diff --git a/apps/ds/app/api/migrate/export/route.ts b/apps/ds/app/api/migrate/export/route.ts new file mode 100644 index 00000000..c18737ea --- /dev/null +++ b/apps/ds/app/api/migrate/export/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { ensureTables, pool } from "@/lib/db"; +import { requireSignedRequest } from "@/lib/signature"; + +export async function POST(request: Request) { + const raw = await request.text(); + try { + await ensureTables(); + const { did } = await requireSignedRequest(request, raw); + const [backups, shares] = await Promise.all([ + pool.query("SELECT encrypted_data, iv, timestamp FROM calendar_backups WHERE user_id = $1", [did]), + pool.query("SELECT share_id, data, timestamp FROM shares WHERE user_id = $1", [did]), + ]); + + return NextResponse.json({ did, backups: backups.rows, shares: shares.rows }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} diff --git a/apps/ds/app/api/migrate/import/route.ts b/apps/ds/app/api/migrate/import/route.ts new file mode 100644 index 00000000..829c5c31 --- /dev/null +++ b/apps/ds/app/api/migrate/import/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { ensureTables, pool } from "@/lib/db"; +import { requireSignedRequest } from "@/lib/signature"; + +export async function POST(request: Request) { + const raw = await request.text(); + const client = await pool.connect(); + try { + await ensureTables(); + const { did } = await requireSignedRequest(request, raw); + const payload = JSON.parse(raw) as { + backups: Array<{ encrypted_data: string; iv: string; timestamp: number }>; + shares: Array<{ share_id: string; data: string; timestamp: number }>; + }; + + await client.query("BEGIN"); + await client.query("DELETE FROM calendar_backups WHERE user_id = $1", [did]); + await client.query("DELETE FROM shares WHERE user_id = $1", [did]); + + for (const row of payload.backups || []) { + await client.query( + "INSERT INTO calendar_backups (user_id, encrypted_data, iv, timestamp) VALUES ($1, $2, $3, $4)", + [did, row.encrypted_data, row.iv, row.timestamp], + ); + } + + for (const row of payload.shares || []) { + await client.query( + "INSERT INTO shares (user_id, share_id, data, timestamp) VALUES ($1, $2, $3, $4)", + [did, row.share_id, row.data, row.timestamp], + ); + } + + await client.query("COMMIT"); + return NextResponse.json({ success: true }); + } catch (error) { + await client.query("ROLLBACK"); + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } finally { + client.release(); + } +} diff --git a/apps/ds/app/api/share/[shareId]/route.ts b/apps/ds/app/api/share/[shareId]/route.ts new file mode 100644 index 00000000..65194000 --- /dev/null +++ b/apps/ds/app/api/share/[shareId]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { ensureTables, pool } from "@/lib/db"; + +export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) { + const appToken = process.env.DS_APP_TOKEN; + if (!appToken) { + return NextResponse.json({ error: "DS_APP_TOKEN is not configured" }, { status: 500 }); + } + // Allow DS data retrieval only from trusted web app server. + if (request.headers.get("x-app-token") !== appToken) { + return NextResponse.json({ error: "Invalid app token" }, { status: 401 }); + } + await ensureTables(); + const { shareId } = await params; + const result = await pool.query( + "SELECT user_id, share_id, data, timestamp FROM shares WHERE share_id = $1 LIMIT 1", + [shareId], + ); + if (result.rowCount === 0) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json({ share: result.rows[0] }); +} diff --git a/apps/ds/app/api/share/route.ts b/apps/ds/app/api/share/route.ts new file mode 100644 index 00000000..a495f136 --- /dev/null +++ b/apps/ds/app/api/share/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { ensureTables, pool } from "@/lib/db"; +import { requireSignedRequest } from "@/lib/signature"; + +function statusForError(error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + if ( + message.includes("Missing signature headers") || + message.includes("Expired timestamp") || + message.includes("Invalid signature") || + message.includes("Invalid app token") || + message.includes("Failed to resolve DID") || + message.includes("Missing DID public key") + ) { + return 401; + } + return 500; +} + +export async function POST(request: Request) { + const raw = await request.text(); + try { + await ensureTables(); + const { did } = await requireSignedRequest(request, raw); + const payload = JSON.parse(raw) as { + share_id: string; + data: string; + timestamp?: number; + }; + + await pool.query( + `INSERT INTO shares (user_id, share_id, data, timestamp) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, share_id) DO UPDATE SET data = EXCLUDED.data, timestamp = EXCLUDED.timestamp`, + [did, payload.share_id, payload.data, payload.timestamp ?? Date.now()], + ); + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: statusForError(error) }, + ); + } +} + +export async function DELETE(request: Request) { + const raw = await request.text(); + try { + await ensureTables(); + const { did } = await requireSignedRequest(request, raw); + const payload = JSON.parse(raw) as { share_id: string }; + await pool.query("DELETE FROM shares WHERE user_id = $1 AND share_id = $2", [ + did, + payload.share_id, + ]); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: statusForError(error) }, + ); + } +} diff --git a/apps/ds/app/layout.tsx b/apps/ds/app/layout.tsx new file mode 100644 index 00000000..e53180ee --- /dev/null +++ b/apps/ds/app/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/ds/app/page.tsx b/apps/ds/app/page.tsx new file mode 100644 index 00000000..39d353fb --- /dev/null +++ b/apps/ds/app/page.tsx @@ -0,0 +1,8 @@ +export default function Home() { + return ( +
+

one calendar ds

+

decentralized storage server is online.

+
+ ); +} diff --git a/apps/ds/lib/db.ts b/apps/ds/lib/db.ts new file mode 100644 index 00000000..0732a25f --- /dev/null +++ b/apps/ds/lib/db.ts @@ -0,0 +1,41 @@ +import { Pool } from "pg"; + +export const pool = new Pool({ connectionString: process.env.POSTGRES_URL }); + +export async function ensureTables() { + const client = await pool.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS calendar_backups ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + encrypted_data TEXT NOT NULL, + iv TEXT NOT NULL, + timestamp BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + await client.query( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_calendar_backups_user_id ON calendar_backups(user_id)", + ); + + await client.query(` + CREATE TABLE IF NOT EXISTS shares ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + share_id TEXT NOT NULL, + data TEXT NOT NULL, + timestamp BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + await client.query( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_shares_user_share ON shares(user_id, share_id)", + ); + await client.query( + "CREATE INDEX IF NOT EXISTS idx_shares_share_id ON shares(share_id)", + ); + } finally { + client.release(); + } +} diff --git a/apps/ds/lib/signature.ts b/apps/ds/lib/signature.ts new file mode 100644 index 00000000..3591e570 --- /dev/null +++ b/apps/ds/lib/signature.ts @@ -0,0 +1,85 @@ +import { verify } from "@noble/ed25519"; +import { base58 } from "@scure/base"; +import { createHash, createPublicKey, verify as verifyNode } from "node:crypto"; + +const MAX_SKEW_MS = 5 * 60 * 1000; + +type PlcDocument = { + verificationMethod?: Array<{ publicKeyMultibase?: string }>; +}; + +function digest(value: string) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function createPayload(method: string, path: string, timestamp: string, body: string) { + return `${method.toUpperCase()}\n${path}\n${timestamp}\n${digest(body)}`; +} + +async function resolveDidPublicKey(did: string) { + const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`, { + cache: "no-store", + }); + if (!res.ok) throw new Error("Failed to resolve DID document"); + const doc = (await res.json()) as PlcDocument; + const multibase = doc.verificationMethod?.find((x) => x.publicKeyMultibase) + ?.publicKeyMultibase; + if (!multibase || !multibase.startsWith("z")) { + throw new Error("Missing DID public key"); + } + return base58.decode(multibase.slice(1)); +} + +export async function requireSignedRequest(request: Request, body = "") { + const appToken = process.env.DS_APP_TOKEN; + if (!appToken) { + throw new Error("DS_APP_TOKEN is not configured"); + } + const incomingToken = request.headers.get("x-app-token"); + if (incomingToken !== appToken) { + throw new Error("Invalid app token"); + } + + const did = request.headers.get("x-did"); + const timestamp = request.headers.get("x-timestamp"); + const signatureHeader = request.headers.get("x-signature"); + + if (!did || !timestamp || !signatureHeader) { + throw new Error("Missing signature headers"); + } + + const ts = Number(timestamp); + if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > MAX_SKEW_MS) { + throw new Error("Expired timestamp"); + } + + const url = new URL(request.url); + const payload = createPayload(request.method, url.pathname, timestamp, body); + const msg = new TextEncoder().encode(payload); + + const encodedSignature = signatureHeader + .replace(/^base64:/, "") + .replace(/ /g, "+"); + const signature = Buffer.from(encodedSignature, "base64url"); + + let ok = false; + const dpopJwkHeader = request.headers.get("x-dpop-jwk"); + if (dpopJwkHeader) { + try { + const jwk = JSON.parse(dpopJwkHeader); + const key = createPublicKey({ key: jwk, format: "jwk" }); + ok = verifyNode("sha256", Buffer.from(payload, "utf8"), key, signature); + } catch { + ok = false; + } + } + + if (!ok) { + const pub = await resolveDidPublicKey(did); + ok = await verify(signature, msg, pub); + } + + if (!ok) throw new Error("Invalid signature"); + + return { did }; +} diff --git a/apps/ds/next.config.ts b/apps/ds/next.config.ts new file mode 100644 index 00000000..cb651cdc --- /dev/null +++ b/apps/ds/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/apps/ds/package.json b/apps/ds/package.json new file mode 100644 index 00000000..1aaeefd3 --- /dev/null +++ b/apps/ds/package.json @@ -0,0 +1,25 @@ +{ + "name": "ds", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev --port 3010", + "build": "next build", + "start": "next start --port 3010" + }, + "dependencies": { + "@noble/ed25519": "^2.2.3", + "@scure/base": "^1.2.6", + "next": "16.2.1", + "pg": "latest", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "25.5.0", + "@types/react": "^18", + "@types/react-dom": "^18", + "typescript": "5.9.3", + "@types/pg": "^8.15.6" + } +} diff --git a/apps/ds/tsconfig.json b/apps/ds/tsconfig.json new file mode 100644 index 00000000..7c2ceba4 --- /dev/null +++ b/apps/ds/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../packages/config/tsconfig.base.json", + "compilerOptions": { + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/apps/ds/types/pg.d.ts b/apps/ds/types/pg.d.ts new file mode 100644 index 00000000..73cf8a9a --- /dev/null +++ b/apps/ds/types/pg.d.ts @@ -0,0 +1,10 @@ +declare module "pg" { + export class Pool { + constructor(config?: { connectionString?: string }); + connect(): Promise<{ + query: (text: string, params?: unknown[]) => Promise<{ rowCount: number; rows: any[] }>; + release: () => void; + }>; + query(text: string, params?: unknown[]): Promise<{ rowCount: number; rows: any[] }>; + } +} diff --git a/app/(app)/[handle]/[shareId]/page.tsx b/apps/web/app/(app)/[handle]/[shareId]/page.tsx similarity index 100% rename from app/(app)/[handle]/[shareId]/page.tsx rename to apps/web/app/(app)/[handle]/[shareId]/page.tsx diff --git a/app/(app)/about/page.tsx b/apps/web/app/(app)/about/page.tsx similarity index 97% rename from app/(app)/about/page.tsx rename to apps/web/app/(app)/about/page.tsx index dc2cbf33..6a951ed0 100644 --- a/app/(app)/about/page.tsx +++ b/apps/web/app/(app)/about/page.tsx @@ -1,7 +1,7 @@ "use client" import { isZhLanguage, useLanguage } from "@/lib/i18n" -import { GithubIcon } from "lucide-react" +import { Github } from "lucide-react" import Link from "next/link" export default function AboutPage() { @@ -75,7 +75,7 @@ export default function AboutPage() { target="_blank" className="flex items-center gap-2 text-blue-600 hover:underline" > - + GitHub @@ -93,7 +93,7 @@ export default function AboutPage() { Privacy Terms - + diff --git a/app/(app)/app/layout.tsx b/apps/web/app/(app)/app/layout.tsx similarity index 100% rename from app/(app)/app/layout.tsx rename to apps/web/app/(app)/app/layout.tsx diff --git a/apps/web/app/(app)/app/page.tsx b/apps/web/app/(app)/app/page.tsx new file mode 100644 index 00000000..a4b4d40d --- /dev/null +++ b/apps/web/app/(app)/app/page.tsx @@ -0,0 +1,181 @@ +"use client" + +import Calendar from "@/components/app/calendar" +import AuthWaitingLoading from "@/components/app/auth-waiting-loading" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { useUser } from "@clerk/nextjs" +import { useEffect, useMemo, useState } from "react" + +function hasClerkSessionCookie() { + if (typeof document === "undefined") return false + + return document.cookie + .split(";") + .some((cookie) => cookie.trim().startsWith("__session=")) +} + +export default function Home() { + const { isLoaded, isSignedIn } = useUser() + const [hasSessionCookie, setHasSessionCookie] = useState(hasClerkSessionCookie) + const [minimumWaitDone, setMinimumWaitDone] = useState(false) + const [dbReady, setDbReady] = useState(false) + const [atprotoSignedIn, setAtprotoSignedIn] = useState(false) + const [atprotoDs, setAtprotoDs] = useState(null) + const [dsDialogOpen, setDsDialogOpen] = useState(false) + const [pendingDsInput, setPendingDsInput] = useState("") + const [savingDs, setSavingDs] = useState(false) + const [dsDialogError, setDsDialogError] = useState("") + + useEffect(() => { + const waitTimer = window.setTimeout(() => { + setMinimumWaitDone(true) + }, 500) + + + const cookieCheckTimer = window.setInterval(() => { + if (hasClerkSessionCookie()) { + setHasSessionCookie(true) + } + }, 50) + + return () => { + window.clearTimeout(waitTimer) + window.clearInterval(cookieCheckTimer) + } + }, []) + + useEffect(() => { + let active = true + const checkDbDataReady = async () => { + try { + const sessionRes = await fetch("/api/atproto/session", { + cache: "no-store", + }) + const sessionData = await sessionRes + .json() + .catch(() => ({ signedIn: false })) as { signedIn?: boolean } + + if (active) { + setAtprotoSignedIn(!!sessionData.signedIn) + } + + if (sessionData.signedIn) { + const dsRes = await fetch("/api/ds/config", { cache: "no-store" }) + const dsData = await dsRes + .json() + .catch(() => ({ ds: null })) as { ds?: string | null } + if (active) { + setAtprotoDs(dsData.ds || null) + setPendingDsInput(dsData.ds || "") + if (!dsData.ds) { + setDsDialogOpen(true) + } + window.dispatchEvent( + new CustomEvent("atproto-ds-updated", { + detail: { ds: dsData.ds || null }, + }), + ) + } + } else if (!isSignedIn) { + if (active) setDbReady(true) + return + } + + const response = await fetch("/api/blob", { cache: "no-store" }) + if (!active) return + if (response.status === 200 || response.status === 404) { + setDbReady(true) + return + } + setDbReady(false) + } catch { + if (active) { + setDbReady(false) + } + } + } + + void checkDbDataReady() + return () => { + active = false + } + }, [isLoaded, isSignedIn]) + + const shouldShowAuthWait = useMemo(() => { + if (!minimumWaitDone) return true + if (hasSessionCookie && !isLoaded) return true + if (isSignedIn && !dbReady) return true + return false + }, [minimumWaitDone, hasSessionCookie, isLoaded, isSignedIn, dbReady]) + + if (shouldShowAuthWait) { + return + } + + const saveDsFromDialog = async () => { + setSavingDs(true) + setDsDialogError("") + try { + const res = await fetch("/api/ds/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ds: pendingDsInput }), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + throw new Error(data.error || "failed to save ds") + } + setAtprotoDs(pendingDsInput) + setDsDialogOpen(false) + setDbReady(false) + window.location.reload() + } catch (error) { + setDsDialogError((error as Error).message || "failed to save ds") + } finally { + setSavingDs(false) + } + } + + return ( + <> + + + + + Set your DS endpoint + + You are signed in with ATProto but no app.onecalendar.ds is set. + Please configure your DS URL to continue syncing data. + + +

{dsDialogError}

+ ) : null} + + + + + + + ) +} diff --git a/app/(app)/privacy/page.tsx b/apps/web/app/(app)/privacy/page.tsx similarity index 100% rename from app/(app)/privacy/page.tsx rename to apps/web/app/(app)/privacy/page.tsx diff --git a/apps/web/app/(app)/share/[did]/[shareId]/page.tsx b/apps/web/app/(app)/share/[did]/[shareId]/page.tsx new file mode 100644 index 00000000..27855a47 --- /dev/null +++ b/apps/web/app/(app)/share/[did]/[shareId]/page.tsx @@ -0,0 +1,59 @@ +import { notFound } from "next/navigation"; + +async function resolveDsFromDid(did: string) { + const url = `https://public.api.bsky.app/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent( + did, + )}&collection=app.onecalendar.ds&rkey=self`; + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) return null; + const data = (await res.json()) as { value?: { ds?: string } }; + return data.value?.ds || null; +} + +export default async function ShareByDidPage({ + params, +}: { + params: Promise<{ did: string; shareId: string }>; +}) { + const { did, shareId } = await params; + + if (!did.startsWith("did:")) { + notFound(); + } + + const ds = await resolveDsFromDid(did); + if (!ds) { + notFound(); + } + const appToken = process.env.DS_APP_TOKEN; + if (!appToken) { + notFound(); + } + + const shareRes = await fetch( + `${ds.replace(/\/$/, "")}/api/share/${encodeURIComponent(shareId)}`, + { + cache: "no-store", + headers: { + "x-app-token": appToken, + }, + }, + ); + + if (!shareRes.ok) { + notFound(); + } + + const payload = await shareRes.json(); + + return ( +
+

Shared Event

+

DID: {did}

+

DS: {ds}

+
+        {JSON.stringify(payload, null, 2)}
+      
+
+ ); +} diff --git a/app/(app)/share/[id]/layout.tsx b/apps/web/app/(app)/share/[id]/layout.tsx similarity index 100% rename from app/(app)/share/[id]/layout.tsx rename to apps/web/app/(app)/share/[id]/layout.tsx diff --git a/app/(app)/share/[id]/page.tsx b/apps/web/app/(app)/share/[id]/page.tsx similarity index 100% rename from app/(app)/share/[id]/page.tsx rename to apps/web/app/(app)/share/[id]/page.tsx diff --git a/app/(app)/terms/page.tsx b/apps/web/app/(app)/terms/page.tsx similarity index 100% rename from app/(app)/terms/page.tsx rename to apps/web/app/(app)/terms/page.tsx diff --git a/apps/web/app/(auth)/at-oauth/page.tsx b/apps/web/app/(auth)/at-oauth/page.tsx new file mode 100644 index 00000000..4162f88a --- /dev/null +++ b/apps/web/app/(auth)/at-oauth/page.tsx @@ -0,0 +1,33 @@ +import { AtprotoLoginForm } from "@/components/auth/atproto-login-form"; +import { AuthBrand } from "@/components/auth/auth-brand"; + +export default function AtprotoLoginPage() { + return ( +
+
+
+
+
+
+
+
+ + +
+
+ ); +} diff --git a/app/(auth)/reset-password/page.tsx b/apps/web/app/(auth)/reset-password/page.tsx similarity index 100% rename from app/(auth)/reset-password/page.tsx rename to apps/web/app/(auth)/reset-password/page.tsx diff --git a/app/(auth)/sign-in/page.tsx b/apps/web/app/(auth)/sign-in/page.tsx similarity index 100% rename from app/(auth)/sign-in/page.tsx rename to apps/web/app/(auth)/sign-in/page.tsx diff --git a/app/(auth)/sign-in/sso-callback/page.tsx b/apps/web/app/(auth)/sign-in/sso-callback/page.tsx similarity index 100% rename from app/(auth)/sign-in/sso-callback/page.tsx rename to apps/web/app/(auth)/sign-in/sso-callback/page.tsx diff --git a/app/(auth)/sign-up/page.tsx b/apps/web/app/(auth)/sign-up/page.tsx similarity index 100% rename from app/(auth)/sign-up/page.tsx rename to apps/web/app/(auth)/sign-up/page.tsx diff --git a/app/(auth)/sign-up/sso-callback/page.tsx b/apps/web/app/(auth)/sign-up/sso-callback/page.tsx similarity index 100% rename from app/(auth)/sign-up/sso-callback/page.tsx rename to apps/web/app/(auth)/sign-up/sso-callback/page.tsx diff --git a/app/api/account/route.ts b/apps/web/app/api/account/route.ts similarity index 100% rename from app/api/account/route.ts rename to apps/web/app/api/account/route.ts diff --git a/app/api/atproto/callback/route.ts b/apps/web/app/api/atproto/callback/route.ts similarity index 73% rename from app/api/atproto/callback/route.ts rename to apps/web/app/api/atproto/callback/route.ts index 38cf3217..1edcf4be 100644 --- a/app/api/atproto/callback/route.ts +++ b/apps/web/app/api/atproto/callback/route.ts @@ -1,15 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; - -export async function GET(request: NextRequest) { - return NextResponse.redirect(new URL("/", request.url)); -} - -/* -import { NextRequest, NextResponse } from "next/server"; import { ATPROTO_DISABLED } from "@/lib/atproto-feature"; -import { getActorProfileRecord, getProfile, profileAvatarBlobUrl } from "@/lib/atproto"; +import { + getActorProfileRecord, + getProfile, + profileAvatarBlobUrl, +} from "@/lib/atproto"; import { setAtprotoSession } from "@/lib/atproto-auth"; -import { clearAtprotoOAuthTxnCookie, consumeAtprotoOAuthTxn, getAtprotoOAuthTxnFromRequest } from "@/lib/atproto-oauth-txn"; +import { + clearAtprotoOAuthTxnCookie, + consumeAtprotoOAuthTxn, + getAtprotoOAuthTxnFromRequest, +} from "@/lib/atproto-oauth-txn"; import { createDpopProof, type DpopPublicJwk } from "@/lib/dpop"; function parseJsonSafe(value: string): T | null { @@ -41,12 +42,16 @@ function normalizeIssuerOrigin(value: string) { } export async function GET(request: NextRequest) { - if (ATPROTO_DISABLED) return redirectWithError(process.env.NEXT_PUBLIC_BASE_URL || request.nextUrl.origin, "atproto_disabled"); + if (ATPROTO_DISABLED) + return redirectWithError( + request.nextUrl.origin, + "atproto_disabled", + ); const code = request.nextUrl.searchParams.get("code"); const state = request.nextUrl.searchParams.get("state"); const iss = request.nextUrl.searchParams.get("iss"); - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || request.nextUrl.origin; + const baseUrl = request.nextUrl.origin; const txn = getAtprotoOAuthTxnFromRequest(request); if (!code || !state || !txn || state !== txn.state) { @@ -54,12 +59,28 @@ export async function GET(request: NextRequest) { } if (!consumeAtprotoOAuthTxn(txn)) { - return redirectWithError(baseUrl, "oauth_state_mismatch", "transaction_already_used"); + return redirectWithError( + baseUrl, + "oauth_state_mismatch", + "transaction_already_used", + ); } - const { verifier, handle, pds, did, dpopPrivateKeyPem, dpopPublicJwk } = txn; - - if (!dpopPublicJwk?.kty || !dpopPublicJwk?.crv || !dpopPublicJwk?.x || !dpopPublicJwk?.y) { + const { + verifier, + handle, + pds, + did, + dpopPrivateKeyPem, + dpopPublicJwk, + } = txn; + + if ( + !dpopPublicJwk?.kty || + !dpopPublicJwk?.crv || + !dpopPublicJwk?.x || + !dpopPublicJwk?.y + ) { return redirectWithError(baseUrl, "invalid_dpop_key"); } @@ -107,7 +128,8 @@ export async function GET(request: NextRequest) { let tokenRes = await makeTokenRequest(); if (!tokenRes.ok) { - const nonce = tokenRes.headers.get("DPoP-Nonce") || tokenRes.headers.get("dpop-nonce"); + const nonce = + tokenRes.headers.get("DPoP-Nonce") || tokenRes.headers.get("dpop-nonce"); if (nonce) { tokenRes = await makeTokenRequest(nonce); } @@ -115,12 +137,23 @@ export async function GET(request: NextRequest) { if (!tokenRes.ok) { const detailText = await tokenRes.text(); - const detailJson = parseJsonSafe<{ error?: string; error_description?: string }>(detailText); - const reason = detailJson?.error_description || detailJson?.error || detailText.slice(0, 160) || "token_exchange_failed"; + const detailJson = parseJsonSafe<{ + error?: string; + error_description?: string; + }>(detailText); + const reason = + detailJson?.error_description || + detailJson?.error || + detailText.slice(0, 160) || + "token_exchange_failed"; return redirectWithError(baseUrl, "token_exchange_failed", reason); } - const tokenData = (await tokenRes.json()) as { access_token?: string; refresh_token?: string; sub?: string }; + const tokenData = (await tokenRes.json()) as { + access_token?: string; + refresh_token?: string; + sub?: string; + }; if (!tokenData.access_token) { return redirectWithError(baseUrl, "missing_access_token"); } @@ -144,7 +177,8 @@ export async function GET(request: NextRequest) { }).catch(() => undefined); const avatarCid = actorProfile?.avatar?.ref?.$link; - const avatarUrl = profileAvatarBlobUrl({ pds, did: actorDid, cid: avatarCid }) || profile?.avatar; + const avatarUrl = + profileAvatarBlobUrl({ pds, did: actorDid, cid: avatarCid }) || profile?.avatar; await setAtprotoSession({ did: actorDid, @@ -161,11 +195,11 @@ export async function GET(request: NextRequest) { const response = NextResponse.redirect(`${baseUrl}/app`); clearAtprotoOAuthTxnCookie(response); - ["__session", "__client_uat", "__clerk_db_jwt", "__clerk_handshake"].forEach((key) => { - response.cookies.delete(key); - }); + ["__session", "__client_uat", "__clerk_db_jwt", "__clerk_handshake"].forEach( + (key) => { + response.cookies.delete(key); + }, + ); return response; } - -*/ diff --git a/app/api/atproto/login/route.ts b/apps/web/app/api/atproto/login/route.ts similarity index 82% rename from app/api/atproto/login/route.ts rename to apps/web/app/api/atproto/login/route.ts index 86227d62..073e1003 100644 --- a/app/api/atproto/login/route.ts +++ b/apps/web/app/api/atproto/login/route.ts @@ -1,10 +1,3 @@ -import { NextRequest, NextResponse } from "next/server"; - -export async function POST(request: NextRequest) { - return NextResponse.redirect(new URL("/", request.url)); -} - -/* import { randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { ATPROTO_DISABLED, atprotoDisabledResponse } from "@/lib/atproto-feature"; @@ -17,23 +10,37 @@ const LOGIN_RATE_LIMIT = 20; const loginRateCache = new Map(); function getExpectedBaseUrl(request: NextRequest) { - return process.env.NEXT_PUBLIC_BASE_URL || request.nextUrl.origin; + return request.nextUrl.origin; } function isAllowedOrigin(request: NextRequest, expectedBaseUrl: string) { const expected = new URL(expectedBaseUrl); + const current = request.nextUrl; + const allowedHosts = new Set([ + expected.host.toLowerCase(), + current.host.toLowerCase(), + ]); + const allowedOrigins = new Set([ + expected.origin, + current.origin, + ]); + const origin = request.headers.get("origin"); if (origin) { try { const parsed = new URL(origin); - if (parsed.origin !== expected.origin) return false; + if (!allowedOrigins.has(parsed.origin)) return false; } catch { return false; } } - const host = (request.headers.get("x-forwarded-host") || request.headers.get("host") || "").toLowerCase(); - if (host && host !== expected.host.toLowerCase()) { + const host = ( + request.headers.get("x-forwarded-host") || + request.headers.get("host") || + "" + ).toLowerCase(); + if (host && !allowedHosts.has(host)) { return false; } @@ -46,7 +53,8 @@ function checkRateLimit(request: NextRequest, handle: string) { if (value.resetAt <= now) loginRateCache.delete(key); } - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; const key = `${ip}:${handle}`; const record = loginRateCache.get(key); @@ -102,7 +110,8 @@ export async function POST(request: NextRequest) { authUrl.searchParams.set("dpop_jkt", dpop.jkt); const response = NextResponse.json({ authorizeUrl: authUrl.toString(), pds, did }); - const secure = request.nextUrl.protocol === "https:" || process.env.NODE_ENV === "production"; + const secure = + request.nextUrl.protocol === "https:" || process.env.NODE_ENV === "production"; setAtprotoOAuthTxnCookie( response, { @@ -119,11 +128,11 @@ export async function POST(request: NextRequest) { secure, ); - ["__session", "__client_uat", "__clerk_db_jwt", "__clerk_handshake"].forEach((key) => { - response.cookies.delete(key); - }); + ["__session", "__client_uat", "__clerk_db_jwt", "__clerk_handshake"].forEach( + (key) => { + response.cookies.delete(key); + }, + ); return response; } - -*/ diff --git a/app/api/atproto/logout/route.ts b/apps/web/app/api/atproto/logout/route.ts similarity index 54% rename from app/api/atproto/logout/route.ts rename to apps/web/app/api/atproto/logout/route.ts index 59c75c7f..c6f048ee 100644 --- a/app/api/atproto/logout/route.ts +++ b/apps/web/app/api/atproto/logout/route.ts @@ -1,10 +1,3 @@ -import { NextRequest, NextResponse } from "next/server"; - -export async function POST(request: NextRequest) { - return NextResponse.redirect(new URL("/", request.url)); -} - -/* import { NextResponse } from "next/server"; import { clearAtprotoSession } from "@/lib/atproto-auth"; @@ -12,5 +5,3 @@ export async function POST() { await clearAtprotoSession(); return NextResponse.json({ success: true }); } - -*/ diff --git a/app/api/atproto/register-url/route.ts b/apps/web/app/api/atproto/register-url/route.ts similarity index 80% rename from app/api/atproto/register-url/route.ts rename to apps/web/app/api/atproto/register-url/route.ts index 7c44b374..5432905a 100644 --- a/app/api/atproto/register-url/route.ts +++ b/apps/web/app/api/atproto/register-url/route.ts @@ -1,10 +1,3 @@ -import { NextRequest, NextResponse } from "next/server"; - -export async function POST(request: NextRequest) { - return NextResponse.redirect(new URL("/", request.url)); -} - -/* import { randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { ATPROTO_DISABLED, atprotoDisabledResponse } from "@/lib/atproto-feature"; @@ -15,7 +8,7 @@ import { generateDpopKeyMaterial } from "@/lib/dpop"; const ROSE_PDS_ORIGIN = "https://rose.madebydanny.uk"; function getBaseUrl(request: NextRequest) { - return process.env.NEXT_PUBLIC_BASE_URL || request.nextUrl.origin; + return request.nextUrl.origin; } export async function POST(request: NextRequest) { @@ -49,19 +42,26 @@ export async function POST(request: NextRequest) { if (!parRes.ok) { const detail = (await parRes.text()).slice(0, 200) || "par_failed"; - return NextResponse.json({ error: `Rose PAR failed: ${detail}` }, { status: 502 }); + return NextResponse.json( + { error: `Rose PAR failed: ${detail}` }, + { status: 502 }, + ); } const parJson = (await parRes.json()) as { request_uri?: string }; if (!parJson.request_uri) { - return NextResponse.json({ error: "Rose PAR response missing request_uri" }, { status: 502 }); + return NextResponse.json( + { error: "Rose PAR response missing request_uri" }, + { status: 502 }, + ); } authorizeUrl.searchParams.set("client_id", clientId); authorizeUrl.searchParams.set("request_uri", parJson.request_uri); const response = NextResponse.json({ authorizeUrl: authorizeUrl.toString() }); - const secure = request.nextUrl.protocol === "https:" || process.env.NODE_ENV === "production"; + const secure = + request.nextUrl.protocol === "https:" || process.env.NODE_ENV === "production"; setAtprotoOAuthTxnCookie( response, { @@ -80,5 +80,3 @@ export async function POST(request: NextRequest) { return response; } - -*/ diff --git a/app/api/atproto/session/route.ts b/apps/web/app/api/atproto/session/route.ts similarity index 82% rename from app/api/atproto/session/route.ts rename to apps/web/app/api/atproto/session/route.ts index efed1a2b..8ef9bc5e 100644 --- a/app/api/atproto/session/route.ts +++ b/apps/web/app/api/atproto/session/route.ts @@ -1,10 +1,3 @@ -import { NextRequest, NextResponse } from "next/server"; - -export async function GET(request: NextRequest) { - return NextResponse.redirect(new URL("/", request.url)); -} - -/* import { NextResponse } from "next/server"; import { ATPROTO_DISABLED, atprotoDisabledResponse } from "@/lib/atproto-feature"; import { getActorProfileRecord, profileAvatarBlobUrl } from "@/lib/atproto"; @@ -28,7 +21,11 @@ export async function GET() { }).catch(() => undefined); const avatarCid = actorProfile?.avatar?.ref?.$link; - const resolvedAvatar = profileAvatarBlobUrl({ pds: session.pds, did: session.did, cid: avatarCid }); + const resolvedAvatar = profileAvatarBlobUrl({ + pds: session.pds, + did: session.did, + cid: avatarCid, + }); if (resolvedAvatar || actorProfile?.displayName) { avatar = resolvedAvatar || avatar; @@ -50,5 +47,3 @@ export async function GET() { avatar, }); } - -*/ diff --git a/app/api/blob/check/route.ts b/apps/web/app/api/blob/check/route.ts similarity index 100% rename from app/api/blob/check/route.ts rename to apps/web/app/api/blob/check/route.ts diff --git a/app/api/blob/route.ts b/apps/web/app/api/blob/route.ts similarity index 54% rename from app/api/blob/route.ts rename to apps/web/app/api/blob/route.ts index 0ec9a1b7..88469480 100644 --- a/app/api/blob/route.ts +++ b/apps/web/app/api/blob/route.ts @@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { currentUser } from "@clerk/nextjs/server"; import { Pool } from "pg"; import { getAtprotoSession } from "@/lib/atproto-auth"; -import { deleteRecord, getRecord, putRecord } from "@/lib/atproto"; +import { getRecord } from "@/lib/atproto"; +import { signedDsFetch } from "@/lib/ds-signed-request"; export const runtime = "nodejs"; @@ -40,8 +41,29 @@ async function initDB() { } } -const ATPROTO_BACKUP_COLLECTION = "app.onecalendar.backup"; -const ATPROTO_BACKUP_RKEY = "latest"; +const ATPROTO_DS_COLLECTION = "app.onecalendar.ds"; +const ATPROTO_DS_RKEY = "self"; + +async function getDsEndpointFromAtproto() { + const atproto = await getAtprotoSession(); + if (!atproto) return { atproto: null, ds: null as string | null }; + + try { + const dsRecord = await getRecord({ + pds: atproto.pds, + repo: atproto.did, + collection: ATPROTO_DS_COLLECTION, + rkey: ATPROTO_DS_RKEY, + accessToken: atproto.accessToken, + dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, + dpopPublicJwk: atproto.dpopPublicJwk, + }); + const ds = (dsRecord.value?.ds as string | undefined)?.trim() || null; + return { atproto, ds }; + } catch { + return { atproto, ds: null as string | null }; + } +} export async function POST(req: NextRequest) { try { @@ -53,24 +75,34 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); } - const atproto = await getAtprotoSession(); + const { atproto, ds } = await getDsEndpointFromAtproto(); if (atproto) { - await putRecord({ - pds: atproto.pds, - repo: atproto.did, - collection: ATPROTO_BACKUP_COLLECTION, - rkey: ATPROTO_BACKUP_RKEY, - accessToken: atproto.accessToken, - dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, - dpopPublicJwk: atproto.dpopPublicJwk, - record: { - $type: ATPROTO_BACKUP_COLLECTION, - ciphertext: encrypted_data, + if (!ds) { + return NextResponse.json( + { error: "ATProto DS is not configured" }, + { status: 400 }, + ); + } + + const dsRes = await signedDsFetch({ + session: atproto, + ds, + path: "/api/blob", + method: "POST", + body: { + encrypted_data, iv, - updatedAt: new Date().toISOString(), + timestamp: Date.now(), }, }); - return NextResponse.json({ success: true, backend: "atproto" }); + if (!dsRes.ok) { + const detail = await dsRes.text(); + return NextResponse.json( + { error: `DS write failed: ${detail}` }, + { status: dsRes.status }, + ); + } + return NextResponse.json({ success: true, backend: "ds", ds }); } const user = await currentUser(); @@ -104,33 +136,51 @@ export async function POST(req: NextRequest) { export async function GET() { try { - const atproto = await getAtprotoSession(); + const { atproto, ds } = await getDsEndpointFromAtproto(); if (atproto) { - try { - const record = await getRecord({ - pds: atproto.pds, - repo: atproto.did, - collection: ATPROTO_BACKUP_COLLECTION, - rkey: ATPROTO_BACKUP_RKEY, - accessToken: atproto.accessToken, - dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, - dpopPublicJwk: atproto.dpopPublicJwk, - }); - const value = record.value ?? {}; - return NextResponse.json({ - ciphertext: value.ciphertext, - iv: value.iv, - timestamp: value.updatedAt, - backend: "atproto", - }); - } catch { + if (!ds) { + return NextResponse.json( + { error: "ATProto DS is not configured" }, + { status: 404 }, + ); + } + + const dsRes = await signedDsFetch({ + session: atproto, + ds, + path: "/api/blob", + method: "GET", + }); + if (dsRes.status === 404) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + if (!dsRes.ok) { + const detail = await dsRes.text(); + return NextResponse.json( + { error: `DS read failed: ${detail}` }, + { status: dsRes.status }, + ); + } + + const payload = (await dsRes.json()) as { + data?: { encrypted_data?: string; iv?: string; timestamp?: string }; + }; + const data = payload.data; + if (!data) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } + + return NextResponse.json({ + ciphertext: data.encrypted_data, + iv: data.iv, + timestamp: data.timestamp, + backend: "ds", + ds, + }); } const user = await currentUser(); - if (!user) - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!user) return NextResponse.json({ error: "Not found" }, { status: 404 }); await initDB(); @@ -160,18 +210,30 @@ export async function GET() { export async function DELETE() { try { - const atproto = await getAtprotoSession(); + const { atproto, ds } = await getDsEndpointFromAtproto(); if (atproto) { - await deleteRecord({ - pds: atproto.pds, - repo: atproto.did, - collection: ATPROTO_BACKUP_COLLECTION, - rkey: ATPROTO_BACKUP_RKEY, - accessToken: atproto.accessToken, - dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, - dpopPublicJwk: atproto.dpopPublicJwk, + if (!ds) { + return NextResponse.json( + { error: "ATProto DS is not configured" }, + { status: 400 }, + ); + } + + const dsRes = await signedDsFetch({ + session: atproto, + ds, + path: "/api/blob", + method: "DELETE", }); - return NextResponse.json({ success: true, backend: "atproto" }); + if (!dsRes.ok) { + const detail = await dsRes.text(); + return NextResponse.json( + { error: `DS delete failed: ${detail}` }, + { status: dsRes.status }, + ); + } + + return NextResponse.json({ success: true, backend: "ds", ds }); } const user = await currentUser(); diff --git a/apps/web/app/api/ds/config/route.ts b/apps/web/app/api/ds/config/route.ts new file mode 100644 index 00000000..2e2c1592 --- /dev/null +++ b/apps/web/app/api/ds/config/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { getRecord, putRecord } from "@/lib/atproto"; +import { getAtprotoSession } from "@/lib/atproto-auth"; + +const DS_COLLECTION = "app.onecalendar.ds"; +const DS_RKEY = "self"; + +export async function GET() { + const atproto = await getAtprotoSession(); + if (!atproto) { + return NextResponse.json({ error: "ATProto login required" }, { status: 401 }); + } + + try { + const record = await getRecord({ + pds: atproto.pds, + repo: atproto.did, + collection: DS_COLLECTION, + rkey: DS_RKEY, + accessToken: atproto.accessToken, + dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, + dpopPublicJwk: atproto.dpopPublicJwk, + }); + + return NextResponse.json({ + did: atproto.did, + ds: (record.value?.ds as string | undefined) || null, + }); + } catch { + return NextResponse.json({ did: atproto.did, ds: null }); + } +} + +export async function POST(request: Request) { + const atproto = await getAtprotoSession(); + if (!atproto) { + return NextResponse.json({ error: "ATProto login required" }, { status: 401 }); + } + + const body = (await request.json()) as { ds?: string }; + const ds = body.ds?.trim(); + if (!ds) { + return NextResponse.json({ error: "ds is required" }, { status: 400 }); + } + + await putRecord({ + pds: atproto.pds, + repo: atproto.did, + collection: DS_COLLECTION, + rkey: DS_RKEY, + record: { + $type: DS_COLLECTION, + ds, + updatedAt: new Date().toISOString(), + }, + accessToken: atproto.accessToken, + dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, + dpopPublicJwk: atproto.dpopPublicJwk, + }); + + return NextResponse.json({ success: true, did: atproto.did, ds }); +} diff --git a/apps/web/app/api/ds/migrate/route.ts b/apps/web/app/api/ds/migrate/route.ts new file mode 100644 index 00000000..207976f6 --- /dev/null +++ b/apps/web/app/api/ds/migrate/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from "next/server"; +import { getRecord, putRecord } from "@/lib/atproto"; +import { getAtprotoSession } from "@/lib/atproto-auth"; +import { signedDsFetch } from "@/lib/ds-signed-request"; + +const DS_COLLECTION = "app.onecalendar.ds"; +const DS_RKEY = "self"; + +export async function POST(request: Request) { + const atproto = await getAtprotoSession(); + if (!atproto) { + return NextResponse.json({ error: "ATProto login required" }, { status: 401 }); + } + + const body = (await request.json()) as { toDs?: string }; + const toDs = body.toDs?.trim(); + if (!toDs) { + return NextResponse.json({ error: "toDs is required" }, { status: 400 }); + } + + const current = await getRecord({ + pds: atproto.pds, + repo: atproto.did, + collection: DS_COLLECTION, + rkey: DS_RKEY, + accessToken: atproto.accessToken, + dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, + dpopPublicJwk: atproto.dpopPublicJwk, + }).catch(() => null); + + const fromDs = (current?.value?.ds as string | undefined)?.trim(); + if (!fromDs) { + return NextResponse.json( + { error: "No source DS record found on app.onecalendar.ds" }, + { status: 400 }, + ); + } + + if (fromDs === toDs) { + return NextResponse.json({ success: true, skipped: true, ds: toDs }); + } + + // Atomic-like flow: export -> import -> cleanup -> update DS record. + const exportRes = await signedDsFetch({ + session: atproto, + ds: fromDs, + path: "/api/migrate/export", + method: "POST", + body: { did: atproto.did }, + }); + + if (!exportRes.ok) { + return NextResponse.json({ error: "Failed to export from source DS" }, { status: 502 }); + } + + const payload = await exportRes.json(); + + const importRes = await signedDsFetch({ + session: atproto, + ds: toDs, + path: "/api/migrate/import", + method: "POST", + body: payload, + }); + + if (!importRes.ok) { + return NextResponse.json({ error: "Failed to import into target DS" }, { status: 502 }); + } + + const cleanupRes = await signedDsFetch({ + session: atproto, + ds: fromDs, + path: "/api/migrate/cleanup", + method: "POST", + body: { did: atproto.did }, + }); + + if (!cleanupRes.ok) { + return NextResponse.json({ error: "Cleanup on source DS failed" }, { status: 502 }); + } + + await putRecord({ + pds: atproto.pds, + repo: atproto.did, + collection: DS_COLLECTION, + rkey: DS_RKEY, + record: { + $type: DS_COLLECTION, + ds: toDs, + updatedAt: new Date().toISOString(), + }, + accessToken: atproto.accessToken, + dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, + dpopPublicJwk: atproto.dpopPublicJwk, + }); + + return NextResponse.json({ success: true, fromDs, toDs, switched: true }); +} diff --git a/apps/web/app/api/ds/proxy/route.ts b/apps/web/app/api/ds/proxy/route.ts new file mode 100644 index 00000000..b7d4df13 --- /dev/null +++ b/apps/web/app/api/ds/proxy/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { getAtprotoSession } from "@/lib/atproto-auth"; +import { signedDsFetch } from "@/lib/ds-signed-request"; + +export async function POST(request: Request) { + const atproto = await getAtprotoSession(); + if (!atproto) { + return NextResponse.json({ error: "ATProto login required" }, { status: 401 }); + } + + const body = (await request.json()) as { + ds?: string; + path?: string; + method?: "GET" | "POST" | "DELETE"; + payload?: unknown; + }; + + if (!body.ds || !body.path || !body.method) { + return NextResponse.json( + { error: "ds, path, method are required" }, + { status: 400 }, + ); + } + + const res = await signedDsFetch({ + session: atproto, + ds: body.ds, + path: body.path, + method: body.method, + body: body.payload, + }); + + const text = await res.text(); + const contentType = res.headers.get("content-type") || "application/json"; + return new NextResponse(text, { + status: res.status, + headers: { + "content-type": contentType, + }, + }); +} diff --git a/app/api/share/list/route.ts b/apps/web/app/api/share/list/route.ts similarity index 100% rename from app/api/share/list/route.ts rename to apps/web/app/api/share/list/route.ts diff --git a/app/api/share/public/route.ts b/apps/web/app/api/share/public/route.ts similarity index 69% rename from app/api/share/public/route.ts rename to apps/web/app/api/share/public/route.ts index 1cf448e7..fff764a1 100644 --- a/app/api/share/public/route.ts +++ b/apps/web/app/api/share/public/route.ts @@ -6,6 +6,8 @@ import { ATPROTO_DISABLED, atprotoDisabledResponse } from "@/lib/atproto-feature const ALGORITHM = "aes-256-gcm"; const ATPROTO_SHARE_COLLECTION = "app.onecalendar.share"; +const ATPROTO_DS_COLLECTION = "app.onecalendar.ds"; +const ATPROTO_DS_RKEY = "self"; const burnPool = process.env.POSTGRES_URL ? new Pool({ @@ -89,8 +91,78 @@ export async function GET(request: NextRequest) { const normalizedHandle = handle.replace(/^@/, "").toLowerCase(); const resolved = await resolveHandle(normalizedHandle); - const record = await getRecord({ pds: resolved.pds, repo: resolved.did, collection: ATPROTO_SHARE_COLLECTION, rkey: id }); - const value = record.value ?? {}; + const dsRecord = await getRecord({ + pds: resolved.pds, + repo: resolved.did, + collection: ATPROTO_DS_COLLECTION, + rkey: ATPROTO_DS_RKEY, + }).catch(() => null); + const ds = (dsRecord?.value?.ds as string | undefined)?.trim() || null; + + let value: + | { + encryptedData?: string; + iv?: string; + authTag?: string; + isProtected?: boolean; + isBurn?: boolean; + timestamp?: string; + } + | null = null; + + if (ds) { + const appToken = process.env.DS_APP_TOKEN; + if (!appToken) { + return NextResponse.json( + { error: "DS_APP_TOKEN is not configured" }, + { status: 500 }, + ); + } + const dsRes = await fetch( + `${ds.replace(/\/$/, "")}/api/share/${encodeURIComponent(id)}`, + { + cache: "no-store", + headers: { + "x-app-token": appToken, + }, + }, + ); + if (dsRes.status === 404) { + return NextResponse.json({ error: "Share not found" }, { status: 404 }); + } + if (!dsRes.ok) { + return NextResponse.json( + { error: "Failed to load share from DS" }, + { status: dsRes.status }, + ); + } + + const dsPayload = (await dsRes.json()) as { share?: { data?: string } }; + const shareData = dsPayload.share?.data; + if (!shareData) { + return NextResponse.json({ error: "Share not found" }, { status: 404 }); + } + value = JSON.parse(shareData) as { + encryptedData?: string; + iv?: string; + authTag?: string; + isProtected?: boolean; + isBurn?: boolean; + timestamp?: string; + }; + } else { + const record = await getRecord({ + pds: resolved.pds, + repo: resolved.did, + collection: ATPROTO_SHARE_COLLECTION, + rkey: id, + }); + value = record.value ?? null; + } + + if (!value) { + return NextResponse.json({ error: "Share not found" }, { status: 404 }); + } const isProtected = !!value.isProtected; const isBurn = !!value.isBurn; diff --git a/app/api/share/route.ts b/apps/web/app/api/share/route.ts similarity index 73% rename from app/api/share/route.ts rename to apps/web/app/api/share/route.ts index d75c21b4..a0e504c7 100644 --- a/app/api/share/route.ts +++ b/apps/web/app/api/share/route.ts @@ -4,6 +4,7 @@ import { Pool } from "pg"; import crypto from "crypto"; import { deleteRecord, getRecord, putRecord } from "@/lib/atproto"; import { getAtprotoSession } from "@/lib/atproto-auth"; +import { signedDsFetch } from "@/lib/ds-signed-request"; const pool = new Pool({ connectionString: process.env.POSTGRES_URL, @@ -41,6 +42,29 @@ async function initializeDatabase() { const ALGORITHM = "aes-256-gcm"; const ATPROTO_SHARE_COLLECTION = "app.onecalendar.share"; +const ATPROTO_DS_COLLECTION = "app.onecalendar.ds"; +const ATPROTO_DS_RKEY = "self"; + +async function getAtprotoDsSession() { + const atproto = await getAtprotoSession(); + if (!atproto) return { atproto: null, ds: null as string | null }; + + try { + const dsRecord = await getRecord({ + pds: atproto.pds, + repo: atproto.did, + collection: ATPROTO_DS_COLLECTION, + rkey: ATPROTO_DS_RKEY, + accessToken: atproto.accessToken, + dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, + dpopPublicJwk: atproto.dpopPublicJwk, + }); + const ds = (dsRecord.value?.ds as string | undefined)?.trim() || null; + return { atproto, ds }; + } catch { + return { atproto, ds: null as string | null }; + } +} function keyV2Unprotected(shareId: string) { return crypto.createHash("sha256").update(shareId, "utf8").digest(); @@ -89,8 +113,43 @@ export async function POST(request: NextRequest) { const key = hasPassword ? keyV3Password(password as string, id) : keyV2Unprotected(id); const { encryptedData, iv, authTag } = encryptWithKey(dataString, key); - const atproto = await getAtprotoSession(); + const { atproto, ds } = await getAtprotoDsSession(); if (atproto) { + if (!ds) { + return NextResponse.json( + { error: "ATProto DS is not configured" }, + { status: 400 }, + ); + } + + const dsPayload = { + encryptedData, + iv, + authTag, + isProtected: hasPassword, + isBurn: burn, + timestamp: new Date().toISOString(), + }; + + const dsRes = await signedDsFetch({ + session: atproto, + ds, + path: "/api/share", + method: "POST", + body: { + share_id: id, + data: JSON.stringify(dsPayload), + timestamp: Date.now(), + }, + }); + if (!dsRes.ok) { + const detail = await dsRes.text(); + return NextResponse.json( + { error: `DS share write failed: ${detail}` }, + { status: dsRes.status }, + ); + } + await putRecord({ pds: atproto.pds, repo: atproto.did, @@ -110,7 +169,13 @@ export async function POST(request: NextRequest) { }, }); - return NextResponse.json({ success: true, id, protected: hasPassword, burnAfterRead: burn, shareLink: `/${atproto.handle}/${id}` }); + return NextResponse.json({ + success: true, + id, + protected: hasPassword, + burnAfterRead: burn, + shareLink: `/${atproto.handle}/${id}`, + }); } const user = await currentUser(); @@ -144,10 +209,46 @@ export async function POST(request: NextRequest) { } async function getAtprotoShare(id: string, password: string, handleParam?: string) { - const atproto = await getAtprotoSession(); + const { atproto, ds } = await getAtprotoDsSession(); if (atproto) { - const record = await getRecord({ pds: atproto.pds, repo: atproto.did, collection: ATPROTO_SHARE_COLLECTION, rkey: id, accessToken: atproto.accessToken, dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, dpopPublicJwk: atproto.dpopPublicJwk }); - const value = record.value ?? {}; + if (!ds) { + return NextResponse.json( + { error: "ATProto DS is not configured" }, + { status: 400 }, + ); + } + + const dsRes = await signedDsFetch({ + session: atproto, + ds, + path: `/api/share/${encodeURIComponent(id)}`, + method: "GET", + }); + if (dsRes.status === 404) { + return NextResponse.json({ error: "Share not found" }, { status: 404 }); + } + if (!dsRes.ok) { + const detail = await dsRes.text(); + return NextResponse.json( + { error: `DS share read failed: ${detail}` }, + { status: dsRes.status }, + ); + } + + const dsPayload = (await dsRes.json()) as { share?: { data?: string } }; + const dsShareRaw = dsPayload.share?.data; + if (!dsShareRaw) { + return NextResponse.json({ error: "Share not found" }, { status: 404 }); + } + + const value = JSON.parse(dsShareRaw) as { + encryptedData?: string; + iv?: string; + authTag?: string; + isProtected?: boolean; + isBurn?: boolean; + timestamp?: string; + }; const isProtected = !!value.isProtected; if (isProtected && !password) { return NextResponse.json({ error: "Password required", requiresPassword: true, burnAfterRead: value.isBurn }, { status: 401 }); @@ -156,7 +257,14 @@ async function getAtprotoShare(id: string, password: string, handleParam?: strin const decryptedData = decryptWithKey(String(value.encryptedData), String(value.iv), String(value.authTag), key); if (value.isBurn) { - await deleteRecord({ pds: atproto.pds, repo: atproto.did, collection: ATPROTO_SHARE_COLLECTION, rkey: id, accessToken: atproto.accessToken, dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, dpopPublicJwk: atproto.dpopPublicJwk }); + await signedDsFetch({ + session: atproto, + ds, + path: "/api/share", + method: "DELETE", + body: { share_id: id }, + }); + await deleteRecord({ pds: atproto.pds, repo: atproto.did, collection: ATPROTO_SHARE_COLLECTION, rkey: id, accessToken: atproto.accessToken, dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, dpopPublicJwk: atproto.dpopPublicJwk }).catch(() => undefined); } return NextResponse.json({ success: true, data: decryptedData, timestamp: value.timestamp, protected: isProtected, burnAfterRead: !!value.isBurn }); @@ -226,8 +334,21 @@ export async function DELETE(request: NextRequest) { const { id } = body as { id?: string }; if (!id) return NextResponse.json({ error: "Missing share ID" }, { status: 400 }); - const atproto = await getAtprotoSession(); + const { atproto, ds } = await getAtprotoDsSession(); if (atproto) { + if (!ds) { + return NextResponse.json( + { error: "ATProto DS is not configured" }, + { status: 400 }, + ); + } + await signedDsFetch({ + session: atproto, + ds, + path: "/api/share", + method: "DELETE", + body: { share_id: id }, + }); await deleteRecord({ pds: atproto.pds, repo: atproto.did, collection: ATPROTO_SHARE_COLLECTION, rkey: id, accessToken: atproto.accessToken, dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, dpopPublicJwk: atproto.dpopPublicJwk }); return NextResponse.json({ success: true }); } diff --git a/app/api/verify/route.ts b/apps/web/app/api/verify/route.ts similarity index 100% rename from app/api/verify/route.ts rename to apps/web/app/api/verify/route.ts diff --git a/app/globals.css b/apps/web/app/globals.css similarity index 99% rename from app/globals.css rename to apps/web/app/globals.css index 5831b2e2..e025ea63 100644 --- a/app/globals.css +++ b/apps/web/app/globals.css @@ -1,6 +1,8 @@ @import "tailwindcss"; @plugin "tailwindcss-animate"; +@source "../../../packages/ui/src/**/*.{ts,tsx}"; + @custom-variant dark (&:is(.dark *)); @custom-variant green (&:is(.green *)); @custom-variant orange (&:is(.orange *)); diff --git a/app/icon.svg b/apps/web/app/icon.svg similarity index 100% rename from app/icon.svg rename to apps/web/app/icon.svg diff --git a/app/layout.tsx b/apps/web/app/layout.tsx similarity index 100% rename from app/layout.tsx rename to apps/web/app/layout.tsx diff --git a/app/manifest.ts b/apps/web/app/manifest.ts similarity index 100% rename from app/manifest.ts rename to apps/web/app/manifest.ts diff --git a/app/not-found.tsx b/apps/web/app/not-found.tsx similarity index 100% rename from app/not-found.tsx rename to apps/web/app/not-found.tsx diff --git a/apps/web/app/oauth-client-metadata.json/route.ts b/apps/web/app/oauth-client-metadata.json/route.ts new file mode 100644 index 00000000..c1030a4f --- /dev/null +++ b/apps/web/app/oauth-client-metadata.json/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const baseUrl = request.nextUrl.origin.replace(/\/$/, ""); + return NextResponse.json( + { + client_id: `${baseUrl}/oauth-client-metadata.json`, + application_type: "web", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + redirect_uris: [`${baseUrl}/api/atproto/callback`], + token_endpoint_auth_method: "none", + scope: "atproto transition:generic", + dpop_bound_access_tokens: true, + }, + { + headers: { + "Cache-Control": "no-store", + }, + }, + ); +} diff --git a/app/page.tsx b/apps/web/app/page.tsx similarity index 100% rename from app/page.tsx rename to apps/web/app/page.tsx diff --git a/app/sitemap.ts b/apps/web/app/sitemap.ts similarity index 100% rename from app/sitemap.ts rename to apps/web/app/sitemap.ts diff --git a/components.json b/apps/web/components.json similarity index 100% rename from components.json rename to apps/web/components.json diff --git a/components/app/analytics/analytics-view.tsx b/apps/web/components/app/analytics/analytics-view.tsx similarity index 100% rename from components/app/analytics/analytics-view.tsx rename to apps/web/components/app/analytics/analytics-view.tsx diff --git a/components/app/analytics/build-info-card.tsx b/apps/web/components/app/analytics/build-info-card.tsx similarity index 100% rename from components/app/analytics/build-info-card.tsx rename to apps/web/components/app/analytics/build-info-card.tsx diff --git a/components/app/analytics/events-calendar.tsx b/apps/web/components/app/analytics/events-calendar.tsx similarity index 100% rename from components/app/analytics/events-calendar.tsx rename to apps/web/components/app/analytics/events-calendar.tsx diff --git a/components/app/analytics/import-export.tsx b/apps/web/components/app/analytics/import-export.tsx similarity index 100% rename from components/app/analytics/import-export.tsx rename to apps/web/components/app/analytics/import-export.tsx diff --git a/components/app/analytics/share-management.tsx b/apps/web/components/app/analytics/share-management.tsx similarity index 100% rename from components/app/analytics/share-management.tsx rename to apps/web/components/app/analytics/share-management.tsx diff --git a/components/app/analytics/time-analytics.tsx b/apps/web/components/app/analytics/time-analytics.tsx similarity index 100% rename from components/app/analytics/time-analytics.tsx rename to apps/web/components/app/analytics/time-analytics.tsx diff --git a/components/app/auth-waiting-loading.tsx b/apps/web/components/app/auth-waiting-loading.tsx similarity index 100% rename from components/app/auth-waiting-loading.tsx rename to apps/web/components/app/auth-waiting-loading.tsx diff --git a/components/app/calendar.tsx b/apps/web/components/app/calendar.tsx similarity index 100% rename from components/app/calendar.tsx rename to apps/web/components/app/calendar.tsx diff --git a/components/app/event/event-dialog.tsx b/apps/web/components/app/event/event-dialog.tsx similarity index 100% rename from components/app/event/event-dialog.tsx rename to apps/web/components/app/event/event-dialog.tsx diff --git a/components/app/event/event-preview.tsx b/apps/web/components/app/event/event-preview.tsx similarity index 99% rename from components/app/event/event-preview.tsx rename to apps/web/components/app/event/event-preview.tsx index 80d3ead8..efda487e 100644 --- a/components/app/event/event-preview.tsx +++ b/apps/web/components/app/event/event-preview.tsx @@ -113,7 +113,7 @@ export default function EventPreview({ }, [open, openShareImmediately, isSignedIn, atprotoSignedIn, language]); useEffect(() => { - fetch("/api/atproto/session") + fetch("/api/atproto/session", { cache: "no-store" }) .then((r) => r.json()) .then((data: { signedIn?: boolean; handle?: string }) => { setAtprotoSignedIn(!!data.signedIn); diff --git a/components/app/profile/daily-toast.tsx b/apps/web/components/app/profile/daily-toast.tsx similarity index 100% rename from components/app/profile/daily-toast.tsx rename to apps/web/components/app/profile/daily-toast.tsx diff --git a/components/app/profile/settings.tsx b/apps/web/components/app/profile/settings.tsx similarity index 70% rename from components/app/profile/settings.tsx rename to apps/web/components/app/profile/settings.tsx index d5b0ac92..707a3459 100644 --- a/components/app/profile/settings.tsx +++ b/apps/web/components/app/profile/settings.tsx @@ -25,6 +25,10 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Kbd } from "@/components/ui/kbd"; import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useUser } from "@clerk/nextjs"; interface SettingsProps { language: Language; @@ -71,8 +75,38 @@ export default function Settings({ toastPosition, setToastPosition, }: SettingsProps) { + const { isSignedIn } = useUser(); const { theme, setTheme } = useTheme(); const t = translations[language]; + const [atprotoSignedIn, setAtprotoSignedIn] = useState(false); + const [dsAddress, setDsAddress] = useState(""); + const [targetDsAddress, setTargetDsAddress] = useState(""); + const [dsMessage, setDsMessage] = useState(""); + const [dsMigrationProgress, setDsMigrationProgress] = useState(""); + const [dsBusy, setDsBusy] = useState(false); + + useEffect(() => { + fetch("/api/atproto/session", { cache: "no-store" }) + .then((r) => r.json()) + .then((data: { signedIn?: boolean }) => { + setAtprotoSignedIn(!!data.signedIn); + }) + .catch(() => setAtprotoSignedIn(false)); + }, []); + + useEffect(() => { + if (!atprotoSignedIn) { + setDsAddress(""); + return; + } + + fetch("/api/ds/config", { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.ds) setDsAddress(data.ds); + }) + .catch(() => undefined); + }, [atprotoSignedIn]); const getGMTTimezones = () => { const timezones = Intl.supportedValuesOf("timeZone"); @@ -146,6 +180,53 @@ export default function Settings({ setTheme(newTheme); }; + const saveDsAddress = async () => { + setDsBusy(true); + setDsMessage(""); + try { + const r = await fetch("/api/ds/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ds: dsAddress }), + }); + if (!r.ok) { + const e = await r.json().catch(() => ({})); + throw new Error(e.error || "failed to save ds"); + } + setDsMessage("DS address saved."); + } catch (error) { + setDsMessage((error as Error).message || "Failed to save DS"); + } finally { + setDsBusy(false); + } + }; + + const migrateDs = async () => { + setDsBusy(true); + setDsMigrationProgress("migrating..."); + setDsMessage(""); + try { + const r = await fetch("/api/ds/migrate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ toDs: targetDsAddress }), + }); + if (!r.ok) { + const e = await r.json().catch(() => ({})); + throw new Error(e.error || "migration failed"); + } + setDsAddress(targetDsAddress); + setTargetDsAddress(""); + setDsMigrationProgress("migration completed"); + setDsMessage("DS switched successfully."); + } catch (error) { + setDsMigrationProgress(""); + setDsMessage((error as Error).message || "migration failed"); + } finally { + setDsBusy(false); + } + }; + return (
@@ -280,6 +361,60 @@ export default function Settings({
+ {atprotoSignedIn ? ( + <> +
+ +
+ setDsAddress(e.target.value)} + /> + +
+

+ This writes app.onecalendar.ds for your Bluesky DID. +

+
+ +
+ +
+ setTargetDsAddress(e.target.value)} + /> + +
+ {dsMigrationProgress ? ( +

+ {dsMigrationProgress} +

+ ) : null} + {dsMessage ? ( +

{dsMessage}

+ ) : null} +
+ + ) : isSignedIn ? ( +

+ Custom DS is available for ATProto accounts only. +

+ ) : null} +
{ - fetch("/api/atproto/session") + fetch("/api/atproto/session", { cache: "no-store" }) .then((r) => r.json()) .then( (data: { diff --git a/components/app/sidebar/bookmark-panel.tsx b/apps/web/components/app/sidebar/bookmark-panel.tsx similarity index 100% rename from components/app/sidebar/bookmark-panel.tsx rename to apps/web/components/app/sidebar/bookmark-panel.tsx diff --git a/components/app/sidebar/countdown.tsx b/apps/web/components/app/sidebar/countdown.tsx similarity index 100% rename from components/app/sidebar/countdown.tsx rename to apps/web/components/app/sidebar/countdown.tsx diff --git a/components/app/sidebar/mini-calendar-sheet.tsx b/apps/web/components/app/sidebar/mini-calendar-sheet.tsx similarity index 100% rename from components/app/sidebar/mini-calendar-sheet.tsx rename to apps/web/components/app/sidebar/mini-calendar-sheet.tsx diff --git a/components/app/sidebar/right-sidebar.tsx b/apps/web/components/app/sidebar/right-sidebar.tsx similarity index 100% rename from components/app/sidebar/right-sidebar.tsx rename to apps/web/components/app/sidebar/right-sidebar.tsx diff --git a/components/app/sidebar/sidebar.tsx b/apps/web/components/app/sidebar/sidebar.tsx similarity index 100% rename from components/app/sidebar/sidebar.tsx rename to apps/web/components/app/sidebar/sidebar.tsx diff --git a/components/app/views/day-view.tsx b/apps/web/components/app/views/day-view.tsx similarity index 100% rename from components/app/views/day-view.tsx rename to apps/web/components/app/views/day-view.tsx diff --git a/components/app/views/month-view.tsx b/apps/web/components/app/views/month-view.tsx similarity index 100% rename from components/app/views/month-view.tsx rename to apps/web/components/app/views/month-view.tsx diff --git a/components/app/views/week-view.tsx b/apps/web/components/app/views/week-view.tsx similarity index 100% rename from components/app/views/week-view.tsx rename to apps/web/components/app/views/week-view.tsx diff --git a/components/app/views/year-view.tsx b/apps/web/components/app/views/year-view.tsx similarity index 100% rename from components/app/views/year-view.tsx rename to apps/web/components/app/views/year-view.tsx diff --git a/components/auth/atproto-login-form.tsx b/apps/web/components/auth/atproto-login-form.tsx similarity index 100% rename from components/auth/atproto-login-form.tsx rename to apps/web/components/auth/atproto-login-form.tsx diff --git a/components/auth/auth-brand.tsx b/apps/web/components/auth/auth-brand.tsx similarity index 100% rename from components/auth/auth-brand.tsx rename to apps/web/components/auth/auth-brand.tsx diff --git a/components/auth/login-form.tsx b/apps/web/components/auth/login-form.tsx similarity index 100% rename from components/auth/login-form.tsx rename to apps/web/components/auth/login-form.tsx diff --git a/components/auth/reset-form.tsx b/apps/web/components/auth/reset-form.tsx similarity index 100% rename from components/auth/reset-form.tsx rename to apps/web/components/auth/reset-form.tsx diff --git a/components/auth/sign-up-form.tsx b/apps/web/components/auth/sign-up-form.tsx similarity index 100% rename from components/auth/sign-up-form.tsx rename to apps/web/components/auth/sign-up-form.tsx diff --git a/components/icons/clock-dashed.tsx b/apps/web/components/icons/clock-dashed.tsx similarity index 100% rename from components/icons/clock-dashed.tsx rename to apps/web/components/icons/clock-dashed.tsx diff --git a/components/icons/share.tsx b/apps/web/components/icons/share.tsx similarity index 100% rename from components/icons/share.tsx rename to apps/web/components/icons/share.tsx diff --git a/components/landing/animated-sphere.tsx b/apps/web/components/landing/animated-sphere.tsx similarity index 100% rename from components/landing/animated-sphere.tsx rename to apps/web/components/landing/animated-sphere.tsx diff --git a/components/landing/animated-tetrahedron.tsx b/apps/web/components/landing/animated-tetrahedron.tsx similarity index 100% rename from components/landing/animated-tetrahedron.tsx rename to apps/web/components/landing/animated-tetrahedron.tsx diff --git a/components/landing/animated-wave.tsx b/apps/web/components/landing/animated-wave.tsx similarity index 100% rename from components/landing/animated-wave.tsx rename to apps/web/components/landing/animated-wave.tsx diff --git a/components/landing/cta-section.tsx b/apps/web/components/landing/cta-section.tsx similarity index 100% rename from components/landing/cta-section.tsx rename to apps/web/components/landing/cta-section.tsx diff --git a/components/landing/developers-section.tsx b/apps/web/components/landing/developers-section.tsx similarity index 100% rename from components/landing/developers-section.tsx rename to apps/web/components/landing/developers-section.tsx diff --git a/components/landing/features-section.tsx b/apps/web/components/landing/features-section.tsx similarity index 100% rename from components/landing/features-section.tsx rename to apps/web/components/landing/features-section.tsx diff --git a/components/landing/footer-section.tsx b/apps/web/components/landing/footer-section.tsx similarity index 100% rename from components/landing/footer-section.tsx rename to apps/web/components/landing/footer-section.tsx diff --git a/components/landing/hero-section.tsx b/apps/web/components/landing/hero-section.tsx similarity index 100% rename from components/landing/hero-section.tsx rename to apps/web/components/landing/hero-section.tsx diff --git a/components/landing/how-it-works-section.tsx b/apps/web/components/landing/how-it-works-section.tsx similarity index 100% rename from components/landing/how-it-works-section.tsx rename to apps/web/components/landing/how-it-works-section.tsx diff --git a/components/landing/index.ts b/apps/web/components/landing/index.ts similarity index 100% rename from components/landing/index.ts rename to apps/web/components/landing/index.ts diff --git a/components/landing/infrastructure-section.tsx b/apps/web/components/landing/infrastructure-section.tsx similarity index 100% rename from components/landing/infrastructure-section.tsx rename to apps/web/components/landing/infrastructure-section.tsx diff --git a/components/landing/integrations-section.tsx b/apps/web/components/landing/integrations-section.tsx similarity index 100% rename from components/landing/integrations-section.tsx rename to apps/web/components/landing/integrations-section.tsx diff --git a/components/landing/metrics-section.tsx b/apps/web/components/landing/metrics-section.tsx similarity index 100% rename from components/landing/metrics-section.tsx rename to apps/web/components/landing/metrics-section.tsx diff --git a/components/landing/navigation.tsx b/apps/web/components/landing/navigation.tsx similarity index 100% rename from components/landing/navigation.tsx rename to apps/web/components/landing/navigation.tsx diff --git a/components/landing/pricing-section.tsx b/apps/web/components/landing/pricing-section.tsx similarity index 100% rename from components/landing/pricing-section.tsx rename to apps/web/components/landing/pricing-section.tsx diff --git a/components/landing/testimonials-section.tsx b/apps/web/components/landing/testimonials-section.tsx similarity index 100% rename from components/landing/testimonials-section.tsx rename to apps/web/components/landing/testimonials-section.tsx diff --git a/components/providers/calendar-context.tsx b/apps/web/components/providers/calendar-context.tsx similarity index 100% rename from components/providers/calendar-context.tsx rename to apps/web/components/providers/calendar-context.tsx diff --git a/components/providers/pwa-provider.tsx b/apps/web/components/providers/pwa-provider.tsx similarity index 100% rename from components/providers/pwa-provider.tsx rename to apps/web/components/providers/pwa-provider.tsx diff --git a/components/providers/theme-provider.tsx b/apps/web/components/providers/theme-provider.tsx similarity index 100% rename from components/providers/theme-provider.tsx rename to apps/web/components/providers/theme-provider.tsx diff --git a/hooks/use-toast.ts b/apps/web/hooks/use-toast.ts similarity index 100% rename from hooks/use-toast.ts rename to apps/web/hooks/use-toast.ts diff --git a/hooks/useLocalStorage.ts b/apps/web/hooks/useLocalStorage.ts similarity index 100% rename from hooks/useLocalStorage.ts rename to apps/web/hooks/useLocalStorage.ts diff --git a/hooks/useMobile.ts b/apps/web/hooks/useMobile.ts similarity index 100% rename from hooks/useMobile.ts rename to apps/web/hooks/useMobile.ts diff --git a/lib/atproto-auth.ts b/apps/web/lib/atproto-auth.ts similarity index 91% rename from lib/atproto-auth.ts rename to apps/web/lib/atproto-auth.ts index bc06cfa6..d9b3e190 100644 --- a/lib/atproto-auth.ts +++ b/apps/web/lib/atproto-auth.ts @@ -17,6 +17,19 @@ export interface AtprotoSession { dpopPublicJwk?: DpopPublicJwk; } +function sanitizeSessionForCookie(session: AtprotoSession): AtprotoSession { + return { + did: session.did, + handle: session.handle, + pds: session.pds, + accessToken: session.accessToken, + dpopPrivateKeyPem: session.dpopPrivateKeyPem, + dpopPublicJwk: session.dpopPublicJwk, + displayName: session.displayName, + avatar: session.avatar, + }; +} + export interface KeyEntry { kid: string; secret: string; @@ -133,7 +146,7 @@ export async function setAtprotoSession(session: AtprotoSession) { store.delete(ATPROTO_SESSION_COOKIE); return; } - const value = encodeSession(session); + const value = encodeSession(sanitizeSessionForCookie(session)); store.set(ATPROTO_SESSION_COOKIE, value, { httpOnly: true, secure: shouldUseSecureCookies(), diff --git a/apps/web/lib/atproto-feature.ts b/apps/web/lib/atproto-feature.ts new file mode 100644 index 00000000..cb898f7e --- /dev/null +++ b/apps/web/lib/atproto-feature.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; + +export const ATPROTO_DISABLED = + process.env.NEXT_PUBLIC_DISABLE_ATPROTO === "1" || + process.env.NEXT_PUBLIC_DISABLE_ATPROTO === "true"; + +export function atprotoDisabledResponse() { + return NextResponse.json( + { error: "ATProto channel is disabled" }, + { status: 410 }, + ); +} diff --git a/lib/atproto-oauth-txn.ts b/apps/web/lib/atproto-oauth-txn.ts similarity index 100% rename from lib/atproto-oauth-txn.ts rename to apps/web/lib/atproto-oauth-txn.ts diff --git a/lib/atproto.ts b/apps/web/lib/atproto.ts similarity index 100% rename from lib/atproto.ts rename to apps/web/lib/atproto.ts diff --git a/lib/crypto.ts b/apps/web/lib/crypto.ts similarity index 100% rename from lib/crypto.ts rename to apps/web/lib/crypto.ts diff --git a/lib/dpop.ts b/apps/web/lib/dpop.ts similarity index 100% rename from lib/dpop.ts rename to apps/web/lib/dpop.ts diff --git a/apps/web/lib/ds-client.ts b/apps/web/lib/ds-client.ts new file mode 100644 index 00000000..c6b30897 --- /dev/null +++ b/apps/web/lib/ds-client.ts @@ -0,0 +1,19 @@ +export async function signedDsRequest(params: { + ds: string; + path: string; + method: "GET" | "POST" | "DELETE"; + payload?: unknown; +}) { + const res = await fetch("/api/ds/proxy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + + if (!res.ok) { + const message = await res.text(); + throw new Error(message || `DS request failed: ${res.status}`); + } + + return (await res.json()) as T; +} diff --git a/apps/web/lib/ds-signed-request.ts b/apps/web/lib/ds-signed-request.ts new file mode 100644 index 00000000..54837334 --- /dev/null +++ b/apps/web/lib/ds-signed-request.ts @@ -0,0 +1,63 @@ +import { createHash, createSign } from "node:crypto"; +import type { AtprotoSession } from "@/lib/atproto-auth"; + +function digest(value: string) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function createPayload(method: string, path: string, timestamp: string, body: string) { + return `${method.toUpperCase()}\n${path}\n${timestamp}\n${digest(body)}`; +} + +function signPayload(payload: string, privateKeyPem: string) { + const signer = createSign("SHA256"); + signer.update(payload); + signer.end(); + return signer.sign(privateKeyPem).toString("base64url"); +} + +export async function signedDsFetch(params: { + session: AtprotoSession; + ds: string; + path: string; + method: "GET" | "POST" | "DELETE"; + body?: unknown; +}) { + if (!params.session.dpopPrivateKeyPem || !params.session.dpopPublicJwk) { + throw new Error("ATProto DPoP key unavailable for signed DS request"); + } + + const ds = params.ds.replace(/\/$/, ""); + const path = params.path.startsWith("/") ? params.path : `/${params.path}`; + const timestamp = Date.now().toString(); + const bodyText = + params.body === undefined || params.method === "GET" + ? "" + : JSON.stringify(params.body); + + const payload = createPayload(params.method, path, timestamp, bodyText); + const signature = signPayload(payload, params.session.dpopPrivateKeyPem); + + const headers: Record = { + "x-did": params.session.did, + "x-timestamp": timestamp, + "x-signature": signature, + "x-dpop-jwk": JSON.stringify(params.session.dpopPublicJwk), + }; + const appToken = process.env.DS_APP_TOKEN; + if (!appToken) { + throw new Error("DS_APP_TOKEN is not configured"); + } + headers["x-app-token"] = appToken; + + if (params.method !== "GET") { + headers["Content-Type"] = "application/json"; + } + + return fetch(`${ds}${path}`, { + method: params.method, + headers, + body: params.method === "GET" ? undefined : bodyText, + cache: "no-store", + }); +} diff --git a/lib/fetch-json.ts b/apps/web/lib/fetch-json.ts similarity index 100% rename from lib/fetch-json.ts rename to apps/web/lib/fetch-json.ts diff --git a/lib/gen-oauth-metadata.mjs b/apps/web/lib/gen-oauth-metadata.mjs similarity index 100% rename from lib/gen-oauth-metadata.mjs rename to apps/web/lib/gen-oauth-metadata.mjs diff --git a/lib/icsUtils.ts b/apps/web/lib/icsUtils.ts similarity index 100% rename from lib/icsUtils.ts rename to apps/web/lib/icsUtils.ts diff --git a/lib/notifications.ts b/apps/web/lib/notifications.ts similarity index 100% rename from lib/notifications.ts rename to apps/web/lib/notifications.ts diff --git a/lib/time-analytics.ts b/apps/web/lib/time-analytics.ts similarity index 100% rename from lib/time-analytics.ts rename to apps/web/lib/time-analytics.ts diff --git a/lib/utils.ts b/apps/web/lib/utils.ts similarity index 100% rename from lib/utils.ts rename to apps/web/lib/utils.ts diff --git a/next.config.ts b/apps/web/next.config.ts similarity index 100% rename from next.config.ts rename to apps/web/next.config.ts diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000..5db1654c --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,81 @@ +{ + "name": "web", + "version": "2.2.7", + "private": true, + "packageManager": "bun@1.3.8", + "scripts": { + "dev": "(cd ../../packages/i18n && bun run generate:locales) && bun run generate:oauth-metadata && next dev", + "build": "(cd ../../packages/i18n && bun run generate:locales) && bun run generate:oauth-metadata && next build", + "start": "next start", + "generate:locales": "(cd ../../packages/i18n && bun run generate:locales)", + "generate:oauth-metadata": "bun lib/gen-oauth-metadata.mjs" + }, + "dependencies": { + "@clerk/localizations": "latest", + "@clerk/nextjs": "latest", + "@hookform/resolvers": "5.2.2", + "@marsidev/react-turnstile": "latest", + "@radix-ui/react-accordion": "latest", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-checkbox": "^1.1.3", + "@radix-ui/react-collapsible": "^1.1.2", + "@radix-ui/react-context-menu": "^2.2.4", + "@radix-ui/react-dialog": "latest", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-hover-card": "^1.1.4", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-menubar": "^1.1.4", + "@radix-ui/react-navigation-menu": "^1.2.3", + "@radix-ui/react-popover": "latest", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slider": "^1.2.2", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-toast": "latest", + "@radix-ui/react-toggle": "^1.1.1", + "@radix-ui/react-toggle-group": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "latest", + "date-fns": "4.1.0", + "embla-carousel-react": "8.6.0", + "framer-motion": "^12.7.4", + "geist": "latest", + "ics": "latest", + "input-otp": "1.4.2", + "lucide-react": "0.468.0", + "motion": "latest", + "next": "16.2.1", + "next-themes": "latest", + "pg": "latest", + "qr-code-styling": "latest", + "radix-ui": "latest", + "react": "^18", + "react-day-picker": "9.14.0", + "react-dom": "^18", + "react-hook-form": "^7.54.1", + "react-markdown": "latest", + "react-resizable-panels": "4.7.6", + "recharts": "3.8.1", + "remark-gfm": "latest", + "sonner": "2.0.7", + "tailwind-merge": "3.5.0", + "tailwindcss-animate": "^1.0.7", + "vaul": "1.1.2", + "zod": "4.3.6" + }, + "devDependencies": { + "@tailwindcss/postcss": "4.2.2", + "@types/node": "25.5.0", + "@types/react": "^18", + "@types/react-dom": "^18", + "postcss": "8.5.8", + "tailwindcss": "4.2.2", + "typescript": "5.9.3" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 00000000..1ed08401 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1 @@ +export { default } from "../../packages/config/postcss.config.mjs"; diff --git a/proxy.ts b/apps/web/proxy.ts similarity index 100% rename from proxy.ts rename to apps/web/proxy.ts diff --git a/public/Banner-dark.jpg b/apps/web/public/Banner-dark.jpg similarity index 100% rename from public/Banner-dark.jpg rename to apps/web/public/Banner-dark.jpg diff --git a/public/Banner.jpg b/apps/web/public/Banner.jpg similarity index 100% rename from public/Banner.jpg rename to apps/web/public/Banner.jpg diff --git a/public/Home.jpg b/apps/web/public/Home.jpg similarity index 100% rename from public/Home.jpg rename to apps/web/public/Home.jpg diff --git a/public/file.svg b/apps/web/public/file.svg similarity index 100% rename from public/file.svg rename to apps/web/public/file.svg diff --git a/public/globe.svg b/apps/web/public/globe.svg similarity index 100% rename from public/globe.svg rename to apps/web/public/globe.svg diff --git a/public/icon.svg b/apps/web/public/icon.svg similarity index 100% rename from public/icon.svg rename to apps/web/public/icon.svg diff --git a/public/oauth-client-metadata.json b/apps/web/public/oauth-client-metadata.json similarity index 100% rename from public/oauth-client-metadata.json rename to apps/web/public/oauth-client-metadata.json diff --git a/public/og.png b/apps/web/public/og.png similarity index 100% rename from public/og.png rename to apps/web/public/og.png diff --git a/public/sf.otf b/apps/web/public/sf.otf similarity index 100% rename from public/sf.otf rename to apps/web/public/sf.otf diff --git a/public/sw.js b/apps/web/public/sw.js similarity index 100% rename from public/sw.js rename to apps/web/public/sw.js diff --git a/public/vercel.svg b/apps/web/public/vercel.svg similarity index 100% rename from public/vercel.svg rename to apps/web/public/vercel.svg diff --git a/public/window.svg b/apps/web/public/window.svg similarity index 100% rename from public/window.svg rename to apps/web/public/window.svg diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 00000000..8ebd4130 --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1 @@ +export { default } from "../../packages/config/tailwind.config"; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 00000000..0cfdcf03 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../packages/config/tsconfig.base.json", + "compilerOptions": { + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"], + "@/components/ui/*": ["../../packages/ui/src/*"], + "@/lib/i18n": ["../../packages/i18n/src/i18n.ts"], + "@/lib/locales": ["../../packages/i18n/src/locales.ts"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/vercel.json b/apps/web/vercel.json similarity index 100% rename from vercel.json rename to apps/web/vercel.json diff --git a/lib/atproto-feature.ts b/lib/atproto-feature.ts deleted file mode 100644 index b470bede..00000000 --- a/lib/atproto-feature.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextResponse } from "next/server"; - -export const ATPROTO_DISABLED = false; - -export function atprotoDisabledResponse() { - return NextResponse.json({ error: "ATProto channel is disabled" }, { status: 410 }); -} - -/* -import { NextResponse } from "next/server"; - -export const ATPROTO_DISABLED = true; - -export function atprotoDisabledResponse() { - return NextResponse.json({ error: "ATProto channel is disabled" }, { status: 410 }); -} -*/ diff --git a/package.json b/package.json index 75fc009e..b1402987 100644 --- a/package.json +++ b/package.json @@ -1,81 +1,22 @@ { - "name": "one-calendar", - "version": "2.2.7", + "name": "one-calendar-monorepo", "private": true, "packageManager": "bun@1.3.8", + "workspaces": [ + "apps/*", + "packages/*" + ], "scripts": { - "dev": "bun run generate:locales && next dev", - "build": "bun run generate:locales && next build", - "start": "next start", - "generate:locales": "bun lib/gen-locales.mjs", - "generate:oauth-metadata": "bun lib/gen-oauth-metadata.mjs" - }, - "dependencies": { - "@clerk/localizations": "latest", - "@clerk/nextjs": "latest", - "@hookform/resolvers": "5.2.2", - "@marsidev/react-turnstile": "latest", - "@radix-ui/react-accordion": "latest", - "@radix-ui/react-alert-dialog": "^1.1.7", - "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-checkbox": "^1.1.3", - "@radix-ui/react-collapsible": "^1.1.2", - "@radix-ui/react-context-menu": "^2.2.4", - "@radix-ui/react-dialog": "latest", - "@radix-ui/react-dropdown-menu": "^2.1.4", - "@radix-ui/react-hover-card": "^1.1.4", - "@radix-ui/react-label": "^2.1.1", - "@radix-ui/react-menubar": "^1.1.4", - "@radix-ui/react-navigation-menu": "^1.2.3", - "@radix-ui/react-popover": "latest", - "@radix-ui/react-scroll-area": "^1.2.2", - "@radix-ui/react-select": "^2.1.4", - "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slider": "^1.2.2", - "@radix-ui/react-slot": "^1.2.0", - "@radix-ui/react-switch": "^1.1.2", - "@radix-ui/react-tabs": "^1.1.2", - "@radix-ui/react-toast": "latest", - "@radix-ui/react-toggle": "^1.1.1", - "@radix-ui/react-toggle-group": "^1.1.1", - "@radix-ui/react-tooltip": "^1.1.6", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "latest", - "date-fns": "4.1.0", - "embla-carousel-react": "8.6.0", - "framer-motion": "^12.7.4", - "geist": "latest", - "ics": "latest", - "input-otp": "1.4.2", - "lucide-react": "latest", - "motion": "latest", - "next": "16.2.1", - "next-themes": "latest", - "pg": "latest", - "qr-code-styling": "latest", - "radix-ui": "latest", - "react": "^18", - "react-day-picker": "9.14.0", - "react-dom": "^18", - "react-hook-form": "^7.54.1", - "react-markdown": "latest", - "react-resizable-panels": "^2.1.7", - "recharts": "3.7.0", - "remark-gfm": "latest", - "sonner": "2.0.7", - "tailwind-merge": "3.5.0", - "tailwindcss-animate": "^1.0.7", - "vaul": "1.1.2", - "zod": "4.3.6" + "dev": "turbo dev --filter=web", + "build": "turbo build", + "start": "turbo start --filter=web", + "generate:locales": "turbo generate:locales --filter=@repo/i18n", + "generate:oauth-metadata": "turbo generate:oauth-metadata --filter=web", + "dev:web": "turbo dev --filter=web", + "dev:ds": "turbo dev --filter=ds", + "start:ds": "turbo start --filter=ds" }, "devDependencies": { - "@tailwindcss/postcss": "4.2.1", - "@types/node": "25.2.3", - "@types/react": "^18", - "@types/react-dom": "^18", - "postcss": "8.5.6", - "tailwindcss": "4.2.1", - "typescript": "5.9.3" + "turbo": "^2.5.8" } } diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 00000000..549604e5 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,11 @@ +{ + "name": "@repo/config", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + "./postcss": "./postcss.config.mjs", + "./tailwind": "./tailwind.config.ts", + "./tsconfig": "./tsconfig.base.json" + } +} diff --git a/postcss.config.mjs b/packages/config/postcss.config.mjs similarity index 100% rename from postcss.config.mjs rename to packages/config/postcss.config.mjs diff --git a/packages/config/tailwind.config.ts b/packages/config/tailwind.config.ts new file mode 100644 index 00000000..20147bb6 --- /dev/null +++ b/packages/config/tailwind.config.ts @@ -0,0 +1,5 @@ +import type { Config } from "tailwindcss"; + +const config = {} satisfies Config; + +export default config; diff --git a/tsconfig.json b/packages/config/tsconfig.base.json similarity index 56% rename from tsconfig.json rename to packages/config/tsconfig.base.json index a5575e9d..9703a038 100644 --- a/tsconfig.json +++ b/packages/config/tsconfig.base.json @@ -12,16 +12,6 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], - "exclude": ["node_modules"] + "incremental": true + } } diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 00000000..0e7db478 --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,8 @@ +{ + "name": "@repo/i18n", + "version": "1.0.0", + "private": true, + "scripts": { + "generate:locales": "bun scripts/gen-locales.mjs" + } +} diff --git a/lib/gen-locales.mjs b/packages/i18n/scripts/gen-locales.mjs similarity index 74% rename from lib/gen-locales.mjs rename to packages/i18n/scripts/gen-locales.mjs index 9c526f07..39cdc06c 100644 --- a/lib/gen-locales.mjs +++ b/packages/i18n/scripts/gen-locales.mjs @@ -5,8 +5,8 @@ import { fileURLToPath } from "node:url" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const projectRoot = path.resolve(__dirname, "..") -const localesDir = path.join(projectRoot, "locales") -const outputFile = path.join(projectRoot, "lib", "locales.ts") +const localesDir = path.join(projectRoot, "src", "locales") +const outputFile = path.join(projectRoot, "src", "locales.ts") const toIdentifier = (value) => `locale${value.replace(/[^a-zA-Z0-9]+(.)/g, (_, chr) => chr.toUpperCase()).replace(/^[a-z]/, (chr) => chr.toUpperCase())}` @@ -17,14 +17,14 @@ const run = async () => { .sort((a, b) => a.localeCompare(b)) if (localeFiles.length === 0) { - throw new Error("No locale json files found in /locales") + throw new Error("No locale json files found in /src/locales") } const imports = localeFiles .map((file) => { const lang = file.replace(/\.json$/, "") const identifier = toIdentifier(lang) - return `import ${identifier} from "@/locales/${file}"` + return `import ${identifier} from "./locales/${file}"` }) .join("\n") @@ -36,7 +36,7 @@ const run = async () => { }) .join("\n") - const content = `/* eslint-disable */\n// This file is auto-generated by lib/gen-locales.mjs\n// Do not edit manually.\n\n${imports}\n\nexport const translations = {\n${entries}\n} as const\n\nexport type Language = keyof typeof translations\n` + const content = `/* eslint-disable */\n// This file is auto-generated by packages/i18n/scripts/gen-locales.mjs\n// Do not edit manually.\n\n${imports}\n\nexport const translations = {\n${entries}\n} as const\n\nexport type Language = keyof typeof translations\n` await fs.writeFile(outputFile, content, "utf8") console.log(`Generated ${path.relative(projectRoot, outputFile)} with ${localeFiles.length} locale(s).`) diff --git a/lib/i18n.ts b/packages/i18n/src/i18n.ts similarity index 99% rename from lib/i18n.ts rename to packages/i18n/src/i18n.ts index e1233873..79657d94 100644 --- a/lib/i18n.ts +++ b/packages/i18n/src/i18n.ts @@ -10,7 +10,7 @@ import { import { translations as localeTranslations, type Language, -} from "@/lib/locales"; +} from "./locales"; const LANGUAGE_STORAGE_KEY = "preferred-language"; diff --git a/locales/bn.json b/packages/i18n/src/locales/bn.json similarity index 100% rename from locales/bn.json rename to packages/i18n/src/locales/bn.json diff --git a/locales/de.json b/packages/i18n/src/locales/de.json similarity index 100% rename from locales/de.json rename to packages/i18n/src/locales/de.json diff --git a/locales/el.json b/packages/i18n/src/locales/el.json similarity index 100% rename from locales/el.json rename to packages/i18n/src/locales/el.json diff --git a/locales/en-GB.json b/packages/i18n/src/locales/en-GB.json similarity index 100% rename from locales/en-GB.json rename to packages/i18n/src/locales/en-GB.json diff --git a/locales/en.json b/packages/i18n/src/locales/en.json similarity index 100% rename from locales/en.json rename to packages/i18n/src/locales/en.json diff --git a/locales/es.json b/packages/i18n/src/locales/es.json similarity index 100% rename from locales/es.json rename to packages/i18n/src/locales/es.json diff --git a/locales/fi.json b/packages/i18n/src/locales/fi.json similarity index 100% rename from locales/fi.json rename to packages/i18n/src/locales/fi.json diff --git a/locales/fr.json b/packages/i18n/src/locales/fr.json similarity index 100% rename from locales/fr.json rename to packages/i18n/src/locales/fr.json diff --git a/locales/hi.json b/packages/i18n/src/locales/hi.json similarity index 100% rename from locales/hi.json rename to packages/i18n/src/locales/hi.json diff --git a/locales/is.json b/packages/i18n/src/locales/is.json similarity index 100% rename from locales/is.json rename to packages/i18n/src/locales/is.json diff --git a/locales/it.json b/packages/i18n/src/locales/it.json similarity index 100% rename from locales/it.json rename to packages/i18n/src/locales/it.json diff --git a/locales/ja.json b/packages/i18n/src/locales/ja.json similarity index 100% rename from locales/ja.json rename to packages/i18n/src/locales/ja.json diff --git a/locales/ko.json b/packages/i18n/src/locales/ko.json similarity index 100% rename from locales/ko.json rename to packages/i18n/src/locales/ko.json diff --git a/locales/lt.json b/packages/i18n/src/locales/lt.json similarity index 100% rename from locales/lt.json rename to packages/i18n/src/locales/lt.json diff --git a/locales/lv.json b/packages/i18n/src/locales/lv.json similarity index 100% rename from locales/lv.json rename to packages/i18n/src/locales/lv.json diff --git a/locales/mk.json b/packages/i18n/src/locales/mk.json similarity index 100% rename from locales/mk.json rename to packages/i18n/src/locales/mk.json diff --git a/locales/nb.json b/packages/i18n/src/locales/nb.json similarity index 100% rename from locales/nb.json rename to packages/i18n/src/locales/nb.json diff --git a/locales/nl.json b/packages/i18n/src/locales/nl.json similarity index 100% rename from locales/nl.json rename to packages/i18n/src/locales/nl.json diff --git a/locales/pl.json b/packages/i18n/src/locales/pl.json similarity index 100% rename from locales/pl.json rename to packages/i18n/src/locales/pl.json diff --git a/locales/pt.json b/packages/i18n/src/locales/pt.json similarity index 100% rename from locales/pt.json rename to packages/i18n/src/locales/pt.json diff --git a/locales/ro.json b/packages/i18n/src/locales/ro.json similarity index 100% rename from locales/ro.json rename to packages/i18n/src/locales/ro.json diff --git a/locales/ru.json b/packages/i18n/src/locales/ru.json similarity index 100% rename from locales/ru.json rename to packages/i18n/src/locales/ru.json diff --git a/locales/sl.json b/packages/i18n/src/locales/sl.json similarity index 100% rename from locales/sl.json rename to packages/i18n/src/locales/sl.json diff --git a/locales/sq.json b/packages/i18n/src/locales/sq.json similarity index 100% rename from locales/sq.json rename to packages/i18n/src/locales/sq.json diff --git a/locales/sr.json b/packages/i18n/src/locales/sr.json similarity index 100% rename from locales/sr.json rename to packages/i18n/src/locales/sr.json diff --git a/locales/sv.json b/packages/i18n/src/locales/sv.json similarity index 100% rename from locales/sv.json rename to packages/i18n/src/locales/sv.json diff --git a/locales/sw.json b/packages/i18n/src/locales/sw.json similarity index 100% rename from locales/sw.json rename to packages/i18n/src/locales/sw.json diff --git a/locales/th.json b/packages/i18n/src/locales/th.json similarity index 100% rename from locales/th.json rename to packages/i18n/src/locales/th.json diff --git a/locales/tr.json b/packages/i18n/src/locales/tr.json similarity index 100% rename from locales/tr.json rename to packages/i18n/src/locales/tr.json diff --git a/locales/uk.json b/packages/i18n/src/locales/uk.json similarity index 100% rename from locales/uk.json rename to packages/i18n/src/locales/uk.json diff --git a/locales/vi.json b/packages/i18n/src/locales/vi.json similarity index 100% rename from locales/vi.json rename to packages/i18n/src/locales/vi.json diff --git a/locales/yue.json b/packages/i18n/src/locales/yue.json similarity index 100% rename from locales/yue.json rename to packages/i18n/src/locales/yue.json diff --git a/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json similarity index 100% rename from locales/zh-CN.json rename to packages/i18n/src/locales/zh-CN.json diff --git a/locales/zh-HK.json b/packages/i18n/src/locales/zh-HK.json similarity index 100% rename from locales/zh-HK.json rename to packages/i18n/src/locales/zh-HK.json diff --git a/locales/zh-TW.json b/packages/i18n/src/locales/zh-TW.json similarity index 100% rename from locales/zh-TW.json rename to packages/i18n/src/locales/zh-TW.json diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 00000000..b8182eeb --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,28 @@ +{ + "name": "@repo/ui", + "version": "1.0.0", + "private": true, + "dependencies": { + "@radix-ui/react-accordion": "latest", + "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.2", + "@radix-ui/react-context-menu": "^2.2.4", + "@radix-ui/react-dialog": "latest", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-toast": "latest", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "0.468.0", + "next-themes": "latest", + "radix-ui": "latest", + "react": "^18", + "react-day-picker": "9.14.0", + "sonner": "2.0.7", + "tailwind-merge": "3.5.0" + } +} diff --git a/components/ui/accordion.tsx b/packages/ui/src/accordion.tsx similarity index 98% rename from components/ui/accordion.tsx rename to packages/ui/src/accordion.tsx index f0428981..44fb5933 100644 --- a/components/ui/accordion.tsx +++ b/packages/ui/src/accordion.tsx @@ -4,7 +4,7 @@ import * as AccordionPrimitive from "@radix-ui/react-accordion"; import { ChevronDownIcon } from "lucide-react"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function Accordion({ ...props diff --git a/components/ui/alert-dialog.tsx b/packages/ui/src/alert-dialog.tsx similarity index 98% rename from components/ui/alert-dialog.tsx rename to packages/ui/src/alert-dialog.tsx index 89631cbb..7a2fa20f 100644 --- a/components/ui/alert-dialog.tsx +++ b/packages/ui/src/alert-dialog.tsx @@ -3,8 +3,8 @@ import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; import * as React from "react"; -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; +import { cn } from "./utils"; +import { Button } from "./button"; function AlertDialog({ ...props diff --git a/components/ui/alert.tsx b/packages/ui/src/alert.tsx similarity index 97% rename from components/ui/alert.tsx rename to packages/ui/src/alert.tsx index 0594a902..1a6e6431 100644 --- a/components/ui/alert.tsx +++ b/packages/ui/src/alert.tsx @@ -1,7 +1,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; const alertVariants = cva( "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", diff --git a/components/ui/avatar.tsx b/packages/ui/src/avatar.tsx similarity index 96% rename from components/ui/avatar.tsx rename to packages/ui/src/avatar.tsx index 1393f509..5f2e9d04 100644 --- a/components/ui/avatar.tsx +++ b/packages/ui/src/avatar.tsx @@ -3,7 +3,7 @@ import * as AvatarPrimitive from "@radix-ui/react-avatar"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function Avatar({ className, diff --git a/components/ui/badge.tsx b/packages/ui/src/badge.tsx similarity index 97% rename from components/ui/badge.tsx rename to packages/ui/src/badge.tsx index c03a5071..5ba4b6a0 100644 --- a/components/ui/badge.tsx +++ b/packages/ui/src/badge.tsx @@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { Slot } from "@radix-ui/react-slot"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; const badgeVariants = cva( "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", diff --git a/components/ui/button.tsx b/packages/ui/src/button.tsx similarity index 98% rename from components/ui/button.tsx rename to packages/ui/src/button.tsx index 7de6f727..c7be0259 100644 --- a/components/ui/button.tsx +++ b/packages/ui/src/button.tsx @@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { Slot } from "@radix-ui/react-slot"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; const buttonVariants = cva( "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", diff --git a/components/ui/calendar.tsx b/packages/ui/src/calendar.tsx similarity index 98% rename from components/ui/calendar.tsx rename to packages/ui/src/calendar.tsx index c36f9e6d..b1324451 100644 --- a/components/ui/calendar.tsx +++ b/packages/ui/src/calendar.tsx @@ -8,8 +8,8 @@ import { type Locale, } from "react-day-picker"; -import { cn } from "@/lib/utils"; -import { Button, buttonVariants } from "@/components/ui/button"; +import { cn } from "./utils"; +import { Button, buttonVariants } from "./button"; import { ChevronLeftIcon, ChevronRightIcon, diff --git a/components/ui/card.tsx b/packages/ui/src/card.tsx similarity index 98% rename from components/ui/card.tsx rename to packages/ui/src/card.tsx index 5c5024c8..fbfa008c 100644 --- a/components/ui/card.tsx +++ b/packages/ui/src/card.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function Card({ className, diff --git a/components/ui/checkbox.tsx b/packages/ui/src/checkbox.tsx similarity index 97% rename from components/ui/checkbox.tsx rename to packages/ui/src/checkbox.tsx index 16a50b87..b857c40a 100644 --- a/components/ui/checkbox.tsx +++ b/packages/ui/src/checkbox.tsx @@ -3,7 +3,7 @@ import { Checkbox as CheckboxPrimitive } from "radix-ui"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; import { CheckIcon } from "lucide-react"; function Checkbox({ diff --git a/components/ui/collapsible.tsx b/packages/ui/src/collapsible.tsx similarity index 100% rename from components/ui/collapsible.tsx rename to packages/ui/src/collapsible.tsx diff --git a/components/ui/context-menu.tsx b/packages/ui/src/context-menu.tsx similarity index 99% rename from components/ui/context-menu.tsx rename to packages/ui/src/context-menu.tsx index d0328d60..b8e1132f 100644 --- a/components/ui/context-menu.tsx +++ b/packages/ui/src/context-menu.tsx @@ -4,7 +4,7 @@ import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; import { Check, ChevronRight, Circle } from "lucide-react"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; const ContextMenu = ContextMenuPrimitive.Root; diff --git a/components/ui/dialog.tsx b/packages/ui/src/dialog.tsx similarity index 98% rename from components/ui/dialog.tsx rename to packages/ui/src/dialog.tsx index e480166a..85e5ebb9 100644 --- a/components/ui/dialog.tsx +++ b/packages/ui/src/dialog.tsx @@ -3,8 +3,8 @@ import { Dialog as DialogPrimitive } from "radix-ui"; import * as React from "react"; -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; +import { cn } from "./utils"; +import { Button } from "./button"; import { XIcon } from "lucide-react"; function Dialog({ diff --git a/components/ui/dropdown-menu.tsx b/packages/ui/src/dropdown-menu.tsx similarity index 99% rename from components/ui/dropdown-menu.tsx rename to packages/ui/src/dropdown-menu.tsx index 0fa61745..ae86f6d0 100644 --- a/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/dropdown-menu.tsx @@ -4,7 +4,7 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function DropdownMenu({ ...props diff --git a/components/ui/empty.tsx b/packages/ui/src/empty.tsx similarity index 98% rename from components/ui/empty.tsx rename to packages/ui/src/empty.tsx index 9f4d3e3d..e03dbdf4 100644 --- a/components/ui/empty.tsx +++ b/packages/ui/src/empty.tsx @@ -1,7 +1,7 @@ import type React from "react"; import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function Empty({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/components/ui/input.tsx b/packages/ui/src/input.tsx similarity index 96% rename from components/ui/input.tsx rename to packages/ui/src/input.tsx index bcc9491a..5e9a3c7c 100644 --- a/components/ui/input.tsx +++ b/packages/ui/src/input.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( diff --git a/components/ui/kbd.tsx b/packages/ui/src/kbd.tsx similarity index 96% rename from components/ui/kbd.tsx rename to packages/ui/src/kbd.tsx index 44fefd02..a3ee0d18 100644 --- a/components/ui/kbd.tsx +++ b/packages/ui/src/kbd.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { return ( diff --git a/components/ui/label.tsx b/packages/ui/src/label.tsx similarity index 95% rename from components/ui/label.tsx rename to packages/ui/src/label.tsx index e96d48e3..1c1b152f 100644 --- a/components/ui/label.tsx +++ b/packages/ui/src/label.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import * as LabelPrimitive from "@radix-ui/react-label"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; const labelVariants = cva( "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", diff --git a/components/ui/popover.tsx b/packages/ui/src/popover.tsx similarity index 98% rename from components/ui/popover.tsx rename to packages/ui/src/popover.tsx index 81bf824f..055af742 100644 --- a/components/ui/popover.tsx +++ b/packages/ui/src/popover.tsx @@ -3,7 +3,7 @@ import { Popover as PopoverPrimitive } from "radix-ui"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function Popover({ ...props diff --git a/components/ui/scroll-area.tsx b/packages/ui/src/scroll-area.tsx similarity index 98% rename from components/ui/scroll-area.tsx rename to packages/ui/src/scroll-area.tsx index 70a001cc..db7f5790 100644 --- a/components/ui/scroll-area.tsx +++ b/packages/ui/src/scroll-area.tsx @@ -3,7 +3,7 @@ import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function ScrollArea({ className, diff --git a/components/ui/select.tsx b/packages/ui/src/select.tsx similarity index 99% rename from components/ui/select.tsx rename to packages/ui/src/select.tsx index 1c0af238..bcf7e4b3 100644 --- a/components/ui/select.tsx +++ b/packages/ui/src/select.tsx @@ -3,7 +3,7 @@ import { Select as SelectPrimitive } from "radix-ui"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"; function Select({ diff --git a/components/ui/separator.tsx b/packages/ui/src/separator.tsx similarity index 95% rename from components/ui/separator.tsx rename to packages/ui/src/separator.tsx index ee783606..d56c4219 100644 --- a/components/ui/separator.tsx +++ b/packages/ui/src/separator.tsx @@ -3,7 +3,7 @@ import * as SeparatorPrimitive from "@radix-ui/react-separator"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function Separator({ className, diff --git a/components/ui/sheet.tsx b/packages/ui/src/sheet.tsx similarity index 99% rename from components/ui/sheet.tsx rename to packages/ui/src/sheet.tsx index cd74d915..20cf478f 100644 --- a/components/ui/sheet.tsx +++ b/packages/ui/src/sheet.tsx @@ -5,7 +5,7 @@ import * as SheetPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; const Sheet = SheetPrimitive.Root; diff --git a/components/ui/sonner.tsx b/packages/ui/src/sonner.tsx similarity index 64% rename from components/ui/sonner.tsx rename to packages/ui/src/sonner.tsx index 02c954b1..41f29e71 100644 --- a/components/ui/sonner.tsx +++ b/packages/ui/src/sonner.tsx @@ -9,13 +9,34 @@ import { } from "lucide-react"; import { Toaster as Sonner, type ToasterProps } from "sonner"; import { useTheme } from "next-themes"; -import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { useEffect, useState } from "react"; + +const TOAST_POSITION_KEY = "toast-position"; + +type ToastPosition = "bottom-left" | "bottom-center" | "bottom-right"; + +const getInitialPosition = (): ToastPosition => { + if (typeof window === "undefined") return "bottom-right"; + const saved = window.localStorage.getItem(TOAST_POSITION_KEY); + if ( + saved === "bottom-left" || + saved === "bottom-center" || + saved === "bottom-right" + ) { + return saved; + } + return "bottom-right"; +}; const Toaster = ({ ...props }: ToasterProps) => { const { theme = "system" } = useTheme(); - const [toastPosition] = useLocalStorage< - "bottom-left" | "bottom-center" | "bottom-right" - >("toast-position", "bottom-right"); + const [toastPosition, setToastPosition] = useState( + "bottom-right", + ); + + useEffect(() => { + setToastPosition(getInitialPosition()); + }, []); return ( ) { return ( diff --git a/components/ui/switch.tsx b/packages/ui/src/switch.tsx similarity index 97% rename from components/ui/switch.tsx rename to packages/ui/src/switch.tsx index 3bf9946f..f06cb161 100644 --- a/components/ui/switch.tsx +++ b/packages/ui/src/switch.tsx @@ -3,7 +3,7 @@ import * as SwitchPrimitive from "@radix-ui/react-switch"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function Switch({ className, diff --git a/components/ui/tabs.tsx b/packages/ui/src/tabs.tsx similarity index 98% rename from components/ui/tabs.tsx rename to packages/ui/src/tabs.tsx index 10c310e8..2b200945 100644 --- a/components/ui/tabs.tsx +++ b/packages/ui/src/tabs.tsx @@ -3,7 +3,7 @@ import * as TabsPrimitive from "@radix-ui/react-tabs"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function Tabs({ className, diff --git a/components/ui/textarea.tsx b/packages/ui/src/textarea.tsx similarity index 96% rename from components/ui/textarea.tsx rename to packages/ui/src/textarea.tsx index c31e691b..f0fb562e 100644 --- a/components/ui/textarea.tsx +++ b/packages/ui/src/textarea.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { return ( diff --git a/components/ui/toast.tsx b/packages/ui/src/toast.tsx similarity index 99% rename from components/ui/toast.tsx rename to packages/ui/src/toast.tsx index edd4572e..765ce2bf 100644 --- a/components/ui/toast.tsx +++ b/packages/ui/src/toast.tsx @@ -5,7 +5,7 @@ import * as ToastPrimitives from "@radix-ui/react-toast"; import { X } from "lucide-react"; import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "./utils"; const ToastProvider = ToastPrimitives.Provider; diff --git a/components/ui/toaster.tsx b/packages/ui/src/toaster.tsx similarity index 89% rename from components/ui/toaster.tsx rename to packages/ui/src/toaster.tsx index 7d82ed55..d38613a5 100644 --- a/components/ui/toaster.tsx +++ b/packages/ui/src/toaster.tsx @@ -7,8 +7,8 @@ import { ToastProvider, ToastTitle, ToastViewport, -} from "@/components/ui/toast"; -import { useToast } from "@/components/ui/use-toast"; +} from "./toast"; +import { useToast } from "./use-toast"; export function Toaster() { const { toasts } = useToast(); diff --git a/components/ui/use-toast.tsx b/packages/ui/src/use-toast.tsx similarity index 98% rename from components/ui/use-toast.tsx rename to packages/ui/src/use-toast.tsx index b0a3cfb2..582118c4 100644 --- a/components/ui/use-toast.tsx +++ b/packages/ui/src/use-toast.tsx @@ -2,7 +2,7 @@ import * as React from "react"; -import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; +import type { ToastActionElement, ToastProps } from "./toast"; const TOAST_LIMIT = 5; const TOAST_REMOVE_DELAY = 5000; diff --git a/packages/ui/src/utils.ts b/packages/ui/src/utils.ts new file mode 100644 index 00000000..a5ef1935 --- /dev/null +++ b/packages/ui/src/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/tailwind.config.ts b/tailwind.config.ts deleted file mode 100644 index b3692b66..00000000 --- a/tailwind.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { Config } from "tailwindcss"; - -export default {} satisfies Config; diff --git a/turbo.json b/turbo.json new file mode 100644 index 00000000..4672105c --- /dev/null +++ b/turbo.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "dev": { + "cache": false, + "persistent": true + }, + "build": { + "dependsOn": [ + "^build" + ], + "outputs": [ + ".next/**", + "!.next/cache/**" + ], + "env": [ + "CLERK_SECRET_KEY", + "CLERK_FRONTEND_API", + "SALT", + "POSTGRES_URL", + "CRON_SECRET", + "ATPROTO_SESSION_SECRET" + ] + }, + "start": { + "cache": false, + "persistent": true + }, + "generate:locales": { + "outputs": [ + "src/locales.ts" + ] + }, + "generate:oauth-metadata": { + "outputs": [ + "public/oauth-client-metadata.json" + ] + } + } +} From f36a1d6639eadeee77c0bcd1f576f1bb54088202 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Sat, 28 Mar 2026 19:54:08 +0800 Subject: [PATCH 2/2] refactor(ds): restore decentralized auth and broaden signature compatibility --- apps/ds/app/api/blob/route.ts | 1 - apps/ds/app/api/share/[shareId]/route.ts | 9 +--- apps/ds/app/api/share/route.ts | 1 - apps/ds/lib/signature.ts | 41 ++++++++++++++----- .../app/(app)/share/[did]/[shareId]/page.tsx | 7 ---- apps/web/app/api/share/public/route.ts | 10 ----- apps/web/lib/ds-signed-request.ts | 5 --- 7 files changed, 32 insertions(+), 42 deletions(-) diff --git a/apps/ds/app/api/blob/route.ts b/apps/ds/app/api/blob/route.ts index ef124e1a..54b3bd45 100644 --- a/apps/ds/app/api/blob/route.ts +++ b/apps/ds/app/api/blob/route.ts @@ -8,7 +8,6 @@ function statusForError(error: unknown) { message.includes("Missing signature headers") || message.includes("Expired timestamp") || message.includes("Invalid signature") || - message.includes("Invalid app token") || message.includes("Failed to resolve DID") || message.includes("Missing DID public key") ) { diff --git a/apps/ds/app/api/share/[shareId]/route.ts b/apps/ds/app/api/share/[shareId]/route.ts index 65194000..4bcaeb5f 100644 --- a/apps/ds/app/api/share/[shareId]/route.ts +++ b/apps/ds/app/api/share/[shareId]/route.ts @@ -2,14 +2,7 @@ import { NextResponse } from "next/server"; import { ensureTables, pool } from "@/lib/db"; export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) { - const appToken = process.env.DS_APP_TOKEN; - if (!appToken) { - return NextResponse.json({ error: "DS_APP_TOKEN is not configured" }, { status: 500 }); - } - // Allow DS data retrieval only from trusted web app server. - if (request.headers.get("x-app-token") !== appToken) { - return NextResponse.json({ error: "Invalid app token" }, { status: 401 }); - } + void request; await ensureTables(); const { shareId } = await params; const result = await pool.query( diff --git a/apps/ds/app/api/share/route.ts b/apps/ds/app/api/share/route.ts index a495f136..bb38a430 100644 --- a/apps/ds/app/api/share/route.ts +++ b/apps/ds/app/api/share/route.ts @@ -8,7 +8,6 @@ function statusForError(error: unknown) { message.includes("Missing signature headers") || message.includes("Expired timestamp") || message.includes("Invalid signature") || - message.includes("Invalid app token") || message.includes("Failed to resolve DID") || message.includes("Missing DID public key") ) { diff --git a/apps/ds/lib/signature.ts b/apps/ds/lib/signature.ts index 3591e570..13d52aa9 100644 --- a/apps/ds/lib/signature.ts +++ b/apps/ds/lib/signature.ts @@ -16,6 +16,29 @@ function createPayload(method: string, path: string, timestamp: string, body: st return `${method.toUpperCase()}\n${path}\n${timestamp}\n${digest(body)}`; } +function trimInteger(bytes: Buffer) { + let i = 0; + while (i < bytes.length - 1 && bytes[i] === 0) i += 1; + let out = bytes.slice(i); + if (out[0] & 0x80) { + out = Buffer.concat([Buffer.from([0]), out]); + } + return out; +} + +function p1363ToDer(signature: Buffer) { + if (signature.length !== 64) return null; + const r = trimInteger(signature.subarray(0, 32)); + const s = trimInteger(signature.subarray(32, 64)); + const sequenceLength = 2 + r.length + 2 + s.length; + return Buffer.concat([ + Buffer.from([0x30, sequenceLength, 0x02, r.length]), + r, + Buffer.from([0x02, s.length]), + s, + ]); +} + async function resolveDidPublicKey(did: string) { const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`, { cache: "no-store", @@ -31,15 +54,6 @@ async function resolveDidPublicKey(did: string) { } export async function requireSignedRequest(request: Request, body = "") { - const appToken = process.env.DS_APP_TOKEN; - if (!appToken) { - throw new Error("DS_APP_TOKEN is not configured"); - } - const incomingToken = request.headers.get("x-app-token"); - if (incomingToken !== appToken) { - throw new Error("Invalid app token"); - } - const did = request.headers.get("x-did"); const timestamp = request.headers.get("x-timestamp"); const signatureHeader = request.headers.get("x-signature"); @@ -61,6 +75,11 @@ export async function requireSignedRequest(request: Request, body = "") { .replace(/^base64:/, "") .replace(/ /g, "+"); const signature = Buffer.from(encodedSignature, "base64url"); + const signatureCandidates = [signature]; + const derFromP1363 = p1363ToDer(signature); + if (derFromP1363) { + signatureCandidates.push(derFromP1363); + } let ok = false; const dpopJwkHeader = request.headers.get("x-dpop-jwk"); @@ -68,7 +87,9 @@ export async function requireSignedRequest(request: Request, body = "") { try { const jwk = JSON.parse(dpopJwkHeader); const key = createPublicKey({ key: jwk, format: "jwk" }); - ok = verifyNode("sha256", Buffer.from(payload, "utf8"), key, signature); + ok = signatureCandidates.some((candidate) => + verifyNode("sha256", Buffer.from(payload, "utf8"), key, candidate), + ); } catch { ok = false; } diff --git a/apps/web/app/(app)/share/[did]/[shareId]/page.tsx b/apps/web/app/(app)/share/[did]/[shareId]/page.tsx index 27855a47..fb7a6a0c 100644 --- a/apps/web/app/(app)/share/[did]/[shareId]/page.tsx +++ b/apps/web/app/(app)/share/[did]/[shareId]/page.tsx @@ -25,18 +25,11 @@ export default async function ShareByDidPage({ if (!ds) { notFound(); } - const appToken = process.env.DS_APP_TOKEN; - if (!appToken) { - notFound(); - } const shareRes = await fetch( `${ds.replace(/\/$/, "")}/api/share/${encodeURIComponent(shareId)}`, { cache: "no-store", - headers: { - "x-app-token": appToken, - }, }, ); diff --git a/apps/web/app/api/share/public/route.ts b/apps/web/app/api/share/public/route.ts index fff764a1..04e4a7ae 100644 --- a/apps/web/app/api/share/public/route.ts +++ b/apps/web/app/api/share/public/route.ts @@ -111,20 +111,10 @@ export async function GET(request: NextRequest) { | null = null; if (ds) { - const appToken = process.env.DS_APP_TOKEN; - if (!appToken) { - return NextResponse.json( - { error: "DS_APP_TOKEN is not configured" }, - { status: 500 }, - ); - } const dsRes = await fetch( `${ds.replace(/\/$/, "")}/api/share/${encodeURIComponent(id)}`, { cache: "no-store", - headers: { - "x-app-token": appToken, - }, }, ); if (dsRes.status === 404) { diff --git a/apps/web/lib/ds-signed-request.ts b/apps/web/lib/ds-signed-request.ts index 54837334..5be79f74 100644 --- a/apps/web/lib/ds-signed-request.ts +++ b/apps/web/lib/ds-signed-request.ts @@ -44,11 +44,6 @@ export async function signedDsFetch(params: { "x-signature": signature, "x-dpop-jwk": JSON.stringify(params.session.dpopPublicJwk), }; - const appToken = process.env.DS_APP_TOKEN; - if (!appToken) { - throw new Error("DS_APP_TOKEN is not configured"); - } - headers["x-app-token"] = appToken; if (params.method !== "GET") { headers["Content-Type"] = "application/json";