First working version. No user limits or usage tracking.

This commit is contained in:
Asaf Gartner 2021-09-22 02:13:11 +03:00
parent 070ea5cc20
commit dc56b1f5d0
7 changed files with 302 additions and 13 deletions

View File

@ -40,10 +40,6 @@ func TestStreams(t *testing.T) {
AssertRegexMatch(t, BuildStreams(), RegexStreams, nil) AssertRegexMatch(t, BuildStreams(), RegexStreams, nil)
} }
func TestSiteMap(t *testing.T) {
AssertRegexMatch(t, BuildSiteMap(), RegexSiteMap, nil)
}
func TestWhenIsIt(t *testing.T) { func TestWhenIsIt(t *testing.T) {
AssertRegexMatch(t, BuildWhenIsIt(), RegexWhenIsIt, nil) AssertRegexMatch(t, BuildWhenIsIt(), RegexWhenIsIt, nil)
} }
@ -288,6 +284,11 @@ func TestEpisodeGuide(t *testing.T) {
AssertSubdomain(t, BuildCineraIndex("hero", "code"), "hero") 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) { func TestProjectCSS(t *testing.T) {
AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil) AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
} }
@ -308,7 +309,8 @@ func TestPublic(t *testing.T) {
} }
func TestForumMarkRead(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) { func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) {

View File

@ -609,6 +609,17 @@ func BuildDiscordShowcaseBacklog() string {
return Url("/discord_showcase_backlog", nil) 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 * Assets
*/ */

View File

@ -10,6 +10,10 @@
#editor { #editor {
resize: vertical; resize: vertical;
} }
#editor.drop {
box-shadow: inset 0px 0px 5px yellow;
}
</style> </style>
{{ end }} {{ end }}
@ -104,6 +108,9 @@
</div> </div>
<script> <script>
const maxFileSize = {{ .MaxFileSize }};
const uploadUrl = {{ .UploadUrl }};
const form = document.querySelector('#form'); const form = document.querySelector('#form');
const titleField = document.querySelector('#title'); // may be undefined, be careful! const titleField = document.querySelector('#title'); // may be undefined, be careful!
const textField = document.querySelector('#editor'); const textField = document.querySelector('#editor');
@ -177,5 +184,155 @@
form.addEventListener('submit', e => { form.addEventListener('submit', e => {
window.localStorage.removeItem(storageKey); window.localStorage.removeItem(storageKey);
}); });
let fileCounter = 0;
textField.addEventListener("dragover", function(ev) {
let effect = "none";
for (let i = 0; i < ev.dataTransfer.items.length; ++i) {
if (ev.dataTransfer.items[i].kind.toLowerCase() == "file") {
effect = "copy";
break;
}
}
ev.dataTransfer.dropEffect = effect;
ev.preventDefault();
});
let enterCounter = 0;
textField.addEventListener("dragenter", function(ev) {
enterCounter++;
let droppable = false;
for (let i = 0; i < ev.dataTransfer.items.length; ++i) {
if (ev.dataTransfer.items[i].kind.toLowerCase() == "file") {
droppable = true;
break;
}
}
if (droppable) {
textField.classList.add("drop");
}
});
textField.addEventListener("dragleave", function(ev) {
enterCounter--;
if (enterCounter == 0) {
textField.classList.remove("drop");
}
});
function makeUploadString(uploadNumber, filename) {
return `Uploading file #${uploadNumber}: \`${filename}\`...`;
}
textField.addEventListener("drop", function(ev) {
enterCounter = 0;
textField.classList.remove("drop");
let items = [];
for (let i = 0; i < ev.dataTransfer.files.length; ++i) {
let f = ev.dataTransfer.files[i];
if (f.size < maxFileSize) {
items.push({ file: f, error: null });
} else {
items.push({ file: null, error: `\`${f.name}\` is too big! Max size is ${maxFileSize} but the file is ${f.size}.` });
}
}
let cursorStart = textField.selectionStart;
let cursorEnd = textField.selectionEnd;
let toInsert = "\n";
for (let i = 0; i < items.length; ++i) {
if (items[i].file) {
fileCounter++;
toInsert += makeUploadString(fileCounter, items[i].file.name) + "\n";
startUpload(fileCounter, items[i].file);
} else {
toInsert += `${items[i].error}\n`;
}
}
textField.value = textField.value.substring(0, cursorStart) + toInsert + textField.value.substring(cursorEnd, textField.value.length);
doMarkdown();
ev.preventDefault();
});
function replaceUploadString(upload, newString) {
let cursorStart = textField.selectionStart;
let cursorEnd = textField.selectionEnd;
let uploadString = makeUploadString(upload.uploadNumber, upload.file.name);
let insertIndex = textField.value.indexOf(uploadString)
textField.value = textField.value.replace(uploadString, newString);
if (cursorStart <= insertIndex + uploadString.length) {
textField.selectionStart = cursorStart;
} else {
textField.selectionStart = cursorStart - uploadString.length + newString.length;
}
if (cursorEnd <= insertIndex + uploadString.length) {
textField.selectionEnd = cursorEnd;
} else {
textField.selectionEnd = cursorEnd - uploadString.length + newString.length;
}
doMarkdown();
}
let uploadQueue = [];
let currentUpload = null;
let currentXhr = null;
function startUpload(uploadNumber, file) {
uploadQueue.push({
uploadNumber: uploadNumber,
file: file
});
uploadNext();
}
function uploadDone(ev) {
try {
if (currentXhr.status == 200 && currentXhr.response) {
if (currentXhr.response.url) {
let url = currentXhr.response.url;
let newString = `[${currentUpload.file.name}](${url})`;
if (currentXhr.response.mime.startsWith("image")) {
newString = "!" + newString;
}
replaceUploadString(currentUpload, newString);
} else if (currentXhr.response.error) {
replaceUploadString(currentUpload, `Upload failed for \`${currentUpload.file.name}\`: ${currentXhr.response.error}.`);
} else {
replaceUploadString(currentUpload, `There was a problem uploading your file \`${currentUpload.file.name}\`.`);
}
} else {
replaceUploadString(currentUpload, `There was a problem uploading your file \`${currentUpload.file.name}\`.`);
}
} catch (err) {
replaceUploadString(currentUpload, `There was a problem uploading your file \`${currentUpload.file.name}\`.`);
console.error(err);
}
currentUpload = null;
currentXhr = null;
uploadNext();
}
function uploadNext() {
if (currentUpload == null) {
next = uploadQueue.shift();
if (next) {
// NOTE(asaf): We use XHR because fetch can't do upload progress reports. Womp womp. https://youtu.be/Pubd-spHN-0?t=2
currentXhr = new XMLHttpRequest();
currentXhr.open("POST", uploadUrl, true);
currentXhr.setRequestHeader("Hmn-Upload-Filename", next.file.name);
currentXhr.responseType = "json";
currentXhr.addEventListener("loadend", uploadDone);
currentXhr.send(next.file);
currentUpload = next;
}
}
}
</script> </script>
{{ end }} {{ end }}

