package pastes import ( "bytes" "net/http" "strconv" "time" htmlfmt "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" "github.com/labstack/echo" "go.dev.pztrn.name/fastpastebin/internal/database/dialects/flatfiles" "go.dev.pztrn.name/fastpastebin/internal/structs" "go.dev.pztrn.name/fastpastebin/internal/templater" ) const ( pasteCookieInvalid = "PASTE_COOKIE_INVALID" pasteExpired = "PASTE_EXPIRED" pasteNotFound = "PASTE_NOT_FOUND" pasteTimestampInvalid = "PASTE_TIMESTAMP_INVALID" ) // Actual getting paste data and returns it's content without formatting. // This function will return paste's structure and optional error string // that defined in constants above. // Actually required only paste ID, all other parameters are optional // for some cases, e.g. public paste won't check for timestamp and cookie // value (they both will be ignored), but private will. func pasteGetData(pasteID int, timestamp int64, cookieValue string) (*structs.Paste, string) { // Get paste. paste, err1 := ctx.Database.GetPaste(pasteID) if err1 != nil { ctx.Logger.Error().Err(err1).Int("paste ID", pasteID).Msg("Failed to get paste") return nil, pasteNotFound } // Check if paste is expired. if paste.IsExpired() { ctx.Logger.Error().Int("paste ID", pasteID).Msg("Paste is expired") return nil, pasteExpired } // Check if we have a private paste and it's parameters are correct. if paste.Private { pasteTS := paste.CreatedAt.Unix() if timestamp != pasteTS { ctx.Logger.Error().Int("paste ID", pasteID).Int64("paste timestamp", pasteTS).Int64("provided timestamp", timestamp).Msg("Incorrect timestamp provided for private paste") return nil, pasteTimestampInvalid } } // If we have a private paste requested and password for that paste // was defined - check additional things that required to view this // paste. if paste.Private && paste.Password != "" { // Generate cookie value to check. pasteCookieValue := paste.GenerateCryptedCookieValue() if cookieValue != pasteCookieValue { return nil, pasteCookieInvalid } } return paste, "" } // GET for "/paste/PASTE_ID" and "/paste/PASTE_ID/TIMESTAMP" (private pastes). // Web interface version. func pasteGETWebInterface(ectx echo.Context) error { pasteIDRaw := ectx.Param("id") // We already get numbers from string, so we will not check strconv.Atoi() // error. pasteID, _ := strconv.Atoi(regexInts.FindAllString(pasteIDRaw, 1)[0]) pasteIDStr := strconv.Itoa(pasteID) ctx.Logger.Debug().Int("paste ID", pasteID).Msg("Trying to get paste data") // Check if we have timestamp passed. // If passed timestamp is invalid (isn't a real UNIX timestamp) we // will show 404 Not Found error and spam about that in logs. var timestamp int64 tsProvidedStr := ectx.Param("timestamp") if tsProvidedStr != "" { tsProvided, err := strconv.ParseInt(tsProvidedStr, 10, 64) if err != nil { ctx.Logger.Error().Err(err).Int("paste ID", pasteID).Int64("provided timestamp", tsProvided).Msg("Invalid timestamp provided for getting private paste") errtpl := templater.GetErrorTemplate(ectx, "Paste #"+pasteIDStr+" not found") // nolint:wrapcheck return ectx.HTML(http.StatusBadRequest, errtpl) } timestamp = tsProvided } // Check if we have "PASTE-PASTEID" cookie defined. It is required // for private pastes. var cookieValue string cookie, err1 := ectx.Cookie("PASTE-" + pasteIDStr) if err1 == nil { cookieValue = cookie.Value } paste, err := pasteGetData(pasteID, timestamp, cookieValue) // For these cases we should return 404 Not Found page. if err == pasteExpired || err == pasteNotFound || err == pasteTimestampInvalid { errtpl := templater.GetErrorTemplate(ectx, "Paste #"+pasteIDRaw+" not found") // nolint:wrapcheck return ectx.HTML(http.StatusNotFound, errtpl) } // If passed cookie value was invalid - go to paste authorization // page. if err == pasteCookieInvalid { ctx.Logger.Info().Int("paste ID", pasteID).Msg("Invalid cookie, redirecting to auth page") // nolint:wrapcheck return ectx.Redirect(http.StatusMovedPermanently, "/paste/"+pasteIDStr+"/"+ectx.Param("timestamp")+"/verify") } // Format paste data map. pasteData := make(map[string]string) pasteData["pasteTitle"] = paste.Title pasteData["pasteID"] = strconv.Itoa(paste.ID) pasteData["pasteDate"] = paste.CreatedAt.Format("2006-01-02 @ 15:04:05") + " UTC" pasteData["pasteLanguage"] = paste.Language pasteExpirationString := "Never" if paste.KeepFor != 0 && paste.KeepForUnitType != 0 { pasteExpirationString = paste.GetExpirationTime().Format("2006-01-02 @ 15:04:05") + " UTC" } pasteData["pasteExpiration"] = pasteExpirationString if paste.Private { pasteData["pasteType"] = "Private" pasteData["pasteTs"] = strconv.FormatInt(paste.CreatedAt.Unix(), 10) + "/" } else { pasteData["pasteType"] = "Public" pasteData["pasteTs"] = "" } // Highlight. // Get lexer. lexer := lexers.Get(paste.Language) if lexer == nil { lexer = lexers.Fallback } // Tokenize paste data. lexered, err3 := lexer.Tokenise(nil, paste.Data) if err3 != nil { ctx.Logger.Error().Err(err3).Msg("Failed to tokenize paste data") } // Get style for HTML output. style := styles.Get("monokai") if style == nil { style = styles.Fallback } // Get HTML formatter. formatter := htmlfmt.New(htmlfmt.WithLineNumbers(true), htmlfmt.LineNumbersInTable(true), htmlfmt.LinkableLineNumbers(true, "L")) // Create buffer and format into it. buf := new(bytes.Buffer) err4 := formatter.Format(buf, style, lexered) if err4 != nil { ctx.Logger.Error().Err(err4).Msg("Failed to format paste data") } pasteData["pastedata"] = buf.String() // Get template and format it. pasteHTML := templater.GetTemplate(ectx, "paste.html", pasteData) // nolint:wrapcheck return ectx.HTML(http.StatusOK, pasteHTML) } // GET for "/paste/PASTE_ID/TIMESTAMP/verify" - a password verify page. func pastePasswordedVerifyGet(ectx echo.Context) error { pasteIDRaw := ectx.Param("id") timestampRaw := ectx.Param("timestamp") // We already get numbers from string, so we will not check strconv.Atoi() // error. pasteID, _ := strconv.Atoi(regexInts.FindAllString(pasteIDRaw, 1)[0]) // Get paste. paste, err1 := ctx.Database.GetPaste(pasteID) if err1 != nil { ctx.Logger.Error().Err(err1).Int("paste ID", pasteID).Msg("Failed to get paste data") errtpl := templater.GetErrorTemplate(ectx, "Paste #"+pasteIDRaw+" not found") // nolint:wrapcheck return ectx.HTML(http.StatusBadRequest, errtpl) } // Check for auth cookie. If present - redirect to paste. cookie, err := ectx.Cookie("PASTE-" + strconv.Itoa(pasteID)) if err == nil { // No cookie, redirect to auth page. ctx.Logger.Debug().Msg("Paste cookie found, checking it...") // Generate cookie value to check. cookieValue := paste.GenerateCryptedCookieValue() if cookieValue == cookie.Value { ctx.Logger.Info().Msg("Valid cookie, redirecting to paste page...") // nolint:wrapcheck return ectx.Redirect(http.StatusMovedPermanently, "/paste/"+pasteIDRaw+"/"+ectx.Param("timestamp")) } ctx.Logger.Debug().Msg("Invalid cookie, showing auth page") } // HTML data. htmlData := make(map[string]string) htmlData["pasteID"] = strconv.Itoa(pasteID) htmlData["pasteTimestamp"] = timestampRaw verifyHTML := templater.GetTemplate(ectx, "passworded_paste_verify.html", htmlData) // nolint:wrapcheck return ectx.HTML(http.StatusOK, verifyHTML) } // POST for "/paste/PASTE_ID/TIMESTAMP/verify" - a password verify page. func pastePasswordedVerifyPost(ectx echo.Context) error { // We should check if database connection available. dbConn := ctx.Database.GetDatabaseConnection() if ctx.Config.Database.Type != flatfiles.FlatFileDialect && dbConn == nil { // nolint:wrapcheck return ectx.Redirect(http.StatusFound, "/database_not_available") } pasteIDRaw := ectx.Param("id") timestampRaw := ectx.Param("timestamp") // We already get numbers from string, so we will not check strconv.Atoi() // error. pasteID, _ := strconv.Atoi(regexInts.FindAllString(pasteIDRaw, 1)[0]) ctx.Logger.Debug().Int("paste ID", pasteID).Msg("Requesting paste") // Get paste. paste, err1 := ctx.Database.GetPaste(pasteID) if err1 != nil { ctx.Logger.Error().Err(err1).Int("paste ID", pasteID).Msg("Failed to get paste") errtpl := templater.GetErrorTemplate(ectx, "Paste #"+strconv.Itoa(pasteID)+" not found") // nolint:wrapcheck return ectx.HTML(http.StatusBadRequest, errtpl) } params, err2 := ectx.FormParams() if err2 != nil { ctx.Logger.Debug().Msg("No form parameters passed") errtpl := templater.GetErrorTemplate(ectx, "Paste #"+strconv.Itoa(pasteID)+" not found") // nolint:wrapcheck return ectx.HTML(http.StatusBadRequest, errtpl) } if paste.VerifyPassword(params["paste-password"][0]) { // Set cookie that this paste's password is verified and paste // can be viewed. cookie := new(http.Cookie) cookie.Name = "PASTE-" + strconv.Itoa(pasteID) cookie.Value = paste.GenerateCryptedCookieValue() cookie.Expires = time.Now().Add(24 * time.Hour) ectx.SetCookie(cookie) // nolint:wrapcheck return ectx.Redirect(http.StatusFound, "/paste/"+strconv.Itoa(pasteID)+"/"+timestampRaw) } errtpl := templater.GetErrorTemplate(ectx, "Invalid password. Please, try again.") // nolint:wrapcheck return ectx.HTML(http.StatusBadRequest, errtpl) } // GET for "/pastes/:id/raw", raw paste output. // Web interface version. func pasteRawGETWebInterface(ectx echo.Context) error { // We should check if database connection available. dbConn := ctx.Database.GetDatabaseConnection() if ctx.Config.Database.Type != flatfiles.FlatFileDialect && dbConn == nil { // nolint:wrapcheck return ectx.Redirect(http.StatusFound, "/database_not_available/raw") } pasteIDRaw := ectx.Param("id") // We already get numbers from string, so we will not check strconv.Atoi() // error. pasteID, _ := strconv.Atoi(regexInts.FindAllString(pasteIDRaw, 1)[0]) ctx.Logger.Debug().Int("paste ID", pasteID).Msg("Requesting paste data") // Get paste. paste, err1 := ctx.Database.GetPaste(pasteID) if err1 != nil { ctx.Logger.Error().Err(err1).Int("paste ID", pasteID).Msg("Failed to get paste from database") // nolint:wrapcheck return ectx.HTML(http.StatusBadRequest, "Paste #"+pasteIDRaw+" does not exist.") } if paste.IsExpired() { ctx.Logger.Error().Int("paste ID", pasteID).Msg("Paste is expired") // nolint:wrapcheck return ectx.HTML(http.StatusBadRequest, "Paste #"+pasteIDRaw+" does not exist.") } // Check if we have a private paste and it's parameters are correct. if paste.Private { tsProvidedStr := ectx.Param("timestamp") tsProvided, err2 := strconv.ParseInt(tsProvidedStr, 10, 64) if err2 != nil { ctx.Logger.Error().Err(err2).Int("paste ID", pasteID).Str("provided timestamp", tsProvidedStr).Msg("Invalid timestamp provided for getting private paste") // nolint:wrapcheck return ectx.String(http.StatusBadRequest, "Paste #"+pasteIDRaw+" not found") } pasteTS := paste.CreatedAt.Unix() if tsProvided != pasteTS { ctx.Logger.Error().Int("paste ID", pasteID).Int64("provided timestamp", tsProvided).Int64("paste timestamp", pasteTS).Msg("Incorrect timestamp provided for private paste") // nolint:wrapcheck return ectx.String(http.StatusBadRequest, "Paste #"+pasteIDRaw+" not found") } } // nolint // ToDo: figure out how to handle passworded pastes here. // Return error for now. if paste.Password != "" { ctx.Logger.Error().Int("paste ID", pasteID).Msg("Cannot render paste as raw: passworded paste. Patches welcome!") return ectx.String(http.StatusBadRequest, "Paste #"+pasteIDRaw+" not found") } // nolint:wrapcheck return ectx.String(http.StatusOK, paste.Data) }