Added mail notification support

This commit is contained in:
2023-03-28 15:47:54 +02:00
parent fb536c2bcb
commit 737df3c802
11 changed files with 150 additions and 29 deletions

View File

@@ -59,11 +59,20 @@ func FormCreate(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
ex, err := common.Untaint(formdata.Expire, cfg.RegDuration) // duration or asap allowed ex, err := common.Untaint(formdata.Expire, cfg.RegDuration) // duration or asap allowed
if err != nil { if err != nil {
return JsonStatus(c, fiber.StatusForbidden, return JsonStatus(c, fiber.StatusForbidden,
"Invalid data: "+err.Error()) "Invalid expire data: "+err.Error())
} }
entry.Expire = ex entry.Expire = ex
} }
if len(formdata.Notify) != 0 {
nt, err := common.Untaint(formdata.Notify, cfg.RegEmail)
if err != nil {
return JsonStatus(c, fiber.StatusForbidden,
"Invalid email address: "+err.Error())
}
entry.Notify = nt
}
// get url [and zip if there are multiple files] // get url [and zip if there are multiple files]
returnUrl := strings.Join([]string{cfg.Url, "form", id}, "/") returnUrl := strings.Join([]string{cfg.Url, "form", id}, "/")
entry.Url = returnUrl entry.Url = returnUrl

54
api/mail.go Normal file
View File

@@ -0,0 +1,54 @@
/*
Copyright © 2023 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package api
import (
"fmt"
"github.com/tlinden/cenophane/cfg"
"net/smtp"
)
var mailtpl string = `To: %s\r
From: %s\r
Subject: %s\r
\r
%s\r
`
/*
Send an email via an external mail gateway. SMTP Auth is
required. Errors may occur with a time delay, like server timeouts
etc. So only call it detached via go routine.
*/
func Sendmail(c *cfg.Config, recipient string, body string, subject string) error {
// Message.
message := []byte(fmt.Sprintf(mailtpl, recipient, c.Mail.From, subject, body))
// Authentication.
auth := smtp.PlainAuth("", c.Mail.From, c.Mail.Password, c.Mail.Server)
// Sending email.
Log("Trying to send mail to %s via %s:%s with subject %s",
recipient, c.Mail.Server, c.Mail.Port, subject)
err := smtp.SendMail(c.Mail.Server+":"+c.Mail.Port, auth, c.Mail.From, []string{recipient}, []byte(message))
if err != nil {
return err
}
return nil
}

View File

