Edit project
This commit is contained in:
parent
950e84d53a
commit
cf46e16df5
|
@ -99,6 +99,42 @@ func ProjectToTemplate(p *models.ProjectWithLogos, url string, theme string) Pro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ProjectLifecycleValues = map[models.ProjectLifecycle]string{
|
||||||
|
models.ProjectLifecycleActive: "active",
|
||||||
|
models.ProjectLifecycleHiatus: "hiatus",
|
||||||
|
models.ProjectLifecycleDead: "dead",
|
||||||
|
models.ProjectLifecycleLTS: "done",
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectLifecycleFromValue(value string) (models.ProjectLifecycle, bool) {
|
||||||
|
for k, v := range ProjectLifecycleValues {
|
||||||
|
if v == value {
|
||||||
|
return k, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return models.ProjectLifecycleUnapproved, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectToProjectSettings(p *models.ProjectWithLogos, owners []*models.User, currentTheme string) ProjectSettings {
|
||||||
|
ownerUsers := make([]User, 0, len(owners))
|
||||||
|
for _, owner := range owners {
|
||||||
|
ownerUsers = append(ownerUsers, UserToTemplate(owner, currentTheme))
|
||||||
|
}
|
||||||
|
return ProjectSettings{
|
||||||
|
Name: p.Name,
|
||||||
|
Slug: p.Slug,
|
||||||
|
Hidden: p.Hidden,
|
||||||
|
Featured: p.Featured,
|
||||||
|
Personal: p.Personal,
|
||||||
|
Lifecycle: ProjectLifecycleValues[p.Lifecycle],
|
||||||
|
Blurb: p.Blurb,
|
||||||
|
Description: p.Description,
|
||||||
|
Owners: ownerUsers,
|
||||||
|
LightLogo: ProjectLogoUrl(p, "light"),
|
||||||
|
DarkLogo: ProjectLogoUrl(p, "dark"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func SessionToTemplate(s *models.Session) Session {
|
func SessionToTemplate(s *models.Session) Session {
|
||||||
return Session{
|
return Session{
|
||||||
CSRFToken: s.CSRFToken,
|
CSRFToken: s.CSRFToken,
|
||||||
|
|
|
@ -25,34 +25,40 @@
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-bar flex flex-column flex-row-ns justify-between {{ if .IsProjectPage }}project{{ end }}">
|
<div class="menu-bar flex flex-column flex-row-ns justify-between w-100 {{ if .IsProjectPage }}project{{ end }}">
|
||||||
<div class="flex flex-column flex-row-ns items-center">
|
<div class="flex flex-column flex-row-ns items-center w-100">
|
||||||
{{ $itemsClass := "items self-stretch flex items-center justify-center justify-start-ns ml2-ns ml3-l" }}
|
{{ $itemsClass := "items self-stretch flex items-center justify-center justify-start-ns ml2-ns ml3-l" }}
|
||||||
{{ if .Header.Project }}
|
{{ if .Header.Project }}
|
||||||
<a href="{{ .Header.HMNHomepageUrl }}" class="hmn-logo small bg-theme-dark">
|
<a href="{{ .Header.HMNHomepageUrl }}" class="hmn-logo small bg-theme-dark flex-shrink-0">
|
||||||
<div>Hand</div>
|
<div>Hand</div>
|
||||||
<div>made</div>
|
<div>made</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ .Project.Url }}">
|
<a href="{{ .Project.Url }}" class="flex-shrink-0">
|
||||||
<h2 class="mb0 mt2 mt0-ns mh3 mr0-ns tc tl-ns">{{ .Project.Name }}</h2>
|
<h2 class="mb0 mt2 mt0-ns mh3 mr0-ns tc tl-ns">{{ .Project.Name }}</h2>
|
||||||
</a>
|
</a>
|
||||||
{{ with .Header.Project }}
|
{{ with .Header.Project }}
|
||||||
<div class="{{ $itemsClass }}">
|
<div class="{{ $itemsClass }} w-100">
|
||||||
{{ if .HasBlog }}
|
{{ if .HasBlog }}
|
||||||
<div class="root-item">
|
<div class="root-item flex-shrink-0">
|
||||||
<a href="{{ .BlogUrl }}">Blog</a>
|
<a href="{{ .BlogUrl }}">Blog</a>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if .HasForums }}
|
{{ if .HasForums }}
|
||||||
<div class="root-item">
|
<div class="root-item flex-shrink-0">
|
||||||
<a href="{{ .ForumsUrl }}">Forums</a>
|
<a href="{{ .ForumsUrl }}">Forums</a>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if .HasEpisodeGuide }}
|
{{ if .HasEpisodeGuide }}
|
||||||
<div class="root-item">
|
<div class="root-item flex-shrink-0">
|
||||||
<a href="{{ .EpisodeGuideUrl }}">Episode Guide</a>
|
<a href="{{ .EpisodeGuideUrl }}">Episode Guide</a>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ if .CanEdit }}
|
||||||
|
<div class="flex-grow-1"></div>
|
||||||
|
<div class="root-item flex-shrink-0">
|
||||||
|
<a href="{{ .EditUrl }}">Edit Project</a>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
|
|
@ -60,7 +60,7 @@
|
||||||
<div class="owner_row flex flex-row bg--card w5 pv1 ph2">
|
<div class="owner_row flex flex-row bg--card w5 pv1 ph2">
|
||||||
<input type="hidden" name="owners" value="{{ .Username }}" />
|
<input type="hidden" name="owners" value="{{ .Username }}" />
|
||||||
<span class="flex-grow-1">{{ .Username }}</span>
|
<span class="flex-grow-1">{{ .Username }}</span>
|
||||||
{{ if ne .ID $.User.ID }}
|
{{ if (or $.User.IsStaff (ne .ID $.User.ID)) }}
|
||||||
<a class="remove_owner" href="javascript:;">X</a>
|
<a class="remove_owner" href="javascript:;">X</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -219,15 +219,20 @@
|
||||||
|
|
||||||
ownersError.textContent = "";
|
ownersError.textContent = "";
|
||||||
let xhr = new XMLHttpRequest();
|
let xhr = new XMLHttpRequest();
|
||||||
|
xhr.withCredentials = true;
|
||||||
xhr.open("POST", ownerCheckUrl);
|
xhr.open("POST", ownerCheckUrl);
|
||||||
xhr.responseType = "json";
|
xhr.responseType = "json";
|
||||||
xhr.addEventListener("load", function(ev) {
|
xhr.addEventListener("load", function(ev) {
|
||||||
let result = xhr.response;
|
let result = xhr.response;
|
||||||
if (result.found) {
|
if (result) {
|
||||||
addOwner(result.canonical);
|
if (result.found) {
|
||||||
addOwnerInput.value = "";
|
addOwner(result.canonical);
|
||||||
|
addOwnerInput.value = "";
|
||||||
|
} else {
|
||||||
|
ownersError.textContent = "Username not found";
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ownersError.textContent = "Username not found";
|
ownersError.textContent = "There was an issue validating this username";
|
||||||
}
|
}
|
||||||
setOwnerQueryState(OWNER_QUERY_STATE_IDLE);
|
setOwnerQueryState(OWNER_QUERY_STATE_IDLE);
|
||||||
if (document.activeElement == addOwnerButton) {
|
if (document.activeElement == addOwnerButton) {
|
||||||
|
|
|
@ -82,8 +82,10 @@
|
||||||
items[i].classList.add('active');
|
items[i].classList.add('active');
|
||||||
|
|
||||||
const smallItems = document.querySelectorAll('.carousel-item-small');
|
const smallItems = document.querySelectorAll('.carousel-item-small');
|
||||||
smallItems.forEach(item => item.classList.remove('active'));
|
if (smallItems.length > 0) {
|
||||||
smallItems[i].classList.add('active');
|
smallItems.forEach(item => item.classList.remove('active'));
|
||||||
|
smallItems[i].classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
const buttons = document.querySelectorAll('.carousel-button');
|
const buttons = document.querySelectorAll('.carousel-button');
|
||||||
buttons.forEach(button => button.classList.remove('active'));
|
buttons.forEach(button => button.classList.remove('active'));
|
||||||
|
|
|
@ -59,9 +59,11 @@ type ProjectHeader struct {
|
||||||
HasForums bool
|
HasForums bool
|
||||||
HasBlog bool
|
HasBlog bool
|
||||||
HasEpisodeGuide bool
|
HasEpisodeGuide bool
|
||||||
|
CanEdit bool
|
||||||
ForumsUrl string
|
ForumsUrl string
|
||||||
BlogUrl string
|
BlogUrl string
|
||||||
EpisodeGuideUrl string
|
EpisodeGuideUrl string
|
||||||
|
EditUrl string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Footer struct {
|
type Footer struct {
|
||||||
|
|
|
@ -45,6 +45,7 @@ func APICheckUsername(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
res.Header().Set("Content-Type", "application/json")
|
res.Header().Set("Content-Type", "application/json")
|
||||||
|
AddCORSHeaders(c, &res)
|
||||||
if found {
|
if found {
|
||||||
res.Write([]byte(fmt.Sprintf(`{ "found": true, "canonical": "%s" }`, canonicalUsername)))
|
res.Write([]byte(fmt.Sprintf(`{ "found": true, "canonical": "%s" }`, canonicalUsername)))
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -102,9 +102,11 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
HasForums: project.HasForums(),
|
HasForums: project.HasForums(),
|
||||||
HasBlog: project.HasBlog(),
|
HasBlog: project.HasBlog(),
|
||||||
HasEpisodeGuide: hasAnnotations,
|
HasEpisodeGuide: hasAnnotations,
|
||||||
|
CanEdit: c.CurrentUserCanEditCurrentProject,
|
||||||
ForumsUrl: c.UrlContext.BuildForum(nil, 1),
|
ForumsUrl: c.UrlContext.BuildForum(nil, 1),
|
||||||
BlogUrl: c.UrlContext.BuildBlog(1),
|
BlogUrl: c.UrlContext.BuildBlog(1),
|
||||||
EpisodeGuideUrl: episodeGuideUrl,
|
EpisodeGuideUrl: episodeGuideUrl,
|
||||||
|
EditUrl: c.UrlContext.BuildProjectEdit(""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package website
|
package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
|
@ -254,10 +255,6 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
var templateData ProjectHomepageData
|
var templateData ProjectHomepageData
|
||||||
|
|
||||||
templateData.BaseData = getBaseData(c, c.CurrentProject.Name, nil)
|
templateData.BaseData = getBaseData(c, c.CurrentProject.Name, nil)
|
||||||
//if canEdit {
|
|
||||||
// // TODO: Move to project-specific navigation
|
|
||||||
// // templateData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "")
|
|
||||||
//}
|
|
||||||
templateData.BaseData.OpenGraphItems = append(templateData.BaseData.OpenGraphItems, templates.OpenGraphItem{
|
templateData.BaseData.OpenGraphItems = append(templateData.BaseData.OpenGraphItems, templates.OpenGraphItem{
|
||||||
Property: "og:description",
|
Property: "og:description",
|
||||||
Value: c.CurrentProject.Blurb,
|
Value: c.CurrentProject.Blurb,
|
||||||
|
@ -399,155 +396,45 @@ func ProjectNew(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectNewSubmit(c *RequestContext) ResponseData {
|
func ProjectNewSubmit(c *RequestContext) ResponseData {
|
||||||
maxBodySize := int64(ProjectLogoMaxFileSize*2 + 1024*1024)
|
formResult := ParseProjectEditForm(c)
|
||||||
c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize)
|
if formResult.Error != nil {
|
||||||
err := c.Req.ParseMultipartForm(maxBodySize)
|
return c.ErrorResponse(http.StatusInternalServerError, formResult.Error)
|
||||||
if err != nil {
|
|
||||||
// NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here.
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form"))
|
|
||||||
}
|
}
|
||||||
|
if len(formResult.RejectionReason) != 0 {
|
||||||
projectName := strings.TrimSpace(c.Req.Form.Get("project_name"))
|
return RejectRequest(c, formResult.RejectionReason)
|
||||||
if len(projectName) == 0 {
|
|
||||||
return RejectRequest(c, "Project name is empty")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
shortDesc := strings.TrimSpace(c.Req.Form.Get("shortdesc"))
|
|
||||||
if len(shortDesc) == 0 {
|
|
||||||
return RejectRequest(c, "Projects must have a short description")
|
|
||||||
}
|
|
||||||
description := c.Req.Form.Get("description")
|
|
||||||
parsedDescription := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
|
|
||||||
|
|
||||||
lifecycleStr := c.Req.Form.Get("lifecycle")
|
|
||||||
var lifecycle models.ProjectLifecycle
|
|
||||||
switch lifecycleStr {
|
|
||||||
case "active":
|
|
||||||
lifecycle = models.ProjectLifecycleActive
|
|
||||||
case "hiatus":
|
|
||||||
lifecycle = models.ProjectLifecycleHiatus
|
|
||||||
case "done":
|
|
||||||
lifecycle = models.ProjectLifecycleLTS
|
|
||||||
case "dead":
|
|
||||||
lifecycle = models.ProjectLifecycleDead
|
|
||||||
default:
|
|
||||||
return RejectRequest(c, "Project status is invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
hiddenStr := c.Req.Form.Get("hidden")
|
|
||||||
hidden := len(hiddenStr) > 0
|
|
||||||
|
|
||||||
lightLogo, err := GetFormImage(c, "light_logo")
|
|
||||||
if err != nil {
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to read image from form"))
|
|
||||||
}
|
|
||||||
darkLogo, err := GetFormImage(c, "dark_logo")
|
|
||||||
if err != nil {
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to read image from form"))
|
|
||||||
}
|
|
||||||
|
|
||||||
owners := c.Req.Form["owners"]
|
|
||||||
|
|
||||||
tx, err := c.Conn.Begin(c.Context())
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
||||||
}
|
}
|
||||||
defer tx.Rollback(c.Context())
|
defer tx.Rollback(c.Context())
|
||||||
|
|
||||||
var lightLogoUUID *uuid.UUID
|
|
||||||
if lightLogo.Exists {
|
|
||||||
lightLogoAsset, err := assets.Create(c.Context(), tx, assets.CreateInput{
|
|
||||||
Content: lightLogo.Content,
|
|
||||||
Filename: lightLogo.Filename,
|
|
||||||
ContentType: lightLogo.Mime,
|
|
||||||
UploaderID: &c.CurrentUser.ID,
|
|
||||||
Width: lightLogo.Width,
|
|
||||||
Height: lightLogo.Height,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to save asset"))
|
|
||||||
}
|
|
||||||
lightLogoUUID = &lightLogoAsset.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
var darkLogoUUID *uuid.UUID
|
|
||||||
if darkLogo.Exists {
|
|
||||||
darkLogoAsset, err := assets.Create(c.Context(), tx, assets.CreateInput{
|
|
||||||
Content: darkLogo.Content,
|
|
||||||
Filename: darkLogo.Filename,
|
|
||||||
ContentType: darkLogo.Mime,
|
|
||||||
UploaderID: &c.CurrentUser.ID,
|
|
||||||
Width: darkLogo.Width,
|
|
||||||
Height: darkLogo.Height,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to save asset"))
|
|
||||||
}
|
|
||||||
darkLogoUUID = &darkLogoAsset.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSelf := false
|
|
||||||
selfUsername := strings.ToLower(c.CurrentUser.Username)
|
|
||||||
for i, _ := range owners {
|
|
||||||
owners[i] = strings.ToLower(owners[i])
|
|
||||||
if owners[i] == selfUsername {
|
|
||||||
hasSelf = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasSelf {
|
|
||||||
owners = append(owners, selfUsername)
|
|
||||||
}
|
|
||||||
|
|
||||||
userResult, err := db.Query(c.Context(), c.Conn, models.User{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM auth_user
|
|
||||||
WHERE LOWER(username) = ANY ($1)
|
|
||||||
`,
|
|
||||||
owners,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to query users"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var projectId int
|
var projectId int
|
||||||
err = tx.QueryRow(c.Context(),
|
err = tx.QueryRow(c.Context(),
|
||||||
`
|
`
|
||||||
INSERT INTO handmade_project
|
INSERT INTO handmade_project
|
||||||
(name, blurb, description, descparsed, logodark_asset_id, logolight_asset_id, lifecycle, hidden, date_created, all_last_updated)
|
(name, blurb, description, descparsed, lifecycle, date_created, all_last_updated)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
($1, $2, $3, $4, $5, $6, $6)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
projectName,
|
"",
|
||||||
shortDesc,
|
"",
|
||||||
description,
|
"",
|
||||||
parsedDescription,
|
"",
|
||||||
darkLogoUUID,
|
models.ProjectLifecycleUnapproved,
|
||||||
lightLogoUUID,
|
|
||||||
lifecycle,
|
|
||||||
hidden,
|
|
||||||
time.Now(), // NOTE(asaf): Using this param twice.
|
time.Now(), // NOTE(asaf): Using this param twice.
|
||||||
).Scan(&projectId)
|
).Scan(&projectId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert new project"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert new project"))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ownerRow := range userResult.ToSlice() {
|
formResult.Payload.ProjectID = projectId
|
||||||
_, err = tx.Exec(c.Context(),
|
|
||||||
`
|
err = UpdateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
|
||||||
INSERT INTO handmade_user_projects
|
if err != nil {
|
||||||
(user_id, project_id)
|
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||||
VALUES
|
|
||||||
($1, $2)
|
|
||||||
`,
|
|
||||||
ownerRow.(*models.User).ID,
|
|
||||||
projectId,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert project owner"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.Commit(c.Context())
|
tx.Commit(c.Context())
|
||||||
|
@ -555,32 +442,345 @@ func ProjectNewSubmit(c *RequestContext) ResponseData {
|
||||||
urlContext := &hmnurl.UrlContext{
|
urlContext := &hmnurl.UrlContext{
|
||||||
PersonalProject: true,
|
PersonalProject: true,
|
||||||
ProjectID: projectId,
|
ProjectID: projectId,
|
||||||
ProjectName: projectName,
|
ProjectName: formResult.Payload.Name,
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Redirect(urlContext.BuildHomepage(), http.StatusSeeOther)
|
return c.Redirect(urlContext.BuildHomepage(), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectEdit(c *RequestContext) ResponseData {
|
func ProjectEdit(c *RequestContext) ResponseData {
|
||||||
// Find project
|
project := c.CurrentProject
|
||||||
// Convert to template
|
if !c.CurrentUserCanEditCurrentProject {
|
||||||
var project templates.ProjectSettings
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
owners, err := FetchProjectOwners(c.Context(), c.Conn, project.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch project owners"))
|
||||||
|
}
|
||||||
|
projectSettings := templates.ProjectToProjectSettings(project, owners, c.Theme)
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
res.MustWriteTemplate("project_edit.html", ProjectEditData{
|
res.MustWriteTemplate("project_edit.html", ProjectEditData{
|
||||||
BaseData: getBaseDataAutocrumb(c, "Edit Project"),
|
BaseData: getBaseDataAutocrumb(c, "Edit Project"),
|
||||||
Editing: true,
|
Editing: true,
|
||||||
ProjectSettings: project,
|
ProjectSettings: projectSettings,
|
||||||
|
|
||||||
|
APICheckUsernameUrl: hmnurl.BuildAPICheckUsername(),
|
||||||
|
LogoMaxFileSize: ProjectLogoMaxFileSize,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectEditSubmit(c *RequestContext) ResponseData {
|
func ProjectEditSubmit(c *RequestContext) ResponseData {
|
||||||
return FourOhFour(c)
|
if !c.CurrentUserCanEditCurrentProject {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
formResult := ParseProjectEditForm(c)
|
||||||
|
if formResult.Error != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, formResult.Error)
|
||||||
|
}
|
||||||
|
if len(formResult.RejectionReason) != 0 {
|
||||||
|
return RejectRequest(c, formResult.RejectionReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
||||||
|
}
|
||||||
|
defer tx.Rollback(c.Context())
|
||||||
|
|
||||||
|
formResult.Payload.ProjectID = c.CurrentProject.ID
|
||||||
|
|
||||||
|
err = UpdateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit(c.Context())
|
||||||
|
|
||||||
|
urlContext := &hmnurl.UrlContext{
|
||||||
|
PersonalProject: formResult.Payload.Personal,
|
||||||
|
ProjectSlug: formResult.Payload.Slug,
|
||||||
|
ProjectID: formResult.Payload.ProjectID,
|
||||||
|
ProjectName: formResult.Payload.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect(urlContext.BuildHomepage(), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectPayload struct {
|
||||||
|
ProjectID int
|
||||||
|
Name string
|
||||||
|
Blurb string
|
||||||
|
Description string
|
||||||
|
ParsedDescription string
|
||||||
|
Lifecycle models.ProjectLifecycle
|
||||||
|
Hidden bool
|
||||||
|
OwnerUsernames []string
|
||||||
|
LightLogo FormImage
|
||||||
|
DarkLogo FormImage
|
||||||
|
|
||||||
|
Slug string
|
||||||
|
Featured bool
|
||||||
|
Personal bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectEditFormResult struct {
|
||||||
|
Payload ProjectPayload
|
||||||
|
RejectionReason string
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
|
||||||
|
var res ProjectEditFormResult
|
||||||
|
maxBodySize := int64(ProjectLogoMaxFileSize*2 + 1024*1024)
|
||||||
|
c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize)
|
||||||
|
err := c.Req.ParseMultipartForm(maxBodySize)
|
||||||
|
if err != nil {
|
||||||
|
// NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here.
|
||||||
|
res.Error = oops.New(err, "failed to parse form")
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
projectName := strings.TrimSpace(c.Req.Form.Get("project_name"))
|
||||||
|
if len(projectName) == 0 {
|
||||||
|
res.RejectionReason = "Project name is empty"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
shortDesc := strings.TrimSpace(c.Req.Form.Get("shortdesc"))
|
||||||
|
if len(shortDesc) == 0 {
|
||||||
|
res.RejectionReason = "Projects must have a short description"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
description := c.Req.Form.Get("description")
|
||||||
|
parsedDescription := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
|
||||||
|
|
||||||
|
lifecycleStr := c.Req.Form.Get("lifecycle")
|
||||||
|
lifecycle, found := templates.ProjectLifecycleFromValue(lifecycleStr)
|
||||||
|
if !found {
|
||||||
|
res.RejectionReason = "Project status is invalid"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
hiddenStr := c.Req.Form.Get("hidden")
|
||||||
|
hidden := len(hiddenStr) > 0
|
||||||
|
|
||||||
|
lightLogo, err := GetFormImage(c, "light_logo")
|
||||||
|
if err != nil {
|
||||||
|
res.Error = oops.New(err, "Failed to read image from form")
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
darkLogo, err := GetFormImage(c, "dark_logo")
|
||||||
|
if err != nil {
|
||||||
|
res.Error = oops.New(err, "Failed to read image from form")
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
owners := c.Req.Form["owners"]
|
||||||
|
|
||||||
|
slug := strings.TrimSpace(c.Req.Form.Get("slug"))
|
||||||
|
officialStr := c.Req.Form.Get("official")
|
||||||
|
official := len(officialStr) > 0
|
||||||
|
featuredStr := c.Req.Form.Get("featured")
|
||||||
|
featured := len(featuredStr) > 0
|
||||||
|
|
||||||
|
if official && len(slug) == 0 {
|
||||||
|
res.RejectionReason = "Official projects must have a slug"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Payload = ProjectPayload{
|
||||||
|
Name: projectName,
|
||||||
|
Blurb: shortDesc,
|
||||||
|
Description: description,
|
||||||
|
ParsedDescription: parsedDescription,
|
||||||
|
Lifecycle: lifecycle,
|
||||||
|
Hidden: hidden,
|
||||||
|
OwnerUsernames: owners,
|
||||||
|
LightLogo: lightLogo,
|
||||||
|
DarkLogo: darkLogo,
|
||||||
|
Slug: slug,
|
||||||
|
Personal: !official,
|
||||||
|
Featured: featured,
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, payload *ProjectPayload) error {
|
||||||
|
var lightLogoUUID *uuid.UUID
|
||||||
|
if payload.LightLogo.Exists {
|
||||||
|
lightLogo := &payload.LightLogo
|
||||||
|
lightLogoAsset, err := assets.Create(ctx, conn, assets.CreateInput{
|
||||||
|
Content: lightLogo.Content,
|
||||||
|
Filename: lightLogo.Filename,
|
||||||
|
ContentType: lightLogo.Mime,
|
||||||
|
UploaderID: &user.ID,
|
||||||
|
Width: lightLogo.Width,
|
||||||
|
Height: lightLogo.Height,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "Failed to save asset")
|
||||||
|
}
|
||||||
|
lightLogoUUID = &lightLogoAsset.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
var darkLogoUUID *uuid.UUID
|
||||||
|
if payload.DarkLogo.Exists {
|
||||||
|
darkLogo := &payload.DarkLogo
|
||||||
|
darkLogoAsset, err := assets.Create(ctx, conn, assets.CreateInput{
|
||||||
|
Content: darkLogo.Content,
|
||||||
|
Filename: darkLogo.Filename,
|
||||||
|
ContentType: darkLogo.Mime,
|
||||||
|
UploaderID: &user.ID,
|
||||||
|
Width: darkLogo.Width,
|
||||||
|
Height: darkLogo.Height,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "Failed to save asset")
|
||||||
|
}
|
||||||
|
darkLogoUUID = &darkLogoAsset.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSelf := false
|
||||||
|
selfUsername := strings.ToLower(user.Username)
|
||||||
|
for i, _ := range payload.OwnerUsernames {
|
||||||
|
payload.OwnerUsernames[i] = strings.ToLower(payload.OwnerUsernames[i])
|
||||||
|
if payload.OwnerUsernames[i] == selfUsername {
|
||||||
|
hasSelf = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasSelf && !user.IsStaff {
|
||||||
|
payload.OwnerUsernames = append(payload.OwnerUsernames, selfUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx,
|
||||||
|
`
|
||||||
|
UPDATE handmade_project SET
|
||||||
|
name = $2,
|
||||||
|
blurb = $3,
|
||||||
|
description = $4,
|
||||||
|
descparsed = $5,
|
||||||
|
lifecycle = $6,
|
||||||
|
hidden = $7
|
||||||
|
WHERE
|
||||||
|
id = $1
|
||||||
|
`,
|
||||||
|
payload.ProjectID,
|
||||||
|
payload.Name,
|
||||||
|
payload.Blurb,
|
||||||
|
payload.Description,
|
||||||
|
payload.ParsedDescription,
|
||||||
|
payload.Lifecycle,
|
||||||
|
payload.Hidden,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "Failed to update project")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.IsStaff {
|
||||||
|
_, err = conn.Exec(ctx,
|
||||||
|
`
|
||||||
|
UPDATE handmade_project SET
|
||||||
|
slug = $2,
|
||||||
|
featured = $3,
|
||||||
|
personal = $4
|
||||||
|
WHERE
|
||||||
|
id = $1
|
||||||
|
`,
|
||||||
|
payload.ProjectID,
|
||||||
|
payload.Slug,
|
||||||
|
payload.Featured,
|
||||||
|
payload.Personal,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "Failed to update project with admin fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.LightLogo.Exists || payload.LightLogo.Remove {
|
||||||
|
_, err = conn.Exec(ctx,
|
||||||
|
`
|
||||||
|
UPDATE handmade_project
|
||||||
|
SET
|
||||||
|
logolight_asset_id = $2
|
||||||
|
WHERE
|
||||||
|
id = $1
|
||||||
|
`,
|
||||||
|
payload.ProjectID,
|
||||||
|
lightLogoUUID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "Failed to update project's light logo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.DarkLogo.Exists || payload.DarkLogo.Remove {
|
||||||
|
_, err = conn.Exec(ctx,
|
||||||
|
`
|
||||||
|
UPDATE handmade_project
|
||||||
|
SET
|
||||||
|
logodark_asset_id = $2
|
||||||
|
WHERE
|
||||||
|
id = $1
|
||||||
|
`,
|
||||||
|
payload.ProjectID,
|
||||||
|
darkLogoUUID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "Failed to update project's dark logo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerResult, err := db.Query(ctx, conn, models.User{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM auth_user
|
||||||
|
WHERE LOWER(username) = ANY ($1)
|
||||||
|
`,
|
||||||
|
payload.OwnerUsernames,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "Failed to query users")
|
||||||
|
}
|
||||||
|
ownerRows := ownerResult.ToSlice()
|
||||||
|
|
||||||
|
_, err = conn.Exec(ctx,
|
||||||
|
`
|
||||||
|
DELETE FROM handmade_user_projects
|
||||||
|
WHERE project_id = $1
|
||||||
|
`,
|
||||||
|
payload.ProjectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "Failed to delete project owners")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ownerRow := range ownerRows {
|
||||||
|
_, err = conn.Exec(ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO handmade_user_projects
|
||||||
|
(user_id, project_id)
|
||||||
|
VALUES
|
||||||
|
($1, $2)
|
||||||
|
`,
|
||||||
|
ownerRow.(*models.User).ID,
|
||||||
|
payload.ProjectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "Failed to insert project owner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormImage struct {
|
type FormImage struct {
|
||||||
Exists bool
|
Exists bool
|
||||||
|
Remove bool
|
||||||
Filename string
|
Filename string
|
||||||
Mime string
|
Mime string
|
||||||
Content []byte
|
Content []byte
|
||||||
|
@ -594,6 +794,8 @@ func GetFormImage(c *RequestContext, fieldName string) (FormImage, error) {
|
||||||
var res FormImage
|
var res FormImage
|
||||||
res.Exists = false
|
res.Exists = false
|
||||||
|
|
||||||
|
removeStr := c.Req.Form.Get("remove_" + fieldName)
|
||||||
|
res.Remove = (removeStr == "true")
|
||||||
img, header, err := c.Req.FormFile(fieldName)
|
img, header, err := c.Req.FormFile(fieldName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, http.ErrMissingFile) {
|
if errors.Is(err, http.ErrMissingFile) {
|
||||||
|
|
|
@ -166,6 +166,8 @@ type RequestContext struct {
|
||||||
Theme string
|
Theme string
|
||||||
UrlContext *hmnurl.UrlContext
|
UrlContext *hmnurl.UrlContext
|
||||||
|
|
||||||
|
CurrentUserCanEditCurrentProject bool
|
||||||
|
|
||||||
Perf *perf.RequestPerf
|
Perf *perf.RequestPerf
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
|
@ -462,11 +462,13 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||||
{
|
{
|
||||||
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
|
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
|
||||||
slug := strings.TrimRight(hostPrefix, ".")
|
slug := strings.TrimRight(hostPrefix, ".")
|
||||||
|
var owners []*models.User
|
||||||
|
|
||||||
if len(slug) > 0 {
|
if len(slug) > 0 {
|
||||||
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{AlwaysVisibleToOwnerAndStaff: true})
|
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{AlwaysVisibleToOwnerAndStaff: true})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
c.CurrentProject = &dbProject.Project
|
c.CurrentProject = &dbProject.Project
|
||||||
|
owners = dbProject.Owners
|
||||||
} else {
|
} else {
|
||||||
if errors.Is(err, db.NotFound) {
|
if errors.Is(err, db.NotFound) {
|
||||||
// do nothing, this is fine
|
// do nothing, this is fine
|
||||||
|
@ -484,12 +486,27 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||||
panic(oops.New(err, "failed to fetch HMN project"))
|
panic(oops.New(err, "failed to fetch HMN project"))
|
||||||
}
|
}
|
||||||
c.CurrentProject = &dbProject.Project
|
c.CurrentProject = &dbProject.Project
|
||||||
|
owners = dbProject.Owners
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.CurrentProject == nil {
|
if c.CurrentProject == nil {
|
||||||
panic("failed to load project data")
|
panic("failed to load project data")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canEditProject := false
|
||||||
|
if c.CurrentUser != nil {
|
||||||
|
canEditProject = c.CurrentUser.IsStaff
|
||||||
|
if !canEditProject {
|
||||||
|
for _, o := range owners {
|
||||||
|
if o.ID == c.CurrentUser.ID {
|
||||||
|
c.CurrentUserCanEditCurrentProject = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.CurrentUserCanEditCurrentProject = canEditProject
|
||||||
|
|
||||||
c.UrlContext = UrlContextForProject(&c.CurrentProject.Project)
|
c.UrlContext = UrlContextForProject(&c.CurrentProject.Project)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -514,6 +531,7 @@ func AddCORSHeaders(c *RequestContext, res *ResponseData) {
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(origin, parsed.Host) {
|
if strings.HasSuffix(origin, parsed.Host) {
|
||||||
res.Header().Add("Access-Control-Allow-Origin", origin)
|
res.Header().Add("Access-Control-Allow-Origin", origin)
|
||||||
|
res.Header().Add("Access-Control-Allow-Credentials", "true")
|
||||||
res.Header().Add("Vary", "Origin")
|
res.Header().Add("Vary", "Origin")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue