11import { createHash } from "node:crypto" ;
22import { createServer } from "node:http" ;
3- import { createReadStream , existsSync , readFileSync , statSync } from "node:fs" ;
3+ import { createReadStream , existsSync , mkdirSync , readFileSync , statSync } from "node:fs" ;
44import { extname , join , normalize , resolve } from "node:path" ;
5+ import { DatabaseSync } from "node:sqlite" ;
56import { pages , redirects , sitemapPaths } from "./content.mjs" ;
67import { renderPage } from "./template.mjs" ;
78
@@ -25,8 +26,15 @@ const ORIGIN = "https://1helm.com";
2526const REPO = "gitcommit90/1Helm" ;
2627const RELEASE_PAGE = `https://github.com/${ REPO } /releases/latest` ;
2728const RELEASE_CACHE_MS = 10 * 60_000 ;
29+ const FEEDBACK_DATA_DIR = resolve ( process . env . SITE_DATA_DIR || join ( ROOT , ".site-data" ) ) ;
30+ const FEEDBACK_ADMIN_TOKEN = String ( process . env . SITE_FEEDBACK_ADMIN_TOKEN || "" ) ;
31+ const FEEDBACK_BODY_LIMIT = 15 * 1024 * 1024 ;
32+ const FEEDBACK_RATE_LIMIT = 30 ;
33+ const FEEDBACK_RATE_WINDOW_MS = 60_000 ;
2834
2935let releaseCache = { at : 0 , assets : null } ;
36+ let feedbackDatabase ;
37+ const feedbackRate = new Map ( ) ;
3038async function latestReleaseAssets ( ) {
3139 if ( Date . now ( ) - releaseCache . at < RELEASE_CACHE_MS && releaseCache . assets ) return releaseCache . assets ;
3240 const response = await fetch ( `https://api.github.com/repos/${ REPO } /releases/latest` , {
@@ -74,6 +82,158 @@ function answer(res, status, body, headers = {}) {
7482 res . end ( body ) ;
7583}
7684
85+ function feedbackDb ( ) {
86+ if ( feedbackDatabase ) return feedbackDatabase ;
87+ mkdirSync ( FEEDBACK_DATA_DIR , { recursive : true , mode : 0o700 } ) ;
88+ feedbackDatabase = new DatabaseSync ( join ( FEEDBACK_DATA_DIR , "feedback.db" ) ) ;
89+ feedbackDatabase . exec ( `
90+ PRAGMA journal_mode=WAL;
91+ PRAGMA foreign_keys=ON;
92+ CREATE TABLE IF NOT EXISTS feedback_reports (
93+ public_id TEXT PRIMARY KEY,
94+ installation_id TEXT NOT NULL,
95+ workspace_name TEXT NOT NULL DEFAULT '',
96+ comment TEXT NOT NULL,
97+ diagnostics TEXT NOT NULL DEFAULT '{}',
98+ attachment_count INTEGER NOT NULL DEFAULT 0,
99+ created_at INTEGER NOT NULL,
100+ received_at INTEGER NOT NULL
101+ );
102+ CREATE INDEX IF NOT EXISTS idx_feedback_received ON feedback_reports(received_at DESC);
103+ CREATE TABLE IF NOT EXISTS feedback_attachments (
104+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105+ report_id TEXT NOT NULL REFERENCES feedback_reports(public_id) ON DELETE CASCADE,
106+ name TEXT NOT NULL,
107+ mime TEXT NOT NULL,
108+ size INTEGER NOT NULL,
109+ data BLOB NOT NULL,
110+ created_at INTEGER NOT NULL
111+ );
112+ ` ) ;
113+ return feedbackDatabase ;
114+ }
115+
116+ function feedbackAddress ( req ) {
117+ return String ( req . headers [ "cf-connecting-ip" ] || req . headers [ "x-forwarded-for" ] || req . socket . remoteAddress || "unknown" ) . split ( "," ) [ 0 ] . trim ( ) ;
118+ }
119+
120+ function feedbackRateLimited ( req ) {
121+ const stamp = Date . now ( ) ;
122+ const key = feedbackAddress ( req ) ;
123+ const current = feedbackRate . get ( key ) ;
124+ if ( ! current || stamp - current . started >= FEEDBACK_RATE_WINDOW_MS ) {
125+ feedbackRate . set ( key , { started : stamp , count : 1 } ) ;
126+ if ( feedbackRate . size > 2_000 ) {
127+ for ( const [ address , bucket ] of feedbackRate ) if ( stamp - bucket . started >= FEEDBACK_RATE_WINDOW_MS ) feedbackRate . delete ( address ) ;
128+ }
129+ return false ;
130+ }
131+ current . count += 1 ;
132+ return current . count > FEEDBACK_RATE_LIMIT ;
133+ }
134+
135+ function readJsonBody ( req , limit = FEEDBACK_BODY_LIMIT ) {
136+ return new Promise ( ( resolveBody , rejectBody ) => {
137+ let size = 0 ;
138+ let rejected = false ;
139+ const chunks = [ ] ;
140+ req . on ( "data" , ( chunk ) => {
141+ size += chunk . length ;
142+ if ( size > limit ) {
143+ if ( ! rejected ) rejectBody ( Object . assign ( new Error ( "Feedback payload is too large." ) , { status : 413 } ) ) ;
144+ rejected = true ;
145+ return ;
146+ }
147+ chunks . push ( chunk ) ;
148+ } ) ;
149+ req . on ( "end" , ( ) => {
150+ if ( rejected ) return ;
151+ try { resolveBody ( JSON . parse ( Buffer . concat ( chunks ) . toString ( "utf8" ) || "{}" ) ) ; }
152+ catch { rejectBody ( Object . assign ( new Error ( "Feedback must be valid JSON." ) , { status : 400 } ) ) ; }
153+ } ) ;
154+ req . on ( "error" , rejectBody ) ;
155+ } ) ;
156+ }
157+
158+ function validatedFeedback ( body ) {
159+ const source = body && typeof body === "object" && ! Array . isArray ( body ) ? body : { } ;
160+ const publicId = String ( source . public_id || "" ) ;
161+ const installationId = String ( source . installation_id || "" ) ;
162+ const workspaceName = String ( source . workspace_name || "" ) . trim ( ) . slice ( 0 , 100 ) ;
163+ const comment = String ( source . comment || "" ) . trim ( ) . slice ( 0 , 10_000 ) ;
164+ const diagnostics = source . diagnostics && typeof source . diagnostics === "object" && ! Array . isArray ( source . diagnostics ) ? source . diagnostics : { } ;
165+ const attachments = Array . isArray ( source . attachments ) ? source . attachments . slice ( 0 , 3 ) : [ ] ;
166+ if ( ! / ^ f b _ [ a - f 0 - 9 ] { 24 } $ / . test ( publicId ) || ! / ^ [ a - f 0 - 9 ] { 16 } $ / . test ( installationId ) ) {
167+ throw Object . assign ( new Error ( "Feedback source could not be verified." ) , { status : 400 } ) ;
168+ }
169+ if ( ! comment && ! attachments . length ) throw Object . assign ( new Error ( "Feedback is empty." ) , { status : 400 } ) ;
170+ const diagnosticsJson = JSON . stringify ( diagnostics ) ;
171+ if ( Buffer . byteLength ( diagnosticsJson ) > 64 * 1024 ) throw Object . assign ( new Error ( "Diagnostics are too large." ) , { status : 413 } ) ;
172+ let total = 0 ;
173+ const cleanAttachments = attachments . map ( ( raw ) => {
174+ const attachment = raw && typeof raw === "object" && ! Array . isArray ( raw ) ? raw : { } ;
175+ const size = Number ( attachment . size || 0 ) ;
176+ const data = String ( attachment . data || "" ) ;
177+ const validBase64 = data . length % 4 === 0 && / ^ [ A - Z a - z 0 - 9 + / ] * = { 0 , 2 } $ / . test ( data ) ;
178+ const padding = data . endsWith ( "==" ) ? 2 : data . endsWith ( "=" ) ? 1 : 0 ;
179+ const decodedSize = data . length ? ( data . length / 4 ) * 3 - padding : 0 ;
180+ if ( ! Number . isSafeInteger ( size ) || ! validBase64 || decodedSize !== size
181+ || size < 0 || size > 5 * 1024 * 1024 || data . length > 7 * 1024 * 1024 ) {
182+ throw Object . assign ( new Error ( "A feedback attachment is too large." ) , { status : 413 } ) ;
183+ }
184+ total += size ;
185+ return {
186+ name : String ( attachment . name || "attachment" ) . slice ( 0 , 255 ) ,
187+ mime : String ( attachment . mime || "application/octet-stream" ) . slice ( 0 , 120 ) ,
188+ size,
189+ data : Buffer . from ( data , "base64" ) ,
190+ } ;
191+ } ) ;
192+ if ( total > 10 * 1024 * 1024 ) throw Object . assign ( new Error ( "Feedback attachments are too large." ) , { status : 413 } ) ;
193+ return { publicId, installationId, workspaceName, comment, diagnosticsJson, attachments : cleanAttachments } ;
194+ }
195+
196+ function saveFeedback ( input ) {
197+ const database = feedbackDb ( ) ;
198+ const timestamp = Date . now ( ) ;
199+ database . exec ( "BEGIN IMMEDIATE" ) ;
200+ try {
201+ const inserted = database . prepare ( `INSERT OR IGNORE INTO feedback_reports
202+ (public_id,installation_id,workspace_name,comment,diagnostics,attachment_count,created_at,received_at)
203+ VALUES (?,?,?,?,?,?,?,?)` ) . run (
204+ input . publicId , input . installationId , input . workspaceName , input . comment , input . diagnosticsJson ,
205+ input . attachments . length , timestamp , timestamp ,
206+ ) ;
207+ if ( inserted . changes ) {
208+ const addAttachment = database . prepare ( `INSERT INTO feedback_attachments
209+ (report_id,name,mime,size,data,created_at) VALUES (?,?,?,?,?,?)` ) ;
210+ for ( const attachment of input . attachments ) addAttachment . run (
211+ input . publicId , attachment . name , attachment . mime , attachment . size , attachment . data , timestamp ,
212+ ) ;
213+ }
214+ database . exec ( "COMMIT" ) ;
215+ } catch ( error ) {
216+ database . exec ( "ROLLBACK" ) ;
217+ throw error ;
218+ }
219+ }
220+
221+ function feedbackInbox ( req , res ) {
222+ const token = String ( req . headers . authorization || "" ) . replace ( / ^ B e a r e r \s + / i, "" ) ;
223+ if ( ! FEEDBACK_ADMIN_TOKEN || token !== FEEDBACK_ADMIN_TOKEN ) {
224+ answer ( res , 404 , JSON . stringify ( { error : "Not found" } ) , { "content-type" : "application/json; charset=utf-8" , "cache-control" : "no-store" } ) ;
225+ return ;
226+ }
227+ const reports = feedbackDb ( ) . prepare ( `SELECT public_id,installation_id,workspace_name,comment,diagnostics,
228+ attachment_count,created_at created,received_at FROM feedback_reports ORDER BY received_at DESC LIMIT 500` ) . all ( ) . map ( ( report ) => ( {
229+ ...report ,
230+ diagnostics : JSON . parse ( String ( report . diagnostics || "{}" ) ) ,
231+ state : "delivered" ,
232+ attachments : [ ] ,
233+ } ) ) ;
234+ answer ( res , 200 , JSON . stringify ( { reports } ) , { "content-type" : "application/json; charset=utf-8" , "cache-control" : "no-store" } ) ;
235+ }
236+
77237function redirect ( res , location , status = 302 ) {
78238 answer ( res , status , "" , { location, "cache-control" : "no-store" } ) ;
79239}
@@ -105,9 +265,36 @@ function serveFile(req, res, file, cache = "public, max-age=86400") {
105265 return true ;
106266}
107267
108- const server = createServer ( ( req , res ) => {
268+ const server = createServer ( async ( req , res ) => {
109269 const url = new URL ( req . url || "/" , `http://${ req . headers . host || "localhost" } ` ) ;
110270 const path = url . pathname . length > 1 ? url . pathname . replace ( / \/ + $ / , "" ) : "/" ;
271+ if ( path === "/api/feedback" && req . method === "POST" ) {
272+ if ( feedbackRateLimited ( req ) ) {
273+ answer ( res , 429 , JSON . stringify ( { error : "Too many feedback reports. Try again shortly." } ) , {
274+ "content-type" : "application/json; charset=utf-8" ,
275+ "cache-control" : "no-store" ,
276+ } ) ;
277+ return ;
278+ }
279+ try {
280+ const input = validatedFeedback ( await readJsonBody ( req ) ) ;
281+ saveFeedback ( input ) ;
282+ answer ( res , 202 , JSON . stringify ( { id : input . publicId } ) , {
283+ "content-type" : "application/json; charset=utf-8" ,
284+ "cache-control" : "no-store" ,
285+ } ) ;
286+ } catch ( error ) {
287+ answer ( res , Number ( error . status ) || 500 , JSON . stringify ( { error : Number ( error . status ) ? error . message : "Feedback could not be saved." } ) , {
288+ "content-type" : "application/json; charset=utf-8" ,
289+ "cache-control" : "no-store" ,
290+ } ) ;
291+ }
292+ return ;
293+ }
294+ if ( path === "/api/feedback" && req . method === "GET" ) {
295+ feedbackInbox ( req , res ) ;
296+ return ;
297+ }
111298 if ( ! [ 'GET' , 'HEAD' ] . includes ( req . method || 'GET' ) ) {
112299 answer ( res , 405 , "Method not allowed" , { "content-type" : "text/plain; charset=utf-8" , allow : "GET, HEAD" } ) ;
113300 return ;
0 commit comments