@@ -24,6 +24,7 @@ import (
"github.com/tlinden/cenophane/cfg" "github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common" "github.com/tlinden/cenophane/common"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -122,7 +123,7 @@ func UploadPost(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
// ok, check if we need to remove a form, if so we do it in the // ok, check if we need to remove a form, if so we do it in the
// background. delete error doesn't lead to upload failure, we // background. delete error doesn't lead to upload failure, we
// only log it. // only log it. same applies to mail notification.
formid, _ := SessionGetFormId(c) formid, _ := SessionGetFormId(c)
if formid != "" { if formid != "" {
go func() { go func() {
@@ -132,6 +133,16 @@ func UploadPost(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
if r.Forms[0].Expire == "asap" { if r.Forms[0].Expire == "asap" {
db.Delete(apicontext, formid) db.Delete(apicontext, formid)
} }
// email notification to form creator
if r.Forms[0].Notify != "" {
body := fmt.Sprintf("Upload is available under: %s", returnUrl)
subject := fmt.Sprintf("Upload form %s has been used", formid)
err := Sendmail(cfg, r.Forms[0].Notify, body, subject)
if err != nil {
Log("Failed to send mail: %s", err.Error())
}
}
} }
} }
}() }()

View File

@@ -17,3 +17,10 @@ apicontext = [
# this is the root context with all permissions # this is the root context with all permissions
super = "root" super = "root"
mail = {
server = "localhost"
port = "25"
from = "root@localhost"
password = ""
}

View File

@@ -32,6 +32,13 @@ type Apicontext struct {
Key string `koanf:"key"` Key string `koanf:"key"`
} }
type Mailsettings struct {
Server string `koanf:"server"`
Port string `koanf:"port"`
From string `koanf:"from"`
Password string `koanf:"password"`
}
// holds the whole configs, filled by commandline flags, env and config file // holds the whole configs, filled by commandline flags, env and config file
type Config struct { type Config struct {
// Flags+config file settings // Flags+config file settings
@@ -57,10 +64,14 @@ type Config struct {
// only settable via config // only settable via config
Apicontexts []Apicontext `koanf:"apicontext"` Apicontexts []Apicontext `koanf:"apicontext"`
// smtp settings
Mail Mailsettings `koanf:mail`
// Internals only // Internals only
RegNormalizedFilename *regexp.Regexp RegNormalizedFilename *regexp.Regexp
RegDuration *regexp.Regexp RegDuration *regexp.Regexp
RegKey *regexp.Regexp RegKey *regexp.Regexp
RegEmail *regexp.Regexp
CleanInterval time.Duration CleanInterval time.Duration
DefaultExpire int DefaultExpire int
} }
@@ -107,6 +118,8 @@ func (c *Config) ApplyDefaults() {
c.RegNormalizedFilename = regexp.MustCompile(`[^\w\d\-_\.]`) c.RegNormalizedFilename = regexp.MustCompile(`[^\w\d\-_\.]`)
c.RegDuration = regexp.MustCompile(`[^dhms0-9]`) c.RegDuration = regexp.MustCompile(`[^dhms0-9]`)
c.RegKey = regexp.MustCompile(`[^a-zA-Z0-9\-]`) c.RegKey = regexp.MustCompile(`[^a-zA-Z0-9\-]`)
c.RegEmail = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
c.RegEmail = regexp.MustCompile(`[^a-z0-9._%+\-@0-9]`)
c.CleanInterval = 10 * time.Second c.CleanInterval = 10 * time.Second
c.DefaultExpire = 30 * 86400 // 1 month c.DefaultExpire = 30 * 86400 // 1 month

View File

@@ -12,33 +12,50 @@ const formtemplate = `
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>File upload form</title> <title>File upload form</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha2/dist/css/bootstrap.min.css"
rel="stylesheet" integrity="sha384-aFq/bzH65dt+w6FI2ooMVUpc+21e0SRygnTpmBvdBgSdnuTN7QbdgL+OapgHtvPp" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css">
</head> </head>
<body> <body>
<div class="container">
<h4>Upload form {{ .Id }}</h4> <h4>Upload form {{ .Id }}</h4>
<!-- Status message --> <!-- Response -->
<div class="statusMsg"></div> <div class="statusMsg"></div>
<!-- File upload form --> <!-- File upload form -->
<div class="col-lg-12"> <div class="col-lg-12">
<form id="fupForm" enctype="multipart/form-data" action="/v1/uploads" method="POST"> <form id="UploadForm" enctype="multipart/form-data" action="/v1/uploads" method="POST">
<div class="form-group"> <div class="mb-3 row">
<label for="expire">Expire</label> <p>
<input type="expire" class="form-control" id="expire" name="expire" placeholder="Enter expire"/> Use this form to upload one or more files. The creator of the form will automatically get notified.
</p>
</div> </div>
<div class="form-group"> <div class="mb-3 row">
<label for="file">Files</label> <label for="file" class="col-sm-2 col-form-label">Select</label>
<input type="file" class="form-control" id="file" name="uploads[]" multiple /> <div class="col-sm-10">
<input type="file" class="form-control" id="file" name="uploads[]" multiple
/>
</div>
</div> </div>
<div class="mb-3 row">
<label for="display" class="col-sm-2 col-form-label">Selected Files</label>
<div class="col-sm-10">
<!-- <input type="textara" class="form-control" id="upload-file-info" readonly>-->
<div id="upload-file-info"></div>
</div>
</div>
<input type="submit" name="submit" class="btn btn-success submitBtn" value="Upload"/> <input type="submit" name="submit" class="btn btn-success submitBtn" value="Upload"/>
</form> </form>
</div> </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-qKXV1j0HvMUeCBQ+QVp7JcfGl760yU08IQ+GpUo5hlbpg51QRiuqHAJz8+BrxE/N" crossorigin="anonymous"></script>
<script> <script>
$(document).ready(function(){ $(document).ready(function(){
// Submit form data via Ajax // Submit form data via Ajax
$("#fupForm").on('submit', function(e){ $("#UploadForm").on('submit', function(e){
e.preventDefault(); e.preventDefault();
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
@@ -50,26 +67,34 @@ const formtemplate = `
processData:false, processData:false,
beforeSend: function(xhr){ beforeSend: function(xhr){
$('.submitBtn').attr("disabled","disabled"); $('.submitBtn').attr("disabled","disabled");
$('#fupForm').css("opacity",".5"); $('#UploadForm').css("opacity",".5");
xhr.setRequestHeader('Authorization', 'Bearer {{.Id}}'); xhr.setRequestHeader('Authorization', 'Bearer {{.Id}}');
}, },
success: function(response){ success: function(response){
$('.statusMsg').html(''); $('.statusMsg').html('');
if(response.success){ if(response.success){
$('#fupForm')[0].reset(); $('#UploadForm')[0].reset();
$('.statusMsg').html('<p class="alert alert-success">Your upload is available at <a href="' $('.statusMsg').html('<p class="alert alert-success">Your upload is available at <code>'
+response.uploads[0].url+'">here</a> for download</p>'); +response.uploads[0].url+'</code> for download</p>');
$('#fupForm').hide(); $('#UploadForm').hide();
}else{ }else{
$('.statusMsg').html('<p class="alert alert-danger">'+response.message+'</p>'); $('.statusMsg').html('<p class="alert alert-danger">'+response.message+'</p>');
} }
$('#fupForm').css("opacity",""); $('#UploadForm').css("opacity","");
$(".submitBtn").removeAttr("disabled"); $(".submitBtn").removeAttr("disabled");
} }
}); });
}); });
$("#file").on('change', function() {
$("#upload-file-info").empty();
for (var i = 0; i < $(this).get(0).files.length; ++i) {
$("#upload-file-info").append('<i class="bi-check-lg"></i> ' + $(this).get(0).files[i].name + '<br>');
}
});
}); });
</script> </script>
</body> </body>
</html> </html>

