Merge branch 'master' of gitssh.handmade.network:hmn/hmn

This commit is contained in:
Asaf Gartner 2021-08-28 10:29:35 +03:00
commit b29ae69a25
51 changed files with 3192 additions and 564 deletions

5
go.mod
View File

@ -7,6 +7,11 @@ require (
github.com/Masterminds/semver v1.5.0 // indirect
github.com/Masterminds/sprig v2.22.0+incompatible
github.com/alecthomas/chroma v0.9.2
github.com/aws/aws-sdk-go-v2 v1.8.1
github.com/aws/aws-sdk-go-v2/config v1.6.1
github.com/aws/aws-sdk-go-v2/credentials v1.3.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.13.0
github.com/aws/smithy-go v1.7.0
github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8
github.com/go-stack/stack v1.8.0
github.com/google/uuid v1.2.0

30
go.sum
View File

@ -40,6 +40,30 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aws/aws-sdk-go-v2 v1.8.1 h1:GcFgQl7MsBygmeeqXyV1ivrTEmsVz/rdFJaTcltG9ag=
github.com/aws/aws-sdk-go-v2 v1.8.1/go.mod h1:xEFuWz+3TYdlPRuo+CqATbeDWIWyaT5uAPwPaWtgse0=
github.com/aws/aws-sdk-go-v2/config v1.6.1 h1:qrZINaORyr78syO1zfD4l7r4tZjy0Z1l0sy4jiysyOM=
github.com/aws/aws-sdk-go-v2/config v1.6.1/go.mod h1:t/y3UPu0XEDy0cEw6mvygaBQaPzWiYAxfP2SzgtvclA=
github.com/aws/aws-sdk-go-v2/credentials v1.3.3 h1:A13QPatmUl41SqUfnuT3V0E3XiNGL6qNTOINbE8cZL4=
github.com/aws/aws-sdk-go-v2/credentials v1.3.3/go.mod h1:oVieKMT3m9BSfqhOfuQ+E0j/yN84ZAJ7Qv8Sfume/ak=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.4.1 h1:rc+fRGvlKbeSd9IFhFS1KWBs0XjTkq0CfK5xqyLgIp0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.4.1/go.mod h1:+GTydg3uHmVlQdkRoetz6VHKbOMEYof70m19IpMLifc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.2.1 h1:IkqRRUZTKaS16P2vpX+FNc2jq3JWa3c478gykQp4ow4=
github.com/aws/aws-sdk-go-v2/internal/ini v1.2.1/go.mod h1:Pv3WenDjI0v2Jl7UaMFIIbPOBbhn33RmmAmGgkXDoqY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.2.2 h1:YcGVEqLQGHDa81776C3daai6ZkkRGf/8RAQ07hV0QcU=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.2.2/go.mod h1:EASdTcM1lGhUe1/p4gkojHwlGJkeoRjjr1sRCzup3Is=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.2.3 h1:VxFCgxsqWe7OThOwJ5IpFX3xrObtuIH9Hg/NW7oot1Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.2.3/go.mod h1:7gcsONBmFoCcKrAqrm95trrMd2+C/ReYKP7Vfu8yHHA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.5.3 h1:7tPSbUWzuoMJ2woUKgOfIPuZS88hMdFHJBBB2vR0bHI=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.5.3/go.mod h1:/ugW3qFkJe/h7sNtI6/zJnwRbvavs6GyOid69uI9eek=
github.com/aws/aws-sdk-go-v2/service/s3 v1.13.0 h1:2oMLrNpOSpkDTocIVv3Fut1XrmlbKPlgnnYMGYqFp0Y=
github.com/aws/aws-sdk-go-v2/service/s3 v1.13.0/go.mod h1:Tzxhu3GnCpj45WJqXyxcLF2gUHzTcmY7CzpQ9x9KVls=
github.com/aws/aws-sdk-go-v2/service/sso v1.3.3 h1:K2gCnGvAASpz+jqP9iyr+F/KNjmTYf8aWOtTQzhmZ5w=
github.com/aws/aws-sdk-go-v2/service/sso v1.3.3/go.mod h1:Jgw5O+SK7MZ2Yi9Yvzb4PggAPYaFSliiQuWR0hNjexk=
github.com/aws/aws-sdk-go-v2/service/sts v1.6.2 h1:l504GWCoQi1Pk68vSUFGLmDIEMzRfVGNgLakDK+Uj58=
github.com/aws/aws-sdk-go-v2/service/sts v1.6.2/go.mod h1:RBhoMJB8yFToaCnbe0jNq5Dcdy0jp6LhHqg55rjClkM=
github.com/aws/smithy-go v1.7.0 h1:+cLHMRrDZvQ4wk+KuQ9yH6eEg6KZEJ9RI2IkDqnygCg=
github.com/aws/smithy-go v1.7.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
@ -92,6 +116,9 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@ -185,6 +212,8 @@ github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3 h1:JnPg/5Q9xVJGfjsO5CPUOjnJps1JaRUm8I9FXVCFK94=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
@ -484,6 +513,7 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

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

@ -0,0 +1,95 @@
function TabState(tabbed) {
this.container = tabbed;
this.tabs = tabbed.querySelector(".tab");
this.tabbar = document.createElement("div");
this.tabbar.classList.add("tab-bar");
this.container.insertBefore(this.tabbar, this.container.firstChild);
this.current_i = -1;
this.tab_buttons = [];
}
function switch_tab_old(state, tab_i) {
return function() {
if (state.current_i >= 0) {
state.tabs[state.current_i].classList.add("hidden");
state.tab_buttons[state.current_i].classList.remove("current");
}
state.tabs[tab_i].classList.remove("hidden");
state.tab_buttons[tab_i].classList.add("current");
var hash = "";
if (state.tabs[tab_i].hasAttribute("data-url-hash")) {
hash = state.tabs[tab_i].getAttribute("data-url-hash");
}
window.location.hash = hash;
state.current_i = tab_i;
};
}
document.addEventListener("DOMContentLoaded", function() {
const tabContainers = document.getElementsByClassName("tabbed");
for (const container of tabContainers) {
const tabBar = document.createElement("div");
tabBar.classList.add("tab-bar");
container.insertAdjacentElement('afterbegin', tabBar);
const tabs = container.querySelectorAll(".tab");
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
tab.classList.toggle('dn', i > 0);
const slug = tab.getAttribute("data-slug");
// TODO: Should this element be a link?
const tabButton = document.createElement("div");
tabButton.classList.add("tab-button");
tabButton.classList.toggle("current", i === 0);
tabButton.innerText = tab.getAttribute("data-name");
tabButton.setAttribute("data-slug", slug);
tabButton.addEventListener("click", () => {
switchTab(container, slug);
});
tabBar.appendChild(tabButton);
}
const initialSlug = window.location.hash;
if (initialSlug) {
switchTab(container, initialSlug.substring(1));
}
}
});
function switchTab(container, slug) {
const tabs = container.querySelectorAll('.tab');
let didMatch = false;
for (const tab of tabs) {
const slugMatches = tab.getAttribute("data-slug") === slug;
tab.classList.toggle('dn', !slugMatches);
// TODO: Also update the tab button styles
if (slugMatches) {
didMatch = true;
}
}
const tabButtons = document.querySelectorAll(".tab-button");
for (const tabButton of tabButtons) {
const buttonSlug = tabButton.getAttribute("data-slug");
tabButton.classList.toggle('current', slug === buttonSlug);
}
if (!didMatch) {
// switch to first tab as a fallback
tabs[0].classList.remove('dn');
tabButtons[0].classList.add('current');
}
window.location.hash = slug;
}

View File

