diff --git a/public/js/tabs.js b/public/js/tabs.js new file mode 100644 index 0000000..2c7b239 --- /dev/null +++ b/public/js/tabs.js @@ -0,0 +1,95 @@ +function TabState(tabbed) { + this.container = tabbed; + this.tabs = tabbed.querySelector(".tab"); + + this.tabbar = document.createElement("div"); + this.tabbar.classList.add("tab-bar"); + this.container.insertBefore(this.tabbar, this.container.firstChild); + + this.current_i = -1; + this.tab_buttons = []; +} + +function switch_tab_old(state, tab_i) { + return function() { + if (state.current_i >= 0) { + state.tabs[state.current_i].classList.add("hidden"); + state.tab_buttons[state.current_i].classList.remove("current"); + } + + state.tabs[tab_i].classList.remove("hidden"); + state.tab_buttons[tab_i].classList.add("current"); + + var hash = ""; + if (state.tabs[tab_i].hasAttribute("data-url-hash")) { + hash = state.tabs[tab_i].getAttribute("data-url-hash"); + } + window.location.hash = hash; + + state.current_i = tab_i; + }; +} + +document.addEventListener("DOMContentLoaded", function() { + const tabContainers = document.getElementsByClassName("tabbed"); + for (const container of tabContainers) { + const tabBar = document.createElement("div"); + tabBar.classList.add("tab-bar"); + container.insertAdjacentElement('afterbegin', tabBar); + + const tabs = container.querySelectorAll(".tab"); + for (let i = 0; i < tabs.length; i++) { + const tab = tabs[i]; + tab.classList.toggle('dn', i > 0); + + const slug = tab.getAttribute("data-slug"); + + // TODO: Should this element be a link? + const tabButton = document.createElement("div"); + tabButton.classList.add("tab-button"); + tabButton.classList.toggle("current", i === 0); + tabButton.innerText = tab.getAttribute("data-name"); + tabButton.setAttribute("data-slug", slug); + + tabButton.addEventListener("click", () => { + switchTab(container, slug); + }); + + tabBar.appendChild(tabButton); + } + + const initialSlug = window.location.hash; + if (initialSlug) { + switchTab(container, initialSlug.substring(1)); + } + } +}); + +function switchTab(container, slug) { + const tabs = container.querySelectorAll('.tab'); + + let didMatch = false; + for (const tab of tabs) { + const slugMatches = tab.getAttribute("data-slug") === slug; + tab.classList.toggle('dn', !slugMatches); + // TODO: Also update the tab button styles + + if (slugMatches) { + didMatch = true; + } + } + + const tabButtons = document.querySelectorAll(".tab-button"); + for (const tabButton of tabButtons) { + const buttonSlug = tabButton.getAttribute("data-slug"); + tabButton.classList.toggle('current', slug === buttonSlug); + } + + if (!didMatch) { + // switch to first tab as a fallback + tabs[0].classList.remove('dn'); + tabButtons[0].classList.add('current'); + } + + window.location.hash = slug; +} \ No newline at end of file diff --git a/public/style.css b/public/style.css index 8f1fde0..62f609a 100644 --- a/public/style.css +++ b/public/style.css @@ -1994,7 +1994,7 @@ img, video { -l = large */ -.flex { +.flex, .tab-bar, .edit-form .edit-form-row { display: flex; } .inline-flex { @@ -2012,10 +2012,10 @@ img, video { .flex-none { flex: none; } -.flex-column { +.flex-column, .edit-form .edit-form-row { flex-direction: column; } -.flex-row { +.flex-row, .tab-bar { flex-direction: row; } .flex-wrap { @@ -2126,13 +2126,13 @@ img, video { .order-last { order: 99999; } -.flex-grow-0 { +.flex-grow-0, .edit-form .edit-form-row > :first-child { flex-grow: 0; } -.flex-grow-1 { +.flex-grow-1, .edit-form .edit-form-row > :nth-child(2) { flex-grow: 1; } -.flex-shrink-0 { +.flex-shrink-0, .edit-form .edit-form-row > :first-child { flex-shrink: 0; } .flex-shrink-1 { @@ -2153,7 +2153,7 @@ img, video { flex: none; } .flex-column-ns { flex-direction: column; } - .flex-row-ns { + .flex-row-ns, .edit-form .edit-form-row { flex-direction: row; } .flex-wrap-ns { flex-wrap: wrap; } @@ -2771,7 +2771,7 @@ code, .code { .h2 { height: 2rem; } -.h3 { +.h3, .edit-form textarea { height: 4rem; } .h4 { @@ -3079,7 +3079,7 @@ code, .code { */ /* Max Width Percentages */ -.mw-100 { +.mw-100, .edit-form textarea { max-width: 100%; } /* Max Width Scale */ @@ -3125,7 +3125,7 @@ code, .code { max-width: 4rem; } .mw4-ns { max-width: 8rem; } - .mw5-ns { + .mw5-ns, .edit-form input[type=text] { max-width: 16rem; } .mw6-ns { max-width: 32rem; } @@ -3243,6 +3243,9 @@ code, .code { .w5 { width: 16rem; } +.w6 { + width: 32rem; } + .w-10 { width: 10%; } @@ -3282,7 +3285,7 @@ code, .code { .w-90 { width: 90%; } -.w-100 { +.w-100, .edit-form .edit-form-row > :first-child, .edit-form input[type=text], .edit-form textarea { width: 100%; } .w-third { @@ -3301,10 +3304,12 @@ code, .code { width: 2rem; } .w3-ns { width: 4rem; } - .w4-ns { + .w4-ns, .edit-form .edit-form-row > :first-child { width: 8rem; } .w5-ns { width: 16rem; } + .w6-ns, .edit-form textarea { + width: 32rem; } .w-10-ns { width: 10%; } .w-20-ns { @@ -3351,6 +3356,8 @@ code, .code { width: 8rem; } .w5-m { width: 16rem; } + .w6-m { + width: 32rem; } .w-10-m { width: 10%; } .w-20-m { @@ -3397,6 +3404,8 @@ code, .code { width: 8rem; } .w5-l { width: 16rem; } + .w6-l { + width: 32rem; } .w-10-l { width: 10%; } .w-20-l { @@ -3445,7 +3454,7 @@ code, .code { .overflow-visible { overflow: visible; } -.overflow-hidden { +.overflow-hidden, .edit-form .edit-form-row > :nth-child(2) { overflow: hidden; } .overflow-scroll { @@ -4614,7 +4623,7 @@ code, .code { .pl7 { padding-left: 16rem; } -.pr0 { +.pr0, .edit-form .edit-form-row > :first-child { padding-right: 0; } .pr1 { @@ -4641,7 +4650,7 @@ code, .code { .pb0 { padding-bottom: 0; } -.pb1 { +.pb1, .edit-form .edit-form-row > :first-child { padding-bottom: 0.25rem; } .pb2 { @@ -4698,7 +4707,7 @@ code, .code { padding-top: 0.25rem; padding-bottom: 0.25rem; } -.pv2, header .menu-bar .items a.project-logo, +.pv2, header .menu-bar .items a.project-logo, .tab-bar .tab-button, button, .button, input[type=button], @@ -4742,7 +4751,7 @@ input[type=submit] { padding-left: 0.5rem; padding-right: 0.5rem; } -.ph3, +.ph3, .tab-bar .tab-button, button, .button, input[type=button], @@ -4898,7 +4907,7 @@ input[type=submit] { margin-top: 0.5rem; margin-bottom: 0.5rem; } -.mv3, hr { +.mv3, hr, .edit-form .edit-form-row { margin-top: 1rem; margin-bottom: 1rem; } @@ -4987,7 +4996,7 @@ input[type=submit] { padding-right: 0; } .pr1-ns { padding-right: 0.25rem; } - .pr2-ns { + .pr2-ns, .edit-form .edit-form-row > :first-child { padding-right: 0.5rem; } .pr3-ns { padding-right: 1rem; } @@ -4999,7 +5008,7 @@ input[type=submit] { padding-right: 8rem; } .pr7-ns { padding-right: 16rem; } - .pb0-ns { + .pb0-ns, .edit-form .edit-form-row > :first-child { padding-bottom: 0; } .pb1-ns { padding-bottom: 0.25rem; } @@ -6169,7 +6178,7 @@ input[type=submit] { -l = large */ -.tl { +.tl, .edit-form .edit-form-row > :first-child { text-align: left; } .tr { @@ -6184,7 +6193,7 @@ input[type=submit] { @media screen and (min-width: 30em) { .tl-ns { text-align: left; } - .tr-ns { + .tr-ns, .edit-form .edit-form-row > :first-child { text-align: right; } .tc-ns { text-align: center; } @@ -7204,7 +7213,7 @@ body { min-height: 100vh; box-sizing: border-box; font-size: 0.875rem; - line-height: 1.5em; + line-height: 1.2em; font-weight: 400; } a { @@ -7321,10 +7330,10 @@ article code { margin-left: auto; margin-right: auto; } -.flex-shrink-0 { +.flex-shrink-0, .edit-form .edit-form-row > :first-child { flex-shrink: 0; } -.flex-grow-1 { +.flex-grow-1, .edit-form .edit-form-row > :nth-child(2) { flex-grow: 1; } .flex-fair { @@ -7780,32 +7789,20 @@ header { .tab-bar { border-color: #d8d8d8; border-color: var(--tab-border-color); - width: 100%; - border-bottom-width: 1px; - border-bottom-style: solid; - box-sizing: border-box; } + width: 100%; } .tab-bar .tab-button { background-color: #dfdfdf; background-color: var(--tab-button-background); border-color: #d8d8d8; border-color: var(--tab-border-color); - height: 100%; - display: inline-block; - padding: 10px 15px; - line-height: 100%; - cursor: pointer; - border-width: 1px; - border-style: solid; - box-sizing: border-box; } + cursor: pointer; } .tab-bar .tab-button:hover { background-color: #efefef; background-color: var(--tab-button-background-hover); } .tab-bar .tab-button.current { background-color: #fff; background-color: var(--tab-button-background-current); - border-bottom-color: transparent; - font-weight: bold; - height: 105%; } + font-weight: 500; } .pagination .page.current { cursor: default; @@ -8016,81 +8013,12 @@ pre { max-height: calc(100vh - 20rem); overflow: auto; } } -.edit-form .error { - margin-left: 5em; - padding: 10px; - color: red; } +.edit-form .edit-form-row > :first-child { + font-weight: 500; } -.edit-form input[type=text] { - min-width: 20em; } - -.edit-form textarea { - font-size: 13pt; } - -.edit-form .note { - margin-bottom: 5px; - font-style: italic; - font-size: 90%; } - -.edit-form .links { - width: 80%; - min-height: 200px; - height: 15vh; } - -.edit-form .half { - padding: 10px; - text-align: center; } - -.edit-form table { - width: 95%; - margin: auto; - border-collapse: separate; - border-spacing: 0px 10px; } - .edit-form table td { - padding-bottom: 15px; - width: 90%; } - .edit-form table td.half { - width: 50%; } - .edit-form table td table { - width: 100%; } - -.edit-form th { - text-align: right; - font-weight: bold; - padding-right: 10px; - padding-bottom: 15px; - vertical-align: top; - max-width: 5em; } - -.edit-form td table th { - text-align: left; } - -.edit-form .page-options label { - font-weight: bold; - margin-right: 20px; } - -.edit-form.profile-edit .longbio { - width: 100%; - min-height: 400px; - height: 30vh; } - -.edit-form.profile-edit .avatar-preview { - border: 1px solid transparent; - margin: 10px; - margin-bottom: 0px; } - -.edit-form.profile-edit textarea.shortbio, -.edit-form.profile-edit textarea.signature { - min-width: 300px; - width: 50%; - min-height: 100px; - height: 4em; } - -.edit-form.profile-edit .logo-preview { - border-color: #999; - border-color: var(--project-edit-logo-previw-border-color); - width: 200px; - border-width: 1px; } +@media screen and (min-width: 30em) { + .edit-form .edit-form-row .pt-input-ns { + padding-top: 0.3rem; } } .edit-form.project-edit .project_description { width: 100%; @@ -8103,14 +8031,10 @@ pre { width: 50%; } .edit-form.project-edit .quota-bar { - border-color: #999; - border-color: var(--project-edit-quota-bar-border-color); width: 500px; border-width: 1px; margin-bottom: 10px; } .edit-form.project-edit .quota-bar .quota-filled { - background-color: #444; - background-color: var(--project-edit-quota-bar-filled-background); height: 100%; } .episode-list .description p { @@ -8361,6 +8285,7 @@ nav.timecodes { input[type=text], input[type=password], +input[type=email], textarea, select { color: black; @@ -8375,6 +8300,7 @@ select { outline: none; } input[type=text].lite, input[type=password].lite, + input[type=email].lite, textarea.lite, select.lite { background-color: transparent; @@ -8386,6 +8312,8 @@ select { input[type=text].lite:focus, input[type=text].lite:active, input[type=password].lite:focus, input[type=password].lite:active, + input[type=email].lite:focus, + input[type=email].lite:active, textarea.lite:focus, textarea.lite:active, select.lite:focus, @@ -8396,6 +8324,8 @@ select { input[type=text]:active, input[type=text]:focus, input[type=password]:active, input[type=password]:focus, + input[type=email]:active, + input[type=email]:focus, textarea:active, textarea:focus, select:active, @@ -8405,14 +8335,19 @@ select { border-color: #4c9ed9; border-color: var(--form-text-border-color-active); } -input[type=text]:not(.lite), input[type=password]:not(.lite) { - padding: 5px; } +input[type=text]:not(.lite), +input[type=password]:not(.lite), +input[type=email]:not(.lite) { + padding: 0.3rem; } + +textarea { + padding: 0.3rem; } form .note { font-style: italic; } select { - padding: 5px 10px; } + padding: 0.3rem 0.6rem; } option[selected] { font-weight: bold; } diff --git a/public/themes/dark/theme.css b/public/themes/dark/theme.css index 190a1dc..f0c0a78 100644 --- a/public/themes/dark/theme.css +++ b/public/themes/dark/theme.css @@ -237,9 +237,6 @@ will throw an error. --project-card-border-color: #333; --project-user-suggestions-background: #222; --project-user-suggestions-border-color: #444; - --project-edit-logo-previw-border-color: #444; - --project-edit-quota-bar-border-color: #444; - --project-edit-quota-bar-filled-background: #888; --notice-text-color: #eee; --notice-unapproved-color: #7a2020; --notice-hidden-color: #494949; diff --git a/public/themes/light/theme.css b/public/themes/light/theme.css index d506bde..0f76c4a 100644 --- a/public/themes/light/theme.css +++ b/public/themes/light/theme.css @@ -255,9 +255,6 @@ will throw an error. --project-card-border-color: #aaa; --project-user-suggestions-background: #fff; --project-user-suggestions-border-color: #ddd; - --project-edit-logo-previw-border-color: #999; - --project-edit-quota-bar-border-color: #999; - --project-edit-quota-bar-filled-background: #444; --notice-text-color: #fff; --notice-unapproved-color: #b42222; --notice-hidden-color: #b6b6b6; diff --git a/src/admintools/admintools.go b/src/admintools/admintools.go index 952c47d..97c532d 100644 --- a/src/admintools/admintools.go +++ b/src/admintools/admintools.go @@ -51,10 +51,7 @@ func init() { } } - hashedPassword, err := auth.HashPassword(password) - if err != nil { - panic(err) - } + hashedPassword := auth.HashPassword(password) err = auth.UpdatePassword(ctx, conn, canonicalUsername, hashedPassword) if err != nil { diff --git a/src/auth/auth.go b/src/auth/auth.go index ba8f669..1fdee8e 100644 --- a/src/auth/auth.go +++ b/src/auth/auth.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" @@ -150,15 +151,12 @@ func CheckPassword(password string, hashedPassword HashedPassword) (bool, error) } } -func HashPassword(password string) (HashedPassword, error) { +func HashPassword(password string) HashedPassword { // Follows the OWASP recommendations as of March 2021. // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html salt := make([]byte, saltLength) - _, err := io.ReadFull(rand.Reader, salt) - if err != nil { - return HashedPassword{}, oops.New(err, "failed to generate salt") - } + io.ReadFull(rand.Reader, salt) saltEnc := base64.StdEncoding.EncodeToString(salt) cfg := Argon2idConfig{ @@ -176,12 +174,12 @@ func HashPassword(password string) (HashedPassword, error) { AlgoConfig: cfg.String(), Salt: saltEnc, Hash: keyEnc, - }, nil + } } var ErrUserDoesNotExist = errors.New("user does not exist") -func UpdatePassword(ctx context.Context, conn *pgxpool.Pool, username string, hp HashedPassword) error { +func UpdatePassword(ctx context.Context, conn db.ConnOrTx, username string, hp HashedPassword) error { tag, err := conn.Exec(ctx, "UPDATE auth_user SET password = $1 WHERE username = $2", hp.String(), username) if err != nil { return oops.New(err, "failed to update password") diff --git a/src/discord/history.go b/src/discord/history.go index a042f0e..80c820b 100644 --- a/src/discord/history.go +++ b/src/discord/history.go @@ -68,6 +68,7 @@ func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) { WHERE c.last_content IS NULL AND msg.guild_id = $1 + ORDER BY msg.sent_at DESC `, config.Config.Discord.GuildID, ) @@ -186,13 +187,13 @@ func handleHistoryMessage(ctx context.Context, dbConn *pgxpool.Pool, msg *Messag break } - newMsg, err := saveMessageAndContents(ctx, tx, msg) + newMsg, err := SaveMessageAndContents(ctx, tx, msg) if err != nil { return err } if createSnippets { - if doSnippet, err := allowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil { - _, err := createMessageSnippet(ctx, tx, msg) + if doSnippet, err := AllowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil { + _, err := CreateMessageSnippet(ctx, tx, msg.ID) if err != nil { return err } diff --git a/src/discord/rest.go b/src/discord/rest.go index db91554..925383d 100644 --- a/src/discord/rest.go +++ b/src/discord/rest.go @@ -14,6 +14,7 @@ import ( "strings" "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/oops" ) @@ -499,6 +500,16 @@ func GetChannelMessages(ctx context.Context, channelID string, in GetChannelMess return msgs, nil } +func GetAuthorizeUrl(state string) string { + params := make(url.Values) + params.Set("response_type", "code") + params.Set("client_id", config.Config.Discord.OAuthClientID) + params.Set("scope", "identify") + params.Set("state", state) + params.Set("redirect_uri", hmnurl.BuildDiscordOAuthCallback()) + return fmt.Sprintf("https://discord.com/api/oauth2/authorize?%s", params.Encode()) +} + func logErrorResponse(ctx context.Context, name string, res *http.Response, msg string) { dump, err := httputil.DumpResponse(res, true) if err != nil { diff --git a/src/discord/showcase.go b/src/discord/showcase.go index df4ad71..8fdc96c 100644 --- a/src/discord/showcase.go +++ b/src/discord/showcase.go @@ -47,7 +47,7 @@ func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message) er defer tx.Rollback(ctx) // save the message, maybe save its contents, and maybe make a snippet too - newMsg, err := saveMessageAndContents(ctx, tx, msg) + newMsg, err := SaveMessageAndContents(ctx, tx, msg) if errors.Is(err, errNotEnoughInfo) { logging.ExtractLogger(ctx).Warn(). Interface("msg", msg). @@ -56,8 +56,8 @@ func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message) er } else if err != nil { return err } - if doSnippet, err := allowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil { - _, err := createMessageSnippet(ctx, tx, msg) + if doSnippet, err := AllowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil { + _, err := CreateMessageSnippet(ctx, tx, msg.ID) if err != nil { return oops.New(err, "failed to create snippet in gateway") } @@ -120,7 +120,7 @@ the database. This does not create snippets or do anything besides save the message itself. */ -func saveMessage( +func SaveMessage( ctx context.Context, tx db.ConnOrTx, msg *Message, @@ -194,12 +194,12 @@ snippets. Idempotent; can be called any time whether the message exists or not. */ -func saveMessageAndContents( +func SaveMessageAndContents( ctx context.Context, tx db.ConnOrTx, msg *Message, ) (*models.DiscordMessage, error) { - newMsg, err := saveMessage(ctx, tx, msg) + newMsg, err := SaveMessage(ctx, tx, msg) if err != nil { return nil, err } @@ -507,7 +507,11 @@ func saveEmbed( return iDiscordEmbed.(*models.DiscordMessageEmbed), nil } -func allowedToCreateMessageSnippet(ctx context.Context, tx db.ConnOrTx, discordUserId string) (bool, error) { +/* +Checks settings and permissions to decide whether we are allowed to create +snippets for a user. +*/ +func AllowedToCreateMessageSnippet(ctx context.Context, tx db.ConnOrTx, discordUserId string) (bool, error) { canSave, err := db.QueryBool(ctx, tx, ` SELECT u.discord_save_showcase @@ -528,7 +532,16 @@ func allowedToCreateMessageSnippet(ctx context.Context, tx db.ConnOrTx, discordU return canSave, nil } -func createMessageSnippet(ctx context.Context, tx db.ConnOrTx, msg *Message) (*models.Snippet, error) { +/* +Attempts to create a snippet from a Discord message. If a snippet already +exists, it will be returned and no new snippets will be created. + +It uses the content saved in the database to do this. If we do not have +any content saved, nothing will happen. + +Does not check user preferences around snippets. +*/ +func CreateMessageSnippet(ctx context.Context, tx db.ConnOrTx, msgID string) (*models.Snippet, error) { // Check for existing snippet, maybe return it type existingSnippetResult struct { Message models.DiscordMessage `db:"msg"` @@ -547,16 +560,16 @@ func createMessageSnippet(ctx context.Context, tx db.ConnOrTx, msg *Message) (*m WHERE msg.id = $1 `, - msg.ID, + msgID, ) if err != nil { - return nil, oops.New(err, "failed to check for existing snippet") + return nil, oops.New(err, "failed to check for existing snippet for message %s", msgID) } existing := iexisting.(*existingSnippetResult) if existing.Snippet != nil { // A snippet already exists - maybe update its content, then return it - if msg.OriginalHasFields("content") && !existing.Snippet.EditedOnWebsite { + if existing.MessageContent != nil && !existing.Snippet.EditedOnWebsite { contentMarkdown := existing.MessageContent.LastContent contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown) @@ -611,7 +624,7 @@ func createMessageSnippet(ctx context.Context, tx db.ConnOrTx, msg *Message) (*m contentMarkdown, contentHTML, assetId, - msg.ID, + msgID, existing.DiscordUser.HMNUserId, ) if err != nil { @@ -623,7 +636,7 @@ func createMessageSnippet(ctx context.Context, tx db.ConnOrTx, msg *Message) (*m SET snippet_created = TRUE WHERE id = $1 `, - msg.ID, + msgID, ) if err != nil { return nil, oops.New(err, "failed to mark message as having snippet") diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index f71960c..b5b16bc 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -194,10 +194,10 @@ func BuildUserProfile(username string) string { return Url("/m/"+url.PathEscape(username), nil) } -var RegexUserSettings = regexp.MustCompile(`^/_settings$`) +var RegexUserSettings = regexp.MustCompile(`^/settings$`) func BuildUserSettings(section string) string { - return ProjectUrlWithFragment("/_settings", nil, "", section) + return ProjectUrlWithFragment("/settings", nil, "", section) } /* @@ -558,12 +558,6 @@ func BuildLibraryResource(projectSlug string, resourceId int) string { * Discord OAuth */ -var RegexDiscordTest = regexp.MustCompile("^/discord$") - -func BuildDiscordTest() string { - return Url("/discord", nil) -} - var RegexDiscordOAuthCallback = regexp.MustCompile("^/_discord_callback$") func BuildDiscordOAuthCallback() string { @@ -576,6 +570,12 @@ func BuildDiscordUnlink() string { return Url("/_discord_unlink", nil) } +var RegexDiscordShowcaseBacklog = regexp.MustCompile("^/discord_showcase_backlog$") + +func BuildDiscordShowcaseBacklog() string { + return Url("/discord_showcase_backlog", nil) +} + /* * Assets */ diff --git a/src/migration/migrations/2021-08-27T190408Z_NewLinkData.go b/src/migration/migrations/2021-08-27T190408Z_NewLinkData.go new file mode 100644 index 0000000..f35e071 --- /dev/null +++ b/src/migration/migrations/2021-08-27T190408Z_NewLinkData.go @@ -0,0 +1,60 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(NewLinkData{}) +} + +type NewLinkData struct{} + +func (m NewLinkData) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 8, 27, 19, 4, 8, 0, time.UTC)) +} + +func (m NewLinkData) Name() string { + return "NewLinkData" +} + +func (m NewLinkData) Description() string { + return "Rework link data to be less completely weird" +} + +func (m NewLinkData) Up(ctx context.Context, tx pgx.Tx) error { + /* + Broadly the goal is to: + - drop `key` + - make `name` not null + - rename `value` to `url` + */ + + _, err := tx.Exec(ctx, `UPDATE handmade_links SET name = '' WHERE name IS NULL`) + if err != nil { + return oops.New(err, "failed to fill in null names") + } + + _, err = tx.Exec(ctx, ` + ALTER TABLE handmade_links + DROP key, + ALTER name SET NOT NULL; + + ALTER TABLE handmade_links + RENAME value TO url; + `) + if err != nil { + return oops.New(err, "failed to alter links table") + } + + return nil +} + +func (m NewLinkData) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/models/link.go b/src/models/link.go index 319339a..5f9ea61 100644 --- a/src/models/link.go +++ b/src/models/link.go @@ -1,11 +1,10 @@ package models type Link struct { - ID int `db:"id"` - Key string `db:"key"` - Name *string `db:"name"` - Value string `db:"value"` - Ordering int `db:"ordering"` - UserID *int `db:"user_id"` - ProjectID *int `db:"project_id"` + ID int `db:"id"` + Name string `db:"name"` + URL string `db:"url"` + Ordering int `db:"ordering"` + UserID *int `db:"user_id"` + ProjectID *int `db:"project_id"` } diff --git a/src/rawdata/scss/_core.scss b/src/rawdata/scss/_core.scss index ab25006..4e9e095 100644 --- a/src/rawdata/scss/_core.scss +++ b/src/rawdata/scss/_core.scss @@ -1,3 +1,6 @@ +// Global variables +$input-padding: 0.3rem; + .noselect { -webkit-touch-callout: none; /* iOS Safari */ -webkit-user-select: none; /* Chrome/Safari/Opera */ @@ -24,7 +27,7 @@ body { min-height: 100vh; box-sizing: border-box; font-size: px2rem(14px); - line-height: 1.5em; + line-height: 1.2em; font-weight: 400; } @@ -735,24 +738,16 @@ footer { .tab-bar { @include usevar(border-color, tab-border-color); + @extend .flex, .flex-row; width: 100%; - border-bottom-width: 1px; - border-bottom-style: solid; - box-sizing: border-box; .tab-button { @include usevar(background-color, tab-button-background); @include usevar(border-color, tab-border-color); + @extend .ph3, .pv2; - height:100%; - display:inline-block; - padding:10px 15px; - line-height:100%; - cursor:pointer; - border-width: 1px; - border-style: solid; - box-sizing:border-box; + cursor: pointer; // TODO: Should this be a link? &:hover { @include usevar(background-color, tab-button-background-hover); @@ -760,10 +755,7 @@ footer { &.current { @include usevar(background-color, tab-button-background-current); - - border-bottom-color: transparent; - font-weight:bold; - height:105%; + font-weight: 500; } } } diff --git a/src/rawdata/scss/_editor.scss b/src/rawdata/scss/_editor.scss index b6ab5bc..b2e787c 100644 --- a/src/rawdata/scss/_editor.scss +++ b/src/rawdata/scss/_editor.scss @@ -44,103 +44,49 @@ } .edit-form { - .error { - margin-left:5em; - padding:10px; - color:red; + .edit-form-row { + @extend .flex; + @extend .flex-column; + @extend .flex-row-ns; + @extend .mv3; + + > :first-child { + @extend .w-100; + @extend .w4-ns; + @extend .flex-grow-0; + @extend .flex-shrink-0; + @extend .tl; + @extend .tr-ns; + @extend .pr0; + @extend .pr2-ns; + @extend .pb1; + @extend .pb0-ns; + font-weight: 500; + } + + > :nth-child(2) { + @extend .flex-grow-1; + @extend .overflow-hidden; + } + + .pt-input-ns { + // NOTE(ben): This could maybe be more general someday? + @media #{$breakpoint-not-small} { + padding-top: $input-padding; + } + } } input[type=text] { - min-width:20em; + @extend .w-100; + @extend .mw5-ns; } textarea { - font-size:13pt; - } - - .note { - margin-bottom:5px; - font-style:italic; - font-size:90%; - } - - .links { - width: 80%; - min-height: 200px; - height: 15vh; - } - - .half { - padding:10px; - text-align:center; - } - - table { - width:95%; - margin:auto; - border-collapse:separate; - border-spacing: 0px 10px; - - td { - padding-bottom:15px; - width:90%; - - &.half { - width:50%; - } - - table { - width:100%; - } - } - } - - th { - text-align:right; - font-weight:bold; - padding-right:10px; - padding-bottom:15px; - vertical-align:top; - max-width:5em; - } - - td table th { - text-align:left; - } - - .page-options label { - font-weight:bold; - margin-right:20px; - } - - &.profile-edit { - .longbio { - width: 100%; - min-height: 400px; - height: 30vh; - } - - .avatar-preview { - border:1px solid transparent; - margin:10px; - margin-bottom:0px; - } - - textarea.shortbio, - textarea.signature, - { - min-width:300px; - width:50%; - min-height: 100px; - height:4em; - } - - .logo-preview { - @include usevar(border-color, 'project-edit-logo-previw-border-color'); - - width:200px; - border-width: 1px; - } + @extend .w-100; + @extend .w6-ns; + @extend .mw-100; + @extend .h3; } &.project-edit { @@ -153,21 +99,21 @@ input.project_blurb, input.project_name, { - min-width:300px; - width:50%; + min-width: 300px; + width: 50%; } .quota-bar { - @include usevar(border-color, 'project-edit-quota-bar-border-color'); + // @include usevar(border-color, 'project-edit-quota-bar-border-color'); - width:500px; + width: 500px; border-width: 1px; - margin-bottom:10px; + margin-bottom: 10px; .quota-filled { - @include usevar(background-color, 'project-edit-quota-bar-filled-background'); + // @include usevar(background-color, 'project-edit-quota-bar-filled-background'); - height:100%; + height: 100%; } } } diff --git a/src/rawdata/scss/_forms.scss b/src/rawdata/scss/_forms.scss index 906725c..2b794da 100644 --- a/src/rawdata/scss/_forms.scss +++ b/src/rawdata/scss/_forms.scss @@ -68,6 +68,7 @@ input[type=text], input[type=password], +input[type=email], textarea, select, { @@ -102,18 +103,25 @@ select, } } -input[type=text], input[type=password] { +input[type=text], +input[type=password], +input[type=email], +{ &:not(.lite) { - padding:5px; + padding: $input-padding; } } +textarea { + padding: $input-padding; +} + form .note { font-style:italic; } select { - padding: 5px 10px; + padding: $input-padding 2*$input-padding; } option[selected] { diff --git a/src/rawdata/scss/tachyons/scss/_variables.scss b/src/rawdata/scss/tachyons/scss/_variables.scss index d4ee6c5..7ef94a1 100644 --- a/src/rawdata/scss/tachyons/scss/_variables.scss +++ b/src/rawdata/scss/tachyons/scss/_variables.scss @@ -42,6 +42,7 @@ $width-2: 2rem !default; $width-3: 4rem !default; $width-4: 8rem !default; $width-5: 16rem !default; +$width-6: 32rem !default; $max-width-1: 1rem !default; $max-width-2: 2rem !default; $max-width-3: 4rem !default; diff --git a/src/rawdata/scss/tachyons/scss/_widths.scss b/src/rawdata/scss/tachyons/scss/_widths.scss index abc4fc7..2b24968 100644 --- a/src/rawdata/scss/tachyons/scss/_widths.scss +++ b/src/rawdata/scss/tachyons/scss/_widths.scss @@ -54,6 +54,7 @@ .w3 { width: $width-3; } .w4 { width: $width-4; } .w5 { width: $width-5; } +.w6 { width: $width-6; } .w-10 { width: 10%; } .w-20 { width: 20%; } @@ -80,6 +81,7 @@ .w3-ns { width: $width-3; } .w4-ns { width: $width-4; } .w5-ns { width: $width-5; } + .w6-ns { width: $width-6; } .w-10-ns { width: 10%; } .w-20-ns { width: 20%; } .w-25-ns { width: 25%; } @@ -105,6 +107,7 @@ .w3-m { width: $width-3; } .w4-m { width: $width-4; } .w5-m { width: $width-5; } + .w6-m { width: $width-6; } .w-10-m { width: 10%; } .w-20-m { width: 20%; } .w-25-m { width: 25%; } @@ -130,6 +133,7 @@ .w3-l { width: $width-3; } .w4-l { width: $width-4; } .w5-l { width: $width-5; } + .w6-l { width: $width-6; } .w-10-l { width: 10%; } .w-20-l { width: 20%; } .w-25-l { width: 25%; } diff --git a/src/rawdata/scss/themes/dark/_variables.scss b/src/rawdata/scss/themes/dark/_variables.scss index e02f3f8..30a8663 100644 --- a/src/rawdata/scss/themes/dark/_variables.scss +++ b/src/rawdata/scss/themes/dark/_variables.scss @@ -38,9 +38,6 @@ $vars: ( project-card-border-color: #333, project-user-suggestions-background: #222, project-user-suggestions-border-color: #444, - project-edit-logo-previw-border-color: #444, - project-edit-quota-bar-border-color: #444, - project-edit-quota-bar-filled-background: #888, notice-text-color: $fg-font-color, notice-unapproved-color: #7a2020, diff --git a/src/rawdata/scss/themes/light/_variables.scss b/src/rawdata/scss/themes/light/_variables.scss index df360d2..9c35f22 100644 --- a/src/rawdata/scss/themes/light/_variables.scss +++ b/src/rawdata/scss/themes/light/_variables.scss @@ -38,9 +38,6 @@ $vars: ( project-card-border-color: #aaa, project-user-suggestions-background: #fff, project-user-suggestions-border-color: #ddd, - project-edit-logo-previw-border-color: #999, - project-edit-quota-bar-border-color: #999, - project-edit-quota-bar-filled-background: #444, notice-text-color: #fff, notice-unapproved-color: #b42222, diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 0d795ce..de1633c 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -147,6 +147,7 @@ func UserToTemplate(u *models.User, currentTheme string) User { IsStaff: u.IsStaff, Name: u.BestName(), + Bio: u.Bio, Blurb: u.Blurb, Signature: u.Signature, DateJoined: u.DateJoined, @@ -162,60 +163,85 @@ func UserToTemplate(u *models.User, currentTheme string) User { } } -var RegexServiceYoutube = regexp.MustCompile(`youtube\.com/(c/)?(?P[\w/-]+)$`) -var RegexServiceTwitter = regexp.MustCompile(`twitter\.com/(?P\w+)$`) -var RegexServiceGithub = regexp.MustCompile(`github\.com/(?P[\w/-]+)$`) -var RegexServiceTwitch = regexp.MustCompile(`twitch\.tv/(?P[\w/-]+)$`) -var RegexServiceHitbox = regexp.MustCompile(`hitbox\.tv/(?P[\w/-]+)$`) -var RegexServicePatreon = regexp.MustCompile(`patreon\.com/(?P[\w/-]+)$`) -var RegexServiceSoundcloud = regexp.MustCompile(`soundcloud\.com/(?P[\w/-]+)$`) -var RegexServiceItch = regexp.MustCompile(`(?P[\w/-]+)\.itch\.io/?$`) - -var LinkServiceMap = map[string]*regexp.Regexp{ - "youtube": RegexServiceYoutube, - "twitter": RegexServiceTwitter, - "github": RegexServiceGithub, - "twitch": RegexServiceTwitch, - "hitbox": RegexServiceHitbox, - "patreon": RegexServicePatreon, - "soundcloud": RegexServiceSoundcloud, - "itch": RegexServiceItch, +// An online site/service for which we recognize the link +type LinkService struct { + Name string + IconName string + Regex *regexp.Regexp } -func ParseKnownServicesForLink(link *models.Link) (serviceName string, userData string) { - for name, re := range LinkServiceMap { - match := re.FindStringSubmatch(link.Value) +var LinkServices = []LinkService{ + { + Name: "YouTube", + IconName: "youtube", + Regex: regexp.MustCompile(`youtube\.com/(c/)?(?P[\w/-]+)$`), + }, + { + Name: "Twitter", + IconName: "twitter", + Regex: regexp.MustCompile(`twitter\.com/(?P\w+)$`), + }, + { + Name: "GitHub", + IconName: "github", + Regex: regexp.MustCompile(`github\.com/(?P[\w/-]+)$`), + }, + { + Name: "Twitch", + IconName: "twitch", + Regex: regexp.MustCompile(`twitch\.tv/(?P[\w/-]+)$`), + }, + { + Name: "Hitbox", + IconName: "hitbox", + Regex: regexp.MustCompile(`hitbox\.tv/(?P[\w/-]+)$`), + }, + { + Name: "Patreon", + IconName: "patreon", + Regex: regexp.MustCompile(`patreon\.com/(?P[\w/-]+)$`), + }, + { + Name: "SoundCloud", + IconName: "soundcloud", + Regex: regexp.MustCompile(`soundcloud\.com/(?P[\w/-]+)$`), + }, + { + Name: "itch.io", + IconName: "itch", + Regex: regexp.MustCompile(`(?P[\w/-]+)\.itch\.io/?$`), + }, +} + +func ParseKnownServicesForLink(link *models.Link) (service LinkService, userData string) { + for _, svc := range LinkServices { + match := svc.Regex.FindStringSubmatch(link.URL) if match != nil { - serviceName = name - userData = match[re.SubexpIndex("userdata")] - return + return svc, match[svc.Regex.SubexpIndex("userdata")] } } - return "", "" + return LinkService{}, "" } func LinkToTemplate(link *models.Link) Link { - name := "" - /* - // NOTE(asaf): While Name and Key are separate things, Name is almost always the same as Key in the db, which looks weird. - // So we're just going to ignore Name until we decide it's worth reusing. - if link.Name != nil { - name = *link.Name - } - */ - serviceName, serviceUserData := ParseKnownServicesForLink(link) - if serviceUserData != "" { - name = serviceUserData + tlink := Link{ + Name: link.Name, + Url: link.URL, + LinkText: link.URL, } - if name == "" { - name = link.Value + + service, userData := ParseKnownServicesForLink(link) + if tlink.Name == "" && service.Name != "" { + tlink.Name = service.Name } - return Link{ - Key: link.Key, - Name: name, - Icon: serviceName, - Url: link.Value, + if service.IconName != "" { + tlink.Icon = service.IconName } + if userData != "" { + tlink.LinkText = userData + } + + return tlink } func TimelineItemsToJSON(items []TimelineItem) string { diff --git a/src/templates/src/project_homepage.html b/src/templates/src/project_homepage.html index 0503db6..eb3a9aa 100644 --- a/src/templates/src/project_homepage.html +++ b/src/templates/src/project_homepage.html @@ -63,8 +63,8 @@ {{ range .ProjectLinks }}
-
{{ .Key }}
- +
{{ .Name }}
+
{{ end }} diff --git a/src/templates/src/user_profile.html b/src/templates/src/user_profile.html index f37dfa8..d23793a 100644 --- a/src/templates/src/user_profile.html +++ b/src/templates/src/user_profile.html @@ -67,8 +67,8 @@ {{ range .ProfileUserLinks }}
-
{{ .Key }}
- +
{{ .Name }}
+
{{ end }} diff --git a/src/templates/src/user_settings.html b/src/templates/src/user_settings.html new file mode 100644 index 0000000..3ddaaae --- /dev/null +++ b/src/templates/src/user_settings.html @@ -0,0 +1,201 @@ +{{ template "base.html" . }} + +{{ define "extrahead" }} + +{{ end }} + +{{ define "content" }} +
+ {{ csrftoken .Session }} +
+
+
Username:
+
+
{{ .User.Username }}
+
If you would like to change your username, please contact us.
+
+
+
+
Real name:
+
+ +
(optional)
+
+
+
+
Email:
+
+ +
+ + +
+
+
+
+
Theme:
+
+ + +
+
+
+
Avatar:
+
+ +
+
+
(Allowed image types: PNG, JPEG and GIF. Avatars may weigh up to 1MB and will be resized if larger than 400x400 pixels)
+
+
+
+
Short bio:
+
+ +
+
+
+
Forum signature:
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
Old password:
+
+ +
+
+
+
New password:
+
+ +
+ Your password must be 8 or more characters, and must differ from your username and current password. + Other than that, please follow best practices. +
+
+
+
+
New password confirmation:
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
Links:
+
+ +
+
Relevant links to put on your profile.
+
Format: url [Title] (e.g. http://example.com/ Example Site)
+
(1 per line, 10 max)
+
+
+
+
+
Description:
+
+ +
+
Include some information about yourself, such as your background, interests, occupation, etc.
+
+
+
+
+
+
+ +
+
+
+ +
+
+ {{ if .DiscordUser }} + Linked account: + {{ .DiscordUser.Username }}#{{ .DiscordUser.Discriminator }} + + Unlink account + + {{ else }} + You haven't linked your Discord account. + Link account + {{ end }} +
+ +
+ + +
Snippets will only be created while this setting is on.
+
+ +
+ + +
+ + {{ if .DiscordUser }} +
+ + Create snippets from all of my #project-showcase posts + +
+ Use this if you have a backlog of content in #project-showcase that you want on your profile. +
+ {{ if gt .DiscordNumUnsavedMessages 0 }} +
+ WARNING: {{ .DiscordNumUnsavedMessages }} of your messages are currently waiting to be processed. If you run this command now, some snippets may still be missing. +
+ {{ end }} +
+ {{ end }} + + +
+
+ + + +
+ {{ csrftoken .Session }} + +
+{{ end }} \ No newline at end of file diff --git a/src/templates/types.go b/src/templates/types.go index 3759720..e86e7d7 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -137,6 +137,7 @@ type User struct { ProfileUrl string DarkTheme bool + ShowEmail bool Timezone string CanEditLibrary bool @@ -145,10 +146,10 @@ type User struct { } type Link struct { - Key string - Name string - Url string - Icon string + Name string + Url string + LinkText string + Icon string } type Podcast struct { diff --git a/src/website/auth.go b/src/website/auth.go index 8eb2ad9..ce32597 100644 --- a/src/website/auth.go +++ b/src/website/auth.go @@ -210,10 +210,7 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData { return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther) } - hashed, err := auth.HashPassword(password) - if err != nil { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password")) - } + hashed := auth.HashPassword(password) c.Perf.StartBlock("SQL", "Create user and one time token") tx, err := c.Conn.Begin(c.Context()) @@ -622,10 +619,7 @@ func DoPasswordResetSubmit(c *RequestContext) ResponseData { return RejectRequest(c, "Password confirmation doesn't match password") } - hashed, err := auth.HashPassword(password) - if err != nil { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password")) - } + hashed := auth.HashPassword(password) c.Perf.StartBlock("SQL", "Update user's password and delete reset token") tx, err := c.Conn.Begin(c.Context()) @@ -707,14 +701,10 @@ func tryLogin(c *RequestContext, user *models.User, password string) (bool, erro // re-hash and save the user's password if necessary if hashed.IsOutdated() { - newHashed, err := auth.HashPassword(password) - if err == nil { - err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed) - if err != nil { - c.Logger.Error().Err(err).Msg("failed to update user's password") - } - } else { - c.Logger.Error().Err(err).Msg("failed to re-hash password") + newHashed := auth.HashPassword(password) + err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed) + if err != nil { + c.Logger.Error().Err(err).Msg("failed to update user's password") } // If errors happen here, we can still continue with logging them in } diff --git a/src/website/discord.go b/src/website/discord.go index 57611fb..2504084 100644 --- a/src/website/discord.go +++ b/src/website/discord.go @@ -2,9 +2,7 @@ package website import ( "errors" - "fmt" "net/http" - "net/url" "time" "git.handmade.network/hmn/hmn/src/auth" @@ -14,62 +12,8 @@ import ( "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" - "git.handmade.network/hmn/hmn/src/templates" ) -func DiscordTest(c *RequestContext) ResponseData { - var userDiscord *models.DiscordUser - iUserDiscord, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{}, - ` - SELECT $columns - FROM handmade_discorduser - WHERE hmn_user_id = $1 - `, - c.CurrentUser.ID, - ) - if err != nil { - if errors.Is(err, db.ErrNoMatchingRows) { - // we're ok, just no user - } else { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current user's Discord account")) - } - } else { - userDiscord = iUserDiscord.(*models.DiscordUser) - } - - type templateData struct { - templates.BaseData - DiscordUser *templates.DiscordUser - AuthorizeURL string - UnlinkURL string - } - - baseData := getBaseData(c) - baseData.Title = "Discord Test" - - params := make(url.Values) - params.Set("response_type", "code") - params.Set("client_id", config.Config.Discord.OAuthClientID) - params.Set("scope", "identify") - params.Set("state", c.CurrentSession.CSRFToken) - params.Set("redirect_uri", hmnurl.BuildDiscordOAuthCallback()) - - td := templateData{ - BaseData: baseData, - AuthorizeURL: fmt.Sprintf("https://discord.com/api/oauth2/authorize?%s", params.Encode()), - UnlinkURL: hmnurl.BuildDiscordUnlink(), - } - - if userDiscord != nil { - u := templates.DiscordUserToTemplate(userDiscord) - td.DiscordUser = &u - } - - var res ResponseData - res.MustWriteTemplate("discordtest.html", td, c.Perf) - return res -} - func DiscordOAuthCallback(c *RequestContext) ResponseData { query := c.Req.URL.Query() @@ -95,14 +39,19 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData { } // Check for error values and redirect back to ???? - if query.Get("error") != "" { + if errCode := query.Get("error"); errCode != "" { // TODO: actually handle these errors - return ErrorResponse(http.StatusBadRequest, errors.New(query.Get("error"))) + if errCode == "access_denied" { + // This occurs when the user cancels. Just go back to the profile page. + return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther) + } else { + return RejectRequest(c, "Failed to authenticate with Discord.") + } } // Do the actual token exchange and redirect back to ???? code := query.Get("code") - res, err := discord.ExchangeOAuthCode(c.Context(), code, hmnurl.BuildDiscordOAuthCallback()) // TODO: Redirect to the right place + res, err := discord.ExchangeOAuthCode(c.Context(), code, hmnurl.BuildDiscordOAuthCallback()) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to exchange Discord authorization code")) } @@ -139,7 +88,7 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save new Discord user info")) } - return c.Redirect(hmnurl.BuildDiscordTest(), http.StatusSeeOther) + return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther) } func DiscordUnlink(c *RequestContext) ResponseData { @@ -159,7 +108,7 @@ func DiscordUnlink(c *RequestContext) ResponseData { ) if err != nil { if errors.Is(err, db.ErrNoMatchingRows) { - return c.Redirect(hmnurl.BuildDiscordTest(), http.StatusSeeOther) + return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther) } else { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get Discord user for unlink")) } @@ -187,5 +136,59 @@ func DiscordUnlink(c *RequestContext) ResponseData { c.Logger.Warn().Err(err).Msg("failed to remove member role on unlink") } - return c.Redirect(hmnurl.BuildDiscordTest(), http.StatusSeeOther) + return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther) +} + +func DiscordShowcaseBacklog(c *RequestContext) ResponseData { + iduser, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{}, + `SELECT $columns FROM handmade_discorduser WHERE hmn_user_id = $1`, + c.CurrentUser.ID, + ) + if errors.Is(err, db.ErrNoMatchingRows) { + // Nothing to do + c.Logger.Warn().Msg("could not do showcase backlog because no discord user exists") + return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther) + } else if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get discord user")) + } + duser := iduser.(*models.DiscordUser) + + ok, err := discord.AllowedToCreateMessageSnippet(c.Context(), c.Conn, duser.UserID) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, err) + } + + if !ok { + // Not allowed to do this, bail out + c.Logger.Warn().Msg("was not allowed to save user snippets") + return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther) + } + + type messageIdQuery struct { + MessageID string `db:"msg.id"` + } + imsgIds, err := db.Query(c.Context(), c.Conn, messageIdQuery{}, + ` + SELECT $columns + FROM + handmade_discordmessage AS msg + WHERE + msg.user_id = $1 + AND msg.channel_id = $2 + `, + duser.UserID, + config.Config.Discord.ShowcaseChannelID, + ) + msgIds := imsgIds.ToSlice() + + for _, imsgId := range msgIds { + msgId := imsgId.(*messageIdQuery) + _, err := discord.CreateMessageSnippet(c.Context(), c.Conn, msgId.MessageID) + if err != nil { + c.Logger.Warn().Err(err).Msg("failed to create snippet from showcase backlog") + continue + } + } + + return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther) } diff --git a/src/website/imagefile_helper.go b/src/website/imagefile_helper.go new file mode 100644 index 0000000..44b9bd6 --- /dev/null +++ b/src/website/imagefile_helper.go @@ -0,0 +1,93 @@ +package website + +import ( + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "image" + "io" + "net/http" + "os" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/oops" +) + +// If a helper method returns this, you should call RejectRequest with the value. +type RejectRequestError error + +/* +Reads an image file from form data and saves it to the filesystem and the database. +If the file doesn't exist, this does nothing. + +NOTE(ben): Someday we should replace this with the asset system. +*/ +func SaveImageFile(c *RequestContext, dbConn db.ConnOrTx, fileFieldName string, maxSize int64, filepath string) (imageFileId int, err error) { + img, header, err := c.Req.FormFile(fileFieldName) + filename := "" + width := 0 + height := 0 + if err != nil && !errors.Is(err, http.ErrMissingFile) { + return 0, oops.New(err, "failed to read uploaded file") + } + + if header != nil { + if header.Size > maxSize { + return 0, RejectRequestError(fmt.Errorf("Image filesize too big. Max size: %d bytes", maxSize)) + } else { + c.Perf.StartBlock("IMAGE", "Decoding image") + config, format, err := image.DecodeConfig(img) + c.Perf.EndBlock() + if err != nil { + return 0, RejectRequestError(errors.New("Image type not supported")) + } + width = config.Width + height = config.Height + if width == 0 || height == 0 { + return 0, RejectRequestError(errors.New("Image has zero size")) + } + + filename = fmt.Sprintf("%s.%s", filepath, format) + storageFilename := fmt.Sprintf("public/media/%s", filename) + c.Perf.StartBlock("IMAGE", "Writing image file") + file, err := os.Create(storageFilename) + if err != nil { + return 0, oops.New(err, "Failed to create local image file") + } + img.Seek(0, io.SeekStart) + _, err = io.Copy(file, img) + if err != nil { + return 0, oops.New(err, "Failed to write image to file") + } + file.Close() + img.Close() + c.Perf.EndBlock() + } + } + c.Perf.EndBlock() + + c.Perf.StartBlock("SQL", "Saving image file") + if filename != "" { + hasher := sha1.New() + img.Seek(0, io.SeekStart) + io.Copy(hasher, img) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs + sha1sum := hasher.Sum(nil) + var imageId int + err = dbConn.QueryRow(c.Context(), + ` + INSERT INTO handmade_imagefile (file, size, sha1sum, protected, width, height) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, + filename, header.Size, hex.EncodeToString(sha1sum), false, width, height, + ).Scan(&imageId) + if err != nil { + return 0, oops.New(err, "Failed to insert image file row") + } + + return imageId, nil + } + + return 0, nil +} diff --git a/src/website/podcast.go b/src/website/podcast.go index 7af26f7..536f3f2 100644 --- a/src/website/podcast.go +++ b/src/website/podcast.go @@ -1,11 +1,8 @@ package website import ( - "crypto/sha1" - "encoding/hex" "errors" "fmt" - "image" "io" "io/fs" "net/http" @@ -142,47 +139,6 @@ func PodcastEditSubmit(c *RequestContext) ResponseData { if len(strings.TrimSpace(description)) == 0 { return RejectRequest(c, "Podcast description is empty") } - podcastImage, header, err := c.Req.FormFile("podcast_image") - imageFilename := "" - imageWidth := 0 - imageHeight := 0 - if err != nil && !errors.Is(err, http.ErrMissingFile) { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read uploaded file")) - } - if header != nil { - if header.Size > maxFileSize { - return RejectRequest(c, fmt.Sprintf("Image filesize too big. Max size: %d bytes", maxFileSize)) - } else { - c.Perf.StartBlock("PODCAST", "Decoding image") - config, format, err := image.DecodeConfig(podcastImage) - c.Perf.EndBlock() - if err != nil { - return RejectRequest(c, "Image type not supported") - } - imageWidth = config.Width - imageHeight = config.Height - if imageWidth == 0 || imageHeight == 0 { - return RejectRequest(c, "Image has zero size") - } - - imageFilename = fmt.Sprintf("podcast/%s/logo%d.%s", c.CurrentProject.Slug, time.Now().UTC().Unix(), format) - storageFilename := fmt.Sprintf("public/media/%s", imageFilename) - c.Perf.StartBlock("PODCAST", "Writing image file") - file, err := os.Create(storageFilename) - if err != nil { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to create local image file")) - } - podcastImage.Seek(0, io.SeekStart) - _, err = io.Copy(file, podcastImage) - if err != nil { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to write image to file")) - } - file.Close() - podcastImage.Close() - c.Perf.EndBlock() - } - } - c.Perf.EndBlock() c.Perf.StartBlock("SQL", "Updating podcast") tx, err := c.Conn.Begin(c.Context()) @@ -190,23 +146,18 @@ func PodcastEditSubmit(c *RequestContext) ResponseData { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction")) } defer tx.Rollback(c.Context()) - if imageFilename != "" { - hasher := sha1.New() - podcastImage.Seek(0, io.SeekStart) - io.Copy(hasher, podcastImage) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs - sha1sum := hasher.Sum(nil) - var imageId int - err = tx.QueryRow(c.Context(), - ` - INSERT INTO handmade_imagefile (file, size, sha1sum, protected, width, height) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id - `, - imageFilename, header.Size, hex.EncodeToString(sha1sum), false, imageWidth, imageHeight, - ).Scan(&imageId) - if err != nil { - return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert image file row")) + + imageId, err := SaveImageFile(c, tx, "podcast_image", maxFileSize, fmt.Sprintf("podcast/%s/logo%d", c.CurrentProject.Slug, time.Now().UTC().Unix())) + if err != nil { + var rejectErr RejectRequestError + if errors.As(err, &rejectErr) { + return RejectRequest(c, rejectErr.Error()) + } else { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to save podcast image")) } + } + + if imageId != 0 { _, err = tx.Exec(c.Context(), ` UPDATE handmade_podcast diff --git a/src/website/routes.go b/src/website/routes.go index 1f4d385..efeff59 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -104,7 +104,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe // CSRF mitigation actions per the OWASP cheat sheet: // https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html return func(c *RequestContext) ResponseData { - c.Req.ParseForm() + c.Req.ParseMultipartForm(100 * 1024 * 1024) csrfToken := c.Req.Form.Get(auth.CSRFFieldName) if csrfToken != c.CurrentSession.CSRFToken { c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed CSRF validation - potential attack?") @@ -228,9 +228,12 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe mainRoutes.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode) mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS) - mainRoutes.GET(hmnurl.RegexDiscordTest, authMiddleware(DiscordTest)) // TODO: Delete this route mainRoutes.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback)) - mainRoutes.POST(hmnurl.RegexDiscordUnlink, authMiddleware(DiscordUnlink)) + mainRoutes.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink))) + mainRoutes.POST(hmnurl.RegexDiscordShowcaseBacklog, authMiddleware(csrfMiddleware(DiscordShowcaseBacklog))) + + mainRoutes.GET(hmnurl.RegexUserSettings, authMiddleware(UserSettings)) + mainRoutes.POST(hmnurl.RegexUserSettings, authMiddleware(csrfMiddleware(UserSettingsSave))) mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS) mainRoutes.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData { diff --git a/src/website/user.go b/src/website/user.go index b6e7111..dc35a39 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -2,14 +2,21 @@ package website import ( "errors" + "fmt" "net/http" "sort" "strings" + "git.handmade.network/hmn/hmn/src/auth" + "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/discord" + hmnemail "git.handmade.network/hmn/hmn/src/email" + "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/templates" + "github.com/jackc/pgx/v4" ) type UserProfileTemplateData struct { @@ -233,3 +240,270 @@ func UserProfile(c *RequestContext) ResponseData { }, c.Perf) return res } + +func UserSettings(c *RequestContext) ResponseData { + var res ResponseData + + type UserSettingsTemplateData struct { + templates.BaseData + + User templates.User + Email string // these fields are handled specially on templates.User + ShowEmail bool + LinksText string + + SubmitUrl string + ContactUrl string + + DiscordUser *templates.DiscordUser + DiscordNumUnsavedMessages int + DiscordAuthorizeUrl string + DiscordUnlinkUrl string + DiscordShowcaseBacklogUrl string + } + + ilinks, err := db.Query(c.Context(), c.Conn, models.Link{}, + ` + SELECT $columns + FROM handmade_links + WHERE user_id = $1 + ORDER BY ordering + `, + c.CurrentUser.ID, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user links")) + } + links := ilinks.ToSlice() + + linksText := "" + for _, ilink := range links { + link := ilink.(*models.Link) + linksText += fmt.Sprintf("%s %s\n", link.URL, link.Name) + } + + var tduser *templates.DiscordUser + var numUnsavedMessages int + iduser, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{}, + ` + SELECT $columns + FROM handmade_discorduser + WHERE hmn_user_id = $1 + `, + c.CurrentUser.ID, + ) + if errors.Is(err, db.ErrNoMatchingRows) { + // this is fine, but don't fetch any more messages + } else if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user's Discord account")) + } else { + duser := iduser.(*models.DiscordUser) + tmp := templates.DiscordUserToTemplate(duser) + tduser = &tmp + + numUnsavedMessages, err = db.QueryInt(c.Context(), c.Conn, + ` + SELECT COUNT(*) + FROM + handmade_discordmessage AS msg + LEFT JOIN handmade_discordmessagecontent AS c ON c.message_id = msg.id + WHERE + msg.user_id = $1 + AND msg.channel_id = $2 + AND c.last_content IS NULL + `, + duser.UserID, + config.Config.Discord.ShowcaseChannelID, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check for unsaved user messages")) + } + } + + templateUser := templates.UserToTemplate(c.CurrentUser, c.Theme) + + baseData := getBaseData(c) + baseData.Title = templateUser.Name + + res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{ + BaseData: baseData, + User: templateUser, + Email: c.CurrentUser.Email, + ShowEmail: c.CurrentUser.ShowEmail, + LinksText: linksText, + + SubmitUrl: hmnurl.BuildUserSettings(""), + ContactUrl: hmnurl.BuildContactPage(), + + DiscordUser: tduser, + DiscordNumUnsavedMessages: numUnsavedMessages, + DiscordAuthorizeUrl: discord.GetAuthorizeUrl(c.CurrentSession.CSRFToken), + DiscordUnlinkUrl: hmnurl.BuildDiscordUnlink(), + DiscordShowcaseBacklogUrl: hmnurl.BuildDiscordShowcaseBacklog(), + }, c.Perf) + return res +} + +func UserSettingsSave(c *RequestContext) ResponseData { + tx, err := c.Conn.Begin(c.Context()) + if err != nil { + panic(err) + } + defer tx.Rollback(c.Context()) + + form, err := c.GetFormValues() + if err != nil { + c.Logger.Warn().Err(err).Msg("failed to parse form on user update") + return c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther) + } + + name := form.Get("realname") + + email := form.Get("email") + if !hmnemail.IsEmail(email) { + return RejectRequest(c, "Your email was not valid.") + } + + showEmail := form.Get("showemail") != "" + darkTheme := form.Get("darktheme") != "" + + blurb := form.Get("shortbio") + signature := form.Get("signature") + bio := form.Get("longbio") + + discordShowcaseAuto := form.Get("discord-showcase-auto") != "" + discordDeleteSnippetOnMessageDelete := form.Get("discord-snippet-keep") == "" + + _, err = tx.Exec(c.Context(), + ` + UPDATE auth_user + SET + name = $2, + email = $3, + showemail = $4, + darktheme = $5, + blurb = $6, + signature = $7, + bio = $8, + discord_save_showcase = $9, + discord_delete_snippet_on_message_delete = $10 + WHERE + id = $1 + `, + c.CurrentUser.ID, + name, + email, + showEmail, + darkTheme, + blurb, + signature, + bio, + discordShowcaseAuto, + discordDeleteSnippetOnMessageDelete, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user")) + } + + // Process links + linksText := form.Get("links") + links := strings.Split(linksText, "\n") + _, err = tx.Exec(c.Context(), `DELETE FROM handmade_links WHERE user_id = $1`, c.CurrentUser.ID) + if err != nil { + c.Logger.Warn().Err(err).Msg("failed to delete old links") + } else { + for i, link := range links { + link = strings.TrimSpace(link) + linkParts := strings.SplitN(link, " ", 2) + url := strings.TrimSpace(linkParts[0]) + name := "" + if len(linkParts) > 1 { + name = strings.TrimSpace(linkParts[1]) + } + + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + continue + } + + _, err := tx.Exec(c.Context(), + ` + INSERT INTO handmade_links (name, url, ordering, user_id) + VALUES ($1, $2, $3, $4) + `, + name, + url, + i, + c.CurrentUser.ID, + ) + if err != nil { + c.Logger.Warn().Err(err).Msg("failed to insert new link") + continue + } + } + } + + // Update password + oldPassword := form.Get("old_password") + newPassword := form.Get("new_password1") + newPasswordConfirmation := form.Get("new_password2") + if oldPassword != "" && newPassword != "" { + errorRes := updatePassword(c, tx, oldPassword, newPassword, newPasswordConfirmation) + if errorRes != nil { + return *errorRes + } + } + + // Update avatar + _, err = SaveImageFile(c, tx, "avatar", 1*1024*1024, fmt.Sprintf("members/avatars/%s", c.CurrentUser.Username)) + if err != nil { + var rejectErr RejectRequestError + if errors.As(err, &rejectErr) { + return RejectRequest(c, rejectErr.Error()) + } else { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save new avatar")) + } + } + + // TODO: Success message + + err = tx.Commit(c.Context()) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save user settings")) + } + + return c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther) +} + +// TODO: Rework this to use that RejectRequestError thing +func updatePassword(c *RequestContext, tx pgx.Tx, old, new, confirm string) *ResponseData { + if new != confirm { + res := RejectRequest(c, "Your password and password confirmation did not match.") + return &res + } + + oldHashedPassword, err := auth.ParsePasswordString(c.CurrentUser.Password) + if err != nil { + c.Logger.Warn().Err(err).Msg("failed to parse user's password string") + return nil + } + + ok, err := auth.CheckPassword(old, oldHashedPassword) + if err != nil { + res := ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check user's password")) + return &res + } + + if !ok { + res := RejectRequest(c, "The old password you provided was not correct.") + return &res + } + + newHashedPassword := auth.HashPassword(new) + err = auth.UpdatePassword(c.Context(), tx, c.CurrentUser.Username, newHashedPassword) + if err != nil { + res := ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update password")) + return &res + } + + return nil +}