First working version. No user limits or usage tracking.
This commit is contained in:
parent
070ea5cc20
commit
dc56b1f5d0
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -10,6 +10,10 @@
|
|||
#editor {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
#editor.drop {
|
||||
box-shadow: inset 0px 0px 5px yellow;
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
|
||||
|
@ -104,6 +108,9 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
const maxFileSize = {{ .MaxFileSize }};
|
||||
const uploadUrl = {{ .UploadUrl }};
|
||||
|
||||
const form = document.querySelector('#form');
|
||||
const titleField = document.querySelector('#title'); // may be undefined, be careful!
|
||||
const textField = document.querySelector('#editor');
|
||||
|
@ -177,5 +184,155 @@
|
|||
form.addEventListener('submit', e => {
|
||||
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>
|
||||
{{ end }}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue