From 3ef183cc2133c269c014edf82884693160a2c542 Mon Sep 17 00:00:00 2001 From: Paul Cretu Date: Tue, 17 Sep 2024 09:04:34 +0000 Subject: [PATCH 1/3] Add support for playing streams from CDN --- ui/.env.development | 3 +- ui/.env.production | 1 + ui/src/components/DetectionsTable.tsx | 2 +- ui/src/components/Player/DetectionsPlayer.tsx | 27 +++++---- ui/src/components/Player/Player.tsx | 24 +++++--- ui/src/components/layouts/MapLayout.tsx | 4 +- ui/src/graphql/generated/index.ts | 4 ++ ui/src/graphql/queries/getCandidate.graphql | 1 + ui/src/graphql/queries/getFeed.graphql | 1 + ui/src/hooks/useTimestampFetcher.ts | 55 +++++++------------ ui/src/utils/urls.ts | 11 ++++ 11 files changed, 76 insertions(+), 57 deletions(-) create mode 100644 ui/src/utils/urls.ts diff --git a/ui/.env.development b/ui/.env.development index 4ae376b91..a35810d85 100644 --- a/ui/.env.development +++ b/ui/.env.development @@ -1,3 +1,4 @@ NEXT_PUBLIC_GQL_ENDPOINT='http://localhost:${SERVER_PORT:-4000}/graphql' NEXT_PUBLIC_SOCKET_ENDPOINT='ws://localhost:${SERVER_PORT:-4000}/socket' -NEXT_PUBLIC_S3_BUCKET='dev-streaming-orcasound-net' \ No newline at end of file +NEXT_PUBLIC_S3_BUCKET='audio-orcasound-net' +NEXT_PUBLIC_AUDIO_BASE_URL='https://audio.orcasound.net' \ No newline at end of file diff --git a/ui/.env.production b/ui/.env.production index 521a553bb..35a8654ec 100644 --- a/ui/.env.production +++ b/ui/.env.production @@ -1,4 +1,5 @@ NEXT_PUBLIC_GQL_ENDPOINT=$GQL_ENDPOINT NEXT_PUBLIC_SOCKET_ENDPOINT=$SOCKET_ENDPOINT NEXT_PUBLIC_S3_BUCKET=$S3_BUCKET +NEXT_PUBLIC_AUDIO_BASE_URL=$AUDIO_BASE_URL NEXT_PUBLIC_GA_ID=$GOOGLE_ANALYTICS_ID \ No newline at end of file diff --git a/ui/src/components/DetectionsTable.tsx b/ui/src/components/DetectionsTable.tsx index 8ba673ed7..037cf070c 100644 --- a/ui/src/components/DetectionsTable.tsx +++ b/ui/src/components/DetectionsTable.tsx @@ -42,7 +42,7 @@ export default function DetectionsTable({ onDetectionUpdate, }: { detections: Detection[]; - feed: Pick; + feed: Pick; candidate: Pick; onDetectionUpdate: () => void; }) { diff --git a/ui/src/components/Player/DetectionsPlayer.tsx b/ui/src/components/Player/DetectionsPlayer.tsx index b0a8161f4..0b031ef7f 100644 --- a/ui/src/components/Player/DetectionsPlayer.tsx +++ b/ui/src/components/Player/DetectionsPlayer.tsx @@ -5,8 +5,12 @@ import dynamic from "next/dynamic"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Feed } from "@/graphql/generated"; -import { getHlsURI } from "@/hooks/useTimestampFetcher"; import { mobileOnly } from "@/styles/responsive"; +import { + getAudioBaseUrlFromBucket, + getHlsUrl, + getNodeRootUrl, +} from "@/utils/urls"; import { type PlayerStatus } from "./Player"; import PlayPauseButton from "./PlayPauseButton"; @@ -24,7 +28,7 @@ export function DetectionsPlayer({ endOffset, onAudioPlay, }: { - feed: Pick; + feed: Pick; marks: { label: string; value: number }[]; timestamp: number; startOffset: number; @@ -38,7 +42,10 @@ export function DetectionsPlayer({ const sliderMax = endOffset - startOffset; const sliderValue = playerTime - startOffset; - const hlsURI = getHlsURI(feed.bucket, feed.nodeName, timestamp); + const audioBaseUrl = + feed.cloudfrontUrl ?? getAudioBaseUrlFromBucket(feed.bucket); + const nodeRootUrl = getNodeRootUrl(audioBaseUrl, feed.nodeName); + const hlsUrl = getHlsUrl(nodeRootUrl, timestamp); const playerOptions = useMemo( () => ({ @@ -55,16 +62,16 @@ export function DetectionsPlayer({ }, sources: [ { - // If hlsURI isn't set, use a dummy URI to trigger an error - // The dummy URI doesn't actually exist, it should return 404 + // If hlsUrl isn't set, use a dummy URL to trigger an error + // The dummy URL doesn't actually exist, it should return 404 // This is the only way to get videojs to throw an error, otherwise // it just won't initialize (if src is undefined/null/empty)) - src: hlsURI ?? `${feed.nodeName}/404`, + src: hlsUrl ?? `${feed.nodeName}/404`, type: "application/x-mpegurl", }, ], }), - [hlsURI, feed?.nodeName], + [hlsUrl, feed?.nodeName], ); const handleReady = useCallback( @@ -127,14 +134,14 @@ export function DetectionsPlayer({ }; useEffect(() => { - if (process.env.NODE_ENV === "development" && hlsURI) { - console.log(`New stream instance: ${hlsURI}`); + if (process.env.NODE_ENV === "development" && hlsUrl) { + console.log(`New stream instance: ${hlsUrl}`); } return () => { setPlayerStatus("idle"); }; - }, [hlsURI, feed.nodeName]); + }, [hlsUrl, feed.nodeName]); const handleSliderChange = ( _e: Event, diff --git a/ui/src/components/Player/Player.tsx b/ui/src/components/Player/Player.tsx index f9aea1eb0..c7d965828 100644 --- a/ui/src/components/Player/Player.tsx +++ b/ui/src/components/Player/Player.tsx @@ -15,6 +15,7 @@ import { } from "@/styles/responsive"; import { analytics } from "@/utils/analytics"; import { useIsRelativeOverflow } from "@/utils/layout"; +import { getAudioBaseUrlFromBucket } from "@/utils/urls"; import { TitlePopover } from "../TitlePopover"; import DetectionButton from "./DetectionButton"; @@ -41,13 +42,18 @@ export default function Player({ | "imageUrl" | "thumbUrl" | "bucket" + | "cloudfrontUrl" >; }) { const [playerStatus, setPlayerStatus] = useState("idle"); const playerRef = useRef(null); - const { timestamp, hlsURI } = useTimestampFetcher( - currentFeed?.bucket, + const audioBaseUrl = + currentFeed?.cloudfrontUrl ?? + (currentFeed?.bucket && getAudioBaseUrlFromBucket(currentFeed.bucket)); + + const { timestamp, hlsUrl } = useTimestampFetcher( + audioBaseUrl, currentFeed?.nodeName, ); @@ -91,17 +97,17 @@ export default function Player({ sources: currentFeed?.nodeName ? [ { - // If hlsURI isn't set, use a dummy URI to trigger an error - // The dummy URI doesn't actually exist, it should return 404 + // If hlsUrl isn't set, use a dummy URL to trigger an error + // The dummy URL doesn't actually exist, it should return 404 // This is the only way to get videojs to throw an error, otherwise // it just won't initialize (if src is undefined/null/empty)) - src: hlsURI ?? `${currentFeed?.nodeName}/404`, + src: hlsUrl ?? `${currentFeed?.nodeName}/404`, type: "application/x-mpegurl", }, ] : [], }), - [hlsURI, currentFeed?.nodeName, currentFeed?.imageUrl], + [hlsUrl, currentFeed?.nodeName, currentFeed?.imageUrl], ); const updateMediaSession = useCallback( @@ -161,14 +167,14 @@ export default function Player({ }; useEffect(() => { - if (process.env.NODE_ENV === "development" && hlsURI) { - console.log(`New stream instance: ${hlsURI}`); + if (process.env.NODE_ENV === "development" && hlsUrl) { + console.log(`New stream instance: ${hlsUrl}`); } return () => { setPlayerStatus("idle"); }; - }, [hlsURI, currentFeed?.nodeName]); + }, [hlsUrl, currentFeed?.nodeName]); return ( ({ name: feedSlug, slug: feedSlug, nodeName: feedSlug, - // TODO: pass in bucket from dynamic feed instead of env/hardcoding + // TODO: pass in bucket and cloudfront from dynamic feed instead of env/hardcoding bucket: process.env.NEXT_PUBLIC_S3_BUCKET ?? "audio-orcasound-net", + cloudfrontUrl: + process.env.NEXT_PUBLIC_AUDIO_BASE_URL ?? "audio.orcasound.net", // TODO: figure out which coordinates to use for dynamic feeds latLng: { lat: 47.6, lng: -122.3 }, }); diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index 8b341b673..fc68352f3 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -2206,6 +2206,7 @@ export type CandidateQuery = { name: string; nodeName: string; bucket: string; + cloudfrontUrl?: string | null; }; detections: Array<{ __typename?: "Detection"; @@ -2254,6 +2255,7 @@ export type FeedQuery = { imageUrl?: string | null; mapUrl?: string | null; bucket: string; + cloudfrontUrl?: string | null; latLng: { __typename?: "LatLng"; lat: number; lng: number }; }; }; @@ -2923,6 +2925,7 @@ export const CandidateDocument = ` name nodeName bucket + cloudfrontUrl } detections { id @@ -3041,6 +3044,7 @@ export const FeedDocument = ` imageUrl mapUrl bucket + cloudfrontUrl } } `; diff --git a/ui/src/graphql/queries/getCandidate.graphql b/ui/src/graphql/queries/getCandidate.graphql index c8e709245..f083121a2 100644 --- a/ui/src/graphql/queries/getCandidate.graphql +++ b/ui/src/graphql/queries/getCandidate.graphql @@ -12,6 +12,7 @@ query candidate($id: ID!) { name nodeName bucket + cloudfrontUrl } detections { id diff --git a/ui/src/graphql/queries/getFeed.graphql b/ui/src/graphql/queries/getFeed.graphql index 93a9d36af..cadb78db0 100644 --- a/ui/src/graphql/queries/getFeed.graphql +++ b/ui/src/graphql/queries/getFeed.graphql @@ -13,5 +13,6 @@ query feed($slug: String!) { imageUrl mapUrl bucket + cloudfrontUrl } } diff --git a/ui/src/hooks/useTimestampFetcher.ts b/ui/src/hooks/useTimestampFetcher.ts index f66be190d..f3bdc9f51 100644 --- a/ui/src/hooks/useTimestampFetcher.ts +++ b/ui/src/hooks/useTimestampFetcher.ts @@ -1,19 +1,6 @@ import { useEffect, useState } from "react"; -if (!process.env.NEXT_PUBLIC_S3_BUCKET) { - throw new Error("NEXT_PUBLIC_S3_BUCKET is not set"); -} - -const getBucketBase = (bucket: string) => `https://${bucket}.s3.amazonaws.com`; - -const getTimestampURI = (bucket: string, nodeName: string) => - `${getBucketBase(bucket)}/${nodeName}/latest.txt`; - -export const getHlsURI = ( - bucket: string, - nodeName: string, - timestamp: number, -) => `${getBucketBase(bucket)}/${nodeName}/hls/${timestamp}/live.m3u8`; +import { getHlsUrl, getLatestTimestampUrl, getNodeRootUrl } from "@/utils/urls"; /** * @typedef {Object} TimestampFetcherOptions @@ -24,41 +11,39 @@ export const getHlsURI = ( /** * @typedef {Object} TimestampFetcherResult * @property {number} timestamp The latest timestamp - * @property {string} hlsURI The URI to the latest HLS stream - * @property {string} awsConsoleURI The URI to the AWS console for the latest HLS stream + * @property {string} hlsUrl The URL to the latest HLS stream */ /** * Starts a timer that fetches the latest timestamp from a feed - * @param {string} bucket The bucket name of the node - * @param {string} nodeName The name of the feed to fetch from, as defined in the S3 bucket + * @param {string} audioBaseUrl The base URL for the audio feed + * @param {string} nodeName The name of the feed to fetch from * @param {TimestampFetcherOptions} options Callbacks for when the fetcher starts and stops - * @returns {TimestampFetcherResult} The latest timestamp, HLS URI, and AWS console URI + * @returns {TimestampFetcherResult} The latest timestamp and HLS URL */ export function useTimestampFetcher( - bucket?: string, + audioBaseUrl?: string, nodeName?: string, { onStart, onStop }: { onStart?: () => void; onStop?: () => void } = {}, ) { const [timestamp, setTimestamp] = useState(); - const hlsURI = - nodeName && bucket && timestamp - ? getHlsURI(bucket, nodeName, timestamp) - : undefined; - const awsConsoleURI = - nodeName && bucket && timestamp - ? `https://s3.console.aws.amazon.com/s3/buckets/${bucket}/${nodeName}/hls/${timestamp}/` + const nodeRootUrl = + audioBaseUrl && nodeName + ? getNodeRootUrl(audioBaseUrl, nodeName) : undefined; + const hlsUrl = + nodeRootUrl && timestamp ? getHlsUrl(nodeRootUrl, timestamp) : undefined; + useEffect(() => { let currentXhr: XMLHttpRequest | undefined; let intervalId: NodeJS.Timeout | undefined; - const fetchTimestamp = (timestampURI: string) => { + const fetchTimestamp = (timestampUrl: string) => { const xhr = new XMLHttpRequest(); currentXhr = xhr; - xhr.open("GET", timestampURI); + xhr.open("GET", timestampUrl); xhr.onload = () => { if (xhr.status === 200) { const newTimestamp = Number(xhr.responseText.trim()); @@ -71,13 +56,13 @@ export function useTimestampFetcher( }; const startFetcher = () => { - if (!nodeName || !bucket) return; - const timestampURI = getTimestampURI(bucket, nodeName); + if (!nodeRootUrl) return; + const timestampUrl = getLatestTimestampUrl(nodeRootUrl); onStart?.(); - fetchTimestamp(timestampURI); + fetchTimestamp(timestampUrl); const newIntervalId = setInterval( - () => fetchTimestamp(timestampURI), + () => fetchTimestamp(timestampUrl), 10000, ); intervalId = newIntervalId; @@ -94,7 +79,7 @@ export function useTimestampFetcher( stopFetcher(); onStop?.(); }; - }, [nodeName, onStart, onStop, bucket]); + }, [nodeRootUrl, onStart, onStop]); - return { timestamp, hlsURI, awsConsoleURI }; + return { timestamp, hlsUrl }; } diff --git a/ui/src/utils/urls.ts b/ui/src/utils/urls.ts new file mode 100644 index 000000000..cabc8d6d1 --- /dev/null +++ b/ui/src/utils/urls.ts @@ -0,0 +1,11 @@ +export const getAudioBaseUrlFromBucket = (bucket: string) => + `https://${bucket}.s3.amazonaws.com`; + +export const getNodeRootUrl = (audioBaseUrl: string, nodeName: string) => + `${audioBaseUrl}/${nodeName}`; + +export const getLatestTimestampUrl = (nodeRootUrl: string) => + `${nodeRootUrl}/latest.txt`; + +export const getHlsUrl = (nodeRootUrl: string, timestamp: number) => + `${nodeRootUrl}/hls/${timestamp}/live.m3u8`; From c4818537c2a7705586f1e55cf1346881e3b1db2e Mon Sep 17 00:00:00 2001 From: Paul Cretu Date: Tue, 17 Sep 2024 09:04:50 +0000 Subject: [PATCH 2/3] Update seeds --- server/priv/repo/seeds.exs | 82 ++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/server/priv/repo/seeds.exs b/server/priv/repo/seeds.exs index 60154d84b..15849594f 100644 --- a/server/priv/repo/seeds.exs +++ b/server/priv/repo/seeds.exs @@ -13,53 +13,75 @@ require Ash.Query feeds = [ - # %{ - # lat_lng_string: "48.5583362, -123.1735774", - # name: "Orcasound Lab (Haro Strait)", - # node_name: "rpi_orcasound_lab", - # slug: "orcasound-lab", - # bucket: "dev-streaming-orcasound-net", - # bucket_region: "us-west-2", - # }, + %{ + lat_lng_string: "48.5583362, -123.1735774", + name: "Orcasound Lab", + node_name: "rpi_orcasound_lab", + slug: "orcasound-lab", + bucket: "audio-orcasound-net", + bucket_region: "us-west-2", + cloudfront_url: "https://audio.orcasound.net", + image_url: "https://s3-us-west-2.amazonaws.com/orcasite/rpi_orcasound_lab/thumbnail.png" + }, %{ lat_lng_string: "47.34922, -122.32512", name: "MaST Center Aquarium", node_name: "rpi_mast_center", slug: "mast-center", - bucket: "dev-streaming-orcasound-net", - bucket_region: "us-west-2" + bucket: "audio-orcasound-net", + bucket_region: "us-west-2", + cloudfront_url: "https://audio.orcasound.net", + image_url: "https://s3-us-west-2.amazonaws.com/orcasite/rpi_mast_center/thumbnail.png" + }, + %{ + lat_lng_string: "48.0336664, -122.6040035", + name: "Bush Point", + node_name: "rpi_bush_point", + slug: "bush-point", + bucket: "audio-orcasound-net", + bucket_region: "us-west-2", + cloudfront_url: "https://audio.orcasound.net", + image_url: "https://s3-us-west-2.amazonaws.com/orcasite/rpi_bush_point/thumbnail.png" + }, + %{ + lat_lng_string: "48.135743, -122.760614", + name: "Port Townsend", + node_name: "rpi_port_townsend", + slug: "port-townsend", + bucket: "audio-orcasound-net", + bucket_region: "us-west-2", + cloudfront_url: "https://audio.orcasound.net", + image_url: "https://s3-us-west-2.amazonaws.com/orcasite/rpi_port_townsend/thumbnail.png" }, - # %{ - # lat_lng_string: "48.0336664, -122.6040035", - # name: "Bush Point", - # node_name: "rpi_bush_point", - # slug: "bush-point", - # bucket: "dev-streaming-orcasound-net", - # bucket_region: "us-west-2", - # }, - # %{ - # lat_lng_string: "48.135743, -122.760614", - # name: "Port Townsend", - # node_name: "rpi_port_townsend", - # slug: "port-townsend", - # bucket: "dev-streaming-orcasound-net", - # bucket_region: "us-west-2", - # }, %{ lat_lng_string: "47.86497296593844, -122.33393605795372", name: "Sunset Bay", node_name: "rpi_sunset_bay", slug: "sunset-bay", - bucket: "dev-streaming-orcasound-net", - bucket_region: "us-west-2" + bucket: "audio-orcasound-net", + bucket_region: "us-west-2", + cloudfront_url: "https://audio.orcasound.net", + image_url: "https://s3-us-west-2.amazonaws.com/orcasite/rpi_sunset_bay/thumbnail.png" }, %{ lat_lng_string: "48.591294, -123.058779", name: "North San Juan Channel", node_name: "rpi_north_sjc", slug: "north-sjc", - bucket: "dev-streaming-orcasound-net", - bucket_region: "us-west-2" + bucket: "audio-orcasound-net", + bucket_region: "us-west-2", + cloudfront_url: "https://audio.orcasound.net", + image_url: "https://s3-us-west-2.amazonaws.com/orcasite/rpi_north_sjc/thumbnail.png" + }, + %{ + lat_lng_string: "47.388383, -122.37267", + name: "Point Robinson", + node_name: "rpi_point_robinson", + slug: "point-robinson", + bucket: "audio-orcasound-net", + bucket_region: "us-west-2", + cloudfront_url: "https://audio.orcasound.net", + image_url: "https://s3-us-west-2.amazonaws.com/orcasite/rpi_point_robinson/thumbnail.png" } ] From 45f172b83c3b1292ef232160615c19a442a1d528 Mon Sep 17 00:00:00 2001 From: Paul Cretu Date: Thu, 19 Sep 2024 02:38:16 +0000 Subject: [PATCH 3/3] Add check to prevent request forgery --- ui/src/components/layouts/MapLayout.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ui/src/components/layouts/MapLayout.tsx b/ui/src/components/layouts/MapLayout.tsx index ac3d0b62e..242717ff7 100644 --- a/ui/src/components/layouts/MapLayout.tsx +++ b/ui/src/components/layouts/MapLayout.tsx @@ -33,19 +33,23 @@ const feedFromSlug = (feedSlug: string) => ({ function MapLayout({ children }: { children: ReactNode }) { const [drawerOpen, setDrawerOpen] = useState(true); const router = useRouter(); - const slug = router.query.feed as string; + const slugFromQuery = router.query.feed as string; + + const feeds = useFeedsQuery().data?.feeds ?? []; + // find the slug in the list of feeds to sanitize user provided query and prevent request forgery + const slug = feeds.find((feed) => feed.slug === slugFromQuery)?.slug; const isDynamic = router.asPath.split("/")[1] === "dynamic"; // don't make feed request if there's no feed slug or is dynamic const feedFromQuery = useFeedQuery( - { slug: slug }, + // slug is guaranteed to be non-null because of the `enabled` conditional below, but TS doesn't pick it up + { slug: slug! }, // use non-null assertion operator { enabled: !!slug || isDynamic }, ).data?.feed; - const feed = isDynamic ? feedFromSlug(slug) : feedFromQuery; + const feed = isDynamic ? feedFromSlug(slugFromQuery) : feedFromQuery; const [currentFeed, setCurrentFeed] = useState(feed); const [map, setMap] = useState(); - const feeds = useFeedsQuery().data?.feeds ?? []; // update the currentFeed only if there's a new feed useEffect(() => {