fix improve error message when browser start fails

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-09-15 19:38:02 +02:00
1 parent b86c3c6731
commit 020ad9d468
5 files changed
+104 -6

No files matched your search

+23
View File
@@ -156,6 +156,12 @@ func (c *Common) handleErrors(
)
return false
}
if ok := handleOperationalError(g, c.Response, err); !ok {
c.Logger.Errorw("operational error",
"error", err,
)
return false
}
if ok := handleDBRowNotFound(g, c.Response, err); !ok {
c.Logger.Debugw("DB row not found error",
"error", err,
@@ -308,6 +314,23 @@ func handleCustomError(
return true
}
// handleOperationalError responds with a 500 that carries the safe message
// from an OperationalError. The full cause is logged by the caller. It is used
// for environment failures such as a blocked download or a browser that will
// not start, so the operator sees what went wrong instead of a generic error.
func handleOperationalError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
var opErr errs.OperationalError
if errors.As(err, &opErr) {
responseHandler.ServerErrorMessage(g, opErr.PublicMessage())
return false
}
return true
}
// handleServerError checks if the error is a server error
// if it is, a 500 response is sent
// if it is not, true is returned
+38
View File
@@ -158,3 +158,41 @@ func NewCustomError(err error) error {
func (e CustomError) Error() string {
return e.Err.Error()
}
// OperationalError is returned when an action fails for an environment or
// infrastructure reason that the operator should see, such as a blocked
// outbound download or a browser that will not start. The cause stays in the
// server logs while a safe message is shown to the client.
type OperationalError struct {
// public is the message shown to the client
public string
// cause is the underlying error kept for the logs
cause error
}
// NewOperationalError creates an operational error with a message for the
// client and the underlying cause for the logs.
func NewOperationalError(public string, cause error) error {
return OperationalError{
public: public,
cause: cause,
}
}
// Error returns the message and the cause for the logs.
func (e OperationalError) Error() string {
if e.cause == nil {
return e.public
}
return e.public + ": " + e.cause.Error()
}
// PublicMessage returns the message shown to the client.
func (e OperationalError) PublicMessage() string {
return e.public
}
// Unwrap returns the underlying cause.
func (e OperationalError) Unwrap() error {
return e.cause
}
+13 -3
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
"github.com/phishingclub/phishingclub/errs"
)
// WipeBrowserCache removes the automatically downloaded Chromium directory.
@@ -86,7 +87,10 @@ func RenderHTMLToPDF(ctx context.Context, htmlContent string, execPath string) (
binPath := b.BinPath()
if _, err := os.Stat(binPath); os.IsNotExist(err) {
if err := b.Download(); err != nil {
return nil, fmt.Errorf("reportpdf: browser download failed: %w", err)
return nil, errs.NewOperationalError(
"The report browser could not be downloaded. Check the server logs for details.",
err,
)
}
}
l = l.Bin(binPath)
@@ -98,13 +102,19 @@ func RenderHTMLToPDF(ctx context.Context, htmlContent string, execPath string) (
// on an early launch failure it blocks forever waiting on the exit channel.
// Kill still removes a process that did start.
l.Kill()
return nil, fmt.Errorf("reportpdf: browser launch failed: %w", err)
return nil, errs.NewOperationalError(
"The report browser failed to start. Check the server logs for details.",
err,
)
}
defer func() { l.Kill(); l.Cleanup() }()
browser := rod.New().ControlURL(u).Context(ctx)
if err := browser.Connect(); err != nil {
return nil, fmt.Errorf("reportpdf: browser connect failed: %w", err)
return nil, errs.NewOperationalError(
"The report browser failed to start. Check the server logs for details.",
err,
)
}
defer browser.Close() //nolint:errcheck
+29 -1
View File
@@ -511,7 +511,35 @@ export class API {
* @param {string} campaignID
*/
generateReport: async (campaignID) => {
window.open(this.getPath(`/campaign/${campaignID}/report`), '_blank');
const response = await fetch(this.getPath(`/campaign/${campaignID}/report`), {
method: 'GET',
credentials: 'same-origin'
});
if (!response.ok) {
let error = 'Failed to generate campaign report';
try {
const body = await response.json();
if (body?.error) {
error = body.error;
}
} catch (_) {
// the error body was not JSON, keep the default message
}
return { success: false, error };
}
const blob = await response.blob();
const disposition = response.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="?([^"]+)"?/);
const filename = match ? match[1] : 'report.pdf';
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
return { success: true };
},
/**
@@ -1231,8 +1231,7 @@
const onConfirmGenerateReport = async () => {
try {
api.campaign.generateReport($page.params.id);
return { success: true };
return await api.campaign.generateReport($page.params.id);
} catch (e) {
console.error('failed to generate campaign report', e);
return { success: false, error: 'Failed to generate campaign report' };