Add user edit form

Add most of the user settings backend

still need to do discord lol

Add the Discord settings

Add avatar uploads
This commit is contained in:
Ben Visness 2021-08-27 12:58:52 -05:00
parent 16ae2188d1
commit 67b86720a9
30 changed files with 1074 additions and 484 deletions

95
public/js/tabs.js Normal file
View File

@ -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;
}

View File

@ -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; }

View File

@ -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;

View File

@ -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;

View File

@ -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 {

View File

@ -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")

View File

@ -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
}

View File

@ -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 {

View File

@ -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")

View File

@ -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
*/

View File

@ -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")
}

View File

@ -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"`
}

View File

@ -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;
}
}
}

View File

@ -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%;
}
}
}

View File

@ -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] {

View File

@ -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;

View File

@ -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%; }

View File

@ -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,

View File

@ -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,

View File

@ -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<userdata>[\w/-]+)$`)
var RegexServiceTwitter = regexp.MustCompile(`twitter\.com/(?P<userdata>\w+)$`)
var RegexServiceGithub = regexp.MustCompile(`github\.com/(?P<userdata>[\w/-]+)$`)
var RegexServiceTwitch = regexp.MustCompile(`twitch\.tv/(?P<userdata>[\w/-]+)$`)
var RegexServiceHitbox = regexp.MustCompile(`hitbox\.tv/(?P<userdata>[\w/-]+)$`)
var RegexServicePatreon = regexp.MustCompile(`patreon\.com/(?P<userdata>[\w/-]+)$`)
var RegexServiceSoundcloud = regexp.MustCompile(`soundcloud\.com/(?P<userdata>[\w/-]+)$`)
var RegexServiceItch = regexp.MustCompile(`(?P<userdata>[\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<userdata>[\w/-]+)$`),
},
{
Name: "Twitter",
IconName: "twitter",
Regex: regexp.MustCompile(`twitter\.com/(?P<userdata>\w+)$`),
},
{
Name: "GitHub",
IconName: "github",
Regex: regexp.MustCompile(`github\.com/(?P<userdata>[\w/-]+)$`),
},
{
Name: "Twitch",
IconName: "twitch",
Regex: regexp.MustCompile(`twitch\.tv/(?P<userdata>[\w/-]+)$`),
},
{
Name: "Hitbox",
IconName: "hitbox",
Regex: regexp.MustCompile(`hitbox\.tv/(?P<userdata>[\w/-]+)$`),
},
{
Name: "Patreon",
IconName: "patreon",
Regex: regexp.MustCompile(`patreon\.com/(?P<userdata>[\w/-]+)$`),
},
{
Name: "SoundCloud",
IconName: "soundcloud",
Regex: regexp.MustCompile(`soundcloud\.com/(?P<userdata>[\w/-]+)$`),
},
{
Name: "itch.io",
IconName: "itch",
Regex: regexp.MustCompile(`(?P<userdata>[\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 {

View File

@ -63,8 +63,8 @@
</div>
{{ range .ProjectLinks }}
<div class="pair flex flex-wrap">
<div class="key flex-auto mr1">{{ .Key }}</div>
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span>{{ .Name }}</a></div>
<div class="key flex-auto mr1">{{ .Name }}</div>
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span> {{ .LinkText }}</a></div>
</div>
{{ end }}
</div>

View File

@ -67,8 +67,8 @@
{{ range .ProfileUserLinks }}
<div class="pair flex flex-wrap">
<div class="key flex-auto mr1">{{ .Key }}</div>
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span>{{ .Name }}</a></div>
<div class="key flex-auto mr1">{{ .Name }}</div>
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span> {{ .LinkText }}</a></div>
</div>
{{ end }}
</div>

View File

@ -0,0 +1,201 @@
{{ template "base.html" . }}
{{ define "extrahead" }}
<script src="{{ static "js/tabs.js" }}"></script>
{{ end }}
{{ define "content" }}
<form class="tabbed edit-form" action="{{ .SubmitUrl }}" method="post" enctype="multipart/form-data">
{{ csrftoken .Session }}
<div class="tab" data-name="Account" data-slug="account">
<div class="edit-form-row">
<div>Username:</div>
<div>
<div>{{ .User.Username }}</div>
<div class="c--dim f7">If you would like to change your username, please <a href="{{ .ContactUrl }}">contact us</a>.</div>
</div>
</div>
<div class="edit-form-row">
<div class="pt-input-ns">Real name:</div>
<div>
<input type="text" name="realname" maxlength="255" class="textbox realname" value="{{ .User.Name }}">
<div class="c--dim f7">(optional)</div>
</div>
</div>
<div class="edit-form-row">
<div class="pt-input-ns">Email:</div>
<div>
<input type="email" name="email" maxlength="254" class="textbox email" value="{{ .Email }}" />
<div class="mt1">
<input type="checkbox" name="showemail" id="email" {{ if .ShowEmail }}checked{{ end }} />
<label for="email">Show on your profile</label>
</div>
</div>
</div>
<div class="edit-form-row">
<div>Theme:</div>
<div>
<input type="checkbox" name="darktheme" id="darktheme" {{ if .User.DarkTheme }}checked{{ end }} />
<label for="darktheme">Use dark theme</label>
</div>
</div>
<div class="edit-form-row">
<div>Avatar:</div>
<div>
<input type="file" name="avatar" id="avatar">
<div class="avatar-preview"><img src="{{ .User.AvatarUrl }}" width="200px">
</div>
<div class="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>
<div class="edit-form-row">
<div class="pt-input-ns">Short bio:</div>
<div>
<textarea class="shortbio" maxlength="140" data-max-chars="140" name="shortbio">
{{- .User.Blurb -}}
</textarea>
</div>
</div>
<div class="edit-form-row">
<div class="pt-input-ns">Forum signature:</div>
<div>
<textarea class="signature" maxlength="255" data-max-chars="255" name="signature">
{{- .User.Signature -}}
</textarea>
</div>
</div>
<div class="edit-form-row">
<div></div>
<div>
<input type="submit" value="Save profile" />
</div>
</div>
</div>
<div class="tab" data-name="Password" data-slug="password">
<div class="edit-form-row">
<div class="pt-input-ns">Old password:</div>
<div>
<input id="id_old_password" name="old_password" type="password" />
</div>
</div>
<div class="edit-form-row">
<div class="pt-input-ns">New password:</div>
<div>
<input id="id_new_password1" name="new_password1" type="password" />
<div class="c--dim f7 mw6">
Your password must be 8 or more characters, and must differ from your username and current password.
Other than that, <a href="http://krebsonsecurity.com/password-dos-and-donts/" class="external" target="_blank">please follow best practices</a>.
</div>
</div>
</div>
<div class="edit-form-row">
<div class="pt-input-ns">New password confirmation:</div>
<div>
<input id="id_new_password2" name="new_password2" type="password" />
</div>
</div>
<div class="edit-form-row">
<div></div>
<div>
<input type="submit" value="Update password" />
</div>
</div>
</div>
<div class="tab" data-name="Profile Page Options" data-slug="profile">
<div class="edit-form-row">
<div class="pt-input-ns">Links:</div>
<div>
<textarea class="links" name="links" id="links" maxlength="2048" data-max-chars="2048">
{{- .LinksText -}}
</textarea>
<div class="c--dim f7">
<div>Relevant links to put on your profile.</div>
<div>Format: url [Title] (e.g. <code>http://example.com/ Example Site</code>)</div>
<div>(1 per line, 10 max)</div>
</div>
</div>
</div>
<div class="edit-form-row">
<div class="pt-input-ns">Description:</div>
<div>
<textarea class="longbio" name="longbio" maxlength="1018" data-max-chars="1018">
{{- .User.Bio -}}
</textarea>
<div class="c--dim f7">
<div>Include some information about yourself, such as your background, interests, occupation, etc.</div>
</div>
</div>
</div>
<div class="edit-form-row">
<div></div>
<div>
<input type="submit" value="Save profile" />
</div>
</div>
</div>
<div class="tab" data-name="Discord" data-slug="discord">
<div>
{{ if .DiscordUser }}
Linked account:
<span class="b ph2">{{ .DiscordUser.Username }}#{{ .DiscordUser.Discriminator }}</span>
<a href="javascript:void(0)" onclick="unlinkDiscord()">
Unlink account
</a>
{{ else }}
You haven't linked your Discord account.
<a href="{{ .DiscordAuthorizeUrl }}">Link account</a>
{{ end }}
</div>
<div class="mv3">
<input type="checkbox" name="discord-showcase-auto" id="discord-showcase-auto" {{ if .User.DiscordSaveShowcase }}checked{{ end }} {{ if not .DiscordUser }}disabled{{ end }} />
<label for="discord-showcase-auto">Automatically capture everything I post in <span class="b nowrap">#project-showcase</span></label>
<div class="f7 c--dimmer">Snippets will only be created while this setting is on.</div>
</div>
<div class="mv3">
<input type="checkbox" name="discord-snippet-keep" id="discord-snippet-keep" {{ if not .User.DiscordDeleteSnippetOnMessageDelete }}checked{{ end }} {{ if not .DiscordUser }}disabled{{ end }} />
<label for="discord-snippet-keep">Keep captured snippets even if I delete them in Discord</label>
</div>
{{ if .DiscordUser }}
<div class="mv3 mw6">
<a href="javascript:void(0)" onclick="discordShowcaseBacklog()">
Create snippets from all of my <span class="b nowrap">#project-showcase</span> posts
</a>
<div class="f7 c--dimmer">
Use this if you have a backlog of content in <span class="b nowrap">#project-showcase</span> that you want on your profile.
</div>
{{ if gt .DiscordNumUnsavedMessages 0 }}
<div class="f7 c--dimmer">
<span class="b">WARNING:</span> {{ .DiscordNumUnsavedMessages }} of your messages are currently waiting to be processed. If you run this command now, some snippets may still be missing.
</div>
{{ end }}
</div>
{{ end }}
<input type="submit" value="Save profile" />
</div>
</form>
<form id="discord-unlink-form" class="dn" action="{{ .DiscordUnlinkUrl }}" method="POST">
{{ csrftoken .Session }}
<script>
function unlinkDiscord() {
document.querySelector('#discord-unlink-form').submit();
}
</script>
</form>
<form id="discord-showcase-backlog" class="dn" action="{{ .DiscordShowcaseBacklogUrl }}" method="POST">
{{ csrftoken .Session }}
<script>
function discordShowcaseBacklog() {
document.querySelector('#discord-showcase-backlog').submit();
}
</script>
</form>
{{ end }}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

View File

@ -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 {

View File

@ -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
}