@@ -604,6 +604,88 @@ router.get('/settings/backups-s3', async (req, res) => {
604604 }
605605} ) ;
606606
607+ // GET /settings/backups/download - Download a local backup file
608+ // Query: ?name=hysteria-backup-YYYY-MM-DDTHH-mm-ss.tar.gz
609+ router . get ( '/settings/backups/download' , async ( req , res ) => {
610+ try {
611+ const backupService = require ( '../../services/backupService' ) ;
612+ const fsSync = require ( 'fs' ) ;
613+
614+ const name = String ( req . query . name || '' ) ;
615+ const localPath = backupService . getLocalBackupPath ( name ) ;
616+
617+ let stats ;
618+ try {
619+ stats = await require ( 'fs' ) . promises . stat ( localPath ) ;
620+ } catch {
621+ return res . status ( 404 ) . json ( { error : 'Backup file not found' } ) ;
622+ }
623+
624+ const safeName = require ( 'path' ) . basename ( localPath ) ;
625+ res . setHeader ( 'Content-Type' , 'application/gzip' ) ;
626+ res . setHeader ( 'Content-Length' , stats . size ) ;
627+ res . setHeader ( 'Content-Disposition' , `attachment; filename="${ safeName } "` ) ;
628+ res . setHeader ( 'Cache-Control' , 'no-store' ) ;
629+
630+ const stream = fsSync . createReadStream ( localPath ) ;
631+ stream . on ( 'error' , ( err ) => {
632+ logger . error ( `[Backup] Local download stream error: ${ err . message } ` ) ;
633+ if ( ! res . headersSent ) res . status ( 500 ) . end ( ) ;
634+ else res . destroy ( err ) ;
635+ } ) ;
636+ stream . pipe ( res ) ;
637+
638+ logger . info ( `[Panel] Backup download (local): ${ safeName } by ${ req . session . adminUsername } ` ) ;
639+ } catch ( error ) {
640+ logger . error ( `[Panel] Backup download error: ${ error . message } ` ) ;
641+ if ( ! res . headersSent ) res . status ( 400 ) . json ( { error : error . message } ) ;
642+ }
643+ } ) ;
644+
645+ // GET /settings/backups-s3/download - Download an S3 backup file
646+ // Query: ?key=<full S3 object key>
647+ router . get ( '/settings/backups-s3/download' , async ( req , res ) => {
648+ try {
649+ const backupService = require ( '../../services/backupService' ) ;
650+ const path = require ( 'path' ) ;
651+
652+ const key = String ( req . query . key || '' ) . trim ( ) ;
653+ if ( ! key ) return res . status ( 400 ) . json ( { error : 'Key is required' } ) ;
654+
655+ const settings = await Settings . get ( ) ;
656+ if ( ! settings ?. backup ?. s3 ?. enabled ) {
657+ return res . status ( 400 ) . json ( { error : 'S3 not configured' } ) ;
658+ }
659+
660+ // Sanity check: key must live in the configured prefix to prevent
661+ // arbitrary object reads from the bucket via this endpoint.
662+ const prefix = ( settings . backup . s3 . prefix || 'backups' ) . replace ( / \/ + $ / , '' ) ;
663+ if ( ! key . startsWith ( `${ prefix } /hysteria-backup-` ) || ! key . endsWith ( '.tar.gz' ) ) {
664+ return res . status ( 400 ) . json ( { error : 'Invalid backup key' } ) ;
665+ }
666+
667+ const { stream, contentLength, contentType } = await backupService . getS3BackupStream ( settings , key ) ;
668+
669+ const safeName = path . basename ( key ) ;
670+ res . setHeader ( 'Content-Type' , contentType || 'application/gzip' ) ;
671+ if ( contentLength ) res . setHeader ( 'Content-Length' , contentLength ) ;
672+ res . setHeader ( 'Content-Disposition' , `attachment; filename="${ safeName } "` ) ;
673+ res . setHeader ( 'Cache-Control' , 'no-store' ) ;
674+
675+ stream . on ( 'error' , ( err ) => {
676+ logger . error ( `[Backup] S3 download stream error: ${ err . message } ` ) ;
677+ if ( ! res . headersSent ) res . status ( 500 ) . end ( ) ;
678+ else res . destroy ( err ) ;
679+ } ) ;
680+ stream . pipe ( res ) ;
681+
682+ logger . info ( `[Panel] Backup download (S3): ${ safeName } by ${ req . session . adminUsername } ` ) ;
683+ } catch ( error ) {
684+ logger . error ( `[Panel] S3 backup download error: ${ error . message } ` ) ;
685+ if ( ! res . headersSent ) res . status ( 500 ) . json ( { error : error . message } ) ;
686+ }
687+ } ) ;
688+
607689// POST /settings/restore-backup - Restore from backup (local or S3)
608690router . post ( '/settings/restore-backup' , async ( req , res ) => {
609691 try {
@@ -714,16 +796,20 @@ router.post('/api-keys/:id/delete', async (req, res) => {
714796// POST /settings/test-webhook - Send test webhook
715797router . post ( '/settings/test-webhook' , async ( req , res ) => {
716798 try {
717- const { url, secret } = req . body ;
799+ const { url, secret, event } = req . body ;
718800
719801 if ( ! url || ! url . trim ( ) ) {
720802 return res . status ( 400 ) . json ( { error : 'URL is required' } ) ;
721803 }
722804
723- const result = await webhookService . test ( url . trim ( ) , secret || '' ) ;
805+ // Whitelist event against EVENTS to prevent spoofing arbitrary headers.
806+ const knownEvents = Object . values ( webhookService . EVENTS ) ;
807+ const safeEvent = event && knownEvents . includes ( event ) ? event : undefined ;
808+
809+ const result = await webhookService . test ( url . trim ( ) , secret || '' , safeEvent ) ;
724810
725811 if ( result . success ) {
726- logger . info ( `[Panel] Webhook test OK: ${ url } (HTTP ${ result . status } )` ) ;
812+ logger . info ( `[Panel] Webhook test OK: ${ url } (HTTP ${ result . status } )${ safeEvent ? ` event= ${ safeEvent } ` : '' } ` ) ;
727813 res . json ( { success : true , status : result . status } ) ;
728814 } else {
729815 res . status ( 400 ) . json ( { success : false , error : result . error , status : result . status } ) ;
0 commit comments