diff --git a/.gitignore b/.gitignore index 3a080fe9..bf238bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,6 @@ tf/plans litestream/config/* litestream/dbs/* - +wireguard.conf !**/**/.keep \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d25cbc81..f91cfd59 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -95,14 +95,16 @@ def redirect_component(page, props = {}) def route_component(route) T.unsafe(self).route_home if route.nil? + phone = session[:verified_phone] + u = current_user if u.nil? render json: { route: ROUTES[:HOME] } elsif !u.is_registration_complete - render json: { route: ROUTES[:REGISTRATION] } + render json: { route: ROUTES[:REGISTRATION], phone: } else Rails.logger.info "ServerRendering.route - Route to page - #{route}" - render json: { route: } + render json: { route:, phone: } end end diff --git a/app/controllers/users/webauthn/sessions_controller.rb b/app/controllers/users/webauthn/sessions_controller.rb index 0bb8364f..412b7d50 100644 --- a/app/controllers/users/webauthn/sessions_controller.rb +++ b/app/controllers/users/webauthn/sessions_controller.rb @@ -49,6 +49,7 @@ def callback stored_passkey.update!(sign_count: verified_webauthn_passkey.sign_count) sign_in(user) + session[:verified_phone] = user.phone if user.is_registration_complete T.unsafe(self).route_legislators else diff --git a/app/frontend/components/SwaySvg.tsx b/app/frontend/components/SwaySvg.tsx deleted file mode 100644 index d4fba5e0..00000000 --- a/app/frontend/components/SwaySvg.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { Image } from "react-bootstrap"; - -interface IProps extends Record { - src: string; - alt?: string; - style?: React.CSSProperties; - containerStyle?: React.CSSProperties; - handleClick?: (e: React.MouseEvent) => void; -} - -const SwaySvg: React.FC = ({ src, alt, style, handleClick }) => { - return {alt; -}; - -interface IIconProps { - src: string; - alt?: string; - style?: React.CSSProperties; - handleClick?: (e: React.MouseEvent) => void; -} - -export const SwaySvgIcon: React.FC = ({ src, alt, handleClick, style }) => { - return {alt}; -}; - -export default SwaySvg; diff --git a/app/frontend/components/bill/BillSummaryModal.tsx b/app/frontend/components/bill/BillSummaryModal.tsx index 7fc31cfe..811828b4 100644 --- a/app/frontend/components/bill/BillSummaryModal.tsx +++ b/app/frontend/components/bill/BillSummaryModal.tsx @@ -7,6 +7,7 @@ import ButtonUnstyled from "app/frontend/components/ButtonUnstyled"; import SuspenseFullScreen from "app/frontend/components/dialogs/SuspenseFullScreen"; import OrganizationIcon from "app/frontend/components/organizations/OrganizationIcon"; import BillSummaryMarkdown from "./BillSummaryMarkdown"; +import { IS_MOBILE_PHONE } from "app/frontend/sway_constants"; const DialogWrapper = lazy(() => import("../dialogs/DialogWrapper")); interface IProps { @@ -65,8 +66,8 @@ const BillSummaryModal: React.FC = ({ setSelectedOrganization(undefined)} style={{ margin: 0 }} > diff --git a/app/frontend/components/dialogs/FileUploadModal.tsx b/app/frontend/components/dialogs/FileUploadModal.tsx index 9e856719..5bc5c334 100644 --- a/app/frontend/components/dialogs/FileUploadModal.tsx +++ b/app/frontend/components/dialogs/FileUploadModal.tsx @@ -33,8 +33,9 @@ const FileUploadModal: React.FC = ({ fileName, currentFilePath, accept, if (!file) return; const cacheBust = new Date().valueOf(); + const name = `${fileName}-${cacheBust}.${file.name.split(".").last()}`; - createPresignedFileUpload({ name: `${fileName}-${cacheBust}`, mime_type: file.type }) + createPresignedFileUpload({ name, mime_type: file.type }) .then((fileUpload) => { if (!fileUpload) return; diff --git a/app/frontend/components/organizations/OrganizationIcon.tsx b/app/frontend/components/organizations/OrganizationIcon.tsx index 31cc4e60..3daecd7a 100644 --- a/app/frontend/components/organizations/OrganizationIcon.tsx +++ b/app/frontend/components/organizations/OrganizationIcon.tsx @@ -1,5 +1,6 @@ import { SWAY_ASSETS_BUCKET_BASE_URL } from "app/frontend/sway_constants/google_cloud_storage"; -import { useCallback, useState } from "react"; + +import { useCallback, useMemo, useState } from "react"; import { Image } from "react-bootstrap"; import { sway } from "sway"; @@ -11,21 +12,46 @@ interface IProps { const DEFAULT_ICON_PATH = "/images/sway-us-light.png"; const OrganizationIcon: React.FC = ({ organization, maxWidth }) => { - const [icon, setIcon] = useState(organization?.iconPath || DEFAULT_ICON_PATH); + const [isError, setError] = useState(false); - const handleError = useCallback(() => setIcon(DEFAULT_ICON_PATH), []); + const icon = useMemo( + () => (organization?.iconPath ? organization.iconPath : DEFAULT_ICON_PATH), + [organization?.iconPath], + ); + const src = useMemo( + () => + icon === DEFAULT_ICON_PATH + ? DEFAULT_ICON_PATH + : `${SWAY_ASSETS_BUCKET_BASE_URL}${(icon.startsWith("/") ? icon : "/" + icon).replace("//", "/")}`, + [icon], + ); - if (icon === DEFAULT_ICON_PATH) { - return ; + const handleError = useCallback(() => { + setError(true); + }, []); + + if (isError) { + return ( +
+ +
{organization?.name}
+
+ ); } + return ( - {organization?.name} +
+ {""} +
{organization?.name}
+
); }; diff --git a/app/frontend/hooks/useAxios.ts b/app/frontend/hooks/useAxios.ts index d089804f..9ae6d074 100644 --- a/app/frontend/hooks/useAxios.ts +++ b/app/frontend/hooks/useAxios.ts @@ -5,6 +5,7 @@ import { sway } from "sway"; import { DEFAULT_ERROR_MESSAGE, handleError, logDev, notify } from "../sway_utils"; import { isFailedRequest } from "../sway_utils/http"; import { useCancellable } from "./useCancellable"; +import { removeNonDigits } from "app/frontend/sway_utils/phone"; type TPayload = Record | Record | FormData; @@ -18,6 +19,7 @@ type TBodyRequest = ( interface IRoutableResponse extends Record { route?: string; + phone?: string; } /* @@ -49,6 +51,11 @@ const handleAxiosError = (ex: AxiosError | Error) => { } }; +const handleRoutedResponse = (result: IRoutableResponse) => { + result.phone && localStorage.setItem("@sway/phone", removeNonDigits(result.phone)); + result.route && router.visit(result.route); +}; + /* * * SECURE/SESSION METHODS @@ -113,7 +120,7 @@ export const useAxiosGet = ( // message: (result as sway.IValidationResult)?.message || DEFAULT_ERROR_MESSAGE, }); } else if ("route" in result && result.route) { - return router.visit(result.route); + return handleRoutedResponse(result); } else if (options?.defaultValue) { setItems(options.defaultValue); } @@ -181,7 +188,7 @@ export const useAxiosPost = ( if (!result) { return null; } else if ("route" in result && result.route) { - router.visit(result.route); + handleRoutedResponse(result); return null; } else if (isFailedRequest(result)) { if (options?.notifyOnValidationResultFailure) { @@ -376,7 +383,7 @@ export const useAxios_NOT_Authenticated_GET = ( if (!result) { return null; } else if ("route" in result && result.route) { - router.visit(result.route); + handleRoutedResponse(result); return null; } else if (isFailedRequest(result)) { if (options?.notifyOnValidationResultFailure) { @@ -448,7 +455,7 @@ export const useAxios_NOT_Authenticated_POST_PUT = if (!result) { return null; } else if ("route" in result && result.route) { - router.visit(result.route); + handleRoutedResponse(result); return null; } else if (isFailedRequest(result)) { if (notifyOnValidationResultFailure) { diff --git a/app/frontend/pages/Passkey.tsx b/app/frontend/pages/Passkey.tsx index 507fff8a..9e3b140d 100644 --- a/app/frontend/pages/Passkey.tsx +++ b/app/frontend/pages/Passkey.tsx @@ -3,7 +3,7 @@ import { useDispatch } from "react-redux"; import { sway } from "sway"; import { setUser } from "app/frontend/redux/actions/userActions"; -import { logDev, notify } from "app/frontend/sway_utils"; +import { handleError, logDev, notify } from "app/frontend/sway_utils"; import { PHONE_INPUT_TRANSFORMER, isValidPhoneNumber } from "app/frontend/sway_utils/phone"; import { ErrorMessage, Field, FieldAttributes, Form, Formik, FormikProps } from "formik"; import { Form as BootstrapForm, Button } from "react-bootstrap"; @@ -14,6 +14,7 @@ import { useSendPhoneVerification } from "app/frontend/hooks/authentication/phon import { useWebAuthnAuthentication } from "app/frontend/hooks/authentication/useWebAuthnAuthentication"; import { AxiosError } from "axios"; import { Animate } from "react-simple-animate"; +import Turnstile, { useTurnstile } from "react-turnstile"; import * as yup from "yup"; interface ISigninValues { @@ -41,6 +42,33 @@ const Passkey: React.FC = () => { const dispatch = useDispatch(); + const turnstile = useTurnstile(); + const [turnstileVerified, setTurnstileVerified] = useState(false); + const handleTurnstileVerify = useCallback( + (token: string) => { + fetch("https://turnstile.sway.vote", { + method: "POST", + body: JSON.stringify({ token }), + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => res.json()) + .then((j) => { + setTurnstileVerified(j.success); + if (!j.success) { + setTurnstileVerified(false); + handleError(new Error(j.message)); + } + }) + .catch((e) => { + console.error(e); + turnstile.reset(); + }); + }, + [turnstile], + ); + const onAuthenticated = useCallback( (user: sway.IUser) => { if (!user) return; @@ -75,10 +103,12 @@ const Passkey: React.FC = () => { const handleSubmit = useCallback( async ({ phone, code }: { phone: string; code?: string }) => { + if (!turnstileVerified) return; + if (code && isConfirmingPhone) { confirmPhoneVerification(phone, code); } else { - startAuthentication(phone) + return startAuthentication(phone) .then((publicKey) => { if (typeof publicKey === "boolean") { if (!publicKey) { @@ -106,7 +136,14 @@ const Passkey: React.FC = () => { }); } }, - [confirmPhoneVerification, isConfirmingPhone, sendPhoneVerification, startAuthentication, verifyAuthentication], + [ + turnstileVerified, + isConfirmingPhone, + confirmPhoneVerification, + startAuthentication, + verifyAuthentication, + sendPhoneVerification, + ], ); const handleCancel = useCallback((e: React.MouseEvent) => { @@ -145,8 +182,18 @@ const Passkey: React.FC = () => { + +
+ +
-
 
{
-
diff --git a/config/routes.rb b/config/routes.rb index 1d4f82ba..e3e7939b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,7 @@ # typed: strict Rails.application.routes.draw do + default_url_options protocol: :https # ServerRendering diff --git a/fly.toml b/fly.toml index 8b1dd6b0..d673d0ca 100644 --- a/fly.toml +++ b/fly.toml @@ -14,7 +14,7 @@ console_command = '/rails/bin/rails console' [[mounts]] source = 'prodswaysqlite' - destination = '/storage' + destination = '/rails/storage' [http_service] internal_port = 3000 diff --git a/lib/sway_google_cloud_storage.rb b/lib/sway_google_cloud_storage.rb index 6fd21361..61b0b17f 100644 --- a/lib/sway_google_cloud_storage.rb +++ b/lib/sway_google_cloud_storage.rb @@ -6,6 +6,7 @@ # https://cloud.google.com/storage/docs/access-control/signing-urls-with-helpers#client-libraries # private writes, public reads # https://cloud.google.com/storage/docs/access-control/making-data-public + module SwayGoogleCloudStorage extend ActiveSupport::Concern diff --git a/lib/tasks/sway.rake b/lib/tasks/sway.rake index 27fed6f7..0f3ab9b1 100644 --- a/lib/tasks/sway.rake +++ b/lib/tasks/sway.rake @@ -1,3 +1,7 @@ +require 'logger' + +require 'google/apis/core' + require_relative '../sway_google_cloud_storage' namespace :sway do @@ -5,17 +9,32 @@ namespace :sway do desc 'Sets up a remote volume with files downloaded from a Google Cloud bucket' task volume_setup: :environment do - download_directory(bucket_name: 'sway-sqlite', bucket_directory_name: 'seeds', local_directory_name: 'storage') - download_directory(bucket_name: 'sway-sqlite', bucket_directory_name: 'geojson', local_directory_name: 'storage') + # https://github.com/googleapis/google-cloud-ruby/tree/main/google-cloud-storage#enabling-logging + google_logger = Logger.new($stdout) + google_logger.level = Logger::INFO + Google::Apis.logger = google_logger + + download_directory(bucket_name: 'sway-sqlite', bucket_directory_name: 'seeds', local_directory_name: '/rails/storage') + download_directory(bucket_name: 'sway-sqlite', bucket_directory_name: 'geojson', local_directory_name: '/rails/storage') + + backup_db + end + + def backup_db(attempt = 0) + puts "sway.rake -> backup_db attempt #{attempt}" if File.exist? 'storage/production.sqlite3' - puts 'Uploading production.db to google storage as backup.' + puts 'sway.rake -> Uploading production.sqlite3 to google storage as backup.' upload_file(bucket_name: 'sway-sqlite', bucket_file_path: 'production.sqlite3', - local_file_path: 'storage/production.sqlite3') - else - puts 'Getting production.db from google storage backup.' - download_file(bucket_name: 'sway-sqlite', bucket_file_path: 'production.sqlite3', - local_file_path: 'storage/production.sqlite3') + local_file_path: '/rails/storage/production.sqlite3') + elsif attempt < 5 + sleep 1 + backup_db(attempt + 1) + # else + # puts 'Getting production.sqlite3 from google storage backup.' + # download_file(bucket_name: 'sway-sqlite', bucket_file_path: 'production.sqlite3', + # local_file_path: 'storage/production.sqlite3') end end + end diff --git a/package-lock.json b/package-lock.json index da81facd..dbff31fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "react-simple-animate": "^3.5.2", "react-social-icons": "^6.16.0", "react-textarea-autosize": "^8.5.3", + "react-turnstile": "^1.1.3", "redux": "^5.0.1", "remark-gfm": "^4.0.0", "use-places-autocomplete": "^4.0.1", @@ -7603,6 +7604,15 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-turnstile": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/react-turnstile/-/react-turnstile-1.1.3.tgz", + "integrity": "sha512-nWgsnN2IgDSj91BK2iF/9GMVRJK0KPuDDxgnhs4o/7zfIRfyZG/ALWs+JJ8unW84MtFXpcEiPsookkd/FIb4aw==", + "peerDependencies": { + "react": ">= 16.13.1", + "react-dom": ">= 16.13.1" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", diff --git a/package.json b/package.json index 2b39685b..0792d828 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "react-simple-animate": "^3.5.2", "react-social-icons": "^6.16.0", "react-textarea-autosize": "^8.5.3", + "react-turnstile": "^1.1.3", "redux": "^5.0.1", "remark-gfm": "^4.0.0", "use-places-autocomplete": "^4.0.1", diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 8a20c819..40e1491c 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -16,13 +16,13 @@ export $(cat .env.github | xargs) SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:clobber RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:clobber -if [[ "$1" = "google" ]]; then +gcloud storage cp --recursive $(pwd)/storage/geojson gs://sway-sqlite/ - ./litestream/replicate.sh +gcloud storage cp --recursive $(pwd)/storage/seeds/data gs://sway-sqlite/seeds/ - gcloud storage cp --recursive $(pwd)/storage/geojson gs://sway-sqlite/ +if [[ "$1" = "google" ]]; then - gcloud storage cp --recursive $(pwd)/storage/seeds/data gs://sway-sqlite/seeds/ + ./litestream/replicate.sh # Cloud Run requires AMD64 images docker buildx build . -f docker/dockerfiles/production.dockerfile --platform linux/amd64 -t us-central1-docker.pkg.dev/sway-421916/sway/sway:latest --push --compress