View File

@@ -22,7 +22,6 @@
<!-- File upload form --> <!-- File upload form -->
<div class="col-lg-12"> <div class="col-lg-12">
<form id="UploadForm" enctype="multipart/form-data" action="/v1/uploads" method="POST"> <form id="UploadForm" enctype="multipart/form-data" action="/v1/uploads" method="POST">
<input name="expire" value="asap" type="hidden"/>
<div class="mb-3 row"> <div class="mb-3 row">
<p> <p>
Use this form to upload one or more files. The creator of the form will automatically get notified. Use this form to upload one or more files. The creator of the form will automatically get notified.
@@ -72,8 +71,8 @@
$('.statusMsg').html(''); $('.statusMsg').html('');
if(response.success){ if(response.success){
$('#UploadForm')[0].reset(); $('#UploadForm')[0].reset();
$('.statusMsg').html('<p class="alert alert-success">Your upload is available at <a href="' $('.statusMsg').html('<p class="alert alert-success">Your upload is available at <code>'
+response.uploads[0].url+'">here</a> for download</p>'); +response.uploads[0].url+'</code> for download</p>');
$('#UploadForm').hide(); $('#UploadForm').hide();
}else{ }else{
$('.statusMsg').html('<p class="alert alert-danger">'+response.message+'</p>'); $('.statusMsg').html('<p class="alert alert-danger">'+response.message+'</p>');

View File

@@ -46,6 +46,7 @@ type Config struct {
// required for forms // required for forms
Description string Description string
Notify string
} }
func Getversion() string { func Getversion() string {

View File

@@ -64,7 +64,7 @@ func FormCreateCommand(conf *cfg.Config) *cobra.Command {
// options // options
formCreateCmd.PersistentFlags().StringVarP(&conf.Expire, "expire", "e", "", "Expire setting: asap or duration (accepted shortcuts: dmh)") formCreateCmd.PersistentFlags().StringVarP(&conf.Expire, "expire", "e", "", "Expire setting: asap or duration (accepted shortcuts: dmh)")
formCreateCmd.PersistentFlags().StringVarP(&conf.Description, "description", "D", "", "Description of the form") formCreateCmd.PersistentFlags().StringVarP(&conf.Description, "description", "D", "", "Description of the form")
formCreateCmd.PersistentFlags().StringVarP(&conf.Description, "notify", "n", "", "Email address to get notified when consumer has uploaded files") formCreateCmd.PersistentFlags().StringVarP(&conf.Notify, "notify", "n", "", "Email address to get notified when consumer has uploaded files")
formCreateCmd.Aliases = append(formCreateCmd.Aliases, "add") formCreateCmd.Aliases = append(formCreateCmd.Aliases, "add")
formCreateCmd.Aliases = append(formCreateCmd.Aliases, "+") formCreateCmd.Aliases = append(formCreateCmd.Aliases, "+")

View File

@@ -50,7 +50,7 @@ type ListParams struct {
Apicontext string `json:"apicontext"` Apicontext string `json:"apicontext"`
} }
const Maxwidth = 10 const Maxwidth = 12
/* /*
Create a new request object for outgoing queries Create a new request object for outgoing queries
@@ -142,15 +142,15 @@ func HandleResponse(c *cfg.Config, resp *req.Response) error {
fmt.Println(trace) fmt.Println(trace)
} }
if !resp.IsSuccessState() {
return fmt.Errorf("bad response: %s", resp.Status)
}
if err := json.Unmarshal([]byte(resp.String()), &r); err != nil { if err := json.Unmarshal([]byte(resp.String()), &r); err != nil {
// text output! // text output!
r.Message = resp.String() r.Message = resp.String()
} }
if !resp.IsSuccessState() {
return fmt.Errorf("bad response: %s (%s)", resp.Status, r.Message)
}
if !r.Success { if !r.Success {
if len(r.Message) == 0 { if len(r.Message) == 0 {
if resp.Err != nil { if resp.Err != nil {
@@ -331,6 +331,7 @@ func CreateForm(w io.Writer, c *cfg.Config) error {
SetFormData(map[string]string{ SetFormData(map[string]string{
"expire": c.Expire, "expire": c.Expire,
"description": c.Description, "description": c.Description,
"notify": c.Notify,
}). }).
Post(rq.Url) Post(rq.Url)

View File

@@ -95,6 +95,7 @@ func WriteExtended(w io.Writer, response *common.Response) {
fmt.Fprintf(w, format, "Context", entry.Context) fmt.Fprintf(w, format, "Context", entry.Context)
fmt.Fprintf(w, format, "Created", entry.Created) fmt.Fprintf(w, format, "Created", entry.Created)
fmt.Fprintf(w, format, "Description", entry.Description) fmt.Fprintf(w, format, "Description", entry.Description)
fmt.Fprintf(w, format, "Notify", entry.Notify)
fmt.Fprintf(w, format, "Url", entry.Url) fmt.Fprintf(w, format, "Url", entry.Url)
fmt.Fprintln(w) fmt.Fprintln(w)
} }