Assets for user avatars
This commit is contained in:
parent
eb32b04437
commit
f4f439489d
|
@ -1,4 +1,4 @@
|
||||||
function ImageSelector(form, maxFileSize, container) {
|
function ImageSelector(form, maxFileSize, container, defaultImageUrl) {
|
||||||
this.form = form;
|
this.form = form;
|
||||||
this.maxFileSize = maxFileSize;
|
this.maxFileSize = maxFileSize;
|
||||||
this.fileInput = container.querySelector(".image_input");
|
this.fileInput = container.querySelector(".image_input");
|
||||||
|
@ -8,6 +8,7 @@ function ImageSelector(form, maxFileSize, container) {
|
||||||
this.removeLink = container.querySelector(".remove");
|
this.removeLink = container.querySelector(".remove");
|
||||||
this.originalImageUrl = this.imageEl.getAttribute("data-original");
|
this.originalImageUrl = this.imageEl.getAttribute("data-original");
|
||||||
this.currentImageUrl = this.originalImageUrl;
|
this.currentImageUrl = this.originalImageUrl;
|
||||||
|
this.defaultImageUrl = defaultImageUrl || "";
|
||||||
|
|
||||||
this.fileInput.value = "";
|
this.fileInput.value = "";
|
||||||
this.removeImageInput.value = "";
|
this.removeImageInput.value = "";
|
||||||
|
@ -45,7 +46,7 @@ ImageSelector.prototype.removeImage = function() {
|
||||||
this.updateSizeLimit(0);
|
this.updateSizeLimit(0);
|
||||||
this.fileInput.value = "";
|
this.fileInput.value = "";
|
||||||
this.removeImageInput.value = "true";
|
this.removeImageInput.value = "true";
|
||||||
this.setImageUrl("");
|
this.setImageUrl(this.defaultImageUrl);
|
||||||
this.updateButtons();
|
this.updateButtons();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -82,13 +83,15 @@ ImageSelector.prototype.setImageUrl = function(url) {
|
||||||
};
|
};
|
||||||
|
|
||||||
ImageSelector.prototype.updateButtons = function() {
|
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";
|
this.resetLink.style.display = "inline-block";
|
||||||
} else {
|
} else {
|
||||||
this.resetLink.style.display = "none";
|
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";
|
this.removeLink.style.display = "inline-block";
|
||||||
} else {
|
} else {
|
||||||
this.removeLink.style.display = "none";
|
this.removeLink.style.display = "none";
|
||||||
|
|
|
@ -399,6 +399,7 @@ func init() {
|
||||||
}
|
}
|
||||||
width := 0
|
width := 0
|
||||||
height := 0
|
height := 0
|
||||||
|
mime := ""
|
||||||
fileExtensionOverrides := []string{".svg"}
|
fileExtensionOverrides := []string{".svg"}
|
||||||
fileExt := strings.ToLower(path.Ext(filepath))
|
fileExt := strings.ToLower(path.Ext(filepath))
|
||||||
tryDecode := true
|
tryDecode := true
|
||||||
|
@ -414,9 +415,13 @@ func init() {
|
||||||
}
|
}
|
||||||
width = config.Width
|
width = config.Width
|
||||||
height = config.Height
|
height = config.Height
|
||||||
|
mime = http.DetectContentType(contents)
|
||||||
|
} else {
|
||||||
|
if fileExt == ".svg" {
|
||||||
|
mime = "image/svg+xml"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mime := http.DetectContentType(contents)
|
|
||||||
filename := path.Base(filepath)
|
filename := path.Base(filepath)
|
||||||
|
|
||||||
asset, err := assets.Create(ctx, conn, assets.CreateInput{
|
asset, err := assets.Create(ctx, conn, assets.CreateInput{
|
||||||
|
@ -490,5 +495,109 @@ func init() {
|
||||||
}
|
}
|
||||||
adminCommand.AddCommand(uploadProjectLogos)
|
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)
|
addProjectCommands(adminCommand)
|
||||||
}
|
}
|
||||||
|
|
31
src/db/db.go
31
src/db/db.go
|
@ -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) {
|
func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
|
||||||
destType := reflect.TypeOf(destExample)
|
destType := reflect.TypeOf(destExample)
|
||||||
columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "")
|
columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, oops.New(err, "failed to generate column names")
|
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)
|
query = strings.Replace(query, "$columns", columnNamesString, -1)
|
||||||
|
|
||||||
rows, err := conn.Query(ctx, query, args...)
|
rows, err := conn.Query(ctx, query, args...)
|
||||||
|
@ -282,8 +292,8 @@ func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{},
|
||||||
return it, nil
|
return it, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix string) (names []string, paths [][]int, err error) {
|
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix []string) (names [][]string, paths [][]int, err error) {
|
||||||
var columnNames []string
|
var columnNames [][]string
|
||||||
var fieldPaths [][]int
|
var fieldPaths [][]int
|
||||||
|
|
||||||
if destType.Kind() == reflect.Ptr {
|
if destType.Kind() == reflect.Ptr {
|
||||||
|
@ -301,7 +311,10 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
|
||||||
var anonPrefixes []AnonPrefix
|
var anonPrefixes []AnonPrefix
|
||||||
|
|
||||||
for _, field := range reflect.VisibleFields(destType) {
|
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 columnName := field.Tag.Get("db"); columnName != "" {
|
||||||
if field.Anonymous {
|
if field.Anonymous {
|
||||||
|
@ -318,7 +331,7 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if equal {
|
if equal {
|
||||||
columnName = anonPrefix.Prefix + "." + columnName
|
fieldColumnNames = append(fieldColumnNames, anonPrefix.Prefix)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -329,11 +342,13 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
|
||||||
fieldType = fieldType.Elem()
|
fieldType = fieldType.Elem()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fieldColumnNames = append(fieldColumnNames, columnName)
|
||||||
|
|
||||||
if typeIsQueryable(fieldType) {
|
if typeIsQueryable(fieldType) {
|
||||||
columnNames = append(columnNames, prefix+columnName)
|
columnNames = append(columnNames, fieldColumnNames)
|
||||||
fieldPaths = append(fieldPaths, path)
|
fieldPaths = append(fieldPaths, path)
|
||||||
} else if fieldType.Kind() == reflect.Struct {
|
} else if fieldType.Kind() == reflect.Struct {
|
||||||
subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, columnName+".")
|
subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, fieldColumnNames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,6 +97,7 @@ func (bot *botInstance) handleProfileCommand(ctx context.Context, i *Interaction
|
||||||
FROM
|
FROM
|
||||||
handmade_discorduser AS duser
|
handmade_discorduser AS duser
|
||||||
JOIN auth_user ON duser.hmn_user_id = auth_user.id
|
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
|
WHERE
|
||||||
duser.userid = $1
|
duser.userid = $1
|
||||||
`,
|
`,
|
||||||
|
|
|
@ -640,6 +640,7 @@ func (bot *botInstance) messageDelete(ctx context.Context, msgDelete MessageDele
|
||||||
handmade_discordmessage AS msg
|
handmade_discordmessage AS msg
|
||||||
LEFT JOIN handmade_discorduser AS duser ON msg.user_id = duser.userid
|
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 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
|
LEFT JOIN handmade_snippet AS snippet ON snippet.discord_message_id = msg.id
|
||||||
WHERE msg.id = $1 AND msg.channel_id = $2
|
WHERE msg.id = $1 AND msg.channel_id = $2
|
||||||
`,
|
`,
|
||||||
|
|
|
@ -526,6 +526,7 @@ func FetchDiscordUser(ctx context.Context, dbConn db.ConnOrTx, discordUserID str
|
||||||
FROM
|
FROM
|
||||||
handmade_discorduser AS duser
|
handmade_discorduser AS duser
|
||||||
JOIN auth_user AS u ON duser.hmn_user_id = u.id
|
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
|
WHERE
|
||||||
duser.userid = $1
|
duser.userid = $1
|
||||||
`,
|
`,
|
||||||
|
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/perf"
|
"git.handmade.network/hmn/hmn/src/perf"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectTypeQuery int
|
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(
|
func FetchProjects(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
dbConn db.ConnOrTx,
|
dbConn db.ConnOrTx,
|
||||||
|
@ -366,12 +361,16 @@ func FetchMultipleProjectsOwners(
|
||||||
userIds = append(userIds, userProject.UserID)
|
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
|
SELECT $columns
|
||||||
FROM auth_user
|
FROM auth_user
|
||||||
|
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||||
WHERE
|
WHERE
|
||||||
id = ANY($1)
|
auth_user.id = ANY($1)
|
||||||
`,
|
`,
|
||||||
userIds,
|
userIds,
|
||||||
)
|
)
|
||||||
|
@ -398,9 +397,9 @@ func FetchMultipleProjectsOwners(
|
||||||
// Get the full user record we fetched
|
// Get the full user record we fetched
|
||||||
var user *models.User
|
var user *models.User
|
||||||
for _, iuser := range iusers {
|
for _, iuser := range iusers {
|
||||||
u := iuser.(*models.User)
|
u := iuser.(*userQuery).User
|
||||||
if u.ID == userProject.UserID {
|
if u.ID == userProject.UserID {
|
||||||
user = u
|
user = &u
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
|
|
|
@ -80,6 +80,7 @@ func FetchSnippets(
|
||||||
FROM
|
FROM
|
||||||
handmade_snippet AS snippet
|
handmade_snippet AS snippet
|
||||||
LEFT JOIN auth_user AS owner ON snippet.owner_id = owner.id
|
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_asset AS asset ON snippet.asset_id = asset.id
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON snippet.discord_message_id = discord_message.id
|
LEFT JOIN handmade_discordmessage AS discord_message ON snippet.discord_message_id = discord_message.id
|
||||||
WHERE
|
WHERE
|
||||||
|
|
|
@ -78,7 +78,9 @@ func FetchThreads(
|
||||||
JOIN handmade_postversion AS first_version ON first_version.id = first_post.current_id
|
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
|
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 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 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 (
|
LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
|
||||||
tlri.thread_id = thread.id
|
tlri.thread_id = thread.id
|
||||||
AND tlri.user_id = $?
|
AND tlri.user_id = $?
|
||||||
|
@ -221,6 +223,7 @@ func CountThreads(
|
||||||
JOIN handmade_project AS project ON thread.project_id = project.id
|
JOIN handmade_project AS project ON thread.project_id = project.id
|
||||||
JOIN handmade_post AS first_post ON first_post.id = thread.first_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 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
|
WHERE
|
||||||
NOT thread.deleted
|
NOT thread.deleted
|
||||||
AND ( -- project has valid lifecycle
|
AND ( -- project has valid lifecycle
|
||||||
|
@ -330,7 +333,9 @@ func FetchPosts(
|
||||||
JOIN handmade_project AS project ON post.project_id = project.id
|
JOIN handmade_project AS project ON post.project_id = project.id
|
||||||
JOIN handmade_postversion AS ver ON ver.id = post.current_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 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 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 (
|
LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
|
||||||
tlri.thread_id = thread.id
|
tlri.thread_id = thread.id
|
||||||
AND tlri.user_id = $?
|
AND tlri.user_id = $?
|
||||||
|
@ -344,6 +349,7 @@ func FetchPosts(
|
||||||
-- check fails.
|
-- check fails.
|
||||||
LEFT JOIN handmade_post AS reply_post ON reply_post.id = post.reply_id
|
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 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
|
WHERE
|
||||||
NOT thread.deleted
|
NOT thread.deleted
|
||||||
AND NOT post.deleted
|
AND NOT post.deleted
|
||||||
|
@ -543,6 +549,7 @@ func CountPosts(
|
||||||
JOIN handmade_thread AS thread ON post.thread_id = thread.id
|
JOIN handmade_thread AS thread ON post.thread_id = thread.id
|
||||||
JOIN handmade_project AS project ON post.project_id = project.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 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
|
WHERE
|
||||||
NOT thread.deleted
|
NOT thread.deleted
|
||||||
AND NOT post.deleted
|
AND NOT post.deleted
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -3,6 +3,8 @@ package models
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
var UserType = reflect.TypeOf(User{})
|
var UserType = reflect.TypeOf(User{})
|
||||||
|
@ -29,11 +31,13 @@ type User struct {
|
||||||
IsStaff bool `db:"is_staff"`
|
IsStaff bool `db:"is_staff"`
|
||||||
Status UserStatus `db:"status"`
|
Status UserStatus `db:"status"`
|
||||||
|
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
Bio string `db:"bio"`
|
Bio string `db:"bio"`
|
||||||
Blurb string `db:"blurb"`
|
Blurb string `db:"blurb"`
|
||||||
Signature string `db:"signature"`
|
Signature string `db:"signature"`
|
||||||
Avatar *string `db:"avatar"`
|
Avatar *string `db:"avatar"`
|
||||||
|
AvatarAssetID *uuid.UUID `db:"avatar_asset_id"`
|
||||||
|
AvatarAsset *Asset `db:"avatar"`
|
||||||
|
|
||||||
DarkTheme bool `db:"darktheme"`
|
DarkTheme bool `db:"darktheme"`
|
||||||
Timezone string `db:"timezone"`
|
Timezone string `db:"timezone"`
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
)
|
)
|
||||||
|
@ -100,8 +101,10 @@ func ProjectToTemplate(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Project) AddLogo(logoUrl string) {
|
func ProjectAndStuffToTemplate(p *hmndata.ProjectAndStuff, url string, theme string) Project {
|
||||||
p.Logo = logoUrl
|
res := ProjectToTemplate(&p.Project, url)
|
||||||
|
res.Logo = ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, theme)
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
var ProjectLifecycleValues = map[models.ProjectLifecycle]string{
|
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 {
|
func UserAvatarUrl(u *models.User, currentTheme string) string {
|
||||||
if currentTheme == "" {
|
if currentTheme == "" {
|
||||||
currentTheme = "light"
|
currentTheme = "light"
|
||||||
}
|
}
|
||||||
avatar := ""
|
avatar := ""
|
||||||
if u != nil && u.Avatar != nil && len(*u.Avatar) > 0 {
|
if u != nil && (u.AvatarAsset != nil || (u.Avatar != nil && len(*u.Avatar) > 0)) {
|
||||||
avatar = hmnurl.BuildUserFile(*u.Avatar)
|
if u.AvatarAsset != nil {
|
||||||
|
avatar = hmnurl.BuildS3Asset(u.AvatarAsset.S3Key)
|
||||||
|
} else {
|
||||||
|
avatar = hmnurl.BuildUserFile(*u.Avatar)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true)
|
avatar = UserAvatarDefaultUrl(currentTheme)
|
||||||
}
|
}
|
||||||
return avatar
|
return avatar
|
||||||
}
|
}
|
||||||
|
@ -178,7 +189,7 @@ func UserToTemplate(u *models.User, currentTheme string) User {
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return User{
|
return User{
|
||||||
Name: "Deleted 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,
|
Blurb: u.Blurb,
|
||||||
Signature: u.Signature,
|
Signature: u.Signature,
|
||||||
DateJoined: u.DateJoined,
|
DateJoined: u.DateJoined,
|
||||||
AvatarUrl: UserAvatarUrl(u, currentTheme),
|
|
||||||
ProfileUrl: hmnurl.BuildUserProfile(u.Username),
|
ProfileUrl: hmnurl.BuildUserProfile(u.Username),
|
||||||
|
AvatarUrl: UserAvatarUrl(u, currentTheme),
|
||||||
|
|
||||||
DarkTheme: u.DarkTheme,
|
DarkTheme: u.DarkTheme,
|
||||||
Timezone: u.Timezone,
|
Timezone: u.Timezone,
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
{{ define "extrahead" }}
|
{{ define "extrahead" }}
|
||||||
<script src="{{ static "js/tabs.js" }}"></script>
|
<script src="{{ static "js/tabs.js" }}"></script>
|
||||||
|
<script src="{{ static "js/image_selector.js" }}"></script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
|
@ -41,12 +42,19 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-form-row">
|
<div class="edit-form-row">
|
||||||
<div>Avatar:</div>
|
<div>Avatar:</div>
|
||||||
<div>
|
<div class="user_avatar">
|
||||||
<input type="file" name="avatar" id="avatar">
|
{{ template "image_selector.html" imageselectordata "avatar" .User.AvatarUrl false }}
|
||||||
<div class="avatar-preview"><img src="{{ .User.AvatarUrl }}" width="200px">
|
|
||||||
</div>
|
|
||||||
<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 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>
|
</div>
|
||||||
|
<script>
|
||||||
|
let avatarMaxFileSize = {{ .AvatarMaxFileSize }};
|
||||||
|
let avatarSelector = new ImageSelector(
|
||||||
|
document.querySelector("#user_form"),
|
||||||
|
avatarMaxFileSize,
|
||||||
|
document.querySelector(".user_avatar"),
|
||||||
|
{{ .DefaultAvatarUrl }}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-form-row">
|
<div class="edit-form-row">
|
||||||
<div class="pt-input-ns">Short bio:</div>
|
<div class="pt-input-ns">Short bio:</div>
|
||||||
|
|
|
@ -162,9 +162,15 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
||||||
return RejectRequest(c, "User id can't be parsed")
|
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,
|
userId,
|
||||||
)
|
)
|
||||||
|
@ -175,7 +181,7 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
user := u.(*models.User)
|
user := u.(*userQuery).User
|
||||||
|
|
||||||
whatHappened := ""
|
whatHappened := ""
|
||||||
if action == ApprovalQueueActionApprove {
|
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_thread AS thread ON post.thread_id = thread.id
|
||||||
JOIN handmade_postversion AS ver ON ver.id = post.current_id
|
JOIN handmade_postversion AS ver ON ver.id = post.current_id
|
||||||
JOIN auth_user AS author ON author.id = post.author_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
|
WHERE
|
||||||
NOT thread.deleted
|
NOT thread.deleted
|
||||||
AND NOT post.deleted
|
AND NOT post.deleted
|
||||||
|
|
|
@ -19,11 +19,15 @@ func APICheckUsername(c *RequestContext) ResponseData {
|
||||||
requestedUsername := usernameArgs[0]
|
requestedUsername := usernameArgs[0]
|
||||||
found = true
|
found = true
|
||||||
c.Perf.StartBlock("SQL", "Fetch user")
|
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
|
SELECT $columns
|
||||||
FROM
|
FROM
|
||||||
auth_user
|
auth_user
|
||||||
|
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||||
WHERE
|
WHERE
|
||||||
LOWER(auth_user.username) = LOWER($1)
|
LOWER(auth_user.username) = LOWER($1)
|
||||||
AND status = ANY ($2)
|
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))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", requestedUsername))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
canonicalUsername = userResult.(*models.User).Username
|
canonicalUsername = userResult.(*userQuery).User.Username
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -75,7 +75,18 @@ func Login(c *RequestContext) ResponseData {
|
||||||
return res
|
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 err != nil {
|
||||||
if errors.Is(err, db.NotFound) {
|
if errors.Is(err, db.NotFound) {
|
||||||
return showLoginWithFailure(c, redirect)
|
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"))
|
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)
|
success, err := tryLogin(c, user, password)
|
||||||
|
|
||||||
|
@ -446,10 +457,14 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
||||||
var user *models.User
|
var user *models.User
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetching 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
|
SELECT $columns
|
||||||
FROM auth_user
|
FROM auth_user
|
||||||
|
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||||
WHERE
|
WHERE
|
||||||
LOWER(username) = LOWER($1)
|
LOWER(username) = LOWER($1)
|
||||||
AND LOWER(email) = LOWER($2)
|
AND LOWER(email) = LOWER($2)
|
||||||
|
@ -464,7 +479,7 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if userRow != nil {
|
if userRow != nil {
|
||||||
user = userRow.(*models.User)
|
user = &userRow.(*userQuery).User
|
||||||
}
|
}
|
||||||
|
|
||||||
if user != nil {
|
if user != nil {
|
||||||
|
@ -776,6 +791,7 @@ func validateUsernameAndToken(c *RequestContext, username string, token string,
|
||||||
`
|
`
|
||||||
SELECT $columns
|
SELECT $columns
|
||||||
FROM auth_user
|
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
|
LEFT JOIN handmade_onetimetoken AS onetimetoken ON onetimetoken.owner_id = auth_user.id
|
||||||
WHERE
|
WHERE
|
||||||
LOWER(auth_user.username) = LOWER($1)
|
LOWER(auth_user.username) = LOWER($1)
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -76,8 +77,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
||||||
var restProjects []templates.Project
|
var restProjects []templates.Project
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, p := range officialProjects {
|
for _, p := range officialProjects {
|
||||||
templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage())
|
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
|
||||||
templateProject.AddLogo(p.LogoURL(c.Theme))
|
|
||||||
|
|
||||||
if p.Project.Slug == "hero" {
|
if p.Project.Slug == "hero" {
|
||||||
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
|
// 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 {
|
if i >= maxPersonalProjects {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage())
|
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
|
||||||
templateProject.AddLogo(p.LogoURL(c.Theme))
|
|
||||||
personalProjects = append(personalProjects, templateProject)
|
personalProjects = append(personalProjects, templateProject)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -244,6 +243,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
handmade_post AS post
|
handmade_post AS post
|
||||||
INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
|
INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
|
||||||
INNER JOIN auth_user AS author ON author.id = post.author_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
|
WHERE
|
||||||
post.project_id = $1
|
post.project_id = $1
|
||||||
ORDER BY post.postdate DESC
|
ORDER BY post.postdate DESC
|
||||||
|
@ -272,8 +272,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details"))
|
||||||
}
|
}
|
||||||
templateData.Project = templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage())
|
templateData.Project = templates.ProjectAndStuffToTemplate(&p, c.UrlContext.BuildHomepage(), c.Theme)
|
||||||
templateData.Project.AddLogo(p.LogoURL(c.Theme))
|
|
||||||
for _, owner := range owners {
|
for _, owner := range owners {
|
||||||
templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme))
|
templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme))
|
||||||
}
|
}
|
||||||
|
@ -514,11 +513,14 @@ func ProjectEdit(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
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(
|
projectSettings := templates.ProjectToProjectSettings(
|
||||||
&p.Project,
|
&p.Project,
|
||||||
p.Owners,
|
p.Owners,
|
||||||
p.TagText(),
|
p.TagText(),
|
||||||
p.LogoURL("light"), p.LogoURL("dark"),
|
lightLogoUrl, darkLogoUrl,
|
||||||
c.Theme,
|
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
|
SELECT $columns
|
||||||
FROM auth_user
|
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)
|
WHERE LOWER(username) = ANY ($1)
|
||||||
`,
|
`,
|
||||||
payload.OwnerUsernames,
|
payload.OwnerUsernames,
|
||||||
|
@ -847,7 +853,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2)
|
($1, $2)
|
||||||
`,
|
`,
|
||||||
ownerRow.(*models.User).ID,
|
ownerRow.(*userQuery).User.ID,
|
||||||
payload.ProjectID,
|
payload.ProjectID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -914,13 +920,28 @@ func GetFormImage(c *RequestContext, fieldName string) (FormImage, error) {
|
||||||
img.Read(res.Content)
|
img.Read(res.Content)
|
||||||
img.Seek(0, io.SeekStart)
|
img.Seek(0, io.SeekStart)
|
||||||
|
|
||||||
config, _, err := image.DecodeConfig(img)
|
fileExtensionOverrides := []string{".svg"}
|
||||||
if err != nil {
|
fileExt := strings.ToLower(path.Ext(res.Filename))
|
||||||
return FormImage{}, err
|
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
|
return res, nil
|
||||||
|
|
|
@ -501,20 +501,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||||
panic("failed to load project data")
|
panic("failed to load project data")
|
||||||
}
|
}
|
||||||
|
|
||||||
canEditProject := false
|
c.CurrentUserCanEditCurrentProject = CanEditProject(c.CurrentUser, owners)
|
||||||
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.UrlContext = hmndata.UrlContextForProject(c.CurrentProject)
|
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 err != nil {
|
||||||
if errors.Is(err, db.NotFound) {
|
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")
|
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")
|
return nil, nil, oops.New(err, "failed to get user for session")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
user := userRow.(*models.User)
|
user := &userRow.(*userQuery).User
|
||||||
|
|
||||||
return user, session, nil
|
return user, session, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,12 @@ package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/assets"
|
||||||
"git.handmade.network/hmn/hmn/src/auth"
|
"git.handmade.network/hmn/hmn/src/auth"
|
||||||
"git.handmade.network/hmn/hmn/src/config"
|
"git.handmade.network/hmn/hmn/src/config"
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"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/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v4"
|
"github.com/jackc/pgx/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -52,11 +52,15 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
profileUser = c.CurrentUser
|
profileUser = c.CurrentUser
|
||||||
} else {
|
} else {
|
||||||
c.Perf.StartBlock("SQL", "Fetch user")
|
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
|
SELECT $columns
|
||||||
FROM
|
FROM
|
||||||
auth_user
|
auth_user
|
||||||
|
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
|
||||||
WHERE
|
WHERE
|
||||||
LOWER(auth_user.username) = $1
|
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))
|
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))
|
templateProjects := make([]templates.Project, 0, len(projectsAndStuff))
|
||||||
numPersonalProjects := 0
|
numPersonalProjects := 0
|
||||||
for _, p := range projectsAndStuff {
|
for _, p := range projectsAndStuff {
|
||||||
templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage())
|
templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
|
||||||
templateProject.AddLogo(p.LogoURL(c.Theme))
|
|
||||||
templateProjects = append(templateProjects, templateProject)
|
templateProjects = append(templateProjects, templateProject)
|
||||||
|
|
||||||
if p.Project.Personal {
|
if p.Project.Personal {
|
||||||
|
@ -201,12 +204,17 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var UserAvatarMaxFileSize = 1 * 1024 * 1024
|
||||||
|
|
||||||
func UserSettings(c *RequestContext) ResponseData {
|
func UserSettings(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
|
|
||||||
type UserSettingsTemplateData struct {
|
type UserSettingsTemplateData struct {
|
||||||
templates.BaseData
|
templates.BaseData
|
||||||
|
|
||||||
|
AvatarMaxFileSize int
|
||||||
|
DefaultAvatarUrl string
|
||||||
|
|
||||||
User templates.User
|
User templates.User
|
||||||
Email string // these fields are handled specially on templates.User
|
Email string // these fields are handled specially on templates.User
|
||||||
ShowEmail bool
|
ShowEmail bool
|
||||||
|
@ -280,11 +288,13 @@ func UserSettings(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseDataAutocrumb(c, templateUser.Name)
|
baseData := getBaseDataAutocrumb(c, templateUser.Name)
|
||||||
|
|
||||||
res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{
|
res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
User: templateUser,
|
AvatarMaxFileSize: UserAvatarMaxFileSize,
|
||||||
Email: c.CurrentUser.Email,
|
DefaultAvatarUrl: templates.UserAvatarDefaultUrl(c.Theme),
|
||||||
ShowEmail: c.CurrentUser.ShowEmail,
|
User: templateUser,
|
||||||
LinksText: linksText,
|
Email: c.CurrentUser.Email,
|
||||||
|
ShowEmail: c.CurrentUser.ShowEmail,
|
||||||
|
LinksText: linksText,
|
||||||
|
|
||||||
SubmitUrl: hmnurl.BuildUserSettings(""),
|
SubmitUrl: hmnurl.BuildUserSettings(""),
|
||||||
ContactUrl: hmnurl.BuildContactPage(),
|
ContactUrl: hmnurl.BuildContactPage(),
|
||||||
|
@ -299,6 +309,14 @@ func UserSettings(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserSettingsSave(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())
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
@ -396,25 +414,39 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update avatar
|
// Update avatar
|
||||||
imageSaveResult := SaveImageFile(c, tx, "avatar", 1*1024*1024, fmt.Sprintf("members/avatars/%s-%d", c.CurrentUser.Username, time.Now().UTC().Unix()))
|
newAvatar, err := GetFormImage(c, "avatar")
|
||||||
if imageSaveResult.ValidationError != "" {
|
if err != nil {
|
||||||
return RejectRequest(c, imageSaveResult.ValidationError)
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read image from form"))
|
||||||
} else if imageSaveResult.FatalError != nil {
|
}
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(imageSaveResult.FatalError, "failed to save new avatar"))
|
var avatarUUID *uuid.UUID
|
||||||
} else if imageSaveResult.ImageFile != nil {
|
if newAvatar.Exists {
|
||||||
_, err = tx.Exec(c.Context(),
|
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
|
UPDATE auth_user
|
||||||
SET
|
SET
|
||||||
avatar = $2
|
avatar_asset_id = $2
|
||||||
WHERE
|
WHERE
|
||||||
id = $1
|
id = $1
|
||||||
`,
|
`,
|
||||||
c.CurrentUser.ID,
|
c.CurrentUser.ID,
|
||||||
imageSaveResult.ImageFile.File,
|
avatarUUID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue