Skip to content

Commit f848c5d

Browse files
authored
Merge pull request #315 from KenEucker/develop
More admin features
2 parents 0e99305 + cb35bae commit f848c5d

56 files changed

Lines changed: 4606 additions & 284 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursor/mcp.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"mcpServers": {
3+
"roboflow": {
4+
"url": "https://mcp.roboflow.com/mcp"
5+
}
6+
}
7+
}

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@ AUTH0_CLIENT_ID=AUTH0CLIENTID
110110
AUTH0_DOMAIN=AUTH0DOMAIN
111111
AUTH0_TOKEN=AUTH0TOKEN
112112
AUTH0_AUDIENCE=AUTH0AUDIENCE
113+
# Used for automated image screening (backend-only; never expose to frontend)
114+
RF_KEY=ROBOFLOWAPIKEY
115+
# Optional aliases/overrides for screening
116+
ROBOFLOW_API_KEY=ROBOFLOWAPIKEY
117+
RF_WORKSPACE=bikes-workspace-6t0na
118+
RF_WORKFLOW=bicycle-no-selfie-screening-api
119+
# Optional Roboflow HTTP timeout in ms (default 120000; runs in screen-background)
120+
RF_TIMEOUT_MS=120000
113121
```
114122
<div align="center">
115123

functions/achievements-background.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import BikeTagClient, { Achievement, Game, Player, Tag } from 'biketag'
2-
import { getSupportedGames } from '../src/common'
2+
import { getSupportedGames } from '../src/common/games'
33
import { getBikeTagClientOpts, log } from './common'
44
import { HttpStatusCode } from './common/constants'
55
import { BackgroundProcessResults } from './common/types'

functions/approve.mts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
setNewBikeTagPost,
1111
} from './common'
1212
import { ErrorMessage, HttpStatusCode } from './common/constants'
13+
import { summarizeTagGps } from '../src/common/gps'
1314

1415
export default async (req: Request) => {
1516
const headers = acceptCorsHeaders()
@@ -94,6 +95,7 @@ export default async (req: Request) => {
9495
log('[approve-tag] Found tag to approve', {
9596
tagnumber: approvedTag.tagnumber,
9697
playerId: approvedTag.playerId,
98+
gps: summarizeTagGps(approvedTag.gps),
9799
})
98100

99101
const newBikeTagPostedResults = await setNewBikeTagPost(

functions/autopost-background.mts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from './common'
1010
import { HttpStatusCode } from './common/constants'
1111
import { BackgroundProcessResults } from './common/types'
12+
import { summarizeTagGps } from '../src/common/gps'
1213

1314
export const autoPostNewBikeTags = async (): Promise<BackgroundProcessResults> => {
1415
if (process.env.SKIP_AUTOPOST_FUNCTION === 'true') {
@@ -95,6 +96,7 @@ export const autoPostNewBikeTags = async (): Promise<BackgroundProcessResults> =
9596
{
9697
game: game.slug,
9798
autoSelectedWinningTag,
99+
gps: summarizeTagGps(autoSelectedWinningTag.gps),
98100
},
99101
'info',
100102
)

functions/common/cdn-purge.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import axios from 'axios'
2+
import { log } from './methods'
3+
4+
const CDN_ENDPOINTS_URL = 'https://api.digitalocean.com/v2/cdn/endpoints'
5+
const endpointIdByOrigin = new Map<string, string>()
6+
7+
const getDoApiToken = (): string | undefined =>
8+
process.env.S3_BE_ACCESS_KEY
9+
10+
export const getCdnPathsFromStorageKey = (key: string): string[] => {
11+
if (!key?.length) return []
12+
13+
const filename = key.split('/').pop() ?? ''
14+
const filenameBase = filename.replace(/\.(webp|jpg|jpeg|png|gif|bmp)$/i, '')
15+
if (!filenameBase.length) return [key]
16+
17+
const paths = new Set<string>([key])
18+
if (key.startsWith('queue/')) {
19+
paths.add(`queue/${filenameBase}_small.webp`)
20+
paths.add(`queue/${filenameBase}_medium.webp`)
21+
}
22+
23+
return [...paths]
24+
}
25+
26+
export const getCdnPathsFromStorageUrl = (url: string): string[] => {
27+
try {
28+
return getCdnPathsFromStorageKey(new URL(url).pathname.replace(/^\//, ''))
29+
} catch {
30+
return []
31+
}
32+
}
33+
34+
const resolveCdnEndpointId = async (bucket: string, region: string): Promise<string | undefined> => {
35+
const origin = `${bucket}.${region}.digitaloceanspaces.com`
36+
const cached = endpointIdByOrigin.get(origin)
37+
if (cached) return cached
38+
39+
const token = getDoApiToken()
40+
if (!token?.length) return undefined
41+
42+
try {
43+
const response = await axios.get(CDN_ENDPOINTS_URL, {
44+
headers: { Authorization: `Bearer ${token}` },
45+
validateStatus: () => true,
46+
})
47+
48+
if (response.status < 200 || response.status >= 300) {
49+
log('[cdn] Failed to list CDN endpoints', { status: response.status, origin }, 'warn')
50+
return undefined
51+
}
52+
53+
const endpoints = response.data?.endpoints ?? []
54+
const match = endpoints.find(
55+
(endpoint: { origin?: string; endpoint?: string; id?: string }) =>
56+
endpoint.origin === origin ||
57+
endpoint.endpoint?.includes(`${bucket}.${region}.cdn.digitaloceanspaces.com`),
58+
)
59+
60+
if (match?.id) {
61+
endpointIdByOrigin.set(origin, match.id)
62+
return match.id
63+
}
64+
65+
log('[cdn] No CDN endpoint matched Spaces origin', { origin }, 'warn')
66+
return undefined
67+
} catch (error: any) {
68+
log('[cdn] CDN endpoint lookup error', { origin, message: error?.message ?? error }, 'warn')
69+
return undefined
70+
}
71+
}
72+
73+
export const purgeSpacesCdnPaths = async (
74+
bucket: string,
75+
region: string,
76+
paths: string[],
77+
): Promise<void> => {
78+
const uniquePaths = [...new Set(paths.filter((path) => path?.length))]
79+
if (!uniquePaths.length) return
80+
81+
const token = getDoApiToken()
82+
if (!token?.length) {
83+
log('[cdn] Skipping purge — S3_BE_ACCESS_KEY not configured', { pathCount: uniquePaths.length }, 'warn')
84+
return
85+
}
86+
87+
const cdnId = await resolveCdnEndpointId(bucket, region)
88+
if (!cdnId?.length) return
89+
90+
for (let index = 0; index < uniquePaths.length; index += 50) {
91+
const batch = uniquePaths.slice(index, index + 50)
92+
try {
93+
const response = await axios.delete(`${CDN_ENDPOINTS_URL}/${cdnId}/cache`, {
94+
headers: {
95+
Authorization: `Bearer ${token}`,
96+
'Content-Type': 'application/json',
97+
},
98+
data: { files: batch },
99+
validateStatus: () => true,
100+
})
101+
102+
if (response.status < 200 || response.status >= 300) {
103+
log('[cdn] Purge request failed', { status: response.status, cdnId, batch }, 'warn')
104+
} else {
105+
log('[cdn] Purged CDN cache paths', { cdnId, count: batch.length }, 'info')
106+
}
107+
} catch (error: any) {
108+
log('[cdn] Purge request error', { cdnId, message: error?.message ?? error }, 'warn')
109+
}
110+
}
111+
}
112+
113+
export const purgeSpacesCdnUrls = async (
114+
bucket: string,
115+
region: string,
116+
urls: string[],
117+
): Promise<void> => {
118+
const paths = urls.flatMap(getCdnPathsFromStorageUrl)
119+
await purgeSpacesCdnPaths(bucket, region, paths)
120+
}

0 commit comments

Comments
 (0)