diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index 24040e53..9c45b90c 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -40,10 +40,6 @@ func TestStreams(t *testing.T) { AssertRegexMatch(t, BuildStreams(), RegexStreams, nil) } -func TestSiteMap(t *testing.T) { - AssertRegexMatch(t, BuildSiteMap(), RegexSiteMap, nil) -} - func TestWhenIsIt(t *testing.T) { AssertRegexMatch(t, BuildWhenIsIt(), RegexWhenIsIt, nil) } @@ -288,6 +284,11 @@ func TestEpisodeGuide(t *testing.T) { AssertSubdomain(t, BuildCineraIndex("hero", "code"), "hero") } +func TestAssetUpload(t *testing.T) { + AssertRegexMatch(t, BuildAssetUpload("hero"), RegexAssetUpload, nil) + AssertSubdomain(t, BuildAssetUpload("hero"), "hero") +} + func TestProjectCSS(t *testing.T) { AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil) } @@ -308,7 +309,8 @@ func TestPublic(t *testing.T) { } func TestForumMarkRead(t *testing.T) { - AssertRegexMatch(t, BuildForumMarkRead(c.CurrentProject.Slug, 5), RegexForumMarkRead, map[string]string{"sfid": "5"}) + AssertRegexMatch(t, BuildForumMarkRead("hero", 5), RegexForumMarkRead, map[string]string{"sfid": "5"}) + AssertSubdomain(t, BuildForumMarkRead("hero", 5), "hero") } func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) { diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index feba7796..a33b83d5 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -609,6 +609,17 @@ func BuildDiscordShowcaseBacklog() string { return Url("/discord_showcase_backlog", nil) } +/* +* User assets + */ + +var RegexAssetUpload = regexp.MustCompile("^/upload_asset$") + +// NOTE(asaf): Providing the projectSlug avoids any CORS problems. +func BuildAssetUpload(projectSlug string) string { + return ProjectUrl("/upload_asset", nil, projectSlug) +} + /* * Assets */ diff --git a/src/templates/src/editor.html b/src/templates/src/editor.html index cf084d26..63e5dcdd 100644 --- a/src/templates/src/editor.html +++ b/src/templates/src/editor.html @@ -10,6 +10,10 @@ #editor { resize: vertical; } + + #editor.drop { + box-shadow: inset 0px 0px 5px yellow; + } {{ end }} @@ -104,6 +108,9 @@ {{ end }} diff --git a/src/website/assets.go b/src/website/assets.go new file mode 100644 index 00000000..6e4fe19d --- /dev/null +++ b/src/website/assets.go @@ -0,0 +1,110 @@ +package website + +import ( + "bytes" + "encoding/json" + "fmt" + "image" + "io" + "net/http" + "strconv" + "strings" + + "git.handmade.network/hmn/hmn/src/assets" + "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/models" +) + +type AssetUploadResult struct { + Url string `json:"url,omitempty"` + Mime string `json:"mime,omitempty"` + Error string `json:"error,omitempty"` +} + +const assetMaxSize = 10 * 1024 * 1024 +const assetMaxSizeAdmin = 10 * 1024 * 1024 * 1024 + +func AssetMaxSize(user *models.User) int { + if user.IsStaff { + return assetMaxSizeAdmin + } else { + return assetMaxSize + } +} + +func AssetUpload(c *RequestContext) ResponseData { + maxFilesize := AssetMaxSize(c.CurrentUser) + + contentLength, hasLength := c.Req.Header["Content-Length"] + if hasLength { + filesize, err := strconv.Atoi(contentLength[0]) + if err == nil && filesize > maxFilesize { + res := ResponseData{ + StatusCode: http.StatusOK, + } + jsonString, _ := json.Marshal(AssetUploadResult{ + Error: fmt.Sprintf("Filesize too big. Maximum size is %d.", maxFilesize), + }) + res.Write(jsonString) + return res + } + } + + filenameHeader, hasFilename := c.Req.Header["Hmn-Upload-Filename"] + originalFilename := "" + if hasFilename { + originalFilename = strings.ReplaceAll(filenameHeader[0], " ", "_") + } + + bodyReader := http.MaxBytesReader(c.Res, c.Req.Body, int64(maxFilesize)) + data, err := io.ReadAll(bodyReader) + if err != nil { + res := ResponseData{ + StatusCode: http.StatusBadRequest, + Errors: []error{err}, + } + return res + } + + mimeType := http.DetectContentType(data) + width := 0 + height := 0 + + if strings.HasPrefix(mimeType, "image") { + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err == nil { + width = config.Width + height = config.Height + } else { + // NOTE(asaf): Not image + mimeType = "application/octet-stream" + } + } + + asset, err := assets.Create(c.Context(), c.Conn, assets.CreateInput{ + Content: data, + Filename: originalFilename, + ContentType: mimeType, + UploaderID: &c.CurrentUser.ID, + Width: width, + Height: height, + }) + + if err != nil { + res := ResponseData{ + StatusCode: http.StatusBadRequest, + Errors: []error{err}, + } + return res + } + + res := ResponseData{ + StatusCode: http.StatusOK, + } + jsonString, err := json.Marshal(AssetUploadResult{ + Url: hmnurl.BuildS3Asset(asset.S3Key), + Mime: asset.MimeType, + }) + res.Write(jsonString) + return res +} diff --git a/src/website/blogs.go b/src/website/blogs.go index 036c6915..0f01fa71 100644 --- a/src/website/blogs.go +++ b/src/website/blogs.go @@ -236,7 +236,7 @@ func BlogNewThread(c *RequestContext) ResponseData { []templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)}, ) - editData := getEditorDataForNew(baseData, nil) + editData := getEditorDataForNew(c.CurrentUser, baseData, nil) editData.SubmitUrl = hmnurl.BuildBlogNewThread(c.CurrentProject.Slug) editData.SubmitLabel = "Create Post" @@ -319,7 +319,7 @@ func BlogPostEdit(c *RequestContext) ResponseData { BlogThreadBreadcrumbs(c.CurrentProject.Slug, &postData.Thread), ) - editData := getEditorDataForEdit(baseData, postData) + editData := getEditorDataForEdit(c.CurrentUser, baseData, postData) editData.SubmitUrl = hmnurl.BuildBlogPostEdit(c.CurrentProject.Slug, cd.ThreadID, cd.PostID) editData.SubmitLabel = "Submit Edited Post" if postData.Thread.FirstID != postData.Post.ID { @@ -388,7 +388,7 @@ func BlogPostReply(c *RequestContext) ResponseData { replyPost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) replyPost.AddContentVersion(postData.CurrentVersion, postData.Editor) - editData := getEditorDataForNew(baseData, &replyPost) + editData := getEditorDataForNew(c.CurrentUser, baseData, &replyPost) editData.SubmitUrl = hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, cd.PostID) editData.SubmitLabel = "Submit Reply" diff --git a/src/website/forums.go b/src/website/forums.go index 03a106fa..ed3f9ecb 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -49,13 +49,18 @@ type editorData struct { IsEditing bool EditInitialContents string PostReplyingTo *templates.Post + + MaxFileSize int + UploadUrl string } -func getEditorDataForNew(baseData templates.BaseData, replyPost *templates.Post) editorData { +func getEditorDataForNew(currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData { result := editorData{ BaseData: baseData, CanEditTitle: replyPost == nil, PostReplyingTo: replyPost, + MaxFileSize: AssetMaxSize(currentUser), + UploadUrl: hmnurl.BuildAssetUpload(baseData.Project.Subdomain), } if replyPost != nil { @@ -65,13 +70,15 @@ func getEditorDataForNew(baseData templates.BaseData, replyPost *templates.Post) return result } -func getEditorDataForEdit(baseData templates.BaseData, p postAndRelatedModels) editorData { +func getEditorDataForEdit(currentUser *models.User, baseData templates.BaseData, p postAndRelatedModels) editorData { return editorData{ BaseData: baseData, Title: p.Thread.Title, CanEditTitle: p.Thread.FirstID == p.Post.ID, IsEditing: true, EditInitialContents: p.CurrentVersion.TextRaw, + MaxFileSize: AssetMaxSize(currentUser), + UploadUrl: hmnurl.BuildAssetUpload(baseData.Project.Subdomain), } } @@ -589,7 +596,7 @@ func ForumNewThread(c *RequestContext) ResponseData { } baseData := getBaseData(c, "Create New Thread", SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID)) - editData := getEditorDataForNew(baseData, nil) + editData := getEditorDataForNew(c.CurrentUser, baseData, nil) editData.SubmitUrl = hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true) editData.SubmitLabel = "Post New Thread" @@ -676,7 +683,7 @@ func ForumPostReply(c *RequestContext) ResponseData { replyPost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) replyPost.AddContentVersion(postData.CurrentVersion, postData.Editor) - editData := getEditorDataForNew(baseData, &replyPost) + editData := getEditorDataForNew(c.CurrentUser, baseData, &replyPost) editData.SubmitUrl = hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID) editData.SubmitLabel = "Submit Reply" @@ -745,7 +752,7 @@ func ForumPostEdit(c *RequestContext) ResponseData { } baseData := getBaseData(c, title, ForumThreadBreadcrumbs(cd.LineageBuilder, c.CurrentProject, &postData.Thread)) - editData := getEditorDataForEdit(baseData, postData) + editData := getEditorDataForEdit(c.CurrentUser, baseData, postData) editData.SubmitUrl = hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID) editData.SubmitLabel = "Submit Edited Post" diff --git a/src/website/routes.go b/src/website/routes.go index de30daab..5c358de2 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -231,6 +231,8 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe ), http.StatusMovedPermanently) }) + mainRoutes.POST(hmnurl.RegexAssetUpload, AssetUpload) + mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex) mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit) mainRoutes.POST(hmnurl.RegexPodcastEdit, PodcastEditSubmit)