Merge branch 'master' of gitssh.handmade.network:hmn/hmn
This commit is contained in:
commit
b29ae69a25
5
go.mod
5
go.mod
|
@ -7,6 +7,11 @@ require (
|
||||||
github.com/Masterminds/semver v1.5.0 // indirect
|
github.com/Masterminds/semver v1.5.0 // indirect
|
||||||
github.com/Masterminds/sprig v2.22.0+incompatible
|
github.com/Masterminds/sprig v2.22.0+incompatible
|
||||||
github.com/alecthomas/chroma v0.9.2
|
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/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8
|
||||||
github.com/go-stack/stack v1.8.0
|
github.com/go-stack/stack v1.8.0
|
||||||
github.com/google/uuid v1.2.0
|
github.com/google/uuid v1.2.0
|
||||||
|
|
30
go.sum
30
go.sum
|
@ -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/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-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/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 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/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
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/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.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.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/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-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/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.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
github.com/jackc/puddle v1.1.3 h1:JnPg/5Q9xVJGfjsO5CPUOjnJps1JaRUm8I9FXVCFK94=
|
github.com/jackc/puddle v1.1.3 h1:JnPg/5Q9xVJGfjsO5CPUOjnJps1JaRUm8I9FXVCFK94=
|
||||||
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
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/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 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
|
||||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
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.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.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.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.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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
|
|
@ -0,0 +1,95 @@
|
||||||
|
function TabState(tabbed) {
|
||||||
|
this.container = tabbed;
|
||||||
|
this.tabs = tabbed.querySelector(".tab");
|
||||||
|
|
||||||
|
this.tabbar = document.createElement("div");
|
||||||
|
this.tabbar.classList.add("tab-bar");
|
||||||
|
this.container.insertBefore(this.tabbar, this.container.firstChild);
|
||||||
|
|
||||||
|
this.current_i = -1;
|
||||||
|
this.tab_buttons = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function switch_tab_old(state, tab_i) {
|
||||||
|
return function() {
|
||||||
|
if (state.current_i >= 0) {
|
||||||
|
state.tabs[state.current_i].classList.add("hidden");
|
||||||
|
state.tab_buttons[state.current_i].classList.remove("current");
|
||||||
|
}
|
||||||
|
|
||||||
|
state.tabs[tab_i].classList.remove("hidden");
|
||||||
|
state.tab_buttons[tab_i].classList.add("current");
|
||||||
|
|
||||||
|
var hash = "";
|
||||||
|
if (state.tabs[tab_i].hasAttribute("data-url-hash")) {
|
||||||
|
hash = state.tabs[tab_i].getAttribute("data-url-hash");
|
||||||
|
}
|
||||||
|
window.location.hash = hash;
|
||||||
|
|
||||||
|
state.current_i = tab_i;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
const tabContainers = document.getElementsByClassName("tabbed");
|
||||||
|
for (const container of tabContainers) {
|
||||||
|
const tabBar = document.createElement("div");
|
||||||
|
tabBar.classList.add("tab-bar");
|
||||||
|
container.insertAdjacentElement('afterbegin', tabBar);
|
||||||
|
|
||||||
|
const tabs = container.querySelectorAll(".tab");
|
||||||
|
for (let i = 0; i < tabs.length; i++) {
|
||||||
|
const tab = tabs[i];
|
||||||
|
tab.classList.toggle('dn', i > 0);
|
||||||
|
|
||||||
|
const slug = tab.getAttribute("data-slug");
|
||||||
|
|
||||||
|
// TODO: Should this element be a link?
|
||||||
|
const tabButton = document.createElement("div");
|
||||||
|
tabButton.classList.add("tab-button");
|
||||||
|
tabButton.classList.toggle("current", i === 0);
|
||||||
|
tabButton.innerText = tab.getAttribute("data-name");
|
||||||
|
tabButton.setAttribute("data-slug", slug);
|
||||||
|
|
||||||
|
tabButton.addEventListener("click", () => {
|
||||||
|
switchTab(container, slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
tabBar.appendChild(tabButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialSlug = window.location.hash;
|
||||||
|
if (initialSlug) {
|
||||||
|
switchTab(container, initialSlug.substring(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function switchTab(container, slug) {
|
||||||
|
const tabs = container.querySelectorAll('.tab');
|
||||||
|
|
||||||
|
let didMatch = false;
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const slugMatches = tab.getAttribute("data-slug") === slug;
|
||||||
|
tab.classList.toggle('dn', !slugMatches);
|
||||||
|
// TODO: Also update the tab button styles
|
||||||
|
|
||||||
|
if (slugMatches) {
|
||||||
|
didMatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabButtons = document.querySelectorAll(".tab-button");
|
||||||
|
for (const tabButton of tabButtons) {
|
||||||
|
const buttonSlug = tabButton.getAttribute("data-slug");
|
||||||
|
tabButton.classList.toggle('current', slug === buttonSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!didMatch) {
|
||||||
|
// switch to first tab as a fallback
|
||||||
|
tabs[0].classList.remove('dn');
|
||||||
|
tabButtons[0].classList.add('current');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.hash = slug;
|
||||||
|
}
|
177
public/style.css
177
public/style.css
|
@ -1994,7 +1994,7 @@ img, video {
|
||||||
-l = large
|
-l = large
|
||||||
|
|
||||||
*/
|
*/
|
||||||
.flex {
|
.flex, .tab-bar, .edit-form .edit-form-row {
|
||||||
display: flex; }
|
display: flex; }
|
||||||
|
|
||||||
.inline-flex {
|
.inline-flex {
|
||||||
|
@ -2012,10 +2012,10 @@ img, video {
|
||||||
.flex-none {
|
.flex-none {
|
||||||
flex: none; }
|
flex: none; }
|
||||||
|
|
||||||
.flex-column {
|
.flex-column, .edit-form .edit-form-row {
|
||||||
flex-direction: column; }
|
flex-direction: column; }
|
||||||
|
|
||||||
.flex-row {
|
.flex-row, .tab-bar {
|
||||||
flex-direction: row; }
|
flex-direction: row; }
|
||||||
|
|
||||||
.flex-wrap {
|
.flex-wrap {
|
||||||
|
@ -2126,13 +2126,13 @@ img, video {
|
||||||
.order-last {
|
.order-last {
|
||||||
order: 99999; }
|
order: 99999; }
|
||||||
|
|
||||||
.flex-grow-0 {
|
.flex-grow-0, .edit-form .edit-form-row > :first-child {
|
||||||
flex-grow: 0; }
|
flex-grow: 0; }
|
||||||
|
|
||||||
.flex-grow-1 {
|
.flex-grow-1, .edit-form .edit-form-row > :nth-child(2) {
|
||||||
flex-grow: 1; }
|
flex-grow: 1; }
|
||||||
|
|
||||||
.flex-shrink-0 {
|
.flex-shrink-0, .edit-form .edit-form-row > :first-child {
|
||||||
flex-shrink: 0; }
|
flex-shrink: 0; }
|
||||||
|
|
||||||
.flex-shrink-1 {
|
.flex-shrink-1 {
|
||||||
|
@ -2153,7 +2153,7 @@ img, video {
|
||||||
flex: none; }
|
flex: none; }
|
||||||
.flex-column-ns {
|
.flex-column-ns {
|
||||||
flex-direction: column; }
|
flex-direction: column; }
|
||||||
.flex-row-ns {
|
.flex-row-ns, .edit-form .edit-form-row {
|
||||||
flex-direction: row; }
|
flex-direction: row; }
|
||||||
.flex-wrap-ns {
|
.flex-wrap-ns {
|
||||||
flex-wrap: wrap; }
|
flex-wrap: wrap; }
|
||||||
|
@ -2771,7 +2771,7 @@ code, .code {
|
||||||
.h2 {
|
.h2 {
|
||||||
height: 2rem; }
|
height: 2rem; }
|
||||||
|
|
||||||
.h3 {
|
.h3, .edit-form textarea {
|
||||||
height: 4rem; }
|
height: 4rem; }
|
||||||
|
|
||||||
.h4 {
|
.h4 {
|
||||||
|
@ -3079,7 +3079,7 @@ code, .code {
|
||||||
|
|
||||||
*/
|
*/
|
||||||
/* Max Width Percentages */
|
/* Max Width Percentages */
|
||||||
.mw-100 {
|
.mw-100, .edit-form textarea {
|
||||||
max-width: 100%; }
|
max-width: 100%; }
|
||||||
|
|
||||||
/* Max Width Scale */
|
/* Max Width Scale */
|
||||||
|
@ -3125,7 +3125,7 @@ code, .code {
|
||||||
max-width: 4rem; }
|
max-width: 4rem; }
|
||||||
.mw4-ns {
|
.mw4-ns {
|
||||||
max-width: 8rem; }
|
max-width: 8rem; }
|
||||||
.mw5-ns {
|
.mw5-ns, .edit-form input[type=text] {
|
||||||
max-width: 16rem; }
|
max-width: 16rem; }
|
||||||
.mw6-ns {
|
.mw6-ns {
|
||||||
max-width: 32rem; }
|
max-width: 32rem; }
|
||||||
|
@ -3243,6 +3243,9 @@ code, .code {
|
||||||
.w5 {
|
.w5 {
|
||||||
width: 16rem; }
|
width: 16rem; }
|
||||||
|
|
||||||
|
.w6 {
|
||||||
|
width: 32rem; }
|
||||||
|
|
||||||
.w-10 {
|
.w-10 {
|
||||||
width: 10%; }
|
width: 10%; }
|
||||||
|
|
||||||
|
@ -3282,7 +3285,7 @@ code, .code {
|
||||||
.w-90 {
|
.w-90 {
|
||||||
width: 90%; }
|
width: 90%; }
|
||||||
|
|
||||||
.w-100 {
|
.w-100, .edit-form .edit-form-row > :first-child, .edit-form input[type=text], .edit-form textarea {
|
||||||
width: 100%; }
|
width: 100%; }
|
||||||
|
|
||||||
.w-third {
|
.w-third {
|
||||||
|
@ -3301,10 +3304,12 @@ code, .code {
|
||||||
width: 2rem; }
|
width: 2rem; }
|
||||||
.w3-ns {
|
.w3-ns {
|
||||||
width: 4rem; }
|
width: 4rem; }
|
||||||
.w4-ns {
|
.w4-ns, .edit-form .edit-form-row > :first-child {
|
||||||
width: 8rem; }
|
width: 8rem; }
|
||||||
.w5-ns {
|
.w5-ns {
|
||||||
width: 16rem; }
|
width: 16rem; }
|
||||||
|
.w6-ns, .edit-form textarea {
|
||||||
|
width: 32rem; }
|
||||||
.w-10-ns {
|
.w-10-ns {
|
||||||
width: 10%; }
|
width: 10%; }
|
||||||
.w-20-ns {
|
.w-20-ns {
|
||||||
|
@ -3351,6 +3356,8 @@ code, .code {
|
||||||
width: 8rem; }
|
width: 8rem; }
|
||||||
.w5-m {
|
.w5-m {
|
||||||
width: 16rem; }
|
width: 16rem; }
|
||||||
|
.w6-m {
|
||||||
|
width: 32rem; }
|
||||||
.w-10-m {
|
.w-10-m {
|
||||||
width: 10%; }
|
width: 10%; }
|
||||||
.w-20-m {
|
.w-20-m {
|
||||||
|
@ -3397,6 +3404,8 @@ code, .code {
|
||||||
width: 8rem; }
|
width: 8rem; }
|
||||||
.w5-l {
|
.w5-l {
|
||||||
width: 16rem; }
|
width: 16rem; }
|
||||||
|
.w6-l {
|
||||||
|
width: 32rem; }
|
||||||
.w-10-l {
|
.w-10-l {
|
||||||
width: 10%; }
|
width: 10%; }
|
||||||
.w-20-l {
|
.w-20-l {
|
||||||
|
@ -3445,7 +3454,7 @@ code, .code {
|
||||||
.overflow-visible {
|
.overflow-visible {
|
||||||
overflow: visible; }
|
overflow: visible; }
|
||||||
|
|
||||||
.overflow-hidden {
|
.overflow-hidden, .edit-form .edit-form-row > :nth-child(2) {
|
||||||
overflow: hidden; }
|
overflow: hidden; }
|
||||||
|
|
||||||
.overflow-scroll {
|
.overflow-scroll {
|
||||||
|
@ -4614,7 +4623,7 @@ code, .code {
|
||||||
.pl7 {
|
.pl7 {
|
||||||
padding-left: 16rem; }
|
padding-left: 16rem; }
|
||||||
|
|
||||||
.pr0 {
|
.pr0, .edit-form .edit-form-row > :first-child {
|
||||||
padding-right: 0; }
|
padding-right: 0; }
|
||||||
|
|
||||||
.pr1 {
|
.pr1 {
|
||||||
|
@ -4641,7 +4650,7 @@ code, .code {
|
||||||
.pb0 {
|
.pb0 {
|
||||||
padding-bottom: 0; }
|
padding-bottom: 0; }
|
||||||
|
|
||||||
.pb1 {
|
.pb1, .edit-form .edit-form-row > :first-child {
|
||||||
padding-bottom: 0.25rem; }
|
padding-bottom: 0.25rem; }
|
||||||
|
|
||||||
.pb2 {
|
.pb2 {
|
||||||
|
@ -4698,7 +4707,7 @@ code, .code {
|
||||||
padding-top: 0.25rem;
|
padding-top: 0.25rem;
|
||||||
padding-bottom: 0.25rem; }
|
padding-bottom: 0.25rem; }
|
||||||
|
|
||||||
.pv2, header .menu-bar .items a.project-logo,
|
.pv2, header .menu-bar .items a.project-logo, .tab-bar .tab-button,
|
||||||
button,
|
button,
|
||||||
.button,
|
.button,
|
||||||
input[type=button],
|
input[type=button],
|
||||||
|
@ -4742,7 +4751,7 @@ input[type=submit] {
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
padding-right: 0.5rem; }
|
padding-right: 0.5rem; }
|
||||||
|
|
||||||
.ph3,
|
.ph3, .tab-bar .tab-button,
|
||||||
button,
|
button,
|
||||||
.button,
|
.button,
|
||||||
input[type=button],
|
input[type=button],
|
||||||
|
@ -4898,7 +4907,7 @@ input[type=submit] {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
margin-bottom: 0.5rem; }
|
margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
.mv3, hr {
|
.mv3, hr, .edit-form .edit-form-row {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
margin-bottom: 1rem; }
|
margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
@ -4987,7 +4996,7 @@ input[type=submit] {
|
||||||
padding-right: 0; }
|
padding-right: 0; }
|
||||||
.pr1-ns {
|
.pr1-ns {
|
||||||
padding-right: 0.25rem; }
|
padding-right: 0.25rem; }
|
||||||
.pr2-ns {
|
.pr2-ns, .edit-form .edit-form-row > :first-child {
|
||||||
padding-right: 0.5rem; }
|
padding-right: 0.5rem; }
|
||||||
.pr3-ns {
|
.pr3-ns {
|
||||||
padding-right: 1rem; }
|
padding-right: 1rem; }
|
||||||
|
@ -4999,7 +5008,7 @@ input[type=submit] {
|
||||||
padding-right: 8rem; }
|
padding-right: 8rem; }
|
||||||
.pr7-ns {
|
.pr7-ns {
|
||||||
padding-right: 16rem; }
|
padding-right: 16rem; }
|
||||||
.pb0-ns {
|
.pb0-ns, .edit-form .edit-form-row > :first-child {
|
||||||
padding-bottom: 0; }
|
padding-bottom: 0; }
|
||||||
.pb1-ns {
|
.pb1-ns {
|
||||||
padding-bottom: 0.25rem; }
|
padding-bottom: 0.25rem; }
|
||||||
|
@ -6169,7 +6178,7 @@ input[type=submit] {
|
||||||
-l = large
|
-l = large
|
||||||
|
|
||||||
*/
|
*/
|
||||||
.tl {
|
.tl, .edit-form .edit-form-row > :first-child {
|
||||||
text-align: left; }
|
text-align: left; }
|
||||||
|
|
||||||
.tr {
|
.tr {
|
||||||
|
@ -6184,7 +6193,7 @@ input[type=submit] {
|
||||||
@media screen and (min-width: 30em) {
|
@media screen and (min-width: 30em) {
|
||||||
.tl-ns {
|
.tl-ns {
|
||||||
text-align: left; }
|
text-align: left; }
|
||||||
.tr-ns {
|
.tr-ns, .edit-form .edit-form-row > :first-child {
|
||||||
text-align: right; }
|
text-align: right; }
|
||||||
.tc-ns {
|
.tc-ns {
|
||||||
text-align: center; }
|
text-align: center; }
|
||||||
|
@ -7204,7 +7213,7 @@ body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.5em;
|
line-height: 1.2em;
|
||||||
font-weight: 400; }
|
font-weight: 400; }
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
@ -7321,10 +7330,10 @@ article code {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto; }
|
margin-right: auto; }
|
||||||
|
|
||||||
.flex-shrink-0 {
|
.flex-shrink-0, .edit-form .edit-form-row > :first-child {
|
||||||
flex-shrink: 0; }
|
flex-shrink: 0; }
|
||||||
|
|
||||||
.flex-grow-1 {
|
.flex-grow-1, .edit-form .edit-form-row > :nth-child(2) {
|
||||||
flex-grow: 1; }
|
flex-grow: 1; }
|
||||||
|
|
||||||
.flex-fair {
|
.flex-fair {
|
||||||
|
@ -7780,32 +7789,20 @@ header {
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
border-color: #d8d8d8;
|
border-color: #d8d8d8;
|
||||||
border-color: var(--tab-border-color);
|
border-color: var(--tab-border-color);
|
||||||
width: 100%;
|
width: 100%; }
|
||||||
border-bottom-width: 1px;
|
|
||||||
border-bottom-style: solid;
|
|
||||||
box-sizing: border-box; }
|
|
||||||
.tab-bar .tab-button {
|
.tab-bar .tab-button {
|
||||||
background-color: #dfdfdf;
|
background-color: #dfdfdf;
|
||||||
background-color: var(--tab-button-background);
|
background-color: var(--tab-button-background);
|
||||||
border-color: #d8d8d8;
|
border-color: #d8d8d8;
|
||||||
border-color: var(--tab-border-color);
|
border-color: var(--tab-border-color);
|
||||||
height: 100%;
|
cursor: pointer; }
|
||||||
display: inline-block;
|
|
||||||
padding: 10px 15px;
|
|
||||||
line-height: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
box-sizing: border-box; }
|
|
||||||
.tab-bar .tab-button:hover {
|
.tab-bar .tab-button:hover {
|
||||||
background-color: #efefef;
|
background-color: #efefef;
|
||||||
background-color: var(--tab-button-background-hover); }
|
background-color: var(--tab-button-background-hover); }
|
||||||
.tab-bar .tab-button.current {
|
.tab-bar .tab-button.current {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
background-color: var(--tab-button-background-current);
|
background-color: var(--tab-button-background-current);
|
||||||
border-bottom-color: transparent;
|
font-weight: 500; }
|
||||||
font-weight: bold;
|
|
||||||
height: 105%; }
|
|
||||||
|
|
||||||
.pagination .page.current {
|
.pagination .page.current {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
@ -8016,81 +8013,12 @@ pre {
|
||||||
max-height: calc(100vh - 20rem);
|
max-height: calc(100vh - 20rem);
|
||||||
overflow: auto; } }
|
overflow: auto; } }
|
||||||
|
|
||||||
.edit-form .error {
|
.edit-form .edit-form-row > :first-child {
|
||||||
margin-left: 5em;
|
font-weight: 500; }
|
||||||
padding: 10px;
|
|
||||||
color: red; }
|
|
||||||
|
|
||||||
.edit-form input[type=text] {
|
@media screen and (min-width: 30em) {
|
||||||
min-width: 20em; }
|
.edit-form .edit-form-row .pt-input-ns {
|
||||||
|
padding-top: 0.3rem; } }
|
||||||
.edit-form textarea {
|
|
||||||
font-size: 13pt; }
|
|
||||||
|
|
||||||
.edit-form .note {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 90%; }
|
|
||||||
|
|
||||||
.edit-form .links {
|
|
||||||
width: 80%;
|
|
||||||
min-height: 200px;
|
|
||||||
height: 15vh; }
|
|
||||||
|
|
||||||
.edit-form .half {
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center; }
|
|
||||||
|
|
||||||
.edit-form table {
|
|
||||||
width: 95%;
|
|
||||||
margin: auto;
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 0px 10px; }
|
|
||||||
.edit-form table td {
|
|
||||||
padding-bottom: 15px;
|
|
||||||
width: 90%; }
|
|
||||||
.edit-form table td.half {
|
|
||||||
width: 50%; }
|
|
||||||
.edit-form table td table {
|
|
||||||
width: 100%; }
|
|
||||||
|
|
||||||
.edit-form th {
|
|
||||||
text-align: right;
|
|
||||||
font-weight: bold;
|
|
||||||
padding-right: 10px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
vertical-align: top;
|
|
||||||
max-width: 5em; }
|
|
||||||
|
|
||||||
.edit-form td table th {
|
|
||||||
text-align: left; }
|
|
||||||
|
|
||||||
.edit-form .page-options label {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-right: 20px; }
|
|
||||||
|
|
||||||
.edit-form.profile-edit .longbio {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 400px;
|
|
||||||
height: 30vh; }
|
|
||||||
|
|
||||||
.edit-form.profile-edit .avatar-preview {
|
|
||||||
border: 1px solid transparent;
|
|
||||||
margin: 10px;
|
|
||||||
margin-bottom: 0px; }
|
|
||||||
|
|
||||||
.edit-form.profile-edit textarea.shortbio,
|
|
||||||
.edit-form.profile-edit textarea.signature {
|
|
||||||
min-width: 300px;
|
|
||||||
width: 50%;
|
|
||||||
min-height: 100px;
|
|
||||||
height: 4em; }
|
|
||||||
|
|
||||||
.edit-form.profile-edit .logo-preview {
|
|
||||||
border-color: #999;
|
|
||||||
border-color: var(--project-edit-logo-previw-border-color);
|
|
||||||
width: 200px;
|
|
||||||
border-width: 1px; }
|
|
||||||
|
|
||||||
.edit-form.project-edit .project_description {
|
.edit-form.project-edit .project_description {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -8103,14 +8031,10 @@ pre {
|
||||||
width: 50%; }
|
width: 50%; }
|
||||||
|
|
||||||
.edit-form.project-edit .quota-bar {
|
.edit-form.project-edit .quota-bar {
|
||||||
border-color: #999;
|
|
||||||
border-color: var(--project-edit-quota-bar-border-color);
|
|
||||||
width: 500px;
|
width: 500px;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
margin-bottom: 10px; }
|
margin-bottom: 10px; }
|
||||||
.edit-form.project-edit .quota-bar .quota-filled {
|
.edit-form.project-edit .quota-bar .quota-filled {
|
||||||
background-color: #444;
|
|
||||||
background-color: var(--project-edit-quota-bar-filled-background);
|
|
||||||
height: 100%; }
|
height: 100%; }
|
||||||
|
|
||||||
.episode-list .description p {
|
.episode-list .description p {
|
||||||
|
@ -8361,6 +8285,7 @@ nav.timecodes {
|
||||||
|
|
||||||
input[type=text],
|
input[type=text],
|
||||||
input[type=password],
|
input[type=password],
|
||||||
|
input[type=email],
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
color: black;
|
color: black;
|
||||||
|
@ -8375,6 +8300,7 @@ select {
|
||||||
outline: none; }
|
outline: none; }
|
||||||
input[type=text].lite,
|
input[type=text].lite,
|
||||||
input[type=password].lite,
|
input[type=password].lite,
|
||||||
|
input[type=email].lite,
|
||||||
textarea.lite,
|
textarea.lite,
|
||||||
select.lite {
|
select.lite {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
@ -8386,6 +8312,8 @@ select {
|
||||||
input[type=text].lite:focus, input[type=text].lite:active,
|
input[type=text].lite:focus, input[type=text].lite:active,
|
||||||
input[type=password].lite:focus,
|
input[type=password].lite:focus,
|
||||||
input[type=password].lite:active,
|
input[type=password].lite:active,
|
||||||
|
input[type=email].lite:focus,
|
||||||
|
input[type=email].lite:active,
|
||||||
textarea.lite:focus,
|
textarea.lite:focus,
|
||||||
textarea.lite:active,
|
textarea.lite:active,
|
||||||
select.lite:focus,
|
select.lite:focus,
|
||||||
|
@ -8396,6 +8324,8 @@ select {
|
||||||
input[type=text]:active, input[type=text]:focus,
|
input[type=text]:active, input[type=text]:focus,
|
||||||
input[type=password]:active,
|
input[type=password]:active,
|
||||||
input[type=password]:focus,
|
input[type=password]:focus,
|
||||||
|
input[type=email]:active,
|
||||||
|
input[type=email]:focus,
|
||||||
textarea:active,
|
textarea:active,
|
||||||
textarea:focus,
|
textarea:focus,
|
||||||
select:active,
|
select:active,
|
||||||
|
@ -8405,14 +8335,19 @@ select {
|
||||||
border-color: #4c9ed9;
|
border-color: #4c9ed9;
|
||||||
border-color: var(--form-text-border-color-active); }
|
border-color: var(--form-text-border-color-active); }
|
||||||
|
|
||||||
input[type=text]:not(.lite), input[type=password]:not(.lite) {
|
input[type=text]:not(.lite),
|
||||||
padding: 5px; }
|
input[type=password]:not(.lite),
|
||||||
|
input[type=email]:not(.lite) {
|
||||||
|
padding: 0.3rem; }
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
padding: 0.3rem; }
|
||||||
|
|
||||||
form .note {
|
form .note {
|
||||||
font-style: italic; }
|
font-style: italic; }
|
||||||
|
|
||||||
select {
|
select {
|
||||||
padding: 5px 10px; }
|
padding: 0.3rem 0.6rem; }
|
||||||
|
|
||||||
option[selected] {
|
option[selected] {
|
||||||
font-weight: bold; }
|
font-weight: bold; }
|
||||||
|
|
|
@ -237,9 +237,6 @@ will throw an error.
|
||||||
--project-card-border-color: #333;
|
--project-card-border-color: #333;
|
||||||
--project-user-suggestions-background: #222;
|
--project-user-suggestions-background: #222;
|
||||||
--project-user-suggestions-border-color: #444;
|
--project-user-suggestions-border-color: #444;
|
||||||
--project-edit-logo-previw-border-color: #444;
|
|
||||||
--project-edit-quota-bar-border-color: #444;
|
|
||||||
--project-edit-quota-bar-filled-background: #888;
|
|
||||||
--notice-text-color: #eee;
|
--notice-text-color: #eee;
|
||||||
--notice-unapproved-color: #7a2020;
|
--notice-unapproved-color: #7a2020;
|
||||||
--notice-hidden-color: #494949;
|
--notice-hidden-color: #494949;
|
||||||
|
|
|
@ -255,9 +255,6 @@ will throw an error.
|
||||||
--project-card-border-color: #aaa;
|
--project-card-border-color: #aaa;
|
||||||
--project-user-suggestions-background: #fff;
|
--project-user-suggestions-background: #fff;
|
||||||
--project-user-suggestions-border-color: #ddd;
|
--project-user-suggestions-border-color: #ddd;
|
||||||
--project-edit-logo-previw-border-color: #999;
|
|
||||||
--project-edit-quota-bar-border-color: #999;
|
|
||||||
--project-edit-quota-bar-filled-background: #444;
|
|
||||||
--notice-text-color: #fff;
|
--notice-text-color: #fff;
|
||||||
--notice-unapproved-color: #b42222;
|
--notice-unapproved-color: #b42222;
|
||||||
--notice-hidden-color: #b6b6b6;
|
--notice-hidden-color: #b6b6b6;
|
||||||
|
|
|
@ -51,10 +51,7 @@ func init() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := auth.HashPassword(password)
|
hashedPassword := auth.HashPassword(password)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = auth.UpdatePassword(ctx, conn, canonicalUsername, hashedPassword)
|
err = auth.UpdatePassword(ctx, conn, canonicalUsername, hashedPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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"))
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
"git.handmade.network/hmn/hmn/src/logging"
|
"git.handmade.network/hmn/hmn/src/logging"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
@ -150,15 +151,12 @@ func CheckPassword(password string, hashedPassword HashedPassword) (bool, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HashPassword(password string) (HashedPassword, error) {
|
func HashPassword(password string) HashedPassword {
|
||||||
// Follows the OWASP recommendations as of March 2021.
|
// Follows the OWASP recommendations as of March 2021.
|
||||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
|
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
|
||||||
|
|
||||||
salt := make([]byte, saltLength)
|
salt := make([]byte, saltLength)
|
||||||
_, err := io.ReadFull(rand.Reader, salt)
|
io.ReadFull(rand.Reader, salt)
|
||||||
if err != nil {
|
|
||||||
return HashedPassword{}, oops.New(err, "failed to generate salt")
|
|
||||||
}
|
|
||||||
saltEnc := base64.StdEncoding.EncodeToString(salt)
|
saltEnc := base64.StdEncoding.EncodeToString(salt)
|
||||||
|
|
||||||
cfg := Argon2idConfig{
|
cfg := Argon2idConfig{
|
||||||
|
@ -176,12 +174,12 @@ func HashPassword(password string) (HashedPassword, error) {
|
||||||
AlgoConfig: cfg.String(),
|
AlgoConfig: cfg.String(),
|
||||||
Salt: saltEnc,
|
Salt: saltEnc,
|
||||||
Hash: keyEnc,
|
Hash: keyEnc,
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ErrUserDoesNotExist = errors.New("user does not exist")
|
var ErrUserDoesNotExist = errors.New("user does not exist")
|
||||||
|
|
||||||
func UpdatePassword(ctx context.Context, conn *pgxpool.Pool, username string, hp HashedPassword) error {
|
func UpdatePassword(ctx context.Context, conn db.ConnOrTx, username string, hp HashedPassword) error {
|
||||||
tag, err := conn.Exec(ctx, "UPDATE auth_user SET password = $1 WHERE username = $2", hp.String(), username)
|
tag, err := conn.Exec(ctx, "UPDATE auth_user SET password = $1 WHERE username = $2", hp.String(), username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "failed to update password")
|
return oops.New(err, "failed to update password")
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"git.handmade.network/hmn/hmn/src/logging"
|
"git.handmade.network/hmn/hmn/src/logging"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgconn"
|
||||||
"github.com/jackc/pgtype"
|
"github.com/jackc/pgtype"
|
||||||
"github.com/jackc/pgx/v4"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/jackc/pgx/v4/log/zerologadapter"
|
"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.
|
// This interface should match both a direct pgx connection or a pgx transaction.
|
||||||
type ConnOrTx interface {
|
type ConnOrTx interface {
|
||||||
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
|
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()
|
var connInfo = pgtype.NewConnInfo()
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -93,7 +93,7 @@ func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) <-chan struct{} {
|
||||||
|
|
||||||
var outgoingMessagesReady = make(chan struct{}, 1)
|
var outgoingMessagesReady = make(chan struct{}, 1)
|
||||||
|
|
||||||
type discordBotInstance struct {
|
type botInstance struct {
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
dbConn *pgxpool.Pool
|
dbConn *pgxpool.Pool
|
||||||
|
|
||||||
|
@ -116,8 +116,8 @@ type discordBotInstance struct {
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBotInstance(dbConn *pgxpool.Pool) *discordBotInstance {
|
func newBotInstance(dbConn *pgxpool.Pool) *botInstance {
|
||||||
return &discordBotInstance{
|
return &botInstance{
|
||||||
dbConn: dbConn,
|
dbConn: dbConn,
|
||||||
forceHeartbeat: make(chan struct{}),
|
forceHeartbeat: make(chan struct{}),
|
||||||
didAckHeartbeat: true,
|
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
|
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.
|
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)
|
defer utils.RecoverPanicAsError(&err)
|
||||||
|
|
||||||
ctx, bot.cancel = context.WithCancel(ctx)
|
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
|
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!)
|
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)
|
res, err := GetGatewayBot(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "failed to get gateway URL")
|
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
|
Sends outgoing gateway messages and channel messages. Handles heartbeats. This function should be
|
||||||
run as its own goroutine.
|
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.wg.Done()
|
||||||
defer bot.cancel()
|
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()
|
_, msgBytes, err := bot.conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -524,7 +524,7 @@ func (bot *discordBotInstance) receiveGatewayMessage(ctx context.Context) (*Gate
|
||||||
return &msg, nil
|
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")
|
logging.ExtractLogger(ctx).Debug().Interface("msg", msg).Msg("sending gateway message")
|
||||||
return bot.conn.WriteMessage(websocket.TextMessage, msg.ToJSON())
|
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
|
really gone wrong, bad enough that the connection should be shut down. Otherwise it will just log
|
||||||
any errors that occur.
|
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 {
|
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))
|
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 {
|
if err != nil {
|
||||||
return oops.New(err, "error on updated message")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *discordBotInstance) messageCreateOrUpdate(ctx context.Context, msg *Message) error {
|
// TODO: Should this return an error? Or just log errors?
|
||||||
if msg.Author != nil && msg.Author.ID == config.Config.Discord.BotUserID {
|
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
|
// Don't process your own messages
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -587,6 +599,78 @@ func (bot *discordBotInstance) messageCreateOrUpdate(ctx context.Context, msg *M
|
||||||
return nil
|
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 {
|
type MessageToSend struct {
|
||||||
ChannelID string
|
ChannelID string
|
||||||
Req CreateMessageRequest
|
Req CreateMessageRequest
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -2,6 +2,8 @@ package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Opcode int
|
type Opcode int
|
||||||
|
@ -108,10 +110,51 @@ type Resume struct {
|
||||||
SequenceNumber int `json:"seq"`
|
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
|
type ChannelType int
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
const (
|
const (
|
||||||
ChannelTypeGuildext ChannelType = 0
|
ChannelTypeGuildText ChannelType = 0
|
||||||
ChannelTypeDM ChannelType = 1
|
ChannelTypeDM ChannelType = 1
|
||||||
ChannelTypeGuildVoice ChannelType = 2
|
ChannelTypeGuildVoice ChannelType = 2
|
||||||
ChannelTypeGroupDM ChannelType = 3
|
ChannelTypeGroupDM ChannelType = 3
|
||||||
|
@ -124,6 +167,14 @@ const (
|
||||||
ChannelTypeGuildStageVoice ChannelType = 13
|
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 {
|
type Channel struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type ChannelType `json:"type"`
|
Type ChannelType `json:"type"`
|
||||||
|
@ -136,6 +187,7 @@ type Channel struct {
|
||||||
|
|
||||||
type MessageType int
|
type MessageType int
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||||
const (
|
const (
|
||||||
MessageTypeDefault MessageType = 0
|
MessageTypeDefault MessageType = 0
|
||||||
|
|
||||||
|
@ -172,17 +224,55 @@ const (
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ChannelID string `json:"channel_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
|
GuildID *string `json:"guild_id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Author *User `json:"author"` // note that this may not be an actual valid user (see the docs)
|
Author User `json:"author"` // note that this may not be an actual valid user (see the docs)
|
||||||
// TODO: Author info
|
Timestamp string `json:"timestamp"`
|
||||||
// TODO: Timestamp parsing, yay
|
|
||||||
Type MessageType `json:"type"`
|
Type MessageType `json:"type"`
|
||||||
|
|
||||||
Attachments []Attachment `json:"attachments"`
|
Attachments []Attachment `json:"attachments"`
|
||||||
|
Embeds []Embed `json:"embeds"`
|
||||||
|
|
||||||
originalMap map[string]interface{}
|
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 {
|
func MessageFromMap(m interface{}) Message {
|
||||||
/*
|
/*
|
||||||
Some gateway events, like MESSAGE_UPDATE, do not contain the
|
Some gateway events, like MESSAGE_UPDATE, do not contain the
|
||||||
|
@ -194,15 +284,16 @@ func MessageFromMap(m interface{}) Message {
|
||||||
msg := Message{
|
msg := Message{
|
||||||
ID: mmap["id"].(string),
|
ID: mmap["id"].(string),
|
||||||
ChannelID: mmap["channel_id"].(string),
|
ChannelID: mmap["channel_id"].(string),
|
||||||
|
GuildID: maybeStringP(mmap, "guild_id"),
|
||||||
Content: maybeString(mmap, "content"),
|
Content: maybeString(mmap, "content"),
|
||||||
|
Timestamp: maybeString(mmap, "timestamp"),
|
||||||
Type: MessageType(maybeInt(mmap, "type")),
|
Type: MessageType(maybeInt(mmap, "type")),
|
||||||
|
|
||||||
originalMap: mmap,
|
originalMap: mmap,
|
||||||
}
|
}
|
||||||
|
|
||||||
if author, ok := mmap["author"]; ok {
|
if author, ok := mmap["author"]; ok {
|
||||||
u := UserFromMap(author)
|
msg.Author = UserFromMap(author)
|
||||||
msg.Author = &u
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if iattachments, ok := mmap["attachments"]; ok {
|
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
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -241,10 +339,18 @@ func UserFromMap(m interface{}) User {
|
||||||
return u
|
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 {
|
type Attachment struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
ContentType string `json:"content_type"`
|
ContentType *string `json:"content_type"`
|
||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
ProxyUrl string `json:"proxy_url"`
|
ProxyUrl string `json:"proxy_url"`
|
||||||
|
@ -257,7 +363,7 @@ func AttachmentFromMap(m interface{}) Attachment {
|
||||||
a := Attachment{
|
a := Attachment{
|
||||||
ID: mmap["id"].(string),
|
ID: mmap["id"].(string),
|
||||||
Filename: mmap["filename"].(string),
|
Filename: mmap["filename"].(string),
|
||||||
ContentType: maybeString(mmap, "content_type"),
|
ContentType: maybeStringP(mmap, "content_type"),
|
||||||
Size: int(mmap["size"].(float64)),
|
Size: int(mmap["size"].(float64)),
|
||||||
Url: mmap["url"].(string),
|
Url: mmap["url"].(string),
|
||||||
ProxyUrl: mmap["proxy_url"].(string),
|
ProxyUrl: mmap["proxy_url"].(string),
|
||||||
|
@ -268,6 +374,224 @@ func AttachmentFromMap(m interface{}) Attachment {
|
||||||
return a
|
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 {
|
func maybeString(m map[string]interface{}, k string) string {
|
||||||
val, ok := m[k]
|
val, ok := m[k]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
@ -276,6 +600,15 @@ func maybeString(m map[string]interface{}, k string) string {
|
||||||
return val.(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 {
|
func maybeInt(m map[string]interface{}, k string) int {
|
||||||
val, ok := m[k]
|
val, ok := m[k]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
@ -292,3 +625,20 @@ func maybeIntP(m map[string]interface{}, k string) *int {
|
||||||
intval := int(val.(float64))
|
intval := int(val.(float64))
|
||||||
return &intval
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -4,14 +4,17 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/config"
|
"git.handmade.network/hmn/hmn/src/config"
|
||||||
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/logging"
|
"git.handmade.network/hmn/hmn/src/logging"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
)
|
)
|
||||||
|
@ -26,6 +29,8 @@ const (
|
||||||
|
|
||||||
var UserAgent = fmt.Sprintf("%s (%s, %s)", BotName, UserAgentURL, UserAgentVersion)
|
var UserAgent = fmt.Sprintf("%s (%s, %s)", BotName, UserAgentURL, UserAgentVersion)
|
||||||
|
|
||||||
|
var NotFound = errors.New("not found")
|
||||||
|
|
||||||
var httpClient = &http.Client{}
|
var httpClient = &http.Client{}
|
||||||
|
|
||||||
func buildUrl(path string) string {
|
func buildUrl(path string) string {
|
||||||
|
@ -83,6 +88,101 @@ func GetGatewayBot(ctx context.Context) (*GetGatewayBotResponse, error) {
|
||||||
return &result, nil
|
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 {
|
type CreateMessageRequest struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
@ -313,6 +413,103 @@ func RemoveGuildMemberRole(ctx context.Context, userID, roleID string) error {
|
||||||
return nil
|
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) {
|
func logErrorResponse(ctx context.Context, name string, res *http.Response, msg string) {
|
||||||
dump, err := httputil.DumpResponse(res, true)
|
dump, err := httputil.DumpResponse(res, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -2,42 +2,100 @@ package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"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/oops"
|
||||||
|
"git.handmade.network/hmn/hmn/src/parsing"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
var reDiscordMessageLink = regexp.MustCompile(`https?://.+?(\s|$)`)
|
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 {
|
switch msg.Type {
|
||||||
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
|
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
|
||||||
default:
|
default:
|
||||||
return nil
|
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
|
hasGoodContent := true
|
||||||
if originalMessageHasField(msg, "content") && !messageHasLinks(msg.Content) {
|
if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) {
|
||||||
hasGoodContent = false
|
hasGoodContent = false
|
||||||
}
|
}
|
||||||
|
|
||||||
hasGoodAttachments := true
|
hasGoodAttachments := true
|
||||||
if originalMessageHasField(msg, "attachments") && len(msg.Attachments) == 0 {
|
if msg.OriginalHasFields("attachments") && len(msg.Attachments) == 0 {
|
||||||
hasGoodAttachments = false
|
hasGoodAttachments = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
didDelete = false
|
||||||
if !hasGoodContent && !hasGoodAttachments {
|
if !hasGoodContent && !hasGoodAttachments {
|
||||||
|
didDelete = true
|
||||||
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
|
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
|
||||||
if err != nil {
|
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)
|
channel, err := CreateDM(ctx, msg.Author.ID)
|
||||||
if err != nil {
|
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{
|
err = SendMessages(ctx, bot.dbConn, MessageToSend{
|
||||||
|
@ -47,50 +105,596 @@ func (bot *discordBotInstance) processShowcaseMsg(ctx context.Context, msg *Mess
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
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 {
|
Ensures that a Discord message is stored in the database. This function is
|
||||||
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
|
idempotent and can be called regardless of whether the item already exists in
|
||||||
default:
|
the database.
|
||||||
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 !originalMessageHasField(msg, "content") {
|
guildID := msg.GuildID
|
||||||
return nil
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
if !messageHasLinks(msg.Content) {
|
_, err = tx.Exec(ctx,
|
||||||
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
|
`
|
||||||
|
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 {
|
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)
|
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 {
|
if err != nil {
|
||||||
return oops.New(err, "failed to create DM channel")
|
panic(err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, oops.New(err, "failed to check for existing Discord message")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = SendMessages(ctx, bot.dbConn, MessageToSend{
|
return iDiscordMessage.(*models.DiscordMessage), nil
|
||||||
ChannelID: channel.ID,
|
}
|
||||||
Req: CreateMessageRequest{
|
|
||||||
Content: "Posts in #the-library are required to have a link. Discuss library content in other relevant channels.",
|
/*
|
||||||
},
|
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 nil, oops.New(err, "failed to save attachment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return oops.New(err, "failed to send showcase warning message")
|
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
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func messageHasLinks(content string) bool {
|
func messageHasLinks(content string) bool {
|
||||||
|
@ -104,12 +708,3 @@ func messageHasLinks(content string) bool {
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func originalMessageHasField(msg *Message, field string) bool {
|
|
||||||
if msg.originalMap == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, ok := msg.originalMap[field]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
|
@ -194,10 +194,10 @@ func BuildUserProfile(username string) string {
|
||||||
return Url("/m/"+url.PathEscape(username), nil)
|
return Url("/m/"+url.PathEscape(username), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexUserSettings = regexp.MustCompile(`^/_settings$`)
|
var RegexUserSettings = regexp.MustCompile(`^/settings$`)
|
||||||
|
|
||||||
func BuildUserSettings(section string) string {
|
func BuildUserSettings(section string) string {
|
||||||
return ProjectUrlWithFragment("/_settings", nil, "", section)
|
return ProjectUrlWithFragment("/settings", nil, "", section)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -558,12 +558,6 @@ func BuildLibraryResource(projectSlug string, resourceId int) string {
|
||||||
* Discord OAuth
|
* Discord OAuth
|
||||||
*/
|
*/
|
||||||
|
|
||||||
var RegexDiscordTest = regexp.MustCompile("^/discord$")
|
|
||||||
|
|
||||||
func BuildDiscordTest() string {
|
|
||||||
return Url("/discord", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
var RegexDiscordOAuthCallback = regexp.MustCompile("^/_discord_callback$")
|
var RegexDiscordOAuthCallback = regexp.MustCompile("^/_discord_callback$")
|
||||||
|
|
||||||
func BuildDiscordOAuthCallback() string {
|
func BuildDiscordOAuthCallback() string {
|
||||||
|
@ -576,6 +570,12 @@ func BuildDiscordUnlink() string {
|
||||||
return Url("/_discord_unlink", nil)
|
return Url("/_discord_unlink", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var RegexDiscordShowcaseBacklog = regexp.MustCompile("^/discord_showcase_backlog$")
|
||||||
|
|
||||||
|
func BuildDiscordShowcaseBacklog() string {
|
||||||
|
return Url("/discord_showcase_backlog", nil)
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Assets
|
* Assets
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -2,7 +2,9 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "git.handmade.network/hmn/hmn/src/admintools"
|
_ "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/buildscss"
|
||||||
|
_ "git.handmade.network/hmn/hmn/src/discord/cmd"
|
||||||
_ "git.handmade.network/hmn/hmn/src/initimage"
|
_ "git.handmade.network/hmn/hmn/src/initimage"
|
||||||
_ "git.handmade.network/hmn/hmn/src/migration"
|
_ "git.handmade.network/hmn/hmn/src/migration"
|
||||||
"git.handmade.network/hmn/hmn/src/website"
|
"git.handmade.network/hmn/hmn/src/website"
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -41,7 +41,7 @@ account, regardless of whether we create snippets or not.
|
||||||
*/
|
*/
|
||||||
type DiscordMessageContent struct {
|
type DiscordMessageContent struct {
|
||||||
MessageID string `db:"message_id"`
|
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"`
|
DiscordID int `db:"discord_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,9 +2,8 @@ package models
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
ID int `db:"id"`
|
ID int `db:"id"`
|
||||||
Key string `db:"key"`
|
Name string `db:"name"`
|
||||||
Name *string `db:"name"`
|
URL string `db:"url"`
|
||||||
Value string `db:"value"`
|
|
||||||
Ordering int `db:"ordering"`
|
Ordering int `db:"ordering"`
|
||||||
UserID *int `db:"user_id"`
|
UserID *int `db:"user_id"`
|
||||||
ProjectID *int `db:"project_id"`
|
ProjectID *int `db:"project_id"`
|
||||||
|
|
|
@ -10,7 +10,7 @@ type Snippet struct {
|
||||||
ID int `db:"id"`
|
ID int `db:"id"`
|
||||||
OwnerID int `db:"owner_id"`
|
OwnerID int `db:"owner_id"`
|
||||||
|
|
||||||
When time.Time `db:"when"`
|
When time.Time `db:"\"when\""`
|
||||||
|
|
||||||
Description string `db:"description"`
|
Description string `db:"description"`
|
||||||
DescriptionHtml string `db:"_description_html"`
|
DescriptionHtml string `db:"_description_html"`
|
||||||
|
|
|
@ -10,21 +10,38 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Used for rendering real-time previews of post content.
|
// Used for rendering real-time previews of post content.
|
||||||
var PreviewMarkdown = goldmark.New(
|
var ForumPreviewMarkdown = goldmark.New(
|
||||||
goldmark.WithExtensions(makeGoldmarkExtensions(true)...),
|
goldmark.WithExtensions(makeGoldmarkExtensions(MarkdownOptions{
|
||||||
|
Previews: true,
|
||||||
|
Embeds: true,
|
||||||
|
})...),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Used for generating the final HTML for a post.
|
// Used for generating the final HTML for a post.
|
||||||
var RealMarkdown = goldmark.New(
|
var ForumRealMarkdown = goldmark.New(
|
||||||
goldmark.WithExtensions(makeGoldmarkExtensions(false)...),
|
goldmark.WithExtensions(makeGoldmarkExtensions(MarkdownOptions{
|
||||||
|
Previews: false,
|
||||||
|
Embeds: true,
|
||||||
|
})...),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Used for generating plain-text previews of posts.
|
// Used for generating plain-text previews of posts.
|
||||||
var PlaintextMarkdown = goldmark.New(
|
var PlaintextMarkdown = goldmark.New(
|
||||||
goldmark.WithExtensions(makeGoldmarkExtensions(false)...),
|
goldmark.WithExtensions(makeGoldmarkExtensions(MarkdownOptions{
|
||||||
|
Previews: false,
|
||||||
|
Embeds: true,
|
||||||
|
})...),
|
||||||
goldmark.WithRenderer(plaintextRenderer{}),
|
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 {
|
func ParseMarkdown(source string, md goldmark.Markdown) string {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := md.Convert([]byte(source), &buf); err != nil {
|
if err := md.Convert([]byte(source), &buf); err != nil {
|
||||||
|
@ -34,19 +51,35 @@ func ParseMarkdown(source string, md goldmark.Markdown) string {
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeGoldmarkExtensions(preview bool) []goldmark.Extender {
|
type MarkdownOptions struct {
|
||||||
return []goldmark.Extender{
|
Previews bool
|
||||||
|
Embeds bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGoldmarkExtensions(opts MarkdownOptions) []goldmark.Extender {
|
||||||
|
var extenders []goldmark.Extender
|
||||||
|
extenders = append(extenders,
|
||||||
extension.GFM,
|
extension.GFM,
|
||||||
highlightExtension,
|
highlightExtension,
|
||||||
SpoilerExtension{},
|
SpoilerExtension{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if opts.Embeds {
|
||||||
|
extenders = append(extenders,
|
||||||
EmbedExtension{
|
EmbedExtension{
|
||||||
Preview: preview,
|
Preview: opts.Previews,
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
extenders = append(extenders,
|
||||||
MathjaxExtension{},
|
MathjaxExtension{},
|
||||||
BBCodeExtension{
|
BBCodeExtension{
|
||||||
Preview: preview,
|
Preview: opts.Previews,
|
||||||
},
|
},
|
||||||
}
|
)
|
||||||
|
|
||||||
|
return extenders
|
||||||
}
|
}
|
||||||
|
|
||||||
var highlightExtension = highlighting.NewHighlighting(
|
var highlightExtension = highlighting.NewHighlighting(
|
||||||
|
|
|
@ -10,14 +10,14 @@ import (
|
||||||
func TestMarkdown(t *testing.T) {
|
func TestMarkdown(t *testing.T) {
|
||||||
t.Run("fenced code blocks", func(t *testing.T) {
|
t.Run("fenced code blocks", func(t *testing.T) {
|
||||||
t.Run("multiple lines", 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)
|
t.Log(html)
|
||||||
assert.Equal(t, 1, strings.Count(html, "<pre"))
|
assert.Equal(t, 1, strings.Count(html, "<pre"))
|
||||||
assert.Contains(t, html, `class="hmn-code"`)
|
assert.Contains(t, html, `class="hmn-code"`)
|
||||||
assert.Contains(t, html, "multiple lines\n\tof code")
|
assert.Contains(t, html, "multiple lines\n\tof code")
|
||||||
})
|
})
|
||||||
t.Run("multiple lines with language", func(t *testing.T) {
|
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)
|
t.Log(html)
|
||||||
assert.Equal(t, 1, strings.Count(html, "<pre"))
|
assert.Equal(t, 1, strings.Count(html, "<pre"))
|
||||||
assert.Contains(t, html, `class="hmn-code"`)
|
assert.Contains(t, html, `class="hmn-code"`)
|
||||||
|
@ -30,7 +30,7 @@ func TestMarkdown(t *testing.T) {
|
||||||
func TestBBCode(t *testing.T) {
|
func TestBBCode(t *testing.T) {
|
||||||
t.Run("[code]", func(t *testing.T) {
|
t.Run("[code]", func(t *testing.T) {
|
||||||
t.Run("one line", 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)
|
t.Log(html)
|
||||||
assert.Equal(t, 1, strings.Count(html, "<pre"))
|
assert.Equal(t, 1, strings.Count(html, "<pre"))
|
||||||
assert.Contains(t, html, `class="hmn-code"`)
|
assert.Contains(t, html, `class="hmn-code"`)
|
||||||
|
@ -41,7 +41,7 @@ func TestBBCode(t *testing.T) {
|
||||||
Multiline code
|
Multiline code
|
||||||
with an indent
|
with an indent
|
||||||
[/code]`
|
[/code]`
|
||||||
html := ParseMarkdown(bbcode, RealMarkdown)
|
html := ParseMarkdown(bbcode, ForumRealMarkdown)
|
||||||
t.Log(html)
|
t.Log(html)
|
||||||
assert.Equal(t, 1, strings.Count(html, "<pre"))
|
assert.Equal(t, 1, strings.Count(html, "<pre"))
|
||||||
assert.Contains(t, html, `class="hmn-code"`)
|
assert.Contains(t, html, `class="hmn-code"`)
|
||||||
|
@ -54,7 +54,7 @@ func main() {
|
||||||
fmt.Println("Hello, world!")
|
fmt.Println("Hello, world!")
|
||||||
}
|
}
|
||||||
[/code]`
|
[/code]`
|
||||||
html := ParseMarkdown(bbcode, RealMarkdown)
|
html := ParseMarkdown(bbcode, ForumRealMarkdown)
|
||||||
t.Log(html)
|
t.Log(html)
|
||||||
assert.Equal(t, 1, strings.Count(html, "<pre"))
|
assert.Equal(t, 1, strings.Count(html, "<pre"))
|
||||||
assert.Contains(t, html, "Println")
|
assert.Contains(t, html, "Println")
|
||||||
|
@ -66,7 +66,7 @@ func main() {
|
||||||
func TestSharlock(t *testing.T) {
|
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.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) {
|
t.Run("sanity check", func(t *testing.T) {
|
||||||
result := ParseMarkdown(sharlock, RealMarkdown)
|
result := ParseMarkdown(sharlock, ForumRealMarkdown)
|
||||||
|
|
||||||
for _, line := range strings.Split(result, "\n") {
|
for _, line := range strings.Split(result, "\n") {
|
||||||
assert.NotContains(t, line, "[b]")
|
assert.NotContains(t, line, "[b]")
|
||||||
|
@ -85,6 +85,6 @@ func TestSharlock(t *testing.T) {
|
||||||
|
|
||||||
func BenchmarkSharlock(b *testing.B) {
|
func BenchmarkSharlock(b *testing.B) {
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
ParseMarkdown(sharlock, RealMarkdown)
|
ParseMarkdown(sharlock, ForumRealMarkdown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
// Global variables
|
||||||
|
$input-padding: 0.3rem;
|
||||||
|
|
||||||
.noselect {
|
.noselect {
|
||||||
-webkit-touch-callout: none; /* iOS Safari */
|
-webkit-touch-callout: none; /* iOS Safari */
|
||||||
-webkit-user-select: none; /* Chrome/Safari/Opera */
|
-webkit-user-select: none; /* Chrome/Safari/Opera */
|
||||||
|
@ -24,7 +27,7 @@ body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: px2rem(14px);
|
font-size: px2rem(14px);
|
||||||
line-height: 1.5em;
|
line-height: 1.2em;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -735,24 +738,16 @@ footer {
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
@include usevar(border-color, tab-border-color);
|
@include usevar(border-color, tab-border-color);
|
||||||
|
@extend .flex, .flex-row;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-bottom-width: 1px;
|
|
||||||
border-bottom-style: solid;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
.tab-button {
|
.tab-button {
|
||||||
@include usevar(background-color, tab-button-background);
|
@include usevar(background-color, tab-button-background);
|
||||||
@include usevar(border-color, tab-border-color);
|
@include usevar(border-color, tab-border-color);
|
||||||
|
@extend .ph3, .pv2;
|
||||||
|
|
||||||
height:100%;
|
cursor: pointer; // TODO: Should this be a link?
|
||||||
display:inline-block;
|
|
||||||
padding:10px 15px;
|
|
||||||
line-height:100%;
|
|
||||||
cursor:pointer;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
box-sizing:border-box;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@include usevar(background-color, tab-button-background-hover);
|
@include usevar(background-color, tab-button-background-hover);
|
||||||
|
@ -760,10 +755,7 @@ footer {
|
||||||
|
|
||||||
&.current {
|
&.current {
|
||||||
@include usevar(background-color, tab-button-background-current);
|
@include usevar(background-color, tab-button-background-current);
|
||||||
|
font-weight: 500;
|
||||||
border-bottom-color: transparent;
|
|
||||||
font-weight:bold;
|
|
||||||
height:105%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,103 +44,49 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
.error {
|
.edit-form-row {
|
||||||
margin-left:5em;
|
@extend .flex;
|
||||||
padding:10px;
|
@extend .flex-column;
|
||||||
color:red;
|
@extend .flex-row-ns;
|
||||||
|
@extend .mv3;
|
||||||
|
|
||||||
|
> :first-child {
|
||||||
|
@extend .w-100;
|
||||||
|
@extend .w4-ns;
|
||||||
|
@extend .flex-grow-0;
|
||||||
|
@extend .flex-shrink-0;
|
||||||
|
@extend .tl;
|
||||||
|
@extend .tr-ns;
|
||||||
|
@extend .pr0;
|
||||||
|
@extend .pr2-ns;
|
||||||
|
@extend .pb1;
|
||||||
|
@extend .pb0-ns;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
> :nth-child(2) {
|
||||||
|
@extend .flex-grow-1;
|
||||||
|
@extend .overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-input-ns {
|
||||||
|
// NOTE(ben): This could maybe be more general someday?
|
||||||
|
@media #{$breakpoint-not-small} {
|
||||||
|
padding-top: $input-padding;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=text] {
|
input[type=text] {
|
||||||
min-width:20em;
|
@extend .w-100;
|
||||||
|
@extend .mw5-ns;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
font-size:13pt;
|
@extend .w-100;
|
||||||
}
|
@extend .w6-ns;
|
||||||
|
@extend .mw-100;
|
||||||
.note {
|
@extend .h3;
|
||||||
margin-bottom:5px;
|
|
||||||
font-style:italic;
|
|
||||||
font-size:90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links {
|
|
||||||
width: 80%;
|
|
||||||
min-height: 200px;
|
|
||||||
height: 15vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.half {
|
|
||||||
padding:10px;
|
|
||||||
text-align:center;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width:95%;
|
|
||||||
margin:auto;
|
|
||||||
border-collapse:separate;
|
|
||||||
border-spacing: 0px 10px;
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding-bottom:15px;
|
|
||||||
width:90%;
|
|
||||||
|
|
||||||
&.half {
|
|
||||||
width:50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width:100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
text-align:right;
|
|
||||||
font-weight:bold;
|
|
||||||
padding-right:10px;
|
|
||||||
padding-bottom:15px;
|
|
||||||
vertical-align:top;
|
|
||||||
max-width:5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
td table th {
|
|
||||||
text-align:left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-options label {
|
|
||||||
font-weight:bold;
|
|
||||||
margin-right:20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.profile-edit {
|
|
||||||
.longbio {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 400px;
|
|
||||||
height: 30vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-preview {
|
|
||||||
border:1px solid transparent;
|
|
||||||
margin:10px;
|
|
||||||
margin-bottom:0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.shortbio,
|
|
||||||
textarea.signature,
|
|
||||||
{
|
|
||||||
min-width:300px;
|
|
||||||
width:50%;
|
|
||||||
min-height: 100px;
|
|
||||||
height:4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-preview {
|
|
||||||
@include usevar(border-color, 'project-edit-logo-previw-border-color');
|
|
||||||
|
|
||||||
width:200px;
|
|
||||||
border-width: 1px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.project-edit {
|
&.project-edit {
|
||||||
|
@ -153,21 +99,21 @@
|
||||||
input.project_blurb,
|
input.project_blurb,
|
||||||
input.project_name,
|
input.project_name,
|
||||||
{
|
{
|
||||||
min-width:300px;
|
min-width: 300px;
|
||||||
width:50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quota-bar {
|
.quota-bar {
|
||||||
@include usevar(border-color, 'project-edit-quota-bar-border-color');
|
// @include usevar(border-color, 'project-edit-quota-bar-border-color');
|
||||||
|
|
||||||
width:500px;
|
width: 500px;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
margin-bottom:10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
.quota-filled {
|
.quota-filled {
|
||||||
@include usevar(background-color, 'project-edit-quota-bar-filled-background');
|
// @include usevar(background-color, 'project-edit-quota-bar-filled-background');
|
||||||
|
|
||||||
height:100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,6 +68,7 @@
|
||||||
|
|
||||||
input[type=text],
|
input[type=text],
|
||||||
input[type=password],
|
input[type=password],
|
||||||
|
input[type=email],
|
||||||
textarea,
|
textarea,
|
||||||
select,
|
select,
|
||||||
{
|
{
|
||||||
|
@ -102,18 +103,25 @@ select,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=text], input[type=password] {
|
input[type=text],
|
||||||
|
input[type=password],
|
||||||
|
input[type=email],
|
||||||
|
{
|
||||||
&:not(.lite) {
|
&:not(.lite) {
|
||||||
padding:5px;
|
padding: $input-padding;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
padding: $input-padding;
|
||||||
|
}
|
||||||
|
|
||||||
form .note {
|
form .note {
|
||||||
font-style:italic;
|
font-style:italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
padding: 5px 10px;
|
padding: $input-padding 2*$input-padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
option[selected] {
|
option[selected] {
|
||||||
|
|
|
@ -42,6 +42,7 @@ $width-2: 2rem !default;
|
||||||
$width-3: 4rem !default;
|
$width-3: 4rem !default;
|
||||||
$width-4: 8rem !default;
|
$width-4: 8rem !default;
|
||||||
$width-5: 16rem !default;
|
$width-5: 16rem !default;
|
||||||
|
$width-6: 32rem !default;
|
||||||
$max-width-1: 1rem !default;
|
$max-width-1: 1rem !default;
|
||||||
$max-width-2: 2rem !default;
|
$max-width-2: 2rem !default;
|
||||||
$max-width-3: 4rem !default;
|
$max-width-3: 4rem !default;
|
||||||
|
|
|
@ -54,6 +54,7 @@
|
||||||
.w3 { width: $width-3; }
|
.w3 { width: $width-3; }
|
||||||
.w4 { width: $width-4; }
|
.w4 { width: $width-4; }
|
||||||
.w5 { width: $width-5; }
|
.w5 { width: $width-5; }
|
||||||
|
.w6 { width: $width-6; }
|
||||||
|
|
||||||
.w-10 { width: 10%; }
|
.w-10 { width: 10%; }
|
||||||
.w-20 { width: 20%; }
|
.w-20 { width: 20%; }
|
||||||
|
@ -80,6 +81,7 @@
|
||||||
.w3-ns { width: $width-3; }
|
.w3-ns { width: $width-3; }
|
||||||
.w4-ns { width: $width-4; }
|
.w4-ns { width: $width-4; }
|
||||||
.w5-ns { width: $width-5; }
|
.w5-ns { width: $width-5; }
|
||||||
|
.w6-ns { width: $width-6; }
|
||||||
.w-10-ns { width: 10%; }
|
.w-10-ns { width: 10%; }
|
||||||
.w-20-ns { width: 20%; }
|
.w-20-ns { width: 20%; }
|
||||||
.w-25-ns { width: 25%; }
|
.w-25-ns { width: 25%; }
|
||||||
|
@ -105,6 +107,7 @@
|
||||||
.w3-m { width: $width-3; }
|
.w3-m { width: $width-3; }
|
||||||
.w4-m { width: $width-4; }
|
.w4-m { width: $width-4; }
|
||||||
.w5-m { width: $width-5; }
|
.w5-m { width: $width-5; }
|
||||||
|
.w6-m { width: $width-6; }
|
||||||
.w-10-m { width: 10%; }
|
.w-10-m { width: 10%; }
|
||||||
.w-20-m { width: 20%; }
|
.w-20-m { width: 20%; }
|
||||||
.w-25-m { width: 25%; }
|
.w-25-m { width: 25%; }
|
||||||
|
@ -130,6 +133,7 @@
|
||||||
.w3-l { width: $width-3; }
|
.w3-l { width: $width-3; }
|
||||||
.w4-l { width: $width-4; }
|
.w4-l { width: $width-4; }
|
||||||
.w5-l { width: $width-5; }
|
.w5-l { width: $width-5; }
|
||||||
|
.w6-l { width: $width-6; }
|
||||||
.w-10-l { width: 10%; }
|
.w-10-l { width: 10%; }
|
||||||
.w-20-l { width: 20%; }
|
.w-20-l { width: 20%; }
|
||||||
.w-25-l { width: 25%; }
|
.w-25-l { width: 25%; }
|
||||||
|
|
|
@ -38,9 +38,6 @@ $vars: (
|
||||||
project-card-border-color: #333,
|
project-card-border-color: #333,
|
||||||
project-user-suggestions-background: #222,
|
project-user-suggestions-background: #222,
|
||||||
project-user-suggestions-border-color: #444,
|
project-user-suggestions-border-color: #444,
|
||||||
project-edit-logo-previw-border-color: #444,
|
|
||||||
project-edit-quota-bar-border-color: #444,
|
|
||||||
project-edit-quota-bar-filled-background: #888,
|
|
||||||
|
|
||||||
notice-text-color: $fg-font-color,
|
notice-text-color: $fg-font-color,
|
||||||
notice-unapproved-color: #7a2020,
|
notice-unapproved-color: #7a2020,
|
||||||
|
|
|
@ -38,9 +38,6 @@ $vars: (
|
||||||
project-card-border-color: #aaa,
|
project-card-border-color: #aaa,
|
||||||
project-user-suggestions-background: #fff,
|
project-user-suggestions-background: #fff,
|
||||||
project-user-suggestions-border-color: #ddd,
|
project-user-suggestions-border-color: #ddd,
|
||||||
project-edit-logo-previw-border-color: #999,
|
|
||||||
project-edit-quota-bar-border-color: #999,
|
|
||||||
project-edit-quota-bar-filled-background: #444,
|
|
||||||
|
|
||||||
notice-text-color: #fff,
|
notice-text-color: #fff,
|
||||||
notice-unapproved-color: #b42222,
|
notice-unapproved-color: #b42222,
|
||||||
|
|
|
@ -147,6 +147,7 @@ func UserToTemplate(u *models.User, currentTheme string) User {
|
||||||
IsStaff: u.IsStaff,
|
IsStaff: u.IsStaff,
|
||||||
|
|
||||||
Name: u.BestName(),
|
Name: u.BestName(),
|
||||||
|
Bio: u.Bio,
|
||||||
Blurb: u.Blurb,
|
Blurb: u.Blurb,
|
||||||
Signature: u.Signature,
|
Signature: u.Signature,
|
||||||
DateJoined: u.DateJoined,
|
DateJoined: u.DateJoined,
|
||||||
|
@ -162,60 +163,85 @@ func UserToTemplate(u *models.User, currentTheme string) User {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexServiceYoutube = regexp.MustCompile(`youtube\.com/(c/)?(?P<userdata>[\w/-]+)$`)
|
// An online site/service for which we recognize the link
|
||||||
var RegexServiceTwitter = regexp.MustCompile(`twitter\.com/(?P<userdata>\w+)$`)
|
type LinkService struct {
|
||||||
var RegexServiceGithub = regexp.MustCompile(`github\.com/(?P<userdata>[\w/-]+)$`)
|
Name string
|
||||||
var RegexServiceTwitch = regexp.MustCompile(`twitch\.tv/(?P<userdata>[\w/-]+)$`)
|
IconName string
|
||||||
var RegexServiceHitbox = regexp.MustCompile(`hitbox\.tv/(?P<userdata>[\w/-]+)$`)
|
Regex *regexp.Regexp
|
||||||
var RegexServicePatreon = regexp.MustCompile(`patreon\.com/(?P<userdata>[\w/-]+)$`)
|
|
||||||
var RegexServiceSoundcloud = regexp.MustCompile(`soundcloud\.com/(?P<userdata>[\w/-]+)$`)
|
|
||||||
var RegexServiceItch = regexp.MustCompile(`(?P<userdata>[\w/-]+)\.itch\.io/?$`)
|
|
||||||
|
|
||||||
var LinkServiceMap = map[string]*regexp.Regexp{
|
|
||||||
"youtube": RegexServiceYoutube,
|
|
||||||
"twitter": RegexServiceTwitter,
|
|
||||||
"github": RegexServiceGithub,
|
|
||||||
"twitch": RegexServiceTwitch,
|
|
||||||
"hitbox": RegexServiceHitbox,
|
|
||||||
"patreon": RegexServicePatreon,
|
|
||||||
"soundcloud": RegexServiceSoundcloud,
|
|
||||||
"itch": RegexServiceItch,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseKnownServicesForLink(link *models.Link) (serviceName string, userData string) {
|
var LinkServices = []LinkService{
|
||||||
for name, re := range LinkServiceMap {
|
{
|
||||||
match := re.FindStringSubmatch(link.Value)
|
Name: "YouTube",
|
||||||
|
IconName: "youtube",
|
||||||
|
Regex: regexp.MustCompile(`youtube\.com/(c/)?(?P<userdata>[\w/-]+)$`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Twitter",
|
||||||
|
IconName: "twitter",
|
||||||
|
Regex: regexp.MustCompile(`twitter\.com/(?P<userdata>\w+)$`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GitHub",
|
||||||
|
IconName: "github",
|
||||||
|
Regex: regexp.MustCompile(`github\.com/(?P<userdata>[\w/-]+)$`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Twitch",
|
||||||
|
IconName: "twitch",
|
||||||
|
Regex: regexp.MustCompile(`twitch\.tv/(?P<userdata>[\w/-]+)$`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Hitbox",
|
||||||
|
IconName: "hitbox",
|
||||||
|
Regex: regexp.MustCompile(`hitbox\.tv/(?P<userdata>[\w/-]+)$`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Patreon",
|
||||||
|
IconName: "patreon",
|
||||||
|
Regex: regexp.MustCompile(`patreon\.com/(?P<userdata>[\w/-]+)$`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "SoundCloud",
|
||||||
|
IconName: "soundcloud",
|
||||||
|
Regex: regexp.MustCompile(`soundcloud\.com/(?P<userdata>[\w/-]+)$`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "itch.io",
|
||||||
|
IconName: "itch",
|
||||||
|
Regex: regexp.MustCompile(`(?P<userdata>[\w/-]+)\.itch\.io/?$`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseKnownServicesForLink(link *models.Link) (service LinkService, userData string) {
|
||||||
|
for _, svc := range LinkServices {
|
||||||
|
match := svc.Regex.FindStringSubmatch(link.URL)
|
||||||
if match != nil {
|
if match != nil {
|
||||||
serviceName = name
|
return svc, match[svc.Regex.SubexpIndex("userdata")]
|
||||||
userData = match[re.SubexpIndex("userdata")]
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", ""
|
return LinkService{}, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func LinkToTemplate(link *models.Link) Link {
|
func LinkToTemplate(link *models.Link) Link {
|
||||||
name := ""
|
tlink := Link{
|
||||||
/*
|
Name: link.Name,
|
||||||
// NOTE(asaf): While Name and Key are separate things, Name is almost always the same as Key in the db, which looks weird.
|
Url: link.URL,
|
||||||
// So we're just going to ignore Name until we decide it's worth reusing.
|
LinkText: link.URL,
|
||||||
if link.Name != nil {
|
|
||||||
name = *link.Name
|
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
serviceName, serviceUserData := ParseKnownServicesForLink(link)
|
service, userData := ParseKnownServicesForLink(link)
|
||||||
if serviceUserData != "" {
|
if tlink.Name == "" && service.Name != "" {
|
||||||
name = serviceUserData
|
tlink.Name = service.Name
|
||||||
}
|
}
|
||||||
if name == "" {
|
if service.IconName != "" {
|
||||||
name = link.Value
|
tlink.Icon = service.IconName
|
||||||
}
|
}
|
||||||
return Link{
|
if userData != "" {
|
||||||
Key: link.Key,
|
tlink.LinkText = userData
|
||||||
Name: name,
|
|
||||||
Icon: serviceName,
|
|
||||||
Url: link.Value,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return tlink
|
||||||
}
|
}
|
||||||
|
|
||||||
func TimelineItemsToJSON(items []TimelineItem) string {
|
func TimelineItemsToJSON(items []TimelineItem) string {
|
||||||
|
|
|
@ -63,8 +63,8 @@
|
||||||
</div>
|
</div>
|
||||||
{{ range .ProjectLinks }}
|
{{ range .ProjectLinks }}
|
||||||
<div class="pair flex flex-wrap">
|
<div class="pair flex flex-wrap">
|
||||||
<div class="key flex-auto mr1">{{ .Key }}</div>
|
<div class="key flex-auto mr1">{{ .Name }}</div>
|
||||||
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span>{{ .Name }}</a></div>
|
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span> {{ .LinkText }}</a></div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -67,8 +67,8 @@
|
||||||
|
|
||||||
{{ range .ProfileUserLinks }}
|
{{ range .ProfileUserLinks }}
|
||||||
<div class="pair flex flex-wrap">
|
<div class="pair flex flex-wrap">
|
||||||
<div class="key flex-auto mr1">{{ .Key }}</div>
|
<div class="key flex-auto mr1">{{ .Name }}</div>
|
||||||
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span>{{ .Name }}</a></div>
|
<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span> {{ .LinkText }}</a></div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,201 @@
|
||||||
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "extrahead" }}
|
||||||
|
<script src="{{ static "js/tabs.js" }}"></script>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<form class="tabbed edit-form" action="{{ .SubmitUrl }}" method="post" enctype="multipart/form-data">
|
||||||
|
{{ csrftoken .Session }}
|
||||||
|
<div class="tab" data-name="Account" data-slug="account">
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div>Username:</div>
|
||||||
|
<div>
|
||||||
|
<div>{{ .User.Username }}</div>
|
||||||
|
<div class="c--dim f7">If you would like to change your username, please <a href="{{ .ContactUrl }}">contact us</a>.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Real name:</div>
|
||||||
|
<div>
|
||||||
|
<input type="text" name="realname" maxlength="255" class="textbox realname" value="{{ .User.Name }}">
|
||||||
|
<div class="c--dim f7">(optional)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Email:</div>
|
||||||
|
<div>
|
||||||
|
<input type="email" name="email" maxlength="254" class="textbox email" value="{{ .Email }}" />
|
||||||
|
<div class="mt1">
|
||||||
|
<input type="checkbox" name="showemail" id="email" {{ if .ShowEmail }}checked{{ end }} />
|
||||||
|
<label for="email">Show on your profile</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div>Theme:</div>
|
||||||
|
<div>
|
||||||
|
<input type="checkbox" name="darktheme" id="darktheme" {{ if .User.DarkTheme }}checked{{ end }} />
|
||||||
|
<label for="darktheme">Use dark theme</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div>Avatar:</div>
|
||||||
|
<div>
|
||||||
|
<input type="file" name="avatar" id="avatar">
|
||||||
|
<div class="avatar-preview"><img src="{{ .User.AvatarUrl }}" width="200px">
|
||||||
|
</div>
|
||||||
|
<div class="c--dim f7">(Allowed image types: PNG, JPEG and GIF. Avatars may weigh up to 1MB and will be resized if larger than 400x400 pixels)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Short bio:</div>
|
||||||
|
<div>
|
||||||
|
<textarea class="shortbio" maxlength="140" data-max-chars="140" name="shortbio">
|
||||||
|
{{- .User.Blurb -}}
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Forum signature:</div>
|
||||||
|
<div>
|
||||||
|
<textarea class="signature" maxlength="255" data-max-chars="255" name="signature">
|
||||||
|
{{- .User.Signature -}}
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div></div>
|
||||||
|
<div>
|
||||||
|
<input type="submit" value="Save profile" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab" data-name="Password" data-slug="password">
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Old password:</div>
|
||||||
|
<div>
|
||||||
|
<input id="id_old_password" name="old_password" type="password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">New password:</div>
|
||||||
|
<div>
|
||||||
|
<input id="id_new_password1" name="new_password1" type="password" />
|
||||||
|
<div class="c--dim f7 mw6">
|
||||||
|
Your password must be 8 or more characters, and must differ from your username and current password.
|
||||||
|
Other than that, <a href="http://krebsonsecurity.com/password-dos-and-donts/" class="external" target="_blank">please follow best practices</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">New password confirmation:</div>
|
||||||
|
<div>
|
||||||
|
<input id="id_new_password2" name="new_password2" type="password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div></div>
|
||||||
|
<div>
|
||||||
|
<input type="submit" value="Update password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab" data-name="Profile Page Options" data-slug="profile">
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Links:</div>
|
||||||
|
<div>
|
||||||
|
<textarea class="links" name="links" id="links" maxlength="2048" data-max-chars="2048">
|
||||||
|
{{- .LinksText -}}
|
||||||
|
</textarea>
|
||||||
|
<div class="c--dim f7">
|
||||||
|
<div>Relevant links to put on your profile.</div>
|
||||||
|
<div>Format: url [Title] (e.g. <code>http://example.com/ Example Site</code>)</div>
|
||||||
|
<div>(1 per line, 10 max)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Description:</div>
|
||||||
|
<div>
|
||||||
|
<textarea class="longbio" name="longbio" maxlength="1018" data-max-chars="1018">
|
||||||
|
{{- .User.Bio -}}
|
||||||
|
</textarea>
|
||||||
|
<div class="c--dim f7">
|
||||||
|
<div>Include some information about yourself, such as your background, interests, occupation, etc.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div></div>
|
||||||
|
<div>
|
||||||
|
<input type="submit" value="Save profile" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab" data-name="Discord" data-slug="discord">
|
||||||
|
<div>
|
||||||
|
{{ if .DiscordUser }}
|
||||||
|
Linked account:
|
||||||
|
<span class="b ph2">{{ .DiscordUser.Username }}#{{ .DiscordUser.Discriminator }}</span>
|
||||||
|
<a href="javascript:void(0)" onclick="unlinkDiscord()">
|
||||||
|
Unlink account
|
||||||
|
</a>
|
||||||
|
{{ else }}
|
||||||
|
You haven't linked your Discord account.
|
||||||
|
<a href="{{ .DiscordAuthorizeUrl }}">Link account</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mv3">
|
||||||
|
<input type="checkbox" name="discord-showcase-auto" id="discord-showcase-auto" {{ if .User.DiscordSaveShowcase }}checked{{ end }} {{ if not .DiscordUser }}disabled{{ end }} />
|
||||||
|
<label for="discord-showcase-auto">Automatically capture everything I post in <span class="b nowrap">#project-showcase</span></label>
|
||||||
|
<div class="f7 c--dimmer">Snippets will only be created while this setting is on.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mv3">
|
||||||
|
<input type="checkbox" name="discord-snippet-keep" id="discord-snippet-keep" {{ if not .User.DiscordDeleteSnippetOnMessageDelete }}checked{{ end }} {{ if not .DiscordUser }}disabled{{ end }} />
|
||||||
|
<label for="discord-snippet-keep">Keep captured snippets even if I delete them in Discord</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .DiscordUser }}
|
||||||
|
<div class="mv3 mw6">
|
||||||
|
<a href="javascript:void(0)" onclick="discordShowcaseBacklog()">
|
||||||
|
Create snippets from all of my <span class="b nowrap">#project-showcase</span> posts
|
||||||
|
</a>
|
||||||
|
<div class="f7 c--dimmer">
|
||||||
|
Use this if you have a backlog of content in <span class="b nowrap">#project-showcase</span> that you want on your profile.
|
||||||
|
</div>
|
||||||
|
{{ if gt .DiscordNumUnsavedMessages 0 }}
|
||||||
|
<div class="f7 c--dimmer">
|
||||||
|
<span class="b">WARNING:</span> {{ .DiscordNumUnsavedMessages }} of your messages are currently waiting to be processed. If you run this command now, some snippets may still be missing.
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<input type="submit" value="Save profile" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="discord-unlink-form" class="dn" action="{{ .DiscordUnlinkUrl }}" method="POST">
|
||||||
|
{{ csrftoken .Session }}
|
||||||
|
<script>
|
||||||
|
function unlinkDiscord() {
|
||||||
|
document.querySelector('#discord-unlink-form').submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="discord-showcase-backlog" class="dn" action="{{ .DiscordShowcaseBacklogUrl }}" method="POST">
|
||||||
|
{{ csrftoken .Session }}
|
||||||
|
<script>
|
||||||
|
function discordShowcaseBacklog() {
|
||||||
|
document.querySelector('#discord-showcase-backlog').submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
|
@ -137,6 +137,7 @@ type User struct {
|
||||||
ProfileUrl string
|
ProfileUrl string
|
||||||
|
|
||||||
DarkTheme bool
|
DarkTheme bool
|
||||||
|
ShowEmail bool
|
||||||
Timezone string
|
Timezone string
|
||||||
|
|
||||||
CanEditLibrary bool
|
CanEditLibrary bool
|
||||||
|
@ -145,9 +146,9 @@ type User struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
Key string
|
|
||||||
Name string
|
Name string
|
||||||
Url string
|
Url string
|
||||||
|
LinkText string
|
||||||
Icon string
|
Icon string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -210,10 +210,7 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
||||||
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
|
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
hashed, err := auth.HashPassword(password)
|
hashed := auth.HashPassword(password)
|
||||||
if err != nil {
|
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password"))
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Create user and one time token")
|
c.Perf.StartBlock("SQL", "Create user and one time token")
|
||||||
tx, err := c.Conn.Begin(c.Context())
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
|
@ -622,10 +619,7 @@ func DoPasswordResetSubmit(c *RequestContext) ResponseData {
|
||||||
return RejectRequest(c, "Password confirmation doesn't match password")
|
return RejectRequest(c, "Password confirmation doesn't match password")
|
||||||
}
|
}
|
||||||
|
|
||||||
hashed, err := auth.HashPassword(password)
|
hashed := auth.HashPassword(password)
|
||||||
if err != nil {
|
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password"))
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Update user's password and delete reset token")
|
c.Perf.StartBlock("SQL", "Update user's password and delete reset token")
|
||||||
tx, err := c.Conn.Begin(c.Context())
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
|
@ -707,15 +701,11 @@ func tryLogin(c *RequestContext, user *models.User, password string) (bool, erro
|
||||||
|
|
||||||
// re-hash and save the user's password if necessary
|
// re-hash and save the user's password if necessary
|
||||||
if hashed.IsOutdated() {
|
if hashed.IsOutdated() {
|
||||||
newHashed, err := auth.HashPassword(password)
|
newHashed := auth.HashPassword(password)
|
||||||
if err == nil {
|
|
||||||
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
|
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger.Error().Err(err).Msg("failed to update user's password")
|
c.Logger.Error().Err(err).Msg("failed to update user's password")
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
c.Logger.Error().Err(err).Msg("failed to re-hash password")
|
|
||||||
}
|
|
||||||
// If errors happen here, we can still continue with logging them in
|
// If errors happen here, we can still continue with logging them in
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,9 +2,7 @@ package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/auth"
|
"git.handmade.network/hmn/hmn/src/auth"
|
||||||
|
@ -14,62 +12,8 @@ import (
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func DiscordTest(c *RequestContext) ResponseData {
|
|
||||||
var userDiscord *models.DiscordUser
|
|
||||||
iUserDiscord, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM handmade_discorduser
|
|
||||||
WHERE hmn_user_id = $1
|
|
||||||
`,
|
|
||||||
c.CurrentUser.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
|
||||||
// we're ok, just no user
|
|
||||||
} else {
|
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current user's Discord account"))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
userDiscord = iUserDiscord.(*models.DiscordUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
type templateData struct {
|
|
||||||
templates.BaseData
|
|
||||||
DiscordUser *templates.DiscordUser
|
|
||||||
AuthorizeURL string
|
|
||||||
UnlinkURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
baseData := getBaseData(c)
|
|
||||||
baseData.Title = "Discord Test"
|
|
||||||
|
|
||||||
params := make(url.Values)
|
|
||||||
params.Set("response_type", "code")
|
|
||||||
params.Set("client_id", config.Config.Discord.OAuthClientID)
|
|
||||||
params.Set("scope", "identify")
|
|
||||||
params.Set("state", c.CurrentSession.CSRFToken)
|
|
||||||
params.Set("redirect_uri", hmnurl.BuildDiscordOAuthCallback())
|
|
||||||
|
|
||||||
td := templateData{
|
|
||||||
BaseData: baseData,
|
|
||||||
AuthorizeURL: fmt.Sprintf("https://discord.com/api/oauth2/authorize?%s", params.Encode()),
|
|
||||||
UnlinkURL: hmnurl.BuildDiscordUnlink(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if userDiscord != nil {
|
|
||||||
u := templates.DiscordUserToTemplate(userDiscord)
|
|
||||||
td.DiscordUser = &u
|
|
||||||
}
|
|
||||||
|
|
||||||
var res ResponseData
|
|
||||||
res.MustWriteTemplate("discordtest.html", td, c.Perf)
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
func DiscordOAuthCallback(c *RequestContext) ResponseData {
|
func DiscordOAuthCallback(c *RequestContext) ResponseData {
|
||||||
query := c.Req.URL.Query()
|
query := c.Req.URL.Query()
|
||||||
|
|
||||||
|
@ -95,14 +39,19 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for error values and redirect back to ????
|
// Check for error values and redirect back to ????
|
||||||
if query.Get("error") != "" {
|
if errCode := query.Get("error"); errCode != "" {
|
||||||
// TODO: actually handle these errors
|
// TODO: actually handle these errors
|
||||||
return ErrorResponse(http.StatusBadRequest, errors.New(query.Get("error")))
|
if errCode == "access_denied" {
|
||||||
|
// This occurs when the user cancels. Just go back to the profile page.
|
||||||
|
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
|
||||||
|
} else {
|
||||||
|
return RejectRequest(c, "Failed to authenticate with Discord.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do the actual token exchange and redirect back to ????
|
// Do the actual token exchange and redirect back to ????
|
||||||
code := query.Get("code")
|
code := query.Get("code")
|
||||||
res, err := discord.ExchangeOAuthCode(c.Context(), code, hmnurl.BuildDiscordOAuthCallback()) // TODO: Redirect to the right place
|
res, err := discord.ExchangeOAuthCode(c.Context(), code, hmnurl.BuildDiscordOAuthCallback())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to exchange Discord authorization code"))
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to exchange Discord authorization code"))
|
||||||
}
|
}
|
||||||
|
@ -139,7 +88,7 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save new Discord user info"))
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save new Discord user info"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Redirect(hmnurl.BuildDiscordTest(), http.StatusSeeOther)
|
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func DiscordUnlink(c *RequestContext) ResponseData {
|
func DiscordUnlink(c *RequestContext) ResponseData {
|
||||||
|
@ -159,7 +108,7 @@ func DiscordUnlink(c *RequestContext) ResponseData {
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
return c.Redirect(hmnurl.BuildDiscordTest(), http.StatusSeeOther)
|
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
|
||||||
} else {
|
} else {
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get Discord user for unlink"))
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get Discord user for unlink"))
|
||||||
}
|
}
|
||||||
|
@ -187,5 +136,59 @@ func DiscordUnlink(c *RequestContext) ResponseData {
|
||||||
c.Logger.Warn().Err(err).Msg("failed to remove member role on unlink")
|
c.Logger.Warn().Err(err).Msg("failed to remove member role on unlink")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Redirect(hmnurl.BuildDiscordTest(), http.StatusSeeOther)
|
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
|
||||||
|
iduser, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{},
|
||||||
|
`SELECT $columns FROM handmade_discorduser WHERE hmn_user_id = $1`,
|
||||||
|
c.CurrentUser.ID,
|
||||||
|
)
|
||||||
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
|
// Nothing to do
|
||||||
|
c.Logger.Warn().Msg("could not do showcase backlog because no discord user exists")
|
||||||
|
return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther)
|
||||||
|
} else if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get discord user"))
|
||||||
|
}
|
||||||
|
duser := iduser.(*models.DiscordUser)
|
||||||
|
|
||||||
|
ok, err := discord.AllowedToCreateMessageSnippet(c.Context(), c.Conn, duser.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
// Not allowed to do this, bail out
|
||||||
|
c.Logger.Warn().Msg("was not allowed to save user snippets")
|
||||||
|
return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageIdQuery struct {
|
||||||
|
MessageID string `db:"msg.id"`
|
||||||
|
}
|
||||||
|
imsgIds, err := db.Query(c.Context(), c.Conn, messageIdQuery{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM
|
||||||
|
handmade_discordmessage AS msg
|
||||||
|
WHERE
|
||||||
|
msg.user_id = $1
|
||||||
|
AND msg.channel_id = $2
|
||||||
|
`,
|
||||||
|
duser.UserID,
|
||||||
|
config.Config.Discord.ShowcaseChannelID,
|
||||||
|
)
|
||||||
|
msgIds := imsgIds.ToSlice()
|
||||||
|
|
||||||
|
for _, imsgId := range msgIds {
|
||||||
|
msgId := imsgId.(*messageIdQuery)
|
||||||
|
_, err := discord.CreateMessageSnippet(c.Context(), c.Conn, msgId.MessageID)
|
||||||
|
if err != nil {
|
||||||
|
c.Logger.Warn().Err(err).Msg("failed to create snippet from showcase backlog")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
package website
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
)
|
||||||
|
|
||||||
|
// If a helper method returns this, you should call RejectRequest with the value.
|
||||||
|
type RejectRequestError error
|
||||||
|
|
||||||
|
/*
|
||||||
|
Reads an image file from form data and saves it to the filesystem and the database.
|
||||||
|
If the file doesn't exist, this does nothing.
|
||||||
|
|
||||||
|
NOTE(ben): Someday we should replace this with the asset system.
|
||||||
|
*/
|
||||||
|
func SaveImageFile(c *RequestContext, dbConn db.ConnOrTx, fileFieldName string, maxSize int64, filepath string) (imageFileId int, err error) {
|
||||||
|
img, header, err := c.Req.FormFile(fileFieldName)
|
||||||
|
filename := ""
|
||||||
|
width := 0
|
||||||
|
height := 0
|
||||||
|
if err != nil && !errors.Is(err, http.ErrMissingFile) {
|
||||||
|
return 0, oops.New(err, "failed to read uploaded file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if header != nil {
|
||||||
|
if header.Size > maxSize {
|
||||||
|
return 0, RejectRequestError(fmt.Errorf("Image filesize too big. Max size: %d bytes", maxSize))
|
||||||
|
} else {
|
||||||
|
c.Perf.StartBlock("IMAGE", "Decoding image")
|
||||||
|
config, format, err := image.DecodeConfig(img)
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
if err != nil {
|
||||||
|
return 0, RejectRequestError(errors.New("Image type not supported"))
|
||||||
|
}
|
||||||
|
width = config.Width
|
||||||
|
height = config.Height
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
return 0, RejectRequestError(errors.New("Image has zero size"))
|
||||||
|
}
|
||||||
|
|
||||||
|
filename = fmt.Sprintf("%s.%s", filepath, format)
|
||||||
|
storageFilename := fmt.Sprintf("public/media/%s", filename)
|
||||||
|
c.Perf.StartBlock("IMAGE", "Writing image file")
|
||||||
|
file, err := os.Create(storageFilename)
|
||||||
|
if err != nil {
|
||||||
|
return 0, oops.New(err, "Failed to create local image file")
|
||||||
|
}
|
||||||
|
img.Seek(0, io.SeekStart)
|
||||||
|
_, err = io.Copy(file, img)
|
||||||
|
if err != nil {
|
||||||
|
return 0, oops.New(err, "Failed to write image to file")
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
img.Close()
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
|
c.Perf.StartBlock("SQL", "Saving image file")
|
||||||
|
if filename != "" {
|
||||||
|
hasher := sha1.New()
|
||||||
|
img.Seek(0, io.SeekStart)
|
||||||
|
io.Copy(hasher, img) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs
|
||||||
|
sha1sum := hasher.Sum(nil)
|
||||||
|
var imageId int
|
||||||
|
err = dbConn.QueryRow(c.Context(),
|
||||||
|
`
|
||||||
|
INSERT INTO handmade_imagefile (file, size, sha1sum, protected, width, height)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
filename, header.Size, hex.EncodeToString(sha1sum), false, width, height,
|
||||||
|
).Scan(&imageId)
|
||||||
|
if err != nil {
|
||||||
|
return 0, oops.New(err, "Failed to insert image file row")
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageId, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
|
@ -1,11 +1,8 @@
|
||||||
package website
|
package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha1"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -142,47 +139,6 @@ func PodcastEditSubmit(c *RequestContext) ResponseData {
|
||||||
if len(strings.TrimSpace(description)) == 0 {
|
if len(strings.TrimSpace(description)) == 0 {
|
||||||
return RejectRequest(c, "Podcast description is empty")
|
return RejectRequest(c, "Podcast description is empty")
|
||||||
}
|
}
|
||||||
podcastImage, header, err := c.Req.FormFile("podcast_image")
|
|
||||||
imageFilename := ""
|
|
||||||
imageWidth := 0
|
|
||||||
imageHeight := 0
|
|
||||||
if err != nil && !errors.Is(err, http.ErrMissingFile) {
|
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read uploaded file"))
|
|
||||||
}
|
|
||||||
if header != nil {
|
|
||||||
if header.Size > maxFileSize {
|
|
||||||
return RejectRequest(c, fmt.Sprintf("Image filesize too big. Max size: %d bytes", maxFileSize))
|
|
||||||
} else {
|
|
||||||
c.Perf.StartBlock("PODCAST", "Decoding image")
|
|
||||||
config, format, err := image.DecodeConfig(podcastImage)
|
|
||||||
c.Perf.EndBlock()
|
|
||||||
if err != nil {
|
|
||||||
return RejectRequest(c, "Image type not supported")
|
|
||||||
}
|
|
||||||
imageWidth = config.Width
|
|
||||||
imageHeight = config.Height
|
|
||||||
if imageWidth == 0 || imageHeight == 0 {
|
|
||||||
return RejectRequest(c, "Image has zero size")
|
|
||||||
}
|
|
||||||
|
|
||||||
imageFilename = fmt.Sprintf("podcast/%s/logo%d.%s", c.CurrentProject.Slug, time.Now().UTC().Unix(), format)
|
|
||||||
storageFilename := fmt.Sprintf("public/media/%s", imageFilename)
|
|
||||||
c.Perf.StartBlock("PODCAST", "Writing image file")
|
|
||||||
file, err := os.Create(storageFilename)
|
|
||||||
if err != nil {
|
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to create local image file"))
|
|
||||||
}
|
|
||||||
podcastImage.Seek(0, io.SeekStart)
|
|
||||||
_, err = io.Copy(file, podcastImage)
|
|
||||||
if err != nil {
|
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to write image to file"))
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
podcastImage.Close()
|
|
||||||
c.Perf.EndBlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Perf.EndBlock()
|
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Updating podcast")
|
c.Perf.StartBlock("SQL", "Updating podcast")
|
||||||
tx, err := c.Conn.Begin(c.Context())
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
|
@ -190,23 +146,18 @@ func PodcastEditSubmit(c *RequestContext) ResponseData {
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
||||||
}
|
}
|
||||||
defer tx.Rollback(c.Context())
|
defer tx.Rollback(c.Context())
|
||||||
if imageFilename != "" {
|
|
||||||
hasher := sha1.New()
|
imageId, err := SaveImageFile(c, tx, "podcast_image", maxFileSize, fmt.Sprintf("podcast/%s/logo%d", c.CurrentProject.Slug, time.Now().UTC().Unix()))
|
||||||
podcastImage.Seek(0, io.SeekStart)
|
|
||||||
io.Copy(hasher, podcastImage) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs
|
|
||||||
sha1sum := hasher.Sum(nil)
|
|
||||||
var imageId int
|
|
||||||
err = tx.QueryRow(c.Context(),
|
|
||||||
`
|
|
||||||
INSERT INTO handmade_imagefile (file, size, sha1sum, protected, width, height)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING id
|
|
||||||
`,
|
|
||||||
imageFilename, header.Size, hex.EncodeToString(sha1sum), false, imageWidth, imageHeight,
|
|
||||||
).Scan(&imageId)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert image file row"))
|
var rejectErr RejectRequestError
|
||||||
|
if errors.As(err, &rejectErr) {
|
||||||
|
return RejectRequest(c, rejectErr.Error())
|
||||||
|
} else {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to save podcast image"))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if imageId != 0 {
|
||||||
_, err = tx.Exec(c.Context(),
|
_, err = tx.Exec(c.Context(),
|
||||||
`
|
`
|
||||||
UPDATE handmade_podcast
|
UPDATE handmade_podcast
|
||||||
|
@ -474,7 +425,7 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Perf.StartBlock("MARKDOWN", "Parsing description")
|
c.Perf.StartBlock("MARKDOWN", "Parsing description")
|
||||||
descriptionRendered := parsing.ParseMarkdown(description, parsing.RealMarkdown)
|
descriptionRendered := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
guidStr := ""
|
guidStr := ""
|
||||||
|
|
|
@ -104,7 +104,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
// CSRF mitigation actions per the OWASP cheat sheet:
|
// CSRF mitigation actions per the OWASP cheat sheet:
|
||||||
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
||||||
return func(c *RequestContext) ResponseData {
|
return func(c *RequestContext) ResponseData {
|
||||||
c.Req.ParseForm()
|
c.Req.ParseMultipartForm(100 * 1024 * 1024)
|
||||||
csrfToken := c.Req.Form.Get(auth.CSRFFieldName)
|
csrfToken := c.Req.Form.Get(auth.CSRFFieldName)
|
||||||
if csrfToken != c.CurrentSession.CSRFToken {
|
if csrfToken != c.CurrentSession.CSRFToken {
|
||||||
c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed CSRF validation - potential attack?")
|
c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed CSRF validation - potential attack?")
|
||||||
|
@ -228,9 +228,12 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
mainRoutes.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode)
|
mainRoutes.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode)
|
||||||
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
|
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
|
||||||
|
|
||||||
mainRoutes.GET(hmnurl.RegexDiscordTest, authMiddleware(DiscordTest)) // TODO: Delete this route
|
|
||||||
mainRoutes.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
mainRoutes.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
||||||
mainRoutes.POST(hmnurl.RegexDiscordUnlink, authMiddleware(DiscordUnlink))
|
mainRoutes.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
||||||
|
mainRoutes.POST(hmnurl.RegexDiscordShowcaseBacklog, authMiddleware(csrfMiddleware(DiscordShowcaseBacklog)))
|
||||||
|
|
||||||
|
mainRoutes.GET(hmnurl.RegexUserSettings, authMiddleware(UserSettings))
|
||||||
|
mainRoutes.POST(hmnurl.RegexUserSettings, authMiddleware(csrfMiddleware(UserSettingsSave)))
|
||||||
|
|
||||||
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
|
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
|
||||||
mainRoutes.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
|
mainRoutes.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
|
||||||
|
|
|
@ -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) {
|
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)
|
ip := net.ParseIP(ipString)
|
||||||
|
|
||||||
const previewMaxLength = 100
|
const previewMaxLength = 100
|
||||||
|
|
|
@ -2,14 +2,21 @@ package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/auth"
|
||||||
|
"git.handmade.network/hmn/hmn/src/config"
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
|
"git.handmade.network/hmn/hmn/src/discord"
|
||||||
|
hmnemail "git.handmade.network/hmn/hmn/src/email"
|
||||||
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
|
"github.com/jackc/pgx/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserProfileTemplateData struct {
|
type UserProfileTemplateData struct {
|
||||||
|
@ -215,11 +222,15 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
|
templateUser := templates.UserToTemplate(profileUser, c.Theme)
|
||||||
|
|
||||||
baseData := getBaseData(c)
|
baseData := getBaseData(c)
|
||||||
|
baseData.Title = templateUser.Name
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
|
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
ProfileUser: templates.UserToTemplate(profileUser, c.Theme),
|
ProfileUser: templateUser,
|
||||||
ProfileUserLinks: profileUserLinks,
|
ProfileUserLinks: profileUserLinks,
|
||||||
ProfileUserProjects: templateProjects,
|
ProfileUserProjects: templateProjects,
|
||||||
TimelineItems: timelineItems,
|
TimelineItems: timelineItems,
|
||||||
|
@ -229,3 +240,270 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UserSettings(c *RequestContext) ResponseData {
|
||||||
|
var res ResponseData
|
||||||
|
|
||||||
|
type UserSettingsTemplateData struct {
|
||||||
|
templates.BaseData
|
||||||
|
|
||||||
|
User templates.User
|
||||||
|
Email string // these fields are handled specially on templates.User
|
||||||
|
ShowEmail bool
|
||||||
|
LinksText string
|
||||||
|
|
||||||
|
SubmitUrl string
|
||||||
|
ContactUrl string
|
||||||
|
|
||||||
|
DiscordUser *templates.DiscordUser
|
||||||
|
DiscordNumUnsavedMessages int
|
||||||
|
DiscordAuthorizeUrl string
|
||||||
|
DiscordUnlinkUrl string
|
||||||
|
DiscordShowcaseBacklogUrl string
|
||||||
|
}
|
||||||
|
|
||||||
|
ilinks, err := db.Query(c.Context(), c.Conn, models.Link{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM handmade_links
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY ordering
|
||||||
|
`,
|
||||||
|
c.CurrentUser.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user links"))
|
||||||
|
}
|
||||||
|
links := ilinks.ToSlice()
|
||||||
|
|
||||||
|
linksText := ""
|
||||||
|
for _, ilink := range links {
|
||||||
|
link := ilink.(*models.Link)
|
||||||
|
linksText += fmt.Sprintf("%s %s\n", link.URL, link.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tduser *templates.DiscordUser
|
||||||
|
var numUnsavedMessages int
|
||||||
|
iduser, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM handmade_discorduser
|
||||||
|
WHERE hmn_user_id = $1
|
||||||
|
`,
|
||||||
|
c.CurrentUser.ID,
|
||||||
|
)
|
||||||
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
|
// this is fine, but don't fetch any more messages
|
||||||
|
} else if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user's Discord account"))
|
||||||
|
} else {
|
||||||
|
duser := iduser.(*models.DiscordUser)
|
||||||
|
tmp := templates.DiscordUserToTemplate(duser)
|
||||||
|
tduser = &tmp
|
||||||
|
|
||||||
|
numUnsavedMessages, err = db.QueryInt(c.Context(), c.Conn,
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM
|
||||||
|
handmade_discordmessage AS msg
|
||||||
|
LEFT JOIN handmade_discordmessagecontent AS c ON c.message_id = msg.id
|
||||||
|
WHERE
|
||||||
|
msg.user_id = $1
|
||||||
|
AND msg.channel_id = $2
|
||||||
|
AND c.last_content IS NULL
|
||||||
|
`,
|
||||||
|
duser.UserID,
|
||||||
|
config.Config.Discord.ShowcaseChannelID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check for unsaved user messages"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templateUser := templates.UserToTemplate(c.CurrentUser, c.Theme)
|
||||||
|
|
||||||
|
baseData := getBaseData(c)
|
||||||
|
baseData.Title = templateUser.Name
|
||||||
|
|
||||||
|
res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{
|
||||||
|
BaseData: baseData,
|
||||||
|
User: templateUser,
|
||||||
|
Email: c.CurrentUser.Email,
|
||||||
|
ShowEmail: c.CurrentUser.ShowEmail,
|
||||||
|
LinksText: linksText,
|
||||||
|
|
||||||
|
SubmitUrl: hmnurl.BuildUserSettings(""),
|
||||||
|
ContactUrl: hmnurl.BuildContactPage(),
|
||||||
|
|
||||||
|
DiscordUser: tduser,
|
||||||
|
DiscordNumUnsavedMessages: numUnsavedMessages,
|
||||||
|
DiscordAuthorizeUrl: discord.GetAuthorizeUrl(c.CurrentSession.CSRFToken),
|
||||||
|
DiscordUnlinkUrl: hmnurl.BuildDiscordUnlink(),
|
||||||
|
DiscordShowcaseBacklogUrl: hmnurl.BuildDiscordShowcaseBacklog(),
|
||||||
|
}, c.Perf)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserSettingsSave(c *RequestContext) ResponseData {
|
||||||
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(c.Context())
|
||||||
|
|
||||||
|
form, err := c.GetFormValues()
|
||||||
|
if err != nil {
|
||||||
|
c.Logger.Warn().Err(err).Msg("failed to parse form on user update")
|
||||||
|
return c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := form.Get("realname")
|
||||||
|
|
||||||
|
email := form.Get("email")
|
||||||
|
if !hmnemail.IsEmail(email) {
|
||||||
|
return RejectRequest(c, "Your email was not valid.")
|
||||||
|
}
|
||||||
|
|
||||||
|
showEmail := form.Get("showemail") != ""
|
||||||
|
darkTheme := form.Get("darktheme") != ""
|
||||||
|
|
||||||
|
blurb := form.Get("shortbio")
|
||||||
|
signature := form.Get("signature")
|
||||||
|
bio := form.Get("longbio")
|
||||||
|
|
||||||
|
discordShowcaseAuto := form.Get("discord-showcase-auto") != ""
|
||||||
|
discordDeleteSnippetOnMessageDelete := form.Get("discord-snippet-keep") == ""
|
||||||
|
|
||||||
|
_, err = tx.Exec(c.Context(),
|
||||||
|
`
|
||||||
|
UPDATE auth_user
|
||||||
|
SET
|
||||||
|
name = $2,
|
||||||
|
email = $3,
|
||||||
|
showemail = $4,
|
||||||
|
darktheme = $5,
|
||||||
|
blurb = $6,
|
||||||
|
signature = $7,
|
||||||
|
bio = $8,
|
||||||
|
discord_save_showcase = $9,
|
||||||
|
discord_delete_snippet_on_message_delete = $10
|
||||||
|
WHERE
|
||||||
|
id = $1
|
||||||
|
`,
|
||||||
|
c.CurrentUser.ID,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
showEmail,
|
||||||
|
darkTheme,
|
||||||
|
blurb,
|
||||||
|
signature,
|
||||||
|
bio,
|
||||||
|
discordShowcaseAuto,
|
||||||
|
discordDeleteSnippetOnMessageDelete,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process links
|
||||||
|
linksText := form.Get("links")
|
||||||
|
links := strings.Split(linksText, "\n")
|
||||||
|
_, err = tx.Exec(c.Context(), `DELETE FROM handmade_links WHERE user_id = $1`, c.CurrentUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.Logger.Warn().Err(err).Msg("failed to delete old links")
|
||||||
|
} else {
|
||||||
|
for i, link := range links {
|
||||||
|
link = strings.TrimSpace(link)
|
||||||
|
linkParts := strings.SplitN(link, " ", 2)
|
||||||
|
url := strings.TrimSpace(linkParts[0])
|
||||||
|
name := ""
|
||||||
|
if len(linkParts) > 1 {
|
||||||
|
name = strings.TrimSpace(linkParts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tx.Exec(c.Context(),
|
||||||
|
`
|
||||||
|
INSERT INTO handmade_links (name, url, ordering, user_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
i,
|
||||||
|
c.CurrentUser.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.Logger.Warn().Err(err).Msg("failed to insert new link")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
oldPassword := form.Get("old_password")
|
||||||
|
newPassword := form.Get("new_password1")
|
||||||
|
newPasswordConfirmation := form.Get("new_password2")
|
||||||
|
if oldPassword != "" && newPassword != "" {
|
||||||
|
errorRes := updatePassword(c, tx, oldPassword, newPassword, newPasswordConfirmation)
|
||||||
|
if errorRes != nil {
|
||||||
|
return *errorRes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update avatar
|
||||||
|
_, err = SaveImageFile(c, tx, "avatar", 1*1024*1024, fmt.Sprintf("members/avatars/%s", c.CurrentUser.Username))
|
||||||
|
if err != nil {
|
||||||
|
var rejectErr RejectRequestError
|
||||||
|
if errors.As(err, &rejectErr) {
|
||||||
|
return RejectRequest(c, rejectErr.Error())
|
||||||
|
} else {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save new avatar"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Success message
|
||||||
|
|
||||||
|
err = tx.Commit(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save user settings"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Rework this to use that RejectRequestError thing
|
||||||
|
func updatePassword(c *RequestContext, tx pgx.Tx, old, new, confirm string) *ResponseData {
|
||||||
|
if new != confirm {
|
||||||
|
res := RejectRequest(c, "Your password and password confirmation did not match.")
|
||||||
|
return &res
|
||||||
|
}
|
||||||
|
|
||||||
|
oldHashedPassword, err := auth.ParsePasswordString(c.CurrentUser.Password)
|
||||||
|
if err != nil {
|
||||||
|
c.Logger.Warn().Err(err).Msg("failed to parse user's password string")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := auth.CheckPassword(old, oldHashedPassword)
|
||||||
|
if err != nil {
|
||||||
|
res := ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check user's password"))
|
||||||
|
return &res
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
res := RejectRequest(c, "The old password you provided was not correct.")
|
||||||
|
return &res
|
||||||
|
}
|
||||||
|
|
||||||
|
newHashedPassword := auth.HashPassword(new)
|
||||||
|
err = auth.UpdatePassword(c.Context(), tx, c.CurrentUser.Username, newHashedPassword)
|
||||||
|
if err != nil {
|
||||||
|
res := ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update password"))
|
||||||
|
return &res
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -45,6 +45,7 @@ var WebsiteCommand = &cobra.Command{
|
||||||
auth.PeriodicallyDeleteInactiveUsers(backgroundJobContext, conn),
|
auth.PeriodicallyDeleteInactiveUsers(backgroundJobContext, conn),
|
||||||
perfCollector.Done,
|
perfCollector.Done,
|
||||||
discord.RunDiscordBot(backgroundJobContext, conn),
|
discord.RunDiscordBot(backgroundJobContext, conn),
|
||||||
|
discord.RunHistoryWatcher(backgroundJobContext, conn),
|
||||||
)
|
)
|
||||||
|
|
||||||
signals := make(chan os.Signal, 1)
|
signals := make(chan os.Signal, 1)
|
||||||
|
|
Loading…
Reference in New Issue