110
src/website/assets.go Normal file
View File

@ -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
}

View File

@ -236,7 +236,7 @@ func BlogNewThread(c *RequestContext) ResponseData {
[]templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)}, []templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)},
) )
editData := getEditorDataForNew(baseData, nil) editData := getEditorDataForNew(c.CurrentUser, baseData, nil)
editData.SubmitUrl = hmnurl.BuildBlogNewThread(c.CurrentProject.Slug) editData.SubmitUrl = hmnurl.BuildBlogNewThread(c.CurrentProject.Slug)
editData.SubmitLabel = "Create Post" editData.SubmitLabel = "Create Post"
@ -319,7 +319,7 @@ func BlogPostEdit(c *RequestContext) ResponseData {
BlogThreadBreadcrumbs(c.CurrentProject.Slug, &postData.Thread), 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.SubmitUrl = hmnurl.BuildBlogPostEdit(c.CurrentProject.Slug, cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Edited Post" editData.SubmitLabel = "Submit Edited Post"
if postData.Thread.FirstID != postData.Post.ID { 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 := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme)
replyPost.AddContentVersion(postData.CurrentVersion, postData.Editor) 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.SubmitUrl = hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Reply" editData.SubmitLabel = "Submit Reply"

View File

@ -49,13 +49,18 @@ type editorData struct {
IsEditing bool IsEditing bool
EditInitialContents string EditInitialContents string
PostReplyingTo *templates.Post 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{ result := editorData{
BaseData: baseData, BaseData: baseData,
CanEditTitle: replyPost == nil, CanEditTitle: replyPost == nil,
PostReplyingTo: replyPost, PostReplyingTo: replyPost,
MaxFileSize: AssetMaxSize(currentUser),
UploadUrl: hmnurl.BuildAssetUpload(baseData.Project.Subdomain),
} }
if replyPost != nil { if replyPost != nil {
@ -65,13 +70,15 @@ func getEditorDataForNew(baseData templates.BaseData, replyPost *templates.Post)
return result return result
} }
func getEditorDataForEdit(baseData templates.BaseData, p postAndRelatedModels) editorData { func getEditorDataForEdit(currentUser *models.User, baseData templates.BaseData, p postAndRelatedModels) editorData {
return editorData{ return editorData{
BaseData: baseData, BaseData: baseData,
Title: p.Thread.Title, Title: p.Thread.Title,
CanEditTitle: p.Thread.FirstID == p.Post.ID, CanEditTitle: p.Thread.FirstID == p.Post.ID,
IsEditing: true, IsEditing: true,
EditInitialContents: p.CurrentVersion.TextRaw, 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)) 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.SubmitUrl = hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true)
editData.SubmitLabel = "Post New Thread" editData.SubmitLabel = "Post New Thread"
@ -676,7 +683,7 @@ func ForumPostReply(c *RequestContext) ResponseData {
replyPost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) replyPost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme)
replyPost.AddContentVersion(postData.CurrentVersion, postData.Editor) 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.SubmitUrl = hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Reply" editData.SubmitLabel = "Submit Reply"
@ -745,7 +752,7 @@ func ForumPostEdit(c *RequestContext) ResponseData {
} }
baseData := getBaseData(c, title, ForumThreadBreadcrumbs(cd.LineageBuilder, c.CurrentProject, &postData.Thread)) 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.SubmitUrl = hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Edited Post" editData.SubmitLabel = "Submit Edited Post"

View File

@ -231,6 +231,8 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
), http.StatusMovedPermanently) ), http.StatusMovedPermanently)
}) })
mainRoutes.POST(hmnurl.RegexAssetUpload, AssetUpload)
mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex) mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex)
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit) mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
mainRoutes.POST(hmnurl.RegexPodcastEdit, PodcastEditSubmit) mainRoutes.POST(hmnurl.RegexPodcastEdit, PodcastEditSubmit)