diff --git a/conference.go b/conference.go index e276e08..65b569b 100644 --- a/conference.go +++ b/conference.go @@ -14,8 +14,6 @@ import ( "context" "errors" "fmt" - "io" - "mime/multipart" "net/http" "reflect" "strconv" @@ -277,61 +275,6 @@ func NewTopic(ctxt ui.AmContext) (string, any, error) { return ui.ErrorPage(ctxt, errors.New("invalid button clicked on form")) } -// slurpFile reads the contrents of a multipart.File into memory. -func slurpFile(file *multipart.FileHeader) ([]byte, error) { - f, err := file.Open() - if err != nil { - return nil, err - } - defer f.Close() - return io.ReadAll(f) -} - -/* AttachmentUpload adds an attachment to a post. - * Parameters: - * ctxt - The AmContext for the request. - * Returns: - * Command string dictating what to be rendered. - * Data as a parameter for the command string. - * Standard Go error status. - */ -func AttachmentUpload(ctxt ui.AmContext) (string, any, error) { - target := ctxt.FormField("tgt") - postidStr := ctxt.FormField("post") - postId, err := strconv.ParseInt(postidStr, 10, 64) - if err != nil { - return ui.ErrorPage(ctxt, fmt.Errorf("internal error converting postID: %v", err)) - } - if ctxt.FormFieldIsSet("upload") { - file, err := ctxt.FormFile("thefile") - if err == nil { - if file.Size <= (1024 * 1024) { // 1 Mb - var post *database.PostHeader - post, err = database.AmGetPost(ctxt.Ctx(), postId) - if err == nil { - var data []byte - data, err = slurpFile(file) - if err == nil { - err = post.SetAttachment(ctxt.Ctx(), ctxt.CurrentUser(), file.Filename, file.Header.Get("Content-Type"), int32(file.Size), data) - if err == nil { - return "redirect", target, nil - } - } - } - } else { - err = errors.New("the file is too large to be attached") - } - } - - ctxt.VarMap().Set("target", target) - ctxt.VarMap().Set("post", postId) - ctxt.VarMap().Set("amsterdam_pageTitle", "Upload Attachment") - ctxt.VarMap().Set("errorMessage", err.Error()) - return "framed_template", "attachment_upload.jet", nil - } - return ui.ErrorPage(ctxt, errors.New("invalid button clicked on form")) -} - /* breakRange breaks up a post range into two elements. * Parameters: * topic - The topic within which the range is defined. diff --git a/conference_ops.go b/conference_ops.go index 708521d..734ccee 100644 --- a/conference_ops.go +++ b/conference_ops.go @@ -11,12 +11,114 @@ package main import ( + "context" + "errors" "fmt" + "io" + "mime/multipart" + "strconv" + "strings" "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/ui" ) +// slurpFile reads the contrents of a multipart.File into memory. +func slurpFile(file *multipart.FileHeader) ([]byte, error) { + f, err := file.Open() + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(f) +} + +/* AttachmentUpload adds an attachment to a post. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + * Standard Go error status. + */ +func AttachmentUpload(ctxt ui.AmContext) (string, any, error) { + target := ctxt.FormField("tgt") + postidStr := ctxt.FormField("post") + postId, err := strconv.ParseInt(postidStr, 10, 64) + if err != nil { + return ui.ErrorPage(ctxt, fmt.Errorf("internal error converting postID: %v", err)) + } + if ctxt.FormFieldIsSet("upload") { + file, err := ctxt.FormFile("thefile") + if err == nil { + var post *database.PostHeader + post, err = database.AmGetPost(ctxt.Ctx(), postId) + if err == nil { + var data []byte + data, err = slurpFile(file) + if err == nil { + err = post.SetAttachment(ctxt.Ctx(), ctxt.CurrentUser(), file.Filename, file.Header.Get("Content-Type"), int32(file.Size), data, ctxt.RemoteIP()) + if err == nil { + return "redirect", target, nil + } + } + } + } + + ctxt.VarMap().Set("target", target) + ctxt.VarMap().Set("post", postId) + ctxt.VarMap().Set("amsterdam_pageTitle", "Upload Attachment") + ctxt.VarMap().Set("errorMessage", err.Error()) + return "framed_template", "attachment_upload.jet", nil + } + return ui.ErrorPage(ctxt, errors.New("invalid button clicked on form")) +} + +/* AttachmentSend sends the data of an attachment to the browser. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + * Standard Go error status. + */ +func AttachmentSend(ctxt ui.AmContext) (string, any, error) { + postIdStr := ctxt.URLParam("post") + postId, err := strconv.ParseInt(postIdStr, 10, 64) + if err != nil { + return ui.ErrorPage(ctxt, fmt.Errorf("internal error converting postID: %v", err)) + } + + hdr, err := database.AmGetPost(ctxt.Ctx(), postId) + if err != nil { + return ui.ErrorPage(ctxt, err) + } + + // Retrieve attachment info and data. + info, err := hdr.AttachmentInfo(ctxt.Ctx()) + if err != nil { + return ui.ErrorPage(ctxt, err) + } else if info == nil { + return ui.ErrorPage(ctxt, errors.New("attachment not found")) + } + data, err := hdr.AttachmentData(ctxt.Ctx()) + if err != nil { + return ui.ErrorPage(ctxt, err) + } + + // Record a "hit" on this attachment in the background. + ampool.Submit(func(ctx context.Context) { + hdr.HitAttachment(ctx) + }) + + // Send the attachment data. + ctxt.SetOutputType(info.MIMEType) + if !strings.HasPrefix(info.MIMEType, "image/") { + ctxt.SetHeader("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", info.Filename)) + } + return "bytes", data, nil +} + /* HideTopic hides or shows rthe current topic for the current user. * Parameters: * ctxt - The AmContext for the request. diff --git a/database/post.go b/database/post.go index c62d82d..dbfda0a 100644 --- a/database/post.go +++ b/database/post.go @@ -82,6 +82,9 @@ func (p *PostHeader) IsPublished(ctx context.Context) (bool, error) { * Standard Go error status. */ func (p *PostHeader) AttachmentInfo(ctx context.Context) (*PostAttachInfo, error) { + if p.ScribbleDate != nil && p.ScribbleUid != nil { + return nil, errors.New("no attachment data for scribbled post") + } rs, err := amdb.QueryContext(ctx, "SELECT filename, mimetype, datalen FROM postattach WHERE postid = ?", p.PostId) if err != nil { return nil, err @@ -94,6 +97,50 @@ func (p *PostHeader) AttachmentInfo(ctx context.Context) (*PostAttachInfo, error return &rc, err } +/* AttachmentData returns attachment data for a post. + * Parameters: + * ctx - Standard Go context value. + * Returns: + * Attachment data as a byte array. + * Standard Go error status. + */ +func (p *PostHeader) AttachmentData(ctx context.Context) ([]byte, error) { + if p.ScribbleDate != nil && p.ScribbleUid != nil { + return nil, errors.New("no attachment data for scribbled post") + } + rs, err := amdb.QueryContext(ctx, "SELECT datalen, stgmethod, data FROM postattach WHERE postid = ?", p.PostId) + if err != nil { + return nil, err + } + if !rs.Next() { + return nil, nil + } + var datalen int32 + var stgmethod int16 + var dbdata []byte + err = rs.Scan(&datalen, &stgmethod, &dbdata) + if err != nil { + return nil, err + } + if stgmethod == stgMethodPlain { + return dbdata, nil + } + r, err := gzip.NewReader(bytes.NewReader(dbdata)) + if err != nil { + return nil, err + } + outdata := make([]byte, 0, datalen) + n, err := r.Read(outdata) + r.Close() + if err != nil || n < int(datalen) { + if err == nil { + err = errors.New("unable to read entire attachment") + } + return nil, err + } + return outdata, nil +} + /* SetAttachment sets the attachment data for a post. * Parameters: * ctx - Standard Go context value. @@ -105,7 +152,11 @@ func (p *PostHeader) AttachmentInfo(ctx context.Context) (*PostAttachInfo, error * Returns: * Standard Go error status. */ -func (p *PostHeader) SetAttachment(ctx context.Context, u *User, fileName string, mimeType string, length int32, data []byte) error { +func (p *PostHeader) SetAttachment(ctx context.Context, u *User, fileName string, mimeType string, length int32, data []byte, ipaddr string) error { + var ar *AuditRecord = nil + defer func() { + AmStoreAudit(ar) + }() if p.ScribbleDate != nil && p.ScribbleUid != nil { return errors.New("cannot attach to scribbled post") } @@ -146,6 +197,18 @@ func (p *PostHeader) SetAttachment(ctx context.Context, u *User, fileName string // Write to the database. _, err = amdb.ExecContext(ctx, "INSERT INTO postattach (postid, datalen, filename, mimetype, stgmethod, data) VALUES (?, ?, ?, ?, ?, ?)", p.PostId, length, fileName, mimeType, stgmethod, realData) + // Generate an audit record. + ar = AmNewAudit(AuditConferenceUploadAttachment, u.Uid, ipaddr, fmt.Sprintf("post=%d", p.PostId), + fmt.Sprintf("len=%d,type=%s,name=%s,method=%d", length, mimeType, fileName, stgmethod)) + return err +} + +// HitAttachment records a "hit" on an attachment. +func (p *PostHeader) HitAttachment(ctx context.Context) error { + if p.ScribbleDate != nil && p.ScribbleUid != nil { + return errors.New("no attachment on scribbled post") + } + _, err := amdb.ExecContext(ctx, "UPDATE postattach SET hits = hits + 1, lasthit = NOW() WHERE postid = ?", p.PostId) return err } diff --git a/main.go b/main.go index 267b36a..98f4acc 100644 --- a/main.go +++ b/main.go @@ -75,6 +75,7 @@ func setupEcho() *echo.Echo { e.GET("/create_comm", ui.AmWrap(CreateCommunityForm)) e.POST("/create_comm", ui.AmWrap(CreateCommunity)) e.POST("/attachment_upload", ui.AmWrap(AttachmentUpload)) + e.GET("/attachment/:post", ui.AmWrap(AttachmentSend)) // community group commGroup := e.Group("/comm/:cid", ui.SetCommunity) diff --git a/ui/amcontext.go b/ui/amcontext.go index 948ee5a..7dd44fb 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -33,6 +33,7 @@ import ( // AmContext is the interface for Amsterdam's wrapper context that exposes the required functionality. type AmContext interface { + AddHeader(string, string) ClearCommunityContext() ClearLoginCookie() ClearSession() @@ -59,6 +60,7 @@ type AmContext interface { ReplaceUser(*database.User) SaveSession() error SetCommunityContext(string) error + SetHeader(string, string) SetLeftMenu(string) SetLoginCookie(string) SetOutputType(string) @@ -96,6 +98,11 @@ type amContext struct { isMemberLocked bool } +// AddHeader adds a header to the response. +func (c *amContext) AddHeader(key, value string) { + c.echoContext.Response().Header().Add(key, value) +} + // ClearCommunityContext clears the community context so changes will be reflected. func (c *amContext) ClearCommunityContext() { c.community = nil @@ -343,6 +350,11 @@ func (c *amContext) SetCommunityContext(param string) error { return nil } +// SetHeader sets a header on the output. +func (c *amContext) SetHeader(key, value string) { + c.echoContext.Response().Header().Set(key, value) +} + // SetLeftMenu sets the current topmost left menu name value. func (c *amContext) SetLeftMenu(name string) { c.session.Set("leftMenu", name) diff --git a/ui/render_wrap.go b/ui/render_wrap.go index 80bb673..e2673ec 100644 --- a/ui/render_wrap.go +++ b/ui/render_wrap.go @@ -13,7 +13,9 @@ package ui import ( "fmt" "net/http" + "time" + "github.com/klauspost/lctime" "github.com/labstack/echo/v4" log "github.com/sirupsen/logrus" ) @@ -89,6 +91,9 @@ func ErrorPage(ctxt AmContext, input_err error) (string, any, error) { return "framed_template", "error.jet", nil } +// expireTime is the expiration time sent in the dynamic headers. +var expireTime string = lctime.Strftime("%c", time.Unix(1, 0)) + /* AmWrap wraps the Amsterdam handler function in a wrapper that implements the spec for * Echo handler functions. * Parameters: @@ -99,6 +104,12 @@ func ErrorPage(ctxt AmContext, input_err error) (string, any, error) { func AmWrap(myfunc func(AmContext) (string, any, error)) echo.HandlerFunc { return func(ctxt echo.Context) error { amctxt := AmContextFromEchoContext(ctxt) + + // Add the dynamic headers. + ctxt.Response().Header().Set("Pragma", "No-cache") + ctxt.Response().Header().Set("Cache-Control", "no-cache") + ctxt.Response().Header().Set("Expires", expireTime) + // Exec the wrapped function. what, rc, err := myfunc(amctxt) if err == nil { diff --git a/ui/views/singlepost.jet b/ui/views/singlepost.jet index 9541d54..6ca02ae 100644 --- a/ui/views/singlepost.jet +++ b/ui/views/singlepost.jet @@ -21,7 +21,7 @@ {{ post_userName }}, {{ DisplayDateTime(post_cur.Posted, .) }}) {{ if post_attach.Filename != "" }} -