@ -1994,7 +1994,7 @@ img, video {
-l = large
*/
.flex {
.flex, .tab-bar, .edit-form .edit-form-row {
display: flex; }
.inline-flex {
@ -2012,10 +2012,10 @@ img, video {
.flex-none {
flex: none; }
.flex-column {
.flex-column, .edit-form .edit-form-row {
flex-direction: column; }
.flex-row {
.flex-row, .tab-bar {
flex-direction: row; }
.flex-wrap {
@ -2126,13 +2126,13 @@ img, video {
.order-last {
order: 99999; }
.flex-grow-0 {
.flex-grow-0, .edit-form .edit-form-row > :first-child {
flex-grow: 0; }
.flex-grow-1 {
.flex-grow-1, .edit-form .edit-form-row > :nth-child(2) {
flex-grow: 1; }
.flex-shrink-0 {
.flex-shrink-0, .edit-form .edit-form-row > :first-child {
flex-shrink: 0; }
.flex-shrink-1 {
@ -2153,7 +2153,7 @@ img, video {
flex: none; }
.flex-column-ns {
flex-direction: column; }
.flex-row-ns {
.flex-row-ns, .edit-form .edit-form-row {
flex-direction: row; }
.flex-wrap-ns {
flex-wrap: wrap; }
@ -2771,7 +2771,7 @@ code, .code {
.h2 {
height: 2rem; }
.h3 {
.h3, .edit-form textarea {
height: 4rem; }
.h4 {
@ -3079,7 +3079,7 @@ code, .code {
*/
/* Max Width Percentages */
.mw-100 {
.mw-100, .edit-form textarea {
max-width: 100%; }
/* Max Width Scale */
@ -3125,7 +3125,7 @@ code, .code {
max-width: 4rem; }
.mw4-ns {
max-width: 8rem; }
.mw5-ns {
.mw5-ns, .edit-form input[type=text] {
max-width: 16rem; }
.mw6-ns {
max-width: 32rem; }
@ -3243,6 +3243,9 @@ code, .code {
.w5 {
width: 16rem; }
.w6 {
width: 32rem; }
.w-10 {
width: 10%; }
@ -3282,7 +3285,7 @@ code, .code {
.w-90 {
width: 90%; }
.w-100 {
.w-100, .edit-form .edit-form-row > :first-child, .edit-form input[type=text], .edit-form textarea {
width: 100%; }
.w-third {
@ -3301,10 +3304,12 @@ code, .code {
width: 2rem; }
.w3-ns {
width: 4rem; }
.w4-ns {
.w4-ns, .edit-form .edit-form-row > :first-child {
width: 8rem; }
.w5-ns {
width: 16rem; }
.w6-ns, .edit-form textarea {
width: 32rem; }
.w-10-ns {
width: 10%; }
.w-20-ns {
@ -3351,6 +3356,8 @@ code, .code {
width: 8rem; }
.w5-m {
width: 16rem; }
.w6-m {
width: 32rem; }
.w-10-m {
width: 10%; }
.w-20-m {
@ -3397,6 +3404,8 @@ code, .code {
width: 8rem; }
.w5-l {
width: 16rem; }
.w6-l {
width: 32rem; }
.w-10-l {
width: 10%; }
.w-20-l {
@ -3445,7 +3454,7 @@ code, .code {
.overflow-visible {
overflow: visible; }
.overflow-hidden {
.overflow-hidden, .edit-form .edit-form-row > :nth-child(2) {
overflow: hidden; }
.overflow-scroll {
@ -4614,7 +4623,7 @@ code, .code {
.pl7 {
padding-left: 16rem; }
.pr0 {
.pr0, .edit-form .edit-form-row > :first-child {
padding-right: 0; }
.pr1 {
@ -4641,7 +4650,7 @@ code, .code {
.pb0 {
padding-bottom: 0; }
.pb1 {
.pb1, .edit-form .edit-form-row > :first-child {
padding-bottom: 0.25rem; }
.pb2 {
@ -4698,7 +4707,7 @@ code, .code {
padding-top: 0.25rem;
padding-bottom: 0.25rem; }
.pv2, header .menu-bar .items a.project-logo,
.pv2, header .menu-bar .items a.project-logo, .tab-bar .tab-button,
button,
.button,
input[type=button],
@ -4742,7 +4751,7 @@ input[type=submit] {
padding-left: 0.5rem;
padding-right: 0.5rem; }
.ph3,
.ph3, .tab-bar .tab-button,
button,
.button,
input[type=button],
@ -4898,7 +4907,7 @@ input[type=submit] {
margin-top: 0.5rem;
margin-bottom: 0.5rem; }
.mv3, hr {
.mv3, hr, .edit-form .edit-form-row {
margin-top: 1rem;
margin-bottom: 1rem; }
@ -4987,7 +4996,7 @@ input[type=submit] {
padding-right: 0; }
.pr1-ns {
padding-right: 0.25rem; }
.pr2-ns {
.pr2-ns, .edit-form .edit-form-row > :first-child {
padding-right: 0.5rem; }
.pr3-ns {
padding-right: 1rem; }
@ -4999,7 +5008,7 @@ input[type=submit] {
padding-right: 8rem; }
.pr7-ns {
padding-right: 16rem; }
.pb0-ns {
.pb0-ns, .edit-form .edit-form-row > :first-child {
padding-bottom: 0; }
.pb1-ns {
padding-bottom: 0.25rem; }
@ -6169,7 +6178,7 @@ input[type=submit] {
-l = large
*/
.tl {
.tl, .edit-form .edit-form-row > :first-child {
text-align: left; }
.tr {
@ -6184,7 +6193,7 @@ input[type=submit] {
@media screen and (min-width: 30em) {
.tl-ns {
text-align: left; }
.tr-ns {
.tr-ns, .edit-form .edit-form-row > :first-child {
text-align: right; }
.tc-ns {
text-align: center; }
@ -7204,7 +7213,7 @@ body {
min-height: 100vh;
box-sizing: border-box;
font-size: 0.875rem;
line-height: 1.5em;
line-height: 1.2em;
font-weight: 400; }
a {
@ -7321,10 +7330,10 @@ article code {
margin-left: auto;
margin-right: auto; }
.flex-shrink-0 {
.flex-shrink-0, .edit-form .edit-form-row > :first-child {
flex-shrink: 0; }
.flex-grow-1 {
.flex-grow-1, .edit-form .edit-form-row > :nth-child(2) {
flex-grow: 1; }
.flex-fair {
@ -7780,32 +7789,20 @@ header {
.tab-bar {
border-color: #d8d8d8;
border-color: var(--tab-border-color);
width: 100%;
border-bottom-width: 1px;
border-bottom-style: solid;
box-sizing: border-box; }
width: 100%; }
.tab-bar .tab-button {
background-color: #dfdfdf;
background-color: var(--tab-button-background);
border-color: #d8d8d8;
border-color: var(--tab-border-color);
height: 100%;
display: inline-block;
padding: 10px 15px;
line-height: 100%;
cursor: pointer;
border-width: 1px;
border-style: solid;
box-sizing: border-box; }
cursor: pointer; }
.tab-bar .tab-button:hover {
background-color: #efefef;
background-color: var(--tab-button-background-hover); }
.tab-bar .tab-button.current {
background-color: #fff;
background-color: var(--tab-button-background-current);
border-bottom-color: transparent;
font-weight: bold;
height: 105%; }
font-weight: 500; }
.pagination .page.current {
cursor: default;
@ -8016,81 +8013,12 @@ pre {
max-height: calc(100vh - 20rem);
overflow: auto; } }
.edit-form .error {
margin-left: 5em;
padding: 10px;
color: red; }
.edit-form .edit-form-row > :first-child {
font-weight: 500; }
.edit-form input[type=text] {
min-width: 20em; }
.edit-form textarea {
font-size: 13pt; }
.edit-form .note {
margin-bottom: 5px;
font-style: italic;
font-size: 90%; }
.edit-form .links {
width: 80%;
min-height: 200px;
height: 15vh; }
.edit-form .half {
padding: 10px;
text-align: center; }
.edit-form table {
width: 95%;
margin: auto;
border-collapse: separate;
border-spacing: 0px 10px; }
.edit-form table td {
padding-bottom: 15px;
width: 90%; }
.edit-form table td.half {
width: 50%; }
.edit-form table td table {
width: 100%; }
.edit-form th {
text-align: right;
font-weight: bold;
padding-right: 10px;
padding-bottom: 15px;
vertical-align: top;
max-width: 5em; }
.edit-form td table th {
text-align: left; }
.edit-form .page-options label {
font-weight: bold;
margin-right: 20px; }
.edit-form.profile-edit .longbio {
width: 100%;
min-height: 400px;
height: 30vh; }
.edit-form.profile-edit .avatar-preview {
border: 1px solid transparent;
margin: 10px;
margin-bottom: 0px; }
.edit-form.profile-edit textarea.shortbio,
.edit-form.profile-edit textarea.signature {
min-width: 300px;
width: 50%;
min-height: 100px;
height: 4em; }
.edit-form.profile-edit .logo-preview {
border-color: #999;
border-color: var(--project-edit-logo-previw-border-color);
width: 200px;
border-width: 1px; }
@media screen and (min-width: 30em) {
.edit-form .edit-form-row .pt-input-ns {
padding-top: 0.3rem; } }
.edit-form.project-edit .project_description {
width: 100%;
@ -8103,14 +8031,10 @@ pre {
width: 50%; }
.edit-form.project-edit .quota-bar {
border-color: #999;
border-color: var(--project-edit-quota-bar-border-color);
width: 500px;
border-width: 1px;
margin-bottom: 10px; }
.edit-form.project-edit .quota-bar .quota-filled {
background-color: #444;
background-color: var(--project-edit-quota-bar-filled-background);
height: 100%; }
.episode-list .description p {
@ -8361,6 +8285,7 @@ nav.timecodes {
input[type=text],
input[type=password],
input[type=email],
textarea,
select {
color: black;
@ -8375,6 +8300,7 @@ select {
outline: none; }
input[type=text].lite,
input[type=password].lite,
input[type=email].lite,
textarea.lite,
select.lite {
background-color: transparent;
@ -8386,6 +8312,8 @@ select {
input[type=text].lite:focus, input[type=text].lite:active,
input[type=password].lite:focus,
input[type=password].lite:active,
input[type=email].lite:focus,
input[type=email].lite:active,
textarea.lite:focus,
textarea.lite:active,
select.lite:focus,
@ -8396,6 +8324,8 @@ select {
input[type=text]:active, input[type=text]:focus,
input[type=password]:active,
input[type=password]:focus,
input[type=email]:active,
input[type=email]:focus,
textarea:active,
textarea:focus,
select:active,
@ -8405,14 +8335,19 @@ select {
border-color: #4c9ed9;
border-color: var(--form-text-border-color-active); }
input[type=text]:not(.lite), input[type=password]:not(.lite) {
padding: 5px; }
input[type=text]:not(.lite),
input[type=password]:not(.lite),
input[type=email]:not(.lite) {
padding: 0.3rem; }
textarea {
padding: 0.3rem; }
form .note {
font-style: italic; }
select {
padding: 5px 10px; }
padding: 0.3rem 0.6rem; }
option[selected] {
font-weight: bold; }

View File

@ -237,9 +237,6 @@ will throw an error.
--project-card-border-color: #333;
--project-user-suggestions-background: #222;
--project-user-suggestions-border-color: #444;
--project-edit-logo-previw-border-color: #444;
--project-edit-quota-bar-border-color: #444;
--project-edit-quota-bar-filled-background: #888;
--notice-text-color: #eee;
--notice-unapproved-color: #7a2020;
--notice-hidden-color: #494949;

View File

@ -255,9 +255,6 @@ will throw an error.
--project-card-border-color: #aaa;
--project-user-suggestions-background: #fff;
--project-user-suggestions-border-color: #ddd;
--project-edit-logo-previw-border-color: #999;
--project-edit-quota-bar-border-color: #999;
--project-edit-quota-bar-filled-background: #444;
--notice-text-color: #fff;
--notice-unapproved-color: #b42222;
--notice-hidden-color: #b6b6b6;

View File

@ -51,10 +51,7 @@ func init() {
}
}
hashedPassword, err := auth.HashPassword(password)
if err != nil {
panic(err)
}
hashedPassword := auth.HashPassword(password)
err = auth.UpdatePassword(ctx, conn, canonicalUsername, hashedPassword)
if err != nil {

155
src/assets/assets.go Normal file
View File

@ -0,0 +1,155 @@
package assets
import (
"bytes"
"context"
"crypto/sha1"
"errors"
"fmt"
"regexp"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
"github.com/google/uuid"
)
var client *s3.Client
func init() {
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(
config.Config.DigitalOcean.AssetsSpacesKey,
config.Config.DigitalOcean.AssetsSpacesSecret,
"",
),
),
awsconfig.WithRegion(config.Config.DigitalOcean.AssetsSpacesRegion),
awsconfig.WithEndpointResolver(aws.EndpointResolverFunc(func(service, region string) (aws.Endpoint, error) {
return aws.Endpoint{
URL: config.Config.DigitalOcean.AssetsSpacesEndpoint,
}, nil
})),
)
if err != nil {
panic(err)
}
client = s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
})
}
type CreateInput struct {
Content []byte
Filename string
ContentType string
// Optional params
UploaderID *int // HMN user id
Width, Height int
}
var REIllegalFilenameChars = regexp.MustCompile(`[^\w \-.]`)
func SanitizeFilename(filename string) string {
if filename == "" {
return "unnamed"
}
return REIllegalFilenameChars.ReplaceAllString(filename, "")
}
func AssetKey(id, filename string) string {
return fmt.Sprintf("%s%s/%s", config.Config.DigitalOcean.AssetsPathPrefix, id, filename)
}
type InvalidAssetError error
func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.Asset, error) {
filename := SanitizeFilename(in.Filename)
if len(in.Content) == 0 {
return nil, InvalidAssetError(fmt.Errorf("could not upload asset '%s': no bytes of data were provided", filename))
}
if in.ContentType == "" {
return nil, InvalidAssetError(fmt.Errorf("could not upload asset '%s': no content type provided", filename))
}
// Upload the asset to the DO space
id := uuid.New()
key := AssetKey(id.String(), filename)
checksum := fmt.Sprintf("%x", sha1.Sum(in.Content))
upload := func() error {
_, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
Key: &key,
Body: bytes.NewReader(in.Content),
ACL: types.ObjectCannedACLPublicRead,
})
return err
}
err := upload()
if err != nil {
var apiError smithy.APIError
if errors.As(err, &apiError) && apiError.ErrorCode() == "NoSuchBucket" {
_, err := client.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
})
if err != nil {
return nil, oops.New(err, "failed to create assets bucket")
}
err = upload()
if err != nil {
return nil, oops.New(err, "failed to upload asset")
}
} else {
return nil, oops.New(err, "failed to upload asset")
}
}
// Save a record in our database
// TODO(db): Would be convient to use RETURNING here...
_, err = dbConn.Exec(ctx,
`
INSERT INTO handmade_asset (id, s3_key, filename, size, mime_type, sha1sum, width, height, uploader_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`,
id,
key,
filename,
len(in.Content),
in.ContentType,
checksum,
in.Width,
in.Height,
in.UploaderID,
)
if err != nil {
return nil, oops.New(err, "failed to save asset record")
}
// Fetch and return the new record
iasset, err := db.QueryOne(ctx, dbConn, models.Asset{},
`
SELECT $columns
FROM handmade_asset
WHERE id = $1
`,
id,
)
if err != nil {
return nil, oops.New(err, "failed to fetch newly-created asset")
}
return iasset.(*models.Asset), nil
}

13
src/assets/assets_test.go Normal file
View File

@ -0,0 +1,13 @@
package assets
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSanitizeFilename(t *testing.T) {
assert.Equal(t, "cool filename.txt.wow", SanitizeFilename("cool filename.txt.wow"))
assert.Equal(t, " hi doggy ", SanitizeFilename("😎 hi doggy 🐶"))
assert.Equal(t, "newlinesaretotallylegal", SanitizeFilename("newlines\naretotallylegal"))
}

View File

@ -13,6 +13,7 @@ import (
"strings"
"time"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
@ -150,15 +151,12 @@ func CheckPassword(password string, hashedPassword HashedPassword) (bool, error)
}
}
func HashPassword(password string) (HashedPassword, error) {
func HashPassword(password string) HashedPassword {
// Follows the OWASP recommendations as of March 2021.
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
salt := make([]byte, saltLength)
_, err := io.ReadFull(rand.Reader, salt)
if err != nil {
return HashedPassword{}, oops.New(err, "failed to generate salt")
}
io.ReadFull(rand.Reader, salt)
saltEnc := base64.StdEncoding.EncodeToString(salt)
cfg := Argon2idConfig{
@ -176,12 +174,12 @@ func HashPassword(password string) (HashedPassword, error) {
AlgoConfig: cfg.String(),
Salt: saltEnc,
Hash: keyEnc,
}, nil
}
}
var ErrUserDoesNotExist = errors.New("user does not exist")
func UpdatePassword(ctx context.Context, conn *pgxpool.Pool, username string, hp HashedPassword) error {
func UpdatePassword(ctx context.Context, conn db.ConnOrTx, username string, hp HashedPassword) error {
tag, err := conn.Exec(ctx, "UPDATE auth_user SET password = $1 WHERE username = $2", hp.String(), username)
if err != nil {
return oops.New(err, "failed to update password")

View File

@ -11,6 +11,7 @@ import (
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/google/uuid"
"github.com/jackc/pgconn"
"github.com/jackc/pgtype"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/log/zerologadapter"
@ -57,6 +58,8 @@ func typeIsQueryable(t reflect.Type) bool {
// This interface should match both a direct pgx connection or a pgx transaction.
type ConnOrTx interface {
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error)
}
var connInfo = pgtype.NewConnInfo()

30
src/discord/cmd/cmd.go Normal file
View File

@ -0,0 +1,30 @@
package cmd
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/discord"
"git.handmade.network/hmn/hmn/src/website"
"github.com/spf13/cobra"
)
func init() {
scrapeCommand := &cobra.Command{
Use: "discordscrapechannel [<channel id>...]",
Short: "Scrape the entire history of Discord channels",
Long: "Scrape the entire history of Discord channels, saving message content (but not creating snippets)",
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
conn := db.NewConnPool(1, 1)
defer conn.Close()
for _, channelID := range args {
discord.Scrape(ctx, conn, channelID, time.Time{}, false)
}
},
}
website.WebsiteCommand.AddCommand(scrapeCommand)
}

View File

@ -93,7 +93,7 @@ func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) <-chan struct{} {
var outgoingMessagesReady = make(chan struct{}, 1)
type discordBotInstance struct {
type botInstance struct {
conn *websocket.Conn
dbConn *pgxpool.Pool
@ -116,8 +116,8 @@ type discordBotInstance struct {
wg sync.WaitGroup
}
func newBotInstance(dbConn *pgxpool.Pool) *discordBotInstance {
return &discordBotInstance{
func newBotInstance(dbConn *pgxpool.Pool) *botInstance {
return &botInstance{
dbConn: dbConn,
forceHeartbeat: make(chan struct{}),
didAckHeartbeat: true,
@ -129,7 +129,7 @@ Runs a bot instance to completion. It will start up a gateway connection and ret
connection is closed. It only returns an error when something unexpected occurs; if so, you should
do exponential backoff before reconnecting. Otherwise you can reconnect right away.
*/
func (bot *discordBotInstance) Run(ctx context.Context) (err error) {
func (bot *botInstance) Run(ctx context.Context) (err error) {
defer utils.RecoverPanicAsError(&err)
ctx, bot.cancel = context.WithCancel(ctx)
@ -223,7 +223,7 @@ and RESUMED messages in our main message receiving loop instead of here.
That way, we could receive exactly one message after sending Resume, either a Resume ACK or an
Invalid Session, and from there it would be crystal clear what to do. Alas!)
*/
func (bot *discordBotInstance) connect(ctx context.Context) error {
func (bot *botInstance) connect(ctx context.Context) error {
res, err := GetGatewayBot(ctx)
if err != nil {
return oops.New(err, "failed to get gateway URL")
@ -328,7 +328,7 @@ func (bot *discordBotInstance) connect(ctx context.Context) error {
Sends outgoing gateway messages and channel messages. Handles heartbeats. This function should be
run as its own goroutine.
*/
func (bot *discordBotInstance) doSender(ctx context.Context) {
func (bot *botInstance) doSender(ctx context.Context) {
defer bot.wg.Done()
defer bot.cancel()
@ -507,7 +507,7 @@ func (bot *discordBotInstance) doSender(ctx context.Context) {
}
}
func (bot *discordBotInstance) receiveGatewayMessage(ctx context.Context) (*GatewayMessage, error) {
func (bot *botInstance) receiveGatewayMessage(ctx context.Context) (*GatewayMessage, error) {
_, msgBytes, err := bot.conn.ReadMessage()
if err != nil {
return nil, err
@ -524,7 +524,7 @@ func (bot *discordBotInstance) receiveGatewayMessage(ctx context.Context) (*Gate
return &msg, nil
}
func (bot *discordBotInstance) sendGatewayMessage(ctx context.Context, msg GatewayMessage) error {
func (bot *botInstance) sendGatewayMessage(ctx context.Context, msg GatewayMessage) error {
logging.ExtractLogger(ctx).Debug().Interface("msg", msg).Msg("sending gateway message")
return bot.conn.WriteMessage(websocket.TextMessage, msg.ToJSON())
}
@ -534,7 +534,7 @@ Processes a single event message from Discord. If this returns an error, it mean
really gone wrong, bad enough that the connection should be shut down. Otherwise it will just log
any errors that occur.
*/
func (bot *discordBotInstance) processEventMsg(ctx context.Context, msg *GatewayMessage) error {
func (bot *botInstance) processEventMsg(ctx context.Context, msg *GatewayMessage) error {
if msg.Opcode != OpcodeDispatch {
panic(fmt.Sprintf("processEventMsg must only be used on Dispatch messages (opcode %d). Validate this before you call this function.", OpcodeDispatch))
}
@ -557,13 +557,25 @@ func (bot *discordBotInstance) processEventMsg(ctx context.Context, msg *Gateway
if err != nil {
return oops.New(err, "error on updated message")
}
case "MESSAGE_DELETE":
bot.messageDelete(ctx, MessageDeleteFromMap(msg.Data))
case "MESSAGE_BULK_DELETE":
bulkDelete := MessageBulkDeleteFromMap(msg.Data)
for _, id := range bulkDelete.IDs {
bot.messageDelete(ctx, MessageDelete{
ID: id,
ChannelID: bulkDelete.ChannelID,
GuildID: bulkDelete.GuildID,
})
}
}
return nil
}
func (bot *discordBotInstance) messageCreateOrUpdate(ctx context.Context, msg *Message) error {
if msg.Author != nil && msg.Author.ID == config.Config.Discord.BotUserID {
// TODO: Should this return an error? Or just log errors?
func (bot *botInstance) messageCreateOrUpdate(ctx context.Context, msg *Message) error {
if msg.OriginalHasFields("author") && msg.Author.ID == config.Config.Discord.BotUserID {
// Don't process your own messages
return nil
}
@ -587,6 +599,78 @@ func (bot *discordBotInstance) messageCreateOrUpdate(ctx context.Context, msg *M
return nil
}
func (bot *botInstance) messageDelete(ctx context.Context, msgDelete MessageDelete) {
log := logging.ExtractLogger(ctx)
tx, err := bot.dbConn.Begin(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to start transaction")
return
}
defer tx.Rollback(ctx)
type deleteMessageQuery struct {
Message models.DiscordMessage `db:"msg"`
DiscordUser *models.DiscordUser `db:"duser"`
HMNUser *models.User `db:"hmnuser"`
SnippetID *int `db:"snippet.id"`
}
iresult, err := db.QueryOne(ctx, tx, deleteMessageQuery{},
`
SELECT $columns
FROM
handmade_discordmessage AS msg
LEFT JOIN handmade_discorduser AS duser ON msg.user_id = duser.userid
LEFT JOIN auth_user AS hmnuser ON duser.hmn_user_id = hmnuser.id
LEFT JOIN handmade_snippet AS snippet ON snippet.discord_message_id = msg.id
WHERE msg.id = $1 AND msg.channel_id = $2
`,
msgDelete.ID, msgDelete.ChannelID,
)
if errors.Is(err, db.ErrNoMatchingRows) {
return
} else if err != nil {
log.Error().Err(err).Msg("failed to check for message to delete")
return
}
result := iresult.(*deleteMessageQuery)
log.Debug().Msg("deleting Discord message")
_, err = tx.Exec(ctx,
`
DELETE FROM handmade_discordmessage
WHERE id = $1 AND channel_id = $2
`,
msgDelete.ID,
msgDelete.ChannelID,
)
shouldDeleteSnippet := result.HMNUser != nil && result.HMNUser.DiscordDeleteSnippetOnMessageDelete
if result.SnippetID != nil && shouldDeleteSnippet {
log.Debug().
Int("snippet_id", *result.SnippetID).
Int("user_id", result.HMNUser.ID).
Msg("deleting snippet from Discord message")
_, err = tx.Exec(ctx,
`
DELETE FROM handmade_snippet
WHERE id = $1
`,
result.SnippetID,
)
if err != nil {
log.Error().Err(err).Msg("failed to delete snippet")
return
}
}
err = tx.Commit(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to delete Discord message")
return
}
}
type MessageToSend struct {
ChannelID string
Req CreateMessageRequest

211
src/discord/history.go Normal file
View File

@ -0,0 +1,211 @@
package discord
import (
"context"
"errors"
"time"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
)
func RunHistoryWatcher(ctx context.Context, dbConn *pgxpool.Pool) <-chan struct{} {
log := logging.ExtractLogger(ctx).With().Str("discord goroutine", "history watcher").Logger()
ctx = logging.AttachLoggerToContext(&log, ctx)
done := make(chan struct{})
go func() {
defer func() {
log.Debug().Msg("shut down Discord history watcher")
done <- struct{}{}
}()
backfillInterval := 1 * time.Hour
newUserTicker := time.NewTicker(5 * time.Second)
backfillTicker := time.NewTicker(backfillInterval)
lastBackfillTime := time.Now().Add(-backfillInterval)
for {
select {
case <-ctx.Done():
return
case <-newUserTicker.C:
// Get content for messages when a user links their account (but do not create snippets)
fetchMissingContent(ctx, dbConn)
case <-backfillTicker.C:
// Run a backfill to patch up places where the Discord bot missed (does create snippets)
Scrape(ctx, dbConn,
config.Config.Discord.ShowcaseChannelID,
lastBackfillTime,
true,
)
}
}
}()
return done
}
func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
log := logging.ExtractLogger(ctx)
type query struct {
Message models.DiscordMessage `db:"msg"`
}
result, err := db.Query(ctx, dbConn, query{},
`
SELECT $columns
FROM
handmade_discordmessage AS msg
JOIN handmade_discorduser AS duser ON msg.user_id = duser.userid -- only fetch messages for linked discord users
LEFT JOIN handmade_discordmessagecontent AS c ON c.message_id = msg.id
WHERE
c.last_content IS NULL
AND msg.guild_id = $1
ORDER BY msg.sent_at DESC
`,
config.Config.Discord.GuildID,
)
if err != nil {
log.Error().Err(err).Msg("failed to check for messages without content")
return
}
imessagesWithoutContent := result.ToSlice()
if len(imessagesWithoutContent) > 0 {
log.Info().Msgf("There are %d Discord messages without content, fetching their content now...", len(imessagesWithoutContent))
msgloop:
for _, imsg := range imessagesWithoutContent {
select {
case <-ctx.Done():
log.Info().Msg("Scrape was canceled")
break msgloop
default:
}
msg := imsg.(*query).Message
discordMsg, err := GetChannelMessage(ctx, msg.ChannelID, msg.ID)
if errors.Is(err, NotFound) {
// This message has apparently been deleted; delete it from our database
_, err = dbConn.Exec(ctx,
`
DELETE FROM handmade_discordmessage
WHERE id = $1
`,
msg.ID,
)
if err != nil {
log.Error().Err(err).Msg("failed to delete missing message")
continue
}
log.Info().Str("msg id", msg.ID).Msg("deleted missing Discord message")
continue
} else if err != nil {
log.Error().Err(err).Msg("failed to get message")
continue
}
log.Info().Str("msg", discordMsg.ShortString()).Msg("fetched message for content")
err = handleHistoryMessage(ctx, dbConn, discordMsg, false)
if err != nil {
log.Error().Err(err).Msg("failed to save content for message")
continue
}
}
log.Info().Msgf("Done fetching missing content")
}
}
func Scrape(ctx context.Context, dbConn *pgxpool.Pool, channelID string, earliestMessageTime time.Time, createSnippets bool) {
log := logging.ExtractLogger(ctx)
log.Info().Msg("Starting scrape")
defer log.Info().Msg("Done with scrape!")
before := ""
for {
msgs, err := GetChannelMessages(ctx, channelID, GetChannelMessagesInput{
Limit: 100,
Before: before,
})
if err != nil {
panic(err) // TODO
}
if len(msgs) == 0 {
logging.Debug().Msg("out of messages, stopping scrape")
return
}
for _, msg := range msgs {
select {
case <-ctx.Done():
log.Info().Msg("Scrape was canceled")
return
default:
}
log.Info().Str("msg", msg.ShortString()).Msg("")
if !earliestMessageTime.IsZero() && msg.Time().Before(earliestMessageTime) {
logging.ExtractLogger(ctx).Info().Time("earliest", earliestMessageTime).Msg("Saw a message before the specified earliest time; exiting")
return
}
err := handleHistoryMessage(ctx, dbConn, &msg, true)
if err != nil {
errLog := logging.ExtractLogger(ctx).Error()
if errors.Is(err, errNotEnoughInfo) {
errLog = logging.ExtractLogger(ctx).Warn()
}
errLog.Err(err).Msg("failed to process Discord message")
}
before = msg.ID
}
}
}
func handleHistoryMessage(ctx context.Context, dbConn *pgxpool.Pool, msg *Message, createSnippets bool) error {
var tx pgx.Tx
for {
var err error
tx, err = dbConn.Begin(ctx)
if err != nil {
logging.ExtractLogger(ctx).Warn().Err(err).Msg("failed to start transaction for message")
time.Sleep(1 * time.Second)
continue
}
break
}
newMsg, err := SaveMessageAndContents(ctx, tx, msg)
if err != nil {
return err
}
if createSnippets {
if doSnippet, err := AllowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil {
_, err := CreateMessageSnippet(ctx, tx, msg.ID)
if err != nil {
return err
}
} else if err != nil {
return err
}
}
err = tx.Commit(ctx)
if err != nil {
return err
}
return nil
}

45
src/discord/library.go Normal file
View File

@ -0,0 +1,45 @@
package discord
import (
"context"
"git.handmade.network/hmn/hmn/src/oops"
)
func (bot *botInstance) processLibraryMsg(ctx context.Context, msg *Message) error {
switch msg.Type {
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
default:
return nil
}
if !msg.OriginalHasFields("content") {
return nil
}
if !messageHasLinks(msg.Content) {
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
if err != nil {
return oops.New(err, "failed to delete message")
}
if !msg.Author.IsBot {
channel, err := CreateDM(ctx, msg.Author.ID)
if err != nil {
return oops.New(err, "failed to create DM channel")
}
err = SendMessages(ctx, bot.dbConn, MessageToSend{
ChannelID: channel.ID,
Req: CreateMessageRequest{
Content: "Posts in #the-library are required to have a link. Discuss library content in other relevant channels.",
},
})
if err != nil {
return oops.New(err, "failed to send showcase warning message")
}
}
}
return nil
}

147
src/discord/markdown.go Normal file
View File

@ -0,0 +1,147 @@
package discord
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/logging"
)
var (
REMarkdownUser = regexp.MustCompile(`<@([0-9]+)>`)
REMarkdownUserNickname = regexp.MustCompile(`<@!([0-9]+)>`)
REMarkdownChannel = regexp.MustCompile(`<#([0-9]+)>`)
REMarkdownRole = regexp.MustCompile(`<@&([0-9]+)>`)
REMarkdownCustomEmoji = regexp.MustCompile(`<a?:(\w+):[0-9]+>`) // includes animated
REMarkdownTimestamp = regexp.MustCompile(`<t:([0-9]+)(:([tTdDfFR]))?>`)
)
func CleanUpMarkdown(ctx context.Context, original string) string {
userMatches := REMarkdownUser.FindAllStringSubmatch(original, -1)
userNicknameMatches := REMarkdownUserNickname.FindAllStringSubmatch(original, -1)
channelMatches := REMarkdownChannel.FindAllStringSubmatch(original, -1)
roleMatches := REMarkdownRole.FindAllStringSubmatch(original, -1)
customEmojiMatches := REMarkdownCustomEmoji.FindAllStringSubmatch(original, -1)
timestampMatches := REMarkdownTimestamp.FindAllStringSubmatch(original, -1)
userIdsToFetch := map[string]struct{}{}
for _, m := range userMatches {
userIdsToFetch[m[1]] = struct{}{}
}
for _, m := range userNicknameMatches {
userIdsToFetch[m[1]] = struct{}{}
}
// do the requests, gathering the resulting data
userNames := map[string]string{}
userNicknames := map[string]string{}
channelNames := map[string]string{}
roleNames := map[string]string{}
var wg sync.WaitGroup
var mutex sync.Mutex
for userId := range userIdsToFetch {
wg.Add(1)
go func(ctx context.Context, userId string) {
defer wg.Done()
member, err := GetGuildMember(ctx, config.Config.Discord.GuildID, userId)
if err != nil {
if errors.Is(err, NotFound) {
// not a problem
} else if err != nil {
logging.ExtractLogger(ctx).Warn().Err(err).Msg("failed to fetch guild member for markdown")
}
return
}
func() {
mutex.Lock()
defer mutex.Unlock()
if member.User != nil {
userNames[userId] = member.User.Username
}
if member.Nick != nil {
userNicknames[userId] = *member.Nick
}
}()
}(ctx, userId)
}
if len(channelMatches) > 0 {
wg.Add(1)
go func(ctx context.Context) {
defer wg.Done()
channels, err := GetGuildChannels(ctx, config.Config.Discord.GuildID)
if err != nil {
logging.ExtractLogger(ctx).Warn().Err(err).Msg("failed to fetch channels for markdown")
return
}
for _, channel := range channels {
channelNames[channel.ID] = channel.Name
}
}(ctx)
}
if len(roleMatches) > 0 {
wg.Add(1)
go func(ctx context.Context) {
defer wg.Done()
roles, err := GetGuildRoles(ctx, config.Config.Discord.GuildID)
if err != nil {
logging.ExtractLogger(ctx).Warn().Err(err).Msg("failed to fetch roles for markdown")
return
}
for _, role := range roles {
roleNames[role.ID] = role.Name
}
}(ctx)
}
wg.Wait()
// Replace all the everything
res := original
for _, m := range userMatches {
resultName := "Unknown User"
if name, ok := userNames[m[1]]; ok {
resultName = name
}
res = strings.Replace(res, m[0], fmt.Sprintf("@%s", resultName), 1)
}
for _, m := range userNicknameMatches {
resultName := "Unknown User"
if name, ok := userNicknames[m[1]]; ok {
resultName = name
} else if name, ok := userNames[m[1]]; ok {
resultName = name
}
res = strings.Replace(res, m[0], fmt.Sprintf("@%s", resultName), 1)
}
for _, m := range channelMatches {
resultName := "Unknown Channel"
if name, ok := channelNames[m[1]]; ok {
resultName = name
}
res = strings.Replace(res, m[0], fmt.Sprintf("#%s", resultName), 1)
}
for _, m := range roleMatches {
resultName := "Unknown Role"
if name, ok := roleNames[m[1]]; ok {
resultName = name
}
res = strings.Replace(res, m[0], fmt.Sprintf("@%s", resultName), 1)
}
for _, m := range customEmojiMatches {
res = strings.Replace(res, m[0], fmt.Sprintf(":%s:", m[1]), 1)
}
for _, m := range timestampMatches {
res = strings.Replace(res, m[0], "<timestamp>", 1) // TODO: Actual timestamp stuff? Is it worth it?
}
return res
}

View File

@ -0,0 +1,38 @@
package discord
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestCleanUpMarkdown(t *testing.T) {
t.Skip("Skipping these tests because they are server-specific and make network requests. Feel free to re-enable, but don't commit :)")
const userBen = "<@!132715550571888640>"
const channelShowcaseTest = "<#759497527883202582>"
const roleHmnMember = "<@&876685379770646538>"
t.Run("normal behavior", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
assert.Equal(t, "@Frogbot some stuff", CleanUpMarkdown(ctx, "<@!745051593728196732> some stuff"))
assert.Equal(t,
"users: @Unknown User @bvisness @bvisness, channels: #Unknown Channel #showcase-test #showcase-test, roles: @Unknown Role @HMN Member @HMN Member, :shakefist: also normal text",
CleanUpMarkdown(ctx, fmt.Sprintf("users: <@!000000> %s %s, channels: <#000000> %s %s, roles: <@&000000> %s %s, <a:shakefist:798333915973943307> also normal text", userBen, userBen, channelShowcaseTest, channelShowcaseTest, roleHmnMember, roleHmnMember)),
)
})
t.Run("context cancellation", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // immediately cancel
assert.Equal(t,
"@Unknown User #Unknown Channel @Unknown Role",
CleanUpMarkdown(ctx, fmt.Sprintf("%s %s %s", userBen, channelShowcaseTest, roleHmnMember)),
)
})
}

View File

@ -0,0 +1,16 @@
package discord
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetMessage(t *testing.T) {
// t.Skip("this test is only for debugging")
msg, err := GetChannelMessage(context.Background(), "404399251276169217", "764575065772916790")
assert.Nil(t, err)
t.Logf("%+v", msg)
}

View File

@ -2,6 +2,8 @@ package discord
import (
"encoding/json"
"fmt"
"time"
)
type Opcode int
@ -108,10 +110,51 @@ type Resume struct {
SequenceNumber int `json:"seq"`
}
// https://discord.com/developers/docs/topics/gateway#message-delete
type MessageDelete struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
GuildID string `json:"guild_id"`
}
func MessageDeleteFromMap(m interface{}) MessageDelete {
mmap := m.(map[string]interface{})
return MessageDelete{
ID: mmap["id"].(string),
ChannelID: mmap["channel_id"].(string),
GuildID: maybeString(mmap, "guild_id"),
}
}
// https://discord.com/developers/docs/topics/gateway#message-delete
type MessageBulkDelete struct {
IDs []string `json:"ids"`
ChannelID string `json:"channel_id"`
GuildID string `json:"guild_id"`
}
func MessageBulkDeleteFromMap(m interface{}) MessageBulkDelete {
mmap := m.(map[string]interface{})
iids := mmap["ids"].([]interface{})
ids := make([]string, len(iids))
for i, iid := range iids {
ids[i] = iid.(string)
}
return MessageBulkDelete{
IDs: ids,
ChannelID: mmap["channel_id"].(string),
GuildID: maybeString(mmap, "guild_id"),
}
}
type ChannelType int
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
const (
ChannelTypeGuildext ChannelType = 0
ChannelTypeGuildText ChannelType = 0
ChannelTypeDM ChannelType = 1
ChannelTypeGuildVoice ChannelType = 2
ChannelTypeGroupDM ChannelType = 3
@ -124,6 +167,14 @@ const (
ChannelTypeGuildStageVoice ChannelType = 13
)
// https://discord.com/developers/docs/topics/permissions#role-object
type Role struct {
ID string `json:"id"`
Name string `json:"name"`
// more fields not yet present
}
// https://discord.com/developers/docs/resources/channel#channel-object
type Channel struct {
ID string `json:"id"`
Type ChannelType `json:"type"`
@ -136,6 +187,7 @@ type Channel struct {
type MessageType int
// https://discord.com/developers/docs/resources/channel#message-object-message-types
const (
MessageTypeDefault MessageType = 0
@ -170,19 +222,57 @@ const (
// https://discord.com/developers/docs/resources/channel#message-object
type Message struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
Content string `json:"content"`
Author *User `json:"author"` // note that this may not be an actual valid user (see the docs)
// TODO: Author info
// TODO: Timestamp parsing, yay
Type MessageType `json:"type"`
ID string `json:"id"`
ChannelID string `json:"channel_id"`
GuildID *string `json:"guild_id"`
Content string `json:"content"`
Author User `json:"author"` // note that this may not be an actual valid user (see the docs)
Timestamp string `json:"timestamp"`
Type MessageType `json:"type"`
Attachments []Attachment `json:"attachments"`
Embeds []Embed `json:"embeds"`
originalMap map[string]interface{}
}
func (m *Message) JumpURL() string {
guildStr := "@me"
if m.GuildID != nil {
guildStr = *m.GuildID
}
return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildStr, m.ChannelID, m.ID)
}
func (m *Message) Time() time.Time {
t, err := time.Parse(time.RFC3339Nano, m.Timestamp)
if err != nil {
panic(err)
}
return t
}
func (m *Message) ShortString() string {
return fmt.Sprintf("%s / %s: \"%s\" (%d attachments, %d embeds)", m.Timestamp, m.Author.Username, m.Content, len(m.Attachments), len(m.Embeds))
}
func (m *Message) OriginalHasFields(fields ...string) bool {
if m.originalMap == nil {
// If we don't know, we assume the fields are there.
// Usually this is because it came from their API, where we
// always have all fields.
return true
}
for _, field := range fields {
_, ok := m.originalMap[field]
if !ok {
return false
}
}
return true
}
func MessageFromMap(m interface{}) Message {
/*
Some gateway events, like MESSAGE_UPDATE, do not contain the
@ -194,15 +284,16 @@ func MessageFromMap(m interface{}) Message {
msg := Message{
ID: mmap["id"].(string),
ChannelID: mmap["channel_id"].(string),
GuildID: maybeStringP(mmap, "guild_id"),
Content: maybeString(mmap, "content"),
Timestamp: maybeString(mmap, "timestamp"),
Type: MessageType(maybeInt(mmap, "type")),
originalMap: mmap,
}
if author, ok := mmap["author"]; ok {
u := UserFromMap(author)
msg.Author = &u
msg.Author = UserFromMap(author)
}
if iattachments, ok := mmap["attachments"]; ok {
@ -212,6 +303,13 @@ func MessageFromMap(m interface{}) Message {
}
}
if iembeds, ok := mmap["embeds"]; ok {
embeds := iembeds.([]interface{})
for _, iembed := range embeds {
msg.Embeds = append(msg.Embeds, EmbedFromMap(iembed))
}
}
return msg
}
@ -241,15 +339,23 @@ func UserFromMap(m interface{}) User {
return u
}
// https://discord.com/developers/docs/resources/guild#guild-member-object
type GuildMember struct {
User *User `json:"user"`
Nick *string `json:"nick"`
// more fields not yet handled here
}
// https://discord.com/developers/docs/resources/channel#attachment-object
type Attachment struct {
ID string `json:"id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int `json:"size"`
Url string `json:"url"`
ProxyUrl string `json:"proxy_url"`
Height *int `json:"height"`
Width *int `json:"width"`
ID string `json:"id"`
Filename string `json:"filename"`
ContentType *string `json:"content_type"`
Size int `json:"size"`
Url string `json:"url"`
ProxyUrl string `json:"proxy_url"`
Height *int `json:"height"`
Width *int `json:"width"`
}
func AttachmentFromMap(m interface{}) Attachment {
@ -257,7 +363,7 @@ func AttachmentFromMap(m interface{}) Attachment {
a := Attachment{
ID: mmap["id"].(string),
Filename: mmap["filename"].(string),
ContentType: maybeString(mmap, "content_type"),
ContentType: maybeStringP(mmap, "content_type"),
Size: int(mmap["size"].(float64)),
Url: mmap["url"].(string),
ProxyUrl: mmap["proxy_url"].(string),
@ -268,6 +374,224 @@ func AttachmentFromMap(m interface{}) Attachment {
return a
}
// https://discord.com/developers/docs/resources/channel#embed-object
type Embed struct {
Title *string `json:"title"`
Type *string `json:"type"`
Description *string `json:"description"`
Url *string `json:"url"`
Timestamp *string `json:"timestamp"`
Color *int `json:"color"`
Footer *EmbedFooter `json:"footer"`
Image *EmbedImage `json:"image"`
Thumbnail *EmbedThumbnail `json:"thumbnail"`
Video *EmbedVideo `json:"video"`
Provider *EmbedProvider `json:"provider"`
Author *EmbedAuthor `json:"author"`
Fields []EmbedField `json:"fields"`
}
type EmbedFooter struct {
Text string `json:"text"`
IconUrl *string `json:"icon_url"`
ProxyIconUrl *string `json:"proxy_icon_url"`
}
type EmbedImageish struct {
Url *string `json:"url"`
ProxyUrl *string `json:"proxy_url"`
Height *int `json:"height"`
Width *int `json:"width"`
}
type EmbedImage struct {
EmbedImageish
}
type EmbedThumbnail struct {
EmbedImageish
}
type EmbedVideo struct {
EmbedImageish
}
type EmbedProvider struct {
Name *string `json:"name"`
Url *string `json:"url"`
}
type EmbedAuthor struct {
Name *string `json:"name"`
Url *string `json:"url"`
IconUrl *string `json:"icon_url"`
ProxyIconUrl *string `json:"proxy_icon_url"`
}
type EmbedField struct {
Name string `json:"name"`
Value string `json:"value"`
Inline *bool `json:"inline"`
}
func EmbedFromMap(m interface{}) Embed {
mmap := m.(map[string]interface{})
e := Embed{
Title: maybeStringP(mmap, "title"),
Type: maybeStringP(mmap, "type"),
Description: maybeStringP(mmap, "description"),
Url: maybeStringP(mmap, "url"),
Timestamp: maybeStringP(mmap, "timestamp"),
Color: maybeIntP(mmap, "color"),
Footer: EmbedFooterFromMap(mmap, "footer"),
Image: EmbedImageFromMap(mmap, "image"),
Thumbnail: EmbedThumbnailFromMap(mmap, "thumbnail"),
Video: EmbedVideoFromMap(mmap, "video"),
Provider: EmbedProviderFromMap(mmap, "provider"),
Author: EmbedAuthorFromMap(mmap, "author"),
Fields: EmbedFieldsFromMap(mmap, "fields"),
}
return e
}
func EmbedFooterFromMap(m map[string]interface{}, k string) *EmbedFooter {
f, ok := m[k]
if !ok {
return nil
}
fMap, ok := f.(map[string]interface{})
if !ok {
return nil
}
return &EmbedFooter{
Text: maybeString(fMap, "text"),
IconUrl: maybeStringP(fMap, "icon_url"),
ProxyIconUrl: maybeStringP(fMap, "proxy_icon_url"),
}
}
func EmbedImageFromMap(m map[string]interface{}, k string) *EmbedImage {
val, ok := m[k]
if !ok {
return nil
}
valMap, ok := val.(map[string]interface{})
if !ok {
return nil
}
return &EmbedImage{
EmbedImageish: EmbedImageish{
Url: maybeStringP(valMap, "url"),
ProxyUrl: maybeStringP(valMap, "proxy_url"),
Height: maybeIntP(valMap, "height"),
Width: maybeIntP(valMap, "width"),
},
}
}
func EmbedThumbnailFromMap(m map[string]interface{}, k string) *EmbedThumbnail {
val, ok := m[k]
if !ok {
return nil
}
valMap, ok := val.(map[string]interface{})
if !ok {
return nil
}
return &EmbedThumbnail{
EmbedImageish: EmbedImageish{
Url: maybeStringP(valMap, "url"),
ProxyUrl: maybeStringP(valMap, "proxy_url"),
Height: maybeIntP(valMap, "height"),
Width: maybeIntP(valMap, "width"),
},
}
}
func EmbedVideoFromMap(m map[string]interface{}, k string) *EmbedVideo {
val, ok := m[k]
if !ok {
return nil
}
valMap, ok := val.(map[string]interface{})
if !ok {
return nil
}
return &EmbedVideo{
EmbedImageish: EmbedImageish{
Url: maybeStringP(valMap, "url"),
ProxyUrl: maybeStringP(valMap, "proxy_url"),
Height: maybeIntP(valMap, "height"),
Width: maybeIntP(valMap, "width"),
},
}
}
func EmbedProviderFromMap(m map[string]interface{}, k string) *EmbedProvider {
val, ok := m[k]
if !ok {
return nil
}
valMap, ok := val.(map[string]interface{})
if !ok {
return nil
}
return &EmbedProvider{
Name: maybeStringP(valMap, "name"),
Url: maybeStringP(valMap, "url"),
}
}
func EmbedAuthorFromMap(m map[string]interface{}, k string) *EmbedAuthor {
val, ok := m[k]
if !ok {
return nil
}
valMap, ok := val.(map[string]interface{})
if !ok {
return nil
}
return &EmbedAuthor{
Name: maybeStringP(valMap, "name"),
Url: maybeStringP(valMap, "url"),
}
}
func EmbedFieldsFromMap(m map[string]interface{}, k string) []EmbedField {
val, ok := m[k]
if !ok {
return nil
}
valSlice, ok := val.([]interface{})
if !ok {
return nil
}
var result []EmbedField
for _, innerVal := range valSlice {
valMap, ok := innerVal.(map[string]interface{})
if !ok {
continue
}
result = append(result, EmbedField{
Name: maybeString(valMap, "name"),
Value: maybeString(valMap, "value"),
Inline: maybeBoolP(valMap, "inline"),
})
}
return result
}
func maybeString(m map[string]interface{}, k string) string {
val, ok := m[k]
if !ok {
@ -276,6 +600,15 @@ func maybeString(m map[string]interface{}, k string) string {
return val.(string)
}
func maybeStringP(m map[string]interface{}, k string) *string {
val, ok := m[k]
if !ok {
return nil
}
strval := val.(string)
return &strval
}
func maybeInt(m map[string]interface{}, k string) int {
val, ok := m[k]
if !ok {
@ -292,3 +625,20 @@ func maybeIntP(m map[string]interface{}, k string) *int {
intval := int(val.(float64))
return &intval
}
func maybeBool(m map[string]interface{}, k string) bool {
val, ok := m[k]
if !ok {
return false
}
return val.(bool)
}
func maybeBoolP(m map[string]interface{}, k string) *bool {
val, ok := m[k]
if !ok {
return nil
}
boolval := val.(bool)
return &boolval
}

View File

@ -4,14 +4,17 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/oops"
)
@ -26,6 +29,8 @@ const (
var UserAgent = fmt.Sprintf("%s (%s, %s)", BotName, UserAgentURL, UserAgentVersion)
var NotFound = errors.New("not found")
var httpClient = &http.Client{}
func buildUrl(path string) string {
@ -83,6 +88,101 @@ func GetGatewayBot(ctx context.Context) (*GetGatewayBotResponse, error) {
return &result, nil
}
func GetGuildRoles(ctx context.Context, guildID string) ([]Role, error) {
const name = "Get Guild Roles"
path := fmt.Sprintf("/guilds/%s/roles", guildID)
res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
return makeRequest(ctx, http.MethodGet, path, nil)
})
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
logErrorResponse(ctx, name, res, "")
return nil, oops.New(nil, "received error from Discord")
}
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
var roles []Role
err = json.Unmarshal(bodyBytes, &roles)
if err != nil {
return nil, oops.New(err, "failed to unmarshal Discord message")
}
return roles, nil
}
func GetGuildChannels(ctx context.Context, guildID string) ([]Channel, error) {
const name = "Get Guild Channels"
path := fmt.Sprintf("/guilds/%s/channels", guildID)
res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
return makeRequest(ctx, http.MethodGet, path, nil)
})
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
logErrorResponse(ctx, name, res, "")
return nil, oops.New(nil, "received error from Discord")
}
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
var channels []Channel
err = json.Unmarshal(bodyBytes, &channels)
if err != nil {
return nil, oops.New(err, "failed to unmarshal Discord message")
}
return channels, nil
}
func GetGuildMember(ctx context.Context, guildID, userID string) (*GuildMember, error) {
const name = "Get Guild Member"
path := fmt.Sprintf("/guilds/%s/members/%s", guildID, userID)
res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
return makeRequest(ctx, http.MethodGet, path, nil)
})
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
return nil, NotFound
} else if res.StatusCode >= 400 {
logErrorResponse(ctx, name, res, "")
return nil, oops.New(nil, "received error from Discord")
}
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
var msg GuildMember
err = json.Unmarshal(bodyBytes, &msg)
if err != nil {
return nil, oops.New(err, "failed to unmarshal Discord message")
}
return &msg, nil
}
type CreateMessageRequest struct {
Content string `json:"content"`
}
@ -313,6 +413,103 @@ func RemoveGuildMemberRole(ctx context.Context, userID, roleID string) error {
return nil
}
func GetChannelMessage(ctx context.Context, channelID, messageID string) (*Message, error) {
const name = "Get Channel Message"
path := fmt.Sprintf("/channels/%s/messages/%s", channelID, messageID)
res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
return makeRequest(ctx, http.MethodGet, path, nil)
})
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
return nil, NotFound
} else if res.StatusCode >= 400 {
logErrorResponse(ctx, name, res, "")
return nil, oops.New(nil, "received error from Discord")
}
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
var msg Message
err = json.Unmarshal(bodyBytes, &msg)
if err != nil {
return nil, oops.New(err, "failed to unmarshal Discord message")
}
return &msg, nil
}
type GetChannelMessagesInput struct {
Around string
Before string
After string
Limit int
}
func GetChannelMessages(ctx context.Context, channelID string, in GetChannelMessagesInput) ([]Message, error) {
const name = "Get Channel Messages"
path := fmt.Sprintf("/channels/%s/messages", channelID)
res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
req := makeRequest(ctx, http.MethodGet, path, nil)
q := req.URL.Query()
if in.Around != "" {
q.Add("around", in.Around)
}
if in.Before != "" {
q.Add("before", in.Before)
}
if in.After != "" {
q.Add("after", in.After)
}
if in.Limit != 0 {
q.Add("limit", strconv.Itoa(in.Limit))
}
req.URL.RawQuery = q.Encode()
return req
})
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
logErrorResponse(ctx, name, res, "")
return nil, oops.New(nil, "received error from Discord")
}
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
var msgs []Message
err = json.Unmarshal(bodyBytes, &msgs)
if err != nil {
return nil, oops.New(err, "failed to unmarshal Discord message")
}
return msgs, nil
}
func GetAuthorizeUrl(state string) string {
params := make(url.Values)
params.Set("response_type", "code")
params.Set("client_id", config.Config.Discord.OAuthClientID)
params.Set("scope", "identify")
params.Set("state", state)
params.Set("redirect_uri", hmnurl.BuildDiscordOAuthCallback())
return fmt.Sprintf("https://discord.com/api/oauth2/authorize?%s", params.Encode())
}
func logErrorResponse(ctx context.Context, name string, res *http.Response, msg string) {
dump, err := httputil.DumpResponse(res, true)
if err != nil {

View File

@ -2,42 +2,100 @@ package discord
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/assets"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/parsing"
"github.com/google/uuid"
)
var reDiscordMessageLink = regexp.MustCompile(`https?://.+?(\s|$)`)
func (bot *discordBotInstance) processShowcaseMsg(ctx context.Context, msg *Message) error {
var errNotEnoughInfo = errors.New("Discord didn't send enough info in this event for us to do this")
func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message) error {
switch msg.Type {
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
default:
return nil
}
didDelete, err := bot.maybeDeleteShowcaseMsg(ctx, msg)
if err != nil {
return err
}
if didDelete {
return nil
}
tx, err := bot.dbConn.Begin(ctx)
if err != nil {
panic(err)
}
defer tx.Rollback(ctx)
// save the message, maybe save its contents, and maybe make a snippet too
newMsg, err := SaveMessageAndContents(ctx, tx, msg)
if errors.Is(err, errNotEnoughInfo) {
logging.ExtractLogger(ctx).Warn().
Interface("msg", msg).
Msg("didn't have enough info to process Discord message")
return nil
} else if err != nil {
return err
}
if doSnippet, err := AllowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil {
_, err := CreateMessageSnippet(ctx, tx, msg.ID)
if err != nil {
return oops.New(err, "failed to create snippet in gateway")
}
} else if err != nil {
return oops.New(err, "failed to check snippet permissions in gateway")
}
err = tx.Commit(ctx)
if err != nil {
return oops.New(err, "failed to commit Discord message updates")
}
return nil
}
func (bot *botInstance) maybeDeleteShowcaseMsg(ctx context.Context, msg *Message) (didDelete bool, err error) {
hasGoodContent := true
if originalMessageHasField(msg, "content") && !messageHasLinks(msg.Content) {
if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) {
hasGoodContent = false
}
hasGoodAttachments := true
if originalMessageHasField(msg, "attachments") && len(msg.Attachments) == 0 {
if msg.OriginalHasFields("attachments") && len(msg.Attachments) == 0 {
hasGoodAttachments = false
}
didDelete = false
if !hasGoodContent && !hasGoodAttachments {
didDelete = true
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
if err != nil {
return oops.New(err, "failed to delete message")
return false, oops.New(err, "failed to delete message")
}
if msg.Author != nil && !msg.Author.IsBot {
if !msg.Author.IsBot {
channel, err := CreateDM(ctx, msg.Author.ID)
if err != nil {
return oops.New(err, "failed to create DM channel")
return false, oops.New(err, "failed to create DM channel")
}
err = SendMessages(ctx, bot.dbConn, MessageToSend{
@ -47,50 +105,596 @@ func (bot *discordBotInstance) processShowcaseMsg(ctx context.Context, msg *Mess
},
})
if err != nil {
return oops.New(err, "failed to send showcase warning message")
return false, oops.New(err, "failed to send showcase warning message")
}
}
}
return nil
return didDelete, nil
}
func (bot *discordBotInstance) processLibraryMsg(ctx context.Context, msg *Message) error {
switch msg.Type {
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
default:
return nil
}
/*
Ensures that a Discord message is stored in the database. This function is
idempotent and can be called regardless of whether the item already exists in
the database.
if !originalMessageHasField(msg, "content") {
return nil
}
This does not create snippets or do anything besides save the message itself.
*/
func SaveMessage(
ctx context.Context,
tx db.ConnOrTx,
msg *Message,
) (*models.DiscordMessage, error) {
iDiscordMessage, err := db.QueryOne(ctx, tx, models.DiscordMessage{},
`
SELECT $columns
FROM handmade_discordmessage
WHERE id = $1
`,
msg.ID,
)
if errors.Is(err, db.ErrNoMatchingRows) {
if !msg.OriginalHasFields("author", "timestamp") {
return nil, errNotEnoughInfo
}
if !messageHasLinks(msg.Content) {
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
guildID := msg.GuildID
if guildID == nil {
/*
This is weird, but it can happen when we fetch messages from
history instead of receiving it from the gateway. In this case
we just assume it's from the HMN server.
*/
guildID = &config.Config.Discord.GuildID
}
_, err = tx.Exec(ctx,
`
INSERT INTO handmade_discordmessage (id, channel_id, guild_id, url, user_id, sent_at, snippet_created)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
msg.ID,
msg.ChannelID,
*guildID,
msg.JumpURL(),
msg.Author.ID,
msg.Time(),
false,
)
if err != nil {
return oops.New(err, "failed to delete message")
return nil, oops.New(err, "failed to save new discord message")
}
if msg.Author != nil && !msg.Author.IsBot {
channel, err := CreateDM(ctx, msg.Author.ID)
if err != nil {
return oops.New(err, "failed to create DM channel")
}
/*
TODO(db): This is a spot where it would be really nice to be able
to use RETURNING, and avoid this second query.
*/
iDiscordMessage, err = db.QueryOne(ctx, tx, models.DiscordMessage{},
`
SELECT $columns
FROM handmade_discordmessage
WHERE id = $1
`,
msg.ID,
)
if err != nil {
panic(err)
}
} else if err != nil {
return nil, oops.New(err, "failed to check for existing Discord message")
}
err = SendMessages(ctx, bot.dbConn, MessageToSend{
ChannelID: channel.ID,
Req: CreateMessageRequest{
Content: "Posts in #the-library are required to have a link. Discuss library content in other relevant channels.",
},
})
return iDiscordMessage.(*models.DiscordMessage), nil
}
/*
Processes a single Discord message, saving as much of the message's content
and attachments as allowed by our rules and user settings. Does NOT create
snippets.
Idempotent; can be called any time whether the message exists or not.
*/
func SaveMessageAndContents(
ctx context.Context,
tx db.ConnOrTx,
msg *Message,
) (*models.DiscordMessage, error) {
newMsg, err := SaveMessage(ctx, tx, msg)
if err != nil {
return nil, err
}
// Check for linked Discord user
iDiscordUser, err := db.QueryOne(ctx, tx, models.DiscordUser{},
`
SELECT $columns
FROM handmade_discorduser
WHERE userid = $1
`,
newMsg.UserID,
)
if errors.Is(err, db.ErrNoMatchingRows) {
return newMsg, nil
} else if err != nil {
return nil, oops.New(err, "failed to look up linked Discord user")
}
discordUser := iDiscordUser.(*models.DiscordUser)
// We have a linked Discord account, so save the message contents (regardless of
// whether we create a snippet or not).
if msg.OriginalHasFields("content") {
_, err = tx.Exec(ctx,
`
INSERT INTO handmade_discordmessagecontent (message_id, discord_id, last_content)
VALUES ($1, $2, $3)
ON CONFLICT (message_id) DO UPDATE SET
discord_id = EXCLUDED.discord_id,
last_content = EXCLUDED.last_content
`,
newMsg.ID,
discordUser.ID,
CleanUpMarkdown(ctx, msg.Content),
)
}
// Save attachments
if msg.OriginalHasFields("attachments") {
for _, attachment := range msg.Attachments {
_, err := saveAttachment(ctx, tx, &attachment, discordUser.HMNUserId, msg.ID)
if err != nil {
return oops.New(err, "failed to send showcase warning message")
return nil, oops.New(err, "failed to save attachment")
}
}
}
return nil
// Save / delete embeds
if msg.OriginalHasFields("embeds") {
numSavedEmbeds, err := db.QueryInt(ctx, tx,
`
SELECT COUNT(*)
FROM handmade_discordmessageembed
WHERE message_id = $1
`,
msg.ID,
)
if err != nil {
return nil, oops.New(err, "failed to count existing embeds")
}
if numSavedEmbeds == 0 {
// No embeds yet, so save new ones
for _, embed := range msg.Embeds {
_, err := saveEmbed(ctx, tx, &embed, discordUser.HMNUserId, msg.ID)
if err != nil {
return nil, oops.New(err, "failed to save embed")
}
}
} else if len(msg.Embeds) > 0 {
// Embeds were removed from the message
_, err := tx.Exec(ctx,
`
DELETE FROM handmade_discordmessageembed
WHERE message_id = $1
`,
msg.ID,
)
if err != nil {
return nil, oops.New(err, "failed to delete embeds")
}
}
}
return newMsg, nil
}
var discordDownloadClient = &http.Client{
Timeout: 10 * time.Second,
}
type DiscordResourceBadStatusCode error
func downloadDiscordResource(ctx context.Context, url string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", oops.New(err, "failed to make Discord download request")
}
res, err := discordDownloadClient.Do(req)
if err != nil {
return nil, "", oops.New(err, "failed to fetch Discord resource data")
}
defer res.Body.Close()
if res.StatusCode < 200 || 299 < res.StatusCode {
return nil, "", DiscordResourceBadStatusCode(fmt.Errorf("status code %d from Discord resource: %s", res.StatusCode, url))
}
content, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
return content, res.Header.Get("Content-Type"), nil
}
/*
Saves a Discord attachment as an HMN asset. Idempotent; will not create an attachment
that already exists
*/
func saveAttachment(
ctx context.Context,
tx db.ConnOrTx,
attachment *Attachment,
hmnUserID int,
discordMessageID string,
) (*models.DiscordMessageAttachment, error) {
iexisting, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{},
`
SELECT $columns
FROM handmade_discordmessageattachment
WHERE id = $1
`,
attachment.ID,
)
if err == nil {
return iexisting.(*models.DiscordMessageAttachment), nil
} else if errors.Is(err, db.ErrNoMatchingRows) {
// this is fine, just create it
} else {
return nil, oops.New(err, "failed to check for existing attachment")
}
width := 0
height := 0
if attachment.Width != nil {
width = *attachment.Width
}
if attachment.Height != nil {
height = *attachment.Height
}
content, _, err := downloadDiscordResource(ctx, attachment.Url)
if err != nil {
return nil, oops.New(err, "failed to download Discord attachment")
}
contentType := "application/octet-stream"
if attachment.ContentType != nil {
contentType = *attachment.ContentType
}
asset, err := assets.Create(ctx, tx, assets.CreateInput{
Content: content,
Filename: attachment.Filename,
ContentType: contentType,
UploaderID: &hmnUserID,
Width: width,
Height: height,
})
if err != nil {
return nil, oops.New(err, "failed to save asset for Discord attachment")
}
// TODO(db): RETURNING plz thanks
_, err = tx.Exec(ctx,
`
INSERT INTO handmade_discordmessageattachment (id, asset_id, message_id)
VALUES ($1, $2, $3)
`,
attachment.ID,
asset.ID,
discordMessageID,
)
if err != nil {
return nil, oops.New(err, "failed to save Discord attachment data")
}
iDiscordAttachment, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{},
`
SELECT $columns
FROM handmade_discordmessageattachment
WHERE id = $1
`,
attachment.ID,
)
if err != nil {
return nil, oops.New(err, "failed to fetch new Discord attachment data")
}
return iDiscordAttachment.(*models.DiscordMessageAttachment), nil
}
func saveEmbed(
ctx context.Context,
tx db.ConnOrTx,
embed *Embed,
hmnUserID int,
discordMessageID string,
) (*models.DiscordMessageEmbed, error) {
// TODO: Does this need to be idempotent? Embeds don't have IDs...
// Maybe Discord will never actually send us the same embed twice?
isOkImageType := func(contentType string) bool {
return strings.HasPrefix(contentType, "image/")
}
isOkVideoType := func(contentType string) bool {
return strings.HasPrefix(contentType, "video/")
}
maybeSaveImageish := func(i EmbedImageish, contentTypeCheck func(string) bool) (*uuid.UUID, error) {
content, contentType, err := downloadDiscordResource(ctx, *i.Url)
if err != nil {
var statusError DiscordResourceBadStatusCode
if errors.As(err, &statusError) {
return nil, nil
} else {
return nil, oops.New(err, "failed to save Discord embed")
}
}
if contentTypeCheck(contentType) {
in := assets.CreateInput{
Content: content,
Filename: "embed",
ContentType: contentType,
UploaderID: &hmnUserID,
}
if i.Width != nil {
in.Width = *i.Width
}
if i.Height != nil {
in.Height = *i.Height
}
asset, err := assets.Create(ctx, tx, in)
if err != nil {
return nil, oops.New(err, "failed to create asset from embed")
}
return &asset.ID, nil
}
return nil, nil
}
var imageAssetId *uuid.UUID
var videoAssetId *uuid.UUID
var err error
if embed.Video != nil && embed.Video.Url != nil {
videoAssetId, err = maybeSaveImageish(embed.Video.EmbedImageish, isOkVideoType)
} else if embed.Image != nil && embed.Image.Url != nil {
imageAssetId, err = maybeSaveImageish(embed.Image.EmbedImageish, isOkImageType)
} else if embed.Thumbnail != nil && embed.Thumbnail.Url != nil {
imageAssetId, err = maybeSaveImageish(embed.Thumbnail.EmbedImageish, isOkImageType)
}
if err != nil {
return nil, err
}
// Save the embed into the db
// TODO(db): Insert, RETURNING
var savedEmbedId int
err = tx.QueryRow(ctx,
`
INSERT INTO handmade_discordmessageembed (title, description, url, message_id, image_id, video_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`,
embed.Title,
embed.Description,
embed.Url,
discordMessageID,
imageAssetId,
videoAssetId,
).Scan(&savedEmbedId)
if err != nil {
return nil, oops.New(err, "failed to insert new embed")
}
iDiscordEmbed, err := db.QueryOne(ctx, tx, models.DiscordMessageEmbed{},
`
SELECT $columns
FROM handmade_discordmessageembed
WHERE id = $1
`,
savedEmbedId,
)
if err != nil {
return nil, oops.New(err, "failed to fetch new Discord embed data")
}
return iDiscordEmbed.(*models.DiscordMessageEmbed), nil
}
/*
Checks settings and permissions to decide whether we are allowed to create
snippets for a user.
*/
func AllowedToCreateMessageSnippet(ctx context.Context, tx db.ConnOrTx, discordUserId string) (bool, error) {
canSave, err := db.QueryBool(ctx, tx,
`
SELECT u.discord_save_showcase
FROM
handmade_discorduser AS duser
JOIN auth_user AS u ON duser.hmn_user_id = u.id
WHERE
duser.userid = $1
`,
discordUserId,
)
if errors.Is(err, db.ErrNoMatchingRows) {
return false, nil
} else if err != nil {
return false, oops.New(err, "failed to check if we can save Discord message")
}
return canSave, nil
}
/*
Attempts to create a snippet from a Discord message. If a snippet already
exists, it will be returned and no new snippets will be created.
It uses the content saved in the database to do this. If we do not have
any content saved, nothing will happen.
Does not check user preferences around snippets.
*/
func CreateMessageSnippet(ctx context.Context, tx db.ConnOrTx, msgID string) (*models.Snippet, error) {
// Check for existing snippet, maybe return it
type existingSnippetResult struct {
Message models.DiscordMessage `db:"msg"`
MessageContent *models.DiscordMessageContent `db:"c"`
Snippet *models.Snippet `db:"snippet"`
DiscordUser *models.DiscordUser `db:"duser"`
}
iexisting, err := db.QueryOne(ctx, tx, existingSnippetResult{},
`
SELECT $columns
FROM
handmade_discordmessage AS msg
LEFT JOIN handmade_discordmessagecontent AS c ON c.message_id = msg.id
LEFT JOIN handmade_snippet AS snippet ON snippet.discord_message_id = msg.id
LEFT JOIN handmade_discorduser AS duser ON msg.user_id = duser.userid
WHERE
msg.id = $1
`,
msgID,
)
if err != nil {
return nil, oops.New(err, "failed to check for existing snippet for message %s", msgID)
}
existing := iexisting.(*existingSnippetResult)
if existing.Snippet != nil {
// A snippet already exists - maybe update its content, then return it
if existing.MessageContent != nil && !existing.Snippet.EditedOnWebsite {
contentMarkdown := existing.MessageContent.LastContent
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
_, err := tx.Exec(ctx,
`
UPDATE handmade_snippet
SET
description = $1,
_description_html = $2
WHERE id = $3
`,
contentMarkdown,
contentHTML,
existing.Snippet.ID,
)
if err != nil {
logging.ExtractLogger(ctx).Warn().Err(err).Msg("failed to update content of snippet on message edit")
}
}
return existing.Snippet, nil
}
if existing.Message.SnippetCreated {
// A snippet once existed but no longer does
// (we do not create another one in this case)
return nil, nil
}
if existing.MessageContent == nil || existing.DiscordUser == nil {
return nil, nil
}
// Get an asset ID or URL to make a snippet from
assetId, url, err := getSnippetAssetOrUrl(ctx, tx, &existing.Message)
if assetId == nil && url == nil {
// Nothing to make a snippet from!
return nil, nil
}
contentMarkdown := existing.MessageContent.LastContent
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
// TODO(db): Insert
isnippet, err := db.QueryOne(ctx, tx, models.Snippet{},
`
INSERT INTO handmade_snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING $columns
`,
url,
existing.Message.SentAt,
contentMarkdown,
contentHTML,
assetId,
msgID,
existing.DiscordUser.HMNUserId,
)
if err != nil {
return nil, oops.New(err, "failed to create snippet from attachment")
}
_, err = tx.Exec(ctx,
`
UPDATE handmade_discordmessage
SET snippet_created = TRUE
WHERE id = $1
`,
msgID,
)
if err != nil {
return nil, oops.New(err, "failed to mark message as having snippet")
}
return isnippet.(*models.Snippet), nil
}
// NOTE(ben): This is maybe redundant with the regexes we use for markdown. But
// do we actually want to reuse those, or should we keep them separate?
var RESnippetableUrl = regexp.MustCompile(`^https?://(youtu\.be|(www\.)?youtube\.com/watch)`)
func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.DiscordMessage) (*uuid.UUID, *string, error) {
// Check attachments
itAttachments, err := db.Query(ctx, tx, models.DiscordMessageAttachment{},
`
SELECT $columns
FROM handmade_discordmessageattachment
WHERE message_id = $1
`,
msg.ID,
)
if err != nil {
return nil, nil, oops.New(err, "failed to fetch message attachments")
}
attachments := itAttachments.ToSlice()
for _, iattachment := range attachments {
attachment := iattachment.(*models.DiscordMessageAttachment)
return &attachment.AssetID, nil, nil
}
// Check embeds
itEmbeds, err := db.Query(ctx, tx, models.DiscordMessageEmbed{},
`
SELECT $columns
FROM handmade_discordmessageembed
WHERE message_id = $1
`,
msg.ID,
)
if err != nil {
return nil, nil, oops.New(err, "failed to fetch discord embeds")
}
embeds := itEmbeds.ToSlice()
for _, iembed := range embeds {
embed := iembed.(*models.DiscordMessageEmbed)
if embed.VideoID != nil {
return embed.VideoID, nil, nil
} else if embed.ImageID != nil {
return embed.ImageID, nil, nil
} else if embed.URL != nil {
if RESnippetableUrl.MatchString(*embed.URL) {
return nil, embed.URL, nil
}
}
}
return nil, nil, nil
}
func messageHasLinks(content string) bool {
@ -104,12 +708,3 @@ func messageHasLinks(content string) bool {
return false
}
func originalMessageHasField(msg *Message, field string) bool {
if msg.originalMap == nil {
return false
}
_, ok := msg.originalMap[field]
return ok
}

View File

@ -194,10 +194,10 @@ func BuildUserProfile(username string) string {
return Url("/m/"+url.PathEscape(username), nil)
}
var RegexUserSettings = regexp.MustCompile(`^/_settings$`)
var RegexUserSettings = regexp.MustCompile(`^/settings$`)
func BuildUserSettings(section string) string {
return ProjectUrlWithFragment("/_settings", nil, "", section)
return ProjectUrlWithFragment("/settings", nil, "", section)
}
/*
@ -558,12 +558,6 @@ func BuildLibraryResource(projectSlug string, resourceId int) string {
* Discord OAuth
*/
var RegexDiscordTest = regexp.MustCompile("^/discord$")
func BuildDiscordTest() string {
return Url("/discord", nil)
}
var RegexDiscordOAuthCallback = regexp.MustCompile("^/_discord_callback$")
func BuildDiscordOAuthCallback() string {
@ -576,6 +570,12 @@ func BuildDiscordUnlink() string {
return Url("/_discord_unlink", nil)
}
var RegexDiscordShowcaseBacklog = regexp.MustCompile("^/discord_showcase_backlog$")
func BuildDiscordShowcaseBacklog() string {
return Url("/discord_showcase_backlog", nil)
}
/*
* Assets
*/

View File

@ -2,7 +2,9 @@ package main
import (
_ "git.handmade.network/hmn/hmn/src/admintools"
_ "git.handmade.network/hmn/hmn/src/assets"
_ "git.handmade.network/hmn/hmn/src/buildscss"
_ "git.handmade.network/hmn/hmn/src/discord/cmd"
_ "git.handmade.network/hmn/hmn/src/initimage"
_ "git.handmade.network/hmn/hmn/src/migration"
"git.handmade.network/hmn/hmn/src/website"

View File

@ -0,0 +1,55 @@
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(DiscordDefaults{})
}
type DiscordDefaults struct{}
func (m DiscordDefaults) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 8, 23, 23, 5, 59, 0, time.UTC))
}
func (m DiscordDefaults) Name() string {
return "DiscordDefaults"
}
func (m DiscordDefaults) Description() string {
return "Add some default values to Discord fields"
}
func (m DiscordDefaults) Up(ctx context.Context, tx pgx.Tx) error {
var err error
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_discordmessage
ALTER snippet_created SET DEFAULT FALSE;
`)
if err != nil {
return oops.New(err, "failed to set message defaults")
}
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_snippet
ALTER "when" SET DEFAULT NOW(),
ALTER edited_on_website SET DEFAULT FALSE;
`)
if err != nil {
return oops.New(err, "failed to set snippet defaults")
}
return nil
}
func (m DiscordDefaults) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,49 @@
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(FixSnippetConstraints{})
}
type FixSnippetConstraints struct{}
func (m FixSnippetConstraints) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 8, 26, 0, 56, 7, 0, time.UTC))
}
func (m FixSnippetConstraints) Name() string {
return "FixSnippetConstraints"
}
func (m FixSnippetConstraints) Description() string {
return "Fix the ON DELETE behaviors of snippets"
}
func (m FixSnippetConstraints) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
ALTER TABLE handmade_snippet
DROP CONSTRAINT handmade_snippet_asset_id_c786de4f_fk_handmade_asset_id,
DROP CONSTRAINT handmade_snippet_discord_message_id_d16f1f4e_fk_handmade_,
DROP CONSTRAINT handmade_snippet_owner_id_fcca1783_fk_auth_user_id,
ADD FOREIGN KEY (asset_id) REFERENCES handmade_asset (id) ON DELETE SET NULL,
ADD FOREIGN KEY (discord_message_id) REFERENCES handmade_discordmessage (id) ON DELETE SET NULL,
ADD FOREIGN KEY (owner_id) REFERENCES auth_user (id) ON DELETE CASCADE;
`)
if err != nil {
return oops.New(err, "failed to fix constraints")
}
return nil
}
func (m FixSnippetConstraints) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,60 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(NewLinkData{})
}
type NewLinkData struct{}
func (m NewLinkData) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 8, 27, 19, 4, 8, 0, time.UTC))
}
func (m NewLinkData) Name() string {
return "NewLinkData"
}
func (m NewLinkData) Description() string {
return "Rework link data to be less completely weird"
}
func (m NewLinkData) Up(ctx context.Context, tx pgx.Tx) error {
/*
Broadly the goal is to:
- drop `key`
- make `name` not null
- rename `value` to `url`
*/
_, err := tx.Exec(ctx, `UPDATE handmade_links SET name = '' WHERE name IS NULL`)
if err != nil {
return oops.New(err, "failed to fill in null names")
}
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_links
DROP key,
ALTER name SET NOT NULL;
ALTER TABLE handmade_links
RENAME value TO url;
`)
if err != nil {
return oops.New(err, "failed to alter links table")
}
return nil
}
func (m NewLinkData) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -41,7 +41,7 @@ account, regardless of whether we create snippets or not.
*/
type DiscordMessageContent struct {
MessageID string `db:"message_id"`
LastContent string `db:"last_content"`
LastContent string `db:"last_content"` // This should always be cleaned up with nice user IDs and stuff
DiscordID int `db:"discord_id"`
}

View File

@ -1,11 +1,10 @@
package models
type Link struct {
ID int `db:"id"`
Key string `db:"key"`
Name *string `db:"name"`
Value string `db:"value"`
Ordering int `db:"ordering"`
UserID *int `db:"user_id"`
ProjectID *int `db:"project_id"`
ID int `db:"id"`
Name string `db:"name"`
URL string `db:"url"`
Ordering int `db:"ordering"`
UserID *int `db:"user_id"`
ProjectID *int `db:"project_id"`
}

View File

@ -10,7 +10,7 @@ type Snippet struct {
ID int `db:"id"`
OwnerID int `db:"owner_id"`
When time.Time `db:"when"`
When time.Time `db:"\"when\""`
Description string `db:"description"`
DescriptionHtml string `db:"_description_html"`

View File

@ -10,21 +10,38 @@ import (
)
// Used for rendering real-time previews of post content.
var PreviewMarkdown = goldmark.New(
goldmark.WithExtensions(makeGoldmarkExtensions(true)...),
var ForumPreviewMarkdown = goldmark.New(
goldmark.WithExtensions(makeGoldmarkExtensions(MarkdownOptions{
Previews: true,
Embeds: true,
})...),
)
// Used for generating the final HTML for a post.
var RealMarkdown = goldmark.New(
goldmark.WithExtensions(makeGoldmarkExtensions(false)...),
var ForumRealMarkdown = goldmark.New(
goldmark.WithExtensions(makeGoldmarkExtensions(MarkdownOptions{
Previews: false,
Embeds: true,
})...),
)
// Used for generating plain-text previews of posts.
var PlaintextMarkdown = goldmark.New(
goldmark.WithExtensions(makeGoldmarkExtensions(false)...),
goldmark.WithExtensions(makeGoldmarkExtensions(MarkdownOptions{
Previews: false,
Embeds: true,
})...),
goldmark.WithRenderer(plaintextRenderer{}),
)
// Used for processing Discord messages
var DiscordMarkdown = goldmark.New(
goldmark.WithExtensions(makeGoldmarkExtensions(MarkdownOptions{
Previews: false,
Embeds: false,
})...),
)
func ParseMarkdown(source string, md goldmark.Markdown) string {
var buf bytes.Buffer
if err := md.Convert([]byte(source), &buf); err != nil {
@ -34,19 +51,35 @@ func ParseMarkdown(source string, md goldmark.Markdown) string {
return buf.String()
}
func makeGoldmarkExtensions(preview bool) []goldmark.Extender {
return []goldmark.Extender{
type MarkdownOptions struct {
Previews bool
Embeds bool
}
func makeGoldmarkExtensions(opts MarkdownOptions) []goldmark.Extender {
var extenders []goldmark.Extender
extenders = append(extenders,
extension.GFM,
highlightExtension,
SpoilerExtension{},
EmbedExtension{
Preview: preview,
},
)
if opts.Embeds {
extenders = append(extenders,
EmbedExtension{
Preview: opts.Previews,
},
)
}
extenders = append(extenders,
MathjaxExtension{},
BBCodeExtension{
Preview: preview,
Preview: opts.Previews,
},
}
)
return extenders
}
var highlightExtension = highlighting.NewHighlighting(

View File

@ -10,14 +10,14 @@ import (
func TestMarkdown(t *testing.T) {
t.Run("fenced code blocks", func(t *testing.T) {
t.Run("multiple lines", func(t *testing.T) {
html := ParseMarkdown("```\nmultiple lines\n\tof code\n```", RealMarkdown)
html := ParseMarkdown("```\nmultiple lines\n\tof code\n```", ForumRealMarkdown)
t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<pre"))
assert.Contains(t, html, `class="hmn-code"`)
assert.Contains(t, html, "multiple lines\n\tof code")
})
t.Run("multiple lines with language", func(t *testing.T) {
html := ParseMarkdown("```go\nfunc main() {\n\tfmt.Println(\"Hello, world!\")\n}\n```", RealMarkdown)
html := ParseMarkdown("```go\nfunc main() {\n\tfmt.Println(\"Hello, world!\")\n}\n```", ForumRealMarkdown)
t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<pre"))
assert.Contains(t, html, `class="hmn-code"`)
@ -30,7 +30,7 @@ func TestMarkdown(t *testing.T) {
func TestBBCode(t *testing.T) {
t.Run("[code]", func(t *testing.T) {
t.Run("one line", func(t *testing.T) {
html := ParseMarkdown("[code]Just some code, you know?[/code]", RealMarkdown)
html := ParseMarkdown("[code]Just some code, you know?[/code]", ForumRealMarkdown)
t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<pre"))
assert.Contains(t, html, `class="hmn-code"`)
@ -41,7 +41,7 @@ func TestBBCode(t *testing.T) {
Multiline code
with an indent
[/code]`
html := ParseMarkdown(bbcode, RealMarkdown)
html := ParseMarkdown(bbcode, ForumRealMarkdown)
t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<pre"))
assert.Contains(t, html, `class="hmn-code"`)
@ -54,7 +54,7 @@ func main() {
fmt.Println("Hello, world!")
}
[/code]`
html := ParseMarkdown(bbcode, RealMarkdown)
html := ParseMarkdown(bbcode, ForumRealMarkdown)
t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<pre"))
assert.Contains(t, html, "Println")
@ -66,7 +66,7 @@ func main() {
func TestSharlock(t *testing.T) {
t.Skipf("This doesn't pass right now because parts of Sharlock's original source read as indented code blocks, or depend on different line break behavior.")
t.Run("sanity check", func(t *testing.T) {
result := ParseMarkdown(sharlock, RealMarkdown)
result := ParseMarkdown(sharlock, ForumRealMarkdown)
for _, line := range strings.Split(result, "\n") {
assert.NotContains(t, line, "[b]")
@ -85,6 +85,6 @@ func TestSharlock(t *testing.T) {
func BenchmarkSharlock(b *testing.B) {
for i := 0; i < b.N; i++ {
ParseMarkdown(sharlock, RealMarkdown)
ParseMarkdown(sharlock, ForumRealMarkdown)
}
}

View File

@ -1,3 +1,6 @@
// Global variables
$input-padding: 0.3rem;
.noselect {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Chrome/Safari/Opera */
@ -24,7 +27,7 @@ body {
min-height: 100vh;
box-sizing: border-box;
font-size: px2rem(14px);
line-height: 1.5em;
line-height: 1.2em;
font-weight: 400;
}
@ -735,24 +738,16 @@ footer {
.tab-bar {
@include usevar(border-color, tab-border-color);
@extend .flex, .flex-row;
width: 100%;
border-bottom-width: 1px;
border-bottom-style: solid;
box-sizing: border-box;
.tab-button {
@include usevar(background-color, tab-button-background);
@include usevar(border-color, tab-border-color);
@extend .ph3, .pv2;
height:100%;
display:inline-block;
padding:10px 15px;
line-height:100%;
cursor:pointer;
border-width: 1px;
border-style: solid;
box-sizing:border-box;
cursor: pointer; // TODO: Should this be a link?
&:hover {
@include usevar(background-color, tab-button-background-hover);
@ -760,10 +755,7 @@ footer {
&.current {
@include usevar(background-color, tab-button-background-current);
border-bottom-color: transparent;
font-weight:bold;
height:105%;
font-weight: 500;
}
}
}

View File

@ -44,103 +44,49 @@
}
.edit-form {
.error {
margin-left:5em;
padding:10px;
color:red;
.edit-form-row {
@extend .flex;
@extend .flex-column;
@extend .flex-row-ns;
@extend .mv3;
> :first-child {
@extend .w-100;
@extend .w4-ns;
@extend .flex-grow-0;
@extend .flex-shrink-0;
@extend .tl;
@extend .tr-ns;
@extend .pr0;
@extend .pr2-ns;
@extend .pb1;
@extend .pb0-ns;
font-weight: 500;
}
> :nth-child(2) {
@extend .flex-grow-1;
@extend .overflow-hidden;
}
.pt-input-ns {
// NOTE(ben): This could maybe be more general someday?
@media #{$breakpoint-not-small} {
padding-top: $input-padding;
}
}
}
input[type=text] {
min-width:20em;
@extend .w-100;
@extend .mw5-ns;
}
textarea {
font-size:13pt;
}
.note {
margin-bottom:5px;
font-style:italic;
font-size:90%;
}
.links {
width: 80%;
min-height: 200px;
height: 15vh;
}
.half {
padding:10px;
text-align:center;
}
table {
width:95%;
margin:auto;
border-collapse:separate;
border-spacing: 0px 10px;
td {
padding-bottom:15px;
width:90%;
&.half {
width:50%;
}
table {
width:100%;
}
}
}
th {
text-align:right;
font-weight:bold;
padding-right:10px;
padding-bottom:15px;
vertical-align:top;
max-width:5em;
}
td table th {
text-align:left;
}
.page-options label {
font-weight:bold;
margin-right:20px;
}
&.profile-edit {
.longbio {
width: 100%;
min-height: 400px;
height: 30vh;
}
.avatar-preview {
border:1px solid transparent;
margin:10px;
margin-bottom:0px;
}
textarea.shortbio,
textarea.signature,
{
min-width:300px;
width:50%;
min-height: 100px;
height:4em;
}
.logo-preview {
@include usevar(border-color, 'project-edit-logo-previw-border-color');
width:200px;
border-width: 1px;
}
@extend .w-100;
@extend .w6-ns;
@extend .mw-100;
@extend .h3;
}
&.project-edit {
@ -153,21 +99,21 @@
input.project_blurb,
input.project_name,
{
min-width:300px;
width:50%;
min-width: 300px;
width: 50%;
}
.quota-bar {
@include usevar(border-color, 'project-edit-quota-bar-border-color');
// @include usevar(border-color, 'project-edit-quota-bar-border-color');
width:500px;
width: 500px;
border-width: 1px;
margin-bottom:10px;
margin-bottom: 10px;
.quota-filled {
@include usevar(background-color, 'project-edit-quota-bar-filled-background');
// @include usevar(background-color, 'project-edit-quota-bar-filled-background');
height:100%;
height: 100%;
}
}
}

View File

@ -68,6 +68,7 @@
input[type=text],
input[type=password],
input[type=email],
textarea,
select,
{
@ -102,18 +103,25 @@ select,
}
}
input[type=text], input[type=password] {
input[type=text],
input[type=password],
input[type=email],
{
&:not(.lite) {
padding:5px;
padding: $input-padding;
}
}
textarea {
padding: $input-padding;
}
form .note {
font-style:italic;
}
select {
padding: 5px 10px;
padding: $input-padding 2*$input-padding;
}
option[selected] {

View File

@ -42,6 +42,7 @@ $width-2: 2rem !default;
$width-3: 4rem !default;
$width-4: 8rem !default;
$width-5: 16rem !default;
$width-6: 32rem !default;
$max-width-1: 1rem !default;
$max-width-2: 2rem !default;
$max-width-3: 4rem !default;

View File

@ -54,6 +54,7 @@
.w3 { width: $width-3; }
.w4 { width: $width-4; }
.w5 { width: $width-5; }
.w6 { width: $width-6; }
.w-10 { width: 10%; }
.w-20 { width: 20%; }
@ -80,6 +81,7 @@
.w3-ns { width: $width-3; }
.w4-ns { width: $width-4; }
.w5-ns { width: $width-5; }
.w6-ns { width: $width-6; }
.w-10-ns { width: 10%; }
.w-20-ns { width: 20%; }
.w-25-ns { width: 25%; }
@ -105,6 +107,7 @@
.w3-m { width: $width-3; }
.w4-m { width: $width-4; }
.w5-m { width: $width-5; }
.w6-m { width: $width-6; }
.w-10-m { width: 10%; }
.w-20-m { width: 20%; }
.w-25-m { width: 25%; }
@ -130,6 +133,7 @@
.w3-l { width: $width-3; }
.w4-l { width: $width-4; }
.w5-l { width: $width-5; }
.w6-l { width: $width-6; }
.w-10-l { width: 10%; }
.w-20-l { width: 20%; }
.w-25-l { width: 25%; }

View File

@ -38,9 +38,6 @@ $vars: (
project-card-border-color: #333,
project-user-suggestions-background: #222,
project-user-suggestions-border-color: #444,
project-edit-logo-previw-border-color: #444,
project-edit-quota-bar-border-color: #444,
project-edit-quota-bar-filled-background: #888,
notice-text-color: $fg-font-color,
notice-unapproved-color: #7a2020,

View File

@ -38,9 +38,6 @@ $vars: (
project-card-border-color: #aaa,
project-user-suggestions-background: #fff,
project-user-suggestions-border-color: #ddd,
project-edit-logo-previw-border-color: #999,
project-edit-quota-bar-border-color: #999,
project-edit-quota-bar-filled-background: #444,
notice-text-color: #fff,
notice-unapproved-color: #b42222,

View File

@ -147,6 +147,7 @@ func UserToTemplate(u *models.User, currentTheme string) User {
IsStaff: u.IsStaff,
Name: u.BestName(),
Bio: u.Bio,
Blurb: u.Blurb,
Signature: u.Signature,
DateJoined: u.DateJoined,
@ -162,60 +163,85 @@ func UserToTemplate(u *models.User, currentTheme string) User {
}
}
var RegexServiceYoutube = regexp.MustCompile(`youtube\.com/(c/)?(?P<userdata>[\w/-]+)$`)
var RegexServiceTwitter = regexp.MustCompile(`twitter\.com/(?P<userdata>\w+)$`)
var RegexServiceGithub = regexp.MustCompile(`github\.com/(?P<userdata>[\w/-]+)$`)
var RegexServiceTwitch = regexp.MustCompile(`twitch\.tv/(?P<userdata>[\w/-]+)$`)
var RegexServiceHitbox = regexp.MustCompile(`hitbox\.tv/(?P<userdata>[\w/-]+)$`)
var RegexServicePatreon = regexp.MustCompile(`patreon\.com/(?P<userdata>[\w/-]+)$`)
var RegexServiceSoundcloud = regexp.MustCompile(`soundcloud\.com/(?P<userdata>[\w/-]+)$`)
var RegexServiceItch = regexp.MustCompile(`(?P<userdata>[\w/-]+)\.itch\.io/?$`)
var LinkServiceMap = map[string]*regexp.Regexp{
"youtube": RegexServiceYoutube,
"twitter": RegexServiceTwitter,
"github": RegexServiceGithub,
"twitch": RegexServiceTwitch,
"hitbox": RegexServiceHitbox,
"patreon": RegexServicePatreon,
"soundcloud": RegexServiceSoundcloud,
"itch": RegexServiceItch,
// An online site/service for which we recognize the link
type LinkService struct {
Name string
IconName string
Regex *regexp.Regexp
}
func ParseKnownServicesForLink(link *models.Link) (serviceName string, userData string) {
for name, re := range LinkServiceMap {
match := re.FindStringSubmatch(link.Value)
var LinkServices = []LinkService{
{
Name: "YouTube",
IconName: "youtube",
Regex: regexp.MustCompile(`youtube\.com/(c/)?(?P<userdata>[\w/-]+)$`),
},
{
Name: "Twitter",
IconName: "twitter",
Regex: regexp.MustCompile(`twitter\.com/(?P<userdata>\w+)$`),
},
{
Name: "GitHub",
IconName: "github",
Regex: regexp.MustCompile(`github\.com/(?P<userdata>[\w/-]+)$`),
},
{
Name: "Twitch",
IconName: "twitch",
Regex: regexp.MustCompile(`twitch\.tv/(?P<userdata>[\w/-]+)$`),
},
{
Name: "Hitbox",
IconName: "hitbox",
Regex: regexp.MustCompile(`hitbox\.tv/(?P<userdata>[\w/-]+)$`),
},
{
Name: "Patreon",
IconName: "patreon",
Regex: regexp.MustCompile(`patreon\.com/(?P<userdata>[\w/-]+)$`),
},
{
Name: "SoundCloud",
IconName: "soundcloud",
Regex: regexp.MustCompile(`soundcloud\.com/(?P<userdata>[\w/-]+)$`),
},
{
Name: "itch.io",
IconName: "itch",
Regex: regexp.MustCompile(`(?P<userdata>[\w/-]+)\.itch\.io/?$`),
},
}
func ParseKnownServicesForLink(link *models.Link) (service LinkService, userData string) {
for _, svc := range LinkServices {
match := svc.Regex.FindStringSubmatch(link.URL)
if match != nil {
serviceName = name
userData = match[re.SubexpIndex("userdata")]
return
return svc, match[svc.Regex.SubexpIndex("userdata")]
}
}
return "", ""
return LinkService{}, ""
}
func LinkToTemplate(link *models.Link) Link {
name := ""
/*
// NOTE(asaf): While Name and Key are separate things, Name is almost always the same as Key in the db, which looks weird.
// So we're just going to ignore Name until we decide it's worth reusing.
if link.Name != nil {
name = *link.Name
}
*/
serviceName, serviceUserData := ParseKnownServicesForLink(link)
if serviceUserData != "" {
name = serviceUserData
tlink := Link{
Name: link.Name,
Url: link.URL,
LinkText: link.URL,
}
if name == "" {
name = link.Value
service, userData := ParseKnownServicesForLink(link)
if tlink.Name == "" && service.Name != "" {
tlink.Name = service.Name
}
return Link{
Key: link.Key,
Name: name,
Icon: serviceName,
Url: link.Value,
if service.IconName != "" {
tlink.Icon = service.IconName
}
if userData != "" {
tlink.LinkText = userData
}
return tlink
}
func TimelineItemsToJSON(items []TimelineItem) string {

View File

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

View File

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

View File

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

View File

@ -137,6 +137,7 @@ type User struct {
ProfileUrl string
DarkTheme bool
ShowEmail bool
Timezone string
CanEditLibrary bool
@ -145,10 +146,10 @@ type User struct {
}
type Link struct {
Key string
Name string
Url string
Icon string
Name string
Url string
LinkText string
Icon string
}
type Podcast struct {

View File

@ -210,10 +210,7 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
}
hashed, err := auth.HashPassword(password)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password"))
}
hashed := auth.HashPassword(password)
c.Perf.StartBlock("SQL", "Create user and one time token")
tx, err := c.Conn.Begin(c.Context())
@ -622,10 +619,7 @@ func DoPasswordResetSubmit(c *RequestContext) ResponseData {
return RejectRequest(c, "Password confirmation doesn't match password")
}
hashed, err := auth.HashPassword(password)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password"))
}
hashed := auth.HashPassword(password)
c.Perf.StartBlock("SQL", "Update user's password and delete reset token")
tx, err := c.Conn.Begin(c.Context())
@ -707,14 +701,10 @@ func tryLogin(c *RequestContext, user *models.User, password string) (bool, erro
// re-hash and save the user's password if necessary
if hashed.IsOutdated() {
newHashed, err := auth.HashPassword(password)
if err == nil {
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
if err != nil {
c.Logger.Error().Err(err).Msg("failed to update user's password")
}
} else {
c.Logger.Error().Err(err).Msg("failed to re-hash password")
newHashed := auth.HashPassword(password)
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
if err != nil {
c.Logger.Error().Err(err).Msg("failed to update user's password")
}
// If errors happen here, we can still continue with logging them in
}

View File

@ -2,9 +2,7 @@ package website
import (
"errors"
"fmt"
"net/http"
"net/url"
"time"
"git.handmade.network/hmn/hmn/src/auth"
@ -14,62 +12,8 @@ import (
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
)
func DiscordTest(c *RequestContext) ResponseData {
var userDiscord *models.DiscordUser
iUserDiscord, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{},
`
SELECT $columns
FROM handmade_discorduser
WHERE hmn_user_id = $1
`,
c.CurrentUser.ID,
)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
// we're ok, just no user
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current user's Discord account"))
}
} else {
userDiscord = iUserDiscord.(*models.DiscordUser)
}
type templateData struct {
templates.BaseData
DiscordUser *templates.DiscordUser
AuthorizeURL string
UnlinkURL string
}
baseData := getBaseData(c)
baseData.Title = "Discord Test"
params := make(url.Values)
params.Set("response_type", "code")
params.Set("client_id", config.Config.Discord.OAuthClientID)
params.Set("scope", "identify")
params.Set("state", c.CurrentSession.CSRFToken)
params.Set("redirect_uri", hmnurl.BuildDiscordOAuthCallback())
td := templateData{
BaseData: baseData,
AuthorizeURL: fmt.Sprintf("https://discord.com/api/oauth2/authorize?%s", params.Encode()),
UnlinkURL: hmnurl.BuildDiscordUnlink(),
}
if userDiscord != nil {
u := templates.DiscordUserToTemplate(userDiscord)
td.DiscordUser = &u
}
var res ResponseData
res.MustWriteTemplate("discordtest.html", td, c.Perf)
return res
}
func DiscordOAuthCallback(c *RequestContext) ResponseData {
query := c.Req.URL.Query()
@ -95,14 +39,19 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
}
// Check for error values and redirect back to ????
if query.Get("error") != "" {
if errCode := query.Get("error"); errCode != "" {
// TODO: actually handle these errors
return ErrorResponse(http.StatusBadRequest, errors.New(query.Get("error")))
if errCode == "access_denied" {
// This occurs when the user cancels. Just go back to the profile page.
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
} else {
return RejectRequest(c, "Failed to authenticate with Discord.")
}
}
// Do the actual token exchange and redirect back to ????
code := query.Get("code")
res, err := discord.ExchangeOAuthCode(c.Context(), code, hmnurl.BuildDiscordOAuthCallback()) // TODO: Redirect to the right place
res, err := discord.ExchangeOAuthCode(c.Context(), code, hmnurl.BuildDiscordOAuthCallback())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to exchange Discord authorization code"))
}
@ -139,7 +88,7 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save new Discord user info"))
}
return c.Redirect(hmnurl.BuildDiscordTest(), http.StatusSeeOther)
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
}
func DiscordUnlink(c *RequestContext) ResponseData {
@ -159,7 +108,7 @@ func DiscordUnlink(c *RequestContext) ResponseData {
)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return c.Redirect(hmnurl.BuildDiscordTest(), http.StatusSeeOther)
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get Discord user for unlink"))
}
@ -187,5 +136,59 @@ func DiscordUnlink(c *RequestContext) ResponseData {
c.Logger.Warn().Err(err).Msg("failed to remove member role on unlink")
}
return c.Redirect(hmnurl.BuildDiscordTest(), http.StatusSeeOther)
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
}
func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
iduser, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{},
`SELECT $columns FROM handmade_discorduser WHERE hmn_user_id = $1`,
c.CurrentUser.ID,
)
if errors.Is(err, db.ErrNoMatchingRows) {
// Nothing to do
c.Logger.Warn().Msg("could not do showcase backlog because no discord user exists")
return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther)
} else if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get discord user"))
}
duser := iduser.(*models.DiscordUser)
ok, err := discord.AllowedToCreateMessageSnippet(c.Context(), c.Conn, duser.UserID)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
}
if !ok {
// Not allowed to do this, bail out
c.Logger.Warn().Msg("was not allowed to save user snippets")
return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther)
}
type messageIdQuery struct {
MessageID string `db:"msg.id"`
}
imsgIds, err := db.Query(c.Context(), c.Conn, messageIdQuery{},
`
SELECT $columns
FROM
handmade_discordmessage AS msg
WHERE
msg.user_id = $1
AND msg.channel_id = $2
`,
duser.UserID,
config.Config.Discord.ShowcaseChannelID,
)
msgIds := imsgIds.ToSlice()
for _, imsgId := range msgIds {
msgId := imsgId.(*messageIdQuery)
_, err := discord.CreateMessageSnippet(c.Context(), c.Conn, msgId.MessageID)
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to create snippet from showcase backlog")
continue
}
}
return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther)
}

View File

@ -0,0 +1,93 @@
package website
import (
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"image"
"io"
"net/http"
"os"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/oops"
)
// If a helper method returns this, you should call RejectRequest with the value.
type RejectRequestError error
/*
Reads an image file from form data and saves it to the filesystem and the database.
If the file doesn't exist, this does nothing.
NOTE(ben): Someday we should replace this with the asset system.
*/
func SaveImageFile(c *RequestContext, dbConn db.ConnOrTx, fileFieldName string, maxSize int64, filepath string) (imageFileId int, err error) {
img, header, err := c.Req.FormFile(fileFieldName)
filename := ""
width := 0
height := 0
if err != nil && !errors.Is(err, http.ErrMissingFile) {
return 0, oops.New(err, "failed to read uploaded file")
}
if header != nil {
if header.Size > maxSize {
return 0, RejectRequestError(fmt.Errorf("Image filesize too big. Max size: %d bytes", maxSize))
} else {
c.Perf.StartBlock("IMAGE", "Decoding image")
config, format, err := image.DecodeConfig(img)
c.Perf.EndBlock()
if err != nil {
return 0, RejectRequestError(errors.New("Image type not supported"))
}
width = config.Width
height = config.Height
if width == 0 || height == 0 {
return 0, RejectRequestError(errors.New("Image has zero size"))
}
filename = fmt.Sprintf("%s.%s", filepath, format)
storageFilename := fmt.Sprintf("public/media/%s", filename)
c.Perf.StartBlock("IMAGE", "Writing image file")
file, err := os.Create(storageFilename)
if err != nil {
return 0, oops.New(err, "Failed to create local image file")
}
img.Seek(0, io.SeekStart)
_, err = io.Copy(file, img)
if err != nil {
return 0, oops.New(err, "Failed to write image to file")
}
file.Close()
img.Close()
c.Perf.EndBlock()
}
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Saving image file")
if filename != "" {
hasher := sha1.New()
img.Seek(0, io.SeekStart)
io.Copy(hasher, img) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs
sha1sum := hasher.Sum(nil)
var imageId int
err = dbConn.QueryRow(c.Context(),
`
INSERT INTO handmade_imagefile (file, size, sha1sum, protected, width, height)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`,
filename, header.Size, hex.EncodeToString(sha1sum), false, width, height,
).Scan(&imageId)
if err != nil {
return 0, oops.New(err, "Failed to insert image file row")
}
return imageId, nil
}
return 0, nil
}

View File

@ -1,11 +1,8 @@
package website
import (
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"image"
"io"
"io/fs"
"net/http"
@ -142,47 +139,6 @@ func PodcastEditSubmit(c *RequestContext) ResponseData {
if len(strings.TrimSpace(description)) == 0 {
return RejectRequest(c, "Podcast description is empty")
}
podcastImage, header, err := c.Req.FormFile("podcast_image")
imageFilename := ""
imageWidth := 0
imageHeight := 0
if err != nil && !errors.Is(err, http.ErrMissingFile) {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read uploaded file"))
}
if header != nil {
if header.Size > maxFileSize {
return RejectRequest(c, fmt.Sprintf("Image filesize too big. Max size: %d bytes", maxFileSize))
} else {
c.Perf.StartBlock("PODCAST", "Decoding image")
config, format, err := image.DecodeConfig(podcastImage)
c.Perf.EndBlock()
if err != nil {
return RejectRequest(c, "Image type not supported")
}
imageWidth = config.Width
imageHeight = config.Height
if imageWidth == 0 || imageHeight == 0 {
return RejectRequest(c, "Image has zero size")
}
imageFilename = fmt.Sprintf("podcast/%s/logo%d.%s", c.CurrentProject.Slug, time.Now().UTC().Unix(), format)
storageFilename := fmt.Sprintf("public/media/%s", imageFilename)
c.Perf.StartBlock("PODCAST", "Writing image file")
file, err := os.Create(storageFilename)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to create local image file"))
}
podcastImage.Seek(0, io.SeekStart)
_, err = io.Copy(file, podcastImage)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to write image to file"))
}
file.Close()
podcastImage.Close()
c.Perf.EndBlock()
}
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Updating podcast")
tx, err := c.Conn.Begin(c.Context())
@ -190,23 +146,18 @@ func PodcastEditSubmit(c *RequestContext) ResponseData {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
}
defer tx.Rollback(c.Context())
if imageFilename != "" {
hasher := sha1.New()
podcastImage.Seek(0, io.SeekStart)
io.Copy(hasher, podcastImage) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs
sha1sum := hasher.Sum(nil)
var imageId int
err = tx.QueryRow(c.Context(),
`
INSERT INTO handmade_imagefile (file, size, sha1sum, protected, width, height)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`,
imageFilename, header.Size, hex.EncodeToString(sha1sum), false, imageWidth, imageHeight,
).Scan(&imageId)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert image file row"))
imageId, err := SaveImageFile(c, tx, "podcast_image", maxFileSize, fmt.Sprintf("podcast/%s/logo%d", c.CurrentProject.Slug, time.Now().UTC().Unix()))
if err != nil {
var rejectErr RejectRequestError
if errors.As(err, &rejectErr) {
return RejectRequest(c, rejectErr.Error())
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to save podcast image"))
}
}
if imageId != 0 {
_, err = tx.Exec(c.Context(),
`
UPDATE handmade_podcast
@ -474,7 +425,7 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
}
c.Perf.StartBlock("MARKDOWN", "Parsing description")
descriptionRendered := parsing.ParseMarkdown(description, parsing.RealMarkdown)
descriptionRendered := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
c.Perf.EndBlock()
guidStr := ""

View File

@ -104,7 +104,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
// CSRF mitigation actions per the OWASP cheat sheet:
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
return func(c *RequestContext) ResponseData {
c.Req.ParseForm()
c.Req.ParseMultipartForm(100 * 1024 * 1024)
csrfToken := c.Req.Form.Get(auth.CSRFFieldName)
if csrfToken != c.CurrentSession.CSRFToken {
c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed CSRF validation - potential attack?")
@ -228,9 +228,12 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
mainRoutes.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode)
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
mainRoutes.GET(hmnurl.RegexDiscordTest, authMiddleware(DiscordTest)) // TODO: Delete this route
mainRoutes.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
mainRoutes.POST(hmnurl.RegexDiscordUnlink, authMiddleware(DiscordUnlink))
mainRoutes.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
mainRoutes.POST(hmnurl.RegexDiscordShowcaseBacklog, authMiddleware(csrfMiddleware(DiscordShowcaseBacklog)))
mainRoutes.GET(hmnurl.RegexUserSettings, authMiddleware(UserSettings))
mainRoutes.POST(hmnurl.RegexUserSettings, authMiddleware(csrfMiddleware(UserSettingsSave)))
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
mainRoutes.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {

View File

@ -332,7 +332,7 @@ func DeletePost(
}
func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedContent string, ipString string, editReason string, editorId *int) (versionId int) {
parsed := parsing.ParseMarkdown(unparsedContent, parsing.RealMarkdown)
parsed := parsing.ParseMarkdown(unparsedContent, parsing.ForumRealMarkdown)
ip := net.ParseIP(ipString)
const previewMaxLength = 100

View File

@ -2,14 +2,21 @@ package website
import (
"errors"
"fmt"
"net/http"
"sort"
"strings"
"git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/discord"
hmnemail "git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
"github.com/jackc/pgx/v4"
)
type UserProfileTemplateData struct {
@ -215,11 +222,15 @@ func UserProfile(c *RequestContext) ResponseData {
c.Perf.EndBlock()
templateUser := templates.UserToTemplate(profileUser, c.Theme)
baseData := getBaseData(c)
baseData.Title = templateUser.Name
var res ResponseData
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
BaseData: baseData,
ProfileUser: templates.UserToTemplate(profileUser, c.Theme),
ProfileUser: templateUser,
ProfileUserLinks: profileUserLinks,
ProfileUserProjects: templateProjects,
TimelineItems: timelineItems,
@ -229,3 +240,270 @@ func UserProfile(c *RequestContext) ResponseData {
}, c.Perf)
return res
}
func UserSettings(c *RequestContext) ResponseData {
var res ResponseData
type UserSettingsTemplateData struct {
templates.BaseData
User templates.User
Email string // these fields are handled specially on templates.User
ShowEmail bool
LinksText string
SubmitUrl string
ContactUrl string
DiscordUser *templates.DiscordUser
DiscordNumUnsavedMessages int
DiscordAuthorizeUrl string
DiscordUnlinkUrl string
DiscordShowcaseBacklogUrl string
}
ilinks, err := db.Query(c.Context(), c.Conn, models.Link{},
`
SELECT $columns
FROM handmade_links
WHERE user_id = $1
ORDER BY ordering
`,
c.CurrentUser.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user links"))
}
links := ilinks.ToSlice()
linksText := ""
for _, ilink := range links {
link := ilink.(*models.Link)
linksText += fmt.Sprintf("%s %s\n", link.URL, link.Name)
}
var tduser *templates.DiscordUser
var numUnsavedMessages int
iduser, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{},
`
SELECT $columns
FROM handmade_discorduser
WHERE hmn_user_id = $1
`,
c.CurrentUser.ID,
)
if errors.Is(err, db.ErrNoMatchingRows) {
// this is fine, but don't fetch any more messages
} else if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user's Discord account"))
} else {
duser := iduser.(*models.DiscordUser)
tmp := templates.DiscordUserToTemplate(duser)
tduser = &tmp
numUnsavedMessages, err = db.QueryInt(c.Context(), c.Conn,
`
SELECT COUNT(*)
FROM
handmade_discordmessage AS msg
LEFT JOIN handmade_discordmessagecontent AS c ON c.message_id = msg.id
WHERE
msg.user_id = $1
AND msg.channel_id = $2
AND c.last_content IS NULL
`,
duser.UserID,
config.Config.Discord.ShowcaseChannelID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check for unsaved user messages"))
}
}
templateUser := templates.UserToTemplate(c.CurrentUser, c.Theme)
baseData := getBaseData(c)
baseData.Title = templateUser.Name
res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{
BaseData: baseData,
User: templateUser,
Email: c.CurrentUser.Email,
ShowEmail: c.CurrentUser.ShowEmail,
LinksText: linksText,
SubmitUrl: hmnurl.BuildUserSettings(""),
ContactUrl: hmnurl.BuildContactPage(),
DiscordUser: tduser,
DiscordNumUnsavedMessages: numUnsavedMessages,
DiscordAuthorizeUrl: discord.GetAuthorizeUrl(c.CurrentSession.CSRFToken),
DiscordUnlinkUrl: hmnurl.BuildDiscordUnlink(),
DiscordShowcaseBacklogUrl: hmnurl.BuildDiscordShowcaseBacklog(),
}, c.Perf)
return res
}
func UserSettingsSave(c *RequestContext) ResponseData {
tx, err := c.Conn.Begin(c.Context())
if err != nil {
panic(err)
}
defer tx.Rollback(c.Context())
form, err := c.GetFormValues()
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to parse form on user update")
return c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther)
}
name := form.Get("realname")
email := form.Get("email")
if !hmnemail.IsEmail(email) {
return RejectRequest(c, "Your email was not valid.")
}
showEmail := form.Get("showemail") != ""
darkTheme := form.Get("darktheme") != ""
blurb := form.Get("shortbio")
signature := form.Get("signature")
bio := form.Get("longbio")
discordShowcaseAuto := form.Get("discord-showcase-auto") != ""
discordDeleteSnippetOnMessageDelete := form.Get("discord-snippet-keep") == ""
_, err = tx.Exec(c.Context(),
`
UPDATE auth_user
SET
name = $2,
email = $3,
showemail = $4,
darktheme = $5,
blurb = $6,
signature = $7,
bio = $8,
discord_save_showcase = $9,
discord_delete_snippet_on_message_delete = $10
WHERE
id = $1
`,
c.CurrentUser.ID,
name,
email,
showEmail,
darkTheme,
blurb,
signature,
bio,
discordShowcaseAuto,
discordDeleteSnippetOnMessageDelete,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user"))
}
// Process links
linksText := form.Get("links")
links := strings.Split(linksText, "\n")
_, err = tx.Exec(c.Context(), `DELETE FROM handmade_links WHERE user_id = $1`, c.CurrentUser.ID)
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to delete old links")
} else {
for i, link := range links {
link = strings.TrimSpace(link)
linkParts := strings.SplitN(link, " ", 2)
url := strings.TrimSpace(linkParts[0])
name := ""
if len(linkParts) > 1 {
name = strings.TrimSpace(linkParts[1])
}
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
continue
}
_, err := tx.Exec(c.Context(),
`
INSERT INTO handmade_links (name, url, ordering, user_id)
VALUES ($1, $2, $3, $4)
`,
name,
url,
i,
c.CurrentUser.ID,
)
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to insert new link")
continue
}
}
}
// Update password
oldPassword := form.Get("old_password")
newPassword := form.Get("new_password1")
newPasswordConfirmation := form.Get("new_password2")
if oldPassword != "" && newPassword != "" {
errorRes := updatePassword(c, tx, oldPassword, newPassword, newPasswordConfirmation)
if errorRes != nil {
return *errorRes
}
}
// Update avatar
_, err = SaveImageFile(c, tx, "avatar", 1*1024*1024, fmt.Sprintf("members/avatars/%s", c.CurrentUser.Username))
if err != nil {
var rejectErr RejectRequestError
if errors.As(err, &rejectErr) {
return RejectRequest(c, rejectErr.Error())
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save new avatar"))
}
}
// TODO: Success message
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save user settings"))
}
return c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther)
}
// TODO: Rework this to use that RejectRequestError thing
func updatePassword(c *RequestContext, tx pgx.Tx, old, new, confirm string) *ResponseData {
if new != confirm {
res := RejectRequest(c, "Your password and password confirmation did not match.")
return &res
}
oldHashedPassword, err := auth.ParsePasswordString(c.CurrentUser.Password)
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to parse user's password string")
return nil
}
ok, err := auth.CheckPassword(old, oldHashedPassword)
if err != nil {
res := ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check user's password"))
return &res
}
if !ok {
res := RejectRequest(c, "The old password you provided was not correct.")
return &res
}
newHashedPassword := auth.HashPassword(new)
err = auth.UpdatePassword(c.Context(), tx, c.CurrentUser.Username, newHashedPassword)
if err != nil {
res := ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update password"))
return &res
}
return nil
}

View File

@ -45,6 +45,7 @@ var WebsiteCommand = &cobra.Command{
auth.PeriodicallyDeleteInactiveUsers(backgroundJobContext, conn),
perfCollector.Done,
discord.RunDiscordBot(backgroundJobContext, conn),
discord.RunHistoryWatcher(backgroundJobContext, conn),
)
signals := make(chan os.Signal, 1)