implemented attachment downloading

This commit is contained in:
2026-01-20 22:23:46 -07:00
parent 664525ea36
commit 700dbd6726
7 changed files with 191 additions and 59 deletions
-57
View File
@@ -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.
+102
View File
@@ -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.
+64 -1
View File
@@ -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
}
+1
View File
@@ -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)
+12
View File
@@ -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)
+11
View File
@@ -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 {
+1 -1
View File
@@ -21,7 +21,7 @@
<a href="/user/{{ post_userName }}" target="_blank" class="text-blue-700 hover:text-blue-900">{{ post_userName }}</a>,
{{ DisplayDateTime(post_cur.Posted, .) }}</em>)
{{ if post_attach.Filename != "" }}
<a href="/TODO" title="(Attachment {{ post_attach.Filename }} - {{ post_attach.Length }} bytes)"
<a href="/attachment/{{ post_cur.PostId }}" title="(Attachment {{ post_attach.Filename }} - {{ post_attach.Length }} bytes)"
{{ if hasPrefix(post_attach.MIMEType, "text/") || hasPrefix(post_attach.MIMEType, "image/" )}}
target="_blank"
{{ end }}