@@ -21,6 +21,8 @@ import { pruneStaleDevices, shouldAlert, type DeviceSet } from './device-trackin
2121type Bindings = {
2222 // KV namespace for license code -> full key mappings
2323 LICENSE_CODES : KVNamespace
24+ // KV namespace for blog post likes
25+ BLOG_LIKES : KVNamespace
2426 // Analytics Engine for device count tracking (fair use monitoring)
2527 DEVICE_COUNTS : AnalyticsEngineDataset
2628 // D1 database for telemetry persistence (crash reports, downloads, update checks)
@@ -868,6 +870,80 @@ app.get('/download/:version/:arch', async (c) => {
868870 return c . redirect ( `https://github.com/vdavid/cmdr/releases/download/v${ version } /Cmdr_${ version } _${ arch } .dmg` , 302 )
869871} )
870872
873+ // --- Blog likes ---
874+
875+ type LikesData = { count : number ; hashes : string [ ] }
876+
877+ const likesAllowedOrigins = new Set ( [ 'https://getcmdr.com' , 'https://www.getcmdr.com' ] )
878+
879+ function likesCors ( c : { req : { header : ( name : string ) => string | undefined } ; header : ( name : string , value : string ) => void } ) {
880+ const origin = c . req . header ( 'origin' )
881+ if ( origin && likesAllowedOrigins . has ( origin ) ) {
882+ c . header ( 'Access-Control-Allow-Origin' , origin )
883+ c . header ( 'Access-Control-Allow-Methods' , 'GET, POST, DELETE, OPTIONS' )
884+ c . header ( 'Access-Control-Allow-Headers' , 'Content-Type' )
885+ c . header ( 'Vary' , 'Origin' )
886+ }
887+ }
888+
889+ async function hashIpForLikes ( ip : string ) : Promise < string > {
890+ const buffer = await crypto . subtle . digest ( 'SHA-256' , new TextEncoder ( ) . encode ( 'cmdr-likes:' + ip ) )
891+ return [ ...new Uint8Array ( buffer ) ] . slice ( 0 , 8 ) . map ( ( b ) => b . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' )
892+ }
893+
894+ async function getLikesData ( kv : KVNamespace , slug : string ) : Promise < LikesData > {
895+ const raw = await kv . get ( `likes:${ slug } ` )
896+ if ( ! raw ) return { count : 0 , hashes : [ ] }
897+ return JSON . parse ( raw ) as LikesData
898+ }
899+
900+ app . options ( '/likes/:slug' , ( c ) => {
901+ likesCors ( c )
902+ return c . body ( null , 204 )
903+ } )
904+
905+ app . get ( '/likes/:slug' , async ( c ) => {
906+ likesCors ( c )
907+ const slug = c . req . param ( 'slug' )
908+ const ip = c . req . header ( 'cf-connecting-ip' ) ?? c . req . header ( 'x-forwarded-for' ) ?? 'unknown'
909+ const ipHash = await hashIpForLikes ( ip )
910+ const data = await getLikesData ( c . env . BLOG_LIKES , slug )
911+ return c . json ( { count : data . count , liked : data . hashes . includes ( ipHash ) } )
912+ } )
913+
914+ app . post ( '/likes/:slug' , async ( c ) => {
915+ likesCors ( c )
916+ const slug = c . req . param ( 'slug' )
917+ const ip = c . req . header ( 'cf-connecting-ip' ) ?? c . req . header ( 'x-forwarded-for' ) ?? 'unknown'
918+ const ipHash = await hashIpForLikes ( ip )
919+ const data = await getLikesData ( c . env . BLOG_LIKES , slug )
920+
921+ if ( ! data . hashes . includes ( ipHash ) ) {
922+ data . hashes . push ( ipHash )
923+ data . count = data . hashes . length
924+ await c . env . BLOG_LIKES . put ( `likes:${ slug } ` , JSON . stringify ( data ) )
925+ }
926+
927+ return c . json ( { count : data . count , liked : true } )
928+ } )
929+
930+ app . delete ( '/likes/:slug' , async ( c ) => {
931+ likesCors ( c )
932+ const slug = c . req . param ( 'slug' )
933+ const ip = c . req . header ( 'cf-connecting-ip' ) ?? c . req . header ( 'x-forwarded-for' ) ?? 'unknown'
934+ const ipHash = await hashIpForLikes ( ip )
935+ const data = await getLikesData ( c . env . BLOG_LIKES , slug )
936+
937+ const idx = data . hashes . indexOf ( ipHash )
938+ if ( idx !== - 1 ) {
939+ data . hashes . splice ( idx , 1 )
940+ data . count = data . hashes . length
941+ await c . env . BLOG_LIKES . put ( `likes:${ slug } ` , JSON . stringify ( data ) )
942+ }
943+
944+ return c . json ( { count : data . count , liked : false } )
945+ } )
946+
871947export { app }
872948
873949// --- Scheduled handler (cron) ---
0 commit comments