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
|
-l = large
|
||||||
|
|
||||||
*/
|
*/
|
||||||
.flex {
|
.flex, .tab-bar, .edit-form .edit-form-row {
|
||||||
display: flex; }
|
display: flex; }
|
||||||
|
|
||||||
.inline-flex {
|
.inline-flex {
|
||||||
|
@ -2012,10 +2012,10 @@ img, video {
|
||||||
.flex-none {
|
.flex-none {
|
||||||
flex: none; }
|
flex: none; }
|
||||||
|
|
||||||
.flex-column {
|
.flex-column, .edit-form .edit-form-row {
|
||||||
flex-direction: column; }
|
flex-direction: column; }
|
||||||
|
|
||||||
.flex-row {
|
.flex-row, .tab-bar {
|
||||||
flex-direction: row; }
|
flex-direction: row; }
|
||||||
|
|
||||||
.flex-wrap {
|
.flex-wrap {
|
||||||
|
@ -2126,13 +2126,13 @@ img, video {
|
||||||
.order-last {
|
.order-last {
|
||||||
order: 99999; }
|
order: 99999; }
|
||||||
|
|
||||||
.flex-grow-0 {
|
.flex-grow-0, .edit-form .edit-form-row > :first-child {
|
||||||
flex-grow: 0; }
|
flex-grow: 0; }
|
||||||
|
|
||||||
.flex-grow-1 {
|
.flex-grow-1, .edit-form .edit-form-row > :nth-child(2) {
|
||||||
flex-grow: 1; }
|
flex-grow: 1; }
|
||||||
|
|
||||||
.flex-shrink-0 {
|
.flex-shrink-0, .edit-form .edit-form-row > :first-child {
|
||||||
flex-shrink: 0; }
|
flex-shrink: 0; }
|
||||||
|
|
||||||
.flex-shrink-1 {
|
.flex-shrink-1 {
|
||||||
|
@ -2153,7 +2153,7 @@ img, video {
|
||||||
flex: none; }
|
flex: none; }
|
||||||
.flex-column-ns {
|
.flex-column-ns {
|
||||||
flex-direction: column; }
|
flex-direction: column; }
|
||||||
.flex-row-ns {
|
.flex-row-ns, .edit-form .edit-form-row {
|
||||||
flex-direction: row; }
|
flex-direction: row; }
|
||||||
.flex-wrap-ns {
|
.flex-wrap-ns {
|
||||||
flex-wrap: wrap; }
|
flex-wrap: wrap; }
|
||||||
|
@ -2771,7 +2771,7 @@ code, .code {
|
||||||
.h2 {
|
.h2 {
|
||||||
height: 2rem; }
|
height: 2rem; }
|
||||||
|
|
||||||
.h3 {
|
.h3, .edit-form textarea {
|
||||||
height: 4rem; }
|
height: 4rem; }
|
||||||
|
|
||||||
.h4 {
|
.h4 {
|
||||||
|
@ -3079,7 +3079,7 @@ code, .code {
|
||||||
|
|
||||||
*/
|
*/
|
||||||
/* Max Width Percentages */
|
/* Max Width Percentages */
|
||||||
.mw-100 {
|
.mw-100, .edit-form textarea {
|
||||||
max-width: 100%; }
|
max-width: 100%; }
|
||||||
|
|
||||||
/* Max Width Scale */
|
/* Max Width Scale */
|
||||||
|
@ -3125,7 +3125,7 @@ code, .code {
|
||||||
max-width: 4rem; }
|
max-width: 4rem; }
|
||||||
.mw4-ns {
|
.mw4-ns {
|
||||||
max-width: 8rem; }
|
max-width: 8rem; }
|
||||||
.mw5-ns {
|
.mw5-ns, .edit-form input[type=text] {
|
||||||
max-width: 16rem; }
|
max-width: 16rem; }
|
||||||
.mw6-ns {
|
.mw6-ns {
|
||||||
max-width: 32rem; }
|
max-width: 32rem; }
|
||||||
|
@ -3243,6 +3243,9 @@ code, .code {
|
||||||
.w5 {
|
.w5 {
|
||||||
width: 16rem; }
|
width: 16rem; }
|
||||||
|
|
||||||
|
.w6 {
|
||||||
|
width: 32rem; }
|
||||||
|
|
||||||
.w-10 {
|
.w-10 {
|
||||||
width: 10%; }
|
width: 10%; }
|
||||||
|
|
||||||
|
@ -3282,7 +3285,7 @@ code, .code {
|
||||||
.w-90 {
|
.w-90 {
|
||||||
width: 90%; }
|
width: 90%; }
|
||||||
|
|
||||||
.w-100 {
|
.w-100, .edit-form .edit-form-row > :first-child, .edit-form input[type=text], .edit-form textarea {
|
||||||
width: 100%; }
|
width: 100%; }
|
||||||
|
|
||||||
.w-third {
|
.w-third {
|
||||||
|
@ -3301,10 +3304,12 @@ code, .code {
|
||||||
width: 2rem; }
|
width: 2rem; }
|
||||||
.w3-ns {
|
.w3-ns {
|
||||||
width: 4rem; }
|
width: 4rem; }
|
||||||
.w4-ns {
|
.w4-ns, .edit-form .edit-form-row > :first-child {
|
||||||
width: 8rem; }
|
width: 8rem; }
|
||||||
.w5-ns {
|
.w5-ns {
|
||||||
width: 16rem; }
|
width: 16rem; }
|
||||||
|
.w6-ns, .edit-form textarea {
|
||||||
|
width: 32rem; }
|
||||||
.w-10-ns {
|
.w-10-ns {
|
||||||
width: 10%; }
|
width: 10%; }
|
||||||
.w-20-ns {
|
.w-20-ns {
|
||||||
|
@ -3351,6 +3356,8 @@ code, .code {
|
||||||
width: 8rem; }
|
width: 8rem; }
|
||||||
.w5-m {
|
.w5-m {
|
||||||
width: 16rem; }
|
width: 16rem; }
|
||||||
|
.w6-m {
|
||||||
|
width: 32rem; }
|
||||||
.w-10-m {
|
.w-10-m {
|
||||||
width: 10%; }
|
width: 10%; }
|
||||||
.w-20-m {
|
.w-20-m {
|
||||||
|
@ -3397,6 +3404,8 @@ code, .code {
|
||||||
width: 8rem; }
|
width: 8rem; }
|
||||||
.w5-l {
|
.w5-l {
|
||||||
width: 16rem; }
|
width: 16rem; }
|
||||||
|
.w6-l {
|
||||||
|
width: 32rem; }
|
||||||
.w-10-l {
|
.w-10-l {
|
||||||
width: 10%; }
|
width: 10%; }
|
||||||
.w-20-l {
|
.w-20-l {
|
||||||
|
@ -3445,7 +3454,7 @@ code, .code {
|
||||||
.overflow-visible {
|
.overflow-visible {
|
||||||
overflow: visible; }
|
overflow: visible; }
|
||||||
|
|
||||||
.overflow-hidden {
|
.overflow-hidden, .edit-form .edit-form-row > :nth-child(2) {
|
||||||
overflow: hidden; }
|
overflow: hidden; }
|
||||||
|
|
||||||
.overflow-scroll {
|
.overflow-scroll {
|
||||||
|
@ -4614,7 +4623,7 @@ code, .code {
|
||||||
.pl7 {
|
.pl7 {
|
||||||
padding-left: 16rem; }
|
padding-left: 16rem; }
|
||||||
|
|
||||||
.pr0 {
|
.pr0, .edit-form .edit-form-row > :first-child {
|
||||||
padding-right: 0; }
|
padding-right: 0; }
|
||||||
|
|
||||||
.pr1 {
|
.pr1 {
|
||||||
|
@ -4641,7 +4650,7 @@ code, .code {
|
||||||
.pb0 {
|
.pb0 {
|
||||||
padding-bottom: 0; }
|
padding-bottom: 0; }
|
||||||
|
|
||||||
.pb1 {
|
.pb1, .edit-form .edit-form-row > :first-child {
|
||||||
padding-bottom: 0.25rem; }
|
padding-bottom: 0.25rem; }
|
||||||
|
|
||||||
.pb2 {
|
.pb2 {
|
||||||
|
@ -4698,7 +4707,7 @@ code, .code {
|
||||||
padding-top: 0.25rem;
|
padding-top: 0.25rem;
|
||||||
padding-bottom: 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,
|
||||||
.button,
|
.button,
|
||||||
input[type=button],
|
input[type=button],
|
||||||
|
@ -4742,7 +4751,7 @@ input[type=submit] {
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
padding-right: 0.5rem; }
|
padding-right: 0.5rem; }
|
||||||
|
|
||||||
.ph3,
|
.ph3, .tab-bar .tab-button,
|
||||||
button,
|
button,
|
||||||
.button,
|
.button,
|
||||||
input[type=button],
|
input[type=button],
|
||||||
|
@ -4898,7 +4907,7 @@ input[type=submit] {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
margin-bottom: 0.5rem; }
|
margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
.mv3, hr {
|
.mv3, hr, .edit-form .edit-form-row {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
margin-bottom: 1rem; }
|
margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
@ -4987,7 +4996,7 @@ input[type=submit] {
|
||||||
padding-right: 0; }
|
padding-right: 0; }
|
||||||
.pr1-ns {
|
.pr1-ns {
|
||||||
padding-right: 0.25rem; }
|
padding-right: 0.25rem; }
|
||||||
.pr2-ns {
|
.pr2-ns, .edit-form .edit-form-row > :first-child {
|
||||||
padding-right: 0.5rem; }
|
padding-right: 0.5rem; }
|
||||||
.pr3-ns {
|
.pr3-ns {
|
||||||
padding-right: 1rem; }
|
padding-right: 1rem; }
|
||||||
|
@ -4999,7 +5008,7 @@ input[type=submit] {
|
||||||
padding-right: 8rem; }
|
padding-right: 8rem; }
|
||||||
.pr7-ns {
|
.pr7-ns {
|
||||||
padding-right: 16rem; }
|
padding-right: 16rem; }
|
||||||
.pb0-ns {
|
.pb0-ns, .edit-form .edit-form-row > :first-child {
|
||||||
padding-bottom: 0; }
|
padding-bottom: 0; }
|
||||||
.pb1-ns {
|
.pb1-ns {
|
||||||
padding-bottom: 0.25rem; }
|
padding-bottom: 0.25rem; }
|
||||||
|
@ -6169,7 +6178,7 @@ input[type=submit] {
|
||||||
-l = large
|
-l = large
|
||||||
|
|
||||||
*/
|
*/
|
||||||
.tl {
|
.tl, .edit-form .edit-form-row > :first-child {
|
||||||
text-align: left; }
|
text-align: left; }
|
||||||
|
|
||||||
.tr {
|
.tr {
|
||||||
|
@ -6184,7 +6193,7 @@ input[type=submit] {
|
||||||
@media screen and (min-width: 30em) {
|
@media screen and (min-width: 30em) {
|
||||||
.tl-ns {
|
.tl-ns {
|
||||||
text-align: left; }
|
text-align: left; }
|
||||||
.tr-ns {
|
.tr-ns, .edit-form .edit-form-row > :first-child {
|
||||||
text-align: right; }
|
text-align: right; }
|
||||||
.tc-ns {
|
.tc-ns {
|
||||||
text-align: center; }
|
text-align: center; }
|
||||||
|
@ -7204,7 +7213,7 @@ body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.5em;
|
line-height: 1.2em;
|
||||||
font-weight: 400; }
|
font-weight: 400; }
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
@ -7321,10 +7330,10 @@ article code {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto; }
|
margin-right: auto; }
|
||||||
|
|
||||||
.flex-shrink-0 {
|
.flex-shrink-0, .edit-form .edit-form-row > :first-child {
|
||||||
flex-shrink: 0; }
|
flex-shrink: 0; }
|
||||||
|
|
||||||
.flex-grow-1 {
|
.flex-grow-1, .edit-form .edit-form-row > :nth-child(2) {
|
||||||
flex-grow: 1; }
|
flex-grow: 1; }
|
||||||
|
|
||||||
.flex-fair {
|
.flex-fair {
|
||||||
|
@ -7780,32 +7789,20 @@ header {
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
border-color: #d8d8d8;
|
border-color: #d8d8d8;
|
||||||
border-color: var(--tab-border-color);
|
border-color: var(--tab-border-color);
|
||||||
width: 100%;
|
width: 100%; }
|
||||||
border-bottom-width: 1px;
|
|
||||||
border-bottom-style: solid;
|
|
||||||
box-sizing: border-box; }
|
|
||||||
.tab-bar .tab-button {
|
.tab-bar .tab-button {
|
||||||
background-color: #dfdfdf;
|
background-color: #dfdfdf;
|
||||||
background-color: var(--tab-button-background);
|
background-color: var(--tab-button-background);
|
||||||
border-color: #d8d8d8;
|
border-color: #d8d8d8;
|
||||||
border-color: var(--tab-border-color);
|
border-color: var(--tab-border-color);
|
||||||
height: 100%;
|
cursor: pointer; }
|
||||||
display: inline-block;
|
|
||||||
padding: 10px 15px;
|
|
||||||
line-height: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
box-sizing: border-box; }
|
|
||||||
.tab-bar .tab-button:hover {
|
.tab-bar .tab-button:hover {
|
||||||
background-color: #efefef;
|
background-color: #efefef;
|
||||||
background-color: var(--tab-button-background-hover); }
|
background-color: var(--tab-button-background-hover); }
|
||||||
.tab-bar .tab-button.current {
|
.tab-bar .tab-button.current {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
background-color: var(--tab-button-background-current);
|
background-color: var(--tab-button-background-current);
|
||||||
border-bottom-color: transparent;
|
font-weight: 500; }
|
||||||
font-weight: bold;
|
|
||||||
height: 105%; }
|
|
||||||
|
|
||||||
.pagination .page.current {
|
.pagination .page.current {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
@ -8016,81 +8013,12 @@ pre {
|
||||||
max-height: calc(100vh - 20rem);
|
max-height: calc(100vh - 20rem);
|
||||||
overflow: auto; } }
|
overflow: auto; } }
|
||||||
|
|
||||||
.edit-form .error {
|
.edit-form .edit-form-row > :first-child {
|
||||||
margin-left: 5em;
|
font-weight: 500; }
|
||||||
padding: 10px;
|
|
||||||
color: red; }
|
|
||||||
|
|
||||||
.edit-form input[type=text] {
|
@media screen and (min-width: 30em) {
|
||||||
min-width: 20em; }
|
.edit-form .edit-form-row .pt-input-ns {
|
||||||
|
padding-top: 0.3rem; } }
|
||||||
.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; }
|
|
||||||
|
|
||||||
.edit-form.project-edit .project_description {
|
.edit-form.project-edit .project_description {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -8103,14 +8031,10 @@ pre {
|
||||||
width: 50%; }
|
width: 50%; }
|
||||||
|
|
||||||
.edit-form.project-edit .quota-bar {
|
.edit-form.project-edit .quota-bar {
|
||||||
border-color: #999;
|
|
||||||
border-color: var(--project-edit-quota-bar-border-color);
|
|
||||||
width: 500px;
|
width: 500px;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
margin-bottom: 10px; }
|
margin-bottom: 10px; }
|
||||||
.edit-form.project-edit .quota-bar .quota-filled {
|
.edit-form.project-edit .quota-bar .quota-filled {
|
||||||
background-color: #444;
|
|
||||||
background-color: var(--project-edit-quota-bar-filled-background);
|
|
||||||
height: 100%; }
|
height: 100%; }
|
||||||
|
|
||||||
.episode-list .description p {
|
.episode-list .description p {
|
||||||
|
@ -8361,6 +8285,7 @@ nav.timecodes {
|
||||||
|
|
||||||
input[type=text],
|
input[type=text],
|
||||||
input[type=password],
|
input[type=password],
|
||||||
|
input[type=email],
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
color: black;
|
color: black;
|
||||||
|
@ -8375,6 +8300,7 @@ select {
|
||||||
outline: none; }
|
outline: none; }
|
||||||
input[type=text].lite,
|
input[type=text].lite,
|
||||||
input[type=password].lite,
|
input[type=password].lite,
|
||||||
|
input[type=email].lite,
|
||||||
textarea.lite,
|
textarea.lite,
|
||||||
select.lite {
|
select.lite {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
@ -8386,6 +8312,8 @@ select {
|
||||||
input[type=text].lite:focus, input[type=text].lite:active,
|
input[type=text].lite:focus, input[type=text].lite:active,
|
||||||
input[type=password].lite:focus,
|
input[type=password].lite:focus,
|
||||||
input[type=password].lite:active,
|
input[type=password].lite:active,
|
||||||
|
input[type=email].lite:focus,
|
||||||
|
input[type=email].lite:active,
|
||||||
textarea.lite:focus,
|
textarea.lite:focus,
|
||||||
textarea.lite:active,
|
textarea.lite:active,
|
||||||
select.lite:focus,
|
select.lite:focus,
|
||||||
|
@ -8396,6 +8324,8 @@ select {
|
||||||
input[type=text]:active, input[type=text]:focus,
|
input[type=text]:active, input[type=text]:focus,
|
||||||
input[type=password]:active,
|
input[type=password]:active,
|
||||||
input[type=password]:focus,
|
input[type=password]:focus,
|
||||||
|
input[type=email]:active,
|
||||||
|
input[type=email]:focus,
|
||||||
textarea:active,
|
textarea:active,
|
||||||
textarea:focus,
|
textarea:focus,
|
||||||
select:active,
|
select:active,
|
||||||
|
@ -8405,14 +8335,19 @@ select {
|
||||||
border-color: #4c9ed9;
|
border-color: #4c9ed9;
|
||||||
border-color: var(--form-text-border-color-active); }
|
border-color: var(--form-text-border-color-active); }
|
||||||
|
|
||||||
input[type=text]:not(.lite), input[type=password]:not(.lite) {
|
input[type=text]:not(.lite),
|
||||||
padding: 5px; }
|
input[type=password]:not(.lite),
|
||||||
|
input[type=email]:not(.lite) {
|
||||||
|
padding: 0.3rem; }
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
padding: 0.3rem; }
|
||||||
|
|
||||||
form .note {
|
form .note {
|
||||||
font-style: italic; }
|
font-style: italic; }
|
||||||
|
|
||||||
select {
|
select {
|
||||||
padding: 5px 10px; }
|
padding: 0.3rem 0.6rem; }
|
||||||
|
|
||||||
option[selected] {
|
option[selected] {
|
||||||
font-weight: bold; }
|
font-weight: bold; }
|
||||||
|
|
|
@ -237,9 +237,6 @@ will throw an error.
|
||||||
--project-card-border-color: #333;
|
--project-card-border-color: #333;
|
||||||
--project-user-suggestions-background: #222;
|
--project-user-suggestions-background: #222;
|
||||||
--project-user-suggestions-border-color: #444;
|
--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-text-color: #eee;
|
||||||
--notice-unapproved-color: #7a2020;
|
--notice-unapproved-color: #7a2020;
|
||||||
--notice-hidden-color: #494949;
|
--notice-hidden-color: #494949;
|
||||||
|
|
|
@ -255,9 +255,6 @@ will throw an error.
|
||||||
--project-card-border-color: #aaa;
|
--project-card-border-color: #aaa;
|
||||||
--project-user-suggestions-background: #fff;
|
--project-user-suggestions-background: #fff;
|
||||||
--project-user-suggestions-border-color: #ddd;
|
--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-text-color: #fff;
|
||||||
--notice-unapproved-color: #b42222;
|
--notice-unapproved-color: #b42222;
|
||||||
--notice-hidden-color: #b6b6b6;
|
--notice-hidden-color: #b6b6b6;
|
||||||
|
|
|
@ -51,10 +51,7 @@ func init() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := auth.HashPassword(password)
|
hashedPassword := auth.HashPassword(password)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = auth.UpdatePassword(ctx, conn, canonicalUsername, hashedPassword)
|
err = auth.UpdatePassword(ctx, conn, canonicalUsername, hashedPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
"git.handmade.network/hmn/hmn/src/logging"
|
"git.handmade.network/hmn/hmn/src/logging"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
@ -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.
|
// Follows the OWASP recommendations as of March 2021.
|
||||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
|
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
|
||||||
|
|
||||||
salt := make([]byte, saltLength)
|
salt := make([]byte, saltLength)
|
||||||
_, err := io.ReadFull(rand.Reader, salt)
|
io.ReadFull(rand.Reader, salt)
|
||||||
if err != nil {
|
|
||||||
return HashedPassword{}, oops.New(err, "failed to generate salt")
|
|
||||||
}
|
|
||||||
saltEnc := base64.StdEncoding.EncodeToString(salt)
|
saltEnc := base64.StdEncoding.EncodeToString(salt)
|
||||||
|
|
||||||
cfg := Argon2idConfig{
|
cfg := Argon2idConfig{
|
||||||
|
@ -176,12 +174,12 @@ func HashPassword(password string) (HashedPassword, error) {
|
||||||
AlgoConfig: cfg.String(),
|
AlgoConfig: cfg.String(),
|
||||||
Salt: saltEnc,
|
Salt: saltEnc,
|
||||||
Hash: keyEnc,
|
Hash: keyEnc,
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ErrUserDoesNotExist = errors.New("user does not exist")
|
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)
|
tag, err := conn.Exec(ctx, "UPDATE auth_user SET password = $1 WHERE username = $2", hp.String(), username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "failed to update password")
|
return oops.New(err, "failed to update password")
|
||||||
|
|
|
@ -68,6 +68,7 @@ func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
|
||||||
WHERE
|
WHERE
|
||||||
c.last_content IS NULL
|
c.last_content IS NULL
|
||||||
AND msg.guild_id = $1
|
AND msg.guild_id = $1
|
||||||
|
ORDER BY msg.sent_at DESC
|
||||||
`,
|
`,
|
||||||
config.Config.Discord.GuildID,
|
config.Config.Discord.GuildID,
|
||||||
)
|
)
|
||||||
|
@ -186,13 +187,13 @@ func handleHistoryMessage(ctx context.Context, dbConn *pgxpool.Pool, msg *Messag
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
newMsg, err := saveMessageAndContents(ctx, tx, msg)
|
newMsg, err := SaveMessageAndContents(ctx, tx, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if createSnippets {
|
if createSnippets {
|
||||||
if doSnippet, err := allowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil {
|
if doSnippet, err := AllowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil {
|
||||||
_, err := createMessageSnippet(ctx, tx, msg)
|
_, err := CreateMessageSnippet(ctx, tx, msg.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/config"
|
"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/logging"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
)
|
)
|
||||||
|
@ -499,6 +500,16 @@ func GetChannelMessages(ctx context.Context, channelID string, in GetChannelMess
|
||||||
return msgs, nil
|
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) {
|
func logErrorResponse(ctx context.Context, name string, res *http.Response, msg string) {
|
||||||
dump, err := httputil.DumpResponse(res, true)
|
dump, err := httputil.DumpResponse(res, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -47,7 +47,7 @@ func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message) er
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
// save the message, maybe save its contents, and maybe make a snippet too
|
// 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) {
|
if errors.Is(err, errNotEnoughInfo) {
|
||||||
logging.ExtractLogger(ctx).Warn().
|
logging.ExtractLogger(ctx).Warn().
|
||||||
Interface("msg", msg).
|
Interface("msg", msg).
|
||||||
|
@ -56,8 +56,8 @@ func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message) er
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if doSnippet, err := allowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil {
|
if doSnippet, err := AllowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil {
|
||||||
_, err := createMessageSnippet(ctx, tx, msg)
|
_, err := CreateMessageSnippet(ctx, tx, msg.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "failed to create snippet in gateway")
|
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.
|
This does not create snippets or do anything besides save the message itself.
|
||||||
*/
|
*/
|
||||||
func saveMessage(
|
func SaveMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx db.ConnOrTx,
|
tx db.ConnOrTx,
|
||||||
msg *Message,
|
msg *Message,
|
||||||
|
@ -194,12 +194,12 @@ snippets.
|
||||||
|
|
||||||
Idempotent; can be called any time whether the message exists or not.
|
Idempotent; can be called any time whether the message exists or not.
|
||||||
*/
|
*/
|
||||||
func saveMessageAndContents(
|
func SaveMessageAndContents(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx db.ConnOrTx,
|
tx db.ConnOrTx,
|
||||||
msg *Message,
|
msg *Message,
|
||||||
) (*models.DiscordMessage, error) {
|
) (*models.DiscordMessage, error) {
|
||||||
newMsg, err := saveMessage(ctx, tx, msg)
|
newMsg, err := SaveMessage(ctx, tx, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -507,7 +507,11 @@ func saveEmbed(
|
||||||
return iDiscordEmbed.(*models.DiscordMessageEmbed), nil
|
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,
|
canSave, err := db.QueryBool(ctx, tx,
|
||||||
`
|
`
|
||||||
SELECT u.discord_save_showcase
|
SELECT u.discord_save_showcase
|
||||||
|
@ -528,7 +532,16 @@ func allowedToCreateMessageSnippet(ctx context.Context, tx db.ConnOrTx, discordU
|
||||||
return canSave, nil
|
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
|
// Check for existing snippet, maybe return it
|
||||||
type existingSnippetResult struct {
|
type existingSnippetResult struct {
|
||||||
Message models.DiscordMessage `db:"msg"`
|
Message models.DiscordMessage `db:"msg"`
|
||||||
|
@ -547,16 +560,16 @@ func createMessageSnippet(ctx context.Context, tx db.ConnOrTx, msg *Message) (*m
|
||||||
WHERE
|
WHERE
|
||||||
msg.id = $1
|
msg.id = $1
|
||||||
`,
|
`,
|
||||||
msg.ID,
|
msgID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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)
|
existing := iexisting.(*existingSnippetResult)
|
||||||
|
|
||||||
if existing.Snippet != nil {
|
if existing.Snippet != nil {
|
||||||
// A snippet already exists - maybe update its content, then return it
|
// 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
|
contentMarkdown := existing.MessageContent.LastContent
|
||||||
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
||||||
|
|
||||||
|
@ -611,7 +624,7 @@ func createMessageSnippet(ctx context.Context, tx db.ConnOrTx, msg *Message) (*m
|
||||||
contentMarkdown,
|
contentMarkdown,
|
||||||
contentHTML,
|
contentHTML,
|
||||||
assetId,
|
assetId,
|
||||||
msg.ID,
|
msgID,
|
||||||
existing.DiscordUser.HMNUserId,
|
existing.DiscordUser.HMNUserId,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -623,7 +636,7 @@ func createMessageSnippet(ctx context.Context, tx db.ConnOrTx, msg *Message) (*m
|
||||||
SET snippet_created = TRUE
|
SET snippet_created = TRUE
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`,
|
`,
|
||||||
msg.ID,
|
msgID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, oops.New(err, "failed to mark message as having snippet")
|
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)
|
return Url("/m/"+url.PathEscape(username), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexUserSettings = regexp.MustCompile(`^/_settings$`)
|
var RegexUserSettings = regexp.MustCompile(`^/settings$`)
|
||||||
|
|
||||||
func BuildUserSettings(section string) string {
|
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
|
* Discord OAuth
|
||||||
*/
|
*/
|
||||||
|
|
||||||
var RegexDiscordTest = regexp.MustCompile("^/discord$")
|
|
||||||
|
|
||||||
func BuildDiscordTest() string {
|
|
||||||
return Url("/discord", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
var RegexDiscordOAuthCallback = regexp.MustCompile("^/_discord_callback$")
|
var RegexDiscordOAuthCallback = regexp.MustCompile("^/_discord_callback$")
|
||||||
|
|
||||||
func BuildDiscordOAuthCallback() string {
|
func BuildDiscordOAuthCallback() string {
|
||||||
|
@ -576,6 +570,12 @@ func BuildDiscordUnlink() string {
|
||||||
return Url("/_discord_unlink", nil)
|
return Url("/_discord_unlink", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var RegexDiscordShowcaseBacklog = regexp.MustCompile("^/discord_showcase_backlog$")
|
||||||
|
|
||||||
|
func BuildDiscordShowcaseBacklog() string {
|
||||||
|
return Url("/discord_showcase_backlog", nil)
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Assets
|
* 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 {
|
type Link struct {
|
||||||
ID int `db:"id"`
|
ID int `db:"id"`
|
||||||
Key string `db:"key"`
|
Name string `db:"name"`
|
||||||
Name *string `db:"name"`
|
URL string `db:"url"`
|
||||||
Value string `db:"value"`
|
|
||||||
Ordering int `db:"ordering"`
|
Ordering int `db:"ordering"`
|
||||||
UserID *int `db:"user_id"`
|
UserID *int `db:"user_id"`
|
||||||
ProjectID *int `db:"project_id"`
|
ProjectID *int `db:"project_id"`
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
// Global variables
|
||||||
|
$input-padding: 0.3rem;
|
||||||
|
|
||||||
.noselect {
|
.noselect {
|
||||||
-webkit-touch-callout: none; /* iOS Safari */
|
-webkit-touch-callout: none; /* iOS Safari */
|
||||||
-webkit-user-select: none; /* Chrome/Safari/Opera */
|
-webkit-user-select: none; /* Chrome/Safari/Opera */
|
||||||
|
@ -24,7 +27,7 @@ body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: px2rem(14px);
|
font-size: px2rem(14px);
|
||||||
line-height: 1.5em;
|
line-height: 1.2em;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -735,24 +738,16 @@ footer {
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
@include usevar(border-color, tab-border-color);
|
@include usevar(border-color, tab-border-color);
|
||||||
|
@extend .flex, .flex-row;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-bottom-width: 1px;
|
|
||||||
border-bottom-style: solid;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
.tab-button {
|
.tab-button {
|
||||||
@include usevar(background-color, tab-button-background);
|
@include usevar(background-color, tab-button-background);
|
||||||
@include usevar(border-color, tab-border-color);
|
@include usevar(border-color, tab-border-color);
|
||||||
|
@extend .ph3, .pv2;
|
||||||
|
|
||||||
height:100%;
|
cursor: pointer; // TODO: Should this be a link?
|
||||||
display:inline-block;
|
|
||||||
padding:10px 15px;
|
|
||||||
line-height:100%;
|
|
||||||
cursor:pointer;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
box-sizing:border-box;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@include usevar(background-color, tab-button-background-hover);
|
@include usevar(background-color, tab-button-background-hover);
|
||||||
|
@ -760,10 +755,7 @@ footer {
|
||||||
|
|
||||||
&.current {
|
&.current {
|
||||||
@include usevar(background-color, tab-button-background-current);
|
@include usevar(background-color, tab-button-background-current);
|
||||||
|
font-weight: 500;
|
||||||
border-bottom-color: transparent;
|
|
||||||
font-weight:bold;
|
|
||||||
height:105%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,103 +44,49 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
.error {
|
.edit-form-row {
|
||||||
margin-left:5em;
|
@extend .flex;
|
||||||
padding:10px;
|
@extend .flex-column;
|
||||||
color:red;
|
@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] {
|
input[type=text] {
|
||||||
min-width:20em;
|
@extend .w-100;
|
||||||
|
@extend .mw5-ns;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
font-size:13pt;
|
@extend .w-100;
|
||||||
}
|
@extend .w6-ns;
|
||||||
|
@extend .mw-100;
|
||||||
.note {
|
@extend .h3;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.project-edit {
|
&.project-edit {
|
||||||
|
@ -153,21 +99,21 @@
|
||||||
input.project_blurb,
|
input.project_blurb,
|
||||||
input.project_name,
|
input.project_name,
|
||||||
{
|
{
|
||||||
min-width:300px;
|
min-width: 300px;
|
||||||
width:50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quota-bar {
|
.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;
|
border-width: 1px;
|
||||||
margin-bottom:10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
.quota-filled {
|
.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%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,6 +68,7 @@
|
||||||
|
|
||||||
input[type=text],
|
input[type=text],
|
||||||
input[type=password],
|
input[type=password],
|
||||||
|
input[type=email],
|
||||||
textarea,
|
textarea,
|
||||||
select,
|
select,
|
||||||
{
|
{
|
||||||
|
@ -102,18 +103,25 @@ select,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=text], input[type=password] {
|
input[type=text],
|
||||||
|
input[type=password],
|
||||||
|
input[type=email],
|
||||||
|
{
|
||||||
&:not(.lite) {
|
&:not(.lite) {
|
||||||
padding:5px;
|
padding: $input-padding;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
padding: $input-padding;
|
||||||
|
}
|
||||||
|
|
||||||
form .note {
|
form .note {
|
||||||
font-style:italic;
|
font-style:italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
padding: 5px 10px;
|
padding: $input-padding 2*$input-padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
option[selected] {
|
option[selected] {
|
||||||
|
|
|
@ -42,6 +42,7 @@ $width-2: 2rem !default;
|
||||||
$width-3: 4rem !default;
|
$width-3: 4rem !default;
|
||||||
$width-4: 8rem !default;
|
$width-4: 8rem !default;
|
||||||
$width-5: 16rem !default;
|
$width-5: 16rem !default;
|
||||||
|
$width-6: 32rem !default;
|
||||||
$max-width-1: 1rem !default;
|
$max-width-1: 1rem !default;
|
||||||
$max-width-2: 2rem !default;
|
$max-width-2: 2rem !default;
|
||||||
$max-width-3: 4rem !default;
|
$max-width-3: 4rem !default;
|
||||||
|
|
|
@ -54,6 +54,7 @@
|
||||||
.w3 { width: $width-3; }
|
.w3 { width: $width-3; }
|
||||||
.w4 { width: $width-4; }
|
.w4 { width: $width-4; }
|
||||||
.w5 { width: $width-5; }
|
.w5 { width: $width-5; }
|
||||||
|
.w6 { width: $width-6; }
|
||||||
|
|
||||||
.w-10 { width: 10%; }
|
.w-10 { width: 10%; }
|
||||||
.w-20 { width: 20%; }
|
.w-20 { width: 20%; }
|
||||||
|
@ -80,6 +81,7 @@
|
||||||
.w3-ns { width: $width-3; }
|
.w3-ns { width: $width-3; }
|
||||||
.w4-ns { width: $width-4; }
|
.w4-ns { width: $width-4; }
|
||||||
.w5-ns { width: $width-5; }
|
.w5-ns { width: $width-5; }
|
||||||
|
.w6-ns { width: $width-6; }
|
||||||
.w-10-ns { width: 10%; }
|
.w-10-ns { width: 10%; }
|
||||||
.w-20-ns { width: 20%; }
|
.w-20-ns { width: 20%; }
|
||||||
.w-25-ns { width: 25%; }
|
.w-25-ns { width: 25%; }
|
||||||
|
@ -105,6 +107,7 @@
|
||||||
.w3-m { width: $width-3; }
|
.w3-m { width: $width-3; }
|
||||||
.w4-m { width: $width-4; }
|
.w4-m { width: $width-4; }
|
||||||
.w5-m { width: $width-5; }
|
.w5-m { width: $width-5; }
|
||||||
|
.w6-m { width: $width-6; }
|
||||||
.w-10-m { width: 10%; }
|
.w-10-m { width: 10%; }
|
||||||
.w-20-m { width: 20%; }
|
.w-20-m { width: 20%; }
|
||||||
.w-25-m { width: 25%; }
|
.w-25-m { width: 25%; }
|
||||||
|
@ -130,6 +133,7 @@
|
||||||
.w3-l { width: $width-3; }
|
.w3-l { width: $width-3; }
|
||||||
.w4-l { width: $width-4; }
|
.w4-l { width: $width-4; }
|
||||||
.w5-l { width: $width-5; }
|
.w5-l { width: $width-5; }
|
||||||
|
.w6-l { width: $width-6; }
|
||||||
.w-10-l { width: 10%; }
|
.w-10-l { width: 10%; }
|
||||||
.w-20-l { width: 20%; }
|
.w-20-l { width: 20%; }
|
||||||
.w-25-l { width: 25%; }
|
.w-25-l { width: 25%; }
|
||||||
|
|
|
@ -38,9 +38,6 @@ $vars: (
|
||||||
project-card-border-color: #333,
|
project-card-border-color: #333,
|
||||||
project-user-suggestions-background: #222,
|
project-user-suggestions-background: #222,
|
||||||
project-user-suggestions-border-color: #444,
|
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-text-color: $fg-font-color,
|
||||||
notice-unapproved-color: #7a2020,
|
notice-unapproved-color: #7a2020,
|
||||||
|
|
|
@ -38,9 +38,6 @@ $vars: (
|
||||||
project-card-border-color: #aaa,
|
project-card-border-color: #aaa,
|
||||||
project-user-suggestions-background: #fff,
|
project-user-suggestions-background: #fff,
|
||||||
project-user-suggestions-border-color: #ddd,
|
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-text-color: #fff,
|
||||||
notice-unapproved-color: #b42222,
|
notice-unapproved-color: #b42222,
|
||||||
|
|
|
@ -147,6 +147,7 @@ func UserToTemplate(u *models.User, currentTheme string) User {
|
||||||
IsStaff: u.IsStaff,
|
IsStaff: u.IsStaff,
|
||||||
|
|
||||||
Name: u.BestName(),
|
Name: u.BestName(),
|
||||||
|
Bio: u.Bio,
|
||||||
Blurb: u.Blurb,
|
Blurb: u.Blurb,
|
||||||
Signature: u.Signature,
|
Signature: u.Signature,
|
||||||
DateJoined: u.DateJoined,
|
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/-]+)$`)
|
// An online site/service for which we recognize the link
|
||||||
var RegexServiceTwitter = regexp.MustCompile(`twitter\.com/(?P<userdata>\w+)$`)
|
type LinkService struct {
|
||||||
var RegexServiceGithub = regexp.MustCompile(`github\.com/(?P<userdata>[\w/-]+)$`)
|
Name string
|
||||||
var RegexServiceTwitch = regexp.MustCompile(`twitch\.tv/(?P<userdata>[\w/-]+)$`)
|
IconName string
|
||||||
var RegexServiceHitbox = regexp.MustCompile(`hitbox\.tv/(?P<userdata>[\w/-]+)$`)
|
Regex *regexp.Regexp
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseKnownServicesForLink(link *models.Link) (serviceName string, userData string) {
|
var LinkServices = []LinkService{
|
||||||
for name, re := range LinkServiceMap {
|
{
|
||||||
match := re.FindStringSubmatch(link.Value)
|
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 {
|
if match != nil {
|
||||||
serviceName = name
|
return svc, match[svc.Regex.SubexpIndex("userdata")]
|
||||||
userData = match[re.SubexpIndex("userdata")]
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", ""
|
return LinkService{}, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func LinkToTemplate(link *models.Link) Link {
|
func LinkToTemplate(link *models.Link) Link {
|
||||||
name := ""
|
tlink := Link{
|
||||||
/*
|
Name: 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.
|
Url: link.URL,
|
||||||
// So we're just going to ignore Name until we decide it's worth reusing.
|
LinkText: link.URL,
|
||||||
if link.Name != nil {
|
|
||||||
name = *link.Name
|
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
serviceName, serviceUserData := ParseKnownServicesForLink(link)
|
service, userData := ParseKnownServicesForLink(link)
|
||||||
if serviceUserData != "" {
|
if tlink.Name == "" && service.Name != "" {
|
||||||
name = serviceUserData
|
tlink.Name = service.Name
|
||||||
}
|
}
|
||||||
if name == "" {
|
if service.IconName != "" {
|
||||||
name = link.Value
|
tlink.Icon = service.IconName
|
||||||
}
|
}
|
||||||
return Link{
|
if userData != "" {
|
||||||
Key: link.Key,
|
tlink.LinkText = userData
|
||||||
Name: name,
|
|
||||||
Icon: serviceName,
|
|
||||||
Url: link.Value,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return tlink
|
||||||
}
|
}
|
||||||
|
|
||||||
func TimelineItemsToJSON(items []TimelineItem) string {
|
func TimelineItemsToJSON(items []TimelineItem) string {
|
||||||
|
|
|
@ -63,8 +63,8 @@
|
||||||
</div>
|
</div>
|
||||||
{{ range .ProjectLinks }}
|
{{ range .ProjectLinks }}
|
||||||
<div class="pair flex flex-wrap">
|
<div class="pair flex flex-wrap">
|
||||||
<div class="key flex-auto mr1">{{ .Key }}</div>
|
<div class="key flex-auto mr1">{{ .Name }}</div>
|
||||||
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span>{{ .Name }}</a></div>
|
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span> {{ .LinkText }}</a></div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -67,8 +67,8 @@
|
||||||
|
|
||||||
{{ range .ProfileUserLinks }}
|
{{ range .ProfileUserLinks }}
|
||||||
<div class="pair flex flex-wrap">
|
<div class="pair flex flex-wrap">
|
||||||
<div class="key flex-auto mr1">{{ .Key }}</div>
|
<div class="key flex-auto mr1">{{ .Name }}</div>
|
||||||
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span>{{ .Name }}</a></div>
|
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span> {{ .LinkText }}</a></div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</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
|
ProfileUrl string
|
||||||
|
|
||||||
DarkTheme bool
|
DarkTheme bool
|
||||||
|
ShowEmail bool
|
||||||
Timezone string
|
Timezone string
|
||||||
|
|
||||||
CanEditLibrary bool
|
CanEditLibrary bool
|
||||||
|
@ -145,9 +146,9 @@ type User struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
Key string
|
|
||||||
Name string
|
Name string
|
||||||
Url string
|
Url string
|
||||||
|
LinkText string
|
||||||
Icon string
|
Icon string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -210,10 +210,7 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
||||||
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
|
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
hashed, err := auth.HashPassword(password)
|
hashed := auth.HashPassword(password)
|
||||||
if err != nil {
|
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password"))
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Create user and one time token")
|
c.Perf.StartBlock("SQL", "Create user and one time token")
|
||||||
tx, err := c.Conn.Begin(c.Context())
|
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")
|
return RejectRequest(c, "Password confirmation doesn't match password")
|
||||||
}
|
}
|
||||||
|
|
||||||
hashed, err := auth.HashPassword(password)
|
hashed := auth.HashPassword(password)
|
||||||
if err != nil {
|
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password"))
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Update user's password and delete reset token")
|
c.Perf.StartBlock("SQL", "Update user's password and delete reset token")
|
||||||
tx, err := c.Conn.Begin(c.Context())
|
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
|
// re-hash and save the user's password if necessary
|
||||||
if hashed.IsOutdated() {
|
if hashed.IsOutdated() {
|
||||||
newHashed, err := auth.HashPassword(password)
|
newHashed := auth.HashPassword(password)
|
||||||
if err == nil {
|
|
||||||
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
|
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger.Error().Err(err).Msg("failed to update user's password")
|
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
|
// If errors happen here, we can still continue with logging them in
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,9 +2,7 @@ package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/auth"
|
"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/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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 {
|
func DiscordOAuthCallback(c *RequestContext) ResponseData {
|
||||||
query := c.Req.URL.Query()
|
query := c.Req.URL.Query()
|
||||||
|
|
||||||
|
@ -95,14 +39,19 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for error values and redirect back to ????
|
// Check for error values and redirect back to ????
|
||||||
if query.Get("error") != "" {
|
if errCode := query.Get("error"); errCode != "" {
|
||||||
// TODO: actually handle these errors
|
// 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 ????
|
// Do the actual token exchange and redirect back to ????
|
||||||
code := query.Get("code")
|
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 {
|
if err != nil {
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to exchange Discord authorization code"))
|
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 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 {
|
func DiscordUnlink(c *RequestContext) ResponseData {
|
||||||
|
@ -159,7 +108,7 @@ func DiscordUnlink(c *RequestContext) ResponseData {
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
return c.Redirect(hmnurl.BuildDiscordTest(), http.StatusSeeOther)
|
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
|
||||||
} else {
|
} else {
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get Discord user for unlink"))
|
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")
|
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
|
package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha1"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -142,47 +139,6 @@ func PodcastEditSubmit(c *RequestContext) ResponseData {
|
||||||
if len(strings.TrimSpace(description)) == 0 {
|
if len(strings.TrimSpace(description)) == 0 {
|
||||||
return RejectRequest(c, "Podcast description is empty")
|
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")
|
c.Perf.StartBlock("SQL", "Updating podcast")
|
||||||
tx, err := c.Conn.Begin(c.Context())
|
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"))
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
||||||
}
|
}
|
||||||
defer tx.Rollback(c.Context())
|
defer tx.Rollback(c.Context())
|
||||||
if imageFilename != "" {
|
|
||||||
hasher := sha1.New()
|
imageId, err := SaveImageFile(c, tx, "podcast_image", maxFileSize, fmt.Sprintf("podcast/%s/logo%d", c.CurrentProject.Slug, time.Now().UTC().Unix()))
|
||||||
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 {
|
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(),
|
_, err = tx.Exec(c.Context(),
|
||||||
`
|
`
|
||||||
UPDATE handmade_podcast
|
UPDATE handmade_podcast
|
||||||
|
|
|
@ -104,7 +104,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
// CSRF mitigation actions per the OWASP cheat sheet:
|
// CSRF mitigation actions per the OWASP cheat sheet:
|
||||||
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
||||||
return func(c *RequestContext) ResponseData {
|
return func(c *RequestContext) ResponseData {
|
||||||
c.Req.ParseForm()
|
c.Req.ParseMultipartForm(100 * 1024 * 1024)
|
||||||
csrfToken := c.Req.Form.Get(auth.CSRFFieldName)
|
csrfToken := c.Req.Form.Get(auth.CSRFFieldName)
|
||||||
if csrfToken != c.CurrentSession.CSRFToken {
|
if csrfToken != c.CurrentSession.CSRFToken {
|
||||||
c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed CSRF validation - potential attack?")
|
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.RegexPodcastEpisode, PodcastEpisode)
|
||||||
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
|
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
|
||||||
|
|
||||||
mainRoutes.GET(hmnurl.RegexDiscordTest, authMiddleware(DiscordTest)) // TODO: Delete this route
|
|
||||||
mainRoutes.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
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.RegexProjectCSS, ProjectCSS)
|
||||||
mainRoutes.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
|
mainRoutes.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
|
||||||
|
|
|
@ -2,14 +2,21 @@ package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"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/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/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
|
"github.com/jackc/pgx/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserProfileTemplateData struct {
|
type UserProfileTemplateData struct {
|
||||||
|
@ -233,3 +240,270 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
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