Assets for user avatars

This commit is contained in:
Asaf Gartner 2021-12-29 16:38:23 +02:00
parent eb32b04437
commit f4f439489d
19 changed files with 385 additions and 100 deletions

View File

@ -1,4 +1,4 @@
function ImageSelector(form, maxFileSize, container) {
function ImageSelector(form, maxFileSize, container, defaultImageUrl) {
this.form = form;
this.maxFileSize = maxFileSize;
this.fileInput = container.querySelector(".image_input");
@ -8,6 +8,7 @@ function ImageSelector(form, maxFileSize, container) {
this.removeLink = container.querySelector(".remove");
this.originalImageUrl = this.imageEl.getAttribute("data-original");
this.currentImageUrl = this.originalImageUrl;
this.defaultImageUrl = defaultImageUrl || "";
this.fileInput.value = "";
this.removeImageInput.value = "";
@ -45,7 +46,7 @@ ImageSelector.prototype.removeImage = function() {
this.updateSizeLimit(0);
this.fileInput.value = "";
this.removeImageInput.value = "true";
this.setImageUrl("");
this.setImageUrl(this.defaultImageUrl);
this.updateButtons();
};
@ -82,13 +83,15 @@ ImageSelector.prototype.setImageUrl = function(url) {
};
ImageSelector.prototype.updateButtons = function() {
if (this.originalImageUrl.length > 0 && this.currentImageUrl != this.originalImageUrl) {
if ((this.originalImageUrl.length > 0 && this.originalImageUrl != this.defaultImageUrl)
&& this.currentImageUrl != this.originalImageUrl) {
this.resetLink.style.display = "inline-block";
} else {
this.resetLink.style.display = "none";
}
if (!this.fileInput.required && this.currentImageUrl != "") {
if (!this.fileInput.required && this.currentImageUrl != this.defaultImageUrl) {
this.removeLink.style.display = "inline-block";
} else {
this.removeLink.style.display = "none";

View File

@ -399,6 +399,7 @@ func init() {
}
width := 0
height := 0
mime := ""
fileExtensionOverrides := []string{".svg"}
fileExt := strings.ToLower(path.Ext(filepath))
tryDecode := true
@ -414,9 +415,13 @@ func init() {
}
width = config.Width
height = config.Height
mime = http.DetectContentType(contents)
} else {
if fileExt == ".svg" {
mime = "image/svg+xml"
}
}
mime := http.DetectContentType(contents)
filename := path.Base(filepath)
asset, err := assets.Create(ctx, conn, assets.CreateInput{
@ -490,5 +495,109 @@ func init() {
}
adminCommand.AddCommand(uploadProjectLogos)
uploadUserAvatars := &cobra.Command{
Use: "uploaduseravatars",
Short: "Uploads avatar imagefiles to S3 and replaces them with assets",
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
conn := db.NewConnPool(1, 1)
defer conn.Close()
type userQuery struct {
User models.User `db:"auth_user"`
}
allUsers, err := db.Query(ctx, conn, userQuery{}, `SELECT $columns FROM auth_user LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id`)
if err != nil {
panic(oops.New(err, "Failed to fetch projects from db"))
}
var fixupUsers []*models.User
numImages := 0
for _, user := range allUsers {
u := &user.(*userQuery).User
if u.Avatar != nil && *u.Avatar != "" {
fixupUsers = append(fixupUsers, u)
numImages += 1
}
}
fmt.Printf("%d images to upload\n", numImages)
uploadImage := func(ctx context.Context, conn db.ConnOrTx, filepath string, owner *models.User) *models.Asset {
filepath = "./public/media/" + filepath
contents, err := os.ReadFile(filepath)
if err != nil {
panic(oops.New(err, fmt.Sprintf("Failed to read file: %s", filepath)))
}
width := 0
height := 0
mime := ""
fileExtensionOverrides := []string{".svg"}
fileExt := strings.ToLower(path.Ext(filepath))
tryDecode := true
for _, ext := range fileExtensionOverrides {
if fileExt == ext {
tryDecode = false
}
}
if tryDecode {
config, _, err := image.DecodeConfig(bytes.NewReader(contents))
if err != nil {
panic(oops.New(err, fmt.Sprintf("Failed to decode file: %s", filepath)))
}
width = config.Width
height = config.Height
mime = http.DetectContentType(contents)
} else {
if fileExt == ".svg" {
mime = "image/svg+xml"
}
}
filename := path.Base(filepath)
asset, err := assets.Create(ctx, conn, assets.CreateInput{
Content: contents,
Filename: filename,
ContentType: mime,
UploaderID: &owner.ID,
Width: width,
Height: height,
})
if err != nil {
panic(oops.New(err, "Failed to create asset"))
}
return asset
}
for _, u := range fixupUsers {
if u.Avatar != nil && *u.Avatar != "" {
avatarAsset := uploadImage(ctx, conn, *u.Avatar, u)
_, err := conn.Exec(ctx,
`
UPDATE auth_user
SET
avatar_asset_id = $2,
avatar = NULL
WHERE
id = $1
`,
u.ID,
avatarAsset.ID,
)
if err != nil {
panic(oops.New(err, "Failed to update user"))
}
numImages -= 1
fmt.Printf(".")
}
}
fmt.Printf("\nDone! %d images not patched for some reason.\n\n", numImages)
},
}
adminCommand.AddCommand(uploadUserAvatars)
addProjectCommands(adminCommand)
}

View File

@ -242,12 +242,22 @@ func Query(ctx context.Context, conn ConnOrTx, destExample interface{}, query st
func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
destType := reflect.TypeOf(destExample)
columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "")
columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, nil)
if err != nil {
return nil, oops.New(err, "failed to generate column names")
}
columnNamesString := strings.Join(columnNames, ", ")
columns := make([]string, 0, len(columnNames))
for _, strSlice := range columnNames {
tableName := strings.Join(strSlice[0:len(strSlice)-1], "_")
fullName := strSlice[len(strSlice)-1]
if tableName != "" {
fullName = tableName + "." + fullName
}
columns = append(columns, fullName)
}
columnNamesString := strings.Join(columns, ", ")
query = strings.Replace(query, "$columns", columnNamesString, -1)
rows, err := conn.Query(ctx, query, args...)
@ -282,8 +292,8 @@ func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{},
return it, nil
}
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix string) (names []string, paths [][]int, err error) {
var columnNames []string
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix []string) (names [][]string, paths [][]int, err error) {
var columnNames [][]string
var fieldPaths [][]int
if destType.Kind() == reflect.Ptr {
@ -301,7 +311,10 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
var anonPrefixes []AnonPrefix
for _, field := range reflect.VisibleFields(destType) {
path := append(pathSoFar, field.Index...)
path := make([]int, len(pathSoFar))
copy(path, pathSoFar)
path = append(path, field.Index...)
fieldColumnNames := prefix[:]
if columnName := field.Tag.Get("db"); columnName != "" {
if field.Anonymous {
@ -318,7 +331,7 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
}
}
if equal {
columnName = anonPrefix.Prefix + "." + columnName
fieldColumnNames = append(fieldColumnNames, anonPrefix.Prefix)
break
}
}
@ -329,11 +342,13 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
fieldType = fieldType.Elem()
}
fieldColumnNames = append(fieldColumnNames, columnName)
if typeIsQueryable(fieldType) {
columnNames = append(columnNames, prefix+columnName)
columnNames = append(columnNames, fieldColumnNames)
fieldPaths = append(fieldPaths, path)
} else if fieldType.Kind() == reflect.Struct {
subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, columnName+".")
subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, fieldColumnNames)
if err != nil {
return nil, nil, err
}

View File

@ -97,6 +97,7 @@ func (bot *botInstance) handleProfileCommand(ctx context.Context, i *Interaction
FROM
handmade_discorduser AS duser
JOIN auth_user ON duser.hmn_user_id = auth_user.id
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE
duser.userid = $1
`,

View File

@ -640,6 +640,7 @@ func (bot *botInstance) messageDelete(ctx context.Context, msgDelete MessageDele
handmade_discordmessage AS msg
LEFT JOIN handmade_discorduser AS duser ON msg.user_id = duser.userid
LEFT JOIN auth_user AS hmnuser ON duser.hmn_user_id = hmnuser.id
LEFT JOIN handmade_asset AS hmnuser_avatar ON hmnuser_avatar.id = hmnuser.avatar_asset_id
LEFT JOIN handmade_snippet AS snippet ON snippet.discord_message_id = msg.id
WHERE msg.id = $1 AND msg.channel_id = $2
`,

View File

@ -526,6 +526,7 @@ func FetchDiscordUser(ctx context.Context, dbConn db.ConnOrTx, discordUserID str
FROM
handmade_discorduser AS duser
JOIN auth_user AS u ON duser.hmn_user_id = u.id
LEFT JOIN handmade_asset AS u_avatar ON u_avatar.id = u.avatar_asset_id
WHERE
duser.userid = $1
`,

View File

@ -9,7 +9,6 @@ import (
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/perf"
"git.handmade.network/hmn/hmn/src/templates"
)
type ProjectTypeQuery int
@ -52,10 +51,6 @@ func (p *ProjectAndStuff) TagText() string {
}
}
func (p *ProjectAndStuff) LogoURL(theme string) string {
return templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, theme)
}
func FetchProjects(
ctx context.Context,
dbConn db.ConnOrTx,
@ -366,12 +361,16 @@ func FetchMultipleProjectsOwners(
userIds = append(userIds, userProject.UserID)
}
}
iusers, err := db.Query(ctx, tx, models.User{},
type userQuery struct {
User models.User `db:"auth_user"`
}
iusers, err := db.Query(ctx, tx, userQuery{},
`
SELECT $columns
FROM auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE
id = ANY($1)
auth_user.id = ANY($1)
`,
userIds,
)
@ -398,9 +397,9 @@ func FetchMultipleProjectsOwners(
// Get the full user record we fetched
var user *models.User
for _, iuser := range iusers {
u := iuser.(*models.User)
u := iuser.(*userQuery).User
if u.ID == userProject.UserID {
user = u
user = &u
}
}
if user == nil {

View File

@ -80,6 +80,7 @@ func FetchSnippets(
FROM
handmade_snippet AS snippet
LEFT JOIN auth_user AS owner ON snippet.owner_id = owner.id
LEFT JOIN handmade_asset AS owner_avatar ON owner_avatar.id = owner.avatar_asset_id
LEFT JOIN handmade_asset AS asset ON snippet.asset_id = asset.id
LEFT JOIN handmade_discordmessage AS discord_message ON snippet.discord_message_id = discord_message.id
WHERE

View File

@ -78,7 +78,9 @@ func FetchThreads(
JOIN handmade_postversion AS first_version ON first_version.id = first_post.current_id
JOIN handmade_postversion AS last_version ON last_version.id = last_post.current_id
LEFT JOIN auth_user AS first_author ON first_author.id = first_post.author_id
LEFT JOIN handmade_asset AS first_author_avatar ON first_author_avatar.id = first_author.avatar_asset_id
LEFT JOIN auth_user AS last_author ON last_author.id = last_post.author_id
LEFT JOIN handmade_asset AS last_author_avatar ON last_author_avatar.id = last_author.avatar_asset_id
LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
tlri.thread_id = thread.id
AND tlri.user_id = $?
@ -221,6 +223,7 @@ func CountThreads(
JOIN handmade_project AS project ON thread.project_id = project.id
JOIN handmade_post AS first_post ON first_post.id = thread.first_id
LEFT JOIN auth_user AS first_author ON first_author.id = first_post.author_id
LEFT JOIN handmade_asset AS first_author_avatar ON first_author_avatar.id = first_author.avatar_asset_id
WHERE
NOT thread.deleted
AND ( -- project has valid lifecycle
@ -330,7 +333,9 @@ func FetchPosts(
JOIN handmade_project AS project ON post.project_id = project.id
JOIN handmade_postversion AS ver ON ver.id = post.current_id
LEFT JOIN auth_user AS author ON author.id = post.author_id
LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
LEFT JOIN handmade_asset AS editor_avatar ON editor_avatar.id = editor.avatar_asset_id
LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
tlri.thread_id = thread.id
AND tlri.user_id = $?
@ -344,6 +349,7 @@ func FetchPosts(
-- check fails.
LEFT JOIN handmade_post AS reply_post ON reply_post.id = post.reply_id
LEFT JOIN auth_user AS reply_author ON reply_post.author_id = reply_author.id
LEFT JOIN handmade_asset AS reply_author_avatar ON reply_author_avatar.id = reply_author.avatar_asset_id
WHERE
NOT thread.deleted
AND NOT post.deleted
@ -543,6 +549,7 @@ func CountPosts(
JOIN handmade_thread AS thread ON post.thread_id = thread.id
JOIN handmade_project AS project ON post.project_id = project.id
LEFT JOIN auth_user AS author ON author.id = post.author_id
LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
WHERE
NOT thread.deleted
AND NOT post.deleted

View File

@ -0,0 +1,47 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(UserAvatarAssetId{})
}
type UserAvatarAssetId struct{}
func (m UserAvatarAssetId) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 12, 26, 10, 16, 33, 0, time.UTC))
}
func (m UserAvatarAssetId) Name() string {
return "UserAvatarAssetId"
}
func (m UserAvatarAssetId) Description() string {
return "Add avatar_asset_id to users"
}
func (m UserAvatarAssetId) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
ALTER TABLE auth_user
ADD COLUMN avatar_asset_id UUID REFERENCES handmade_asset (id) ON DELETE SET NULL;
`,
)
return err
}
func (m UserAvatarAssetId) Down(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
ALTER TABLE auth_user
DROP COLUMN avatar_asset_id;
`,
)
return err
}

View File

@ -3,6 +3,8 @@ package models
import (
"reflect"
"time"
"github.com/google/uuid"
)
var UserType = reflect.TypeOf(User{})
@ -29,11 +31,13 @@ type User struct {
IsStaff bool `db:"is_staff"`
Status UserStatus `db:"status"`
Name string `db:"name"`
Bio string `db:"bio"`
Blurb string `db:"blurb"`
Signature string `db:"signature"`
Avatar *string `db:"avatar"`
Name string `db:"name"`
Bio string `db:"bio"`
Blurb string `db:"blurb"`
Signature string `db:"signature"`
Avatar *string `db:"avatar"`
AvatarAssetID *uuid.UUID `db:"avatar_asset_id"`
AvatarAsset *Asset `db:"avatar"`
DarkTheme bool `db:"darktheme"`
Timezone string `db:"timezone"`

View File

@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"git.handmade.network/hmn/hmn/src/hmndata"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
)
@ -100,8 +101,10 @@ func ProjectToTemplate(
}
}
func (p *Project) AddLogo(logoUrl string) {
p.Logo = logoUrl
func ProjectAndStuffToTemplate(p *hmndata.ProjectAndStuff, url string, theme string) Project {
res := ProjectToTemplate(&p.Project, url)
res.Logo = ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, theme)
return res
}
var ProjectLifecycleValues = map[models.ProjectLifecycle]string{
@ -161,15 +164,23 @@ func ThreadToTemplate(t *models.Thread) Thread {
}
}
func UserAvatarDefaultUrl(currentTheme string) string {
return hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true)
}
func UserAvatarUrl(u *models.User, currentTheme string) string {
if currentTheme == "" {
currentTheme = "light"
}
avatar := ""
if u != nil && u.Avatar != nil && len(*u.Avatar) > 0 {
avatar = hmnurl.BuildUserFile(*u.Avatar)
if u != nil && (u.AvatarAsset != nil || (u.Avatar != nil && len(*u.Avatar) > 0)) {
if u.AvatarAsset != nil {
avatar = hmnurl.BuildS3Asset(u.AvatarAsset.S3Key)
} else {
avatar = hmnurl.BuildUserFile(*u.Avatar)
}
} else {
avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true)
avatar = UserAvatarDefaultUrl(currentTheme)
}
return avatar
}
@ -178,7 +189,7 @@ func UserToTemplate(u *models.User, currentTheme string) User {
if u == nil {
return User{
Name: "Deleted user",
AvatarUrl: UserAvatarUrl(u, currentTheme),
AvatarUrl: UserAvatarUrl(nil, currentTheme),
}
}
@ -200,8 +211,8 @@ func UserToTemplate(u *models.User, currentTheme string) User {
Blurb: u.Blurb,
Signature: u.Signature,
DateJoined: u.DateJoined,
AvatarUrl: UserAvatarUrl(u, currentTheme),
ProfileUrl: hmnurl.BuildUserProfile(u.Username),
AvatarUrl: UserAvatarUrl(u, currentTheme),
DarkTheme: u.DarkTheme,
Timezone: u.Timezone,

View File

@ -2,6 +2,7 @@
{{ define "extrahead" }}
<script src="{{ static "js/tabs.js" }}"></script>
<script src="{{ static "js/image_selector.js" }}"></script>
{{ end }}
{{ define "content" }}
@ -41,12 +42,19 @@
</div>
<div class="edit-form-row">
<div>Avatar:</div>
<div>
<input type="file" name="avatar" id="avatar">
<div class="avatar-preview"><img src="{{ .User.AvatarUrl }}" width="200px">
</div>
<div class="user_avatar">
{{ template "image_selector.html" imageselectordata "avatar" .User.AvatarUrl false }}
<div class="c--dim f7">(Allowed image types: PNG, JPEG and GIF. Avatars may weigh up to 1MB and will be resized if larger than 400x400 pixels)</div>
</div>
<script>
let avatarMaxFileSize = {{ .AvatarMaxFileSize }};
let avatarSelector = new ImageSelector(
document.querySelector("#user_form"),
avatarMaxFileSize,
document.querySelector(".user_avatar"),
{{ .DefaultAvatarUrl }}
);
</script>
</div>
<div class="edit-form-row">
<div class="pt-input-ns">Short bio:</div>

View File

@ -162,9 +162,15 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
return RejectRequest(c, "User id can't be parsed")
}
u, err := db.QueryOne(c.Context(), c.Conn, models.User{},
type userQuery struct {
User models.User `db:"auth_user"`
}
u, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
`
SELECT $columns FROM auth_user WHERE id = $1
SELECT $columns
FROM auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE auth_user.id = $1
`,
userId,
)
@ -175,7 +181,7 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
}
}
user := u.(*models.User)
user := u.(*userQuery).User
whatHappened := ""
if action == ApprovalQueueActionApprove {
@ -241,6 +247,7 @@ func fetchUnapprovedPosts(c *RequestContext) ([]*UnapprovedPost, error) {
JOIN handmade_thread AS thread ON post.thread_id = thread.id
JOIN handmade_postversion AS ver ON ver.id = post.current_id
JOIN auth_user AS author ON author.id = post.author_id
LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
WHERE
NOT thread.deleted
AND NOT post.deleted

View File

@ -19,11 +19,15 @@ func APICheckUsername(c *RequestContext) ResponseData {
requestedUsername := usernameArgs[0]
found = true
c.Perf.StartBlock("SQL", "Fetch user")
userResult, err := db.QueryOne(c.Context(), c.Conn, models.User{},
type userQuery struct {
User models.User `db:"auth_user"`
}
userResult, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
`
SELECT $columns
FROM
auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE
LOWER(auth_user.username) = LOWER($1)
AND status = ANY ($2)
@ -39,7 +43,7 @@ func APICheckUsername(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", requestedUsername))
}
} else {
canonicalUsername = userResult.(*models.User).Username
canonicalUsername = userResult.(*userQuery).User.Username
}
}

View File

@ -75,7 +75,18 @@ func Login(c *RequestContext) ResponseData {
return res
}
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE LOWER(username) = LOWER($1)", username)
type userQuery struct {
User models.User `db:"auth_user"`
}
userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
`
SELECT $columns
FROM auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE LOWER(username) = LOWER($1)
`,
username,
)
if err != nil {
if errors.Is(err, db.NotFound) {
return showLoginWithFailure(c, redirect)
@ -83,7 +94,7 @@ func Login(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
}
}
user := userRow.(*models.User)
user := &userRow.(*userQuery).User
success, err := tryLogin(c, user, password)
@ -446,10 +457,14 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
var user *models.User
c.Perf.StartBlock("SQL", "Fetching user")
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{},
type userQuery struct {
User models.User `db:"auth_user"`
}
userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
`
SELECT $columns
FROM auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE
LOWER(username) = LOWER($1)
AND LOWER(email) = LOWER($2)
@ -464,7 +479,7 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
}
}
if userRow != nil {
user = userRow.(*models.User)
user = &userRow.(*userQuery).User
}
if user != nil {
@ -776,6 +791,7 @@ func validateUsernameAndToken(c *RequestContext, username string, token string,
`
SELECT $columns
FROM auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
LEFT JOIN handmade_onetimetoken AS onetimetoken ON onetimetoken.owner_id = auth_user.id
WHERE
LOWER(auth_user.username) = LOWER($1)

View File

@ -9,6 +9,7 @@ import (
"math"
"math/rand"
"net/http"
"path"
"sort"
"strings"
"time"
@ -76,8 +77,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
var restProjects []templates.Project
now := time.Now()
for _, p := range officialProjects {
templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage())
templateProject.AddLogo(p.LogoURL(c.Theme))
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
if p.Project.Slug == "hero" {
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
@ -139,8 +139,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
if i >= maxPersonalProjects {
break
}
templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage())
templateProject.AddLogo(p.LogoURL(c.Theme))
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
personalProjects = append(personalProjects, templateProject)
}
}
@ -244,6 +243,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
handmade_post AS post
INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
INNER JOIN auth_user AS author ON author.id = post.author_id
LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
WHERE
post.project_id = $1
ORDER BY post.postdate DESC
@ -272,8 +272,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details"))
}
templateData.Project = templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage())
templateData.Project.AddLogo(p.LogoURL(c.Theme))
templateData.Project = templates.ProjectAndStuffToTemplate(&p, c.UrlContext.BuildHomepage(), c.Theme)
for _, owner := range owners {
templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme))
}
@ -514,11 +513,14 @@ func ProjectEdit(c *RequestContext) ResponseData {
}
c.Perf.EndBlock()
lightLogoUrl := templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, "light")
darkLogoUrl := templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, "dark")
projectSettings := templates.ProjectToProjectSettings(
&p.Project,
p.Owners,
p.TagText(),
p.LogoURL("light"), p.LogoURL("dark"),
lightLogoUrl, darkLogoUrl,
c.Theme,
)
@ -816,10 +818,14 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
}
}
ownerRows, err := db.Query(ctx, tx, models.User{},
type userQuery struct {
User models.User `db:"auth_user"`
}
ownerRows, err := db.Query(ctx, tx, userQuery{},
`
SELECT $columns
FROM auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE LOWER(username) = ANY ($1)
`,
payload.OwnerUsernames,
@ -847,7 +853,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
VALUES
($1, $2)
`,
ownerRow.(*models.User).ID,
ownerRow.(*userQuery).User.ID,
payload.ProjectID,
)
if err != nil {
@ -914,13 +920,28 @@ func GetFormImage(c *RequestContext, fieldName string) (FormImage, error) {
img.Read(res.Content)
img.Seek(0, io.SeekStart)
config, _, err := image.DecodeConfig(img)
if err != nil {
return FormImage{}, err
fileExtensionOverrides := []string{".svg"}
fileExt := strings.ToLower(path.Ext(res.Filename))
tryDecode := true
for _, ext := range fileExtensionOverrides {
if fileExt == ext {
tryDecode = false
}
}
if tryDecode {
config, _, err := image.DecodeConfig(img)
if err != nil {
return FormImage{}, err
}
res.Width = config.Width
res.Height = config.Height
res.Mime = http.DetectContentType(res.Content)
} else {
if fileExt == ".svg" {
res.Mime = "image/svg+xml"
}
}
res.Width = config.Width
res.Height = config.Height
res.Mime = http.DetectContentType(res.Content)
}
return res, nil

View File

@ -501,20 +501,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
panic("failed to load project data")
}
canEditProject := false
if c.CurrentUser != nil {
if c.CurrentUser.IsStaff {
canEditProject = true
} else {
for _, o := range owners {
if o.ID == c.CurrentUser.ID {
canEditProject = true
break
}
}
}
}
c.CurrentUserCanEditCurrentProject = canEditProject
c.CurrentUserCanEditCurrentProject = CanEditProject(c.CurrentUser, owners)
c.UrlContext = hmndata.UrlContextForProject(c.CurrentProject)
}
@ -557,7 +544,18 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User
}
}
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", session.Username)
type userQuery struct {
User models.User `db:"auth_user"`
}
userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
`
SELECT $columns
FROM auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE username = $1
`,
session.Username,
)
if err != nil {
if errors.Is(err, db.NotFound) {
logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found")
@ -566,7 +564,7 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User
return nil, nil, oops.New(err, "failed to get user for session")
}
}
user := userRow.(*models.User)
user := &userRow.(*userQuery).User
return user, session, nil
}

View File

@ -2,13 +2,12 @@ package website
import (
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/assets"
"git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
@ -19,6 +18,7 @@ import (
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
"github.com/google/uuid"
"github.com/jackc/pgx/v4"
)
@ -52,11 +52,15 @@ func UserProfile(c *RequestContext) ResponseData {
profileUser = c.CurrentUser
} else {
c.Perf.StartBlock("SQL", "Fetch user")
userResult, err := db.QueryOne(c.Context(), c.Conn, models.User{},
type userQuery struct {
User models.User `db:"auth_user"`
}
userResult, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
`
SELECT $columns
FROM
auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE
LOWER(auth_user.username) = $1
`,
@ -70,7 +74,7 @@ func UserProfile(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", username))
}
}
profileUser = userResult.(*models.User)
profileUser = &userResult.(*userQuery).User
}
{
@ -114,8 +118,7 @@ func UserProfile(c *RequestContext) ResponseData {
templateProjects := make([]templates.Project, 0, len(projectsAndStuff))
numPersonalProjects := 0
for _, p := range projectsAndStuff {
templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage())
templateProject.AddLogo(p.LogoURL(c.Theme))
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
templateProjects = append(templateProjects, templateProject)
if p.Project.Personal {
@ -201,12 +204,17 @@ func UserProfile(c *RequestContext) ResponseData {
return res
}
var UserAvatarMaxFileSize = 1 * 1024 * 1024
func UserSettings(c *RequestContext) ResponseData {
var res ResponseData
type UserSettingsTemplateData struct {
templates.BaseData
AvatarMaxFileSize int
DefaultAvatarUrl string
User templates.User
Email string // these fields are handled specially on templates.User
ShowEmail bool
@ -280,11 +288,13 @@ func UserSettings(c *RequestContext) ResponseData {
baseData := getBaseDataAutocrumb(c, templateUser.Name)
res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{
BaseData: baseData,
User: templateUser,
Email: c.CurrentUser.Email,
ShowEmail: c.CurrentUser.ShowEmail,
LinksText: linksText,
BaseData: baseData,
AvatarMaxFileSize: UserAvatarMaxFileSize,
DefaultAvatarUrl: templates.UserAvatarDefaultUrl(c.Theme),
User: templateUser,
Email: c.CurrentUser.Email,
ShowEmail: c.CurrentUser.ShowEmail,
LinksText: linksText,
SubmitUrl: hmnurl.BuildUserSettings(""),
ContactUrl: hmnurl.BuildContactPage(),
@ -299,6 +309,14 @@ func UserSettings(c *RequestContext) ResponseData {
}
func UserSettingsSave(c *RequestContext) ResponseData {
maxBodySize := int64(UserAvatarMaxFileSize + 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.
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form"))
}
tx, err := c.Conn.Begin(c.Context())
if err != nil {
panic(err)
@ -396,25 +414,39 @@ func UserSettingsSave(c *RequestContext) ResponseData {
}
// Update avatar
imageSaveResult := SaveImageFile(c, tx, "avatar", 1*1024*1024, fmt.Sprintf("members/avatars/%s-%d", c.CurrentUser.Username, time.Now().UTC().Unix()))
if imageSaveResult.ValidationError != "" {
return RejectRequest(c, imageSaveResult.ValidationError)
} else if imageSaveResult.FatalError != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(imageSaveResult.FatalError, "failed to save new avatar"))
} else if imageSaveResult.ImageFile != nil {
_, err = tx.Exec(c.Context(),
newAvatar, err := GetFormImage(c, "avatar")
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read image from form"))
}
var avatarUUID *uuid.UUID
if newAvatar.Exists {
avatarAsset, err := assets.Create(c.Context(), tx, assets.CreateInput{
Content: newAvatar.Content,
Filename: newAvatar.Filename,
ContentType: newAvatar.Mime,
UploaderID: &c.CurrentUser.ID,
Width: newAvatar.Width,
Height: newAvatar.Height,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to upload avatar"))
}
avatarUUID = &avatarAsset.ID
}
if newAvatar.Exists || newAvatar.Remove {
_, err := tx.Exec(c.Context(),
`
UPDATE auth_user
SET
avatar = $2
avatar_asset_id = $2
WHERE
id = $1
`,
c.CurrentUser.ID,
imageSaveResult.ImageFile.File,
avatarUUID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user"))
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user's avatar"))
}
}