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:
parent
16ae2188d1
commit
67b86720a9
|
@ -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;
|
||||
}
|
177
public/style.css
177
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; }
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -2,9 +2,8 @@ package models
|
|||
|
||||
type Link struct {
|
||||
ID int `db:"id"`
|
||||
Key string `db:"key"`
|
||||
Name *string `db:"name"`
|
||||
Value string `db:"value"`
|
||||
Name string `db:"name"`
|
||||
URL string `db:"url"`
|
||||
Ordering int `db:"ordering"`
|
||||
UserID *int `db:"user_id"`
|
||||
ProjectID *int `db:"project_id"`
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
@ -158,14 +104,14 @@
|
|||
}
|
||||
|
||||
.quota-bar {
|
||||
@include usevar(border-color, 'project-edit-quota-bar-border-color');
|
||||
// @include usevar(border-color, 'project-edit-quota-bar-border-color');
|
||||
|
||||
width: 500px;
|
||||
border-width: 1px;
|
||||
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%;
|
||||
}
|
||||
|
|
|
@ -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] {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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%; }
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
tlink := Link{
|
||||
Name: link.Name,
|
||||
Url: link.URL,
|
||||
LinkText: link.URL,
|
||||
}
|
||||
*/
|
||||
serviceName, serviceUserData := ParseKnownServicesForLink(link)
|
||||
if serviceUserData != "" {
|
||||
name = serviceUserData
|
||||
|
||||
service, userData := ParseKnownServicesForLink(link)
|
||||
if tlink.Name == "" && service.Name != "" {
|
||||
tlink.Name = service.Name
|
||||
}
|
||||
if name == "" {
|
||||
name = link.Value
|
||||
if service.IconName != "" {
|
||||
tlink.Icon = service.IconName
|
||||
}
|
||||
return Link{
|
||||
Key: link.Key,
|
||||
Name: name,
|
||||
Icon: serviceName,
|
||||
Url: link.Value,
|
||||
if userData != "" {
|
||||
tlink.LinkText = userData
|
||||
}
|
||||
|
||||
return tlink
|
||||
}
|
||||
|
||||
func TimelineItemsToJSON(items []TimelineItem) string {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }}
|
|
@ -137,6 +137,7 @@ type User struct {
|
|||
ProfileUrl string
|
||||
|
||||
DarkTheme bool
|
||||
ShowEmail bool
|
||||
Timezone string
|
||||
|
||||
CanEditLibrary bool
|
||||
|
@ -145,9 +146,9 @@ type User struct {
|
|||
}
|
||||
|
||||
type Link struct {
|
||||
Key string
|
||||
Name string
|
||||
Url string
|
||||
LinkText string
|
||||
Icon string
|
||||
}
|
||||
|
||||
|
|
|
@ -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,15 +701,11 @@ 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 {
|
||||
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")
|
||||
}
|
||||
} else {
|
||||
c.Logger.Error().Err(err).Msg("failed to re-hash password")
|
||||
}
|
||||
// If errors happen here, we can still continue with logging them in
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
||||
imageId, err := SaveImageFile(c, tx, "podcast_image", maxFileSize, fmt.Sprintf("podcast/%s/logo%d", c.CurrentProject.Slug, time.Now().UTC().Unix()))
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert image file row"))
|
||||
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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue